Initial Commit
This commit is contained in:
181
openspec/archive/2026-04-19-enterprise-pagination/proposal.md
Normal file
181
openspec/archive/2026-04-19-enterprise-pagination/proposal.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# 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
|
||||
@@ -0,0 +1,107 @@
|
||||
# Capability Spec: Pagination
|
||||
|
||||
**Package**: `@suaveplan/pagination`
|
||||
**Version**: 0.0.0 (genesis)
|
||||
**Status**: ADDED
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### PG-01 — Cursor encode/decode contract
|
||||
|
||||
`encodeCursor(value)` MUST produce a base64url string with no padding characters. `decodeCursor(cursor)` MUST recover the original string exactly. A malformed cursor MUST throw `PaginationError` with code `PAGINATION.INVALID_CURSOR`. `validateCursor(cursor)` MUST return `false` rather than throw for the same malformed input.
|
||||
|
||||
### PG-02 — Cursor pagination normalization
|
||||
|
||||
`normalizeCursorRequest(request)` MUST apply default limit when none is supplied, clamp limit to the configured maximum, validate `direction` is one of `'forward' | 'backward'`, and throw `PaginationError` with code `PAGINATION.INVALID_LIMIT` if the supplied limit is ≤ 0 or exceeds the maximum. Output MUST conform to `CursorPaginationOptions`.
|
||||
|
||||
### PG-03 — Cursor meta and connection shape
|
||||
|
||||
`calculateCursorMeta(opts)` MUST derive `hasNextPage`, `hasPreviousPage`, `startCursor`, and `endCursor` from the provided edges and fetch options. `createConnection(edges, meta)` MUST return a `Connection<T>` with both fields populated. `cursorPaginateArray(array, opts)` MUST return a `CursorPaginatedResponse<T>` that includes `pageInfo` and `edges`.
|
||||
|
||||
### PG-04 — Cursor fetch helpers (adapter-backed)
|
||||
|
||||
`fetchAfter(adapter, cursor, opts)` MUST call `adapter.find` with the decoded cursor as the lower bound and return a `CursorPageResult<T>` with `edges`, `pageInfo`, and `meta`. `fetchBefore(adapter, cursor, opts)` MUST use the decoded cursor as the upper bound and reverse the result order so consumers always receive items in logical sort order.
|
||||
|
||||
### PG-05 — Offset pagination normalization
|
||||
|
||||
`normalizeOffsetRequest(request)` MUST throw `PaginationError` with code `PAGINATION.INVALID_PAGE` if `page < 1` or is non-integer, and `PAGINATION.INVALID_LIMIT` if `limit ≤ 0`. It MUST apply default values from `PaginationDefaults` when fields are absent. `pageToOffset(page, limit)` and `offsetToPage(offset, limit)` MUST be inverses of each other for all valid non-negative integer inputs.
|
||||
|
||||
### PG-06 — Offset pagination array and meta
|
||||
|
||||
`paginateArray(array, opts)` MUST return exactly `min(limit, remaining)` items starting from `pageToOffset(page, limit)`. It MUST never throw for an out-of-bounds page — it MUST return an empty `data` array and a correct `meta` indicating no items. `calculateOffsetMeta(opts)` MUST derive `totalPages`, `currentPage`, `nextPage`, `previousPage`, `hasNextPage`, and `hasPreviousPage`.
|
||||
|
||||
### PG-07 — Offset response merge
|
||||
|
||||
`mergeOffsetResponses(responses)` MUST concatenate `data` arrays in order and recompute `meta` as if the merged result were a single page starting at offset 0. The output MUST be a valid `OffsetPaginatedResponse<T>`.
|
||||
|
||||
### PG-08 — Keyset cursor encode/decode contract
|
||||
|
||||
`encodeKeysetCursor(keys)` MUST JSON-serialise the record then base64url-encode it. Non-JSON-serialisable values (e.g. `undefined`, circular references) MUST throw `PaginationError` with code `PAGINATION.INVALID_KEYSET_CURSOR`. `decodeKeysetCursor(cursor)` MUST recover the original record exactly; a malformed base64 or invalid JSON MUST throw `PAGINATION.INVALID_KEYSET_CURSOR`.
|
||||
|
||||
### PG-09 — Keyset pagination traversal
|
||||
|
||||
`keysetPaginate(array, opts)` MUST use `opts.cursor` as a non-inclusive lower bound (for forward traversal) or upper bound (for backward traversal) and return at most `opts.limit` items. `extractSortKeys(item, config)` MUST return `KeysetSortKey[]` matching `config.fields` in the declared order. `fetchKeysetPage(adapter, opts)` MUST delegate to `adapter.find` with keyset bounds and wrap the result as a `KeysetPageResult<T>`.
|
||||
|
||||
### PG-10 — Page token encode/decode and validation
|
||||
|
||||
`encodePageToken(payload)` MUST JSON-serialise and base64url-encode a `PageTokenPayload`. `decodePageToken(token)` MUST validate the result against `PageTokenPayloadSchema`; invalid structure MUST throw `PAGINATION.INVALID_TOKEN`. `hashFilters(filters)` MUST produce the same hash for deep-equal `FilterConfig` objects regardless of key insertion order. `validateTokenFilter(token, filters)` MUST return `true` only when the token's stored hash matches `hashFilters(filters)`.
|
||||
|
||||
### PG-11 — Injectable clock for token timestamps
|
||||
|
||||
`encodePageToken` and `createCursorToken` / `createOffsetToken` MUST accept an optional `clock?: Clock` from `@suaveplan/types`. When supplied, `clock.now()` MUST be used instead of `Date.now()` for any timestamp embedded in the token payload.
|
||||
|
||||
### PG-12 — AbortSignal on async iterators
|
||||
|
||||
`createCursorIterator(fetchFn, opts)` and `createOffsetIterator(fetchFn, opts)` MUST accept `opts.signal?: AbortSignal`. Before each `fetchFn` call, the iterator MUST check whether the signal is already aborted and, if so, throw `PaginationError` with code `PAGINATION.ABORTED` without calling `fetchFn`. Abort during an in-flight `fetchFn` MUST propagate naturally (the promise rejects). `collectAll` and `collectUpTo` MUST forward the signal to the underlying iterator.
|
||||
|
||||
### PG-13 — Adapter interface
|
||||
|
||||
`PaginationAdapter<T>` MUST expose three async methods: `count(opts): Promise<number>`, `find(opts): Promise<T[]>`, `findOne(opts): Promise<T | null>`. The interface MUST be generic over `T` and carry no implementation. `MemoryPaginationAdapter<T>` MUST implement all three methods against an in-memory array and honour `SortConfig` multi-field sort, cursor slicing, keyset slicing, and offset slicing in `find`. `createSortConfig<T>(fields)` MUST validate that all field names exist on `T` at the type level.
|
||||
|
||||
### PG-14 — Zod schemas at public boundaries
|
||||
|
||||
`CursorPaginationRequestSchema`, `OffsetPaginationRequestSchema`, `KeysetPaginationOptionsSchema`, and `PageTokenPayloadSchema` MUST be exported from their respective `*.schemas.ts` files. Passing an invalid value to any schema's `.parse()` MUST throw a `ZodError`; consuming code is responsible for converting that to a `PaginationError`.
|
||||
|
||||
### PG-15 — Structured errors
|
||||
|
||||
All errors thrown by the package MUST extend `PaginationError`, which MUST extend `SuaveplanError` from `@suaveplan/error`. Error codes MUST be registered via `defineCodes` under the `PAGINATION` namespace. The full code registry is: `PAGINATION.INVALID_CURSOR`, `PAGINATION.INVALID_PAGE`, `PAGINATION.INVALID_LIMIT`, `PAGINATION.INVALID_KEYSET_CURSOR`, `PAGINATION.INVALID_TOKEN`, `PAGINATION.INVALID_SORT`, `PAGINATION.ABORTED`.
|
||||
|
||||
### PG-16 — OTel instrumentation
|
||||
|
||||
The package MUST emit the following instruments via `@opentelemetry/api`:
|
||||
|
||||
- Counter `pagination.request.total` with attributes `strategy` (`cursor` | `offset` | `keyset`) and `outcome` (`success` | `error` | `aborted`)
|
||||
- Histogram `pagination.page.size` with attribute `strategy`; records the count of items returned per page fetch
|
||||
- Histogram `pagination.encode_decode.duration_ms` with attributes `direction` (`encode` | `decode`) and `type` (`cursor` | `keyset` | `token`)
|
||||
|
||||
All instruments MUST be no-ops when no exporter is registered. Tests MUST verify that calling instrumented functions without an exporter does not throw.
|
||||
|
||||
### PG-17 — Property-based test contracts
|
||||
|
||||
The following properties MUST be covered by fast-check tests:
|
||||
|
||||
- `decodeCursor(encodeCursor(s)) === s` for all non-empty strings
|
||||
- `decodeKeysetCursor(encodeKeysetCursor(keys))` deep-equals `keys` for all JSON-serialisable record shapes
|
||||
- `decodePageToken(encodePageToken(payload))` deep-equals `payload` for all valid `PageTokenPayload` shapes
|
||||
- `paginateArray(arr, { page, limit })` returns `≤ limit` items for all valid page/limit combinations
|
||||
- `pageToOffset(offsetToPage(o, l), l) === o` for all non-negative integer offsets and positive integer limits
|
||||
|
||||
### PG-18 — Benchmark suite
|
||||
|
||||
The package MUST include a `src/__benchmarks__/` directory with mitata benchmarks:
|
||||
|
||||
- `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 MUST be published in `README.md`.
|
||||
|
||||
### PG-19 — Streaming features deferred
|
||||
|
||||
`collectStream`, `countStream`, `filterStream`, `mapStream`, `paginate` (stream variant), and `takeFromStream` are NOT included in this genesis implementation. They MUST be added in a follow-up change once `@suaveplan/streams` is available on genesis.
|
||||
|
||||
### PG-20 — sideEffects: false + coverage 100%
|
||||
|
||||
`"sideEffects": false` MUST be set in `package.json` and enforced by a tree-shake guard test. Vitest coverage thresholds MUST be set to 100/100/100/100 (statements/branches/functions/lines) with provider `istanbul`. No `.skip` or `.only` is permitted in `src/`.
|
||||
146
openspec/archive/2026-04-19-enterprise-pagination/tasks.md
Normal file
146
openspec/archive/2026-04-19-enterprise-pagination/tasks.md
Normal file
@@ -0,0 +1,146 @@
|
||||
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/data/pagination.
|
||||
|
||||
# Tasks: enterprise-pagination
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 Create `packages/universal/pagination/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
|
||||
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
|
||||
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake
|
||||
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
|
||||
|
||||
## 2. Types + errors
|
||||
|
||||
- [x] 2.1 Implement `src/types/pagination.types.ts` — `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`
|
||||
- [x] 2.2 Implement `src/errors/pagination-error.ts` — `PaginationError` base extends `SuaveplanError`; `defineCodes` for `PAGINATION.INVALID_CURSOR`, `PAGINATION.INVALID_PAGE`, `PAGINATION.INVALID_LIMIT`, `PAGINATION.INVALID_KEYSET_CURSOR`, `PAGINATION.INVALID_TOKEN`, `PAGINATION.INVALID_SORT`, `PAGINATION.ABORTED`
|
||||
- [x] 2.3 Implement `src/error-codes.ts` and auto-register via side-effect import in `src/index.ts`
|
||||
- [x] 2.4 Tests: error construction, code narrowing, error message round-trip, `defineCodes` registration
|
||||
- [x] 2.5 Co-located `src/errors/pagination-error.md`
|
||||
|
||||
## 3. Zod schemas
|
||||
|
||||
- [x] 3.1 `src/cursor/cursor.schemas.ts` — `CursorPaginationRequestSchema`, `CursorPaginatedResponseSchema`, `CursorPaginationMetaSchema`, `EdgeSchema`, `ConnectionSchema`
|
||||
- [x] 3.2 `src/offset/offset.schemas.ts` — `OffsetPaginationRequestSchema`, `OffsetPaginatedResponseSchema`, `OffsetPaginationMetaSchema`
|
||||
- [x] 3.3 `src/keyset/keyset.schemas.ts` — `KeysetPaginationOptionsSchema`, `KeysetPageResultSchema`, `KeysetSortKeySchema`
|
||||
- [x] 3.4 `src/page-token/page-token.schemas.ts` — `PageTokenPayloadSchema`
|
||||
- [x] 3.5 `src/adapters/adapters.schemas.ts` — `SortConfigSchema`, `PaginationDefaultsSchema`
|
||||
- [x] 3.6 Tests: valid inputs parse, invalid inputs throw with `PAGINATION.*` code, schema `.parse()` round-trips match type inferences
|
||||
- [x] 3.7 Co-located `.md` for each schemas file
|
||||
|
||||
## 4. Cursor pagination
|
||||
|
||||
- [x] 4.1 Implement `src/cursor/cursor.ts`:
|
||||
- `encodeCursor(value)` → `string` — base64url-encode
|
||||
- `decodeCursor(cursor)` → `string` — base64url-decode; throws `PAGINATION.INVALID_CURSOR` on malformed input
|
||||
- `validateCursor(cursor)` → `boolean`
|
||||
- `normalizeCursorRequest(request)` → `CursorPaginationOptions` — apply defaults, clamp limit, validate direction
|
||||
- `calculateCursorMeta(opts)` → `CursorPaginationMeta`
|
||||
- `createEdge(node, cursor)` → `Edge<T>`
|
||||
- `createEdges(nodes, cursorFn)` → `Edge<T>[]`
|
||||
- `createConnection(edges, meta)` → `Connection<T>`
|
||||
- `createCursorResponse(data, opts)` → `CursorPaginatedResponse<T>`
|
||||
- `cursorPaginateArray(array, opts)` → `CursorPaginatedResponse<T>`
|
||||
- [x] 4.2 Implement `src/cursor/cursor-pagination.ts`:
|
||||
- `fetchAfter(adapter, cursor, opts)` → `Promise<CursorPageResult<T>>`
|
||||
- `fetchBefore(adapter, cursor, opts)` → `Promise<CursorPageResult<T>>`
|
||||
- [x] 4.3 Unit tests: encode/decode round-trips, invalid cursor error, normalization defaults, meta calculation, edge/connection construction, array pagination with and without cursors
|
||||
- [x] 4.4 Property-based tests: `decodeCursor(encodeCursor(s)) === s` for all non-empty strings; monotone cursor position property on sorted arrays
|
||||
- [x] 4.5 Co-located `src/cursor/cursor.md` and `src/cursor/cursor-pagination.md`
|
||||
|
||||
## 5. Offset pagination
|
||||
|
||||
- [x] 5.1 Implement `src/offset/offset.ts`:
|
||||
- `pageToOffset(page, limit)` → `number`
|
||||
- `offsetToPage(offset, limit)` → `number`
|
||||
- `getPageRange(meta)` → `{ start: number; end: number }`
|
||||
- `normalizeOffsetRequest(request)` → `OffsetPaginationOptions` — validate page ≥ 1, limit > 0; throw `PAGINATION.INVALID_PAGE` / `PAGINATION.INVALID_LIMIT`
|
||||
- `calculateOffsetMeta(opts)` → `OffsetPaginationMeta`
|
||||
- `createOffsetResponse(data, meta)` → `OffsetPaginatedResponse<T>`
|
||||
- `paginateArray(array, opts)` → `OffsetPaginatedResponse<T>`
|
||||
- `estimateTotalCount(adapter, opts)` → `Promise<number>`
|
||||
- `mergeOffsetResponses(responses)` → `OffsetPaginatedResponse<T>`
|
||||
- [x] 5.2 Unit tests: `pageToOffset`/`offsetToPage` inverses, normalization error paths, `paginateArray` boundary conditions (first page, last page, empty array, page beyond end), meta correctness, merge responses
|
||||
- [x] 5.3 Property-based tests: `paginateArray` always returns `≤ limit` items; `pageToOffset(offsetToPage(o, l), l) === o` for valid inputs
|
||||
- [x] 5.4 Co-located `src/offset/offset.md`
|
||||
|
||||
## 6. Keyset pagination
|
||||
|
||||
- [x] 6.1 Implement `src/keyset/keyset-pagination.ts`:
|
||||
- `encodeKeysetCursor(keys)` → `string` — JSON + base64url; throws `PAGINATION.INVALID_KEYSET_CURSOR` on non-serialisable values
|
||||
- `decodeKeysetCursor(cursor)` → `Record<string, unknown>` — base64url + JSON.parse; throws `PAGINATION.INVALID_KEYSET_CURSOR` on malformed input
|
||||
- `extractSortKeys(item, config)` → `KeysetSortKey[]`
|
||||
- `keysetPaginate(array, opts)` → `KeysetPageResult<T>` — pure in-memory keyset pagination over a sorted array
|
||||
- `fetchKeysetPage(adapter, opts)` → `Promise<KeysetPageResult<T>>`
|
||||
- [x] 6.2 Unit tests: encode/decode round-trips, invalid cursor error path, sort key extraction, `keysetPaginate` forward and backward traversal, `fetchKeysetPage` with `MemoryPaginationAdapter`
|
||||
- [x] 6.3 Property-based tests: `decodeKeysetCursor(encodeKeysetCursor(keys))` deep-equals `keys` for arbitrary serialisable record shapes
|
||||
- [x] 6.4 Co-located `src/keyset/keyset-pagination.md`
|
||||
|
||||
## 7. Page tokens
|
||||
|
||||
- [x] 7.1 Implement `src/page-token/page-token.ts`:
|
||||
- `encodePageToken(payload)` → `string` — JSON + base64url
|
||||
- `decodePageToken(token)` → `PageTokenPayload` — base64url + JSON.parse + Zod validation; throws `PAGINATION.INVALID_TOKEN` on malformed input
|
||||
- `createCursorToken(payload)` → `string`
|
||||
- `createOffsetToken(payload)` → `string`
|
||||
- `extractCursorValue(token)` → `string`
|
||||
- `extractSortInfo(token)` → `{ field: string; direction: SortDirection }`
|
||||
- `hashFilters(filters)` → `string` — deterministic stable hash of a `FilterConfig`
|
||||
- `validateTokenFilter(token, filters)` → `boolean` — checks `hashFilters(filters)` matches stored hash
|
||||
- Injectable `clock?: Clock` for token timestamp (defaults to `Date.now`)
|
||||
- [x] 7.2 Unit tests: round-trip encode/decode, invalid token error path, cursor/offset token construction, sort info extraction, filter hash stability across equivalent objects, filter validation pass/fail
|
||||
- [x] 7.3 Property-based tests: `decodePageToken(encodePageToken(payload))` deep-equals `payload` for arbitrary `PageTokenPayload` shapes
|
||||
- [x] 7.4 Co-located `src/page-token/page-token.md`
|
||||
|
||||
## 8. Helpers
|
||||
|
||||
- [x] 8.1 Implement `src/helpers/helpers.ts`:
|
||||
- `isPaginatedResponseEmpty(response)` → `boolean`
|
||||
- `mapPaginated(response, fn)` → `PaginatedResponse<U>`
|
||||
- `createCursorIterator(fetchFn, opts)` → `AsyncIterableIterator<T>` — `opts.signal?: AbortSignal`; throws `PAGINATION.ABORTED` on abort between pages
|
||||
- `createOffsetIterator(fetchFn, opts)` → `AsyncIterableIterator<T>` — same abort behaviour
|
||||
- `collectAll(iterator, signal?)` → `Promise<T[]>` — forwards signal; respects abort mid-collect
|
||||
- `collectUpTo(iterator, limit, signal?)` → `Promise<T[]>` — stops at `limit` items; respects signal
|
||||
- `mergeOffsetResponses` re-exported from offset module for convenience
|
||||
- [x] 8.2 Unit tests: `isPaginatedResponseEmpty` edge cases, `mapPaginated` type transformation, cursor iterator forward traversal, offset iterator forward traversal, `collectAll` / `collectUpTo` on both iterator types, abort mid-iteration
|
||||
- [x] 8.3 Co-located `src/helpers/helpers.md`
|
||||
|
||||
## 9. Adapters
|
||||
|
||||
- [x] 9.1 Implement `src/adapters/database-adapter.ts`:
|
||||
- `PaginationAdapter<T>` interface — `count(opts): Promise<number>`, `find(opts): Promise<T[]>`, `findOne(opts): Promise<T | null>`
|
||||
- `SortConfig<T>` type and `createSortConfig<T>(fields)` factory
|
||||
- `MemoryPaginationAdapter<T>` class — full in-memory implementation: sorts by `SortConfig`, applies cursor/keyset/offset slicing, respects all `PaginationAdapter` opts
|
||||
- `createMemoryAdapter<T>(items, config?)` → `MemoryPaginationAdapter<T>` factory
|
||||
- [x] 9.2 Unit tests: adapter `count`/`find`/`findOne`, sort config single and multi-field, ascending/descending, cursor-based slicing, keyset slicing, offset slicing, empty dataset
|
||||
- [x] 9.3 Co-located `src/adapters/database-adapter.md`
|
||||
|
||||
## 10. OTel instrumentation
|
||||
|
||||
- [x] 10.1 `src/otel/instrumentation.ts` — create and export meter, register instruments:
|
||||
- Counter `pagination.request.total` (attributes: `strategy`, `outcome`)
|
||||
- Histogram `pagination.page.size` (attributes: `strategy`)
|
||||
- Histogram `pagination.encode_decode.duration_ms` (attributes: `direction`, `type`)
|
||||
- [x] 10.2 Wire counters and histograms into cursor, offset, keyset public entrypoints
|
||||
- [x] 10.3 Tests: no-exporter pass (instruments do not throw), attribute labels are correct for each strategy
|
||||
- [x] 10.4 Co-located `src/otel/instrumentation.md`
|
||||
|
||||
## 11. Benchmarks
|
||||
|
||||
- [x] 11.1 `src/__benchmarks__/cursor-encode-decode.bench.ts` — `encodeCursor` + `decodeCursor` round-trip × 10 000 iterations (mitata)
|
||||
- [x] 11.2 `src/__benchmarks__/offset-paginate.bench.ts` — `paginateArray` on a 10 000-item array × 10 000 iterations
|
||||
- [x] 11.3 `src/__benchmarks__/keyset-encode-decode.bench.ts` — `encodeKeysetCursor` + `decodeKeysetCursor` round-trip × 10 000 iterations
|
||||
- [x] 11.4 Publish baseline numbers in `README.md`
|
||||
|
||||
## 12. Docs
|
||||
|
||||
- [x] 12.1 Co-located `.md` for every implementation file (≥200 words, both simple and advanced examples, full API reference)
|
||||
- [x] 12.2 Rewrite `README.md` with public API surface, strategy comparison table (cursor vs offset vs keyset), iterator usage guide, adapter extension guide, OTel attributes reference, benchmark numbers, consumer migration notes
|
||||
- [x] 12.3 `bun run docs:check --filter=@suaveplan/pagination` ≥ 90%
|
||||
|
||||
## 13. Verify and publish
|
||||
|
||||
- [x] 13.1 `bunx turbo lint typecheck test --filter=@suaveplan/pagination` green
|
||||
- [x] 13.2 `bunx turbo test:coverage --filter=@suaveplan/pagination` at 100/100/100/100
|
||||
- [x] 13.3 `bunx turbo build --filter=@suaveplan/pagination` green
|
||||
- [x] 13.4 `bun scripts/publish.ts --package @suaveplan/pagination` to Verdaccio at 0.0.0
|
||||
- [x] 13.5 Archive OpenSpec change to `openspec/archive/2026-04-19-enterprise-pagination/`
|
||||
Reference in New Issue
Block a user