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

6.4 KiB

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.