From c8addedee8b0be01432b987a92b3b0204f2061cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:25:19 +0000 Subject: [PATCH 1/2] fix: Stop the paginator when a page cursor repeats Nothing halted pagination against a server that pins one cursor: the per-request timeout resets every page while flattenToArray grows without bound. A next page reported with a null cursor threw a plain error from deep inside iteration instead of ending it. Route flatten, flattenToArray, and page iteration through one private walk generator that tracks seen cursors and stops silently when the cursor is null or repeats. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- src/lib/seam-paginator.ts | 38 ++++++++------ test/seam/connect/seam-paginator.test.ts | 63 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/lib/seam-paginator.ts b/src/lib/seam-paginator.ts index 0741f46f..d35791d8 100644 --- a/src/lib/seam-paginator.ts +++ b/src/lib/seam-paginator.ts @@ -130,10 +130,7 @@ export class SeamPaginator< EnsureReadonlyArray > { const items = [] as EnsureMutableArray - let [current, pagination] = await this.firstPage() - items.push(...current) - while (pagination.hasNextPage) { - ;[current, pagination] = await this.nextPage(pagination.nextPageCursor) + for await (const [current] of this.#walk()) { items.push(...current) } return items as EnsureReadonlyArray @@ -145,12 +142,7 @@ export class SeamPaginator< async *flatten(): AsyncGenerator< EnsureReadonlyArray > { - let [current, pagination] = await this.firstPage() - for (const item of current) { - yield item - } - while (pagination.hasNextPage) { - ;[current, pagination] = await this.nextPage(pagination.nextPageCursor) + for await (const [current] of this.#walk()) { for (const item of current) { yield item } @@ -163,13 +155,31 @@ export class SeamPaginator< async *[Symbol.asyncIterator](): AsyncGenerator< EnsureReadonlyArray > { - let [current, pagination] = await this.firstPage() - yield current - while (pagination.hasNextPage) { - ;[current, pagination] = await this.nextPage(pagination.nextPageCursor) + for await (const [current] of this.#walk()) { yield current } } + + /** + * Yields each page along with its pagination state. + * Iteration stops when there is no next page, + * or when the next page cursor is null or repeats a previous cursor, + * so a server pinning one cursor cannot cause an infinite request loop. + */ + async *#walk(): AsyncGenerator< + [EnsureReadonlyArray, Pagination] + > { + const seenCursors = new Set() + let page = await this.firstPage() + yield page + while (page[1].hasNextPage) { + const cursor = page[1].nextPageCursor + if (cursor == null || seenCursors.has(cursor)) return + seenCursors.add(cursor) + page = await this.nextPage(cursor) + yield page + } + } } type EnsureReadonlyArray = T extends readonly any[] ? T : never diff --git a/test/seam/connect/seam-paginator.test.ts b/test/seam/connect/seam-paginator.test.ts index 2fca098c..66e7d6bc 100644 --- a/test/seam/connect/seam-paginator.test.ts +++ b/test/seam/connect/seam-paginator.test.ts @@ -1,5 +1,6 @@ import test from 'ava' import { getTestServer } from 'fixtures/seam/connect/api.js' +import nock from 'nock' import { SeamHttp, SeamPaginator } from '@seamapi/http/connect' @@ -88,6 +89,68 @@ test('SeamPaginator: flatten allows iteration over all devices', async (t) => { t.is(devices.length, allDevices.length) }) +test('SeamPaginator: stops iterating when the page cursor repeats', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint) + .get('/devices/list') + .query({ limit: '1', _strict: 'true' }) + .reply(200, { + devices: [{ device_id: 'device-1' }], + pagination: { + has_next_page: true, + next_page_cursor: 'repeated-cursor', + next_page_url: null, + }, + }) + .get('/devices/list') + .query({ limit: '1', page_cursor: 'repeated-cursor', _strict: 'true' }) + .reply(200, { + devices: [{ device_id: 'device-2' }], + pagination: { + has_next_page: true, + next_page_cursor: 'repeated-cursor', + next_page_url: null, + }, + }) + + const pages = seam.createPaginator(seam.devices.list({ limit: 1 })) + const devices = await pages.flattenToArray() + + t.deepEqual( + devices.map(({ device_id: deviceId }) => deviceId), + ['device-1', 'device-2'], + ) +}) + +test('SeamPaginator: stops iterating when there is a next page without a cursor', async (t) => { + const { seed, endpoint } = await getTestServer(t) + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint) + .get('/devices/list') + .query({ limit: '1', _strict: 'true' }) + .reply(200, { + devices: [{ device_id: 'device-1' }], + pagination: { + has_next_page: true, + next_page_cursor: null, + next_page_url: null, + }, + }) + + const pages = seam.createPaginator(seam.devices.list({ limit: 1 })) + + const seenPages = [] + for await (const page of pages) { + seenPages.push(page) + } + + t.is(seenPages.length, 1) + t.is(seenPages[0]?.[0]?.device_id, 'device-1') +}) + test('SeamPaginator: instance allows iteration over all pages', async (t) => { const { seed, endpoint } = await getTestServer(t) const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) From bd5aa38d8a158b32ae4f9776c22b0c1910a58dca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 23:28:50 +0000 Subject: [PATCH 2/2] docs: Drop the walk generator comment Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- src/lib/seam-paginator.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/lib/seam-paginator.ts b/src/lib/seam-paginator.ts index 60fd3042..cf2526ce 100644 --- a/src/lib/seam-paginator.ts +++ b/src/lib/seam-paginator.ts @@ -170,12 +170,6 @@ export class SeamPaginator< } } - /** - * Yields each page along with its pagination state. - * Iteration stops when there is no next page, - * or when the next page cursor is null or repeats a previous cursor, - * so a server pinning one cursor cannot cause an infinite request loop. - */ async *#walk(): AsyncGenerator< [EnsureReadonlyArray, Pagination] > {