Files
suaveplan/openspec/archive/2026-04-19-enterprise-pagination/proposal.md
2026-05-27 23:29:53 +01:00

8.4 KiB
Raw Blame History

OpenSpec Change: enterprise-pagination

  • Change ID: enterprise-pagination
  • Package: @suaveplan/pagination
  • Category: universal
  • Target path: packages/universal/pagination
  • Status: PROPOSED
  • Date: 2026-04-19
  • Reference spec: /mnt/s/web/suaveplan/enterprise/pagination.md

Summary

Greenfield implementation of @suaveplan/pagination for the genesis branch. The existing main-branch pagination package (98.13% test coverage) provides the baseline public-API surface to preserve; this change delivers enterprise-grade pagination primitives covering cursor, offset, and keyset strategies with injectable clock, AbortSignal on all async iterators, structured errors under PAGINATION.* codes, Zod schemas for all request/response shapes, OTel counters and histograms on public entrypoints, property-based tests for round-trip encode/decode and monotone cursor ordering, and a mitata benchmark suite. All functions are pure — no IO — so no Redis or database containers are needed; the MemoryPaginationAdapter covers the full adapter interface contract under test. Streaming features dependent on @suaveplan/streams are deferred until that package ships.

Current-state audit (genesis)

The package does not exist on genesis yet — this is a fresh build.

Available workspace deps on genesis

Package Available
@suaveplan/error ✅
@suaveplan/types ✅
@suaveplan/events ✅
@suaveplan/logger ✅
@suaveplan/opentelemetry ❌ not yet built — use @opentelemetry/api directly
@suaveplan/schema-registry ❌ not yet built — export Zod schemas directly
@suaveplan/streams ❌ not yet built — skip streaming features for now
@suaveplan/feature-flags ❌ not yet built — skip

External deps (via catalog):

  • zod → catalog:forms
  • @opentelemetry/api → catalog:observability
  • tslib → catalog:build

Dev deps (via catalog):

  • vitest, @vitest/coverage-istanbul → catalog:testing
  • fast-check → catalog:testing
  • mitata → catalog:testing

Preserved public API surface (from main branch)

Cursor pagination

  • calculateCursorMeta(opts) → CursorPaginationMeta
  • createConnection(edges, meta) → Connection<T>
  • createCursorResponse(data, opts) → CursorPaginatedResponse<T>
  • createEdge(node, cursor) → Edge<T>
  • createEdges(nodes, cursorFn) → Edge<T>[]
  • cursorPaginateArray(array, opts) → CursorPaginatedResponse<T>
  • normalizeCursorRequest(request) → CursorPaginationOptions
  • decodeCursor(cursor) → string
  • encodeCursor(value) → string
  • fetchAfter(adapter, cursor, opts) → Promise<CursorPageResult<T>>
  • fetchBefore(adapter, cursor, opts) → Promise<CursorPageResult<T>>
  • validateCursor(cursor) → boolean

Offset pagination

  • calculateOffsetMeta(opts) → OffsetPaginationMeta
  • createOffsetResponse(data, meta) → OffsetPaginatedResponse<T>
  • estimateTotalCount(adapter, opts) → Promise<number>
  • getPageRange(meta) → { start: number; end: number }
  • normalizeOffsetRequest(request) → OffsetPaginationOptions
  • offsetToPage(offset, limit) → number
  • pageToOffset(page, limit) → number
  • paginateArray(array, opts) → OffsetPaginatedResponse<T>

Keyset pagination

  • decodeKeysetCursor(cursor) → Record<string, unknown>
  • encodeKeysetCursor(keys) → string
  • extractSortKeys(item, config) → KeysetSortKey[]
  • fetchKeysetPage(adapter, opts) → Promise<KeysetPageResult<T>>
  • keysetPaginate(array, opts) → KeysetPageResult<T>

Helpers

  • collectAll(iterator) → Promise<T[]>
  • collectUpTo(iterator, limit) → Promise<T[]>
  • createCursorIterator(fetchFn, opts) → AsyncIterableIterator<T>
  • createOffsetIterator(fetchFn, opts) → AsyncIterableIterator<T>
  • isPaginatedResponseEmpty(response) → boolean
  • mapPaginated(response, fn) → PaginatedResponse<U>
  • mergeOffsetResponses(responses) → OffsetPaginatedResponse<T>

Adapters

  • PaginationAdapter<T> — interface: count(opts), find(opts), findOne(opts)
  • createMemoryAdapter<T>(items, config?) → MemoryPaginationAdapter<T>
  • createSortConfig<T>(fields) → SortConfig<T>
  • MemoryPaginationAdapter<T> — in-memory reference implementation of PaginationAdapter

Page tokens

  • createCursorToken(payload) → string
  • createOffsetToken(payload) → string
  • decodePageToken(token) → PageTokenPayload
  • encodePageToken(payload) → string
  • extractCursorValue(token) → string
  • extractSortInfo(token) → { field: string; direction: SortDirection }
  • hashFilters(filters) → string
  • validateTokenFilter(token, filters) → boolean

Types

  • Connection<T>, CursorDirection, CursorPageResult<T>, CursorPaginatedResponse<T>
  • CursorPaginationMeta, CursorPaginationOptions, CursorPaginationRequest
  • Edge<T>, FilterConfig, KeysetPageResult<T>, KeysetPaginationOptions
  • KeysetSortKey, OffsetPaginatedResponse<T>, OffsetPaginationMeta
  • OffsetPaginationRequest, PageResult<T>, PageTokenPayload
  • PaginatedIteratorResult<T>, PaginationDefaults, SortConfig<T>, SortDirection

Streaming (deferred)

  • collectStream, countStream, filterStream, mapStream, paginate, takeFromStream — all skipped until @suaveplan/streams is available on genesis.

New additions for genesis

Structured errors

  • PaginationError base extends SuaveplanError with codes under PAGINATION.* namespace
  • Codes:
    • PAGINATION.INVALID_CURSOR — malformed or tampered base64 cursor string
    • PAGINATION.INVALID_PAGE — page number out of range (< 1) or non-integer
    • PAGINATION.INVALID_LIMIT — limit ≤ 0 or exceeds configured maximum
    • PAGINATION.INVALID_KEYSET_CURSOR — malformed or incompatible keyset cursor
    • PAGINATION.INVALID_TOKEN — malformed or expired page token
    • PAGINATION.INVALID_SORT — unknown sort field or conflicting sort config
    • PAGINATION.ABORTED — async iterator or fetch cancelled via AbortSignal

Zod schemas

  • CursorPaginationRequestSchema, CursorPaginatedResponseSchema
  • OffsetPaginationRequestSchema, OffsetPaginatedResponseSchema
  • KeysetPaginationOptionsSchema, KeysetPageResultSchema
  • PageTokenPayloadSchema, SortConfigSchema, PaginationDefaultsSchema
  • All exported from *.schemas.ts sibling files; validated at public API boundaries

Injectable clock

  • CursorPaginationOptions.clock?: Clock — used for token timestamp generation; defaults to Date.now
  • OffsetPaginationOptions.clock?: Clock — same
  • Clock sourced from @suaveplan/types

AbortSignal on async iterators

  • createCursorIterator(fetchFn, opts) — opts.signal?: AbortSignal; throws PaginationError (code PAGINATION.ABORTED) on abort between pages
  • createOffsetIterator(fetchFn, opts) — same
  • collectAll and collectUpTo forward the signal to the iterator

OTel instrumentation

  • Counter: pagination.request.total (by strategy label: cursor / offset / keyset; by outcome: success / error / aborted)
  • Histogram: pagination.page.size (by strategy; records actual items returned per page)
  • Histogram: pagination.encode_decode.duration_ms (by direction: encode / decode; by type: cursor / keyset / token)
  • All instruments are no-ops without exporter; tested with a no-exporter pass

Property-based tests (fast-check)

  • Cursor round-trip: decodeCursor(encodeCursor(s)) === s for all non-empty strings
  • Keyset cursor round-trip: decodeKeysetCursor(encodeKeysetCursor(keys)) deep-equals keys for arbitrary record shapes
  • Page token round-trip: decodePageToken(encodePageToken(payload)) deep-equals payload
  • Monotone cursor ordering: for any sorted array, successive cursor positions are non-decreasing
  • Offset correctness: paginateArray(arr, { page, limit }) always returns ≤ limit items

Benchmarks (mitata)

  • cursor-encode-decode.bench.ts — encodeCursor + decodeCursor round-trip × 10 000 iterations
  • offset-paginate.bench.ts — paginateArray on a 10 000-item array × 10 000 iterations
  • keyset-encode-decode.bench.ts — encodeKeysetCursor + decodeKeysetCursor round-trip × 10 000 iterations
  • Baseline numbers published in README

Impact

  • Adds one new package: packages/universal/pagination
  • Adds one new spec capability: pagination
  • No existing package consumers on genesis yet