first commit
This commit is contained in:
92
docs/foundation/data/pagination/README.md
Normal file
92
docs/foundation/data/pagination/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# @suaveplan/pagination - Universal pagination primitives: cursor, offset, keyset strategies with adapters, iterators, and type-safe request/response shapes.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal pagination primitives for cursor-based, offset-based, and keyset-based (seek) pagination strategies. The package covers the full pagination surface: request normalisation, response envelope construction, opaque cursor and page-token encoding, async iterators that drain pages automatically, a pluggable adapter interface for any data source, and optional OpenTelemetry instrumentation. All three strategies share the same error hierarchy and Zod schemas so they compose cleanly in multi-strategy APIs.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapters](./src/adapters/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [database-adapter](./src/adapters/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cursor-pagination](./src/cursor/cursor-pagination.md) — The `cursor-pagination` module provides base64url cursor encoding/decoding and adapter-backed forward/backward fetch op…
|
||||
- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [codes](./src/errors/codes.md) — Defines and registers all stable error codes for the `@suaveplan/pagination` package using `defineCodes` from `@suavepl…
|
||||
- [pagination-error](./src/errors/pagination-error.md) — `PaginationError` is the structured error base class for all failures originating within `@suaveplan/pagination`. It ex…
|
||||
- [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [keyset](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [keyset-pagination](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [pagination-otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [page-tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [database-adapter](./src/adapters/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_- [cursor-pagination](./src/cursor/cursor-pagination.md) — The `cursor-pagination` module provides base64url cursor encoding/decoding and adapter-backed forward/backward fetch op…
|
||||
- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — Defines and registers all stable error codes for the `@suaveplan/pagination` package using `defineCodes` from `@suavepl…
|
||||
- [pagination-error](./src/errors/pagination-error.md) — `PaginationError` is the structured error base class for all failures originating within `@suaveplan/pagination`. It ex…
|
||||
- [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [keyset](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_- [keyset-pagination](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_- [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_- [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [pagination-otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-adapter](./src/adapters/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_- [cursor-pagination](./src/cursor/cursor-pagination.md) — The `cursor-pagination` module provides base64url cursor encoding/decoding and adapter-backed forward/backward fetch op…
|
||||
- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — Defines and registers all stable error codes for the `@suaveplan/pagination` package using `defineCodes` from `@suavepl…
|
||||
- [pagination-error](./src/errors/pagination-error.md) — `PaginationError` is the structured error base class for all failures originating within `@suaveplan/pagination`. It ex…
|
||||
- [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [keyset](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_- [keyset-pagination](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_- [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_- [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [pagination-otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-adapter](./src/adapters/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_- [cursor-pagination](./src/cursor/cursor-pagination.md) — The `cursor-pagination` module provides base64url cursor encoding/decoding and adapter-backed forward/backward fetch op…
|
||||
- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — Defines and registers all stable error codes for the `@suaveplan/pagination` package using `defineCodes` from `@suavepl…
|
||||
- [pagination-error](./src/errors/pagination-error.md) — `PaginationError` is the structured error base class for all failures originating within `@suaveplan/pagination`. It ex…
|
||||
- [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [keyset](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_- [keyset-pagination](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_- [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_- [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [pagination-otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-adapter](./src/adapters/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cursor-pagination](./src/cursor/cursor-pagination.md) — The `cursor-pagination` module provides base64url cursor encoding/decoding and adapter-backed forward/backward fetch op…
|
||||
- [cursor](./src/cursor/cursor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — Defines and registers all stable error codes for the `@suaveplan/pagination` package using `defineCodes` from `@suavepl…
|
||||
- [pagination-error](./src/errors/pagination-error.md) — `PaginationError` is the structured error base class for all failures originating within `@suaveplan/pagination`. It ex…
|
||||
- [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ - [helpers](./src/helpers/helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [keyset](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_ - [keyset-pagination](./src/keyset/keyset-pagination.md) — <!-- begin:auto-docs --> _(no submodules)_- [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_ - [offset](./src/offset/offset.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pagination-otel](./src/otel/pagination-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_ - [page-tokens](./src/tokens/page-tokens.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/pagination
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { paginateArray } from "@suaveplan/pagination/offset";
|
||||
import { cursorPaginateArray } from "@suaveplan/pagination/cursor";
|
||||
import { createOffsetIterator, collectAll } from "@suaveplan/pagination/helpers";
|
||||
|
||||
// Offset: slice page 2, 10 items per page
|
||||
const items = Array.from({ length: 50 }, (_, i) => ({ id: i + 1 }));
|
||||
const page2 = paginateArray(items, 2, 10);
|
||||
// page2.data → items 11–20
|
||||
// page2.meta.totalPages → 5, page2.meta.hasNext → true
|
||||
|
||||
// Cursor: paginate an in-memory array by item.id
|
||||
const users = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }];
|
||||
const first = cursorPaginateArray(users, { limit: 2 }, (u) => u.id);
|
||||
// first.meta.endCursor → "b", first.meta.hasNextPage → true
|
||||
const second = cursorPaginateArray(
|
||||
users,
|
||||
{ cursor: first.meta.endCursor ?? undefined, limit: 2 },
|
||||
(u) => u.id
|
||||
);
|
||||
// second.data → [{ id: "c" }, { id: "d" }]
|
||||
|
||||
// Helpers: drain all pages into a flat array
|
||||
const iter = createOffsetIterator(
|
||||
(page, limit) => Promise.resolve(paginateArray(items, page, limit)),
|
||||
{ limit: 10 }
|
||||
);
|
||||
const all = await collectAll(iter); // → all 50 items
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| [src/cursor/cursor.md](src/cursor/cursor.md) | In-memory cursor pagination building blocks: `cursorPaginateArray`, `createEdges`, `createConnection`, `calculateCursorMeta`, `normalizeCursorRequest` |
|
||||
| [src/cursor/cursor-pagination.md](src/cursor/cursor-pagination.md) | Adapter-backed cursor pagination: `encodeCursor`, `decodeCursor`, `validateCursor`, `fetchAfter`, `fetchBefore` |
|
||||
| [src/offset/offset.md](src/offset/offset.md) | Offset/page-number pagination: `paginateArray`, `normalizeOffsetRequest`, `calculateOffsetMeta`, `pageToOffset`, `offsetToPage`, `getPageRange`, `estimateTotalCount` |
|
||||
| [src/keyset/keyset-pagination.md](src/keyset/keyset-pagination.md) | Seek-based keyset pagination: `keysetPaginate`, `fetchKeysetPage`, `encodeKeysetCursor`, `decodeKeysetCursor`, `extractSortKeys` |
|
||||
| [src/helpers/helpers.md](src/helpers/helpers.md) | Higher-order iteration utilities: `createOffsetIterator`, `createCursorIterator`, `collectAll`, `collectUpTo`, `mapPaginated`, `mergeOffsetResponses` |
|
||||
| [src/adapters/database-adapter.md](src/adapters/database-adapter.md) | `PaginationAdapter` interface, `MemoryPaginationAdapter`, and `createMemoryAdapter` factory |
|
||||
| [src/tokens/page-tokens.md](src/tokens/page-tokens.md) | Opaque page tokens with sort and filter metadata: `createCursorToken`, `createOffsetToken`, `encodePageToken`, `decodePageToken`, `hashFilters`, `validateTokenFilter` |
|
||||
| [src/otel/pagination-otel.md](src/otel/pagination-otel.md) | OTel instrumentation: `getPaginationInstruments` with request counter, page-size histogram, and encode/decode histogram; graceful no-op fallback |
|
||||
| [src/errors/pagination-error.md](src/errors/pagination-error.md) | `PaginationError` — structured error class extending `SuaveplanError` with `PAGINATION.*` codes |
|
||||
| [src/errors/codes.md](src/errors/codes.md) | `PAGINATION_CODES` constant and `PaginationCode` union type |
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Pagination Adapters
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The adapters module defines the `PaginationAdapter<T>` interface that all three pagination strategies — cursor, offset, and keyset — consume for data access. It also ships `MemoryPaginationAdapter<T>`, a pure in-memory reference implementation that stores items in a plain array and supports equality-based filtering and single-field sorting. The adapter abstraction decouples pagination logic from the underlying data layer, so swapping from an in-memory store to a SQL database or HTTP API requires implementing just two methods.
|
||||
|
||||
## Features
|
||||
|
||||
- `PaginationAdapter<T>` interface — two methods: `fetchPage` (paginated fetch) and `count` (total matching items).
|
||||
- `MemoryPaginationAdapter<T>` — in-memory implementation with `addItems`, `setItems`, `getAll`, `fetchPage`, and `count`.
|
||||
- `createMemoryAdapter` — factory function that creates a pre-populated `MemoryPaginationAdapter`.
|
||||
- `createSortConfig` — convenience builder for `SortConfig` values.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createMemoryAdapter, createSortConfig } from "@suaveplan/pagination/adapters";
|
||||
|
||||
const adapter = createMemoryAdapter([
|
||||
{ id: 1, name: "Alice", age: 30 },
|
||||
{ id: 2, name: "Bob", age: 25 },
|
||||
{ id: 3, name: "Carol", age: 35 },
|
||||
]);
|
||||
|
||||
const sort = createSortConfig("name", "asc");
|
||||
const page = await adapter.fetchPage(0, 2, sort);
|
||||
// page.items → [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
|
||||
// page.total → 3
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createMemoryAdapter } from "@suaveplan/pagination/adapters";
|
||||
import { fetchAfter, encodeCursor } from "@suaveplan/pagination/cursor";
|
||||
|
||||
const adapter = createMemoryAdapter(
|
||||
Array.from({ length: 50 }, (_, i) => ({ id: i + 1, score: i * 2 }))
|
||||
);
|
||||
|
||||
// Count with filter
|
||||
const activeCount = await adapter.count({ score: 10 });
|
||||
|
||||
// Use with cursor pagination
|
||||
const page = await fetchAfter(adapter, undefined, { limit: 5, sortBy: "id" }, (i) => i.id);
|
||||
// page.items → first 5 items sorted by id
|
||||
|
||||
// Replace dataset at runtime
|
||||
adapter.setItems([{ id: 99, score: 0 }]);
|
||||
const count = await adapter.count();
|
||||
// → 1
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface PaginationAdapter<T>`
|
||||
|
||||
| Method | Signature |
|
||||
|-------------|------------------------------------------------------------------------------|
|
||||
| `fetchPage` | `(offset, limit, sort?, filter?) => Promise<PageResult<T>>` |
|
||||
| `count` | `(filter?) => Promise<number>` |
|
||||
|
||||
`fetchPage` must return items in the requested sort order and the total matching count in `PageResult.total`.
|
||||
|
||||
### `class MemoryPaginationAdapter<T>`
|
||||
|
||||
| Method | Description |
|
||||
|-------------|-----------------------------------------------------|
|
||||
| `constructor(items?)` | Initialises with a shallow copy of `items`. |
|
||||
| `addItems(items)` | Appends items to the store. |
|
||||
| `setItems(items)` | Replaces the entire store. |
|
||||
| `getAll()` | Returns a shallow copy of all items. |
|
||||
| `fetchPage(...)` | Applies filter, sorts, slices, returns `PageResult`. |
|
||||
| `count(filter?)` | Returns the count of items matching the filter. |
|
||||
|
||||
### `createMemoryAdapter<T>(items?): MemoryPaginationAdapter<T>`
|
||||
|
||||
Factory that creates a `MemoryPaginationAdapter` pre-populated with `items` (default: empty array).
|
||||
|
||||
### `createSortConfig(field, direction?): SortConfig`
|
||||
|
||||
Builds a `{ field, direction }` sort config. Direction defaults to `"asc"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Filtering uses strict equality (`===`) on top-level fields only — nested field access is not supported in the reference implementation. Sorting uses `localeCompare` for strings and arithmetic comparison for numbers; null values sort last. The `items` array is always stored as a shallow copy to prevent external mutation from affecting the adapter state. Class members are `protected` (not `private`) to enable subclassing in tests without re-implementing the full interface.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Cursor Pagination (Adapter-based)
|
||||
|
||||
## Purpose
|
||||
|
||||
The `cursor-pagination` module provides base64url cursor encoding/decoding and adapter-backed forward/backward fetch operations for cursor-based pagination. Unlike `cursor.ts` — which operates on in-memory arrays — this module integrates with the `PaginationAdapter` interface so it can drive any data source (database, HTTP API, or in-memory store). Cursors are opaque URL-safe strings; they encode a typed value (`string | number`) so the type is preserved through the round-trip.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeCursor` — wraps a value in `{ v }` JSON and encodes to base64url (URL-safe, no padding).
|
||||
- `decodeCursor` — decodes a base64url cursor; returns `null` on any malformed input.
|
||||
- `validateCursor` — predicate: returns `true` for well-formed cursors.
|
||||
- `fetchAfter` — loads items after a cursor using a `PaginationAdapter`; supports `AbortSignal`.
|
||||
- `fetchBefore` — loads items before a cursor; supports `AbortSignal`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { encodeCursor, decodeCursor, validateCursor } from "@suaveplan/pagination/cursor";
|
||||
|
||||
const token = encodeCursor("user_42");
|
||||
// → "eyJ2IjoidXNlcl80MiJ9" (URL-safe base64url)
|
||||
|
||||
const value = decodeCursor(token);
|
||||
// → "user_42"
|
||||
|
||||
validateCursor(token); // → true
|
||||
validateCursor("!!!"); // → false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { fetchAfter } from "@suaveplan/pagination/cursor";
|
||||
import { createMemoryAdapter } from "@suaveplan/pagination/adapters";
|
||||
|
||||
const adapter = createMemoryAdapter([
|
||||
{ id: 1, name: "Alice" },
|
||||
{ id: 2, name: "Bob" },
|
||||
{ id: 3, name: "Carol" },
|
||||
]);
|
||||
|
||||
const page = await fetchAfter(
|
||||
adapter,
|
||||
undefined, // no cursor → start from beginning
|
||||
{ limit: 2, sortBy: "id", sortDirection: "asc" },
|
||||
(item) => item.id // sort-key extractor
|
||||
);
|
||||
// page.items → [{ id: 1 }, { id: 2 }]
|
||||
// page.hasNextPage → true
|
||||
// page.endCursor → encodeCursor(2)
|
||||
|
||||
// Abort support
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await fetchAfter(adapter, undefined, { limit: 10, signal: controller.signal }, (i) => i.id);
|
||||
// throws PaginationError(PAGINATION.ABORTED)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `encodeCursor(value: string | number): string`
|
||||
|
||||
Encodes a value to base64url. The JSON wrapper `{ v: value }` preserves the type across decode.
|
||||
|
||||
### `decodeCursor(cursor: string): string | number | null`
|
||||
|
||||
Decodes a base64url cursor. Returns `null` if the input is not valid base64url or the JSON payload is malformed.
|
||||
|
||||
### `validateCursor(cursor: string): boolean`
|
||||
|
||||
Returns `true` iff `decodeCursor` returns a non-null value.
|
||||
|
||||
### `fetchAfter<T>(adapter, cursor, options, getSortValue): Promise<CursorPageResult<T>>`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---------------|-----------------------------------|------------------------------------------|
|
||||
| `adapter` | `CursorAdapter<T>` | Data source adapter |
|
||||
| `cursor` | `string \| undefined` | Position to start after (undefined = start) |
|
||||
| `options` | `CursorPaginationOptions` | limit, sortBy, sortDirection, filter, signal |
|
||||
| `getSortValue` | `(item: T) => string \| number` | Extracts the sortable key from an item |
|
||||
|
||||
Throws `PaginationError(PAGINATION.ABORTED)` if `options.signal` is already aborted or fires during the fetch.
|
||||
|
||||
### `fetchBefore<T>(adapter, cursor, options, getSortValue): Promise<CursorPageResult<T>>`
|
||||
|
||||
Same signature as `fetchAfter`, but slices the window ending before the cursor position.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The base64url alphabet uses `-` and `_` instead of `+` and `/`, and strips trailing `=` padding — producing tokens safe for URL path segments and query parameters with no encoding required. Both `fetchAfter` and `fetchBefore` load all matching items in a single adapter call and then slice in memory; this is intentional for the in-memory reference adapter. Production adapters will implement efficient seeks directly in the storage layer.
|
||||
105
docs/foundation/data/pagination/src/cursor/cursor.md
Normal file
105
docs/foundation/data/pagination/src/cursor/cursor.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Cursor Pagination
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The cursor module provides the in-memory building blocks for Relay-style cursor-based pagination. It offers utilities for constructing edges, connections, and metadata from any sorted data source without requiring a database adapter. All functions are pure — they accept plain arrays and return plain objects — making them trivially testable and completely portable across runtimes.
|
||||
|
||||
Cursor-based pagination is preferred over offset pagination for large datasets or real-time feeds because it avoids the "jumping rows" problem: items inserted or deleted between page fetches cannot corrupt a cursor-based window.
|
||||
|
||||
## Features
|
||||
|
||||
- `calculateCursorMeta` — compute `CursorPaginationMeta` (start/end cursor, hasNextPage, hasPreviousPage, optional totalCount) from an edge array.
|
||||
- `createCursorResponse` — build a `CursorPaginatedResponse<T>` in one call.
|
||||
- `createConnection` — build a Relay-style `Connection<T>` with `edges` and `pageInfo`.
|
||||
- `createEdge` / `createEdges` — wrap nodes with their cursor strings.
|
||||
- `normalizeCursorRequest` — clamp limit, default direction, from raw input.
|
||||
- `cursorPaginateArray` — paginate an in-memory array with forward/backward cursor support.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { cursorPaginateArray } from "@suaveplan/pagination/cursor";
|
||||
|
||||
const items = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }];
|
||||
|
||||
// First page — no cursor, limit 2
|
||||
const page1 = cursorPaginateArray(items, { limit: 2 }, (item) => item.id);
|
||||
// page1.data → [{ id: "a" }, { id: "b" }]
|
||||
// page1.meta.endCursor → "b"
|
||||
// page1.meta.hasNextPage → true
|
||||
|
||||
// Next page — use endCursor from previous response
|
||||
const page2 = cursorPaginateArray(
|
||||
items,
|
||||
{ cursor: page1.meta.endCursor ?? undefined, limit: 2 },
|
||||
(item) => item.id
|
||||
);
|
||||
// page2.data → [{ id: "c" }, { id: "d" }]
|
||||
// page2.meta.hasNextPage → false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createEdges,
|
||||
createConnection,
|
||||
normalizeCursorRequest,
|
||||
} from "@suaveplan/pagination/cursor";
|
||||
|
||||
// Relay-style connection with totalCount
|
||||
const users = [
|
||||
{ id: "u1", name: "Alice" },
|
||||
{ id: "u2", name: "Bob" },
|
||||
];
|
||||
const req = normalizeCursorRequest({ limit: 10 }, { maxLimit: 50 });
|
||||
const edges = createEdges(users, (u) => u.id);
|
||||
const connection = createConnection(edges, false, "forward", 2);
|
||||
// connection.edges[0].cursor → "u1"
|
||||
// connection.pageInfo.hasNextPage → false
|
||||
// connection.totalCount → 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `calculateCursorMeta<T>(edges, hasMore, direction?, totalCount?)`
|
||||
|
||||
| Parameter | Type | Default |
|
||||
|-------------|-----------------------------|-------------|
|
||||
| `edges` | `Edge<T>[]` | — |
|
||||
| `hasMore` | `boolean` | — |
|
||||
| `direction` | `"forward" \| "backward"` | `"forward"` |
|
||||
| `totalCount` | `number \| undefined` | — |
|
||||
|
||||
Returns `CursorPaginationMeta`.
|
||||
|
||||
### `createCursorResponse<T>(edges, hasMore, direction?, totalCount?)`
|
||||
|
||||
Returns `CursorPaginatedResponse<T>` — the `data` field contains the unwrapped nodes.
|
||||
|
||||
### `createConnection<T>(edges, hasMore, direction?, totalCount?)`
|
||||
|
||||
Returns `Connection<T>` — the Relay-style form with `edges`, `pageInfo`, and optional `totalCount`.
|
||||
|
||||
### `createEdge<T>(node, cursor)`
|
||||
|
||||
Returns `Edge<T>` — `{ node, cursor }`.
|
||||
|
||||
### `createEdges<T>(nodes, getCursor)`
|
||||
|
||||
Maps `nodes` to `Edge<T>[]` using the provided cursor extractor. The extractor receives `(node, index)`.
|
||||
|
||||
### `normalizeCursorRequest(input, defaults?)`
|
||||
|
||||
Clamps `limit` to `[1, maxLimit]` (default `maxLimit = 100`). Defaults `direction` to `"forward"`. Returns a full `CursorPaginationRequest`.
|
||||
|
||||
### `cursorPaginateArray<T>(items, request, getCursor?)`
|
||||
|
||||
Slices `items` according to `cursor`, `limit`, and `direction`. Default cursor extractor is `(_, i) => String(i)`. Returns `CursorPaginatedResponse<T>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All cursor positions are string values matched by strict equality. The default extractor uses array indices as cursors; callers are encouraged to pass a stable domain-key extractor (e.g., `item.id`) in production. For backward pagination, `startIndex` is clamped to `0` to avoid negative slices. The `hasNextPage`/`hasPreviousPage` logic follows the GraphQL Relay Cursor Connections Specification.
|
||||
84
docs/foundation/data/pagination/src/errors/codes.md
Normal file
84
docs/foundation/data/pagination/src/errors/codes.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Pagination Error Codes
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines and registers all stable error codes for the `@suaveplan/pagination` package using `defineCodes` from `@suaveplan/error`. Every error thrown by `@suaveplan/pagination` carries one of these codes, enabling downstream consumers — loggers, HTTP problem-detail responders, retry wrappers — to classify failures without string-matching against messages.
|
||||
|
||||
## Features
|
||||
|
||||
- All codes grouped under the `PAGINATION` namespace (e.g. `PAGINATION.INVALID_CURSOR`)
|
||||
- Registered once at module load via a side-effect import in `src/index.ts`
|
||||
- Exports the `PaginationCode` union type for exhaustive `switch` discrimination
|
||||
- Seven stable codes covering every failure mode: invalid cursor, page, limit, keyset cursor, page token, sort config, and aborted iteration
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { PAGINATION_CODES, type PaginationCode } from "@suaveplan/pagination/errors";
|
||||
|
||||
// Use code constants in switch statements
|
||||
function handlePaginationError(code: PaginationCode) {
|
||||
switch (code) {
|
||||
case PAGINATION_CODES.INVALID_CURSOR:
|
||||
return "The provided cursor is malformed or expired.";
|
||||
case PAGINATION_CODES.INVALID_PAGE:
|
||||
return "Page number must be ≥ 1.";
|
||||
case PAGINATION_CODES.ABORTED:
|
||||
return "Pagination was cancelled by the caller.";
|
||||
default:
|
||||
return "Unknown pagination error.";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { PaginationError } from "@suaveplan/pagination/errors";
|
||||
import { PAGINATION_CODES } from "@suaveplan/pagination/errors";
|
||||
|
||||
// Catch and discriminate programmatically
|
||||
try {
|
||||
const page = await fetchAfter(adapter, request, { signal });
|
||||
} catch (err) {
|
||||
if (err instanceof PaginationError) {
|
||||
if (err.code === PAGINATION_CODES.ABORTED) {
|
||||
// User cancelled — not a real error
|
||||
return null;
|
||||
}
|
||||
if (err.code === PAGINATION_CODES.INVALID_CURSOR) {
|
||||
// Stale cursor from a previous session — restart from page 1
|
||||
return fetchFirstPage(adapter, request);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PAGINATION_CODES`
|
||||
|
||||
`Record` of stable error code strings produced by `defineCodes("PAGINATION", [...])`.
|
||||
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `INVALID_CURSOR` | `"PAGINATION.INVALID_CURSOR"` |
|
||||
| `INVALID_PAGE` | `"PAGINATION.INVALID_PAGE"` |
|
||||
| `INVALID_LIMIT` | `"PAGINATION.INVALID_LIMIT"` |
|
||||
| `INVALID_KEYSET_CURSOR` | `"PAGINATION.INVALID_KEYSET_CURSOR"` |
|
||||
| `INVALID_TOKEN` | `"PAGINATION.INVALID_TOKEN"` |
|
||||
| `INVALID_SORT` | `"PAGINATION.INVALID_SORT"` |
|
||||
| `ABORTED` | `"PAGINATION.ABORTED"` |
|
||||
|
||||
### `PaginationCode`
|
||||
|
||||
Union type of all values in `PAGINATION_CODES`. Use this as a discriminant in `switch` statements.
|
||||
|
||||
```typescript
|
||||
type PaginationCode = "PAGINATION.INVALID_CURSOR" | "PAGINATION.INVALID_PAGE" | ...
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes are defined with `defineCodes` from `@suaveplan/error`, which registers them in the process-wide error code registry and ensures no namespace collision with other packages. The module is imported as a side effect from `src/index.ts` so registration happens automatically when the package is loaded. Tests can call `defineCodes` directly if they need to clear and re-register in isolation.
|
||||
@@ -0,0 +1,69 @@
|
||||
# PaginationError
|
||||
|
||||
## Purpose
|
||||
|
||||
`PaginationError` is the structured error base class for all failures originating within `@suaveplan/pagination`. It extends `SuaveplanError` from `@suaveplan/error` and carries a fully-qualified `PAGINATION.*` code, making every failure machine-identifiable without string-matching on messages. This tight coupling between errors and codes allows downstream consumers to handle specific pagination failures — such as a malformed cursor or an aborted iterator — with type-safe switch statements rather than fragile `instanceof` chains.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `SuaveplanError<PaginationCode>` — inherits cause-chain traversal, OTel span-event adapter, and Zod-validated wire serialisation from the error substrate.
|
||||
- Carries one of seven `PAGINATION.*` codes: `INVALID_CURSOR`, `INVALID_PAGE`, `INVALID_LIMIT`, `INVALID_KEYSET_CURSOR`, `INVALID_TOKEN`, `INVALID_SORT`, `ABORTED`.
|
||||
- Accepts an optional `SuaveplanErrorOptions` bag for `cause`, `context`, and `meta` fields.
|
||||
- Fully tree-shakeable — no global registry side-effects on import.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { PaginationError } from "@suaveplan/pagination/errors";
|
||||
import { PAGINATION_CODES } from "@suaveplan/pagination/errors";
|
||||
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.INVALID_CURSOR,
|
||||
"pagination.invalidCursor"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { PaginationError, PAGINATION_CODES } from "@suaveplan/pagination/errors";
|
||||
|
||||
function decodeCursor(raw: string): string {
|
||||
try {
|
||||
return atob(raw);
|
||||
} catch (cause) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.INVALID_CURSOR,
|
||||
"pagination.invalidCursor",
|
||||
{ cause, context: { raw } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Catching by code:
|
||||
try {
|
||||
decodeCursor("!!!invalid!!!");
|
||||
} catch (err) {
|
||||
if (err instanceof PaginationError && err.code === PAGINATION_CODES.INVALID_CURSOR) {
|
||||
console.error("Bad cursor supplied by client", err.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new PaginationError(code, messageKey, options?)`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-------------|-------------------------|--------------------------------------------------------|
|
||||
| `code` | `PaginationCode` | One of the `PAGINATION.*` constants from `codes.ts`. |
|
||||
| `messageKey` | `string` | i18n key for human-readable message resolution. |
|
||||
| `options` | `SuaveplanErrorOptions` | Optional: `cause`, `context`, `meta`, `redact` fields. |
|
||||
|
||||
Returns a `PaginationError` instance, which is also an instance of `SuaveplanError` and `Error`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor is intentionally a pass-through — it exists solely to widen the `protected` base constructor to `public` so callers outside the package can construct instances directly. The Biome `noUselessConstructor` suppression is required for this pattern. All real logic lives in `SuaveplanError`.
|
||||
|
||||
The `PAGINATION_CODES` constant (defined in `codes.ts`) is generated via `defineCodes("PAGINATION", [...])` from `@suaveplan/error`, which produces a frozen record mapping each short reason to its fully-qualified `PAGINATION.<REASON>` string. This ensures the code registry is compile-time exhaustive and cannot drift from the type.
|
||||
110
docs/foundation/data/pagination/src/helpers/helpers.md
Normal file
110
docs/foundation/data/pagination/src/helpers/helpers.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Pagination Helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The helpers module provides higher-order utilities for consuming paginated APIs: async iterators for offset and cursor strategies, collection helpers that drain iterators into arrays, a mapping helper, an emptiness check, and a merge utility for combining pre-fetched pages. All iterators support `AbortSignal` so long-running collection passes can be cancelled between page fetches without leaving hanging promises.
|
||||
|
||||
These helpers work with any function that returns an `OffsetPaginatedResponse<T>` or `CursorPaginatedResponse<T>` — they are not tied to a specific adapter or data source.
|
||||
|
||||
## Features
|
||||
|
||||
- `createOffsetIterator` — `AsyncIterable<PaginatedIteratorResult<T>>` driven by an offset fetcher function.
|
||||
- `createCursorIterator` — `AsyncIterable<PaginatedIteratorResult<T>>` driven by a cursor fetcher function.
|
||||
- `collectAll` — drain an iterable into a flat `T[]`; abortable.
|
||||
- `collectUpTo` — drain up to N items; stops early; abortable.
|
||||
- `mapPaginated` — map items across all pages, returning a flat `U[]`.
|
||||
- `isPaginatedResponseEmpty` — check whether a response has zero items.
|
||||
- `mergeOffsetResponses` — combine sequential pages into a single `{ data, total }`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createOffsetIterator, collectAll } from "@suaveplan/pagination/helpers";
|
||||
import { paginateArray } from "@suaveplan/pagination/offset";
|
||||
|
||||
const items = Array.from({ length: 25 }, (_, i) => i + 1);
|
||||
|
||||
const iter = createOffsetIterator(
|
||||
(page, limit) => Promise.resolve(paginateArray(items, page, limit)),
|
||||
{ limit: 10 }
|
||||
);
|
||||
|
||||
const all = await collectAll(iter);
|
||||
// → [1, 2, ..., 25]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createCursorIterator,
|
||||
collectUpTo,
|
||||
mapPaginated,
|
||||
} from "@suaveplan/pagination/helpers";
|
||||
|
||||
// Abort after 2 seconds
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
const iter = createCursorIterator(
|
||||
async (cursor, limit) => fetchUsersPage(cursor, limit),
|
||||
{ limit: 50, signal: controller.signal }
|
||||
);
|
||||
|
||||
// Collect at most 200 users
|
||||
const users = await collectUpTo(iter, 200, controller.signal);
|
||||
|
||||
// Map without collecting all pages first
|
||||
const names = await mapPaginated(
|
||||
createCursorIterator(async (c, l) => fetchUsersPage(c, l), { limit: 100 }),
|
||||
(u) => u.name
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createOffsetIterator<T>(fetcher, opts?): AsyncIterable<PaginatedIteratorResult<T>>`
|
||||
|
||||
| Parameter | Type | Default |
|
||||
|------------------|------------------------------------------------------|---------|
|
||||
| `fetcher` | `(page, limit) => Promise<OffsetPaginatedResponse<T>>` | — |
|
||||
| `opts.startPage` | `number` | `1` |
|
||||
| `opts.limit` | `number` | `20` |
|
||||
| `opts.signal` | `AbortSignal \| undefined` | — |
|
||||
|
||||
Throws `PaginationError(PAGINATION.ABORTED)` between pages if the signal fires.
|
||||
|
||||
### `createCursorIterator<T>(fetcher, opts?): AsyncIterable<PaginatedIteratorResult<T>>`
|
||||
|
||||
| Parameter | Type | Default |
|
||||
|--------------|-------------------------------------------------------------|---------|
|
||||
| `fetcher` | `(cursor, limit) => Promise<CursorPaginatedResponse<T>>` | — |
|
||||
| `opts.limit` | `number` | `20` |
|
||||
| `opts.signal` | `AbortSignal \| undefined` | — |
|
||||
|
||||
### `collectAll<T>(iterable, signal?): Promise<T[]>`
|
||||
|
||||
Drains the entire iterable. Checks `signal?.aborted` after each page.
|
||||
|
||||
### `collectUpTo<T>(iterable, maxItems, signal?): Promise<T[]>`
|
||||
|
||||
Returns at most `maxItems` items, stopping early without consuming subsequent pages.
|
||||
|
||||
### `mapPaginated<T, U>(iterable, mapper): Promise<U[]>`
|
||||
|
||||
Maps each item with `mapper` and returns a flat `U[]`.
|
||||
|
||||
### `isPaginatedResponseEmpty<T>(response): boolean`
|
||||
|
||||
Returns `response.data.length === 0`. Accepts both `OffsetPaginatedResponse<T>` and `CursorPaginatedResponse<T>`.
|
||||
|
||||
### `mergeOffsetResponses<T>(responses): { data: T[]; total: number }`
|
||||
|
||||
Concatenates `data` arrays; `total` is taken from the first response (assumed to be the authoritative count for all pages).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `createOffsetIterator` and `createCursorIterator` factories close over their state (current page / cursor, done flag) in the `[Symbol.asyncIterator]()` factory, so each `for await` loop gets a fresh independent cursor. The abort check happens both before and after the async fetch call to handle signals that fire during IO.
|
||||
103
docs/foundation/data/pagination/src/keyset/keyset-pagination.md
Normal file
103
docs/foundation/data/pagination/src/keyset/keyset-pagination.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Keyset Pagination
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The keyset pagination module implements seek-based (keyset) pagination, which avoids the performance degradation of deep offset pagination by using the sort-key values of the last seen item as the cursor position. Unlike numeric offsets — which require the database to scan and discard all preceding rows — a keyset cursor lets the storage engine jump directly to the next page using an indexed seek.
|
||||
|
||||
This module provides both adapter-backed (`fetchKeysetPage`) and in-memory (`keysetPaginate`) variants, plus encoding/decoding utilities for opaque keyset cursor strings.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodeKeysetCursor` — serialize `KeysetSortKey[]` to a URL-safe base64url string.
|
||||
- `decodeKeysetCursor` — deserialize and validate a keyset cursor; returns `null` on malformed input.
|
||||
- `extractSortKeys` — pull sort-key values from a typed item based on sort config.
|
||||
- `fetchKeysetPage` — fetch a page from an adapter starting after a keyset position.
|
||||
- `keysetPaginate` — paginate an in-memory array using keyset logic.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { keysetPaginate, encodeKeysetCursor, decodeKeysetCursor } from "@suaveplan/pagination/keyset";
|
||||
|
||||
const items = [
|
||||
{ id: 1, name: "Alice" },
|
||||
{ id: 2, name: "Bob" },
|
||||
{ id: 3, name: "Carol" },
|
||||
{ id: 4, name: "Dave" },
|
||||
];
|
||||
const options = { limit: 2, sortKeys: [{ field: "id", direction: "asc" as const }] };
|
||||
|
||||
// First page
|
||||
const page1 = keysetPaginate(items, null, options);
|
||||
// page1.items → [{ id: 1 }, { id: 2 }]
|
||||
// page1.hasMore → true
|
||||
|
||||
// Encode position for the next request
|
||||
const cursor = encodeKeysetCursor(page1.lastKeys!);
|
||||
|
||||
// Second page — pass decoded keys
|
||||
const page2 = keysetPaginate(items, decodeKeysetCursor(cursor), options);
|
||||
// page2.items → [{ id: 3 }, { id: 4 }]
|
||||
// page2.hasMore → false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { fetchKeysetPage } from "@suaveplan/pagination/keyset";
|
||||
import { createMemoryAdapter } from "@suaveplan/pagination/adapters";
|
||||
|
||||
const adapter = createMemoryAdapter(
|
||||
Array.from({ length: 100 }, (_, i) => ({ id: i + 1, score: Math.floor(i / 10) }))
|
||||
);
|
||||
|
||||
// Composite sort: score desc, then id asc
|
||||
const options = {
|
||||
limit: 5,
|
||||
sortKeys: [
|
||||
{ field: "score", direction: "desc" as const },
|
||||
{ field: "id", direction: "asc" as const },
|
||||
],
|
||||
};
|
||||
|
||||
let lastKeys = null;
|
||||
do {
|
||||
const page = await fetchKeysetPage(adapter, lastKeys, options);
|
||||
console.log(page.items);
|
||||
lastKeys = page.lastKeys;
|
||||
} while (lastKeys !== null);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `encodeKeysetCursor(keys: KeysetSortKey[]): string`
|
||||
|
||||
Serialises the keys array to JSON, encodes as base64url. Throws `PaginationError(PAGINATION.INVALID_KEYSET_CURSOR)` only if `JSON.stringify` fails (e.g., circular references).
|
||||
|
||||
### `decodeKeysetCursor(cursor: string): KeysetSortKey[] | null`
|
||||
|
||||
Decodes a base64url keyset cursor. Validates each element has `field: string`, `direction: "asc"|"desc"`, and `value: string|number`. Returns `null` on any invalid input.
|
||||
|
||||
### `extractSortKeys<T>(item, sortKeys): KeysetSortKey[]`
|
||||
|
||||
| Parameter | Type |
|
||||
|-----------|---------------------------------------------------|
|
||||
| `item` | `T` |
|
||||
| `sortKeys` | `Array<{ field: string; direction: SortDirection }>` |
|
||||
|
||||
Returns `KeysetSortKey[]` with runtime values extracted from the item.
|
||||
|
||||
### `fetchKeysetPage<T>(adapter, lastKeys, options): Promise<KeysetPageResult<T>>`
|
||||
|
||||
Loads all items from the adapter, filters those after `lastKeys`, returns at most `options.limit`. Returns `{ items: [], hasMore: false, lastKeys: null }` when `sortKeys` is empty.
|
||||
|
||||
### `keysetPaginate<T>(items, lastKeys, options): KeysetPageResult<T>`
|
||||
|
||||
Sorts `items` by the primary sort key, filters items after `lastKeys`, returns at most `options.limit`. Equivalent to `fetchKeysetPage` but operates on a plain array without async IO.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The comparison logic for `isAfterKeyset` short-circuits on the first discriminating key in a composite sort — exactly matching how a B-tree index seek works. String comparison uses `localeCompare` for locale-aware ordering; numeric comparison uses `<` to avoid IEEE-754 edge cases with `===`. The in-memory `keysetPaginate` sorts a shallow copy of the input array to avoid mutating the caller's data.
|
||||
105
docs/foundation/data/pagination/src/offset/offset.md
Normal file
105
docs/foundation/data/pagination/src/offset/offset.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Offset Pagination
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The offset pagination module provides the computational building blocks for traditional page-number-based pagination. It handles request normalisation, metadata calculation, page-to-offset conversion, and in-memory array slicing. All functions are pure and synchronous except `estimateTotalCount`, which calls an adapter to sample the dataset.
|
||||
|
||||
Offset pagination is the correct choice when total count is cheap to compute, pages are shallow (< a few thousand rows), or the UI must render absolute page numbers. For deep pagination on large datasets, prefer keyset or cursor pagination to avoid scanning skipped rows.
|
||||
|
||||
## Features
|
||||
|
||||
- `calculateOffsetMeta` — compute full metadata (page, limit, total, totalPages, hasNext, hasPrevious).
|
||||
- `createOffsetResponse` — assemble a typed `OffsetPaginatedResponse<T>` envelope.
|
||||
- `normalizeOffsetRequest` — clamp page/limit to configured defaults, default sortOrder.
|
||||
- `pageToOffset` — convert 1-based page number to a zero-based array/SQL offset.
|
||||
- `offsetToPage` — convert a zero-based offset back to a 1-based page number.
|
||||
- `getPageRange` — produce a windowed list of page numbers for UI navigation controls.
|
||||
- `paginateArray` — slice an in-memory array and return a full `OffsetPaginatedResponse<T>`.
|
||||
- `estimateTotalCount` — sample-based approximate total count via adapter.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { paginateArray } from "@suaveplan/pagination/offset";
|
||||
|
||||
const items = Array.from({ length: 50 }, (_, i) => ({ id: i + 1 }));
|
||||
const page2 = paginateArray(items, 2, 10);
|
||||
// page2.data → items 11–20
|
||||
// page2.meta.page → 2
|
||||
// page2.meta.totalPages → 5
|
||||
// page2.meta.hasNext → true
|
||||
// page2.meta.hasPrevious → true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
normalizeOffsetRequest,
|
||||
getPageRange,
|
||||
pageToOffset,
|
||||
offsetToPage,
|
||||
} from "@suaveplan/pagination/offset";
|
||||
|
||||
// Normalise untrusted input from a query string
|
||||
const req = normalizeOffsetRequest(
|
||||
{ page: -5, limit: 9999 },
|
||||
{ maxLimit: 100, defaultLimit: 20 }
|
||||
);
|
||||
// req.page → 1 (clamped), req.limit → 100 (clamped)
|
||||
|
||||
// Render page buttons for a 300-item result set
|
||||
const pages = getPageRange(15, 30, 7);
|
||||
// → [12, 13, 14, 15, 16, 17, 18]
|
||||
|
||||
// Convert offset 40 at limit 10 → page 5
|
||||
const page = offsetToPage(40, 10); // → 5
|
||||
const offset = pageToOffset(5, 10); // → 40
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `calculateOffsetMeta(total, page, limit): OffsetPaginationMeta`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|----------|--------------------------------------|
|
||||
| `total` | `number` | Total item count across all pages |
|
||||
| `page` | `number` | Current 1-based page number |
|
||||
| `limit` | `number` | Items per page |
|
||||
|
||||
Returns `OffsetPaginationMeta` with `totalPages`, `hasNext`, and `hasPrevious`.
|
||||
|
||||
### `createOffsetResponse<T>(data, total, page, limit): OffsetPaginatedResponse<T>`
|
||||
|
||||
Wraps `data` and computed metadata into the standard response envelope.
|
||||
|
||||
### `normalizeOffsetRequest(input, defaults?): OffsetPaginationRequest`
|
||||
|
||||
Clamps `page` to `≥ 1` and `limit` to `[minLimit, maxLimit]`. Defaults: minLimit=1, maxLimit=100, defaultLimit=20, defaultPage=1.
|
||||
|
||||
### `pageToOffset(page, limit): number`
|
||||
|
||||
Returns `Math.max(0, (page - 1) * limit)`.
|
||||
|
||||
### `offsetToPage(offset, limit): number`
|
||||
|
||||
Returns `Math.floor(offset / limit) + 1`. Returns `1` if `limit ≤ 0`.
|
||||
|
||||
### `getPageRange(currentPage, totalPages, windowSize?): number[]`
|
||||
|
||||
Default `windowSize` is 5. The window is centred on `currentPage` and never exceeds `[1, totalPages]`.
|
||||
|
||||
### `paginateArray<T>(items, page, limit): OffsetPaginatedResponse<T>`
|
||||
|
||||
Slices `items[offset..offset+limit]` and builds the full response with computed metadata.
|
||||
|
||||
### `estimateTotalCount(adapter, opts): Promise<number>`
|
||||
|
||||
Samples up to `opts.sampleSize` (default 1000) items from the adapter and extrapolates. Returns `0` if the adapter reports zero total.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`normalizeOffsetRequest` uses `Math.floor` to discard fractional page/limit values from untrusted API input. The `sortOrder` field defaults to `"desc"` because most read-heavy UIs show newest items first. `getPageRange` is intentionally view-agnostic — it returns page numbers only; rendering (ellipsis, prev/next buttons) belongs in the UI layer.
|
||||
75
docs/foundation/data/pagination/src/otel/pagination-otel.md
Normal file
75
docs/foundation/data/pagination/src/otel/pagination-otel.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Pagination OTel Instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The OTel module provides optional OpenTelemetry instrumentation for pagination operations. It exposes three instruments — a counter for request totals, a histogram for page sizes, and a histogram for encode/decode durations — using a lazy-initialisation pattern that gracefully degrades to silent no-op stubs when `@opentelemetry/api` is absent from the runtime. This makes instrumentation completely safe in environments without an OTel exporter and avoids adding a hard peer dependency on the OTel API.
|
||||
|
||||
## Features
|
||||
|
||||
- `getPaginationInstruments()` — returns the singleton `PaginationInstruments` set, initialising from OTel API on first call.
|
||||
- `resetPaginationInstruments()` — resets the cached singleton for test isolation.
|
||||
- `PaginationInstruments` type — exposes `requestTotal` (counter), `pageSize` (histogram), and `encodeDecode` (histogram).
|
||||
- Graceful no-op fallback — all instruments are safe to call without an exporter installed.
|
||||
- Named instruments follow the OpenTelemetry semantic-conventions pattern: `pagination.<scope>.<metric>`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { getPaginationInstruments } from "@suaveplan/pagination/otel";
|
||||
|
||||
const { requestTotal, pageSize } = getPaginationInstruments();
|
||||
|
||||
// Record a successful cursor request
|
||||
requestTotal.add(1, { strategy: "cursor", outcome: "success" });
|
||||
|
||||
// Record that a page returned 10 items
|
||||
pageSize.record(10, { strategy: "cursor" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { getPaginationInstruments } from "@suaveplan/pagination/otel";
|
||||
|
||||
async function trackedFetch(cursor: string | undefined) {
|
||||
const { requestTotal, pageSize, encodeDecode } = getPaginationInstruments();
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const result = await fetchPage(cursor);
|
||||
requestTotal.add(1, { strategy: "cursor", outcome: "success" });
|
||||
pageSize.record(result.items.length, { strategy: "cursor" });
|
||||
encodeDecode.record(performance.now() - t0, { direction: "decode", type: "cursor" });
|
||||
return result;
|
||||
} catch (err) {
|
||||
requestTotal.add(1, { strategy: "cursor", outcome: "error" });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getPaginationInstruments(): PaginationInstruments`
|
||||
|
||||
Returns the singleton instrument set. On the first call, attempts to acquire a meter from `@opentelemetry/api` via dynamic `require`. If OTel is absent or has no `metrics` API, all instruments are replaced with silent stubs. The result is cached for subsequent calls.
|
||||
|
||||
### `resetPaginationInstruments(): void`
|
||||
|
||||
Resets the cached instruments to `null`, forcing re-initialisation on the next `getPaginationInstruments()` call. Intended for test isolation only — do not call in production.
|
||||
|
||||
### `PaginationInstruments`
|
||||
|
||||
| Field | Type | OTel instrument name |
|
||||
|---------------|------------------|-----------------------------------------------|
|
||||
| `requestTotal` | `OtelCounter` | `pagination.request.total` |
|
||||
| `pageSize` | `OtelHistogram` | `pagination.page.size` |
|
||||
| `encodeDecode` | `OtelHistogram` | `pagination.encode_decode.duration_ms` |
|
||||
|
||||
Instruments support an optional `attributes` record (e.g., `{ strategy: "cursor", outcome: "success" }`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Dynamic `require` is used (instead of a static import) so the module compiles without `@opentelemetry/api` in `package.json`. The try/catch wraps the entire OTel setup so any initialisation error (missing package, no `metrics` key) silently activates the no-op path. Callers do not need to check whether OTel is available — all four instrument methods (`add`, `record`) are always safe to call.
|
||||
95
docs/foundation/data/pagination/src/tokens/page-tokens.md
Normal file
95
docs/foundation/data/pagination/src/tokens/page-tokens.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Page Tokens
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The page tokens module provides opaque, URL-safe token strings that carry all the state needed to resume a paginated query: the cursor or offset value, sort field and direction, and an optional filter hash for cache invalidation. Tokens are encoded as base64url JSON so they survive URL query-string round-trips without additional percent-encoding.
|
||||
|
||||
This module sits above the raw cursor encoding in `cursor-pagination.ts`: where that module encodes a single value, page tokens encode a structured payload that includes sort and filter metadata — making them suitable for stateless pagination APIs where the client must carry all context.
|
||||
|
||||
## Features
|
||||
|
||||
- `encodePageToken` / `decodePageToken` — full payload round-trip with type validation.
|
||||
- `createCursorToken` — create a token from a domain value (ID, score, timestamp) and optional sort/filter info.
|
||||
- `createOffsetToken` — shortcut for offset-based tokens.
|
||||
- `extractCursorValue` — pull just the value field from a token.
|
||||
- `extractSortInfo` — pull sort field and direction from a token.
|
||||
- `hashFilters` — stable djb2 hash of a filter map for cache-invalidation.
|
||||
- `validateTokenFilter` — check that a token's filter hash matches an expected value.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createCursorToken, decodePageToken } from "@suaveplan/pagination/tokens";
|
||||
|
||||
const token = createCursorToken("user_99", "createdAt", "desc");
|
||||
// → opaque base64url string
|
||||
|
||||
const payload = decodePageToken(token);
|
||||
// → { v: "user_99", s: "createdAt", d: "desc" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createCursorToken,
|
||||
hashFilters,
|
||||
validateTokenFilter,
|
||||
extractSortInfo,
|
||||
} from "@suaveplan/pagination/tokens";
|
||||
|
||||
const filters = { status: "active", role: "admin" };
|
||||
const hash = hashFilters(filters);
|
||||
// → stable hash string, e.g. "1q7f9k"
|
||||
|
||||
const token = createCursorToken("cursor_abc", "name", "asc", hash);
|
||||
|
||||
// On the next request, verify the filter hasn't changed
|
||||
const valid = validateTokenFilter(token, hash); // → true
|
||||
const invalid = validateTokenFilter(token, "differenthash"); // → false
|
||||
|
||||
// Extract sort metadata to rebuild the query
|
||||
const sort = extractSortInfo(token);
|
||||
// → { sortBy: "name", sortDirection: "asc" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `encodePageToken(payload: PageTokenPayload): string`
|
||||
|
||||
Serialises the payload to JSON and encodes as base64url. The payload must have a `v` field (string or number); `s`, `d`, `f` are optional.
|
||||
|
||||
### `decodePageToken(token: string): PageTokenPayload | null`
|
||||
|
||||
Decodes and validates a token. Returns `null` for any invalid input, never throws.
|
||||
|
||||
### `createCursorToken(value, sortBy?, sortDirection?, filterHash?): string`
|
||||
|
||||
Builds and encodes a `PageTokenPayload` from a cursor value and optional sort/filter metadata.
|
||||
|
||||
### `createOffsetToken(offset, sortBy?, sortDirection?, filterHash?): string`
|
||||
|
||||
Alias of `createCursorToken` with `offset: number` as the first argument.
|
||||
|
||||
### `extractCursorValue(token: string): string | number | null`
|
||||
|
||||
Returns the `v` field of the decoded token, or `null` if the token is invalid.
|
||||
|
||||
### `extractSortInfo(token: string): { sortBy?: string; sortDirection?: SortDirection } | null`
|
||||
|
||||
Returns the `s` and `d` fields of the decoded token, or `null` if the token is invalid.
|
||||
|
||||
### `hashFilters(filters: Record<string, unknown>): string`
|
||||
|
||||
Sorts keys lexicographically, concatenates `key=value` pairs, and applies a djb2 hash. Returns the absolute hash as a base-36 string. Suitable for cache-invalidation only — not cryptographic.
|
||||
|
||||
### `validateTokenFilter(token: string, expectedFilterHash: string): boolean`
|
||||
|
||||
Returns `true` when the token is valid and its `f` field equals `expectedFilterHash`. Returns `true` (unconditional) when the token has no `f` field. Returns `false` for invalid tokens or mismatched hashes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The djb2 hash in `hashFilters` uses bitwise OR with `0` to keep the accumulator within signed 32-bit integer range — preventing floating-point precision loss for long filter strings. The filter hash is NOT a cryptographic MAC; it cannot prevent token forgery. Use HMAC-signed tokens if forgery resistance is required.
|
||||
@@ -0,0 +1,81 @@
|
||||
# pagination.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions for the three pagination styles supported by `@suaveplan/pagination`: offset, cursor (Relay-style connections), and keyset. Keeping every request/response/meta/edge shape in one flat file makes the cross-style invariants obvious and gives adapter authors a single file to import from without pulling in any runtime code.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero runtime cost — all exports are pure TypeScript types
|
||||
- Offset family: `OffsetPaginationRequest`, `OffsetPaginationMeta`, `OffsetPaginatedResponse<T>`
|
||||
- Cursor family: `CursorPaginationRequest`, `CursorPaginationMeta`, `CursorPaginatedResponse<T>`, Relay-style `Edge<T>` and `Connection<T>` shapes
|
||||
- Keyset family: `KeysetPaginationRequest`, `KeysetPaginationMeta`, `KeysetPaginatedResponse<T>`, `SortKey` descriptor
|
||||
- Shared `SortDirection` and `CursorDirection` unions used by every family
|
||||
- `PaginationAdapter<T>` contract describing the minimal `fetch` surface any backend must implement to plug into the generic helpers
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
OffsetPaginationRequest,
|
||||
OffsetPaginatedResponse,
|
||||
} from "@suaveplan/pagination/types/pagination.types";
|
||||
|
||||
const request: OffsetPaginationRequest = { page: 1, limit: 20, sortBy: "createdAt", sortOrder: "desc" };
|
||||
|
||||
const response: OffsetPaginatedResponse<{ id: string }> = {
|
||||
data: [{ id: "a" }, { id: "b" }],
|
||||
meta: { page: 1, limit: 20, total: 42, totalPages: 3, hasNext: true, hasPrevious: false },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Connection,
|
||||
CursorPaginationRequest,
|
||||
CursorPaginatedResponse,
|
||||
Edge,
|
||||
} from "@suaveplan/pagination/types/pagination.types";
|
||||
|
||||
interface Post { id: string; title: string; }
|
||||
|
||||
const request: CursorPaginationRequest = { cursor: undefined, limit: 10, direction: "forward" };
|
||||
|
||||
const edges: Edge<Post>[] = [
|
||||
{ cursor: "cur_1", node: { id: "p_1", title: "Hello" } },
|
||||
{ cursor: "cur_2", node: { id: "p_2", title: "World" } },
|
||||
];
|
||||
|
||||
const connection: Connection<Post> = {
|
||||
edges,
|
||||
pageInfo: {
|
||||
startCursor: "cur_1",
|
||||
endCursor: "cur_2",
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
totalCount: 42,
|
||||
};
|
||||
|
||||
const response: CursorPaginatedResponse<Post> = {
|
||||
data: edges.map((e) => e.node),
|
||||
meta: connection.pageInfo,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SortDirection` — `"asc" | "desc"`.
|
||||
- `CursorDirection` — `"forward" | "backward"`.
|
||||
- `OffsetPaginationRequest` / `OffsetPaginationMeta` / `OffsetPaginatedResponse<T>`.
|
||||
- `CursorPaginationRequest` / `CursorPaginationMeta` / `CursorPaginatedResponse<T>`.
|
||||
- `Edge<T>`, `Connection<T>` — Relay-style graph-layer shapes.
|
||||
- `KeysetPaginationRequest` / `KeysetPaginationMeta` / `KeysetPaginatedResponse<T>`.
|
||||
- `SortKey` — sort column descriptor used by keyset encoders.
|
||||
- `PaginationAdapter<T>` — backend-agnostic fetch contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every shape is flat and JSON-serialisable by design so responses can cross the wire without custom reviver logic. `totalCount` is deliberately optional on cursor/keyset metas because counting is expensive in large tables and many callers don't need it. The `Connection`/`Edge` naming mirrors the Relay specification so GraphQL adapters can reuse these types verbatim. `Clock` is imported from `@suaveplan/types` rather than redefined locally, keeping the time-injection shape consistent with `datetime`, `retry`, `timing`, and the rest of the project.
|
||||
Reference in New Issue
Block a user