# 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` with both fields populated. `cursorPaginateArray(array, opts)` MUST return a `CursorPaginatedResponse` 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` 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`. ### 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`. ### 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` MUST expose three async methods: `count(opts): Promise`, `find(opts): Promise`, `findOne(opts): Promise`. The interface MUST be generic over `T` and carry no implementation. `MemoryPaginationAdapter` 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(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/`.