Skip to content

Commit 1dd8165

Browse files
fix(cli): reject pagination cursor cycles
1 parent 8f1f22c commit 1dd8165

5 files changed

Lines changed: 57 additions & 2 deletions

File tree

‎apps/docs/content/docs/cli/output.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Paginated lists include the rows under `data` and the continuation cursor under
3131
`nextCursor`. A `null` cursor means no pages remain:
3232

3333
```json
34-
{ "data": [{ "runId": "run_1" }], "nextCursor": null }
34+
{ "data": [{ "runId": "9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543" }], "nextCursor": null }
3535
```
3636

3737
Read list rows with `jq '.data[]'` and check for more pages with `jq '.nextCursor'`.

‎packages/sim-cli/src/http/client.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ describe('cursor pagination', () => {
8181
})
8282
})
8383

84+
it.each([{ cursors: ['c1', 'c1'] }, { cursors: ['c1', 'c2', 'c1'] }])(
85+
'rejects cursor cycles $cursors before making another request',
86+
async ({ cursors }) => {
87+
const request = vi.fn().mockRejectedValue(new Error('Pagination did not stop at the cycle'))
88+
for (const nextCursor of cursors) {
89+
request.mockResolvedValueOnce({ data: ['item'], nextCursor })
90+
}
91+
92+
await expect(
93+
requestAllPages<string>({ request } as Pick<SimClient, 'request'>, '/api/v2/items', {
94+
pageSize: 100,
95+
})
96+
).rejects.toThrow('repeated pagination cursor')
97+
expect(request).toHaveBeenCalledTimes(cursors.length)
98+
}
99+
)
100+
84101
it('reports progress on stderr once a second page is coming, then clears the line', async () => {
85102
const request = vi
86103
.fn()

‎packages/sim-cli/src/http/client.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,15 @@ export function pageProgress(): PageProgress {
763763
}
764764
}
765765

766+
/** Rejects cursor cycles before a pager repeats requests or returns an unusable continuation. */
767+
export function assertCursorAdvances(cursor: string | null, seenCursors: Set<string>): void {
768+
if (cursor === null) return
769+
if (seenCursors.has(cursor)) {
770+
throw new SimApiError('The API returned a repeated pagination cursor; cannot continue.', 0)
771+
}
772+
seenCursors.add(cursor)
773+
}
774+
766775
/** Follows a standard v2 cursor envelope without duplicating pagination loops. */
767776
export async function requestAllPages<T>(
768777
client: Pick<SimClient, 'request'>,
@@ -774,6 +783,7 @@ export async function requestAllPages<T>(
774783
if (limit <= 0) return []
775784

776785
const items: T[] = []
786+
const seenCursors = new Set<string>()
777787
const progress = pageProgress()
778788
let cursor: string | null = null
779789
// `finally`, because a page that throws part-way through would otherwise skip
@@ -789,6 +799,7 @@ export async function requestAllPages<T>(
789799
cursor,
790800
},
791801
})
802+
assertCursorAdvances(page.nextCursor, seenCursors)
792803
items.push(...page.data)
793804
cursor = page.nextCursor
794805

‎packages/sim-cli/src/runtime/build.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,31 @@ describe('contract-selected list rendering', () => {
13241324
})
13251325

13261326
describe('pagination slot', () => {
1327+
it.each([
1328+
{ argv: ['files', 'list'], cursors: ['c1', 'c1'] },
1329+
{ argv: ['files', 'list'], cursors: ['c1', 'c2', 'c1'] },
1330+
{ argv: ['tables', 'rows', 'query', 'tbl_1', '--limit', '0'], cursors: ['c1', 'c1'] },
1331+
{ argv: ['logs', 'list', '--cursor', 'c1'], cursors: ['c1'] },
1332+
{ argv: ['tables', 'rows', 'query', 'tbl_1', '--cursor', 'c1'], cursors: ['c1'] },
1333+
])(
1334+
'rejects cursor cycles in $argv without printing partial results',
1335+
async ({ argv, cursors }) => {
1336+
mockRequest.mockReset()
1337+
mockRequest.mockRejectedValue(new Error('Pagination did not stop at the cycle'))
1338+
for (const nextCursor of cursors) {
1339+
mockRequest.mockResolvedValueOnce({ data: [{ id: 'r1' }], nextCursor })
1340+
}
1341+
const printed = vi.spyOn(console, 'log').mockImplementation(() => {})
1342+
printed.mockClear()
1343+
1344+
await expect(program().parseAsync(['node', 'sim', ...argv])).rejects.toThrow(
1345+
'repeated pagination cursor'
1346+
)
1347+
expect(mockRequest).toHaveBeenCalledTimes(cursors.length)
1348+
expect(printed).not.toHaveBeenCalled()
1349+
}
1350+
)
1351+
13271352
it.each([
13281353
['files', 'list'],
13291354
['tables', 'list'],

‎packages/sim-cli/src/runtime/execute.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Command } from 'commander'
22
import { clientFrom } from '../context'
33
import type { CommandSpec } from '../contract/types'
44
import type { V2OperationName } from '../generated/v2-api'
5-
import { pageProgress, SimApiError, type V2Page } from '../http/client'
5+
import { assertCursorAdvances, pageProgress, SimApiError, type V2Page } from '../http/client'
66
import { safeOneLine } from '../output/render'
77
import { camel } from './derive'
88
import { DEFAULT_PAGE_SIZE, defaultListLimit } from './options'
@@ -402,6 +402,7 @@ export async function executeOperation(
402402
}
403403
const limit = pagedLimit === 0 ? Number.POSITIVE_INFINITY : pagedLimit
404404
const rows: unknown[] = []
405+
const seenCursors = new Set<string>(initialCursor ? [initialCursor] : [])
405406
const progress = pageProgress()
406407
let cursor: string | null = initialCursor ?? null
407408
/** The first page's envelope: where a fact about the whole query is stated. */
@@ -428,6 +429,7 @@ export async function executeOperation(
428429
0
429430
)
430431
}
432+
assertCursorAdvances(page.nextCursor, seenCursors)
431433
envelope = foldPageEnvelope(envelope, page)
432434
rows.push(...page.data)
433435
cursor = page.nextCursor

0 commit comments

Comments
 (0)