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
52 changes: 52 additions & 0 deletions src/horizonPaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -95,3 +96,54 @@ export async function collectAll<T>(
}
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<T>(
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,
};
}
19 changes: 18 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";
27 changes: 27 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
32 changes: 32 additions & 0 deletions test/formatAddress.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
108 changes: 108 additions & 0 deletions test/paginateArray.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});