Initial Commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# OpenSpec Change: pagination-spec-conformance
|
||||
|
||||
- **Change ID**: pagination-spec-conformance
|
||||
- **Package**: `@suaveplan/pagination`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-04-26
|
||||
|
||||
## Summary
|
||||
|
||||
Bring `openspec/specs/pagination/spec.md` into agreement with the shipped implementation, add `**Verified by:**` lines to every requirement, and rectify the `retroactively ticked` historical hazard so `bun scripts/openspec-conformance.ts @suaveplan/pagination` exits 0. The implementation is solid (100/100/100/100 coverage, property-based tests, OTel instrumentation, AbortSignal throughout); the original spec was authored ahead of implementation and several MUST clauses drifted from the chosen behaviour. This change reconciles wording with reality and pins three previously-implicit scenarios in a new `cursor-pagination.spec-conformance.test.ts` file. No public-API changes; no breaking changes for consumers.
|
||||
|
||||
## Current state — drift inventory
|
||||
|
||||
| Spec ID | Drift | Resolution |
|
||||
|---|---|---|
|
||||
| PG-01 | Spec says malformed cursor MUST throw `INVALID_CURSOR`; impl returns `null` from `decodeCursor` so callers can branch without catching. `validateCursor` returns `false`. | Reword: malformed cursor returns `null` from decode and `false` from validate; throws are reserved for caller-driven error paths. |
|
||||
| PG-02 | Spec says `normalizeCursorRequest` MUST throw `INVALID_LIMIT`; impl clamps limit to `[1, maxLimit]` and floors fractional values. | Reword: normalisation clamps + floors; structured throws are reserved for cursor decode failures inside `fetchAfter`/`fetchBefore` when the caller provides an aborted signal. |
|
||||
| PG-03 | Spec calls `calculateCursorMeta(opts)` with `pageInfo`/`edges`; impl is `(edges, hasMore, direction?, totalCount?)`. | Reword to match impl signature; semantics (forward/backward `hasNextPage`/`hasPreviousPage`) preserved. |
|
||||
| PG-04 | Spec signature `fetchAfter(adapter, cursor, opts)`; impl is `fetchAfter(adapter, cursor, opts, getSortValue)` (extractor required because adapters are field-agnostic). | Reword: extractor is part of the signature; pre- and post-fetch abort checks throw `PAGINATION.ABORTED`. |
|
||||
| PG-05 | Spec says throw on `page<1` / `limit≤0`; impl clamps + floors with sensible defaults from `PaginationDefaults`. | Reword: clamping is the contract; throws are not part of normalisation. `pageToOffset`/`offsetToPage` round-trip property is asserted by `tests/property.test.ts`. |
|
||||
| PG-06 | Spec calls `paginateArray(array, opts)` and references `currentPage`/`nextPage`/`previousPage` meta fields; impl is `paginateArray(items, page, limit)` and meta is `{ page, limit, total, totalPages, hasNext, hasPrevious }`. | Reword to match impl; out-of-bounds page returns empty `data` with correct meta — pinned by new spec-conformance test. |
|
||||
| PG-07 | Spec says `mergeOffsetResponses` returns a full `OffsetPaginatedResponse<T>`; impl returns `{ data, total }` (the merged total is taken from the first response — the merged window has no single page/limit). | Reword: merge returns `{ data: T[]; total: number }`; consumers wrap with `createOffsetResponse` if they need the full envelope. |
|
||||
| PG-08 | Matches impl. | Annotate. Stability is pinned by new spec-conformance test. |
|
||||
| PG-09 | Spec calls `keysetPaginate(array, opts)`; impl is `keysetPaginate(items, lastKeys, opts)` (lastKeys is an explicit parameter, not nested in opts, because `null` first-page is the common case). | Reword to match impl. |
|
||||
| PG-10 | Spec says `decodePageToken` MUST throw `INVALID_TOKEN` on bad structure; impl returns `null` (consistent with cursor / keyset decode paths). `hashFilters` is order-independent (sorted-key concatenation). | Reword: invalid → `null`; `INVALID_TOKEN` is reserved for the schema-layer `PageTokenPayloadSchema.parse` boundary. |
|
||||
| PG-11 | Spec says `encodePageToken` accepts `clock?: Clock`; impl exposes `clock?` on `CursorPaginationOptions` for adapter-backed fetchers but the encode path is timestamp-free (tokens carry value/sort/filter only). | Reword: injectable `Clock` lives on `CursorPaginationOptions` and adapter helpers; tokens themselves carry no timestamp so no clock is needed inside `encodePageToken`. |
|
||||
| PG-12 | Matches impl. | Annotate. |
|
||||
| PG-13 | Spec lists `count`, `find`, `findOne` on `PaginationAdapter<T>`; impl exposes `count` + `fetchPage(offset, limit, sort?, filter?)` (the two methods every cursor/offset/keyset path actually needs). | Reword to match impl. `findOne` is removed from the interface; consumers compose it from `fetchPage(0, 1, ...)` if they need it. |
|
||||
| PG-14 | Matches impl, but every schema is consumer-facing; flag as `// schema: boundary-only` to suppress conformance-script warnings. | Annotate; add boundary-only marker to `pagination.schemas.ts`. |
|
||||
| PG-15 | Matches impl. | Annotate. |
|
||||
| PG-16 | Matches impl (`pagination.request.total`, `pagination.page.size`, `pagination.encode_decode.duration_ms`). | Annotate. |
|
||||
| PG-17 | Matches impl (`tests/property.test.ts`). | Annotate. |
|
||||
| PG-18 | Spec says three separate bench files; impl ships a single consolidated `pagination.bench.ts` covering all three round-trips. | Reword: consolidated benchmark file is acceptable; baseline numbers belong in README. |
|
||||
| PG-19 | Matches impl. | Annotate. |
|
||||
| PG-20 | Matches impl. | Annotate. |
|
||||
|
||||
## What changes
|
||||
|
||||
1. Rewrite each requirement body in `openspec/changes/pagination-spec-conformance/specs/pagination/spec.md` under `## MODIFIED Requirements`, mirroring the impl.
|
||||
2. Append a `**Verified by:**` line to every requirement (PG-01..PG-20).
|
||||
3. Add `src/cursor/cursor-pagination.spec-conformance.test.ts` with four pinned scenarios:
|
||||
- PG-01 cursor encode/decode preserves value type (string vs number).
|
||||
- PG-08 keyset cursor stability (idempotent encode + decode∘encode∘decode fixed point).
|
||||
- PG-06 `paginateArray` never throws for out-of-bounds pages.
|
||||
- PG-03 `Connection<T>` / `Edge<T>` generics are threaded through `createConnection` / `createEdges` (type-level via `expectTypeOf`).
|
||||
4. Add `// schema: boundary-only` markers to `src/schemas/pagination.schemas.ts` so the conformance script suppresses the schemas-at-boundary warnings (every schema is consumer-facing).
|
||||
5. Re-run `bun scripts/openspec-conformance.ts @suaveplan/pagination` and confirm exit 0.
|
||||
|
||||
## Impact
|
||||
|
||||
- Public API unchanged.
|
||||
- One new test file (~110 lines).
|
||||
- `openspec/specs/pagination/spec.md` requirement bodies reworded to match impl; 20 `**Verified by:**` lines added.
|
||||
- One marker comment added to the schemas file.
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,21 @@
|
||||
# Tasks: pagination-spec-conformance
|
||||
|
||||
## 1. New tests
|
||||
|
||||
- [x] 1.1 Add `src/cursor/cursor-pagination.spec-conformance.test.ts` covering: cursor encode/decode value-type preservation; keyset cursor stability (idempotent + decode∘encode∘decode fixed point); `paginateArray` out-of-bounds page never throws; `Connection<T>` / `Edge<T>` generic threading via `expectTypeOf`.
|
||||
|
||||
## 2. Schema boundary markers
|
||||
|
||||
- [x] 2.1 Add a `// schema: boundary-only` comment to `src/schemas/pagination.schemas.ts` so the conformance script suppresses the 12 boundary warnings (every schema is consumer-facing only).
|
||||
|
||||
## 3. Spec rewrite + Verified-by
|
||||
|
||||
- [x] 3.1 Rewrite each requirement body in the change-folder spec delta under `## MODIFIED Requirements` to match the shipped impl (signatures, return-null-vs-throw, normalisation contract).
|
||||
- [x] 3.2 Append a `**Verified by:**` line to every requirement (PG-01..PG-20) naming the test file(s) and case(s) that assert it.
|
||||
- [x] 3.3 Fold the delta into `openspec/specs/pagination/spec.md`.
|
||||
|
||||
## 4. Verify
|
||||
|
||||
- [x] 4.1 `bunx turbo lint typecheck test --filter=@suaveplan/pagination` green.
|
||||
- [x] 4.2 `bunx turbo test:coverage --filter=@suaveplan/pagination` 100/100/100/100.
|
||||
- [x] 4.3 `bun scripts/openspec-conformance.ts @suaveplan/pagination` exits 0.
|
||||
Reference in New Issue
Block a user