diff --git a/src/horizonPaginator.ts b/src/horizonPaginator.ts index 3dcc12a..b3de970 100644 --- a/src/horizonPaginator.ts +++ b/src/horizonPaginator.ts @@ -22,6 +22,7 @@ import type { CollectionPage, HorizonPaginatorOptions } from "./types.js"; import { buildCursorKey, getDefaultCursorStore } from "./cursorTracker.js"; +import { SdkError, SdkErrorCode } from "./errors.js"; /** Default namespace for cursor store keys. */ const DEFAULT_NAMESPACE = "horizon"; @@ -183,3 +184,74 @@ export async function collectAll( } return results; } + +// ───────────────────────────────────────────────────────────────────────────── +// #618 — Local array pagination helper (paginateArray) +// ----------------------------------------------------------------------------- +// Spec + resolved anomalies: RESOLUCION_ANOMALIAS_618.md (experimento_autonomia). + +/** Options for {@link paginateArray}. */ +export interface PaginateArrayOptions { + /** 1-indexed page number. */ + page: number; + /** Page size; must be between 1 and 200 inclusive. */ + pageSize: number; +} + +/** Result of paginating a local array. */ +export interface PaginateArrayResult { + /** Items for the requested page. */ + data: T[]; + /** Total number of items in the source array. */ + total: number; + /** Total number of pages. */ + totalPages: number; + /** Whether a next page exists. */ + hasNext: boolean; + /** Whether a previous page exists. */ + hasPrev: boolean; +} + +/** + * Paginate a local in-memory array into 1-indexed pages. + * + * Pure function: the input array is never mutated. An out-of-range `page` + * (including 0, negatives and non-integers) returns `data: []` without + * throwing. An empty array yields `totalPages: 0`. + * + * @param items - The full array to paginate. + * @param opts - Page number (1-indexed) and page size. + * @returns The requested page plus pagination metadata. + * @throws {SdkError} if `pageSize` is not an integer between 1 and 200. + */ +export function paginateArray( + items: T[], + opts: PaginateArrayOptions, +): PaginateArrayResult { + const { page, pageSize } = opts; + + // Spec: pageSize must be between 1 and 200 (inclusive). + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) { + // SdkErrorCode only exposes INVALID_RECIPIENT as the closest + // invalid-argument code (issue #607 enum is intentionally closed). + // Extending the enum is out of scope for this issue (see PR notes). + throw new SdkError( + `pageSize must be an integer between 1 and 200, got ${pageSize}`, + SdkErrorCode.INVALID_RECIPIENT, + { page, pageSize }, + ); + } + + const total = items.length; + const totalPages = Math.ceil(total / pageSize); + + // Out-of-range page (page < 1 or beyond totalPages) → empty page, no error. + if (!Number.isInteger(page) || page < 1 || page > totalPages) { + return { data: [], total, totalPages, hasNext: false, hasPrev: false }; + } + + const start = (page - 1) * pageSize; + const data = items.slice(start, start + pageSize); + + return { data, total, totalPages, hasNext: page < totalPages, hasPrev: page > 1 }; +} diff --git a/src/index.ts b/src/index.ts index f9cb56b..2690e1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1427,3 +1427,14 @@ export type { SubmitTransactionOptions, SubmitServer, } from "./transaction/submit.js"; + +// --------------------------------------------------------------------------- +// #618 - Local array pagination helper (paginateArray) +// Spec + resolved anomalies: RESOLUCION_ANOMALIAS_618.md (experimento_autonomia). +// --------------------------------------------------------------------------- + +export { paginateArray } from "./horizonPaginator.js"; +export type { + PaginateArrayOptions, + PaginateArrayResult, +} from "./horizonPaginator.js"; diff --git a/test/horizonPaginator.test.ts b/test/horizonPaginator.test.ts index 96330b9..bec6b85 100644 --- a/test/horizonPaginator.test.ts +++ b/test/horizonPaginator.test.ts @@ -5,7 +5,13 @@ import { getDefaultCursorStore, buildCursorKey, } from "../src/cursorTracker.js"; -import { paginate, collectAll, HorizonPaginator } from "../src/horizonPaginator.js"; +import { + paginate, + collectAll, + HorizonPaginator, + paginateArray, +} from "../src/horizonPaginator.js"; +import { SdkError } from "../src/errors.js"; import type { CollectionPage } from "../src/types.js"; describe("InMemoryCursorStore", () => { @@ -249,3 +255,112 @@ describe("paginate – page-size negotiation integration (#692)", () => { expect(results).toHaveLength(8); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #618 — paginateArray (local array pagination) +// Spec + resolved anomalies: RESOLUCION_ANOMALIAS_618.md (experimento_autonomia). + +describe("paginateArray (#618)", () => { + // 1..10 + const TEN = Array.from({ length: 10 }, (_, i) => i + 1); + + // ---- Spec: first page / last page / ranges ---- + it("returns the first page (1-indexed)", () => { + const r = paginateArray(TEN, { page: 1, pageSize: 5 }); + expect(r.data).toEqual([1, 2, 3, 4, 5]); + expect(r.total).toBe(10); + expect(r.totalPages).toBe(2); + expect(r.hasNext).toBe(true); + expect(r.hasPrev).toBe(false); + }); + + it("returns the last page", () => { + const r = paginateArray(TEN, { page: 2, pageSize: 5 }); + expect(r.data).toEqual([6, 7, 8, 9, 10]); + expect(r.hasNext).toBe(false); + expect(r.hasPrev).toBe(true); + }); + + it("handles a page smaller than pageSize on the last page", () => { + const r = paginateArray([1, 2, 3, 4, 5, 6, 7], { page: 2, pageSize: 5 }); + expect(r.data).toEqual([6, 7]); + expect(r.totalPages).toBe(2); + expect(r.hasNext).toBe(false); + expect(r.hasPrev).toBe(true); + }); + + // ---- Spec: out-of-range page → data: [], no error ---- + it("returns data: [] for a page beyond totalPages", () => { + const r = paginateArray(TEN, { page: 99, pageSize: 5 }); + expect(r.data).toEqual([]); + expect(r.total).toBe(10); + expect(r.totalPages).toBe(2); + expect(r.hasNext).toBe(false); + expect(r.hasPrev).toBe(false); + }); + + it("returns data: [] for page 0 (1-indexed invariant, anomaly resolved)", () => { + const r = paginateArray(TEN, { page: 0, pageSize: 5 }); + expect(r.data).toEqual([]); + expect(r.totalPages).toBe(2); + expect(r.hasNext).toBe(false); + expect(r.hasPrev).toBe(false); + }); + + it("returns data: [] for a negative page (anomaly resolved)", () => { + const r = paginateArray(TEN, { page: -3, pageSize: 5 }); + expect(r.data).toEqual([]); + }); + + // ---- Spec: pageSize bounds → throw SdkError ---- + it("throws SdkError for pageSize 0", () => { + expect(() => paginateArray(TEN, { page: 1, pageSize: 0 })) + .toThrowError(SdkError); + }); + + it("throws SdkError for pageSize 201", () => { + expect(() => paginateArray(TEN, { page: 1, pageSize: 201 })) + .toThrowError(SdkError); + }); + + it("throws SdkError with INVALID_RECIPIENT code (enum-closed decision)", () => { + try { + paginateArray(TEN, { page: 1, pageSize: 0 }); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(SdkError); + expect((err as SdkError).code).toBe("INVALID_RECIPIENT"); + } + }); + + it("accepts pageSize 200 (upper bound inclusive)", () => { + const r = paginateArray(TEN, { page: 1, pageSize: 200 }); + expect(r.data).toEqual(TEN); + expect(r.totalPages).toBe(1); + expect(r.hasNext).toBe(false); + }); + + // ---- Anomaly: empty array ---- + it("returns totalPages 0 and data [] for an empty array (anomaly resolved)", () => { + const r = paginateArray([], { page: 1, pageSize: 5 }); + expect(r.data).toEqual([]); + expect(r.total).toBe(0); + expect(r.totalPages).toBe(0); + expect(r.hasNext).toBe(false); + expect(r.hasPrev).toBe(false); + }); + + // ---- Purity: does not mutate input ---- + it("does not mutate the input array", () => { + const src = [1, 2, 3, 4, 5, 6, 7]; + const snapshot = [...src]; + paginateArray(src, { page: 2, pageSize: 3 }); + expect(src).toEqual(snapshot); + }); + + // ---- Non-integer page/pageSize ---- + it("treats a non-integer page as out-of-range (data: [])", () => { + const r = paginateArray(TEN, { page: 1.5, pageSize: 5 }); + expect(r.data).toEqual([]); + }); +});