11 KiB
Capability Spec Delta: pagination (spec-conformance)
Package: @suaveplan/pagination
Status: PROPOSED
This delta reconciles openspec/specs/pagination/spec.md requirement bodies with the shipped implementation and annotates each with a **Verified by:** line. No public-API changes; no behavioural changes — the impl is correct and was specified ahead of time with wording that drifted from the chosen contract.
MODIFIED 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 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
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::"normalizeCursorRequest" (clamp/floor/default cases)
PG-03 — Cursor meta and connection shape
calculateCursorMeta(edges, hasMore, direction?, totalCount?) MUST derive startCursor, endCursor, hasNextPage, hasPreviousPage from the edges and direction, and propagate totalCount only when supplied. createConnection(edges, hasMore, direction?, totalCount?) MUST return a Connection<T> with edges, pageInfo, and totalCount. 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.
Verified by: src/cursor/cursor.test.ts (calculateCursorMeta and createConnection cases) 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) and fetchBefore(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/previous options.limit items. Both MUST accept options.signal?: AbortSignal and throw PaginationError(PAGINATION.ABORTED) when the signal fires before OR after the fetch. The getSortValue extractor is part of the signature because PaginationAdapter is field-agnostic.
Verified by: src/cursor/cursor-pagination.test.ts (incl. post-fetch abort cases)
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.
Verified by: src/offset/offset.test.ts::"normalizeOffsetRequest" AND tests/property.test.ts
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.
Verified by: src/offset/offset.test.ts AND src/cursor/cursor-pagination.spec-conformance.test.ts::"PG-06 — paginateArray out-of-bounds page" AND tests/property.test.ts
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.
Verified by: src/helpers/helpers.test.ts::"mergeOffsetResponses"
PG-08 — Keyset cursor encode/decode contract
encodeKeysetCursor(keys) MUST JSON-serialise the KeysetSortKey[] then base64url-encode it; non-JSON-serialisable inputs MUST throw PaginationError(PAGINATION.INVALID_KEYSET_CURSOR). decodeKeysetCursor(cursor) MUST return the original KeysetSortKey[] for well-formed input and null for malformed input — it MUST NOT throw. Encoding MUST be deterministic.
Verified by: src/keyset/keyset-pagination.test.ts AND src/cursor/cursor-pagination.spec-conformance.test.ts::"PG-08 — keyset cursor stability" AND tests/property.test.ts
PG-09 — Keyset pagination traversal
keysetPaginate(items, lastKeys, options) MUST sort items by the primary sort key, filter to those after lastKeys (or pass through unfiltered when 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 null case is the common one.
Verified by: src/keyset/keyset-pagination.test.ts
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 — it MUST NOT throw. INVALID_TOKEN is reserved for the PageTokenPayloadSchema.parse boundary. hashFilters(filters) MUST produce the same hash for deep-equal FilterConfig objects regardless of key insertion order.
Verified by: src/tokens/page-tokens.test.ts AND tests/property.test.ts
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.
Verified by: src/types/pagination.types.ts (structural contract) AND src/cursor/cursor-pagination.test.ts
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 and collectUpTo MUST also honour the signal. The adapter-backed fetchers mirror the same pre/post-fetch abort discipline.
Verified by: src/helpers/helpers.test.ts
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>>. findOne is NOT part of the interface; consumers compose it from fetchPage(0, 1, ...) if needed. MemoryPaginationAdapter<T> MUST implement both methods. createSortConfig(field, direction?) MUST construct a typed SortConfig.
Verified by: src/adapters/database-adapter.test.ts
PG-14 — Zod schemas at public boundaries
The schemas exported from src/schemas/pagination.schemas.ts MUST be available for consumer-facing validation. 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
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: INVALID_CURSOR, INVALID_PAGE, INVALID_LIMIT, INVALID_KEYSET_CURSOR, INVALID_TOKEN, INVALID_SORT, ABORTED. Codes the package actively throws are INVALID_KEYSET_CURSOR and ABORTED; the others are reserved for consumer-driven validation paths and the schema boundary.
Verified by: src/errors/pagination-error.test.ts AND signal/abort tests across cursor/keyset/helpers
PG-16 — OTel instrumentation
The package MUST emit Counter pagination.request.total, Histogram pagination.page.size, Histogram pagination.encode_decode.duration_ms. All instruments MUST be no-ops when no exporter is registered.
Verified by: src/otel/pagination-otel.test.ts
PG-17 — Property-based test contracts
Cursor / keyset / page-token round-trips, paginateArray ≤ limit, and pageToOffset/offsetToPage inverse property MUST be covered by fast-check tests.
Verified by: tests/property.test.ts
PG-18 — Benchmark suite
The package MUST include a src/__benchmarks__/ directory with a mitata benchmark file (pagination.bench.ts). The single consolidated file is acceptable. Baseline numbers MUST be published in README.md.
Verified by: src/__benchmarks__/pagination.bench.ts AND README.md
PG-19 — Streaming features deferred
Stream variants are NOT included; src/index.ts MUST NOT export any stream symbols. They are deferred until @suaveplan/streams is available on genesis.
Verified by: src/index.ts (no stream exports)
PG-20 — sideEffects: false + coverage 100%
"sideEffects": false MUST be set in package.json and enforced by a tree-shake guard. Vitest coverage thresholds MUST be 100/100/100/100 with provider istanbul. No .skip or .only is permitted in src/.
Verified by: tests/tree-shake.test.ts AND vitest.config.ts
Newly added tests in this change
src/cursor/cursor-pagination.spec-conformance.test.ts— pins four previously implicit scenarios:- Cursor encode/decode preserves value type (string vs number).
- Keyset cursor stability — idempotent encoding AND
decode∘encode∘decodeis a fixed point. paginateArrayout-of-bounds page returns emptydatawith correct meta and never throws.Connection<T>/Edge<T>generics survivecreateEdges/createConnection(type-level viaexpectTypeOf).