Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/horizonPaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -183,3 +184,74 @@ export async function collectAll<T>(
}
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<T> {
/** 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<T>(
items: T[],
opts: PaginateArrayOptions,
): PaginateArrayResult<T> {
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 };
}
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
117 changes: 116 additions & 1 deletion test/horizonPaginator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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([]);
});
});