182 lines
8.4 KiB
Markdown
182 lines
8.4 KiB
Markdown
# 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
|