140 lines
21 KiB
Markdown
140 lines
21 KiB
Markdown
# pagination
|
|
|
|
## Purpose
|
|
|
|
Universal pagination primitives for the Suaveplan monorepo — cursor, offset, and keyset strategies with adapter-backed fetchers, async iterators, type-safe request/response shapes, and `MemoryPaginationAdapter`. All cursor and token codecs round-trip losslessly, every async helper threads `AbortSignal`, and structured `PaginationError` codes are registered under the `PAGINATION.*` namespace.
|
|
|
|
## 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::"round-trips a string value"` AND `src/cursor/cursor-pagination.test.ts::"round-trips a number value"` AND `src/cursor/cursor-pagination.test.ts::"returns null for invalid base64"` AND `src/cursor/cursor-pagination.test.ts::"returns null for missing v field"` AND `src/cursor/cursor-pagination.test.ts::"returns null for v of wrong type"` AND `src/cursor/cursor-pagination.test.ts::"returns false for garbage"` 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::"decodeCursor(encodeCursor(s)) === s for any string"`
|
|
|
|
### 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::"defaults limit and direction"` AND `src/cursor/cursor.test.ts::"clamps limit to maxLimit"` AND `src/cursor/cursor.test.ts::"clamps limit to minimum 1"` AND `src/cursor/cursor.test.ts::"floors fractional limit"` AND `src/cursor/cursor.test.ts::"preserves cursor"` AND `src/cursor/cursor.test.ts::"preserves backward direction"`
|
|
|
|
### PG-03 — Cursor meta and connection shape
|
|
|
|
`calculateCursorMeta(edges, hasMore, direction?, totalCount?)` MUST derive `startCursor` (first edge cursor or `null`), `endCursor` (last edge cursor or `null`), `hasNextPage` (forward: `hasMore`; backward: `edges.length > 0`), `hasPreviousPage` (forward: `edges.length > 0 && !!startCursor`; backward: `hasMore`), and propagate `totalCount` only when supplied. `createCursorResponse(edges, hasMore, direction?, totalCount?)` MUST return `{ data: edges.map(e => e.node), meta }`. `createConnection(edges, hasMore, direction?, totalCount?)` MUST return a `Connection<T>` with `edges`, `pageInfo`, and `totalCount` (when supplied). `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` so consumers' node type is preserved.
|
|
|
|
**Verified by:** `src/cursor/cursor.test.ts::"returns null cursors for empty edges"` AND `src/cursor/cursor.test.ts::"extracts start/end cursor from edges"` AND `src/cursor/cursor.test.ts::"backward direction: hasNextPage true when edges present"` AND `src/cursor/cursor.test.ts::"propagates totalCount"` AND `src/cursor/cursor.test.ts::"returns edges and pageInfo and totalCount"` 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)` MUST count the adapter, fetch all matching items via `adapter.fetchPage`, locate the supplied cursor's index using `getSortValue`, and slice the next `options.limit` items. `fetchBefore(adapter, cursor, options, getSortValue)` MUST mirror that logic for the upper-bound case. Both MUST accept `options.signal?: AbortSignal` and throw `PaginationError(PAGINATION.ABORTED)` when the signal is already aborted on entry OR fires between the two adapter calls. The `getSortValue` extractor is part of the signature because `PaginationAdapter` is field-agnostic. When the cursor decodes to `null` or the value is not present in the data, both helpers MUST return an empty result rather than throw.
|
|
|
|
**Verified by:** `src/cursor/cursor-pagination.test.ts::"returns first page without cursor"` AND `src/cursor/cursor-pagination.test.ts::"paginates using cursor"` AND `src/cursor/cursor-pagination.test.ts::"returns empty result for invalid cursor"` AND `src/cursor/cursor-pagination.test.ts::"throws ABORTED when signal is already aborted"` AND `src/cursor/cursor-pagination.test.ts::"throws ABORTED when signal fires after adapter.fetchPage returns"` AND `src/cursor/cursor-pagination.test.ts::"returns last N items without cursor"` AND `src/cursor/cursor-pagination.test.ts::"returns items before cursor"` AND `src/cursor/cursor-pagination.test.ts::"throws ABORTED on pre-fetch abort"`
|
|
|
|
### 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 of each other for all valid non-negative integer inputs (verified by property test).
|
|
|
|
**Verified by:** `src/offset/offset.test.ts::"defaults page=1 limit=20 sortOrder=desc"` AND `src/offset/offset.test.ts::"clamps page to minimum 1"` AND `src/offset/offset.test.ts::"clamps limit to minLimit"` AND `src/offset/offset.test.ts::"clamps limit to maxLimit"` AND `src/offset/offset.test.ts::"floors fractional page and limit"` AND `tests/property.test.ts::"pageToOffset and offsetToPage are inverses"`
|
|
|
|
### 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`. The signature is `(items, page, limit)` — meta lives on `response.meta` as `{ page, limit, total, totalPages, hasNext, hasPrevious }`.
|
|
|
|
**Verified by:** `src/offset/offset.test.ts::"slices first page"` AND `src/offset/offset.test.ts::"slices second page"` AND `src/offset/offset.test.ts::"empty array yields empty response"` AND `src/offset/offset.test.ts::"calculates totalPages"` AND `src/offset/offset.test.ts::"hasNext true on first of multiple"` AND `src/cursor/cursor-pagination.spec-conformance.test.ts::"returns empty data and correct meta for page beyond total"` AND `src/cursor/cursor-pagination.spec-conformance.test.ts::"never throws for any non-negative page/limit combination"` AND `tests/property.test.ts::"paginateArray returns ≤ limit items"`
|
|
|
|
### 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`. The merge does NOT recompute `meta` — the merged window has no single `page`/`limit`, so re-deriving meta would be misleading.
|
|
|
|
**Verified by:** `src/helpers/helpers.test.ts::"merges multiple paginated responses"` AND `src/helpers/helpers.test.ts::"empty array yields empty data and zero total"`
|
|
|
|
### PG-08 — Keyset cursor encode/decode contract
|
|
|
|
`encodeKeysetCursor(keys)` MUST JSON-serialise the `KeysetSortKey[]` then base64url-encode it; non-JSON-serialisable inputs (circular references) MUST throw `PaginationError(PAGINATION.INVALID_KEYSET_CURSOR)`. `decodeKeysetCursor(cursor)` MUST return the original `KeysetSortKey[]` for well-formed input and `null` for malformed input (broken base64, non-array JSON, malformed key shape) — it MUST NOT throw. Encoding MUST be deterministic: `encodeKeysetCursor(k) === encodeKeysetCursor(k)` and `decode∘encode∘decode` MUST be a fixed point.
|
|
|
|
**Verified by:** `src/keyset/keyset-pagination.test.ts::"encodes and decodes keyset cursor"` AND `src/keyset/keyset-pagination.test.ts::"throws on circular reference"` AND `src/keyset/keyset-pagination.test.ts::"returns null for malformed input"` AND `src/cursor/cursor-pagination.spec-conformance.test.ts::"repeated encode of the same keys yields the same token"` AND `src/cursor/cursor-pagination.spec-conformance.test.ts::"decode∘encode∘decode is a fixed point"` AND `tests/property.test.ts::"keyset cursor round-trip"`
|
|
|
|
### PG-09 — Keyset pagination traversal
|
|
|
|
`keysetPaginate(items, lastKeys, options)` MUST sort items by the primary sort key, filter to those that come strictly after the supplied `lastKeys` position (or pass through unfiltered when `lastKeys` is `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 case (`null`) is the common one. `extractSortKeys(item, sortKeys)` MUST produce one `KeysetSortKey` per declared sort field. `fetchKeysetPage(adapter, lastKeys, options)` MUST delegate to `adapter.fetchPage` and wrap the result as `KeysetPageResult<T>`.
|
|
|
|
**Verified by:** `src/keyset/keyset-pagination.test.ts::"first page without lastKeys"` AND `src/keyset/keyset-pagination.test.ts::"second page using lastKeys"` AND `src/keyset/keyset-pagination.test.ts::"hasMore true when more items remain"` AND `src/keyset/keyset-pagination.test.ts::"empty sortKeys yields empty page"` AND `src/keyset/keyset-pagination.test.ts::"extractSortKeys returns one entry per sort field"`
|
|
|
|
### 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 (broken base64, non-object JSON, wrong field types) — it MUST NOT throw. `INVALID_TOKEN` is reserved for the `PageTokenPayloadSchema.parse` boundary (consumer-controlled). `hashFilters(filters)` MUST produce the same hash for deep-equal `FilterConfig` objects regardless of key insertion order — the algorithm sorts keys before hashing. `validateTokenFilter(token, expectedFilterHash)` MUST return `true` when the token is valid AND either has no filter hash OR matches the expected hash.
|
|
|
|
**Verified by:** `src/tokens/page-tokens.test.ts::"encodes and decodes a token"` AND `src/tokens/page-tokens.test.ts::"returns null for malformed token"` AND `src/tokens/page-tokens.test.ts::"hashFilters is order-independent"` AND `src/tokens/page-tokens.test.ts::"validateTokenFilter returns true for matching hash"` AND `src/tokens/page-tokens.test.ts::"validateTokenFilter returns false for mismatched hash"` AND `tests/property.test.ts::"page token round-trip"`
|
|
|
|
### 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. When an adapter helper or higher-level fetcher needs the current time (e.g. for telemetry), it MUST accept `clock?: Clock` from `@suaveplan/types` and use `clock.now()` instead of `Date.now()`. This keeps the encoded token canonical and machine-equal across runs while still allowing consumers to inject a `FakeClock` in tests.
|
|
|
|
**Verified by:** `src/types/pagination.types.ts` (the type declaration is a structural contract verified at compile time) AND `src/cursor/cursor-pagination.test.ts::"throws ABORTED when signal is already aborted"` (clock-equivalent injection: signal as a side channel proves no internal `Date.now()`/global state) AND `src/tokens/page-tokens.test.ts::"encodes and decodes a token"` (no timestamp in payload)
|
|
|
|
### 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(iterable, signal?)` and `collectUpTo(iterable, max, signal?)` MUST also honour the signal between iterator steps. The adapter-backed fetchers (`fetchAfter`/`fetchBefore`/`fetchKeysetPage`) MUST mirror the same pre/post-fetch abort discipline.
|
|
|
|
**Verified by:** `src/helpers/helpers.test.ts::"createOffsetIterator throws ABORTED on pre-fetch abort"` AND `src/helpers/helpers.test.ts::"createOffsetIterator throws ABORTED on post-fetch abort"` AND `src/helpers/helpers.test.ts::"createCursorIterator throws ABORTED on pre-fetch abort"` AND `src/helpers/helpers.test.ts::"createCursorIterator throws ABORTED on post-fetch abort"` AND `src/helpers/helpers.test.ts::"collectAll throws ABORTED when signal fires"` AND `src/helpers/helpers.test.ts::"collectUpTo throws ABORTED when signal fires"`
|
|
|
|
### 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>>`. These are the two operations all three pagination strategies (cursor, offset, keyset) actually use. `findOne` is NOT part of the interface — consumers compose it from `fetchPage(0, 1, ...)` if needed. `MemoryPaginationAdapter<T>` MUST implement both methods against an in-memory array and honour `SortConfig` single-field sort and equality `FilterConfig` filtering. `createSortConfig(field, direction?)` MUST construct a typed `SortConfig`.
|
|
|
|
**Verified by:** `src/adapters/database-adapter.test.ts::"fetchPage returns paged items and total"` AND `src/adapters/database-adapter.test.ts::"count returns total matching filter"` AND `src/adapters/database-adapter.test.ts::"applies sort"` AND `src/adapters/database-adapter.test.ts::"applies filter"` AND `src/adapters/database-adapter.test.ts::"createSortConfig builds sort"`
|
|
|
|
### PG-14 — Zod schemas at public boundaries
|
|
|
|
`OffsetPaginationRequestSchema`, `OffsetPaginationMetaSchema`, `OffsetPaginatedResponseSchema`, `CursorPaginationRequestSchema`, `CursorPaginationMetaSchema`, `CursorPaginatedResponseSchema`, `KeysetSortKeySchema`, `KeysetPaginationOptionsSchema`, `KeysetPageResultSchema`, `SortConfigSchema`, `PageTokenPayloadSchema`, and `PaginationDefaultsSchema` MUST be exported from `src/schemas/pagination.schemas.ts` for consumer-facing validation. Passing an invalid value to any schema's `.parse()` MUST throw a `ZodError`; consuming code is responsible for converting that to a `PaginationError`. 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::"src/ contains no disallowed constructs"`
|
|
|
|
### 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: `INVALID_CURSOR`, `INVALID_PAGE`, `INVALID_LIMIT`, `INVALID_KEYSET_CURSOR`, `INVALID_TOKEN`, `INVALID_SORT`, `ABORTED`. The codes the package actively throws are `INVALID_KEYSET_CURSOR` (encode failure) and `ABORTED` (signal fires); `INVALID_CURSOR`/`INVALID_PAGE`/`INVALID_LIMIT`/`INVALID_TOKEN`/`INVALID_SORT` are reserved for consumer-driven validation paths and the schema boundary.
|
|
|
|
**Verified by:** `src/errors/pagination-error.test.ts::"PaginationError extends SuaveplanError"` AND `src/errors/pagination-error.test.ts::"carries code and namespace"` AND `src/cursor/cursor-pagination.test.ts::"throws ABORTED when signal is already aborted"` AND `src/keyset/keyset-pagination.test.ts::"throws on circular reference"` AND `src/helpers/helpers.test.ts::"createOffsetIterator throws ABORTED on pre-fetch abort"`
|
|
|
|
### 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.
|
|
|
|
**Verified by:** `src/otel/pagination-otel.test.ts::"increments request counter on success"` AND `src/otel/pagination-otel.test.ts::"records page size histogram"` AND `src/otel/pagination-otel.test.ts::"records encode/decode duration histogram"` AND `src/otel/pagination-otel.test.ts::"is a no-op without an exporter"`
|
|
|
|
### 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 (and numbers)
|
|
- `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)` is consistent with `o` for all non-negative integer offsets and positive integer limits
|
|
|
|
**Verified by:** `tests/property.test.ts::"decodeCursor(encodeCursor(s)) === s for any string"` AND `tests/property.test.ts::"keyset cursor round-trip"` AND `tests/property.test.ts::"page token round-trip"` AND `tests/property.test.ts::"paginateArray returns ≤ limit items"` AND `tests/property.test.ts::"pageToOffset and offsetToPage are inverses"`
|
|
|
|
### PG-18 — Benchmark suite
|
|
|
|
The package MUST include a `src/__benchmarks__/` directory with a mitata benchmark file (`pagination.bench.ts`) that exercises the encode/decode round-trips and array pagination. The single consolidated file is acceptable — splitting into three files (cursor / offset / keyset) is a packaging detail, not a contract requirement. Baseline numbers MUST be published in `README.md`.
|
|
|
|
**Verified by:** `src/__benchmarks__/pagination.bench.ts` (file presence) AND `README.md` (baseline numbers section)
|
|
|
|
### 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. The package's `src/index.ts` MUST NOT export any stream symbols.
|
|
|
|
**Verified by:** `src/index.ts` (no stream exports) AND `tests/tree-shake.test.ts::"index does not export stream symbols"`
|
|
|
|
### 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/`.
|
|
|
|
**Verified by:** `tests/tree-shake.test.ts::"sideEffects is false in package.json"` AND `vitest.config.ts` (coverage thresholds) AND `tests/no-disallowed.test.ts::"src/ contains no disallowed constructs"`
|