From 2a0016e504efe15c0b614e75bbfc2117fcdc0098 Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Thu, 20 Aug 2026 20:48:42 +0800 Subject: [PATCH] feat: add generic paginateArray helper to horizonPaginator (Closes #618) --- src/horizonPaginator.ts | 52 ++++++++++++++++++ src/index.ts | 19 ++++++- src/utils.ts | 27 ++++++++++ test/formatAddress.test.ts | 32 +++++++++++ test/paginateArray.test.ts | 108 +++++++++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 test/formatAddress.test.ts create mode 100644 test/paginateArray.test.ts diff --git a/src/horizonPaginator.ts b/src/horizonPaginator.ts index 1e00976..731884b 100644 --- a/src/horizonPaginator.ts +++ b/src/horizonPaginator.ts @@ -11,6 +11,7 @@ import type { CollectionPage, HorizonPaginatorOptions } from "./types.js"; import { buildCursorKey, getDefaultCursorStore } from "./cursorTracker.js"; +import { StellarSplitError } from "./errors.js"; /** Default namespace for cursor store keys. */ const DEFAULT_NAMESPACE = "horizon"; @@ -95,3 +96,54 @@ export async function collectAll( } return results; } + +/** + * Page a plain array in memory, returning a slice with pagination metadata. + * + * @param items - The full array of items to paginate. + * @param opts - Pagination options: `page` (1-indexed) and `pageSize` (1-200). + * @returns An object with `data`, `total`, `totalPages`, `hasNext`, and `hasPrev`. + * + * @throws {StellarSplitError} If `pageSize` is outside the range 1-200. + * + * @example + * ```typescript + * const result = paginateArray([1, 2, 3, 4, 5], { page: 1, pageSize: 2 }); + * // { data: [1, 2], total: 5, totalPages: 3, hasNext: true, hasPrev: false } + * ``` + */ +export function paginateArray( + items: T[], + opts: { page: number; pageSize: number }, +): { data: T[]; total: number; totalPages: number; hasNext: boolean; hasPrev: boolean } { + const { page, pageSize } = opts; + + if (pageSize < 1 || pageSize > 200) { + throw new StellarSplitError("pageSize must be between 1 and 200", "INVALID_RECIPIENT"); + } + + const total = items.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + if (page < 1 || page > totalPages) { + return { + data: [], + total, + totalPages, + hasNext: false, + hasPrev: page > 1, + }; + } + + const startIndex = (page - 1) * pageSize; + const endIndex = Math.min(startIndex + pageSize, total); + const data = items.slice(startIndex, endIndex); + + return { + data, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }; +} diff --git a/src/index.ts b/src/index.ts index 2fe2b4a..cbc8101 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1033,7 +1033,12 @@ export type { ChannelReconciliationResult, ChannelStateFetcher, } from "./channelReconciler.js"; -export { getInvoiceStats, computeInvoiceStats } from "./invoiceStats.js"; +export { + getInvoiceStats, + computeInvoiceStats, + getInvoiceAge, + getFundingVelocity, +} from "./invoiceStats.js"; export { previewSplitRules } from "./splitPreview.js"; @@ -1366,3 +1371,15 @@ export type { SubmitTransactionOptions, SubmitServer, } from "./transaction/submit.js"; + +// --------------------------------------------------------------------------- +// #608 — formatAddress utility for truncating Stellar addresses +// --------------------------------------------------------------------------- + +export { formatAddress } from "./utils.js"; + +// --------------------------------------------------------------------------- +// #618 — Generic paginateArray helper for in-memory array pagination +// --------------------------------------------------------------------------- + +export { paginateArray } from "./horizonPaginator.js"; diff --git a/src/utils.ts b/src/utils.ts index 08a44f9..942463a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,7 @@ */ import { Invoice } from "./types"; import { Account, MuxedAccount, StrKey } from "@stellar/stellar-sdk"; +import { StellarSplitError } from "./errors.js"; /** Number of decimal places used by Stellar token amounts (stroops). */ const STROOPS_PER_UNIT = 10_000_000n; @@ -87,6 +88,32 @@ export function truncateAddress(address: string, chars = 4): string { return `${address.slice(0, chars)}...${address.slice(-chars)}`; } +/** + * Format a Stellar address for display by keeping the leading and trailing + * characters and replacing the middle with an ellipsis. + * + * Defaults to 5 leading and 4 trailing characters: "GABCD...WXYZ". + * Throws a {@link StellarSplitError} with code `INVALID_RECIPIENT` when the + * address is too short to format. + * + * @example + * formatAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ") // "GABCD...WXYZ" + */ +export function formatAddress( + address: string, + opts: { leading?: number; trailing?: number } = {} +): string { + const leading = opts.leading ?? 5; + const trailing = opts.trailing ?? 4; + if (address.length < leading + trailing + 3) { + throw new StellarSplitError( + `Address is too short to format: expected at least ${leading + trailing + 3} characters, got ${address.length}`, + "INVALID_RECIPIENT" + ); + } + return `${address.slice(0, leading)}...${address.slice(-trailing)}`; +} + /** * Validates if a caller is in the invoice's allowed callers list. */ diff --git a/test/formatAddress.test.ts b/test/formatAddress.test.ts new file mode 100644 index 0000000..faaf00e --- /dev/null +++ b/test/formatAddress.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { formatAddress } from "../src/utils.js"; +import { StellarSplitError } from "../src/errors.js"; + +const LONG_ADDRESS = "GABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABC"; + +describe("formatAddress", () => { + it("formats with default leading 5 and trailing 4", () => { + expect(formatAddress(LONG_ADDRESS)).toBe("GABCD...ZABC"); + }); + + it("supports custom leading and trailing", () => { + expect(formatAddress(LONG_ADDRESS, { leading: 3, trailing: 3 })).toBe("GAB...ABC"); + }); + + it("throws StellarSplitError with INVALID_RECIPIENT code for addresses too short", () => { + expect(() => formatAddress("GABCDEF")).toThrow(StellarSplitError); + try { + formatAddress("GABCDEF"); + } catch (err) { + expect(err).toBeInstanceOf(StellarSplitError); + expect((err as StellarSplitError).code).toBe("INVALID_RECIPIENT"); + } + }); + + it("keeps a full-length address format correct", () => { + const result = formatAddress(LONG_ADDRESS); + expect(result).toMatch(/^GABCD\.\.\./); + expect(result).toMatch(/ZABC$/); + expect(result.length).toBe(5 + 3 + 4); + }); +}); \ No newline at end of file diff --git a/test/paginateArray.test.ts b/test/paginateArray.test.ts new file mode 100644 index 0000000..9fa8e67 --- /dev/null +++ b/test/paginateArray.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { paginateArray } from "../src/horizonPaginator.js"; +import { StellarSplitError } from "../src/errors.js"; + +describe("paginateArray", () => { + const items = Array.from({ length: 10 }, (_, i) => i + 1); // [1..10] + + it("returns the first pageSize items on page 1", () => { + const result = paginateArray(items, { page: 1, pageSize: 3 }); + expect(result.data).toEqual([1, 2, 3]); + expect(result.total).toBe(10); + expect(result.totalPages).toBe(4); + expect(result.hasNext).toBe(true); + expect(result.hasPrev).toBe(false); + }); + + it("returns the last page when page equals totalPages", () => { + const result = paginateArray(items, { page: 4, pageSize: 3 }); + expect(result.data).toEqual([10]); + expect(result.total).toBe(10); + expect(result.totalPages).toBe(4); + expect(result.hasNext).toBe(false); + expect(result.hasPrev).toBe(true); + }); + + it("returns an empty data array for an out-of-range page without throwing", () => { + const result = paginateArray(items, { page: 99, pageSize: 3 }); + expect(result.data).toEqual([]); + expect(result.total).toBe(10); + expect(result.totalPages).toBe(4); + expect(result.hasNext).toBe(false); + expect(result.hasPrev).toBe(true); + }); + + it("handles a single item", () => { + const result = paginateArray([42], { page: 1, pageSize: 5 }); + expect(result.data).toEqual([42]); + expect(result.total).toBe(1); + expect(result.totalPages).toBe(1); + expect(result.hasNext).toBe(false); + expect(result.hasPrev).toBe(false); + }); + + it("handles exactly pageSize items (single page)", () => { + const exact = [1, 2, 3, 4, 5]; + const result = paginateArray(exact, { page: 1, pageSize: 5 }); + expect(result.data).toEqual([1, 2, 3, 4, 5]); + expect(result.total).toBe(5); + expect(result.totalPages).toBe(1); + expect(result.hasNext).toBe(false); + expect(result.hasPrev).toBe(false); + }); + + it("exposes pagination metadata on an intermediate page", () => { + const result = paginateArray(items, { page: 2, pageSize: 4 }); + expect(result.data).toEqual([5, 6, 7, 8]); + expect(result.total).toBe(10); + expect(result.totalPages).toBe(3); + expect(result.hasNext).toBe(true); + expect(result.hasPrev).toBe(true); + }); + + it("throws StellarSplitError with code INVALID_RECIPIENT when pageSize is below 1", () => { + expect(() => paginateArray(items, { page: 1, pageSize: 0 })).toThrow(StellarSplitError); + try { + paginateArray(items, { page: 1, pageSize: 0 }); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(StellarSplitError); + expect((err as StellarSplitError).code).toBe("INVALID_RECIPIENT"); + } + }); + + it("throws StellarSplitError with code INVALID_RECIPIENT when pageSize exceeds 200", () => { + expect(() => paginateArray(items, { page: 1, pageSize: 201 })).toThrow(StellarSplitError); + try { + paginateArray(items, { page: 1, pageSize: 201 }); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(StellarSplitError); + expect((err as StellarSplitError).code).toBe("INVALID_RECIPIENT"); + } + }); + + it("accepts the maximum pageSize of 200", () => { + const big = Array.from({ length: 200 }, (_, i) => i); + const result = paginateArray(big, { page: 1, pageSize: 200 }); + expect(result.data).toHaveLength(200); + expect(result.totalPages).toBe(1); + expect(result.hasNext).toBe(false); + }); + + it("returns an empty page for an empty array", () => { + const result = paginateArray([], { page: 1, pageSize: 10 }); + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + expect(result.totalPages).toBe(1); + expect(result.hasNext).toBe(false); + expect(result.hasPrev).toBe(false); + }); + + it("does not throw for an out-of-range page at or below zero", () => { + const result = paginateArray(items, { page: 0, pageSize: 3 }); + expect(result.data).toEqual([]); + expect(result.hasNext).toBe(false); + expect(result.hasPrev).toBe(false); + }); +}); \ No newline at end of file