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) #

0.6.20

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) #

0.6.20

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) #

0.6.20
{
  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) #

0.6.20
{
  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.