Skip to content

Commit b51222f

Browse files
authored
Merge pull request #127 from kernel/fix-offset-pagination-page-skip
Fix offset auto-pagination skipping every other page
2 parents a6dda60 + d318410 commit b51222f

2 files changed

Lines changed: 76 additions & 4 deletions

File tree

src/core/pagination.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,29 @@ export class OffsetPagination<Item> extends AbstractPage<Item> {
148148
}
149149

150150
nextPageRequestOptions(): PageRequestOptions | null {
151-
const offset = this.next_offset ?? 0;
152-
const length = this.getPaginatedItems().length;
153-
const currentCount = offset + length;
151+
// X-Next-Offset is the absolute start of the next page, or 0 on the last
152+
// page (the API's stop sentinel). The old code added the current page
153+
// length on top, skipping a full page per iteration. Only a positive
154+
// offset advances; 0 is a value the server affirmatively sent to mean
155+
// "no more", so we stop on it (matching the Go SDK pager).
156+
const offset = this.next_offset;
157+
if (offset == null) {
158+
if (this.has_more) {
159+
throw new KernelError(
160+
'Server reported X-Has-More: true without an X-Next-Offset header; refusing to silently truncate pagination',
161+
);
162+
}
163+
return null;
164+
}
165+
if (offset === 0) {
166+
return null;
167+
}
154168

155169
return {
156170
...this.options,
157171
query: {
158172
...maybeObj(this.options.query),
159-
offset: currentCount,
173+
offset,
160174
},
161175
};
162176
}

tests/pagination.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Kernel, { KernelError } from '@onkernel/sdk';
2+
import { OffsetPagination } from '@onkernel/sdk/core/pagination';
3+
import type { FinalRequestOptions } from '@onkernel/sdk/internal/request-options';
4+
5+
const client = new Kernel({
6+
apiKey: 'test-api-key',
7+
fetch: () => Promise.reject(new Error('unexpected request')),
8+
});
9+
10+
function pageWith(
11+
headers: Record<string, string>,
12+
items: unknown[],
13+
offset?: number,
14+
): OffsetPagination<unknown> {
15+
const options: FinalRequestOptions = {
16+
method: 'get',
17+
path: '/proxies',
18+
query: offset === undefined ? {} : { offset },
19+
};
20+
return new OffsetPagination(client, new Response('[]', { headers }), items, options);
21+
}
22+
23+
describe('OffsetPagination', () => {
24+
test('requests the next page at exactly X-Next-Offset', () => {
25+
// X-Next-Offset already holds the next page's start. Adding the current
26+
// page length on top (the old behavior) skipped a full page per iteration.
27+
const page = pageWith({ 'x-next-offset': '100', 'x-has-more': 'true' }, new Array(100).fill({}), 0);
28+
expect(page.nextPageRequestOptions()?.query).toEqual({ offset: 100 });
29+
});
30+
31+
test('stops cleanly when the last page omits X-Next-Offset', () => {
32+
const page = pageWith({ 'x-has-more': 'false' }, new Array(50).fill({}), 100);
33+
expect(page.nextPageRequestOptions()).toBeNull();
34+
expect(page.hasNextPage()).toBe(false);
35+
});
36+
37+
test('stops on the X-Next-Offset 0 sentinel even when X-Has-More is true', () => {
38+
// Call nextPageRequestOptions directly with has_more=true so the has_more
39+
// gate in hasNextPage() cannot mask it: the 0 offset alone must stop.
40+
const page = pageWith({ 'x-next-offset': '0', 'x-has-more': 'true' }, new Array(50).fill({}), 100);
41+
expect(page.nextPageRequestOptions()).toBeNull();
42+
});
43+
44+
test('stops when X-Has-More is false even with a positive X-Next-Offset', () => {
45+
const page = pageWith({ 'x-next-offset': '200', 'x-has-more': 'false' }, new Array(50).fill({}), 100);
46+
expect(page.hasNextPage()).toBe(false);
47+
});
48+
49+
test('stops on an empty page', () => {
50+
const page = pageWith({ 'x-next-offset': '300', 'x-has-more': 'true' }, [], 200);
51+
expect(page.hasNextPage()).toBe(false);
52+
});
53+
54+
test('refuses to silently truncate when X-Has-More is true but X-Next-Offset is missing', () => {
55+
const page = pageWith({ 'x-has-more': 'true' }, new Array(100).fill({}));
56+
expect(() => page.hasNextPage()).toThrow(KernelError);
57+
});
58+
});

0 commit comments

Comments
 (0)