From 44294a1de4dc3709f8de0ca646a4e293453a88d1 Mon Sep 17 00:00:00 2001 From: pantera Date: Fri, 2 Oct 2026 23:04:45 -0700 Subject: [PATCH 1/2] fix: page backward in autoPagination after a before cursor --- src/common/utils/pagination.spec.ts | 76 +++++++++++++++++++++++++++++ src/common/utils/pagination.ts | 29 +++++++++-- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/common/utils/pagination.spec.ts b/src/common/utils/pagination.spec.ts index b7899b12f..bef6e50b5 100644 --- a/src/common/utils/pagination.spec.ts +++ b/src/common/utils/pagination.spec.ts @@ -37,6 +37,25 @@ describe('AutoPaginatable', () => { expect(mockApiCall).not.toHaveBeenCalled(); }); + it('returns initial data when limit and a before cursor are specified', async () => { + const initialData: List = { + object: 'list', + data: [{ id: 'u1' }, { id: 'u2' }], + listMetadata: { before: 'u1' }, + }; + + const paginatable = new AutoPaginatable( + initialData, + mockApiCall, + { limit: 2, before: 'u3' }, + ); + + const result = await paginatable.autoPagination(); + + expect(result).toEqual(initialData.data); + expect(mockApiCall).not.toHaveBeenCalled(); + }); + it('paginates through all pages', async () => { const initialData: List = { object: 'list', @@ -84,6 +103,63 @@ describe('AutoPaginatable', () => { limit: 100, after: 'cursor2', }); + for (const call of mockApiCall.mock.calls) { + expect(call[0]).not.toHaveProperty('before'); + } + }); + + it('paginates backward when a before cursor is specified', async () => { + const initialData: List = { + object: 'list', + data: [{ id: 'u3' }, { id: 'u4' }], + listMetadata: { before: 'u3', after: 'u5' }, + }; + + mockApiCall + .mockResolvedValueOnce({ + object: 'list', + data: [{ id: 'u3' }, { id: 'u4' }], + listMetadata: { before: 'u3', after: 'u5' }, + }) + .mockResolvedValueOnce({ + object: 'list', + data: [{ id: 'u1' }, { id: 'u2' }], + listMetadata: { before: null }, + }); + + const paginatable = new AutoPaginatable( + initialData, + mockApiCall, + { before: 'u5', order: 'desc' }, + ); + + const resultPromise = paginatable.autoPagination(); + + await jest.advanceTimersByTimeAsync(350); + + const result = await resultPromise; + + expect(result).toEqual([ + { id: 'u4' }, + { id: 'u3' }, + { id: 'u2' }, + { id: 'u1' }, + ]); + + expect(mockApiCall).toHaveBeenCalledTimes(2); + expect(mockApiCall).toHaveBeenNthCalledWith(1, { + limit: 100, + before: 'u5', + order: 'desc', + }); + expect(mockApiCall).toHaveBeenNthCalledWith(2, { + limit: 100, + before: 'u3', + order: 'desc', + }); + for (const call of mockApiCall.mock.calls) { + expect(call[0]).not.toHaveProperty('after'); + } }); it('respects rate limiting between requests', async () => { diff --git a/src/common/utils/pagination.ts b/src/common/utils/pagination.ts index ba725c6e7..7ac2e726c 100644 --- a/src/common/utils/pagination.ts +++ b/src/common/utils/pagination.ts @@ -24,21 +24,39 @@ export class AutoPaginatable< return this.list.listMetadata; } + private get direction(): 'forward' | 'backward' { + return this.options.before && !this.options.after ? 'backward' : 'forward'; + } + private async *generatePages( params: PaginationOptions, ): AsyncGenerator { + const { before, after, ...options } = this.options; const result = await this.apiCall({ - ...this.options, + ...options, limit: 100, - after: params.after, + ...(this.direction === 'backward' + ? { before: params.before } + : { after: params.after }), }); - yield result.data; + yield this.direction === 'backward' + ? [...result.data].reverse() + : result.data; + + const nextCursor = + this.direction === 'backward' + ? result.listMetadata.before + : result.listMetadata.after; - if (result.listMetadata.after) { + if (nextCursor) { // Delay of 4rps to respect list users rate limits await new Promise((resolve) => setTimeout(resolve, 350)); - yield* this.generatePages({ after: result.listMetadata.after }); + yield* this.generatePages( + this.direction === 'backward' + ? { before: nextCursor } + : { after: nextCursor }, + ); } } @@ -54,6 +72,7 @@ export class AutoPaginatable< const results: ResourceType[] = []; for await (const page of this.generatePages({ + before: this.options.before, after: this.options.after, })) { results.push(...page); From 8fa40731b18c784ec340b6ec12bc74898681d1c0 Mon Sep 17 00:00:00 2001 From: pantera Date: Thu, 8 Oct 2026 12:03:48 -0700 Subject: [PATCH 2/2] fix: keep autoPagination forward-only after before cursor --- src/common/utils/pagination.spec.ts | 48 +++++++++++++++++------------ src/common/utils/pagination.ts | 28 ++++------------- 2 files changed, 34 insertions(+), 42 deletions(-) diff --git a/src/common/utils/pagination.spec.ts b/src/common/utils/pagination.spec.ts index bef6e50b5..128432b5c 100644 --- a/src/common/utils/pagination.spec.ts +++ b/src/common/utils/pagination.spec.ts @@ -108,57 +108,65 @@ describe('AutoPaginatable', () => { } }); - it('paginates backward when a before cursor is specified', async () => { + it('paginates forward from a before cursor, dropping before on follow-up requests', async () => { const initialData: List = { object: 'list', - data: [{ id: 'u3' }, { id: 'u4' }], - listMetadata: { before: 'u3', after: 'u5' }, + data: [{ id: 'u11' }, { id: 'u12' }], + listMetadata: { after: 'u12' }, }; mockApiCall .mockResolvedValueOnce({ object: 'list', - data: [{ id: 'u3' }, { id: 'u4' }], - listMetadata: { before: 'u3', after: 'u5' }, + data: [{ id: 'u11' }, { id: 'u12' }], + listMetadata: { before: 'u11', after: 'u12' }, }) .mockResolvedValueOnce({ object: 'list', - data: [{ id: 'u1' }, { id: 'u2' }], - listMetadata: { before: null }, + data: [{ id: 'u13' }, { id: 'u14' }], + listMetadata: { before: 'u13', after: 'u14' }, + }) + .mockResolvedValueOnce({ + object: 'list', + data: [{ id: 'u15' }], + listMetadata: { after: null }, }); const paginatable = new AutoPaginatable( initialData, mockApiCall, - { before: 'u5', order: 'desc' }, + { before: 'u21' }, ); const resultPromise = paginatable.autoPagination(); - await jest.advanceTimersByTimeAsync(350); + await jest.advanceTimersByTimeAsync(700); const result = await resultPromise; expect(result).toEqual([ - { id: 'u4' }, - { id: 'u3' }, - { id: 'u2' }, - { id: 'u1' }, + { id: 'u11' }, + { id: 'u12' }, + { id: 'u13' }, + { id: 'u14' }, + { id: 'u15' }, ]); - expect(mockApiCall).toHaveBeenCalledTimes(2); + expect(mockApiCall).toHaveBeenCalledTimes(3); expect(mockApiCall).toHaveBeenNthCalledWith(1, { limit: 100, - before: 'u5', - order: 'desc', + before: 'u21', }); expect(mockApiCall).toHaveBeenNthCalledWith(2, { limit: 100, - before: 'u3', - order: 'desc', + after: 'u12', }); - for (const call of mockApiCall.mock.calls) { - expect(call[0]).not.toHaveProperty('after'); + expect(mockApiCall).toHaveBeenNthCalledWith(3, { + limit: 100, + after: 'u14', + }); + for (const call of mockApiCall.mock.calls.slice(1)) { + expect(call[0]).not.toHaveProperty('before'); } }); diff --git a/src/common/utils/pagination.ts b/src/common/utils/pagination.ts index 7ac2e726c..dbb21c22b 100644 --- a/src/common/utils/pagination.ts +++ b/src/common/utils/pagination.ts @@ -24,39 +24,23 @@ export class AutoPaginatable< return this.list.listMetadata; } - private get direction(): 'forward' | 'backward' { - return this.options.before && !this.options.after ? 'backward' : 'forward'; - } - private async *generatePages( params: PaginationOptions, ): AsyncGenerator { - const { before, after, ...options } = this.options; + const { before, ...options } = this.options; const result = await this.apiCall({ ...options, limit: 100, - ...(this.direction === 'backward' - ? { before: params.before } - : { after: params.after }), + ...(params.before ? { before } : {}), + after: params.after, }); - yield this.direction === 'backward' - ? [...result.data].reverse() - : result.data; - - const nextCursor = - this.direction === 'backward' - ? result.listMetadata.before - : result.listMetadata.after; + yield result.data; - if (nextCursor) { + if (result.listMetadata.after) { // Delay of 4rps to respect list users rate limits await new Promise((resolve) => setTimeout(resolve, 350)); - yield* this.generatePages( - this.direction === 'backward' - ? { before: nextCursor } - : { after: nextCursor }, - ); + yield* this.generatePages({ after: result.listMetadata.after }); } }