Files
suaveplan/openspec/archive/2026-04-19-enterprise-pagination/specs/pagination/spec.md
2026-05-27 23:29:53 +01:00

108 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/`.