Initial Commit
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# 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/`.
|
||||
Reference in New Issue
Block a user