Skip to content

Commit 75ee2e7

Browse files
authored
fix(pagination): drive paginate by cursor metadata instead of re-fetching the first page (#561)
paginate() rebuilt a fresh { limit } query on every loop iteration and never read cursor metadata, so any page containing exactly limit items was re-requested forever, yielding duplicate items. The sibling helpers parseCursorPage/buildPaginationQuery were never used by paginate. - paginate() now accepts a fetch that returns CursorPage<T> and feeds nextCursor back through buildPaginationQuery for the next request - stops when the cursor is null/empty, a page is shorter than limit, or maxPages is reached (hasMore=false is also honored) - rewrote the paginate test block: cursor advancement, no-duplicate guarantee for a full page, maxPages, empty page, short page, and hasMore=false stop conditions (16 tests, all passing) Co-authored-by: foxxx009 <foxxx009@users.noreply.github.com>
1 parent 313cf66 commit 75ee2e7

2 files changed

Lines changed: 96 additions & 31 deletions

File tree

src/pagination.ts

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,40 +38,43 @@ export function buildPaginationQuery(
3838
/**
3939
* Async iterator helper that auto-paginates through a cursor-based list endpoint.
4040
*
41+
* `fetchPage` receives a `PaginationQuery` (containing the `cursor` from the
42+
* previous response when present) and must return a `CursorPage<T>` describing
43+
* the fetched page: its items, the cursor for the next page, and whether more
44+
* pages exist. Iteration stops when the returned cursor is null/empty, a page
45+
* is shorter than `limit` (when `limit` is set), or `maxPages` is reached.
46+
*
4147
* @example
4248
* for await (const agent of paginate(client.agents.list.bind(client.agents))) {
4349
* console.log(agent.id);
4450
* }
4551
*/
4652
export async function* paginate<T>(
47-
fetchPage: (query?: PaginationQuery) => Promise<readonly T[]>,
53+
fetchPage: (query?: PaginationQuery) => Promise<CursorPage<T>>,
4854
options?: { limit?: number; maxPages?: number },
4955
): AsyncGenerator<T, void, unknown> {
5056
const maxPages = options?.maxPages ?? 100;
51-
let pageCount = 0;
57+
let nextCursor: string | undefined;
58+
59+
for (let pageCount = 0; pageCount < maxPages; pageCount += 1) {
60+
const query: PaginationQuery = {
61+
...(options?.limit !== undefined ? { limit: options.limit } : {}),
62+
...buildPaginationQuery(nextCursor),
63+
};
64+
const page = await fetchPage(query);
5265

53-
while (pageCount < maxPages) {
54-
const query: PaginationQuery = options?.limit
55-
? { limit: options.limit }
56-
: {};
57-
const items = await fetchPage(query);
58-
for (const item of items) {
66+
for (const item of page.items) {
5967
yield item;
6068
}
61-
// Without a cursor mechanism from the response, we stop after one page
62-
// since we can't know if there are more items.
63-
pageCount += 1;
64-
// If we got fewer items than the limit, we're done
65-
if (options?.limit && items.length < options.limit) {
66-
break;
67-
}
68-
// Without response headers exposing next cursor, we stop to avoid infinite loop
69-
if (items.length === 0) {
69+
70+
// A short page means the dataset is exhausted even if a cursor leaks back.
71+
if (options?.limit !== undefined && page.items.length < options.limit) {
7072
break;
7173
}
72-
// If no limit specified, we do one page (can't know if there are more)
73-
if (!options?.limit) {
74+
// No further cursor: the last page was reached.
75+
if (!page.hasMore || page.nextCursor === null || page.nextCursor === '') {
7476
break;
7577
}
78+
nextCursor = page.nextCursor;
7679
}
7780
}

tests/pagination-helper.test.ts

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildPaginationQuery,
55
paginate,
66
} from '../src/pagination';
7+
import type { CursorPage } from '../src/pagination';
78

89
describe('pagination helper (issue #61)', () => {
910
describe('parseCursorPage', () => {
@@ -57,8 +58,17 @@ describe('pagination helper (issue #61)', () => {
5758
});
5859

5960
describe('paginate', () => {
60-
it('yields all items from a single page', async () => {
61-
const fetchPage = vi.fn().mockResolvedValue([1, 2, 3]);
61+
const page = (
62+
items: number[],
63+
nextCursor: string | null,
64+
): CursorPage<number> => ({
65+
items,
66+
nextCursor,
67+
hasMore: nextCursor != null && nextCursor !== '',
68+
});
69+
70+
it('yields all items from a single cursor-less page', async () => {
71+
const fetchPage = vi.fn().mockResolvedValue(page([1, 2, 3], null));
6272
const results: number[] = [];
6373
for await (const item of paginate<number>(fetchPage)) {
6474
results.push(item);
@@ -67,24 +77,72 @@ describe('pagination helper (issue #61)', () => {
6777
expect(fetchPage).toHaveBeenCalledTimes(1);
6878
});
6979

70-
it('stops at maxPages when limit is set', async () => {
71-
const fetchPage = vi.fn().mockResolvedValue([1, 2]);
80+
it('advances on cursor metadata across a two-page dataset (issue #403)', async () => {
81+
const fetchPage = vi
82+
.fn()
83+
.mockResolvedValueOnce(page([1, 2], 'c1'))
84+
.mockResolvedValueOnce(page([3, 4], null));
85+
const results: number[] = [];
86+
for await (const item of paginate<number>(fetchPage, { limit: 2 })) {
87+
results.push(item);
88+
}
89+
expect(results).toEqual([1, 2, 3, 4]);
90+
expect(fetchPage).toHaveBeenCalledTimes(2);
91+
// First call carries only the limit, second call feeds the cursor back.
92+
expect(fetchPage).toHaveBeenNthCalledWith(1, { limit: 2 });
93+
expect(fetchPage).toHaveBeenNthCalledWith(2, {
94+
limit: 2,
95+
cursor: 'c1',
96+
});
97+
});
98+
99+
it('never re-fetches a page whose length equals limit (issue #403)', async () => {
100+
// Old bug: a full page with no further cursor was re-requested forever,
101+
// yielding duplicate items.
102+
const fetchPage = vi
103+
.fn()
104+
.mockResolvedValueOnce(page([1, 2], null))
105+
.mockResolvedValue(page([1, 2], null));
106+
const results: number[] = [];
107+
for await (const item of paginate<number>(fetchPage, { limit: 2 })) {
108+
results.push(item);
109+
}
110+
expect(results).toEqual([1, 2]);
111+
expect(fetchPage).toHaveBeenCalledTimes(1);
112+
});
113+
114+
it('stops at maxPages while the server keeps returning cursors', async () => {
115+
const fetchPage = vi.fn().mockResolvedValue(page([1, 2], 'c-next'));
72116
const results: number[] = [];
73117
for await (const item of paginate<number>(fetchPage, {
74118
limit: 2,
75119
maxPages: 3,
76120
})) {
77121
results.push(item);
78122
}
79-
expect(results.length).toBe(6); // 3 pages * 2 items
123+
expect(results).toEqual([1, 2, 1, 2, 1, 2]);
80124
expect(fetchPage).toHaveBeenCalledTimes(3);
125+
expect(fetchPage).toHaveBeenLastCalledWith({
126+
limit: 2,
127+
cursor: 'c-next',
128+
});
129+
});
130+
131+
it('stops on an empty page', async () => {
132+
const fetchPage = vi.fn().mockResolvedValue(page([], null));
133+
const results: number[] = [];
134+
for await (const item of paginate<number>(fetchPage, { limit: 10 })) {
135+
results.push(item);
136+
}
137+
expect(results).toEqual([]);
138+
expect(fetchPage).toHaveBeenCalledTimes(1);
81139
});
82140

83-
it('stops when page returns fewer items than limit', async () => {
141+
it('stops when a page is shorter than the limit', async () => {
84142
const fetchPage = vi
85143
.fn()
86-
.mockResolvedValueOnce([1, 2])
87-
.mockResolvedValueOnce([3]);
144+
.mockResolvedValueOnce(page([1, 2], 'c1'))
145+
.mockResolvedValueOnce(page([3], 'c2'));
88146
const results: number[] = [];
89147
for await (const item of paginate<number>(fetchPage, { limit: 2 })) {
90148
results.push(item);
@@ -93,13 +151,17 @@ describe('pagination helper (issue #61)', () => {
93151
expect(fetchPage).toHaveBeenCalledTimes(2);
94152
});
95153

96-
it('stops on empty page', async () => {
97-
const fetchPage = vi.fn().mockResolvedValue([]);
154+
it('stops when hasMore is false even if a cursor leaks back', async () => {
155+
const fetchPage = vi.fn().mockResolvedValue({
156+
items: [1],
157+
nextCursor: 'c1',
158+
hasMore: false,
159+
});
98160
const results: number[] = [];
99-
for await (const item of paginate<number>(fetchPage, { limit: 10 })) {
161+
for await (const item of paginate<number>(fetchPage)) {
100162
results.push(item);
101163
}
102-
expect(results).toEqual([]);
164+
expect(results).toEqual([1]);
103165
expect(fetchPage).toHaveBeenCalledTimes(1);
104166
});
105167
});

0 commit comments

Comments
 (0)