Files
suaveplan/openspec/archive/2026-05-02-pagination-spec-conformance/proposal.md
2026-05-27 23:29:53 +01:00

55 lines
6.4 KiB
Markdown

# 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.