Initial Commit
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# 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∘decode` is a fixed point.
|
||||
- `paginateArray` out-of-bounds page returns empty `data` with correct meta and never throws.
|
||||
- `Connection<T>` / `Edge<T>` generics survive `createEdges` / `createConnection` (type-level via `expectTypeOf`).
|
||||
Reference in New Issue
Block a user