Skip to content

Commit e88a390

Browse files
committed
feat(horizonPaginator): add generic paginateArray helper (#618)
1 parent 5e1f9d6 commit e88a390

3 files changed

Lines changed: 199 additions & 1 deletion

File tree

src/horizonPaginator.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import type { CollectionPage, HorizonPaginatorOptions } from "./types.js";
2424
import { buildCursorKey, getDefaultCursorStore } from "./cursorTracker.js";
25+
import { SdkError, SdkErrorCode } from "./errors.js";
2526

2627
/** Default namespace for cursor store keys. */
2728
const DEFAULT_NAMESPACE = "horizon";
@@ -183,3 +184,74 @@ export async function collectAll<T>(
183184
}
184185
return results;
185186
}
187+
188+
// ─────────────────────────────────────────────────────────────────────────────
189+
// #618 — Local array pagination helper (paginateArray)
190+
// -----------------------------------------------------------------------------
191+
// Spec + resolved anomalies: RESOLUCION_ANOMALIAS_618.md (experimento_autonomia).
192+
193+
/** Options for {@link paginateArray}. */
194+
export interface PaginateArrayOptions {
195+
/** 1-indexed page number. */
196+
page: number;
197+
/** Page size; must be between 1 and 200 inclusive. */
198+
pageSize: number;
199+
}
200+
201+
/** Result of paginating a local array. */
202+
export interface PaginateArrayResult<T> {
203+
/** Items for the requested page. */
204+
data: T[];
205+
/** Total number of items in the source array. */
206+
total: number;
207+
/** Total number of pages. */
208+
totalPages: number;
209+
/** Whether a next page exists. */
210+
hasNext: boolean;
211+
/** Whether a previous page exists. */
212+
hasPrev: boolean;
213+
}
214+
215+
/**
216+
* Paginate a local in-memory array into 1-indexed pages.
217+
*
218+
* Pure function: the input array is never mutated. An out-of-range `page`
219+
* (including 0, negatives and non-integers) returns `data: []` without
220+
* throwing. An empty array yields `totalPages: 0`.
221+
*
222+
* @param items - The full array to paginate.
223+
* @param opts - Page number (1-indexed) and page size.
224+
* @returns The requested page plus pagination metadata.
225+
* @throws {SdkError} if `pageSize` is not an integer between 1 and 200.
226+
*/
227+
export function paginateArray<T>(
228+
items: T[],
229+
opts: PaginateArrayOptions,
230+
): PaginateArrayResult<T> {
231+
const { page, pageSize } = opts;
232+
233+
// Spec: pageSize must be between 1 and 200 (inclusive).
234+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) {
235+
// SdkErrorCode only exposes INVALID_RECIPIENT as the closest
236+
// invalid-argument code (issue #607 enum is intentionally closed).
237+
// Extending the enum is out of scope for this issue (see PR notes).
238+
throw new SdkError(
239+
`pageSize must be an integer between 1 and 200, got ${pageSize}`,
240+
SdkErrorCode.INVALID_RECIPIENT,
241+
{ page, pageSize },
242+
);
243+
}
244+
245+
const total = items.length;
246+
const totalPages = Math.ceil(total / pageSize);
247+
248+
// Out-of-range page (page < 1 or beyond totalPages) → empty page, no error.
249+
if (!Number.isInteger(page) || page < 1 || page > totalPages) {
250+
return { data: [], total, totalPages, hasNext: false, hasPrev: false };
251+
}
252+
253+
const start = (page - 1) * pageSize;
254+
const data = items.slice(start, start + pageSize);
255+
256+
return { data, total, totalPages, hasNext: page < totalPages, hasPrev: page > 1 };
257+
}

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1427,3 +1427,14 @@ export type {
14271427
SubmitTransactionOptions,
14281428
SubmitServer,
14291429
} from "./transaction/submit.js";
1430+
1431+
// ---------------------------------------------------------------------------
1432+
// #618 - Local array pagination helper (paginateArray)
1433+
// Spec + resolved anomalies: RESOLUCION_ANOMALIAS_618.md (experimento_autonomia).
1434+
// ---------------------------------------------------------------------------
1435+
1436+
export { paginateArray } from "./horizonPaginator.js";
1437+
export type {
1438+
PaginateArrayOptions,
1439+
PaginateArrayResult,
1440+
} from "./horizonPaginator.js";

test/horizonPaginator.test.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import {
55
getDefaultCursorStore,
66
buildCursorKey,
77
} from "../src/cursorTracker.js";
8-
import { paginate, collectAll, HorizonPaginator } from "../src/horizonPaginator.js";
8+
import {
9+
paginate,
10+
collectAll,
11+
HorizonPaginator,
12+
paginateArray,
13+
} from "../src/horizonPaginator.js";
14+
import { SdkError } from "../src/errors.js";
915
import type { CollectionPage } from "../src/types.js";
1016

1117
describe("InMemoryCursorStore", () => {
@@ -249,3 +255,112 @@ describe("paginate – page-size negotiation integration (#692)", () => {
249255
expect(results).toHaveLength(8);
250256
});
251257
});
258+
259+
// ─────────────────────────────────────────────────────────────────────────────
260+
// #618 — paginateArray (local array pagination)
261+
// Spec + resolved anomalies: RESOLUCION_ANOMALIAS_618.md (experimento_autonomia).
262+
263+
describe("paginateArray (#618)", () => {
264+
// 1..10
265+
const TEN = Array.from({ length: 10 }, (_, i) => i + 1);
266+
267+
// ---- Spec: first page / last page / ranges ----
268+
it("returns the first page (1-indexed)", () => {
269+
const r = paginateArray(TEN, { page: 1, pageSize: 5 });
270+
expect(r.data).toEqual([1, 2, 3, 4, 5]);
271+
expect(r.total).toBe(10);
272+
expect(r.totalPages).toBe(2);
273+
expect(r.hasNext).toBe(true);
274+
expect(r.hasPrev).toBe(false);
275+
});
276+
277+
it("returns the last page", () => {
278+
const r = paginateArray(TEN, { page: 2, pageSize: 5 });
279+
expect(r.data).toEqual([6, 7, 8, 9, 10]);
280+
expect(r.hasNext).toBe(false);
281+
expect(r.hasPrev).toBe(true);
282+
});
283+
284+
it("handles a page smaller than pageSize on the last page", () => {
285+
const r = paginateArray([1, 2, 3, 4, 5, 6, 7], { page: 2, pageSize: 5 });
286+
expect(r.data).toEqual([6, 7]);
287+
expect(r.totalPages).toBe(2);
288+
expect(r.hasNext).toBe(false);
289+
expect(r.hasPrev).toBe(true);
290+
});
291+
292+
// ---- Spec: out-of-range page → data: [], no error ----
293+
it("returns data: [] for a page beyond totalPages", () => {
294+
const r = paginateArray(TEN, { page: 99, pageSize: 5 });
295+
expect(r.data).toEqual([]);
296+
expect(r.total).toBe(10);
297+
expect(r.totalPages).toBe(2);
298+
expect(r.hasNext).toBe(false);
299+
expect(r.hasPrev).toBe(false);
300+
});
301+
302+
it("returns data: [] for page 0 (1-indexed invariant, anomaly resolved)", () => {
303+
const r = paginateArray(TEN, { page: 0, pageSize: 5 });
304+
expect(r.data).toEqual([]);
305+
expect(r.totalPages).toBe(2);
306+
expect(r.hasNext).toBe(false);
307+
expect(r.hasPrev).toBe(false);
308+
});
309+
310+
it("returns data: [] for a negative page (anomaly resolved)", () => {
311+
const r = paginateArray(TEN, { page: -3, pageSize: 5 });
312+
expect(r.data).toEqual([]);
313+
});
314+
315+
// ---- Spec: pageSize bounds → throw SdkError ----
316+
it("throws SdkError for pageSize 0", () => {
317+
expect(() => paginateArray(TEN, { page: 1, pageSize: 0 }))
318+
.toThrowError(SdkError);
319+
});
320+
321+
it("throws SdkError for pageSize 201", () => {
322+
expect(() => paginateArray(TEN, { page: 1, pageSize: 201 }))
323+
.toThrowError(SdkError);
324+
});
325+
326+
it("throws SdkError with INVALID_RECIPIENT code (enum-closed decision)", () => {
327+
try {
328+
paginateArray(TEN, { page: 1, pageSize: 0 });
329+
expect.unreachable("should have thrown");
330+
} catch (err) {
331+
expect(err).toBeInstanceOf(SdkError);
332+
expect((err as SdkError).code).toBe("INVALID_RECIPIENT");
333+
}
334+
});
335+
336+
it("accepts pageSize 200 (upper bound inclusive)", () => {
337+
const r = paginateArray(TEN, { page: 1, pageSize: 200 });
338+
expect(r.data).toEqual(TEN);
339+
expect(r.totalPages).toBe(1);
340+
expect(r.hasNext).toBe(false);
341+
});
342+
343+
// ---- Anomaly: empty array ----
344+
it("returns totalPages 0 and data [] for an empty array (anomaly resolved)", () => {
345+
const r = paginateArray([], { page: 1, pageSize: 5 });
346+
expect(r.data).toEqual([]);
347+
expect(r.total).toBe(0);
348+
expect(r.totalPages).toBe(0);
349+
expect(r.hasNext).toBe(false);
350+
expect(r.hasPrev).toBe(false);
351+
});
352+
353+
// ---- Purity: does not mutate input ----
354+
it("does not mutate the input array", () => {
355+
const src = [1, 2, 3, 4, 5, 6, 7];
356+
const snapshot = [...src];
357+
paginateArray(src, { page: 2, pageSize: 3 });
358+
expect(src).toEqual(snapshot);
359+
});
360+
361+
// ---- Non-integer page/pageSize ----
362+
it("treats a non-integer page as out-of-range (data: [])", () => {
363+
const r = paginateArray(TEN, { page: 1.5, pageSize: 5 });
364+
expect(r.data).toEqual([]);
365+
});
366+
});

0 commit comments

Comments
 (0)