Pagination
Cursor-based pagination — opaque tokens that encode the last-row sort key + direction, resilient to inserts and deletes between pages.
Every CRUD list endpoint reinvents pagination, usually wrong. The two failure modes: offset pagination at depth (LIMIT n OFFSET 50000 scan-and-skips 50,000 rows; concurrent writes shift the offset and rows get missed or duplicated), and cursor pagination without a tie-breaker (WHERE createdAt > ? skips or duplicates rows when two records share createdAt).
This module ships both done correctly. cursor() uses composite (orderBy, _id) ordering — _id is the implicit tie-breaker, so two rows with identical orderByVal are still totally ordered. Forward navigation: WHERE (orderByVal > ?) OR (orderByVal = ? AND _id > ?). Backward: same with <, then reverse the result set.
Cursors are HMAC-tagged with operator-supplied secret. A tampered cursor is detected at decode time and rejected with PaginationError. Cursor format: , state is canonical JSON of { v, orderKey, vals, forward }, tag is SHA3-512(secret || stateJson).slice(0, 16). Direction is part of the cursor — operators don't round-trip it via query string. Multi-column ordering accepted: a string, an array of strings, or [{ column, direction }, ...]; _id is appended as a tiebreaker if not already in the chain.
offset() is the legacy-client tool, not the recommended path. It returns total (from COUNT(*)) and computes totalPages so legacy clients can render numbered nav.
Cursor TTL / expiry is operator-side: embed a timestamp in your own state and check at decode-time before passing to .cursor(). The framework's HMAC tag carries no notion of time. Search / filter integration composes — chain .where() on the Query before handing to .cursor().
b.pagination.encodeCursor(state, secret) #
Low-level cursor encoder for raw-SQL or custom row-source paths. Wraps state with the framework version field, canonicalises via the shared canonical-JSON walker, then computes the SHA3-512 HMAC tag and emits . State is any plain-data object — Buffer / Map / Set / RegExp / functions / circular references are rejected loudly. secret is a Buffer or non-empty string; an empty secret throws.
var token = b.pagination.encodeCursor(
{ orderKey: ["createdAt:asc", "_id:asc"], vals: [1700000000000, "u-42"], forward: true },
"page-secret"
);
// token is `.`, ready to round-trip via query string.
var state = b.pagination.decodeCursor(token, "page-secret");
state.forward; // → true
b.pagination.decodeCursor(token, secret) #
Inverse of encodeCursor. Splits on the . separator, base64url- decodes both halves, recomputes the HMAC tag against secret and compares with b.crypto.timingSafeEqual. On mismatch (tamper or wrong secret) throws PaginationError("pagination/cursor-tag- mismatch"). State JSON is parsed via b.safeJson.parse with a 8-KiB byte cap. The framework version field (v) must match the current CURSOR_VERSION; older cursors throw pagination/cursor- version so operators can detect rolling-deploy mismatches.
try {
var state = b.pagination.decodeCursor(req.query.cursor, "page-secret");
state.vals; // → [1700000000000, "u-42"]
state.forward; // → true
} catch (e) {
// PaginationError — tamper, wrong secret, or stale cursor version.
res.statusCode = 400;
res.end("invalid cursor");
}
b.pagination.cursor(query, opts) #
{
cursor: string, // opaque token from a previous response (omit for first page)
limit: number, // requested page size; clamped to opts.max, defaults to opts.default
max: number, // hard cap on limit (defaults to 100)
default: number, // limit when none requested (defaults to 25)
orderBy: string|array, // column name, ["a","b"], or [{column,direction}]
direction: "asc"|"desc", // default direction applied to string/array forms
secret: Buffer|string, // REQUIRED — HMAC key for cursor tag
forward: boolean, // override cursor's encoded direction (rare)
}
Cursor pagination over a b.db.from(...) Query. O(1) at any depth. Builds the keyset WHERE from the previous page's column values, applies the operator's orderBy chain (with _id appended as tiebreaker), fetches limit + 1 rows to detect hasMore without a second COUNT(*), and returns { items, nextCursor, prevCursor, limit, hasMore }. Cursors round-trip via opaque base64url strings — operators don't pick apart the encoded state.
Operators MUST pass opts.secret (Buffer or non-empty string) for HMAC tagging. There's no auto-derivation — framework-derived secrets would surprise across deploys.
var page = await b.pagination.cursor(b.db.from("users"), {
cursor: req.query.cursor,
limit: parseInt(req.query.limit, 10),
max: 100,
default: 25,
orderBy: "createdAt",
direction: "desc",
secret: "page-secret",
});
page.items; // → array of rows (length <= limit)
page.nextCursor; // → string token, or null when there's no next page
page.hasMore; // → true when more rows exist beyond this page
// Multi-column ordering with mixed directions:
var mixed = await b.pagination.cursor(b.db.from("orders"), {
orderBy: [{ column: "priority", direction: "desc" }, { column: "createdAt", direction: "asc" }],
secret: "page-secret",
});
b.pagination.offset(query, opts) #
{
page: number, // 1-based page number (defaults to 1; non-integer coerces to 1)
perPage: number, // rows per page (clamped to opts.max, defaults to opts.default)
max: number, // hard cap on perPage (defaults to 100)
default: number, // perPage when none requested (defaults to 25)
orderBy: string, // column name (defaults to "_id"); identifier-validated against safeSql
direction: "asc"|"desc", // sort direction (defaults to "asc")
}
Offset pagination — page-numbered, ergonomic for legacy clients that render numbered nav. Issues COUNT(*) to compute total and totalPages. Use cursor() for new endpoints; offset() is only the right shape when the consumer's UI already binds to page numbers. Re-applies the operator's where() chain unmodified, then adds ORDER BY orderBy direction LIMIT perPage OFFSET (page-1)*perPage.
var page = await b.pagination.offset(b.db.from("users"), {
page: parseInt(req.query.page, 10),
perPage: parseInt(req.query.perPage, 10),
max: 100,
default: 25,
orderBy: "createdAt",
direction: "desc",
});
page.total; // → e.g. 1284
page.totalPages; // → e.g. 52 (when perPage=25)
page.hasMore; // → true when page < totalPages
Last updated 2026-08-08T16:39:49.652Z by seeder.