8.7 KiB
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.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.
PG-17 — Property-based test contracts
The following properties MUST be covered by fast-check tests:
decodeCursor(encodeCursor(s)) === sfor all non-empty stringsdecodeKeysetCursor(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) === ofor 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+decodeCursorround-trip × 10 000 iterationsoffset-paginate.bench.ts—paginateArrayon a 10 000-item array × 10 000 iterationskeyset-encode-decode.bench.ts—encodeKeysetCursor+decodeKeysetCursorround-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/.