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
12 changes: 12 additions & 0 deletions backend/src/pagingCursorBuilder-alias.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";

import defaultBuilder, {
pagingCursorBuilder,
buildPagingParams,
} from "./pagingCursorBuilder.js";

test("default export and named alias are identical to buildPagingParams", () => {
assert.equal(defaultBuilder, buildPagingParams);
assert.equal(pagingCursorBuilder, buildPagingParams);
});
28 changes: 28 additions & 0 deletions backend/src/pagingCursorBuilder-empty.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";

import { buildPagingParams, DEFAULT_PAGE_LIMIT } from "./pagingCursorBuilder.js";

test("returns empty defaults when records array is empty", () => {
const result = buildPagingParams([]);
assert.deepEqual(result, {
cursor: null,
order: "asc",
limit: DEFAULT_PAGE_LIMIT,
hasMore: false,
});
});

test("returns fallbackCursor when records array is empty and fallbackCursor is supplied", () => {
const result = buildPagingParams([], {
fallbackCursor: "now",
limit: 25,
order: "desc",
});
assert.deepEqual(result, {
cursor: "now",
order: "desc",
limit: 25,
hasMore: false,
});
});
27 changes: 27 additions & 0 deletions backend/src/pagingCursorBuilder-extract.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";

import { extractPagingToken } from "./pagingCursorBuilder.js";

test("extracts token from paging_token property", () => {
assert.equal(extractPagingToken({ paging_token: "12345" }), "12345");
});

test("extracts token from pagingToken camelCase property", () => {
assert.equal(extractPagingToken({ pagingToken: "98765" }), "98765");
});

test("extracts token from cursor property", () => {
assert.equal(extractPagingToken({ cursor: "cursor_xyz" }), "cursor_xyz");
});

test("returns null if no token property is found", () => {
assert.equal(extractPagingToken({}), null);
assert.equal(extractPagingToken({ paging_token: null }), null);
});

test("throws TypeError when record is not an object", () => {
assert.throws(() => extractPagingToken(null), TypeError);
assert.throws(() => extractPagingToken("invalid"), TypeError);
assert.throws(() => extractPagingToken(123), TypeError);
});
33 changes: 33 additions & 0 deletions backend/src/pagingCursorBuilder-happy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";

import { buildPagingParams } from "./pagingCursorBuilder.js";

test("extracts cursor from last record with paging_token", () => {
const records = [
{ id: "1", paging_token: "100" },
{ id: "2", paging_token: "200" },
{ id: "3", paging_token: "300" },
];
const result = buildPagingParams(records, { limit: 10, order: "asc" });
assert.deepEqual(result, {
cursor: "300",
order: "asc",
limit: 10,
hasMore: false,
});
});

test("sets hasMore to true when records length reaches or exceeds limit", () => {
const records = [
{ id: "1", paging_token: "10" },
{ id: "2", paging_token: "20" },
];
const result = buildPagingParams(records, { limit: 2, order: "desc" });
assert.deepEqual(result, {
cursor: "20",
order: "desc",
limit: 2,
hasMore: true,
});
});
49 changes: 49 additions & 0 deletions backend/src/pagingCursorBuilder-limit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";

import {
buildPagingParams,
MIN_PAGE_LIMIT,
MAX_PAGE_LIMIT,
} from "./pagingCursorBuilder.js";

test("allows boundary limit values MIN_PAGE_LIMIT and MAX_PAGE_LIMIT", () => {
const minResult = buildPagingParams([], { limit: MIN_PAGE_LIMIT });
assert.equal(minResult.limit, 1);

const maxResult = buildPagingParams([], { limit: MAX_PAGE_LIMIT });
assert.equal(maxResult.limit, 200);
});

test("throws RangeError when limit is less than MIN_PAGE_LIMIT", () => {
assert.throws(
() => buildPagingParams([], { limit: 0 }),
RangeError,
);
assert.throws(
() => buildPagingParams([], { limit: -10 }),
RangeError,
);
});

test("throws RangeError when limit is greater than MAX_PAGE_LIMIT", () => {
assert.throws(
() => buildPagingParams([], { limit: 201 }),
RangeError,
);
});

test("throws TypeError when limit is not an integer", () => {
assert.throws(
() => buildPagingParams([], { limit: "10" }),
TypeError,
);
assert.throws(
() => buildPagingParams([], { limit: 10.5 }),
TypeError,
);
assert.throws(
() => buildPagingParams([], { limit: NaN }),
TypeError,
);
});
22 changes: 22 additions & 0 deletions backend/src/pagingCursorBuilder-order.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";

import { buildPagingParams } from "./pagingCursorBuilder.js";

test("supports asc and desc in lowercase and uppercase", () => {
assert.equal(buildPagingParams([], { order: "asc" }).order, "asc");
assert.equal(buildPagingParams([], { order: "desc" }).order, "desc");
assert.equal(buildPagingParams([], { order: "ASC" }).order, "asc");
assert.equal(buildPagingParams([], { order: "DESC" }).order, "desc");
});

test("throws TypeError on unsupported order values", () => {
assert.throws(
() => buildPagingParams([], { order: "invalid" }),
TypeError,
);
assert.throws(
() => buildPagingParams([], { order: 123 }),
TypeError,
);
});
15 changes: 15 additions & 0 deletions backend/src/pagingCursorBuilder-records.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";

import { buildPagingParams } from "./pagingCursorBuilder.js";

test("returns null cursor if last record contains no recognized paging token", () => {
const records = [{ id: "1" }, { id: "2", other: "data" }];
const res = buildPagingParams(records);
assert.equal(res.cursor, null);
});

test("throws TypeError when last record in non-empty array is not an object", () => {
assert.throws(() => buildPagingParams(["not-an-object"]), TypeError);
assert.throws(() => buildPagingParams([null]), TypeError);
});
12 changes: 12 additions & 0 deletions backend/src/pagingCursorBuilder-types.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";

import { buildPagingParams } from "./pagingCursorBuilder.js";

test("throws TypeError when records is not an array", () => {
assert.throws(() => buildPagingParams(null), TypeError);
assert.throws(() => buildPagingParams(undefined), TypeError);
assert.throws(() => buildPagingParams("records"), TypeError);
assert.throws(() => buildPagingParams(12345), TypeError);
assert.throws(() => buildPagingParams({}), TypeError);
});
108 changes: 108 additions & 0 deletions backend/src/pagingCursorBuilder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Horizon paging cursor builder.
*
* Builds the `{ cursor, order, limit }` query parameters for the next page of a
* Horizon collection request from the last record's `paging_token`, with sane
* defaults when the input array is empty.
*/

/**
* Default pagination limit.
*/
export const DEFAULT_PAGE_LIMIT = 10;

/**
* Maximum page limit permitted by Horizon API.
*/
export const MAX_PAGE_LIMIT = 200;

/**
* Minimum page limit permitted by Horizon API.
*/
export const MIN_PAGE_LIMIT = 1;

/**
* Allowed sort orders.
*/
export const VALID_ORDERS = new Set(["asc", "desc"]);

/**
* Extract paging token from a record.
*
* @param {object} record
* @returns {string|null}
*/
export function extractPagingToken(record) {
if (!record || typeof record !== "object") {
throw new TypeError("each record must be an object");
}
const token = record.paging_token ?? record.pagingToken ?? record.cursor;
return token == null ? null : String(token).trim();
}

/**
* Build Horizon query parameters for collection paging.
*
* @param {unknown} records Horizon collection records array
* @param {{
* limit?: number,
* order?: "asc" | "desc",
* fallbackCursor?: string | null
* }} [options={}]
* @returns {{
* cursor: string | null,
* order: "asc" | "desc",
* limit: number,
* hasMore: boolean
* }}
* @throws {TypeError} When records is not an array or options are invalid
* @throws {RangeError} When limit is out of [1, 200]
*/
export function buildPagingParams(records, options = {}) {
if (!Array.isArray(records)) {
throw new TypeError("records must be an array");
}

// Validate limit
let limit = options?.limit ?? DEFAULT_PAGE_LIMIT;
if (typeof limit !== "number" || !Number.isInteger(limit)) {
throw new TypeError("limit must be an integer");
}
if (limit < MIN_PAGE_LIMIT || limit > MAX_PAGE_LIMIT) {
throw new RangeError(`limit must be between ${MIN_PAGE_LIMIT} and ${MAX_PAGE_LIMIT}`);
}

// Validate order
let order = (options?.order ?? "asc").toLowerCase();
if (!VALID_ORDERS.has(order)) {
throw new TypeError('order must be "asc" or "desc"');
}

if (records.length === 0) {
const fallbackCursor = options?.fallbackCursor ?? null;
return {
cursor: fallbackCursor == null ? null : String(fallbackCursor),
order,
limit,
hasMore: false,
};
}

// Read last record
const lastRecord = records[records.length - 1];
const cursor = extractPagingToken(lastRecord);

return {
cursor,
order,
limit,
hasMore: records.length >= limit,
};
}

/**
* Alias matching the module name.
*/
export const pagingCursorBuilder = buildPagingParams;

export default buildPagingParams;
47 changes: 47 additions & 0 deletions docs/features/pagingCursorBuilder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Horizon paging cursor builder

`backend/src/pagingCursorBuilder.js` extracts cursor information from Horizon collection response records and builds next-page request parameters (`cursor`, `order`, `limit`, `hasMore`).

## What it does

- Extracts `paging_token`, `pagingToken`, or `cursor` property from the trailing record in a collections result set.
- Validates the requested page limit within the permitted Horizon range `[1, 200]`.
- Enforces valid ordering (`asc` or `desc`).
- Signals `hasMore: true` when record count matches or exceeds the page limit.
- Gracefully handles empty results with configurable fallback cursor.

## Public API

### `buildPagingParams(records, options)`

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `records` | `Array<object>` | — | Array of Horizon collection records |
| `options.limit` | `number` | `10` | Number of items requested per page (between 1 and 200) |
| `options.order` | `string` | `"asc"` | Ordering: `"asc"` or `"desc"` |
| `options.fallbackCursor` | `string \| null` | `null` | Cursor value to return when `records` is empty |

### Return Value

```js
{
cursor: string | null,
order: "asc" | "desc",
limit: number,
hasMore: boolean
}
```

### Examples

```js
import { buildPagingParams } from "./pagingCursorBuilder.js";

const records = [
{ id: "1", paging_token: "100" },
{ id: "2", paging_token: "200" },
];

const params = buildPagingParams(records, { limit: 10, order: "asc" });
// => { cursor: "200", order: "asc", limit: 10, hasMore: false }
```