doc/pagination.md
Every list endpoint follows one wire contract. This document is the source of truth for adding pagination to a new or existing list surface.
Requests accept:
| Param | Type | Meaning |
|---|---|---|
limit | number | Maximum items per page. Each endpoint documents its cap and default. |
cursor | string | null | Opaque continuation token. null (or any presence of the key) requests the first page. |
offset | number | Skip N items. Legacy/discouraged — see below. Cannot be combined with cursor. |
includeTotal | boolean | Adds total to the response. |
Paginated responses are an envelope:
{ "items": [...], "cursor": "…", "total": 123 }
cursor is present only while more pages exist. Clients iterate until it
is absent.total is present only when the request set includeTotal.limit — or even to zero — while a
cursor is still returned. Never use items.length < limit as an
end-of-list signal.New request params are camelCase (includeTotal, fetchUntilFull).
Pre-existing snake_case params stay for compatibility.
Requests without pagination params keep returning the full result in the legacy shape (bare array) forever — old clients never break.
The envelope trigger depends on the endpoint's history:
limit pre-dates the convention (readdir, subdomain
select) return the envelope only when the request contains cursor
(including null) or includeTotal; limit/offset-only requests keep
the bare array.list, workers
getFilePaths) return the envelope when any pagination param is present.Cursors are opaque base64-encoded JSON, produced and consumed only by the
backend (src/backend/util/pagination.ts). What a cursor wraps is an
implementation detail per store:
LastEvaluatedKey.(sortValue, id) of the last
row — and the query seeks past it (WHERE (col, id) > (?, ?) ORDER BY col, id). Any SQL list gaining a cursor must have a deterministic ORDER BY
ending in a unique tiebreaker (id).Cursors that carry a sort also pin it: a request that passes a cursor plus a conflicting sort is rejected with 400.
SQL-backed endpoints support offset natively. DynamoDB-backed endpoints
(kv) emulate it by advancing past skipped items with Select: COUNT queries
— no item data is transferred, but read capacity is still consumed for
everything skipped, and the caller is metered for it. Offset is supported
for parity, not recommended — cost grows linearly with the offset, so it is
capped (kv: 5000). Use cursors.
includeTotal)SELECT COUNT(*) with the same WHERE clause as the listing.Select: COUNT loop over the query (TTL-filtered), metered to
the caller. Cost is proportional to the total item count — request the
total on the first page only, not every page.total approximates the visible set: it counts
non-protected plus caller-owned rows, and misses rows visible only through
explicit permission grants.DynamoDB applies Limit before its filter expression, so TTL-filtered pages
are structurally short. fetchUntilFull: true makes the backend keep
fetching (bounded number of continuation queries) until the page holds
limit items or the keyset is exhausted. Requires limit. If the bound is
hit, the response simply carries a cursor — still convention-legal.
encodeCursor/decodeCursor/normalizeLimit/normalizeOffset from
src/backend/util/pagination.ts.limit + 1 rows to detect whether a next page exists (SQL), or use
LastEvaluatedKey (DynamoDB).