Files
2026-05-27 23:29:53 +01:00

182 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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