Skip to content

Commit 5b1d40e

Browse files
committed
Add automatic pagination support to list and history commands
1 parent 9dee531 commit 5b1d40e

34 files changed

Lines changed: 1059 additions & 368 deletions

File tree

.claude/skills/ably-new-command/references/patterns.md

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ For single-shot publish, REST is preferred (simpler, no connection overhead). Se
138138
## History Pattern
139139

140140
```typescript
141+
import { collectPaginatedResults } from "../../utils/pagination.js";
142+
141143
async run(): Promise<void> {
142144
const { args, flags } = await this.parse(MyHistoryCommand);
143145

@@ -155,13 +157,22 @@ async run(): Promise<void> {
155157
};
156158

157159
const history = await channel.history(historyParams);
158-
const messages = history.items;
160+
const { items: messages, hasMore, pagesConsumed } = await collectPaginatedResults(history, flags.limit);
161+
162+
if (pagesConsumed > 1 && !this.shouldOutputJson(flags)) {
163+
this.log(formatWarning(`Fetched ${pagesConsumed} pages to retrieve ${messages.length} results. Each page incurs additional API requests.`));
164+
}
159165

160166
if (this.shouldOutputJson(flags)) {
161-
this.logJsonResult({ messages }, flags);
167+
this.logJsonResult({ messages, hasMore }, flags);
162168
} else {
163169
this.log(formatSuccess(`Found ${messages.length} messages.`));
164170
// Display each message
171+
172+
if (hasMore) {
173+
const warning = formatLimitWarning(messages.length, flags.limit, "messages");
174+
if (warning) this.log(warning);
175+
}
165176
}
166177
} catch (error) {
167178
this.fail(error, flags, "history", { channel: args.channel });
@@ -334,11 +345,58 @@ async run(): Promise<void> {
334345
}
335346
```
336347
348+
**Product API list with pagination** (e.g., `push devices list`, `channels list`) — use `collectPaginatedResults` or `collectHttpPaginatedResults`:
349+
```typescript
350+
import { collectPaginatedResults, collectHttpPaginatedResults } from "../../utils/pagination.js";
351+
352+
async run(): Promise<void> {
353+
const { flags } = await this.parse(MyListCommand);
354+
355+
try {
356+
const rest = await this.createAblyRestClient(flags);
357+
if (!rest) return;
358+
359+
// For SDK methods that return PaginatedResult:
360+
const firstPage = await rest.someResource.list({ limit: flags.limit });
361+
const { items, hasMore, pagesConsumed } = await collectPaginatedResults(firstPage, flags.limit);
362+
363+
// For rest.request() that returns HttpPaginatedResponse:
364+
// const firstPage = await rest.request("get", "/some/endpoint", 2, { limit: String(flags.limit) });
365+
// const { items, hasMore, pagesConsumed } = await collectHttpPaginatedResults(firstPage, flags.limit);
366+
367+
if (pagesConsumed > 1 && !this.shouldOutputJson(flags)) {
368+
this.log(formatWarning(`Fetched ${pagesConsumed} pages to retrieve ${items.length} results. Each page incurs additional API requests.`));
369+
}
370+
371+
if (this.shouldOutputJson(flags)) {
372+
this.logJsonResult({ items, hasMore }, flags);
373+
} else {
374+
this.log(`Found ${items.length} items:\n`);
375+
for (const item of items) {
376+
this.log(formatHeading(`Item ID: ${item.id}`));
377+
this.log(` ${formatLabel("Type")} ${item.type}`);
378+
this.log("");
379+
}
380+
381+
if (hasMore) {
382+
const warning = formatLimitWarning(items.length, flags.limit, "items");
383+
if (warning) this.log(warning);
384+
}
385+
}
386+
} catch (error) {
387+
this.fail(error, flags, "listItems");
388+
}
389+
}
390+
```
391+
337392
Key conventions for list output:
338393
- `formatResource()` is for inline resource name references, not for record headings
339394
- `formatHeading()` is for record heading lines that act as visual separators between multi-field records
340395
- `formatLabel(text)` for field labels in detail lines (automatically appends `:`)
341396
- `formatSuccess()` is not used in list commands — it's for confirming an action completed
397+
- `formatLimitWarning()` should only be shown when `hasMore` is true — it means there are more results beyond the limit
398+
- Always include `hasMore` in JSON output for paginated commands so consumers know if results are truncated
399+
- Use `collectPaginatedResults()` for SDK paginated results, `collectHttpPaginatedResults()` for `rest.request()` results, and `collectFilteredPaginatedResults()` when a client-side filter is applied across pages
342400
343401
---
344402

README.md

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -536,12 +536,13 @@ List all apps in the current account
536536

537537
```
538538
USAGE
539-
$ ably apps list [-v] [--json | --pretty-json]
539+
$ ably apps list [-v] [--json | --pretty-json] [--limit <value>]
540540
541541
FLAGS
542-
-v, --verbose Output verbose logs
543-
--json Output in JSON format
544-
--pretty-json Output in colorized JSON format
542+
-v, --verbose Output verbose logs
543+
--json Output in JSON format
544+
--limit=<value> [default: 100] Maximum number of results to return (default: 100)
545+
--pretty-json Output in colorized JSON format
545546
546547
DESCRIPTION
547548
List all apps in the current account
@@ -669,13 +670,14 @@ List channel rules for an app
669670

670671
```
671672
USAGE
672-
$ ably apps rules list [-v] [--json | --pretty-json] [--app <value>]
673+
$ ably apps rules list [-v] [--json | --pretty-json] [--app <value>] [--limit <value>]
673674
674675
FLAGS
675-
-v, --verbose Output verbose logs
676-
--app=<value> The app ID or name (defaults to current app)
677-
--json Output in JSON format
678-
--pretty-json Output in colorized JSON format
676+
-v, --verbose Output verbose logs
677+
--app=<value> The app ID or name (defaults to current app)
678+
--json Output in JSON format
679+
--limit=<value> [default: 100] Maximum number of results to return (default: 100)
680+
--pretty-json Output in colorized JSON format
679681
680682
DESCRIPTION
681683
List channel rules for an app
@@ -1056,13 +1058,14 @@ List all keys in the app
10561058

10571059
```
10581060
USAGE
1059-
$ ably auth keys list [-v] [--json | --pretty-json] [--app <value>]
1061+
$ ably auth keys list [-v] [--json | --pretty-json] [--app <value>] [--limit <value>]
10601062
10611063
FLAGS
1062-
-v, --verbose Output verbose logs
1063-
--app=<value> The app ID (defaults to current app)
1064-
--json Output in JSON format
1065-
--pretty-json Output in colorized JSON format
1064+
-v, --verbose Output verbose logs
1065+
--app=<value> The app ID (defaults to current app)
1066+
--json Output in JSON format
1067+
--limit=<value> [default: 100] Maximum number of results to return (default: 100)
1068+
--pretty-json Output in colorized JSON format
10661069
10671070
DESCRIPTION
10681071
List all keys in the app
@@ -2227,13 +2230,14 @@ List all integrations
22272230

22282231
```
22292232
USAGE
2230-
$ ably integrations list [-v] [--json | --pretty-json] [--app <value>]
2233+
$ ably integrations list [-v] [--json | --pretty-json] [--app <value>] [--limit <value>]
22312234
22322235
FLAGS
2233-
-v, --verbose Output verbose logs
2234-
--app=<value> The app ID or name (defaults to current app)
2235-
--json Output in JSON format
2236-
--pretty-json Output in colorized JSON format
2236+
-v, --verbose Output verbose logs
2237+
--app=<value> The app ID or name (defaults to current app)
2238+
--json Output in JSON format
2239+
--limit=<value> [default: 100] Maximum number of results to return (default: 100)
2240+
--pretty-json Output in colorized JSON format
22372241
22382242
DESCRIPTION
22392243
List all integrations
@@ -3390,13 +3394,14 @@ List all queues
33903394

33913395
```
33923396
USAGE
3393-
$ ably queues list [-v] [--json | --pretty-json] [--app <value>]
3397+
$ ably queues list [-v] [--json | --pretty-json] [--app <value>] [--limit <value>]
33943398
33953399
FLAGS
3396-
-v, --verbose Output verbose logs
3397-
--app=<value> The app ID or name (defaults to current app)
3398-
--json Output in JSON format
3399-
--pretty-json Output in colorized JSON format
3400+
-v, --verbose Output verbose logs
3401+
--app=<value> The app ID or name (defaults to current app)
3402+
--json Output in JSON format
3403+
--limit=<value> [default: 100] Maximum number of results to return (default: 100)
3404+
--pretty-json Output in colorized JSON format
34003405
34013406
DESCRIPTION
34023407
List all queues

src/commands/apps/list.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { Flags } from "@oclif/core";
12
import chalk from "chalk";
23

34
import { ControlBaseCommand } from "../../control-base-command.js";
5+
import { formatLimitWarning } from "../../utils/output.js";
46

57
export default class AppsList extends ControlBaseCommand {
68
static override description = "List all apps in the current account";
@@ -13,6 +15,10 @@ export default class AppsList extends ControlBaseCommand {
1315

1416
static override flags = {
1517
...ControlBaseCommand.globalFlags,
18+
limit: Flags.integer({
19+
default: 100,
20+
description: "Maximum number of results to return (default: 100)",
21+
}),
1622
};
1723

1824
async run(): Promise<void> {
@@ -21,7 +27,9 @@ export default class AppsList extends ControlBaseCommand {
2127
await this.runControlCommand(
2228
flags,
2329
async (controlApi) => {
24-
const apps = await controlApi.listApps();
30+
const allApps = await controlApi.listApps();
31+
const hasMore = allApps.length > flags.limit;
32+
const apps = allApps.slice(0, flags.limit);
2533

2634
// Get current app ID from config
2735
const currentAppId = this.configManager.getCurrentAppId();
@@ -33,7 +41,7 @@ export default class AppsList extends ControlBaseCommand {
3341
isCurrent: app.id === currentAppId,
3442
}));
3543

36-
this.logJsonResult({ apps: appsWithCurrentFlag }, flags);
44+
this.logJsonResult({ apps: appsWithCurrentFlag, hasMore }, flags);
3745
return;
3846
}
3947

@@ -77,6 +85,11 @@ export default class AppsList extends ControlBaseCommand {
7785

7886
this.log(""); // Add a blank line between apps
7987
}
88+
89+
if (hasMore) {
90+
const warning = formatLimitWarning(apps.length, flags.limit, "apps");
91+
if (warning) this.log(warning);
92+
}
8093
},
8194
"Error listing apps",
8295
);

src/commands/apps/rules/list.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import type { Namespace } from "../../../services/control-api.js";
33

44
import { ControlBaseCommand } from "../../../control-base-command.js";
55
import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js";
6-
import { formatCountLabel, formatHeading } from "../../../utils/output.js";
6+
import {
7+
formatCountLabel,
8+
formatHeading,
9+
formatLimitWarning,
10+
} from "../../../utils/output.js";
711

812
interface ChannelRuleOutput {
913
authenticated: boolean;
@@ -40,6 +44,10 @@ export default class RulesListCommand extends ControlBaseCommand {
4044
description: "The app ID or name (defaults to current app)",
4145
required: false,
4246
}),
47+
limit: Flags.integer({
48+
default: 100,
49+
description: "Maximum number of results to return (default: 100)",
50+
}),
4351
};
4452

4553
async run(): Promise<void> {
@@ -48,12 +56,15 @@ export default class RulesListCommand extends ControlBaseCommand {
4856

4957
try {
5058
const controlApi = this.createControlApi(flags);
51-
const namespaces = await controlApi.listNamespaces(appId);
59+
const allNamespaces = await controlApi.listNamespaces(appId);
60+
const hasMore = allNamespaces.length > flags.limit;
61+
const namespaces = allNamespaces.slice(0, flags.limit);
5262

5363
if (this.shouldOutputJson(flags)) {
5464
this.logJsonResult(
5565
{
5666
appId,
67+
hasMore,
5768
rules: namespaces.map(
5869
(rule: Namespace): ChannelRuleOutput => ({
5970
authenticated: rule.authenticated || false,
@@ -102,6 +113,15 @@ export default class RulesListCommand extends ControlBaseCommand {
102113

103114
this.log(""); // Add a blank line between rules
104115
});
116+
117+
if (hasMore) {
118+
const warning = formatLimitWarning(
119+
namespaces.length,
120+
flags.limit,
121+
"channel rules",
122+
);
123+
if (warning) this.log(warning);
124+
}
105125
}
106126
} catch (error) {
107127
this.fail(error, flags, "ruleList", { appId });

src/commands/auth/keys/list.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import chalk from "chalk";
33

44
import { ControlBaseCommand } from "../../../control-base-command.js";
55
import { formatCapabilities } from "../../../utils/key-display.js";
6+
import { formatLimitWarning } from "../../../utils/output.js";
67

78
export default class KeysListCommand extends ControlBaseCommand {
89
static description = "List all keys in the app";
@@ -20,6 +21,10 @@ export default class KeysListCommand extends ControlBaseCommand {
2021
description: "The app ID (defaults to current app)",
2122
env: "ABLY_APP_ID",
2223
}),
24+
limit: Flags.integer({
25+
default: 100,
26+
description: "Maximum number of results to return (default: 100)",
27+
}),
2328
};
2429

2530
async run(): Promise<void> {
@@ -41,7 +46,9 @@ export default class KeysListCommand extends ControlBaseCommand {
4146

4247
try {
4348
const controlApi = this.createControlApi(flags);
44-
const keys = await controlApi.listKeys(appId);
49+
const allKeys = await controlApi.listKeys(appId);
50+
const hasMore = allKeys.length > flags.limit;
51+
const keys = allKeys.slice(0, flags.limit);
4552

4653
// Get the current key name for highlighting (app_id.key_Id)
4754
const currentKeyId = this.configManager.getKeyId(appId);
@@ -65,6 +72,7 @@ export default class KeysListCommand extends ControlBaseCommand {
6572
this.logJsonResult(
6673
{
6774
appId,
75+
hasMore,
6876
keys: keysWithCurrent,
6977
},
7078
flags,
@@ -99,6 +107,11 @@ export default class KeysListCommand extends ControlBaseCommand {
99107

100108
this.log("");
101109
}
110+
111+
if (hasMore) {
112+
const warning = formatLimitWarning(keys.length, flags.limit, "keys");
113+
if (warning) this.log(warning);
114+
}
102115
}
103116
} catch (error) {
104117
this.fail(error, flags, "keyList", { appId });

0 commit comments

Comments
 (0)