Skip to content

Commit 410e76f

Browse files
authored
Merge pull request #3 from javrrr/hotfix/pagination-yield-and-next-page-url
Fix pagination iteration and next-page handling
2 parents 64afb00 + 9e29a9c commit 410e76f

3 files changed

Lines changed: 106 additions & 25 deletions

File tree

src/core/pagination.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,35 @@ export interface PaginateOptions<T> extends PaginationParams {
4444
extractItems?: (raw: unknown) => T[];
4545
}
4646

47+
function normalizeNextPagePath(nextPageUrl: string): string {
48+
const trimmed = nextPageUrl.trim();
49+
let pathWithQuery = trimmed;
50+
51+
try {
52+
// API may return a full absolute URL; HttpClient expects a path.
53+
const parsed = new URL(trimmed);
54+
pathWithQuery = `${parsed.pathname}${parsed.search}`;
55+
} catch {
56+
// Not an absolute URL, keep as-is.
57+
}
58+
59+
// API may return "/services/data/vXX.X/..." while the client baseUrl already
60+
// includes "/services/data/vXX.X".
61+
const withVersionPrefix = pathWithQuery.match(/^\/services\/data\/v\d+(?:\.\d+)?(\/.*)$/);
62+
if (withVersionPrefix) {
63+
return withVersionPrefix[1];
64+
}
65+
66+
return pathWithQuery;
67+
}
68+
4769
/**
4870
* Async generator that yields pages of items from a paginated endpoint.
4971
* Supports both offset-based and nextPageUrl-based pagination.
5072
*/
5173
export async function* paginate<T>(
5274
options: PaginateOptions<T>,
53-
): AsyncGenerator<T[], void, undefined> {
75+
): AsyncGenerator<T, void, undefined> {
5476
const {
5577
httpClient,
5678
path,
@@ -65,6 +87,7 @@ export async function* paginate<T>(
6587

6688
let currentOffset = startOffset;
6789
let nextUrl: string | undefined;
90+
let totalSize: number | undefined;
6891

6992
while (true) {
7093
const paginationQuery: Record<string, string | number | boolean | undefined> = {
@@ -87,22 +110,31 @@ export async function* paginate<T>(
87110
});
88111

89112
const items = customExtractor ? customExtractor(raw) : extractItems(raw);
113+
if (typeof raw.totalSize === "number" && Number.isFinite(raw.totalSize) && raw.totalSize >= 0) {
114+
totalSize = raw.totalSize;
115+
}
90116

91117
if (items.length === 0) {
92118
break;
93119
}
94120

95-
yield items;
121+
for (const item of items) {
122+
yield item;
123+
}
124+
currentOffset += items.length;
125+
126+
// Safety net: stop when we have already yielded all advertised rows.
127+
if (totalSize !== undefined && currentOffset >= totalSize) {
128+
break;
129+
}
96130

97131
// Check for next page
98132
if (raw.nextPageUrl) {
99-
nextUrl = raw.nextPageUrl;
100-
currentOffset += items.length;
133+
nextUrl = normalizeNextPagePath(raw.nextPageUrl);
101134
} else if (items.length < batchSize) {
102135
// No more pages
103136
break;
104137
} else {
105-
currentOffset += items.length;
106138
nextUrl = undefined;
107139
}
108140
}
@@ -115,8 +147,8 @@ export async function collectAll<T>(
115147
options: PaginateOptions<T>,
116148
): Promise<T[]> {
117149
const all: T[] = [];
118-
for await (const page of paginate(options)) {
119-
all.push(...page);
150+
for await (const item of paginate(options)) {
151+
all.push(item);
120152
}
121153
return all;
122154
}

src/resources/base-resource.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export abstract class BaseResource {
4040
path: string,
4141
params?: PaginationParams & { query?: Record<string, string | number | boolean | undefined> },
4242
requestOptions?: RequestOptions,
43-
): AsyncGenerator<T[], void, undefined> {
43+
): AsyncGenerator<T, void, undefined> {
4444
const { batchSize, offset, orderBy, pageSizeParam, query, ...rest } = params ?? {};
4545
return paginate<T>({
4646
httpClient: this.httpClient,

tests/core/pagination.test.ts

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,22 @@ function createMockHttpClient(responses: unknown[]): HttpClient {
1717
}
1818

1919
describe("pagination", () => {
20-
it("yields pages of items using offset-based pagination", async () => {
20+
it("yields individual items using offset-based pagination", async () => {
2121
const httpClient = createMockHttpClient([
2222
{ data: [{ id: 1 }, { id: 2 }] },
2323
{ data: [{ id: 3 }] },
2424
]);
2525

26-
const pages: unknown[][] = [];
27-
for await (const page of paginate({
26+
const items: unknown[] = [];
27+
for await (const item of paginate({
2828
httpClient,
2929
path: "/ssot/test",
3030
batchSize: 2,
3131
})) {
32-
pages.push(page);
32+
items.push(item);
3333
}
3434

35-
expect(pages).toHaveLength(2);
36-
expect(pages[0]).toEqual([{ id: 1 }, { id: 2 }]);
37-
expect(pages[1]).toEqual([{ id: 3 }]);
35+
expect(items).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
3836
});
3937

4038
it("stops when empty page is returned", async () => {
@@ -43,16 +41,16 @@ describe("pagination", () => {
4341
{ data: [] },
4442
]);
4543

46-
const pages: unknown[][] = [];
47-
for await (const page of paginate({
44+
const items: unknown[] = [];
45+
for await (const item of paginate({
4846
httpClient,
4947
path: "/ssot/test",
5048
batchSize: 10,
5149
})) {
52-
pages.push(page);
50+
items.push(item);
5351
}
5452

55-
expect(pages).toHaveLength(1);
53+
expect(items).toEqual([{ id: 1 }]);
5654
});
5755

5856
it("collectAll gathers all items", async () => {
@@ -120,14 +118,14 @@ describe("pagination", () => {
120118
{ data: [{ id: 1 }] },
121119
]);
122120

123-
const pages: unknown[][] = [];
124-
for await (const page of paginate({
121+
const items: unknown[] = [];
122+
for await (const item of paginate({
125123
httpClient,
126124
path: "/ssot/test",
127125
batchSize: 5,
128126
pageSizeParam: "limit",
129127
})) {
130-
pages.push(page);
128+
items.push(item);
131129
}
132130

133131
expect(httpClient.get).toHaveBeenCalledWith("/ssot/test", {
@@ -140,13 +138,13 @@ describe("pagination", () => {
140138
{ data: [{ id: 1 }] },
141139
]);
142140

143-
const pages: unknown[][] = [];
144-
for await (const page of paginate({
141+
const items: unknown[] = [];
142+
for await (const item of paginate({
145143
httpClient,
146144
path: "/ssot/test",
147145
batchSize: 5,
148146
})) {
149-
pages.push(page);
147+
items.push(item);
150148
}
151149

152150
expect(httpClient.get).toHaveBeenCalledWith("/ssot/test", {
@@ -169,4 +167,55 @@ describe("pagination", () => {
169167

170168
expect(all).toEqual([{ name: "a" }]);
171169
});
170+
171+
it("strips API base prefix from absolute nextPageUrl", async () => {
172+
const httpClient = createMockHttpClient([
173+
{
174+
data: [{ id: 1 }],
175+
nextPageUrl: "https://instance.my.salesforce.com/services/data/v66.0/ssot/test?offset=1&batchSize=1",
176+
},
177+
{ data: [{ id: 2 }] },
178+
]);
179+
180+
const all = await collectAll({
181+
httpClient,
182+
path: "/ssot/test",
183+
batchSize: 1,
184+
});
185+
186+
expect(all).toEqual([{ id: 1 }, { id: 2 }]);
187+
expect(httpClient.get).toHaveBeenNthCalledWith(2, "/ssot/test?offset=1&batchSize=1", {
188+
query: undefined,
189+
});
190+
});
191+
192+
it("uses totalSize to avoid extra offset-based request", async () => {
193+
const httpClient = createMockHttpClient([
194+
{ data: [{ id: 1 }, { id: 2 }], totalSize: 2 },
195+
]);
196+
197+
const all = await collectAll({
198+
httpClient,
199+
path: "/ssot/test",
200+
batchSize: 2,
201+
});
202+
203+
expect(all).toEqual([{ id: 1 }, { id: 2 }]);
204+
expect(httpClient.get).toHaveBeenCalledTimes(1);
205+
});
206+
207+
it("uses totalSize guard even when nextPageUrl is present", async () => {
208+
const httpClient = createMockHttpClient([
209+
{ data: [{ id: 1 }], totalSize: 1, nextPageUrl: "/ssot/test?offset=1&batchSize=1" },
210+
]);
211+
212+
const all = await collectAll({
213+
httpClient,
214+
path: "/ssot/test",
215+
batchSize: 1,
216+
});
217+
218+
expect(all).toEqual([{ id: 1 }]);
219+
expect(httpClient.get).toHaveBeenCalledTimes(1);
220+
});
172221
});

0 commit comments

Comments
 (0)