Files
2026-05-27 23:29:53 +01:00

8.7 KiB
Raw Permalink Blame History

Capability Spec: Pagination

Package: @suaveplan/pagination Version: 0.0.0 (genesis) Status: ADDED


ADDED Requirements

PG-01 — Cursor encode/decode contract

encodeCursor(value) MUST produce a base64url string with no padding characters. decodeCursor(cursor) MUST recover the original string exactly. A malformed cursor MUST throw PaginationError with code PAGINATION.INVALID_CURSOR. validateCursor(cursor) MUST return false rather than throw for the same malformed input.

PG-02 — Cursor pagination normalization

normalizeCursorRequest(request) MUST apply default limit when none is supplied, clamp limit to the configured maximum, validate direction is one of 'forward' | 'backward', and throw PaginationError with code PAGINATION.INVALID_LIMIT if the supplied limit is ≤ 0 or exceeds the maximum. Output MUST conform to CursorPaginationOptions.

PG-03 — Cursor meta and connection shape

calculateCursorMeta(opts) MUST derive hasNextPage, hasPreviousPage, startCursor, and endCursor from the provided edges and fetch options. createConnection(edges, meta) MUST return a Connection<T> with both fields populated. cursorPaginateArray(array, opts) MUST return a CursorPaginatedResponse<T> that includes pageInfo and edges.

PG-04 — Cursor fetch helpers (adapter-backed)

fetchAfter(adapter, cursor, opts) MUST call adapter.find with the decoded cursor as the lower bound and return a CursorPageResult<T> with edges, pageInfo, and meta. fetchBefore(adapter, cursor, opts) MUST use the decoded cursor as the upper bound and reverse the result order so consumers always receive items in logical sort order.

PG-05 — Offset pagination normalization

normalizeOffsetRequest(request) MUST throw PaginationError with code PAGINATION.INVALID_PAGE if page < 1 or is non-integer, and PAGINATION.INVALID_LIMIT if limit ≤ 0. It MUST apply default values from PaginationDefaults when fields are absent. pageToOffset(page, limit) and offsetToPage(offset, limit) MUST be inverses of each other for all valid non-negative integer inputs.

PG-06 — Offset pagination array and meta

paginateArray(array, opts) MUST return exactly min(limit, remaining) items starting from pageToOffset(page, limit). It MUST never throw for an out-of-bounds page — it MUST return an empty data array and a correct meta indicating no items. calculateOffsetMeta(opts) MUST derive totalPages, currentPage, nextPage, previousPage, hasNextPage, and hasPreviousPage.

PG-07 — Offset response merge

mergeOffsetResponses(responses) MUST concatenate data arrays in order and recompute meta as if the merged result were a single page starting at offset 0. The output MUST be a valid OffsetPaginatedResponse<T>.

PG-08 — Keyset cursor encode/decode contract

encodeKeysetCursor(keys) MUST JSON-serialise the record then base64url-encode it. Non-JSON-serialisable values (e.g. undefined, circular references) MUST throw PaginationError with code PAGINATION.INVALID_KEYSET_CURSOR. decodeKeysetCursor(cursor) MUST recover the original record exactly; a malformed base64 or invalid JSON MUST throw PAGINATION.INVALID_KEYSET_CURSOR.

PG-09 — Keyset pagination traversal

keysetPaginate(array, opts) MUST use opts.cursor as a non-inclusive lower bound (for forward traversal) or upper bound (for backward traversal) and return at most opts.limit items. extractSortKeys(item, config) MUST return KeysetSortKey[] matching config.fields in the declared order. fetchKeysetPage(adapter, opts) MUST delegate to adapter.find with keyset bounds and wrap the result as a KeysetPageResult<T>.

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 PageTokenPayloadSchema; invalid structure MUST throw PAGINATION.INVALID_TOKEN. hashFilters(filters) MUST produce the same hash for deep-equal FilterConfig objects regardless of key insertion order. validateTokenFilter(token, filters) MUST return true only when the token's stored hash matches hashFilters(filters).

PG-11 — Injectable clock for token timestamps

encodePageToken and createCursorToken / createOffsetToken MUST accept an optional clock?: Clock from @suaveplan/types. When supplied, clock.now() MUST be used instead of Date.now() for any timestamp embedded in the token payload.

PG-12 — AbortSignal on async iterators

createCursorIterator(fetchFn, opts) and createOffsetIterator(fetchFn, opts) MUST accept opts.signal?: AbortSignal. Before each fetchFn call, the iterator MUST check whether the signal is already aborted and, if so, throw PaginationError with code PAGINATION.ABORTED without calling fetchFn. Abort during an in-flight fetchFn MUST propagate naturally (the promise rejects). collectAll and collectUpTo MUST forward the signal to the underlying iterator.

PG-13 — Adapter interface

PaginationAdapter<T> MUST expose three async methods: count(opts): Promise<number>, find(opts): Promise<T[]>, findOne(opts): Promise<T | null>. The interface MUST be generic over T and carry no implementation. MemoryPaginationAdapter<T> MUST implement all three methods against an in-memory array and honour SortConfig multi-field sort, cursor slicing, keyset slicing, and offset slicing in find. createSortConfig<T>(fields) MUST validate that all field names exist on T at the type level.

PG-14 — Zod schemas at public boundaries

CursorPaginationRequestSchema, OffsetPaginationRequestSchema, KeysetPaginationOptionsSchema, and PageTokenPayloadSchema MUST be exported from their respective *.schemas.ts files. Passing an invalid value to any schema's .parse() MUST throw a ZodError; consuming code is responsible for converting that to a PaginationError.

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: PAGINATION.INVALID_CURSOR, PAGINATION.INVALID_PAGE, PAGINATION.INVALID_LIMIT, PAGINATION.INVALID_KEYSET_CURSOR, PAGINATION.INVALID_TOKEN, PAGINATION.INVALID_SORT, PAGINATION.ABORTED.

PG-16 — OTel instrumentation

The package MUST emit the following instruments via @opentelemetry/api:

  • Counter pagination.request.total with attributes strategy (cursor | offset | keyset) and outcome (success | error | aborted)
  • Histogram pagination.page.size with attribute strategy; records the count of items returned per page fetch
  • Histogram pagination.encode_decode.duration_ms with attributes direction (encode | decode) and type (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.

PG-17 — Property-based test contracts

The following properties MUST be covered by fast-check tests:

  • decodeCursor(encodeCursor(s)) === s for all non-empty strings
  • decodeKeysetCursor(encodeKeysetCursor(keys)) deep-equals keys for all JSON-serialisable record shapes
  • decodePageToken(encodePageToken(payload)) deep-equals payload for all valid PageTokenPayload shapes
  • paginateArray(arr, { page, limit }) returns ≤ limit items for all valid page/limit combinations
  • pageToOffset(offsetToPage(o, l), l) === o for all non-negative integer offsets and positive integer limits

PG-18 — Benchmark suite

The package MUST include a src/__benchmarks__/ directory with mitata benchmarks:

  • cursor-encode-decode.bench.ts — encodeCursor + decodeCursor round-trip × 10 000 iterations
  • offset-paginate.bench.ts — paginateArray on a 10 000-item array × 10 000 iterations
  • keyset-encode-decode.bench.ts — encodeKeysetCursor + decodeKeysetCursor round-trip × 10 000 iterations

Baseline numbers MUST be published in README.md.

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.

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/.