# 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` - `createCursorResponse(data, opts)` → `CursorPaginatedResponse` - `createEdge(node, cursor)` → `Edge` - `createEdges(nodes, cursorFn)` → `Edge[]` - `cursorPaginateArray(array, opts)` → `CursorPaginatedResponse` - `normalizeCursorRequest(request)` → `CursorPaginationOptions` - `decodeCursor(cursor)` → `string` - `encodeCursor(value)` → `string` - `fetchAfter(adapter, cursor, opts)` → `Promise>` - `fetchBefore(adapter, cursor, opts)` → `Promise>` - `validateCursor(cursor)` → `boolean` ### Offset pagination - `calculateOffsetMeta(opts)` → `OffsetPaginationMeta` - `createOffsetResponse(data, meta)` → `OffsetPaginatedResponse` - `estimateTotalCount(adapter, opts)` → `Promise` - `getPageRange(meta)` → `{ start: number; end: number }` - `normalizeOffsetRequest(request)` → `OffsetPaginationOptions` - `offsetToPage(offset, limit)` → `number` - `pageToOffset(page, limit)` → `number` - `paginateArray(array, opts)` → `OffsetPaginatedResponse` ### Keyset pagination - `decodeKeysetCursor(cursor)` → `Record` - `encodeKeysetCursor(keys)` → `string` - `extractSortKeys(item, config)` → `KeysetSortKey[]` - `fetchKeysetPage(adapter, opts)` → `Promise>` - `keysetPaginate(array, opts)` → `KeysetPageResult` ### Helpers - `collectAll(iterator)` → `Promise` - `collectUpTo(iterator, limit)` → `Promise` - `createCursorIterator(fetchFn, opts)` → `AsyncIterableIterator` - `createOffsetIterator(fetchFn, opts)` → `AsyncIterableIterator` - `isPaginatedResponseEmpty(response)` → `boolean` - `mapPaginated(response, fn)` → `PaginatedResponse` - `mergeOffsetResponses(responses)` → `OffsetPaginatedResponse` ### Adapters - `PaginationAdapter` — interface: `count(opts)`, `find(opts)`, `findOne(opts)` - `createMemoryAdapter(items, config?)` → `MemoryPaginationAdapter` - `createSortConfig(fields)` → `SortConfig` - `MemoryPaginationAdapter` — 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`, `CursorDirection`, `CursorPageResult`, `CursorPaginatedResponse` - `CursorPaginationMeta`, `CursorPaginationOptions`, `CursorPaginationRequest` - `Edge`, `FilterConfig`, `KeysetPageResult`, `KeysetPaginationOptions` - `KeysetSortKey`, `OffsetPaginatedResponse`, `OffsetPaginationMeta` - `OffsetPaginationRequest`, `PageResult`, `PageTokenPayload` - `PaginatedIteratorResult`, `PaginationDefaults`, `SortConfig`, `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