8.4 KiB
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:observabilitytslib→catalog:build
Dev deps (via catalog):
vitest,@vitest/coverage-istanbul→catalog:testingfast-check→catalog:testingmitata→catalog:testing
Preserved public API surface (from main branch)
Cursor pagination
calculateCursorMeta(opts)→CursorPaginationMetacreateConnection(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)→CursorPaginationOptionsdecodeCursor(cursor)→stringencodeCursor(value)→stringfetchAfter(adapter, cursor, opts)→Promise<CursorPageResult<T>>fetchBefore(adapter, cursor, opts)→Promise<CursorPageResult<T>>validateCursor(cursor)→boolean
Offset pagination
calculateOffsetMeta(opts)→OffsetPaginationMetacreateOffsetResponse(data, meta)→OffsetPaginatedResponse<T>estimateTotalCount(adapter, opts)→Promise<number>getPageRange(meta)→{ start: number; end: number }normalizeOffsetRequest(request)→OffsetPaginationOptionsoffsetToPage(offset, limit)→numberpageToOffset(page, limit)→numberpaginateArray(array, opts)→OffsetPaginatedResponse<T>
Keyset pagination
decodeKeysetCursor(cursor)→Record<string, unknown>encodeKeysetCursor(keys)→stringextractSortKeys(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)→booleanmapPaginated(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 ofPaginationAdapter
Page tokens
createCursorToken(payload)→stringcreateOffsetToken(payload)→stringdecodePageToken(token)→PageTokenPayloadencodePageToken(payload)→stringextractCursorValue(token)→stringextractSortInfo(token)→{ field: string; direction: SortDirection }hashFilters(filters)→stringvalidateTokenFilter(token, filters)→boolean
Types
Connection<T>,CursorDirection,CursorPageResult<T>,CursorPaginatedResponse<T>CursorPaginationMeta,CursorPaginationOptions,CursorPaginationRequestEdge<T>,FilterConfig,KeysetPageResult<T>,KeysetPaginationOptionsKeysetSortKey,OffsetPaginatedResponse<T>,OffsetPaginationMetaOffsetPaginationRequest,PageResult<T>,PageTokenPayloadPaginatedIteratorResult<T>,PaginationDefaults,SortConfig<T>,SortDirection
Streaming (deferred)
collectStream,countStream,filterStream,mapStream,paginate,takeFromStream— all skipped until@suaveplan/streamsis available on genesis.
New additions for genesis
Structured errors
PaginationErrorbase extendsSuaveplanErrorwith codes underPAGINATION.*namespace- Codes:
PAGINATION.INVALID_CURSOR— malformed or tampered base64 cursor stringPAGINATION.INVALID_PAGE— page number out of range (< 1) or non-integerPAGINATION.INVALID_LIMIT— limit ≤ 0 or exceeds configured maximumPAGINATION.INVALID_KEYSET_CURSOR— malformed or incompatible keyset cursorPAGINATION.INVALID_TOKEN— malformed or expired page tokenPAGINATION.INVALID_SORT— unknown sort field or conflicting sort configPAGINATION.ABORTED— async iterator or fetch cancelled viaAbortSignal
Zod schemas
CursorPaginationRequestSchema,CursorPaginatedResponseSchemaOffsetPaginationRequestSchema,OffsetPaginatedResponseSchemaKeysetPaginationOptionsSchema,KeysetPageResultSchemaPageTokenPayloadSchema,SortConfigSchema,PaginationDefaultsSchema- All exported from
*.schemas.tssibling files; validated at public API boundaries
Injectable clock
CursorPaginationOptions.clock?: Clock— used for token timestamp generation; defaults toDate.nowOffsetPaginationOptions.clock?: Clock— sameClocksourced from@suaveplan/types
AbortSignal on async iterators
createCursorIterator(fetchFn, opts)—opts.signal?: AbortSignal; throwsPaginationError(codePAGINATION.ABORTED) on abort between pagescreateOffsetIterator(fetchFn, opts)— samecollectAllandcollectUpToforward 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)) === sfor all non-empty strings - Keyset cursor round-trip:
decodeKeysetCursor(encodeKeysetCursor(keys))deep-equalskeysfor arbitrary record shapes - Page token round-trip:
decodePageToken(encodePageToken(payload))deep-equalspayload - Monotone cursor ordering: for any sorted array, successive cursor positions are non-decreasing
- Offset correctness:
paginateArray(arr, { page, limit })always returns≤ limititems
Benchmarks (mitata)
cursor-encode-decode.bench.ts—encodeCursor+decodeCursorround-trip × 10 000 iterationsoffset-paginate.bench.ts—paginateArrayon a 10 000-item array × 10 000 iterationskeyset-encode-decode.bench.ts—encodeKeysetCursor+decodeKeysetCursorround-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