diff --git a/src/lib/seam-paginator.ts b/src/lib/seam-paginator.ts index 504a4f54..cf2526ce 100644 --- a/src/lib/seam-paginator.ts +++ b/src/lib/seam-paginator.ts @@ -142,10 +142,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 @@ -155,12 +152,7 @@ export class SeamPaginator< * Yields each item across all pages, fetching the next page as needed. */ async *flatten(): AsyncGenerator> { - 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 } @@ -173,13 +165,25 @@ 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 } } + + 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 fb623a74..60ba69a6 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 { type Device, SeamHttp, SeamPaginator } from '@seamapi/http/connect' @@ -95,6 +96,68 @@ test('SeamPaginator: flatten allows iteration over all devices', async (t) => { const expectType = (_value: Expected): void => {} +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 })