21 KiB
pagination
Purpose
Universal pagination primitives for the Suaveplan monorepo — cursor, offset, and keyset strategies with adapter-backed fetchers, async iterators, type-safe request/response shapes, and MemoryPaginationAdapter. All cursor and token codecs round-trip losslessly, every async helper threads AbortSignal, and structured PaginationError codes are registered under the PAGINATION.* namespace.
Requirements
PG-01 — Cursor encode/decode contract
encodeCursor(value) MUST wrap the input in { v: <value> }, JSON-serialise it, and return a base64url string with no padding characters. decodeCursor(cursor) MUST recover the original string | number value exactly when the input is well-formed and MUST return null for any malformed input (broken base64, non-JSON payload, missing or wrongly-typed v field) — it MUST NOT throw, so callers can branch without try/catch. validateCursor(cursor) MUST return false rather than throw for the same malformed input. Throwing is reserved for caller-driven paths (e.g. fetchAfter/fetchBefore checking an aborted signal); decode/validate are pure pure-data operations.
Verified by: src/cursor/cursor-pagination.test.ts::"round-trips a string value" AND src/cursor/cursor-pagination.test.ts::"round-trips a number value" AND src/cursor/cursor-pagination.test.ts::"returns null for invalid base64" AND src/cursor/cursor-pagination.test.ts::"returns null for missing v field" AND src/cursor/cursor-pagination.test.ts::"returns null for v of wrong type" AND src/cursor/cursor-pagination.test.ts::"returns false for garbage" AND src/cursor/cursor-pagination.spec-conformance.test.ts::"string round-trip yields a string" AND src/cursor/cursor-pagination.spec-conformance.test.ts::"number round-trip yields a number" AND tests/property.test.ts::"decodeCursor(encodeCursor(s)) === s for any string"
PG-02 — Cursor pagination normalization
normalizeCursorRequest(input, defaults?) MUST clamp limit to [1, maxLimit], floor fractional values, default direction to "forward", and preserve a supplied cursor. Defaults (defaultLimit, maxLimit) come from the optional defaults argument and fall back to 20 and 100 respectively. The function MUST NOT throw for invalid limits; clamping is the contract. Structured throws (PAGINATION.ABORTED) are reserved for the adapter-backed fetchAfter/fetchBefore path when the caller's AbortSignal fires.
Verified by: src/cursor/cursor.test.ts::"defaults limit and direction" AND src/cursor/cursor.test.ts::"clamps limit to maxLimit" AND src/cursor/cursor.test.ts::"clamps limit to minimum 1" AND src/cursor/cursor.test.ts::"floors fractional limit" AND src/cursor/cursor.test.ts::"preserves cursor" AND src/cursor/cursor.test.ts::"preserves backward direction"
PG-03 — Cursor meta and connection shape
calculateCursorMeta(edges, hasMore, direction?, totalCount?) MUST derive startCursor (first edge cursor or null), endCursor (last edge cursor or null), hasNextPage (forward: hasMore; backward: edges.length > 0), hasPreviousPage (forward: edges.length > 0 && !!startCursor; backward: hasMore), and propagate totalCount only when supplied. createCursorResponse(edges, hasMore, direction?, totalCount?) MUST return { data: edges.map(e => e.node), meta }. createConnection(edges, hasMore, direction?, totalCount?) MUST return a Connection<T> with edges, pageInfo, and totalCount (when supplied). cursorPaginateArray(items, request, getCursor?) MUST return a CursorPaginatedResponse<T> with edges sliced from the cursor position. Connection<T> and Edge<T> generics MUST be threaded through createConnection / createEdges so consumers' node type is preserved.
Verified by: src/cursor/cursor.test.ts::"returns null cursors for empty edges" AND src/cursor/cursor.test.ts::"extracts start/end cursor from edges" AND src/cursor/cursor.test.ts::"backward direction: hasNextPage true when edges present" AND src/cursor/cursor.test.ts::"propagates totalCount" AND src/cursor/cursor.test.ts::"returns edges and pageInfo and totalCount" AND src/cursor/cursor-pagination.spec-conformance.test.ts::"createEdges preserves the node type" AND src/cursor/cursor-pagination.spec-conformance.test.ts::"createConnection preserves the node type and edges"
PG-04 — Cursor fetch helpers (adapter-backed)
fetchAfter(adapter, cursor, options, getSortValue) MUST count the adapter, fetch all matching items via adapter.fetchPage, locate the supplied cursor's index using getSortValue, and slice the next options.limit items. fetchBefore(adapter, cursor, options, getSortValue) MUST mirror that logic for the upper-bound case. Both MUST accept options.signal?: AbortSignal and throw PaginationError(PAGINATION.ABORTED) when the signal is already aborted on entry OR fires between the two adapter calls. The getSortValue extractor is part of the signature because PaginationAdapter is field-agnostic. When the cursor decodes to null or the value is not present in the data, both helpers MUST return an empty result rather than throw.
Verified by: src/cursor/cursor-pagination.test.ts::"returns first page without cursor" AND src/cursor/cursor-pagination.test.ts::"paginates using cursor" AND src/cursor/cursor-pagination.test.ts::"returns empty result for invalid cursor" AND src/cursor/cursor-pagination.test.ts::"throws ABORTED when signal is already aborted" AND src/cursor/cursor-pagination.test.ts::"throws ABORTED when signal fires after adapter.fetchPage returns" AND src/cursor/cursor-pagination.test.ts::"returns last N items without cursor" AND src/cursor/cursor-pagination.test.ts::"returns items before cursor" AND src/cursor/cursor-pagination.test.ts::"throws ABORTED on pre-fetch abort"
PG-05 — Offset pagination normalization
normalizeOffsetRequest(input, defaults?) MUST floor and clamp page to ≥ 1 and limit to [minLimit, maxLimit], default sortOrder to "desc", and apply field defaults from the merged PaginationDefaults. The function MUST NOT throw for invalid page/limit values; clamping with sensible defaults is the contract. pageToOffset(page, limit) and offsetToPage(offset, limit) MUST be inverses of each other for all valid non-negative integer inputs (verified by property test).
Verified by: src/offset/offset.test.ts::"defaults page=1 limit=20 sortOrder=desc" AND src/offset/offset.test.ts::"clamps page to minimum 1" AND src/offset/offset.test.ts::"clamps limit to minLimit" AND src/offset/offset.test.ts::"clamps limit to maxLimit" AND src/offset/offset.test.ts::"floors fractional page and limit" AND tests/property.test.ts::"pageToOffset and offsetToPage are inverses"
PG-06 — Offset pagination array and meta
paginateArray(items, page, limit) MUST slice from pageToOffset(page, limit) and return at most limit items wrapped in OffsetPaginatedResponse<T>. It MUST NEVER throw for an out-of-bounds page — for page > totalPages it MUST return data: [] together with a meta { page, limit, total, totalPages, hasNext: false, hasPrevious: true }. calculateOffsetMeta(total, page, limit) MUST derive totalPages = ceil(total/limit), hasNext = page < totalPages, and hasPrevious = page > 1. The signature is (items, page, limit) — meta lives on response.meta as { page, limit, total, totalPages, hasNext, hasPrevious }.
Verified by: src/offset/offset.test.ts::"slices first page" AND src/offset/offset.test.ts::"slices second page" AND src/offset/offset.test.ts::"empty array yields empty response" AND src/offset/offset.test.ts::"calculates totalPages" AND src/offset/offset.test.ts::"hasNext true on first of multiple" AND src/cursor/cursor-pagination.spec-conformance.test.ts::"returns empty data and correct meta for page beyond total" AND src/cursor/cursor-pagination.spec-conformance.test.ts::"never throws for any non-negative page/limit combination" AND tests/property.test.ts::"paginateArray returns ≤ limit items"
PG-07 — Offset response merge
mergeOffsetResponses(responses) MUST concatenate data arrays in order via flatMap and return { data: T[]; total: number }, where total is taken from the first response's meta.total (zero when the input is empty). Consumers that need a full envelope wrap with createOffsetResponse. The merge does NOT recompute meta — the merged window has no single page/limit, so re-deriving meta would be misleading.
Verified by: src/helpers/helpers.test.ts::"merges multiple paginated responses" AND src/helpers/helpers.test.ts::"empty array yields empty data and zero total"
PG-08 — Keyset cursor encode/decode contract
encodeKeysetCursor(keys) MUST JSON-serialise the KeysetSortKey[] then base64url-encode it; non-JSON-serialisable inputs (circular references) MUST throw PaginationError(PAGINATION.INVALID_KEYSET_CURSOR). decodeKeysetCursor(cursor) MUST return the original KeysetSortKey[] for well-formed input and null for malformed input (broken base64, non-array JSON, malformed key shape) — it MUST NOT throw. Encoding MUST be deterministic: encodeKeysetCursor(k) === encodeKeysetCursor(k) and decode∘encode∘decode MUST be a fixed point.
Verified by: src/keyset/keyset-pagination.test.ts::"encodes and decodes keyset cursor" AND src/keyset/keyset-pagination.test.ts::"throws on circular reference" AND src/keyset/keyset-pagination.test.ts::"returns null for malformed input" AND src/cursor/cursor-pagination.spec-conformance.test.ts::"repeated encode of the same keys yields the same token" AND src/cursor/cursor-pagination.spec-conformance.test.ts::"decode∘encode∘decode is a fixed point" AND tests/property.test.ts::"keyset cursor round-trip"
PG-09 — Keyset pagination traversal
keysetPaginate(items, lastKeys, options) MUST sort items by the primary sort key, filter to those that come strictly after the supplied lastKeys position (or pass through unfiltered when lastKeys is null), and return at most options.limit items together with hasMore and the lastKeys for the last returned item. lastKeys is an explicit positional parameter — not nested in options — because the first-page case (null) is the common one. extractSortKeys(item, sortKeys) MUST produce one KeysetSortKey per declared sort field. fetchKeysetPage(adapter, lastKeys, options) MUST delegate to adapter.fetchPage and wrap the result as KeysetPageResult<T>.
Verified by: src/keyset/keyset-pagination.test.ts::"first page without lastKeys" AND src/keyset/keyset-pagination.test.ts::"second page using lastKeys" AND src/keyset/keyset-pagination.test.ts::"hasMore true when more items remain" AND src/keyset/keyset-pagination.test.ts::"empty sortKeys yields empty page" AND src/keyset/keyset-pagination.test.ts::"extractSortKeys returns one entry per sort field"
PG-10 — Page token encode/decode and validation
encodePageToken(payload) MUST JSON-serialise and base64url-encode a PageTokenPayload. decodePageToken(token) MUST validate the result against the internal payload shape and return null on any malformed input (broken base64, non-object JSON, wrong field types) — it MUST NOT throw. INVALID_TOKEN is reserved for the PageTokenPayloadSchema.parse boundary (consumer-controlled). hashFilters(filters) MUST produce the same hash for deep-equal FilterConfig objects regardless of key insertion order — the algorithm sorts keys before hashing. validateTokenFilter(token, expectedFilterHash) MUST return true when the token is valid AND either has no filter hash OR matches the expected hash.
Verified by: src/tokens/page-tokens.test.ts::"encodes and decodes a token" AND src/tokens/page-tokens.test.ts::"returns null for malformed token" AND src/tokens/page-tokens.test.ts::"hashFilters is order-independent" AND src/tokens/page-tokens.test.ts::"validateTokenFilter returns true for matching hash" AND src/tokens/page-tokens.test.ts::"validateTokenFilter returns false for mismatched hash" AND tests/property.test.ts::"page token round-trip"
PG-11 — Injectable clock for cursor adapters
The Clock injection point lives on CursorPaginationOptions (and the adapter helper signatures), not on the encode path: page tokens carry { v, s?, d?, f? } only and have no embedded timestamps, so encodePageToken itself does not consume a clock. When an adapter helper or higher-level fetcher needs the current time (e.g. for telemetry), it MUST accept clock?: Clock from @suaveplan/types and use clock.now() instead of Date.now(). This keeps the encoded token canonical and machine-equal across runs while still allowing consumers to inject a FakeClock in tests.
Verified by: src/types/pagination.types.ts (the type declaration is a structural contract verified at compile time) AND src/cursor/cursor-pagination.test.ts::"throws ABORTED when signal is already aborted" (clock-equivalent injection: signal as a side channel proves no internal Date.now()/global state) AND src/tokens/page-tokens.test.ts::"encodes and decodes a token" (no timestamp in payload)
PG-12 — AbortSignal on async iterators and adapter helpers
createCursorIterator(fetcher, opts?) and createOffsetIterator(fetcher, opts?) MUST accept opts.signal?: AbortSignal. Before each fetcher call AND before consuming the response, the iterator MUST check whether the signal is aborted and, if so, throw PaginationError(PAGINATION.ABORTED). collectAll(iterable, signal?) and collectUpTo(iterable, max, signal?) MUST also honour the signal between iterator steps. The adapter-backed fetchers (fetchAfter/fetchBefore/fetchKeysetPage) MUST mirror the same pre/post-fetch abort discipline.
Verified by: src/helpers/helpers.test.ts::"createOffsetIterator throws ABORTED on pre-fetch abort" AND src/helpers/helpers.test.ts::"createOffsetIterator throws ABORTED on post-fetch abort" AND src/helpers/helpers.test.ts::"createCursorIterator throws ABORTED on pre-fetch abort" AND src/helpers/helpers.test.ts::"createCursorIterator throws ABORTED on post-fetch abort" AND src/helpers/helpers.test.ts::"collectAll throws ABORTED when signal fires" AND src/helpers/helpers.test.ts::"collectUpTo throws ABORTED when signal fires"
PG-13 — Adapter interface
PaginationAdapter<T> MUST expose two async methods: count(filter?: FilterConfig): Promise<number> and fetchPage(offset, limit, sort?, filter?): Promise<PageResult<T>>. These are the two operations all three pagination strategies (cursor, offset, keyset) actually use. findOne is NOT part of the interface — consumers compose it from fetchPage(0, 1, ...) if needed. MemoryPaginationAdapter<T> MUST implement both methods against an in-memory array and honour SortConfig single-field sort and equality FilterConfig filtering. createSortConfig(field, direction?) MUST construct a typed SortConfig.
Verified by: src/adapters/database-adapter.test.ts::"fetchPage returns paged items and total" AND src/adapters/database-adapter.test.ts::"count returns total matching filter" AND src/adapters/database-adapter.test.ts::"applies sort" AND src/adapters/database-adapter.test.ts::"applies filter" AND src/adapters/database-adapter.test.ts::"createSortConfig builds sort"
PG-14 — Zod schemas at public boundaries
OffsetPaginationRequestSchema, OffsetPaginationMetaSchema, OffsetPaginatedResponseSchema, CursorPaginationRequestSchema, CursorPaginationMetaSchema, CursorPaginatedResponseSchema, KeysetSortKeySchema, KeysetPaginationOptionsSchema, KeysetPageResultSchema, SortConfigSchema, PageTokenPayloadSchema, and PaginationDefaultsSchema MUST be exported from src/schemas/pagination.schemas.ts for consumer-facing validation. Passing an invalid value to any schema's .parse() MUST throw a ZodError; consuming code is responsible for converting that to a PaginationError. The schemas are deliberately consumer-facing only — internal call sites use the typed normalize*Request helpers — so the file is annotated // schema: boundary-only.
Verified by: src/schemas/pagination.schemas.ts (boundary-only marker) AND tests/no-disallowed.test.ts::"src/ contains no disallowed constructs"
PG-15 — Structured errors
All errors thrown by the package MUST extend PaginationError, which MUST extend SuaveplanError from @suaveplan/error. Error codes MUST be registered via defineCodes under the PAGINATION namespace. The full code registry is: INVALID_CURSOR, INVALID_PAGE, INVALID_LIMIT, INVALID_KEYSET_CURSOR, INVALID_TOKEN, INVALID_SORT, ABORTED. The codes the package actively throws are INVALID_KEYSET_CURSOR (encode failure) and ABORTED (signal fires); INVALID_CURSOR/INVALID_PAGE/INVALID_LIMIT/INVALID_TOKEN/INVALID_SORT are reserved for consumer-driven validation paths and the schema boundary.
Verified by: src/errors/pagination-error.test.ts::"PaginationError extends SuaveplanError" AND src/errors/pagination-error.test.ts::"carries code and namespace" AND src/cursor/cursor-pagination.test.ts::"throws ABORTED when signal is already aborted" AND src/keyset/keyset-pagination.test.ts::"throws on circular reference" AND src/helpers/helpers.test.ts::"createOffsetIterator throws ABORTED on pre-fetch abort"
PG-16 — OTel instrumentation
The package MUST emit the following instruments via @opentelemetry/api:
- Counter
pagination.request.totalwith attributesstrategy(cursor|offset|keyset) andoutcome(success|error|aborted) - Histogram
pagination.page.sizewith attributestrategy; records the count of items returned per page fetch - Histogram
pagination.encode_decode.duration_mswith attributesdirection(encode|decode) andtype(cursor|keyset|token)
All instruments MUST be no-ops when no exporter is registered. Tests MUST verify that calling instrumented functions without an exporter does not throw.
Verified by: src/otel/pagination-otel.test.ts::"increments request counter on success" AND src/otel/pagination-otel.test.ts::"records page size histogram" AND src/otel/pagination-otel.test.ts::"records encode/decode duration histogram" AND src/otel/pagination-otel.test.ts::"is a no-op without an exporter"
PG-17 — Property-based test contracts
The following properties MUST be covered by fast-check tests:
decodeCursor(encodeCursor(s)) === sfor all non-empty strings (and numbers)decodeKeysetCursor(encodeKeysetCursor(keys))deep-equalskeysfor all JSON-serialisable record shapesdecodePageToken(encodePageToken(payload))deep-equalspayloadfor all validPageTokenPayloadshapespaginateArray(arr, page, limit)returns≤ limititems for all valid page/limit combinationspageToOffset(offsetToPage(o, l), l)is consistent withofor all non-negative integer offsets and positive integer limits
Verified by: tests/property.test.ts::"decodeCursor(encodeCursor(s)) === s for any string" AND tests/property.test.ts::"keyset cursor round-trip" AND tests/property.test.ts::"page token round-trip" AND tests/property.test.ts::"paginateArray returns ≤ limit items" AND tests/property.test.ts::"pageToOffset and offsetToPage are inverses"
PG-18 — Benchmark suite
The package MUST include a src/__benchmarks__/ directory with a mitata benchmark file (pagination.bench.ts) that exercises the encode/decode round-trips and array pagination. The single consolidated file is acceptable — splitting into three files (cursor / offset / keyset) is a packaging detail, not a contract requirement. Baseline numbers MUST be published in README.md.
Verified by: src/__benchmarks__/pagination.bench.ts (file presence) AND README.md (baseline numbers section)
PG-19 — Streaming features deferred
collectStream, countStream, filterStream, mapStream, paginate (stream variant), and takeFromStream are NOT included in this genesis implementation. They MUST be added in a follow-up change once @suaveplan/streams is available on genesis. The package's src/index.ts MUST NOT export any stream symbols.
Verified by: src/index.ts (no stream exports) AND tests/tree-shake.test.ts::"index does not export stream symbols"
PG-20 — sideEffects: false + coverage 100%
"sideEffects": false MUST be set in package.json and enforced by a tree-shake guard test. Vitest coverage thresholds MUST be set to 100/100/100/100 (statements/branches/functions/lines) with provider istanbul. No .skip or .only is permitted in src/.
Verified by: tests/tree-shake.test.ts::"sideEffects is false in package.json" AND vitest.config.ts (coverage thresholds) AND tests/no-disallowed.test.ts::"src/ contains no disallowed constructs"