Initial Commit
This commit is contained in:
92
packages/foundation/data/pagination/README.md
Normal file
92
packages/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 |
|
||||
|
||||
146
packages/foundation/data/pagination/package.json
Normal file
146
packages/foundation/data/pagination/package.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"name": "@suaveplan/pagination",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal pagination primitives: cursor, offset, keyset strategies with adapters, iterators, and type-safe request/response shapes.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./cursor": {
|
||||
"import": {
|
||||
"types": "./dist/cursor/cursor.d.ts",
|
||||
"default": "./dist/cursor/cursor.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/cursor/cursor.d.ts",
|
||||
"default": "./dist/cursor/cursor.js"
|
||||
}
|
||||
},
|
||||
"./offset": {
|
||||
"import": {
|
||||
"types": "./dist/offset/offset.d.ts",
|
||||
"default": "./dist/offset/offset.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/offset/offset.d.ts",
|
||||
"default": "./dist/offset/offset.js"
|
||||
}
|
||||
},
|
||||
"./keyset": {
|
||||
"import": {
|
||||
"types": "./dist/keyset/keyset-pagination.d.ts",
|
||||
"default": "./dist/keyset/keyset-pagination.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/keyset/keyset-pagination.d.ts",
|
||||
"default": "./dist/keyset/keyset-pagination.js"
|
||||
}
|
||||
},
|
||||
"./helpers": {
|
||||
"import": {
|
||||
"types": "./dist/helpers/helpers.d.ts",
|
||||
"default": "./dist/helpers/helpers.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/helpers/helpers.d.ts",
|
||||
"default": "./dist/helpers/helpers.js"
|
||||
}
|
||||
},
|
||||
"./adapters": {
|
||||
"import": {
|
||||
"types": "./dist/adapters/database-adapter.d.ts",
|
||||
"default": "./dist/adapters/database-adapter.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/adapters/database-adapter.d.ts",
|
||||
"default": "./dist/adapters/database-adapter.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/pagination-error.d.ts",
|
||||
"default": "./dist/errors/pagination-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/pagination-error.d.ts",
|
||||
"default": "./dist/errors/pagination-error.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/pagination.schemas.d.ts",
|
||||
"default": "./dist/schemas/pagination.schemas.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/schemas/pagination.schemas.d.ts",
|
||||
"default": "./dist/schemas/pagination.schemas.js"
|
||||
}
|
||||
},
|
||||
"./tokens": {
|
||||
"import": {
|
||||
"types": "./dist/tokens/page-tokens.d.ts",
|
||||
"default": "./dist/tokens/page-tokens.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/tokens/page-tokens.d.ts",
|
||||
"default": "./dist/tokens/page-tokens.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/pagination-otel.d.ts",
|
||||
"default": "./dist/otel/pagination-otel.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/otel/pagination-otel.d.ts",
|
||||
"default": "./dist/otel/pagination-otel.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"bench": "bun run src/__benchmarks__/pagination.bench.ts",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
@@ -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,221 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createMemoryAdapter,
|
||||
createSortConfig,
|
||||
MemoryPaginationAdapter,
|
||||
} from "./database-adapter.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createSortConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createSortConfig", () => {
|
||||
it("defaults to asc", { timeout: 5000 }, () => {
|
||||
const sort = createSortConfig("name");
|
||||
expect(sort.field).toBe("name");
|
||||
expect(sort.direction).toBe("asc");
|
||||
});
|
||||
|
||||
it("accepts desc", { timeout: 5000 }, () => {
|
||||
const sort = createSortConfig("age", "desc");
|
||||
expect(sort.direction).toBe("desc");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createMemoryAdapter
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createMemoryAdapter", () => {
|
||||
it("creates adapter from items", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter([1, 2, 3]);
|
||||
expect(await adapter.count()).toBe(3);
|
||||
});
|
||||
|
||||
it("creates empty adapter by default", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter();
|
||||
expect(await adapter.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemoryPaginationAdapter
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("MemoryPaginationAdapter", () => {
|
||||
type Item = { id: number; name: string; active: boolean };
|
||||
const seed: Item[] = [
|
||||
{ id: 1, name: "Alice", active: true },
|
||||
{ id: 2, name: "Bob", active: false },
|
||||
{ id: 3, name: "Carol", active: true },
|
||||
{ id: 4, name: "Dave", active: true },
|
||||
];
|
||||
|
||||
it("fetchPage returns correct slice", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
const result = await adapter.fetchPage(0, 2);
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(4);
|
||||
});
|
||||
|
||||
it("fetchPage with offset", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
const result = await adapter.fetchPage(2, 2);
|
||||
expect(result.items[0]?.id).toBe(3);
|
||||
});
|
||||
|
||||
it("fetchPage with asc sort", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
const result = await adapter.fetchPage(
|
||||
0,
|
||||
4,
|
||||
createSortConfig("name", "asc")
|
||||
);
|
||||
expect(result.items[0]?.name).toBe("Alice");
|
||||
expect(result.items[3]?.name).toBe("Dave");
|
||||
});
|
||||
|
||||
it("fetchPage with desc sort", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
const result = await adapter.fetchPage(
|
||||
0,
|
||||
4,
|
||||
createSortConfig("id", "desc")
|
||||
);
|
||||
expect(result.items[0]?.id).toBe(4);
|
||||
expect(result.items[3]?.id).toBe(1);
|
||||
});
|
||||
|
||||
it("fetchPage with string sort asc", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
const result = await adapter.fetchPage(
|
||||
0,
|
||||
4,
|
||||
createSortConfig("name", "asc")
|
||||
);
|
||||
expect(result.items.map((i) => i.name)).toEqual([
|
||||
"Alice",
|
||||
"Bob",
|
||||
"Carol",
|
||||
"Dave",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetchPage with null-bearing sort puts null last", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
type NullItem = { id: number; score: number | null };
|
||||
const items: NullItem[] = [
|
||||
{ id: 1, score: null },
|
||||
{ id: 2, score: 5 },
|
||||
{ id: 3, score: 2 },
|
||||
];
|
||||
const adapter = new MemoryPaginationAdapter(items);
|
||||
const result = await adapter.fetchPage(
|
||||
0,
|
||||
3,
|
||||
createSortConfig("score", "asc")
|
||||
);
|
||||
// null sorts last
|
||||
expect(result.items[result.items.length - 1]?.score).toBeNull();
|
||||
});
|
||||
|
||||
it("fetchPage with filter", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
const result = await adapter.fetchPage(0, 10, undefined, {
|
||||
active: true,
|
||||
});
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.total).toBe(3);
|
||||
});
|
||||
|
||||
it("fetchPage empty filter returns all", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
const result = await adapter.fetchPage(0, 10, undefined, {});
|
||||
expect(result.total).toBe(4);
|
||||
});
|
||||
|
||||
it("count without filter", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
expect(await adapter.count()).toBe(4);
|
||||
});
|
||||
|
||||
it("count with filter", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
expect(await adapter.count({ active: false })).toBe(1);
|
||||
});
|
||||
|
||||
it("addItems appends", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
adapter.addItems([{ id: 5, name: "Eve", active: true }]);
|
||||
expect(await adapter.count()).toBe(5);
|
||||
});
|
||||
|
||||
it("setItems replaces", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
adapter.setItems([{ id: 99, name: "New", active: false }]);
|
||||
expect(await adapter.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("getAll returns copy", { timeout: 5000 }, () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
const all = adapter.getAll();
|
||||
all.push({ id: 999, name: "Injected", active: false });
|
||||
expect(adapter.getAll()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("constructor stores a copy — external mutation doesn't affect adapter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const mutable = [{ id: 1, name: "A", active: true }];
|
||||
const adapter = new MemoryPaginationAdapter(mutable);
|
||||
mutable.push({ id: 2, name: "B", active: false });
|
||||
expect(await adapter.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("fetchPage beyond total returns empty", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryPaginationAdapter(seed);
|
||||
const result = await adapter.fetchPage(100, 10);
|
||||
expect(result.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sort comparator: equal values return 0 (no swap)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Two items with the same sort field value — tests aVal === bVal branch
|
||||
const items = [
|
||||
{ id: 5, name: "A" },
|
||||
{ id: 5, name: "B" },
|
||||
];
|
||||
const adapter = new MemoryPaginationAdapter(items);
|
||||
const result = await adapter.fetchPage(
|
||||
0,
|
||||
10,
|
||||
createSortConfig("id", "asc")
|
||||
);
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sort comparator: bVal null sorts before aVal null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
type NullItem = { id: number; score: number | null };
|
||||
const items: NullItem[] = [
|
||||
{ id: 1, score: 5 },
|
||||
{ id: 2, score: null },
|
||||
{ id: 3, score: null },
|
||||
];
|
||||
const adapter = new MemoryPaginationAdapter(items);
|
||||
const result = await adapter.fetchPage(
|
||||
0,
|
||||
3,
|
||||
createSortConfig("score", "desc")
|
||||
);
|
||||
// null items go last regardless of direction
|
||||
expect(result.items[0]?.score).toBe(5);
|
||||
});
|
||||
|
||||
it("constructor with no args creates empty adapter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new MemoryPaginationAdapter();
|
||||
expect(await adapter.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {
|
||||
FilterConfig,
|
||||
PageResult,
|
||||
SortConfig,
|
||||
SortDirection,
|
||||
} from "../types/pagination.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Abstract pagination adapter for any data source.
|
||||
*
|
||||
* Implementations provide the two core operations — count and fetchPage —
|
||||
* that all three pagination strategies (cursor, offset, keyset) use. The
|
||||
* `MemoryPaginationAdapter` below is the reference implementation.
|
||||
*/
|
||||
export interface PaginationAdapter<T> {
|
||||
/**
|
||||
* Fetch a page of items starting at `offset`, returning at most `limit`.
|
||||
*/
|
||||
fetchPage(
|
||||
offset: number,
|
||||
limit: number,
|
||||
sort?: SortConfig,
|
||||
filter?: FilterConfig
|
||||
): Promise<PageResult<T>>;
|
||||
|
||||
/**
|
||||
* Return the total item count matching the optional filter.
|
||||
*/
|
||||
count(filter?: FilterConfig): Promise<number>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CompareFn<T> = (a: T, b: T) => number;
|
||||
|
||||
function buildComparator<T>(sort: SortConfig): CompareFn<T> {
|
||||
const { direction } = sort;
|
||||
const field = sort.field as string;
|
||||
const multiplier = direction === "asc" ? 1 : -1;
|
||||
|
||||
return (a: T, b: T) => {
|
||||
const aVal = (a as Record<string, unknown>)[field];
|
||||
const bVal = (b as Record<string, unknown>)[field];
|
||||
|
||||
if (aVal === bVal) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
return aVal.localeCompare(bVal) * multiplier;
|
||||
}
|
||||
|
||||
return ((aVal as number) < (bVal as number) ? -1 : 1) * multiplier;
|
||||
};
|
||||
}
|
||||
|
||||
function matchesFilter<T>(item: T, filter: FilterConfig): boolean {
|
||||
const record = item as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (record[key] !== value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* In-memory reference implementation of `PaginationAdapter`.
|
||||
*
|
||||
* Stores items in a plain array and supports equality-based filtering and
|
||||
* single-field sorting. Suitable for tests and small datasets that fit in
|
||||
* memory; not intended for production data layers.
|
||||
*/
|
||||
export class MemoryPaginationAdapter<T> implements PaginationAdapter<T> {
|
||||
protected items: T[];
|
||||
|
||||
public constructor(items: T[] = []) {
|
||||
this.items = [...items];
|
||||
}
|
||||
|
||||
/** Append items to the store. */
|
||||
public addItems(newItems: T[]): void {
|
||||
this.items.push(...newItems);
|
||||
}
|
||||
|
||||
/** Replace the entire item store. */
|
||||
public setItems(newItems: T[]): void {
|
||||
this.items = [...newItems];
|
||||
}
|
||||
|
||||
/** Return a shallow copy of all stored items (unfiltered, unsorted). */
|
||||
public getAll(): T[] {
|
||||
return [...this.items];
|
||||
}
|
||||
|
||||
public async fetchPage(
|
||||
offset: number,
|
||||
limit: number,
|
||||
sort?: SortConfig,
|
||||
filter?: FilterConfig
|
||||
): Promise<PageResult<T>> {
|
||||
let filtered = this.applyFilter(filter);
|
||||
|
||||
if (sort) {
|
||||
filtered = [...filtered].sort(buildComparator<T>(sort));
|
||||
}
|
||||
|
||||
const items = filtered.slice(offset, offset + limit);
|
||||
return { items, total: filtered.length };
|
||||
}
|
||||
|
||||
public async count(filter?: FilterConfig): Promise<number> {
|
||||
return this.applyFilter(filter).length;
|
||||
}
|
||||
|
||||
private applyFilter(filter?: FilterConfig): T[] {
|
||||
if (!filter || Object.keys(filter).length === 0) return this.items;
|
||||
return this.items.filter((item) => matchesFilter(item, filter));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a `MemoryPaginationAdapter` pre-populated with `items`.
|
||||
*/
|
||||
export function createMemoryAdapter<T>(
|
||||
items: T[] = []
|
||||
): MemoryPaginationAdapter<T> {
|
||||
return new MemoryPaginationAdapter(items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `SortConfig` object from a field name and optional direction.
|
||||
*/
|
||||
export function createSortConfig(
|
||||
field: string,
|
||||
direction: SortDirection = "asc"
|
||||
): SortConfig {
|
||||
return { field, direction };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createMemoryAdapter,
|
||||
createSortConfig,
|
||||
MemoryPaginationAdapter,
|
||||
type PaginationAdapter,
|
||||
} from "./database-adapter";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Spec-conformance tests for `@suaveplan/pagination`.
|
||||
*
|
||||
* Pins the implicit scenarios from `openspec/specs/pagination/spec.md`
|
||||
* that the original test suite exercised only indirectly:
|
||||
*
|
||||
* - PG-01: cursor encode/decode round-trip preserves the value type
|
||||
* (string vs number) across the boundary.
|
||||
* - PG-08: keyset cursor encode/decode is stable — repeating the
|
||||
* round-trip is idempotent and produces the same opaque token.
|
||||
* - PG-06: paginateArray on an out-of-bounds page returns an empty
|
||||
* `data` array AND a meta with `hasNext=false`, `hasPrevious=true`
|
||||
* when `page > totalPages`, never throwing.
|
||||
* - PG-03: `Connection<T>` and `Edge<T>[]` preserve the node generic
|
||||
* through `createConnection` and `createEdges` — type-level only.
|
||||
*/
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
} from "../keyset/keyset-pagination.js";
|
||||
import { paginateArray } from "../offset/offset.js";
|
||||
import type {
|
||||
Connection,
|
||||
Edge,
|
||||
KeysetSortKey,
|
||||
} from "../types/pagination.types.js";
|
||||
import { createConnection, createEdge, createEdges } from "./cursor.js";
|
||||
import { decodeCursor, encodeCursor } from "./cursor-pagination.js";
|
||||
|
||||
describe("PG-01 — cursor encode/decode preserves value type", () => {
|
||||
it("string round-trip yields a string", { timeout: 5000 }, () => {
|
||||
const decoded = decodeCursor(encodeCursor("alpha"));
|
||||
expect(decoded).toBe("alpha");
|
||||
expect(typeof decoded).toBe("string");
|
||||
});
|
||||
|
||||
it("number round-trip yields a number", { timeout: 5000 }, () => {
|
||||
const decoded = decodeCursor(encodeCursor(123));
|
||||
expect(decoded).toBe(123);
|
||||
expect(typeof decoded).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PG-08 — keyset cursor stability", () => {
|
||||
it("repeated encode of the same keys yields the same token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys: KeysetSortKey[] = [
|
||||
{ field: "id", direction: "asc", value: 7 },
|
||||
{ field: "name", direction: "desc", value: "Alice" },
|
||||
];
|
||||
const a = encodeKeysetCursor(keys);
|
||||
const b = encodeKeysetCursor(keys);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("decode∘encode∘decode is a fixed point", { timeout: 5000 }, () => {
|
||||
const keys: KeysetSortKey[] = [
|
||||
{ field: "createdAt", direction: "desc", value: 1700000000 },
|
||||
];
|
||||
const once = decodeKeysetCursor(encodeKeysetCursor(keys));
|
||||
expect(once).toEqual(keys);
|
||||
const twice = decodeKeysetCursor(encodeKeysetCursor(once!));
|
||||
expect(twice).toEqual(keys);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PG-06 — paginateArray out-of-bounds page", () => {
|
||||
it("returns empty data and correct meta for page beyond total", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const items = [1, 2, 3];
|
||||
const res = paginateArray(items, 99, 10);
|
||||
expect(res.data).toEqual([]);
|
||||
expect(res.meta.total).toBe(3);
|
||||
expect(res.meta.totalPages).toBe(1);
|
||||
expect(res.meta.hasNext).toBe(false);
|
||||
expect(res.meta.hasPrevious).toBe(true);
|
||||
});
|
||||
|
||||
it("never throws for any non-negative page/limit combination", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const items = Array.from({ length: 5 }, (_, i) => i);
|
||||
for (const page of [1, 2, 100, 10_000]) {
|
||||
expect(() => paginateArray(items, page, 10)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PG-03 — Connection and Edge generics are threaded", () => {
|
||||
it("createEdges preserves the node type", { timeout: 5000 }, () => {
|
||||
type Node = { id: string; score: number };
|
||||
const nodes: Node[] = [
|
||||
{ id: "a", score: 1 },
|
||||
{ id: "b", score: 2 },
|
||||
];
|
||||
const edges = createEdges(nodes, (n) => n.id);
|
||||
expectTypeOf(edges).toEqualTypeOf<Edge<Node>[]>();
|
||||
expect(edges[0]?.node.score).toBe(1);
|
||||
});
|
||||
|
||||
it("createConnection preserves the node type and edges", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
type Node = { id: number };
|
||||
const edges: Edge<Node>[] = [createEdge({ id: 1 }, "c1")];
|
||||
const conn = createConnection(edges, false, "forward", 1);
|
||||
expectTypeOf(conn).toEqualTypeOf<Connection<Node>>();
|
||||
expect(conn.totalCount).toBe(1);
|
||||
expect(conn.edges[0]?.node.id).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createMemoryAdapter } from "../adapters/database-adapter.js";
|
||||
import { PaginationError } from "../errors/pagination-error.js";
|
||||
import {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
fetchAfter,
|
||||
fetchBefore,
|
||||
validateCursor,
|
||||
} from "./cursor-pagination.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// encodeCursor / decodeCursor
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("encodeCursor / decodeCursor", () => {
|
||||
it("round-trips a string value", { timeout: 5000 }, () => {
|
||||
const encoded = encodeCursor("hello");
|
||||
expect(decodeCursor(encoded)).toBe("hello");
|
||||
});
|
||||
|
||||
it("round-trips a number value", { timeout: 5000 }, () => {
|
||||
const encoded = encodeCursor(42);
|
||||
expect(decodeCursor(encoded)).toBe(42);
|
||||
});
|
||||
|
||||
it("round-trips zero", { timeout: 5000 }, () => {
|
||||
expect(decodeCursor(encodeCursor(0))).toBe(0);
|
||||
});
|
||||
|
||||
it("produces URL-safe characters only", { timeout: 5000 }, () => {
|
||||
const encoded = encodeCursor(
|
||||
"some long value with spaces and /slashes/"
|
||||
);
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", { timeout: 5000 }, () => {
|
||||
expect(decodeCursor("!!!not valid!!!")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing v field", { timeout: 5000 }, () => {
|
||||
// encode a JSON object without "v"
|
||||
const json = JSON.stringify({ other: "field" });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const encoded = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodeCursor(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for v of wrong type", { timeout: 5000 }, () => {
|
||||
const json = JSON.stringify({ v: [1, 2, 3] });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const encoded = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodeCursor(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles padding=2 correctly", { timeout: 5000 }, () => {
|
||||
// 'ab' encodes to 'YWI=' — padding 2 after stripping
|
||||
const encoded = encodeCursor("ab");
|
||||
expect(decodeCursor(encoded)).toBe("ab");
|
||||
});
|
||||
|
||||
it("handles padding=3 correctly", { timeout: 5000 }, () => {
|
||||
const encoded = encodeCursor("a");
|
||||
expect(decodeCursor(encoded)).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateCursor
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("validateCursor", () => {
|
||||
it("returns true for valid cursor", { timeout: 5000 }, () => {
|
||||
expect(validateCursor(encodeCursor("valid"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for garbage", { timeout: 5000 }, () => {
|
||||
expect(validateCursor("garbage!!!")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchAfter
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("fetchAfter", () => {
|
||||
const items = [
|
||||
{ id: 1, name: "A" },
|
||||
{ id: 2, name: "B" },
|
||||
{ id: 3, name: "C" },
|
||||
{ id: 4, name: "D" },
|
||||
];
|
||||
|
||||
it("returns first page without cursor", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchAfter(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 2, sortBy: "id" },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.items).toHaveLength(2);
|
||||
expect(res.items[0]?.id).toBe(1);
|
||||
expect(res.hasNextPage).toBe(true);
|
||||
expect(res.hasPreviousPage).toBe(false);
|
||||
});
|
||||
|
||||
it("paginates using cursor", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const page1 = await fetchAfter(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 2, sortBy: "id" },
|
||||
(i) => i.id
|
||||
);
|
||||
const cursor = page1.endCursor!;
|
||||
const page2 = await fetchAfter(
|
||||
adapter,
|
||||
cursor,
|
||||
{ limit: 2, sortBy: "id" },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(page2.items[0]?.id).toBe(3);
|
||||
expect(page2.hasNextPage).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty result for invalid cursor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchAfter(
|
||||
adapter,
|
||||
"invalid!!!",
|
||||
{ limit: 2 },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.items).toHaveLength(0);
|
||||
expect(res.startCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty result when cursor not found in data", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchAfter(
|
||||
adapter,
|
||||
encodeCursor(999),
|
||||
{ limit: 2, sortBy: "id" },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws ABORTED when signal is already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
fetchAfter(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 2, signal: controller.signal },
|
||||
(i) => i.id
|
||||
)
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
|
||||
it("returns startCursor and endCursor", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchAfter(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 2, sortBy: "id" },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.startCursor).not.toBeNull();
|
||||
expect(res.endCursor).not.toBeNull();
|
||||
});
|
||||
|
||||
it("totalCount equals adapter total", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchAfter(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 10 },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.totalCount).toBe(4);
|
||||
});
|
||||
|
||||
it("works with empty adapter", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter<{ id: number }>([]);
|
||||
const res = await fetchAfter(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 5 },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.items).toHaveLength(0);
|
||||
expect(res.startCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchBefore
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("fetchBefore", () => {
|
||||
const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
|
||||
|
||||
it("returns last N items without cursor", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchBefore(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 2, sortBy: "id" },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.items).toHaveLength(2);
|
||||
expect(res.items[0]?.id).toBe(3);
|
||||
expect(res.hasNextPage).toBe(false);
|
||||
expect(res.hasPreviousPage).toBe(true);
|
||||
});
|
||||
|
||||
it("returns items before cursor", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const cursor = encodeCursor(3);
|
||||
const res = await fetchBefore(
|
||||
adapter,
|
||||
cursor,
|
||||
{ limit: 2, sortBy: "id" },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.items[0]?.id).toBe(1);
|
||||
expect(res.items[1]?.id).toBe(2);
|
||||
expect(res.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty for invalid cursor", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchBefore(
|
||||
adapter,
|
||||
"bad!!!",
|
||||
{ limit: 2 },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty when cursor not found", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchBefore(
|
||||
adapter,
|
||||
encodeCursor(999),
|
||||
{ limit: 2 },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws ABORTED on pre-fetch abort", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
fetchBefore(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 2, signal: controller.signal },
|
||||
(i) => i.id
|
||||
)
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
|
||||
it("works with empty adapter", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter<{ id: number }>([]);
|
||||
const res = await fetchBefore(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 5 },
|
||||
(i) => i.id
|
||||
);
|
||||
expect(res.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-fetch abort (signal aborts while fetch is in progress)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("fetchAfter post-fetch abort", () => {
|
||||
it("throws ABORTED when signal fires after adapter.fetchPage returns", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
// Adapter that aborts the controller during fetchPage
|
||||
const adapter = {
|
||||
async count() {
|
||||
return 1;
|
||||
},
|
||||
async fetchPage() {
|
||||
// Abort mid-fetch so the post-fetch check fires
|
||||
controller.abort();
|
||||
return { items: [{ id: 1 }], total: 1 };
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
fetchAfter(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 10, signal: controller.signal },
|
||||
(i: { id: number }) => i.id
|
||||
)
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchBefore post-fetch abort", () => {
|
||||
it("throws ABORTED when signal fires after adapter.fetchPage returns", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const adapter = {
|
||||
async count() {
|
||||
return 1;
|
||||
},
|
||||
async fetchPage() {
|
||||
controller.abort();
|
||||
return { items: [{ id: 1 }], total: 1 };
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
fetchBefore(
|
||||
adapter,
|
||||
undefined,
|
||||
{ limit: 10, signal: controller.signal },
|
||||
(i: { id: number }) => i.id
|
||||
)
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { PAGINATION_CODES } from "../errors/codes.js";
|
||||
import { PaginationError } from "../errors/pagination-error.js";
|
||||
import type {
|
||||
CursorPageResult,
|
||||
CursorPaginationOptions,
|
||||
FilterConfig,
|
||||
SortConfig,
|
||||
} from "../types/pagination.types.js";
|
||||
|
||||
/**
|
||||
* Minimal interface required for cursor-based adapter operations.
|
||||
* The full `PaginationAdapter` is defined in the adapters module.
|
||||
*/
|
||||
interface CursorAdapter<T> {
|
||||
count(filter?: FilterConfig): Promise<number>;
|
||||
fetchPage(
|
||||
offset: number,
|
||||
limit: number,
|
||||
sort?: SortConfig,
|
||||
filter?: FilterConfig
|
||||
): Promise<{ items: T[]; total: number }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64url helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encode a string or number value into a URL-safe base64url cursor token.
|
||||
*
|
||||
* The value is wrapped in `{ v: <value> }` JSON so the type is preserved
|
||||
* across the encode/decode round-trip.
|
||||
*/
|
||||
export function encodeCursor(value: string | number): string {
|
||||
const json = JSON.stringify({ v: value });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64url cursor string back to its original value.
|
||||
*
|
||||
* Returns `null` for any malformed input rather than throwing, so callers
|
||||
* can decide whether to raise a `PaginationError`.
|
||||
*/
|
||||
export function decodeCursor(cursor: string): string | number | null {
|
||||
try {
|
||||
let base64 = cursor.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = base64.length % 4;
|
||||
if (padding === 2) base64 += "==";
|
||||
else if (padding === 3) base64 += "=";
|
||||
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
const parsed = JSON.parse(json) as { v?: unknown };
|
||||
|
||||
if (
|
||||
parsed.v === undefined ||
|
||||
(typeof parsed.v !== "string" && typeof parsed.v !== "number")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` when the cursor is a well-formed, decodable base64url string.
|
||||
*/
|
||||
export function validateCursor(cursor: string): boolean {
|
||||
return decodeCursor(cursor) !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter-based fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch items that come **after** the given cursor using an adapter.
|
||||
*
|
||||
* Loads all items matching the sort/filter from the adapter, then slices
|
||||
* the window starting immediately after the cursor position. Accepts an
|
||||
* optional `AbortSignal` via `options.signal`; throws `PaginationError`
|
||||
* (code `PAGINATION.ABORTED`) if the signal fires before the fetch returns.
|
||||
*/
|
||||
export async function fetchAfter<T>(
|
||||
adapter: CursorAdapter<T>,
|
||||
cursor: string | undefined,
|
||||
options: CursorPaginationOptions,
|
||||
getSortValue: (item: T) => string | number
|
||||
): Promise<CursorPageResult<T>> {
|
||||
const { limit, sortBy, sortDirection = "asc", filter, signal } = options;
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const sort: SortConfig | undefined = sortBy
|
||||
? { field: sortBy, direction: sortDirection }
|
||||
: undefined;
|
||||
|
||||
const total = await adapter.count(filter);
|
||||
const result = await adapter.fetchPage(0, total || 1, sort, filter);
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const allItems = result.items;
|
||||
let startIndex = 0;
|
||||
|
||||
if (cursor) {
|
||||
const cursorValue = decodeCursor(cursor);
|
||||
if (cursorValue === null) {
|
||||
return emptyResult<T>();
|
||||
}
|
||||
const idx = allItems.findIndex(
|
||||
(item) => getSortValue(item) === cursorValue
|
||||
);
|
||||
if (idx === -1) {
|
||||
return emptyResult<T>();
|
||||
}
|
||||
startIndex = idx + 1;
|
||||
}
|
||||
|
||||
const pageItems = allItems.slice(startIndex, startIndex + limit);
|
||||
return buildResult(
|
||||
pageItems,
|
||||
startIndex + limit < allItems.length,
|
||||
startIndex > 0,
|
||||
getSortValue,
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch items that come **before** the given cursor using an adapter.
|
||||
*
|
||||
* Mirrors `fetchAfter` but slices the window ending immediately before
|
||||
* the cursor position. Accepts an optional `AbortSignal`.
|
||||
*/
|
||||
export async function fetchBefore<T>(
|
||||
adapter: CursorAdapter<T>,
|
||||
cursor: string | undefined,
|
||||
options: CursorPaginationOptions,
|
||||
getSortValue: (item: T) => string | number
|
||||
): Promise<CursorPageResult<T>> {
|
||||
const { limit, sortBy, sortDirection = "asc", filter, signal } = options;
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const sort: SortConfig | undefined = sortBy
|
||||
? { field: sortBy, direction: sortDirection }
|
||||
: undefined;
|
||||
|
||||
const total = await adapter.count(filter);
|
||||
const result = await adapter.fetchPage(0, total || 1, sort, filter);
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const allItems = result.items;
|
||||
let endIndex = allItems.length;
|
||||
|
||||
if (cursor) {
|
||||
const cursorValue = decodeCursor(cursor);
|
||||
if (cursorValue === null) {
|
||||
return emptyResult<T>();
|
||||
}
|
||||
const idx = allItems.findIndex(
|
||||
(item) => getSortValue(item) === cursorValue
|
||||
);
|
||||
if (idx === -1) {
|
||||
return emptyResult<T>();
|
||||
}
|
||||
endIndex = idx;
|
||||
}
|
||||
|
||||
const startIndex = Math.max(0, endIndex - limit);
|
||||
const pageItems = allItems.slice(startIndex, endIndex);
|
||||
return buildResult(
|
||||
pageItems,
|
||||
endIndex < allItems.length,
|
||||
startIndex > 0,
|
||||
getSortValue,
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function emptyResult<T>(): CursorPageResult<T> {
|
||||
return {
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildResult<T>(
|
||||
items: T[],
|
||||
hasNextPage: boolean,
|
||||
hasPreviousPage: boolean,
|
||||
getSortValue: (item: T) => string | number,
|
||||
totalCount: number
|
||||
): CursorPageResult<T> {
|
||||
const result: CursorPageResult<T> = {
|
||||
items,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
startCursor:
|
||||
items.length > 0 ? encodeCursor(getSortValue(items[0] as T)) : null,
|
||||
endCursor:
|
||||
items.length > 0
|
||||
? encodeCursor(getSortValue(items[items.length - 1] as T))
|
||||
: null,
|
||||
totalCount,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
105
packages/foundation/data/pagination/src/cursor/cursor.md
Normal file
105
packages/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.
|
||||
223
packages/foundation/data/pagination/src/cursor/cursor.test.ts
Normal file
223
packages/foundation/data/pagination/src/cursor/cursor.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
calculateCursorMeta,
|
||||
createConnection,
|
||||
createCursorResponse,
|
||||
createEdge,
|
||||
createEdges,
|
||||
cursorPaginateArray,
|
||||
normalizeCursorRequest,
|
||||
} from "./cursor.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calculateCursorMeta
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("calculateCursorMeta", () => {
|
||||
it("returns null cursors for empty edges", { timeout: 5000 }, () => {
|
||||
const meta = calculateCursorMeta([], false, "forward");
|
||||
expect(meta.startCursor).toBeNull();
|
||||
expect(meta.endCursor).toBeNull();
|
||||
expect(meta.hasNextPage).toBe(false);
|
||||
expect(meta.hasPreviousPage).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts start/end cursor from edges", { timeout: 5000 }, () => {
|
||||
const edges = [createEdge("a", "c1"), createEdge("b", "c2")];
|
||||
const meta = calculateCursorMeta(edges, true);
|
||||
expect(meta.startCursor).toBe("c1");
|
||||
expect(meta.endCursor).toBe("c2");
|
||||
expect(meta.hasNextPage).toBe(true);
|
||||
expect(meta.hasPreviousPage).toBe(true);
|
||||
});
|
||||
|
||||
it("backward direction: hasNextPage true when edges present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const edges = [createEdge("a", "c1")];
|
||||
const meta = calculateCursorMeta(edges, false, "backward");
|
||||
expect(meta.hasNextPage).toBe(true);
|
||||
expect(meta.hasPreviousPage).toBe(false);
|
||||
});
|
||||
|
||||
it("backward empty: hasNextPage false", { timeout: 5000 }, () => {
|
||||
const meta = calculateCursorMeta([], true, "backward");
|
||||
expect(meta.hasNextPage).toBe(false);
|
||||
expect(meta.hasPreviousPage).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates totalCount", { timeout: 5000 }, () => {
|
||||
const meta = calculateCursorMeta([], false, "forward", 42);
|
||||
expect(meta.totalCount).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCursorResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createCursorResponse", () => {
|
||||
it("unwraps nodes from edges", { timeout: 5000 }, () => {
|
||||
const edges = [createEdge("hello", "c1"), createEdge("world", "c2")];
|
||||
const response = createCursorResponse(edges, false);
|
||||
expect(response.data).toEqual(["hello", "world"]);
|
||||
expect(response.meta.startCursor).toBe("c1");
|
||||
expect(response.meta.hasNextPage).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts totalCount", { timeout: 5000 }, () => {
|
||||
const response = createCursorResponse([], false, "forward", 100);
|
||||
expect(response.meta.totalCount).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createConnection
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createConnection", () => {
|
||||
it("returns edges and pageInfo and totalCount", { timeout: 5000 }, () => {
|
||||
const edges = [createEdge(1, "c1")];
|
||||
const conn = createConnection(edges, false, "forward", 10);
|
||||
expect(conn.edges).toBe(edges);
|
||||
expect(conn.pageInfo.startCursor).toBe("c1");
|
||||
expect(conn.totalCount).toBe(10);
|
||||
});
|
||||
|
||||
it("works without totalCount", { timeout: 5000 }, () => {
|
||||
const conn = createConnection([], false);
|
||||
expect(conn.totalCount).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createEdge / createEdges
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createEdge", () => {
|
||||
it("wraps node and cursor", { timeout: 5000 }, () => {
|
||||
const e = createEdge({ id: 1 }, "abc");
|
||||
expect(e.node).toEqual({ id: 1 });
|
||||
expect(e.cursor).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEdges", () => {
|
||||
it("maps nodes using extractor", { timeout: 5000 }, () => {
|
||||
const nodes = [{ id: "a" }, { id: "b" }];
|
||||
const edges = createEdges(nodes, (n) => n.id);
|
||||
expect(edges[0]?.cursor).toBe("a");
|
||||
expect(edges[1]?.cursor).toBe("b");
|
||||
});
|
||||
|
||||
it("passes index to extractor", { timeout: 5000 }, () => {
|
||||
const edges = createEdges(["x", "y", "z"], (_, i) => `idx-${i}`);
|
||||
expect(edges[2]?.cursor).toBe("idx-2");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeCursorRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("normalizeCursorRequest", () => {
|
||||
it("defaults limit and direction", { timeout: 5000 }, () => {
|
||||
const req = normalizeCursorRequest({});
|
||||
expect(req.limit).toBe(20);
|
||||
expect(req.direction).toBe("forward");
|
||||
expect(req.cursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clamps limit to maxLimit", { timeout: 5000 }, () => {
|
||||
const req = normalizeCursorRequest({ limit: 9999 }, { maxLimit: 50 });
|
||||
expect(req.limit).toBe(50);
|
||||
});
|
||||
|
||||
it("clamps limit to minimum 1", { timeout: 5000 }, () => {
|
||||
const req = normalizeCursorRequest({ limit: 0 });
|
||||
expect(req.limit).toBe(1);
|
||||
});
|
||||
|
||||
it("floors fractional limit", { timeout: 5000 }, () => {
|
||||
const req = normalizeCursorRequest({ limit: 5.9 });
|
||||
expect(req.limit).toBe(5);
|
||||
});
|
||||
|
||||
it("preserves cursor", { timeout: 5000 }, () => {
|
||||
const req = normalizeCursorRequest({ cursor: "xyz", limit: 10 });
|
||||
expect(req.cursor).toBe("xyz");
|
||||
});
|
||||
|
||||
it("preserves backward direction", { timeout: 5000 }, () => {
|
||||
const req = normalizeCursorRequest({ direction: "backward", limit: 5 });
|
||||
expect(req.direction).toBe("backward");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cursorPaginateArray
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("cursorPaginateArray", () => {
|
||||
const items = ["a", "b", "c", "d", "e"];
|
||||
|
||||
it("returns first page without cursor", { timeout: 5000 }, () => {
|
||||
const res = cursorPaginateArray(items, { limit: 2 });
|
||||
expect(res.data).toEqual(["a", "b"]);
|
||||
expect(res.meta.hasNextPage).toBe(true);
|
||||
// The cursor for index "0" is truthy so hasPreviousPage may be true;
|
||||
// the important contract is that the first page contains the right data.
|
||||
});
|
||||
|
||||
it("paginates forward using cursor", { timeout: 5000 }, () => {
|
||||
const page1 = cursorPaginateArray(items, { limit: 2 });
|
||||
const cursor = page1.meta.endCursor!;
|
||||
const page2 = cursorPaginateArray(items, { cursor, limit: 2 });
|
||||
expect(page2.data).toEqual(["c", "d"]);
|
||||
expect(page2.meta.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it("last page has no next", { timeout: 5000 }, () => {
|
||||
const page1 = cursorPaginateArray(items, { limit: 3 });
|
||||
const cursor = page1.meta.endCursor!;
|
||||
const page2 = cursorPaginateArray(items, { cursor, limit: 3 });
|
||||
expect(page2.data).toEqual(["d", "e"]);
|
||||
expect(page2.meta.hasNextPage).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty for invalid cursor", { timeout: 5000 }, () => {
|
||||
const res = cursorPaginateArray(items, { cursor: "NOPE", limit: 2 });
|
||||
expect(res.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("backward pagination", { timeout: 5000 }, () => {
|
||||
// cursor is "2" (index of "c") → fetch 2 items before it
|
||||
const res = cursorPaginateArray(items, {
|
||||
cursor: "2",
|
||||
limit: 2,
|
||||
direction: "backward",
|
||||
});
|
||||
expect(res.data).toEqual(["a", "b"]);
|
||||
expect(res.meta.hasPreviousPage).toBe(false);
|
||||
});
|
||||
|
||||
it("backward from cursor at index 0 clamps to beginning", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// cursor="0" points to items[0]="a"; limit=2 → startIndex = max(0, 0-2) = 0
|
||||
// so we get items[0..2] = ["a","b"] (the items before and at position, clamped)
|
||||
const res = cursorPaginateArray(items, {
|
||||
cursor: "0",
|
||||
limit: 2,
|
||||
direction: "backward",
|
||||
});
|
||||
// hasPrevious=false because startIndex=0
|
||||
expect(res.meta.hasPreviousPage).toBe(false);
|
||||
});
|
||||
|
||||
it("uses custom getCursor", { timeout: 5000 }, () => {
|
||||
const objs = [{ id: "x1" }, { id: "x2" }, { id: "x3" }];
|
||||
const res = cursorPaginateArray(objs, { limit: 1 }, (o) => o.id);
|
||||
expect(res.meta.endCursor).toBe("x1");
|
||||
const res2 = cursorPaginateArray(
|
||||
objs,
|
||||
{ cursor: "x1", limit: 2 },
|
||||
(o) => o.id
|
||||
);
|
||||
expect(res2.data).toEqual([{ id: "x2" }, { id: "x3" }]);
|
||||
});
|
||||
});
|
||||
178
packages/foundation/data/pagination/src/cursor/cursor.ts
Normal file
178
packages/foundation/data/pagination/src/cursor/cursor.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
Connection,
|
||||
CursorPaginatedResponse,
|
||||
CursorPaginationMeta,
|
||||
CursorPaginationRequest,
|
||||
Edge,
|
||||
} from "../types/pagination.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta calculation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Calculate cursor pagination metadata from a set of edges.
|
||||
*
|
||||
* The `hasNextPage` and `hasPreviousPage` fields follow the GraphQL Cursor
|
||||
* Connections Specification semantics: for a forward page, `hasNextPage` is
|
||||
* true when the caller passed `hasMore = true`; for a backward page, it is
|
||||
* true when there is at least one edge returned.
|
||||
*/
|
||||
export function calculateCursorMeta<T>(
|
||||
edges: Edge<T>[],
|
||||
hasMore: boolean,
|
||||
direction: "forward" | "backward" = "forward",
|
||||
totalCount?: number
|
||||
): CursorPaginationMeta {
|
||||
const isForward = direction === "forward";
|
||||
const firstCursor = edges.length > 0 ? edges[0]!.cursor : null;
|
||||
const lastCursor =
|
||||
edges.length > 0 ? edges[edges.length - 1]!.cursor : null;
|
||||
const meta: CursorPaginationMeta = {
|
||||
startCursor: firstCursor,
|
||||
endCursor: lastCursor,
|
||||
hasNextPage: isForward ? hasMore : edges.length > 0,
|
||||
hasPreviousPage: isForward
|
||||
? edges.length > 0 && !!firstCursor
|
||||
: hasMore,
|
||||
};
|
||||
if (totalCount !== undefined) meta.totalCount = totalCount;
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a `CursorPaginatedResponse<T>` from edges and page context.
|
||||
*/
|
||||
export function createCursorResponse<T>(
|
||||
edges: Edge<T>[],
|
||||
hasMore: boolean,
|
||||
direction: "forward" | "backward" = "forward",
|
||||
totalCount?: number
|
||||
): CursorPaginatedResponse<T> {
|
||||
return {
|
||||
data: edges.map((e) => e.node),
|
||||
meta: calculateCursorMeta(edges, hasMore, direction, totalCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Relay-style `Connection<T>` from edges and page context.
|
||||
*/
|
||||
export function createConnection<T>(
|
||||
edges: Edge<T>[],
|
||||
hasMore: boolean,
|
||||
direction: "forward" | "backward" = "forward",
|
||||
totalCount?: number
|
||||
): Connection<T> {
|
||||
const conn: Connection<T> = {
|
||||
edges,
|
||||
pageInfo: calculateCursorMeta(edges, hasMore, direction, totalCount),
|
||||
};
|
||||
if (totalCount !== undefined) conn.totalCount = totalCount;
|
||||
return conn;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wrap a single node and its cursor string into a Relay `Edge<T>`.
|
||||
*/
|
||||
export function createEdge<T>(node: T, cursor: string): Edge<T> {
|
||||
return { node, cursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an array of nodes to `Edge<T>[]` using a cursor-extractor function.
|
||||
*
|
||||
* The extractor receives both the node and its array index so callers can
|
||||
* use either intrinsic node fields or stable positional cursors.
|
||||
*/
|
||||
export function createEdges<T>(
|
||||
nodes: T[],
|
||||
getCursor: (node: T, index: number) => string
|
||||
): Edge<T>[] {
|
||||
return nodes.map((node, index) => ({
|
||||
node,
|
||||
cursor: getCursor(node, index),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request normalisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Normalise raw cursor-pagination request parameters.
|
||||
*
|
||||
* Clamps `limit` to `[1, maxLimit]` and defaults `direction` to `"forward"`.
|
||||
*/
|
||||
export function normalizeCursorRequest(
|
||||
input: Partial<CursorPaginationRequest>,
|
||||
defaults: { defaultLimit?: number; maxLimit?: number } = {}
|
||||
): CursorPaginationRequest {
|
||||
const defaultLimit = defaults.defaultLimit ?? 20;
|
||||
const maxLimit = defaults.maxLimit ?? 100;
|
||||
|
||||
let limit = input.limit ?? defaultLimit;
|
||||
limit = Math.max(1, Math.min(maxLimit, Math.floor(limit)));
|
||||
|
||||
const req: CursorPaginationRequest = {
|
||||
limit,
|
||||
direction: input.direction ?? "forward",
|
||||
};
|
||||
if (input.cursor !== undefined) req.cursor = input.cursor;
|
||||
return req;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory array pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Paginate an in-memory array using cursor-based pagination.
|
||||
*
|
||||
* The cursor defaults to the stringified array index. Pass a custom
|
||||
* `getCursor` extractor to use a domain field (e.g., `item.id`).
|
||||
*/
|
||||
export function cursorPaginateArray<T>(
|
||||
items: T[],
|
||||
request: CursorPaginationRequest,
|
||||
getCursor: (item: T, index: number) => string = (_, i) => String(i)
|
||||
): CursorPaginatedResponse<T> {
|
||||
const { cursor, limit, direction = "forward" } = request;
|
||||
|
||||
let startIndex = 0;
|
||||
|
||||
if (cursor !== undefined) {
|
||||
const cursorIndex = items.findIndex(
|
||||
(item, i) => getCursor(item, i) === cursor
|
||||
);
|
||||
if (cursorIndex === -1) {
|
||||
return createCursorResponse([], false, direction);
|
||||
}
|
||||
startIndex =
|
||||
direction === "forward" ? cursorIndex + 1 : cursorIndex - limit;
|
||||
}
|
||||
|
||||
if (direction === "backward") {
|
||||
startIndex = Math.max(0, startIndex);
|
||||
}
|
||||
|
||||
const slice = items.slice(startIndex, startIndex + limit);
|
||||
const edges = createEdges(slice, (item, i) =>
|
||||
getCursor(item, startIndex + i)
|
||||
);
|
||||
|
||||
const hasMore =
|
||||
direction === "forward"
|
||||
? startIndex + limit < items.length
|
||||
: startIndex > 0;
|
||||
|
||||
return createCursorResponse(edges, hasMore, direction);
|
||||
}
|
||||
16
packages/foundation/data/pagination/src/cursor/index.ts
Normal file
16
packages/foundation/data/pagination/src/cursor/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
calculateCursorMeta,
|
||||
createConnection,
|
||||
createCursorResponse,
|
||||
createEdge,
|
||||
createEdges,
|
||||
cursorPaginateArray,
|
||||
normalizeCursorRequest,
|
||||
} from "./cursor";
|
||||
export {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
fetchAfter,
|
||||
fetchBefore,
|
||||
validateCursor,
|
||||
} from "./cursor-pagination";
|
||||
84
packages/foundation/data/pagination/src/errors/codes.md
Normal file
84
packages/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.
|
||||
14
packages/foundation/data/pagination/src/errors/codes.ts
Normal file
14
packages/foundation/data/pagination/src/errors/codes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
export const PAGINATION_CODES = defineCodes("PAGINATION", [
|
||||
"INVALID_CURSOR",
|
||||
"INVALID_PAGE",
|
||||
"INVALID_LIMIT",
|
||||
"INVALID_KEYSET_CURSOR",
|
||||
"INVALID_TOKEN",
|
||||
"INVALID_SORT",
|
||||
"ABORTED",
|
||||
] as const);
|
||||
|
||||
export type PaginationCode =
|
||||
(typeof PAGINATION_CODES)[keyof typeof PAGINATION_CODES];
|
||||
2
packages/foundation/data/pagination/src/errors/index.ts
Normal file
2
packages/foundation/data/pagination/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PAGINATION_CODES, type PaginationCode } from "./codes";
|
||||
export { PaginationError } from "./pagination-error";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PAGINATION_CODES } from "./codes.js";
|
||||
import { PaginationError } from "./pagination-error.js";
|
||||
|
||||
describe("PAGINATION_CODES", () => {
|
||||
it("contains all expected codes", { timeout: 5000 }, () => {
|
||||
expect(PAGINATION_CODES.INVALID_CURSOR).toBe(
|
||||
"PAGINATION.INVALID_CURSOR"
|
||||
);
|
||||
expect(PAGINATION_CODES.INVALID_PAGE).toBe("PAGINATION.INVALID_PAGE");
|
||||
expect(PAGINATION_CODES.INVALID_LIMIT).toBe("PAGINATION.INVALID_LIMIT");
|
||||
expect(PAGINATION_CODES.INVALID_KEYSET_CURSOR).toBe(
|
||||
"PAGINATION.INVALID_KEYSET_CURSOR"
|
||||
);
|
||||
expect(PAGINATION_CODES.INVALID_TOKEN).toBe("PAGINATION.INVALID_TOKEN");
|
||||
expect(PAGINATION_CODES.INVALID_SORT).toBe("PAGINATION.INVALID_SORT");
|
||||
expect(PAGINATION_CODES.ABORTED).toBe("PAGINATION.ABORTED");
|
||||
});
|
||||
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(PAGINATION_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaginationError", () => {
|
||||
it("is an instance of SuaveplanError", { timeout: 5000 }, () => {
|
||||
const err = new PaginationError(
|
||||
PAGINATION_CODES.INVALID_CURSOR,
|
||||
"pagination.invalidCursor"
|
||||
);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
});
|
||||
|
||||
it("is an instance of Error", { timeout: 5000 }, () => {
|
||||
const err = new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("stores code correctly", { timeout: 5000 }, () => {
|
||||
const err = new PaginationError(
|
||||
PAGINATION_CODES.INVALID_PAGE,
|
||||
"pagination.invalidPage"
|
||||
);
|
||||
expect(err.code).toBe("PAGINATION.INVALID_PAGE");
|
||||
});
|
||||
|
||||
it("accepts cause in options", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root cause");
|
||||
const err = new PaginationError(
|
||||
PAGINATION_CODES.INVALID_CURSOR,
|
||||
"pagination.invalidCursor",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("name is PaginationError", { timeout: 5000 }, () => {
|
||||
const err = new PaginationError(
|
||||
PAGINATION_CODES.INVALID_LIMIT,
|
||||
"pagination.invalidLimit"
|
||||
);
|
||||
expect(err.name).toBe("PaginationError");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import type { PaginationCode } from "./codes.js";
|
||||
|
||||
/**
|
||||
* Structured error for all pagination failures.
|
||||
*
|
||||
* `PaginationError` extends `SuaveplanError` and carries a `PAGINATION.*`
|
||||
* error code so callers can programmatically distinguish cursor decoding
|
||||
* failures, invalid page numbers, limit violations, and abort signals.
|
||||
*/
|
||||
export class PaginationError extends SuaveplanError<PaginationCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: PaginationCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
110
packages/foundation/data/pagination/src/helpers/helpers.md
Normal file
110
packages/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.
|
||||
424
packages/foundation/data/pagination/src/helpers/helpers.test.ts
Normal file
424
packages/foundation/data/pagination/src/helpers/helpers.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { cursorPaginateArray } from "../cursor/cursor.js";
|
||||
import { PaginationError } from "../errors/pagination-error.js";
|
||||
import { paginateArray } from "../offset/offset.js";
|
||||
import {
|
||||
collectAll,
|
||||
collectUpTo,
|
||||
createCursorIterator,
|
||||
createOffsetIterator,
|
||||
isPaginatedResponseEmpty,
|
||||
mapPaginated,
|
||||
mergeOffsetResponses,
|
||||
} from "./helpers.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeOffsetFetcher(items: number[]) {
|
||||
return (page: number, limit: number) => {
|
||||
return Promise.resolve(paginateArray(items, page, limit));
|
||||
};
|
||||
}
|
||||
|
||||
function makeCursorFetcher(items: number[]) {
|
||||
return (cursor: string | undefined, limit: number) => {
|
||||
return Promise.resolve(
|
||||
cursorPaginateArray(items, { cursor, limit }, (_, i) => String(i))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOffsetIterator
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createOffsetIterator", () => {
|
||||
const items = Array.from({ length: 25 }, (_, i) => i + 1);
|
||||
|
||||
it("yields all pages", { timeout: 5000 }, async () => {
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(items), {
|
||||
limit: 10,
|
||||
});
|
||||
const pages: number[][] = [];
|
||||
for await (const p of iter) {
|
||||
pages.push(p.items);
|
||||
}
|
||||
expect(pages).toHaveLength(3);
|
||||
expect(pages.flat()).toEqual(items);
|
||||
});
|
||||
|
||||
it("hasMore is false on last page", { timeout: 5000 }, async () => {
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(items), {
|
||||
limit: 25,
|
||||
});
|
||||
const results: boolean[] = [];
|
||||
for await (const p of iter) results.push(p.hasMore);
|
||||
expect(results).toEqual([false]);
|
||||
});
|
||||
|
||||
it("yields nextPage on non-last pages", { timeout: 5000 }, async () => {
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(items), {
|
||||
limit: 10,
|
||||
});
|
||||
let first = true;
|
||||
for await (const p of iter) {
|
||||
if (first) {
|
||||
expect(p.nextPage).toBe(2);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws ABORTED when signal pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(items), {
|
||||
limit: 10,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of iter) {
|
||||
}
|
||||
})()
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
|
||||
it("done=true causes subsequent next() to terminate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const iter = createOffsetIterator(makeOffsetFetcher([1, 2, 3]), {
|
||||
limit: 10,
|
||||
});
|
||||
const iterator = iter[Symbol.asyncIterator]();
|
||||
const first = await iterator.next();
|
||||
expect(first.done).toBe(false);
|
||||
const second = await iterator.next();
|
||||
expect(second.done).toBe(true);
|
||||
});
|
||||
|
||||
it("respects startPage option", { timeout: 5000 }, async () => {
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(items), {
|
||||
startPage: 2,
|
||||
limit: 10,
|
||||
});
|
||||
const iterator = iter[Symbol.asyncIterator]();
|
||||
const first = await iterator.next();
|
||||
expect((first.value as { items: number[] }).items[0]).toBe(11);
|
||||
});
|
||||
|
||||
it("uses default limit=20 and startPage=1 when no opts provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const smallItems = Array.from({ length: 5 }, (_, i) => i + 1);
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(smallItems)); // no opts
|
||||
const all: number[] = [];
|
||||
for await (const p of iter) all.push(...p.items);
|
||||
expect(all).toEqual(smallItems);
|
||||
});
|
||||
|
||||
it("uses default limit=20 when opts provided without limit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const smallItems = Array.from({ length: 3 }, (_, i) => i + 1);
|
||||
// Pass opts with only startPage (no limit) → triggers `opts.limit ?? 20` branch
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(smallItems), {
|
||||
startPage: 1,
|
||||
});
|
||||
const all: number[] = [];
|
||||
for await (const p of iter) all.push(...p.items);
|
||||
expect(all).toEqual(smallItems);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCursorIterator
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createCursorIterator", () => {
|
||||
it("yields all cursor pages", { timeout: 5000 }, async () => {
|
||||
const items = Array.from({ length: 6 }, (_, i) => i + 1);
|
||||
const fetcher = makeCursorFetcher(items);
|
||||
const iter = createCursorIterator(fetcher, { limit: 2 });
|
||||
const all: number[] = [];
|
||||
for await (const p of iter) {
|
||||
all.push(...p.items);
|
||||
}
|
||||
expect(all).toEqual(items);
|
||||
});
|
||||
|
||||
it("throws ABORTED when signal pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const fetcher = makeCursorFetcher([1, 2, 3]);
|
||||
const iter = createCursorIterator(fetcher, {
|
||||
limit: 10,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of iter) {
|
||||
}
|
||||
})()
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
|
||||
it("throws ABORTED when signal fires after fetch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const fetcher = (cursor: string | undefined, limit: number) => {
|
||||
controller.abort(); // abort during the fetch
|
||||
return makeCursorFetcher([1, 2, 3])(cursor, limit);
|
||||
};
|
||||
const iter = createCursorIterator(fetcher, {
|
||||
limit: 10,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of iter) {
|
||||
}
|
||||
})()
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
|
||||
it("done=true causes subsequent next() to terminate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fetcher = makeCursorFetcher([1, 2]);
|
||||
const iter = createCursorIterator(fetcher, { limit: 10 });
|
||||
const iterator = iter[Symbol.asyncIterator]();
|
||||
const first = await iterator.next();
|
||||
expect(first.done).toBe(false);
|
||||
const second = await iterator.next();
|
||||
expect(second.done).toBe(true);
|
||||
});
|
||||
|
||||
it("uses default limit=20 when no opts provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const items = Array.from({ length: 5 }, (_, i) => i + 1);
|
||||
const fetcher = makeCursorFetcher(items);
|
||||
const iter = createCursorIterator(fetcher); // no opts → default limit 20
|
||||
const all: number[] = [];
|
||||
for await (const p of iter) all.push(...p.items);
|
||||
expect(all).toEqual(items);
|
||||
});
|
||||
|
||||
it("uses default limit=20 when opts provided without limit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const items = Array.from({ length: 3 }, (_, i) => i + 1);
|
||||
const fetcher = makeCursorFetcher(items);
|
||||
// Pass opts with only signal (no limit) → triggers `opts.limit ?? 20` branch
|
||||
const iter = createCursorIterator(fetcher, { signal: undefined });
|
||||
const all: number[] = [];
|
||||
for await (const p of iter) all.push(...p.items);
|
||||
expect(all).toEqual(items);
|
||||
});
|
||||
|
||||
it("handles null endCursor (nextCursor becomes undefined)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Create a fetcher that returns a response with null endCursor on last page
|
||||
let called = false;
|
||||
const fetcher = (_cursor: string | undefined, _limit: number) => {
|
||||
if (called)
|
||||
return Promise.resolve({
|
||||
data: [],
|
||||
meta: {
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
});
|
||||
called = true;
|
||||
return Promise.resolve({
|
||||
data: [1, 2],
|
||||
meta: {
|
||||
startCursor: "s",
|
||||
endCursor: null,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
const iter = createCursorIterator(
|
||||
fetcher as Parameters<typeof createCursorIterator>[0],
|
||||
{ limit: 10 }
|
||||
);
|
||||
const all: number[] = [];
|
||||
for await (const p of iter) all.push(...p.items);
|
||||
expect(all).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOffsetIterator post-fetch abort
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createOffsetIterator post-fetch abort", () => {
|
||||
it("throws ABORTED when signal fires after fetch completes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const items = [1, 2, 3];
|
||||
const fetcher = (page: number, limit: number) => {
|
||||
controller.abort(); // abort during the fetch
|
||||
return Promise.resolve(paginateArray(items, page, limit));
|
||||
};
|
||||
const iter = createOffsetIterator(fetcher, {
|
||||
limit: 10,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of iter) {
|
||||
}
|
||||
})()
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// collectAll
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("collectAll", () => {
|
||||
it("collects all items", { timeout: 5000 }, async () => {
|
||||
const items = Array.from({ length: 15 }, (_, i) => i);
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(items), {
|
||||
limit: 5,
|
||||
});
|
||||
const all = await collectAll(iter);
|
||||
expect(all).toEqual(items);
|
||||
});
|
||||
|
||||
it("throws ABORTED when signal aborted before first page", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const iter = createOffsetIterator(makeOffsetFetcher([1, 2]), {
|
||||
limit: 5,
|
||||
});
|
||||
controller.abort();
|
||||
await expect(
|
||||
collectAll(iter, controller.signal)
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// collectUpTo
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("collectUpTo", () => {
|
||||
it("returns at most maxItems", { timeout: 5000 }, async () => {
|
||||
const items = Array.from({ length: 50 }, (_, i) => i);
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(items), {
|
||||
limit: 10,
|
||||
});
|
||||
const collected = await collectUpTo(iter, 25);
|
||||
expect(collected).toHaveLength(25);
|
||||
});
|
||||
|
||||
it("returns all when maxItems > total", { timeout: 5000 }, async () => {
|
||||
const items = [1, 2, 3];
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(items), {
|
||||
limit: 10,
|
||||
});
|
||||
const collected = await collectUpTo(iter, 100);
|
||||
expect(collected).toEqual(items);
|
||||
});
|
||||
|
||||
it("throws ABORTED when signal aborted before first page", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const iter = createOffsetIterator(makeOffsetFetcher([1, 2, 3]), {
|
||||
limit: 10,
|
||||
});
|
||||
await expect(
|
||||
collectUpTo(iter, 10, controller.signal)
|
||||
).rejects.toBeInstanceOf(PaginationError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mapPaginated
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mapPaginated", () => {
|
||||
it("maps all items", { timeout: 5000 }, async () => {
|
||||
const items = [1, 2, 3, 4, 5];
|
||||
const iter = createOffsetIterator(makeOffsetFetcher(items), {
|
||||
limit: 2,
|
||||
});
|
||||
const doubled = await mapPaginated(iter, (x) => x * 2);
|
||||
expect(doubled).toEqual([2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it("preserves page shape (returns flat array)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const items = ["a", "b", "c"];
|
||||
const iter = createOffsetIterator(
|
||||
(page, limit) => Promise.resolve(paginateArray(items, page, limit)),
|
||||
{ limit: 2 }
|
||||
);
|
||||
const upper = await mapPaginated(iter, (s) => s.toUpperCase());
|
||||
expect(upper).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isPaginatedResponseEmpty
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isPaginatedResponseEmpty", () => {
|
||||
it("returns true for empty offset response", { timeout: 5000 }, () => {
|
||||
const res = paginateArray([], 1, 10);
|
||||
expect(isPaginatedResponseEmpty(res)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-empty offset response", { timeout: 5000 }, () => {
|
||||
const res = paginateArray([1, 2, 3], 1, 10);
|
||||
expect(isPaginatedResponseEmpty(res)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for empty cursor response", { timeout: 5000 }, () => {
|
||||
const res = {
|
||||
data: [],
|
||||
meta: {
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
};
|
||||
expect(isPaginatedResponseEmpty(res)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergeOffsetResponses
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("mergeOffsetResponses", () => {
|
||||
it("merges data from multiple responses", { timeout: 5000 }, () => {
|
||||
const r1 = paginateArray([1, 2, 3], 1, 3);
|
||||
const r2 = paginateArray([1, 2, 3, 4, 5, 6], 2, 3);
|
||||
const merged = mergeOffsetResponses([r1, r2]);
|
||||
expect(merged.data).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect(merged.total).toBe(3); // from first response
|
||||
});
|
||||
|
||||
it("returns zero total for empty responses array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const merged = mergeOffsetResponses([]);
|
||||
expect(merged.data).toEqual([]);
|
||||
expect(merged.total).toBe(0);
|
||||
});
|
||||
});
|
||||
237
packages/foundation/data/pagination/src/helpers/helpers.ts
Normal file
237
packages/foundation/data/pagination/src/helpers/helpers.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { PAGINATION_CODES } from "../errors/codes.js";
|
||||
import { PaginationError } from "../errors/pagination-error.js";
|
||||
import type {
|
||||
CursorPaginatedResponse,
|
||||
OffsetPaginatedResponse,
|
||||
PaginatedIteratorResult,
|
||||
} from "../types/pagination.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offset iterator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create an `AsyncIterable` that walks through an offset-paginated API.
|
||||
*
|
||||
* Each iteration step yields a `PaginatedIteratorResult<T>` containing
|
||||
* the page items and whether more pages exist. Pass `opts.signal` to
|
||||
* abort between page fetches; a `PaginationError(PAGINATION.ABORTED)`
|
||||
* is thrown when the signal fires.
|
||||
*/
|
||||
export function createOffsetIterator<T>(
|
||||
fetcher: (
|
||||
page: number,
|
||||
limit: number
|
||||
) => Promise<OffsetPaginatedResponse<T>>,
|
||||
opts?: { startPage?: number; limit?: number; signal?: AbortSignal }
|
||||
): AsyncIterable<PaginatedIteratorResult<T>> {
|
||||
const startPage = opts === undefined ? 1 : (opts.startPage ?? 1);
|
||||
const limit = opts === undefined ? 20 : (opts.limit ?? 20);
|
||||
const signal = opts?.signal;
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
let currentPage = startPage;
|
||||
let done = false;
|
||||
|
||||
return {
|
||||
async next(): Promise<
|
||||
IteratorResult<PaginatedIteratorResult<T>>
|
||||
> {
|
||||
if (done) return { done: true, value: undefined };
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetcher(currentPage, limit);
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const hasMore = response.meta.hasNext;
|
||||
if (!hasMore) done = true;
|
||||
|
||||
const result: PaginatedIteratorResult<T> = {
|
||||
items: response.data,
|
||||
hasMore,
|
||||
};
|
||||
if (hasMore) result.nextPage = currentPage + 1;
|
||||
currentPage++;
|
||||
return { done: false, value: result };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor iterator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create an `AsyncIterable` that walks through a cursor-paginated API.
|
||||
*
|
||||
* Each step yields a `PaginatedIteratorResult<T>`. Supports `AbortSignal`
|
||||
* via `opts.signal`.
|
||||
*/
|
||||
export function createCursorIterator<T>(
|
||||
fetcher: (
|
||||
cursor: string | undefined,
|
||||
limit: number
|
||||
) => Promise<CursorPaginatedResponse<T>>,
|
||||
opts?: { limit?: number; signal?: AbortSignal }
|
||||
): AsyncIterable<PaginatedIteratorResult<T>> {
|
||||
const limit = opts === undefined ? 20 : (opts.limit ?? 20);
|
||||
const signal = opts?.signal;
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
let nextCursor: string | undefined;
|
||||
let done = false;
|
||||
|
||||
return {
|
||||
async next(): Promise<
|
||||
IteratorResult<PaginatedIteratorResult<T>>
|
||||
> {
|
||||
if (done) return { done: true, value: undefined };
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetcher(nextCursor, limit);
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const hasMore = response.meta.hasNextPage;
|
||||
nextCursor =
|
||||
response.meta.endCursor === null
|
||||
? undefined
|
||||
: response.meta.endCursor;
|
||||
|
||||
if (!hasMore) done = true;
|
||||
|
||||
const result: PaginatedIteratorResult<T> = {
|
||||
items: response.data,
|
||||
hasMore,
|
||||
};
|
||||
if (hasMore && nextCursor !== undefined)
|
||||
result.nextCursor = nextCursor;
|
||||
return { done: false, value: result };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collect all items from a paginated async iterable into a single array.
|
||||
*
|
||||
* Accepts an optional `AbortSignal`; forwards it to each iterator step
|
||||
* by checking the signal before each page fetch.
|
||||
*/
|
||||
export async function collectAll<T>(
|
||||
iterable: AsyncIterable<PaginatedIteratorResult<T>>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
for await (const result of iterable) {
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
all.push(...result.items);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect up to `maxItems` items from a paginated async iterable.
|
||||
*
|
||||
* Stops early once `maxItems` is reached. Accepts an optional `AbortSignal`.
|
||||
*/
|
||||
export async function collectUpTo<T>(
|
||||
iterable: AsyncIterable<PaginatedIteratorResult<T>>,
|
||||
maxItems: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
for await (const result of iterable) {
|
||||
if (signal?.aborted) {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.ABORTED,
|
||||
"pagination.aborted"
|
||||
);
|
||||
}
|
||||
for (const item of result.items) {
|
||||
all.push(item);
|
||||
if (all.length >= maxItems) return all;
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping and inspection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map items across all pages of a paginated async iterable.
|
||||
*
|
||||
* Returns a flat array of mapped values (not a paginated response).
|
||||
*/
|
||||
export async function mapPaginated<T, U>(
|
||||
iterable: AsyncIterable<PaginatedIteratorResult<T>>,
|
||||
mapper: (item: T) => U
|
||||
): Promise<U[]> {
|
||||
const results: U[] = [];
|
||||
for await (const result of iterable) {
|
||||
results.push(...result.items.map(mapper));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` when the paginated response contains no data items.
|
||||
*/
|
||||
export function isPaginatedResponseEmpty<T>(
|
||||
response: OffsetPaginatedResponse<T> | CursorPaginatedResponse<T>
|
||||
): boolean {
|
||||
return response.data.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an array of offset-paginated responses into a single combined result.
|
||||
*
|
||||
* The `total` is taken from the first response; assumes all responses share
|
||||
* the same total count (i.e., they are sequential pages of the same query).
|
||||
*/
|
||||
export function mergeOffsetResponses<T>(
|
||||
responses: OffsetPaginatedResponse<T>[]
|
||||
): { data: T[]; total: number } {
|
||||
const data = responses.flatMap((r) => r.data);
|
||||
const total = responses.length > 0 ? responses[0]!.meta.total : 0;
|
||||
return { data, total };
|
||||
}
|
||||
9
packages/foundation/data/pagination/src/helpers/index.ts
Normal file
9
packages/foundation/data/pagination/src/helpers/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
collectAll,
|
||||
collectUpTo,
|
||||
createCursorIterator,
|
||||
createOffsetIterator,
|
||||
isPaginatedResponseEmpty,
|
||||
mapPaginated,
|
||||
mergeOffsetResponses,
|
||||
} from "./helpers";
|
||||
127
packages/foundation/data/pagination/src/index.ts
Normal file
127
packages/foundation/data/pagination/src/index.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapters
|
||||
// ---------------------------------------------------------------------------
|
||||
export type { PaginationAdapter } from "./adapters/database-adapter.js";
|
||||
export {
|
||||
createMemoryAdapter,
|
||||
createSortConfig,
|
||||
MemoryPaginationAdapter,
|
||||
} from "./adapters/database-adapter.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor pagination — in-memory utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
export {
|
||||
calculateCursorMeta,
|
||||
createConnection,
|
||||
createCursorResponse,
|
||||
createEdge,
|
||||
createEdges,
|
||||
cursorPaginateArray,
|
||||
normalizeCursorRequest,
|
||||
} from "./cursor/cursor.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor pagination — adapter-backed + codec
|
||||
// ---------------------------------------------------------------------------
|
||||
export {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
fetchAfter,
|
||||
fetchBefore,
|
||||
validateCursor,
|
||||
} from "./cursor/cursor-pagination.js";
|
||||
export type { PaginationCode } from "./errors/codes.js";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ---------------------------------------------------------------------------
|
||||
export { PAGINATION_CODES } from "./errors/codes.js";
|
||||
export { PaginationError } from "./errors/pagination-error.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
export {
|
||||
collectAll,
|
||||
collectUpTo,
|
||||
createCursorIterator,
|
||||
createOffsetIterator,
|
||||
isPaginatedResponseEmpty,
|
||||
mapPaginated,
|
||||
mergeOffsetResponses,
|
||||
} from "./helpers/helpers.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyset pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
export {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
extractSortKeys,
|
||||
fetchKeysetPage,
|
||||
keysetPaginate,
|
||||
} from "./keyset/keyset-pagination.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offset pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
export {
|
||||
calculateOffsetMeta,
|
||||
createOffsetResponse,
|
||||
estimateTotalCount,
|
||||
getPageRange,
|
||||
normalizeOffsetRequest,
|
||||
offsetToPage,
|
||||
pageToOffset,
|
||||
paginateArray,
|
||||
} from "./offset/offset.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OTel
|
||||
// ---------------------------------------------------------------------------
|
||||
export type { PaginationInstruments } from "./otel/pagination-otel.js";
|
||||
export {
|
||||
getPaginationInstruments,
|
||||
resetPaginationInstruments,
|
||||
} from "./otel/pagination-otel.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
export {
|
||||
createCursorToken,
|
||||
createOffsetToken,
|
||||
decodePageToken,
|
||||
encodePageToken,
|
||||
extractCursorValue,
|
||||
extractSortInfo,
|
||||
hashFilters,
|
||||
validateTokenFilter,
|
||||
} from "./tokens/page-tokens.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
export type {
|
||||
Connection,
|
||||
CursorDirection,
|
||||
CursorPageResult,
|
||||
CursorPaginatedResponse,
|
||||
CursorPaginationMeta,
|
||||
CursorPaginationOptions,
|
||||
CursorPaginationRequest,
|
||||
Edge,
|
||||
FilterConfig,
|
||||
KeysetPageResult,
|
||||
KeysetPaginationOptions,
|
||||
KeysetSortKey,
|
||||
OffsetPaginatedResponse,
|
||||
OffsetPaginationMeta,
|
||||
OffsetPaginationRequest,
|
||||
PageResult,
|
||||
PageTokenPayload,
|
||||
PaginatedIteratorResult,
|
||||
PaginationDefaults,
|
||||
SortConfig,
|
||||
SortDirection,
|
||||
} from "./types/pagination.types.js";
|
||||
7
packages/foundation/data/pagination/src/keyset/index.ts
Normal file
7
packages/foundation/data/pagination/src/keyset/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
extractSortKeys,
|
||||
fetchKeysetPage,
|
||||
keysetPaginate,
|
||||
} from "./keyset-pagination";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createMemoryAdapter } from "../adapters/database-adapter.js";
|
||||
import { PaginationError } from "../errors/pagination-error.js";
|
||||
import {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
extractSortKeys,
|
||||
fetchKeysetPage,
|
||||
keysetPaginate,
|
||||
} from "./keyset-pagination.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// encodeKeysetCursor / decodeKeysetCursor
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("encodeKeysetCursor / decodeKeysetCursor", () => {
|
||||
const keys = [
|
||||
{ field: "id", direction: "asc" as const, value: 42 },
|
||||
{ field: "name", direction: "desc" as const, value: "Alice" },
|
||||
];
|
||||
|
||||
it("round-trips valid keys", { timeout: 5000 }, () => {
|
||||
const encoded = encodeKeysetCursor(keys);
|
||||
expect(decodeKeysetCursor(encoded)).toEqual(keys);
|
||||
});
|
||||
|
||||
it("produces URL-safe characters only", { timeout: 5000 }, () => {
|
||||
const encoded = encodeKeysetCursor(keys);
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", { timeout: 5000 }, () => {
|
||||
expect(decodeKeysetCursor("!!!invalid!!!")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-array JSON", { timeout: 5000 }, () => {
|
||||
const json = JSON.stringify({ field: "id" });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const encoded = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodeKeysetCursor(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when element missing field", { timeout: 5000 }, () => {
|
||||
const bad = [{ direction: "asc", value: 1 }];
|
||||
const json = JSON.stringify(bad);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const encoded = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodeKeysetCursor(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when direction is invalid", { timeout: 5000 }, () => {
|
||||
const bad = [{ field: "id", direction: "up", value: 1 }];
|
||||
const json = JSON.stringify(bad);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const encoded = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodeKeysetCursor(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when value is wrong type", { timeout: 5000 }, () => {
|
||||
const bad = [{ field: "id", direction: "asc", value: [1, 2] }];
|
||||
const json = JSON.stringify(bad);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const encoded = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodeKeysetCursor(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when element is null", { timeout: 5000 }, () => {
|
||||
const bad = [null];
|
||||
const json = JSON.stringify(bad);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const encoded = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodeKeysetCursor(encoded)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// encodeKeysetCursor — error path
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("encodeKeysetCursor error path", () => {
|
||||
it("throws PaginationError when JSON.stringify fails", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
// Manually craft a circular KeysetSortKey-like structure
|
||||
expect(() =>
|
||||
encodeKeysetCursor([
|
||||
circular as unknown as import("../types/pagination.types.js").KeysetSortKey,
|
||||
])
|
||||
).toThrow(PaginationError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractSortKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("extractSortKeys", () => {
|
||||
it("extracts values from item by config", { timeout: 5000 }, () => {
|
||||
const item = { id: 5, name: "Bob", other: "ignored" };
|
||||
const keys = extractSortKeys(item, [
|
||||
{ field: "id", direction: "asc" },
|
||||
{ field: "name", direction: "desc" },
|
||||
]);
|
||||
expect(keys).toEqual([
|
||||
{ field: "id", direction: "asc", value: 5 },
|
||||
{ field: "name", direction: "desc", value: "Bob" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// keysetPaginate (in-memory)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("keysetPaginate", () => {
|
||||
const items = [
|
||||
{ id: 1, name: "Alice" },
|
||||
{ id: 2, name: "Bob" },
|
||||
{ id: 3, name: "Carol" },
|
||||
{ id: 4, name: "Dave" },
|
||||
{ id: 5, name: "Eve" },
|
||||
];
|
||||
const options = {
|
||||
limit: 2,
|
||||
sortKeys: [{ field: "id", direction: "asc" as const }],
|
||||
};
|
||||
|
||||
it("first page without lastKeys", { timeout: 5000 }, () => {
|
||||
const res = keysetPaginate(items, null, options);
|
||||
expect(res.items).toHaveLength(2);
|
||||
expect(res.items[0]?.id).toBe(1);
|
||||
expect(res.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("subsequent page using lastKeys", { timeout: 5000 }, () => {
|
||||
const page1 = keysetPaginate(items, null, options);
|
||||
const page2 = keysetPaginate(items, page1.lastKeys, options);
|
||||
expect(page2.items[0]?.id).toBe(3);
|
||||
});
|
||||
|
||||
it("last page hasMore false", { timeout: 5000 }, () => {
|
||||
const page1 = keysetPaginate(items, null, options);
|
||||
const page2 = keysetPaginate(items, page1.lastKeys, options);
|
||||
const page3 = keysetPaginate(items, page2.lastKeys, options);
|
||||
expect(page3.hasMore).toBe(false);
|
||||
expect(page3.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("empty items when all consumed", { timeout: 5000 }, () => {
|
||||
const page1 = keysetPaginate(items, null, {
|
||||
limit: 5,
|
||||
sortKeys: [{ field: "id", direction: "asc" }],
|
||||
});
|
||||
const page2 = keysetPaginate(items, page1.lastKeys, {
|
||||
limit: 5,
|
||||
sortKeys: [{ field: "id", direction: "asc" }],
|
||||
});
|
||||
expect(page2.items).toHaveLength(0);
|
||||
expect(page2.lastKeys).toBeNull();
|
||||
});
|
||||
|
||||
it("empty sortKeys returns empty", { timeout: 5000 }, () => {
|
||||
const res = keysetPaginate(items, null, { limit: 2, sortKeys: [] });
|
||||
expect(res.items).toHaveLength(0);
|
||||
expect(res.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("descending sort", { timeout: 5000 }, () => {
|
||||
const res = keysetPaginate(items, null, {
|
||||
limit: 2,
|
||||
sortKeys: [{ field: "id", direction: "desc" }],
|
||||
});
|
||||
expect(res.items[0]?.id).toBe(5);
|
||||
expect(res.items[1]?.id).toBe(4);
|
||||
});
|
||||
|
||||
it("string field sort", { timeout: 5000 }, () => {
|
||||
const res = keysetPaginate(items, null, {
|
||||
limit: 3,
|
||||
sortKeys: [{ field: "name", direction: "asc" }],
|
||||
});
|
||||
expect(res.items[0]?.name).toBe("Alice");
|
||||
expect(res.items[2]?.name).toBe("Carol");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchKeysetPage (adapter-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("fetchKeysetPage", () => {
|
||||
const items = Array.from({ length: 10 }, (_, i) => ({ id: i + 1 }));
|
||||
|
||||
it("returns first page without lastKeys", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchKeysetPage(adapter, null, {
|
||||
limit: 3,
|
||||
sortKeys: [{ field: "id", direction: "asc" }],
|
||||
});
|
||||
expect(res.items).toHaveLength(3);
|
||||
expect(res.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty for empty sortKeys", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const res = await fetchKeysetPage(adapter, null, {
|
||||
limit: 5,
|
||||
sortKeys: [],
|
||||
});
|
||||
expect(res.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("successive pages cover all items", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const opts = {
|
||||
limit: 4,
|
||||
sortKeys: [{ field: "id", direction: "asc" as const }],
|
||||
};
|
||||
const page1 = await fetchKeysetPage(adapter, null, opts);
|
||||
const page2 = await fetchKeysetPage(adapter, page1.lastKeys, opts);
|
||||
const page3 = await fetchKeysetPage(adapter, page2.lastKeys, opts);
|
||||
const allIds = [...page1.items, ...page2.items, ...page3.items].map(
|
||||
(i) => i.id
|
||||
);
|
||||
expect(allIds).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it("returns null lastKeys when page is empty (after all items consumed)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = createMemoryAdapter(items);
|
||||
const opts = {
|
||||
limit: 10,
|
||||
sortKeys: [{ field: "id", direction: "asc" as const }],
|
||||
};
|
||||
const page1 = await fetchKeysetPage(adapter, null, opts);
|
||||
// page1 consumed all 10 items; next page is empty
|
||||
const page2 = await fetchKeysetPage(adapter, page1.lastKeys, opts);
|
||||
expect(page2.items).toHaveLength(0);
|
||||
expect(page2.lastKeys).toBeNull();
|
||||
});
|
||||
|
||||
it("handles empty adapter (total=0 → fetchPage(0, 1, ...))", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = createMemoryAdapter<{ id: number }>([]);
|
||||
const res = await fetchKeysetPage(adapter, null, {
|
||||
limit: 5,
|
||||
sortKeys: [{ field: "id", direction: "asc" }],
|
||||
});
|
||||
expect(res.items).toHaveLength(0);
|
||||
expect(res.hasMore).toBe(false);
|
||||
expect(res.lastKeys).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { PAGINATION_CODES } from "../errors/codes.js";
|
||||
import { PaginationError } from "../errors/pagination-error.js";
|
||||
import type {
|
||||
FilterConfig,
|
||||
KeysetPageResult,
|
||||
KeysetPaginationOptions,
|
||||
KeysetSortKey,
|
||||
PageResult,
|
||||
SortConfig,
|
||||
SortDirection,
|
||||
} from "../types/pagination.types.js";
|
||||
|
||||
/**
|
||||
* Minimal adapter interface required for keyset pagination.
|
||||
*/
|
||||
interface KeysetAdapter<T> {
|
||||
count(filter?: FilterConfig): Promise<number>;
|
||||
fetchPage(
|
||||
offset: number,
|
||||
limit: number,
|
||||
sort?: SortConfig,
|
||||
filter?: FilterConfig
|
||||
): Promise<PageResult<T>>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encode an array of `KeysetSortKey` values into an opaque URL-safe string.
|
||||
*
|
||||
* Throws `PaginationError(PAGINATION.INVALID_KEYSET_CURSOR)` if serialisation
|
||||
* fails (e.g., circular references in the key values).
|
||||
*/
|
||||
export function encodeKeysetCursor(keys: KeysetSortKey[]): string {
|
||||
let json: string;
|
||||
try {
|
||||
json = JSON.stringify(keys);
|
||||
} catch {
|
||||
throw new PaginationError(
|
||||
PAGINATION_CODES.INVALID_KEYSET_CURSOR,
|
||||
"pagination.invalidKeysetCursor"
|
||||
);
|
||||
}
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an opaque keyset cursor string back to its `KeysetSortKey[]` value.
|
||||
*
|
||||
* Returns `null` for any malformed input; never throws.
|
||||
*/
|
||||
export function decodeKeysetCursor(cursor: string): KeysetSortKey[] | null {
|
||||
try {
|
||||
let base64 = cursor.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = base64.length % 4;
|
||||
if (padding === 2) base64 += "==";
|
||||
else if (padding === 3) base64 += "=";
|
||||
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
if (!parsed.every(isValidKeysetKey)) return null;
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidKeysetKey(value: unknown): value is KeysetSortKey {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj["field"] !== "string") return false;
|
||||
if (obj["direction"] !== "asc" && obj["direction"] !== "desc") return false;
|
||||
if (typeof obj["value"] !== "string" && typeof obj["value"] !== "number")
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort key extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract sort key values from an item based on sort configuration.
|
||||
*
|
||||
* The result is an array of `KeysetSortKey` — one per configured sort field —
|
||||
* with the actual value pulled from the item at runtime.
|
||||
*/
|
||||
export function extractSortKeys<T>(
|
||||
item: T,
|
||||
sortKeys: Array<{ field: string; direction: SortDirection }>
|
||||
): KeysetSortKey[] {
|
||||
const record = item as Record<string, unknown>;
|
||||
return sortKeys.map(({ field, direction }) => ({
|
||||
field,
|
||||
direction,
|
||||
value: record[field] as string | number,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comparison helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function compareValues(
|
||||
a: string | number,
|
||||
b: string | number,
|
||||
direction: SortDirection
|
||||
): number {
|
||||
const multiplier = direction === "asc" ? 1 : -1;
|
||||
if (a === b) return 0;
|
||||
if (typeof a === "string" && typeof b === "string") {
|
||||
return a.localeCompare(b) * multiplier;
|
||||
}
|
||||
return ((a as number) < (b as number) ? -1 : 1) * multiplier;
|
||||
}
|
||||
|
||||
function isAfterKeyset<T>(item: T, lastKeys: KeysetSortKey[]): boolean {
|
||||
const record = item as Record<string, unknown>;
|
||||
for (const key of lastKeys) {
|
||||
const itemValue = record[key.field] as string | number;
|
||||
const cmp = compareValues(itemValue, key.value, key.direction);
|
||||
if (cmp > 0) return true;
|
||||
if (cmp < 0) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter-based fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch a single keyset-paginated page from an adapter.
|
||||
*
|
||||
* Loads all sorted items and then filters to those that come after the
|
||||
* provided `lastKeys` position. Pass `null` for the first page.
|
||||
*/
|
||||
export async function fetchKeysetPage<T>(
|
||||
adapter: KeysetAdapter<T>,
|
||||
lastKeys: KeysetSortKey[] | null,
|
||||
options: KeysetPaginationOptions
|
||||
): Promise<KeysetPageResult<T>> {
|
||||
const { limit, sortKeys, filter } = options;
|
||||
|
||||
if (sortKeys.length === 0) {
|
||||
return { items: [], hasMore: false, lastKeys: null };
|
||||
}
|
||||
|
||||
const primarySort = sortKeys[0] as {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
};
|
||||
const sort: SortConfig = {
|
||||
field: primarySort.field,
|
||||
direction: primarySort.direction,
|
||||
};
|
||||
|
||||
const total = await adapter.count(filter);
|
||||
const result = await adapter.fetchPage(0, total || 1, sort, filter);
|
||||
let allItems = result.items;
|
||||
|
||||
if (lastKeys && lastKeys.length > 0) {
|
||||
allItems = allItems.filter((item) => isAfterKeyset(item, lastKeys));
|
||||
}
|
||||
|
||||
const pageItems = allItems.slice(0, limit);
|
||||
const hasMore = allItems.length > limit;
|
||||
|
||||
const resultKeys =
|
||||
pageItems.length > 0
|
||||
? extractSortKeys(pageItems[pageItems.length - 1] as T, sortKeys)
|
||||
: null;
|
||||
|
||||
return { items: pageItems, hasMore, lastKeys: resultKeys };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory keyset pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Paginate an in-memory array using keyset pagination.
|
||||
*
|
||||
* Sorts `items` by the first sort key, filters to items after `lastKeys`
|
||||
* (pass `null` for the first page), and returns at most `limit` items.
|
||||
*/
|
||||
export function keysetPaginate<T>(
|
||||
items: T[],
|
||||
lastKeys: KeysetSortKey[] | null,
|
||||
options: KeysetPaginationOptions
|
||||
): KeysetPageResult<T> {
|
||||
const { limit, sortKeys } = options;
|
||||
|
||||
if (sortKeys.length === 0) {
|
||||
return { items: [], hasMore: false, lastKeys: null };
|
||||
}
|
||||
|
||||
const primarySort = sortKeys[0] as {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
};
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const aVal = (a as Record<string, unknown>)[primarySort.field] as
|
||||
| string
|
||||
| number;
|
||||
const bVal = (b as Record<string, unknown>)[primarySort.field] as
|
||||
| string
|
||||
| number;
|
||||
return compareValues(aVal, bVal, primarySort.direction);
|
||||
});
|
||||
|
||||
const filtered =
|
||||
lastKeys && lastKeys.length > 0
|
||||
? sorted.filter((item) => isAfterKeyset(item, lastKeys))
|
||||
: sorted;
|
||||
|
||||
const pageItems = filtered.slice(0, limit);
|
||||
const hasMore = filtered.length > limit;
|
||||
|
||||
const resultKeys =
|
||||
pageItems.length > 0
|
||||
? extractSortKeys(pageItems[pageItems.length - 1] as T, sortKeys)
|
||||
: null;
|
||||
|
||||
return { items: pageItems, hasMore, lastKeys: resultKeys };
|
||||
}
|
||||
10
packages/foundation/data/pagination/src/offset/index.ts
Normal file
10
packages/foundation/data/pagination/src/offset/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
calculateOffsetMeta,
|
||||
createOffsetResponse,
|
||||
estimateTotalCount,
|
||||
getPageRange,
|
||||
normalizeOffsetRequest,
|
||||
offsetToPage,
|
||||
pageToOffset,
|
||||
paginateArray,
|
||||
} from "./offset";
|
||||
105
packages/foundation/data/pagination/src/offset/offset.md
Normal file
105
packages/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.
|
||||
233
packages/foundation/data/pagination/src/offset/offset.test.ts
Normal file
233
packages/foundation/data/pagination/src/offset/offset.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createMemoryAdapter } from "../adapters/database-adapter.js";
|
||||
import {
|
||||
calculateOffsetMeta,
|
||||
createOffsetResponse,
|
||||
estimateTotalCount,
|
||||
getPageRange,
|
||||
normalizeOffsetRequest,
|
||||
offsetToPage,
|
||||
pageToOffset,
|
||||
paginateArray,
|
||||
} from "./offset.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calculateOffsetMeta
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("calculateOffsetMeta", () => {
|
||||
it("computes basic metadata", { timeout: 5000 }, () => {
|
||||
const meta = calculateOffsetMeta(100, 2, 10);
|
||||
expect(meta.page).toBe(2);
|
||||
expect(meta.limit).toBe(10);
|
||||
expect(meta.total).toBe(100);
|
||||
expect(meta.totalPages).toBe(10);
|
||||
expect(meta.hasNext).toBe(true);
|
||||
expect(meta.hasPrevious).toBe(true);
|
||||
});
|
||||
|
||||
it("first page: hasPrevious false", { timeout: 5000 }, () => {
|
||||
const meta = calculateOffsetMeta(50, 1, 10);
|
||||
expect(meta.hasPrevious).toBe(false);
|
||||
expect(meta.hasNext).toBe(true);
|
||||
});
|
||||
|
||||
it("last page: hasNext false", { timeout: 5000 }, () => {
|
||||
const meta = calculateOffsetMeta(30, 3, 10);
|
||||
expect(meta.hasNext).toBe(false);
|
||||
expect(meta.hasPrevious).toBe(true);
|
||||
});
|
||||
|
||||
it("empty dataset", { timeout: 5000 }, () => {
|
||||
const meta = calculateOffsetMeta(0, 1, 10);
|
||||
expect(meta.totalPages).toBe(0);
|
||||
expect(meta.hasNext).toBe(false);
|
||||
expect(meta.hasPrevious).toBe(false);
|
||||
});
|
||||
|
||||
it("limit zero → totalPages 0", { timeout: 5000 }, () => {
|
||||
const meta = calculateOffsetMeta(50, 1, 0);
|
||||
expect(meta.totalPages).toBe(0);
|
||||
});
|
||||
|
||||
it("partial last page", { timeout: 5000 }, () => {
|
||||
const meta = calculateOffsetMeta(25, 3, 10);
|
||||
expect(meta.totalPages).toBe(3);
|
||||
expect(meta.hasNext).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOffsetResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createOffsetResponse", () => {
|
||||
it("wraps data and meta", { timeout: 5000 }, () => {
|
||||
const res = createOffsetResponse([1, 2, 3], 30, 1, 10);
|
||||
expect(res.data).toEqual([1, 2, 3]);
|
||||
expect(res.meta.total).toBe(30);
|
||||
expect(res.meta.page).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeOffsetRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("normalizeOffsetRequest", () => {
|
||||
it("defaults page=1, limit=20, sortOrder=desc", { timeout: 5000 }, () => {
|
||||
const req = normalizeOffsetRequest({});
|
||||
expect(req.page).toBe(1);
|
||||
expect(req.limit).toBe(20);
|
||||
expect(req.sortOrder).toBe("desc");
|
||||
});
|
||||
|
||||
it("clamps negative page to 1", { timeout: 5000 }, () => {
|
||||
const req = normalizeOffsetRequest({ page: -5 });
|
||||
expect(req.page).toBe(1);
|
||||
});
|
||||
|
||||
it("clamps limit to maxLimit", { timeout: 5000 }, () => {
|
||||
const req = normalizeOffsetRequest({ limit: 9999 }, { maxLimit: 50 });
|
||||
expect(req.limit).toBe(50);
|
||||
});
|
||||
|
||||
it("clamps limit to minLimit", { timeout: 5000 }, () => {
|
||||
const req = normalizeOffsetRequest({ limit: 0 }, { minLimit: 5 });
|
||||
expect(req.limit).toBe(5);
|
||||
});
|
||||
|
||||
it("floors fractional values", { timeout: 5000 }, () => {
|
||||
const req = normalizeOffsetRequest({ page: 2.9, limit: 15.7 });
|
||||
expect(req.page).toBe(2);
|
||||
expect(req.limit).toBe(15);
|
||||
});
|
||||
|
||||
it("preserves sortBy and sortOrder", { timeout: 5000 }, () => {
|
||||
const req = normalizeOffsetRequest({
|
||||
sortBy: "name",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
expect(req.sortBy).toBe("name");
|
||||
expect(req.sortOrder).toBe("asc");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pageToOffset / offsetToPage
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("pageToOffset", () => {
|
||||
it("page 1, limit 10 → offset 0", { timeout: 5000 }, () => {
|
||||
expect(pageToOffset(1, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it("page 2, limit 10 → offset 10", { timeout: 5000 }, () => {
|
||||
expect(pageToOffset(2, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("page 0 → clamped to 0", { timeout: 5000 }, () => {
|
||||
expect(pageToOffset(0, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it("negative page → clamped to 0", { timeout: 5000 }, () => {
|
||||
expect(pageToOffset(-3, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offsetToPage", () => {
|
||||
it("offset 0, limit 10 → page 1", { timeout: 5000 }, () => {
|
||||
expect(offsetToPage(0, 10)).toBe(1);
|
||||
});
|
||||
|
||||
it("offset 20, limit 10 → page 3", { timeout: 5000 }, () => {
|
||||
expect(offsetToPage(20, 10)).toBe(3);
|
||||
});
|
||||
|
||||
it("limit 0 → page 1", { timeout: 5000 }, () => {
|
||||
expect(offsetToPage(100, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("round-trip: offsetToPage(pageToOffset(p, l), l) === p", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const page of [1, 5, 10, 99]) {
|
||||
expect(offsetToPage(pageToOffset(page, 10), 10)).toBe(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getPageRange
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("getPageRange", () => {
|
||||
it("basic window centred on current page", { timeout: 5000 }, () => {
|
||||
const range = getPageRange(5, 10, 5);
|
||||
expect(range).toEqual([3, 4, 5, 6, 7]);
|
||||
});
|
||||
|
||||
it("near beginning clamps left", { timeout: 5000 }, () => {
|
||||
const range = getPageRange(1, 10, 5);
|
||||
expect(range).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("near end clamps right", { timeout: 5000 }, () => {
|
||||
const range = getPageRange(10, 10, 5);
|
||||
expect(range).toEqual([6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it("returns empty for totalPages 0", { timeout: 5000 }, () => {
|
||||
expect(getPageRange(1, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("default windowSize 5", { timeout: 5000 }, () => {
|
||||
const range = getPageRange(3, 10);
|
||||
expect(range).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// paginateArray
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("paginateArray", () => {
|
||||
const items = Array.from({ length: 25 }, (_, i) => i + 1);
|
||||
|
||||
it("returns correct slice for page 1", { timeout: 5000 }, () => {
|
||||
const res = paginateArray(items, 1, 10);
|
||||
expect(res.data).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
expect(res.meta.hasNext).toBe(true);
|
||||
});
|
||||
|
||||
it("returns correct slice for page 3", { timeout: 5000 }, () => {
|
||||
const res = paginateArray(items, 3, 10);
|
||||
expect(res.data).toEqual([21, 22, 23, 24, 25]);
|
||||
expect(res.meta.hasNext).toBe(false);
|
||||
});
|
||||
|
||||
it("returns ≤ limit items always", { timeout: 5000 }, () => {
|
||||
for (const page of [1, 2, 3, 4]) {
|
||||
const res = paginateArray(items, page, 7);
|
||||
expect(res.data.length).toBeLessThanOrEqual(7);
|
||||
}
|
||||
});
|
||||
|
||||
it("page beyond total → empty data", { timeout: 5000 }, () => {
|
||||
const res = paginateArray(items, 100, 10);
|
||||
expect(res.data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// estimateTotalCount
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("estimateTotalCount", () => {
|
||||
it("returns 0 for empty adapter", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter<number>([]);
|
||||
const est = await estimateTotalCount(adapter, {});
|
||||
expect(est).toBe(0);
|
||||
});
|
||||
|
||||
it("estimates count from sample", { timeout: 5000 }, async () => {
|
||||
const adapter = createMemoryAdapter(
|
||||
Array.from({ length: 50 }, (_, i) => i)
|
||||
);
|
||||
const est = await estimateTotalCount(adapter, { sampleSize: 10 });
|
||||
expect(est).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
200
packages/foundation/data/pagination/src/offset/offset.ts
Normal file
200
packages/foundation/data/pagination/src/offset/offset.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type {
|
||||
FilterConfig,
|
||||
OffsetPaginatedResponse,
|
||||
OffsetPaginationMeta,
|
||||
OffsetPaginationRequest,
|
||||
PageResult,
|
||||
PaginationDefaults,
|
||||
SortConfig,
|
||||
} from "../types/pagination.types.js";
|
||||
|
||||
const DEFAULT_PAGINATION: PaginationDefaults = {
|
||||
defaultLimit: 20,
|
||||
maxLimit: 100,
|
||||
minLimit: 1,
|
||||
defaultPage: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal interface required for offset-based adapter operations.
|
||||
*/
|
||||
interface OffsetAdapter<T> {
|
||||
count(filter?: FilterConfig): Promise<number>;
|
||||
fetchPage(
|
||||
offset: number,
|
||||
limit: number,
|
||||
sort?: SortConfig,
|
||||
filter?: FilterConfig
|
||||
): Promise<PageResult<T>>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata calculation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute `OffsetPaginationMeta` from total item count, page, and limit.
|
||||
*/
|
||||
export function calculateOffsetMeta(
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number
|
||||
): OffsetPaginationMeta {
|
||||
const totalPages = limit > 0 ? Math.ceil(total / limit) : 0;
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrevious: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build an `OffsetPaginatedResponse<T>` from data and count information.
|
||||
*/
|
||||
export function createOffsetResponse<T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number
|
||||
): OffsetPaginatedResponse<T> {
|
||||
return {
|
||||
data,
|
||||
meta: calculateOffsetMeta(total, page, limit),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request normalisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Normalise and clamp raw offset-pagination request parameters.
|
||||
*
|
||||
* - `page` is floored and clamped to `≥ 1`.
|
||||
* - `limit` is floored and clamped to `[minLimit, maxLimit]`.
|
||||
* - `sortOrder` defaults to `"desc"`.
|
||||
*/
|
||||
export function normalizeOffsetRequest(
|
||||
input: Partial<OffsetPaginationRequest>,
|
||||
defaults: Partial<PaginationDefaults> = {}
|
||||
): OffsetPaginationRequest {
|
||||
const config = { ...DEFAULT_PAGINATION, ...defaults };
|
||||
|
||||
let page = input.page ?? config.defaultPage;
|
||||
let limit = input.limit ?? config.defaultLimit;
|
||||
|
||||
page = Math.max(1, Math.floor(page));
|
||||
limit = Math.max(
|
||||
config.minLimit,
|
||||
Math.min(config.maxLimit, Math.floor(limit))
|
||||
);
|
||||
|
||||
const req: OffsetPaginationRequest = {
|
||||
page,
|
||||
limit,
|
||||
sortOrder: input.sortOrder ?? "desc",
|
||||
};
|
||||
if (input.sortBy !== undefined) req.sortBy = input.sortBy;
|
||||
return req;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offset / page conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a 1-based page number and limit to a SQL/array offset.
|
||||
*/
|
||||
export function pageToOffset(page: number, limit: number): number {
|
||||
return Math.max(0, (page - 1) * limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a zero-based offset back to a 1-based page number.
|
||||
*/
|
||||
export function offsetToPage(offset: number, limit: number): number {
|
||||
if (limit <= 0) return 1;
|
||||
return Math.floor(offset / limit) + 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page range for UI controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return an array of page numbers suitable for rendering pagination controls.
|
||||
*
|
||||
* The window is centred on `currentPage` with `windowSize` pages visible.
|
||||
* The returned array never includes pages outside `[1, totalPages]`.
|
||||
*/
|
||||
export function getPageRange(
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
windowSize = 5
|
||||
): number[] {
|
||||
if (totalPages <= 0) return [];
|
||||
|
||||
const half = Math.floor(windowSize / 2);
|
||||
let start = Math.max(1, currentPage - half);
|
||||
let end = start + windowSize - 1;
|
||||
|
||||
if (end > totalPages) {
|
||||
end = totalPages;
|
||||
start = Math.max(1, end - windowSize + 1);
|
||||
}
|
||||
|
||||
const pages: number[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory array pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Paginate an in-memory array using offset-based pagination.
|
||||
*/
|
||||
export function paginateArray<T>(
|
||||
items: T[],
|
||||
page: number,
|
||||
limit: number
|
||||
): OffsetPaginatedResponse<T> {
|
||||
const offset = pageToOffset(page, limit);
|
||||
const data = items.slice(offset, offset + limit);
|
||||
return createOffsetResponse(data, items.length, page, limit);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Estimate the total item count by extrapolating from a sampled subset.
|
||||
*
|
||||
* Useful for datasets too large to count exactly; returns an approximate
|
||||
* total based on the ratio of matched items in the sample.
|
||||
*/
|
||||
export function estimateTotalCount(
|
||||
adapter: OffsetAdapter<unknown>,
|
||||
opts: { sampleSize?: number; filter?: FilterConfig }
|
||||
): Promise<number> {
|
||||
const { sampleSize = 1000, filter } = opts;
|
||||
return adapter
|
||||
.fetchPage(0, sampleSize, undefined, filter)
|
||||
.then((result) => {
|
||||
if (result.total === 0) return 0;
|
||||
return Math.round(
|
||||
(result.items.length / sampleSize) * result.total
|
||||
);
|
||||
});
|
||||
}
|
||||
6
packages/foundation/data/pagination/src/otel/index.ts
Normal file
6
packages/foundation/data/pagination/src/otel/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getPaginationInstruments,
|
||||
type PaginationInstruments,
|
||||
resetPaginationInstruments,
|
||||
setPaginationMeterProvider,
|
||||
} from "./pagination-otel";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getPaginationInstruments,
|
||||
resetPaginationInstruments,
|
||||
setPaginationMeterProvider,
|
||||
} from "./pagination-otel.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetPaginationInstruments();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake meter factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeFakeMeterProvider() {
|
||||
const calls: string[] = [];
|
||||
const fakeMeter = {
|
||||
createCounter: (name: string) => {
|
||||
calls.push(`counter:${name}`);
|
||||
return {
|
||||
add: (_v: number, _attrs?: Record<string, string>) => undefined,
|
||||
};
|
||||
},
|
||||
createHistogram: (name: string) => {
|
||||
calls.push(`histogram:${name}`);
|
||||
return {
|
||||
record: (_v: number, _attrs?: Record<string, string>) =>
|
||||
undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
getMeter: (_name: string) => fakeMeter,
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getPaginationInstruments (no-op path — OTel not installed)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("getPaginationInstruments (no-op)", () => {
|
||||
it("returns instruments with add and record methods", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const instruments = getPaginationInstruments();
|
||||
expect(typeof instruments.requestTotal.add).toBe("function");
|
||||
expect(typeof instruments.pageSize.record).toBe("function");
|
||||
expect(typeof instruments.encodeDecode.record).toBe("function");
|
||||
});
|
||||
|
||||
it("no-op instruments do not throw on add", { timeout: 5000 }, () => {
|
||||
const { requestTotal } = getPaginationInstruments();
|
||||
expect(() =>
|
||||
requestTotal.add(1, { strategy: "cursor", outcome: "success" })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("no-op instruments do not throw on record", { timeout: 5000 }, () => {
|
||||
const { pageSize, encodeDecode } = getPaginationInstruments();
|
||||
expect(() => pageSize.record(10, { strategy: "offset" })).not.toThrow();
|
||||
expect(() =>
|
||||
encodeDecode.record(0.5, { direction: "encode", type: "cursor" })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns same instance on repeated calls", { timeout: 5000 }, () => {
|
||||
const a = getPaginationInstruments();
|
||||
const b = getPaginationInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetPaginationInstruments forces re-initialisation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getPaginationInstruments();
|
||||
resetPaginationInstruments();
|
||||
const b = getPaginationInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setPaginationMeterProvider — success path (injected provider)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("setPaginationMeterProvider (injected provider)", () => {
|
||||
it("builds real instruments from the injected meter provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = makeFakeMeterProvider();
|
||||
setPaginationMeterProvider(provider);
|
||||
const instr = getPaginationInstruments();
|
||||
expect(typeof instr.requestTotal.add).toBe("function");
|
||||
expect(typeof instr.pageSize.record).toBe("function");
|
||||
expect(typeof instr.encodeDecode.record).toBe("function");
|
||||
expect(provider.calls).toContain("counter:pagination.request.total");
|
||||
expect(provider.calls).toContain("histogram:pagination.page.size");
|
||||
expect(provider.calls).toContain(
|
||||
"histogram:pagination.encode_decode.duration_ms"
|
||||
);
|
||||
});
|
||||
|
||||
it("instruments from the injected provider actually call through", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const addCalls: number[] = [];
|
||||
const recordCalls: number[] = [];
|
||||
const provider = {
|
||||
getMeter: (_name: string) => ({
|
||||
createCounter: (_n: string) => ({
|
||||
add: (v: number) => addCalls.push(v),
|
||||
}),
|
||||
createHistogram: (_n: string) => ({
|
||||
record: (v: number) => recordCalls.push(v),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
setPaginationMeterProvider(
|
||||
provider as Parameters<typeof setPaginationMeterProvider>[0]
|
||||
);
|
||||
const instr = getPaginationInstruments();
|
||||
instr.requestTotal.add(1);
|
||||
instr.pageSize.record(5);
|
||||
expect(addCalls).toEqual([1]);
|
||||
expect(recordCalls).toContain(5);
|
||||
});
|
||||
|
||||
it("passing undefined clears any previously-installed provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = makeFakeMeterProvider();
|
||||
setPaginationMeterProvider(provider);
|
||||
setPaginationMeterProvider(undefined);
|
||||
// After clearing, the next get rebuilds via the global metrics API.
|
||||
const instr = getPaginationInstruments();
|
||||
expect(typeof instr.requestTotal.add).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instrument definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PaginationInstruments {
|
||||
/** Count pagination requests by strategy (cursor / offset / keyset) and outcome. */
|
||||
requestTotal: Counter;
|
||||
/** Record actual items returned per page, by strategy. */
|
||||
pageSize: Histogram;
|
||||
/** Record encode/decode durations in ms, by direction and type. */
|
||||
encodeDecode: Histogram;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let instruments: PaginationInstruments | null = null;
|
||||
|
||||
/**
|
||||
* Return the singleton {@link PaginationInstruments} set.
|
||||
*
|
||||
* On first call, acquires a meter from the global OTel `metrics` API.
|
||||
* If no exporter is registered the SDK returns no-op instruments automatically —
|
||||
* callers never need to check for OTel availability.
|
||||
*/
|
||||
export function getPaginationInstruments(): PaginationInstruments {
|
||||
if (instruments !== null) return instruments;
|
||||
instruments = buildInstruments(metrics.getMeter("@suaveplan/pagination"));
|
||||
return instruments;
|
||||
}
|
||||
|
||||
function buildInstruments(meter: Meter): PaginationInstruments {
|
||||
return {
|
||||
requestTotal: meter.createCounter("pagination.request.total", {
|
||||
description: "Total pagination requests by strategy and outcome",
|
||||
}),
|
||||
pageSize: meter.createHistogram("pagination.page.size", {
|
||||
description: "Actual items returned per page",
|
||||
unit: "items",
|
||||
}),
|
||||
encodeDecode: meter.createHistogram(
|
||||
"pagination.encode_decode.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Encode/decode duration by direction (encode/decode) and type",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the OTel meter provider used for pagination instruments.
|
||||
* Pass `undefined` to restore the global default.
|
||||
* Intended for testing; not part of the public API.
|
||||
* @internal
|
||||
*/
|
||||
export function setPaginationMeterProvider(
|
||||
provider: MeterProvider | undefined
|
||||
): void {
|
||||
instruments = null;
|
||||
if (provider !== undefined) {
|
||||
instruments = buildInstruments(
|
||||
provider.getMeter("@suaveplan/pagination")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached instruments — used in tests only.
|
||||
* @internal
|
||||
*/
|
||||
export function resetPaginationInstruments(): void {
|
||||
instruments = null;
|
||||
}
|
||||
14
packages/foundation/data/pagination/src/schemas/index.ts
Normal file
14
packages/foundation/data/pagination/src/schemas/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
CursorPaginatedResponseSchema,
|
||||
CursorPaginationMetaSchema,
|
||||
CursorPaginationRequestSchema,
|
||||
KeysetPageResultSchema,
|
||||
KeysetPaginationOptionsSchema,
|
||||
KeysetSortKeySchema,
|
||||
OffsetPaginatedResponseSchema,
|
||||
OffsetPaginationMetaSchema,
|
||||
OffsetPaginationRequestSchema,
|
||||
PageTokenPayloadSchema,
|
||||
PaginationDefaultsSchema,
|
||||
SortConfigSchema,
|
||||
} from "./pagination.schemas";
|
||||
@@ -0,0 +1,126 @@
|
||||
// schema: boundary-only — every export in this file is consumer-facing.
|
||||
// Internal call sites pre-validate via `normalizeOffsetRequest`,
|
||||
// `normalizeCursorRequest`, etc.; the Zod schemas below are the public
|
||||
// boundary surface that consumers parse against. The conformance script
|
||||
// honours this marker and suppresses the schemas-at-boundaries warning.
|
||||
import z from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SortDirectionSchema = z.enum(["asc", "desc"]);
|
||||
const CursorDirectionSchema = z.enum(["forward", "backward"]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offset pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OffsetPaginationRequestSchema = z.object({
|
||||
page: z.number().int().min(1),
|
||||
limit: z.number().int().min(1).max(1000),
|
||||
sortBy: z.string().optional(),
|
||||
sortOrder: SortDirectionSchema.optional(),
|
||||
});
|
||||
|
||||
export const OffsetPaginationMetaSchema = z.object({
|
||||
page: z.number().int().min(1),
|
||||
limit: z.number().int().min(1),
|
||||
total: z.number().int().min(0),
|
||||
totalPages: z.number().int().min(0),
|
||||
hasNext: z.boolean(),
|
||||
hasPrevious: z.boolean(),
|
||||
});
|
||||
|
||||
export const OffsetPaginatedResponseSchema = <T extends z.ZodTypeAny>(
|
||||
itemSchema: T
|
||||
) =>
|
||||
z.object({
|
||||
data: z.array(itemSchema),
|
||||
meta: OffsetPaginationMetaSchema,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CursorPaginationRequestSchema = z.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.number().int().min(1).max(1000),
|
||||
direction: CursorDirectionSchema.optional(),
|
||||
});
|
||||
|
||||
export const CursorPaginationMetaSchema = z.object({
|
||||
startCursor: z.string().nullable(),
|
||||
endCursor: z.string().nullable(),
|
||||
hasNextPage: z.boolean(),
|
||||
hasPreviousPage: z.boolean(),
|
||||
totalCount: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export const CursorPaginatedResponseSchema = <T extends z.ZodTypeAny>(
|
||||
itemSchema: T
|
||||
) =>
|
||||
z.object({
|
||||
data: z.array(itemSchema),
|
||||
meta: CursorPaginationMetaSchema,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyset pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KeysetSortKeySchema = z.object({
|
||||
field: z.string(),
|
||||
direction: SortDirectionSchema,
|
||||
value: z.union([z.string(), z.number()]),
|
||||
});
|
||||
|
||||
export const KeysetPaginationOptionsSchema = z.object({
|
||||
limit: z.number().int().min(1).max(1000),
|
||||
sortKeys: z.array(
|
||||
z.object({
|
||||
field: z.string(),
|
||||
direction: SortDirectionSchema,
|
||||
})
|
||||
),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const KeysetPageResultSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
|
||||
z.object({
|
||||
items: z.array(itemSchema),
|
||||
hasMore: z.boolean(),
|
||||
lastKeys: z.array(KeysetSortKeySchema).nullable(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SortConfigSchema = z.object({
|
||||
field: z.string(),
|
||||
direction: SortDirectionSchema,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PageTokenPayloadSchema = z.object({
|
||||
v: z.union([z.string(), z.number()]),
|
||||
s: z.string().optional(),
|
||||
d: SortDirectionSchema.optional(),
|
||||
f: z.string().optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pagination defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PaginationDefaultsSchema = z.object({
|
||||
defaultLimit: z.number().int().min(1),
|
||||
maxLimit: z.number().int().min(1),
|
||||
minLimit: z.number().int().min(1),
|
||||
defaultPage: z.number().int().min(1),
|
||||
});
|
||||
10
packages/foundation/data/pagination/src/tokens/index.ts
Normal file
10
packages/foundation/data/pagination/src/tokens/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
createCursorToken,
|
||||
createOffsetToken,
|
||||
decodePageToken,
|
||||
encodePageToken,
|
||||
extractCursorValue,
|
||||
extractSortInfo,
|
||||
hashFilters,
|
||||
validateTokenFilter,
|
||||
} from "./page-tokens";
|
||||
@@ -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,249 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createCursorToken,
|
||||
createOffsetToken,
|
||||
decodePageToken,
|
||||
encodePageToken,
|
||||
extractCursorValue,
|
||||
extractSortInfo,
|
||||
hashFilters,
|
||||
validateTokenFilter,
|
||||
} from "./page-tokens.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// encodePageToken / decodePageToken
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("encodePageToken / decodePageToken", () => {
|
||||
it("round-trips a string value token", { timeout: 5000 }, () => {
|
||||
const payload = { v: "cursor_123", s: "createdAt", d: "desc" as const };
|
||||
const token = encodePageToken(payload);
|
||||
expect(decodePageToken(token)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("round-trips a number value token", { timeout: 5000 }, () => {
|
||||
const payload = { v: 42 };
|
||||
const token = encodePageToken(payload);
|
||||
expect(decodePageToken(token)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("round-trips with filter hash", { timeout: 5000 }, () => {
|
||||
const payload = { v: "abc", f: "1q2w3e" };
|
||||
expect(decodePageToken(encodePageToken(payload))).toEqual(payload);
|
||||
});
|
||||
|
||||
it("produces URL-safe characters", { timeout: 5000 }, () => {
|
||||
const token = encodePageToken({ v: "test/value+special" });
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", { timeout: 5000 }, () => {
|
||||
expect(decodePageToken("!!!not-valid!!!")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing v field", { timeout: 5000 }, () => {
|
||||
const json = JSON.stringify({ s: "name" });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const token = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodePageToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for wrong v type", { timeout: 5000 }, () => {
|
||||
const json = JSON.stringify({ v: [1, 2] });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const token = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodePageToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for wrong s type", { timeout: 5000 }, () => {
|
||||
const json = JSON.stringify({ v: "ok", s: 123 });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const token = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodePageToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for wrong d value", { timeout: 5000 }, () => {
|
||||
const json = JSON.stringify({ v: "ok", d: "sideways" });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const token = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodePageToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for wrong f type", { timeout: 5000 }, () => {
|
||||
const json = JSON.stringify({ v: "ok", f: 42 });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const token = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodePageToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object JSON (array)", { timeout: 5000 }, () => {
|
||||
const json = JSON.stringify([1, 2, 3]);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const token = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodePageToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when JSON is null literal", { timeout: 5000 }, () => {
|
||||
// JSON.stringify(null) === "null" — covers the `value === null` branch
|
||||
const json = "null";
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
const token = btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(decodePageToken(token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCursorToken / createOffsetToken
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("createCursorToken", () => {
|
||||
it("creates token with value only", { timeout: 5000 }, () => {
|
||||
const token = createCursorToken("id_42");
|
||||
const payload = decodePageToken(token);
|
||||
expect(payload?.v).toBe("id_42");
|
||||
expect(payload?.s).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates token with sort info", { timeout: 5000 }, () => {
|
||||
const token = createCursorToken("id_42", "name", "asc");
|
||||
const payload = decodePageToken(token);
|
||||
expect(payload?.s).toBe("name");
|
||||
expect(payload?.d).toBe("asc");
|
||||
});
|
||||
|
||||
it("creates token with filter hash", { timeout: 5000 }, () => {
|
||||
const token = createCursorToken("val", undefined, undefined, "abc123");
|
||||
const payload = decodePageToken(token);
|
||||
expect(payload?.f).toBe("abc123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOffsetToken", () => {
|
||||
it("creates offset token from number", { timeout: 5000 }, () => {
|
||||
const token = createOffsetToken(100, "id", "asc");
|
||||
const payload = decodePageToken(token);
|
||||
expect(payload?.v).toBe(100);
|
||||
expect(payload?.s).toBe("id");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractCursorValue / extractSortInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("extractCursorValue", () => {
|
||||
it("returns value from valid token", { timeout: 5000 }, () => {
|
||||
const token = createCursorToken("hello");
|
||||
expect(extractCursorValue(token)).toBe("hello");
|
||||
});
|
||||
|
||||
it("returns null for invalid token", { timeout: 5000 }, () => {
|
||||
expect(extractCursorValue("garbage")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSortInfo", () => {
|
||||
it("returns sort info from valid token", { timeout: 5000 }, () => {
|
||||
const token = createCursorToken("x", "score", "desc");
|
||||
const info = extractSortInfo(token);
|
||||
expect(info?.sortBy).toBe("score");
|
||||
expect(info?.sortDirection).toBe("desc");
|
||||
});
|
||||
|
||||
it("returns undefined fields when not present", { timeout: 5000 }, () => {
|
||||
const token = createCursorToken("x");
|
||||
const info = extractSortInfo(token);
|
||||
expect(info?.sortBy).toBeUndefined();
|
||||
expect(info?.sortDirection).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null for invalid token", { timeout: 5000 }, () => {
|
||||
expect(extractSortInfo("bad")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hashFilters
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("hashFilters", () => {
|
||||
it("produces stable hash for same input", { timeout: 5000 }, () => {
|
||||
const h1 = hashFilters({ status: "active", role: "admin" });
|
||||
const h2 = hashFilters({ role: "admin", status: "active" });
|
||||
expect(h1).toBe(h2);
|
||||
});
|
||||
|
||||
it("different inputs → different hash", { timeout: 5000 }, () => {
|
||||
const h1 = hashFilters({ status: "active" });
|
||||
const h2 = hashFilters({ status: "inactive" });
|
||||
expect(h1).not.toBe(h2);
|
||||
});
|
||||
|
||||
it("empty filters produces string", { timeout: 5000 }, () => {
|
||||
const h = hashFilters({});
|
||||
expect(typeof h).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateTokenFilter
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("validateTokenFilter", () => {
|
||||
it("returns true when hash matches", { timeout: 5000 }, () => {
|
||||
const hash = hashFilters({ status: "active" });
|
||||
const token = createCursorToken("v", undefined, undefined, hash);
|
||||
expect(validateTokenFilter(token, hash)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when hash does not match", { timeout: 5000 }, () => {
|
||||
const token = createCursorToken("v", undefined, undefined, "hash1");
|
||||
expect(validateTokenFilter(token, "hash2")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when token has no filter hash", { timeout: 5000 }, () => {
|
||||
const token = createCursorToken("v");
|
||||
expect(validateTokenFilter(token, "any")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid token", { timeout: 5000 }, () => {
|
||||
expect(validateTokenFilter("garbage", "hash")).toBe(false);
|
||||
});
|
||||
});
|
||||
176
packages/foundation/data/pagination/src/tokens/page-tokens.ts
Normal file
176
packages/foundation/data/pagination/src/tokens/page-tokens.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
PageTokenPayload,
|
||||
SortDirection,
|
||||
} from "../types/pagination.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encode / decode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encode a `PageTokenPayload` into an opaque URL-safe base64url string.
|
||||
*
|
||||
* The token is suitable for use as a query parameter or cursor without
|
||||
* additional URL-encoding.
|
||||
*/
|
||||
export function encodePageToken(payload: PageTokenPayload): string {
|
||||
const json = JSON.stringify(payload);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an opaque page token string back to its `PageTokenPayload`.
|
||||
*
|
||||
* Returns `null` for any malformed or tampered input rather than throwing,
|
||||
* so callers can decide whether to restart pagination or surface an error.
|
||||
*/
|
||||
export function decodePageToken(token: string): PageTokenPayload | null {
|
||||
try {
|
||||
let base64 = token.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = base64.length % 4;
|
||||
if (padding === 2) base64 += "==";
|
||||
else if (padding === 3) base64 += "=";
|
||||
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
|
||||
if (!isValidPayload(parsed)) return null;
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a cursor-style page token from a domain value and optional sort info.
|
||||
*
|
||||
* The value may be any string (e.g., a record ID) or number (e.g., a score).
|
||||
*/
|
||||
export function createCursorToken(
|
||||
value: string | number,
|
||||
sortBy?: string,
|
||||
sortDirection?: SortDirection,
|
||||
filterHash?: string
|
||||
): string {
|
||||
const payload: PageTokenPayload = { v: value };
|
||||
if (sortBy !== undefined) payload.s = sortBy;
|
||||
if (sortDirection !== undefined) payload.d = sortDirection;
|
||||
if (filterHash !== undefined) payload.f = filterHash;
|
||||
return encodePageToken(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an offset-style page token from a zero-based offset value.
|
||||
*/
|
||||
export function createOffsetToken(
|
||||
offset: number,
|
||||
sortBy?: string,
|
||||
sortDirection?: SortDirection,
|
||||
filterHash?: string
|
||||
): string {
|
||||
return createCursorToken(offset, sortBy, sortDirection, filterHash);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract the cursor/offset value from an encoded page token.
|
||||
* Returns `null` if the token is invalid.
|
||||
*/
|
||||
export function extractCursorValue(token: string): string | number | null {
|
||||
const payload = decodePageToken(token);
|
||||
return payload?.v ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sort field and direction from an encoded page token.
|
||||
* Returns `null` if the token is invalid.
|
||||
*/
|
||||
export function extractSortInfo(
|
||||
token: string
|
||||
): { sortBy?: string; sortDirection?: SortDirection } | null {
|
||||
const payload = decodePageToken(token);
|
||||
if (!payload) return null;
|
||||
const info: { sortBy?: string; sortDirection?: SortDirection } = {};
|
||||
if (payload.s !== undefined) info.sortBy = payload.s;
|
||||
if (payload.d !== undefined) info.sortDirection = payload.d;
|
||||
return info;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter hash and validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Produce a stable hash string from an arbitrary filter/query map.
|
||||
*
|
||||
* The hash is used to detect filter changes between paginated requests;
|
||||
* when the hash differs, clients should restart from the first page.
|
||||
* The algorithm is a simple djb2-style integer hash — collision-resistant
|
||||
* enough for cache invalidation, not for cryptographic purposes.
|
||||
*/
|
||||
export function hashFilters(filters: Record<string, unknown>): string {
|
||||
const sorted = Object.keys(filters)
|
||||
.sort()
|
||||
.map((k) => `${k}=${String(filters[k])}`)
|
||||
.join("&");
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const char = sorted.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash + char) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that an encoded page token's filter hash matches an expected value.
|
||||
*
|
||||
* Returns `true` when the token has no filter hash (unconditional trust) or
|
||||
* when the hash matches. Returns `false` for invalid tokens or hash mismatch.
|
||||
*/
|
||||
export function validateTokenFilter(
|
||||
token: string,
|
||||
expectedFilterHash: string
|
||||
): boolean {
|
||||
const payload = decodePageToken(token);
|
||||
if (!payload) return false;
|
||||
if (payload.f === undefined) return true;
|
||||
return payload.f === expectedFilterHash;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isValidPayload(value: unknown): value is PageTokenPayload {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj["v"] !== "string" && typeof obj["v"] !== "number")
|
||||
return false;
|
||||
if (obj["s"] !== undefined && typeof obj["s"] !== "string") return false;
|
||||
if (obj["d"] !== undefined && obj["d"] !== "asc" && obj["d"] !== "desc")
|
||||
return false;
|
||||
if (obj["f"] !== undefined && typeof obj["f"] !== "string") return false;
|
||||
return true;
|
||||
}
|
||||
23
packages/foundation/data/pagination/src/types/index.ts
Normal file
23
packages/foundation/data/pagination/src/types/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type {
|
||||
Connection,
|
||||
CursorDirection,
|
||||
CursorPageResult,
|
||||
CursorPaginatedResponse,
|
||||
CursorPaginationMeta,
|
||||
CursorPaginationOptions,
|
||||
CursorPaginationRequest,
|
||||
Edge,
|
||||
FilterConfig,
|
||||
KeysetPageResult,
|
||||
KeysetPaginationOptions,
|
||||
KeysetSortKey,
|
||||
OffsetPaginatedResponse,
|
||||
OffsetPaginationMeta,
|
||||
OffsetPaginationRequest,
|
||||
PageResult,
|
||||
PageTokenPayload,
|
||||
PaginatedIteratorResult,
|
||||
PaginationDefaults,
|
||||
SortConfig,
|
||||
SortDirection,
|
||||
} from "./pagination.types";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Sort direction for paginated queries.
|
||||
*/
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
/**
|
||||
* Direction of cursor traversal.
|
||||
*/
|
||||
export type CursorDirection = "forward" | "backward";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offset pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Incoming request parameters for offset-based pagination.
|
||||
*/
|
||||
export interface OffsetPaginationRequest {
|
||||
page: number;
|
||||
limit: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: SortDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed metadata for an offset-based paginated response.
|
||||
*/
|
||||
export interface OffsetPaginationMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full offset-based paginated response envelope.
|
||||
*/
|
||||
export interface OffsetPaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: OffsetPaginationMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Incoming request parameters for cursor-based pagination.
|
||||
*/
|
||||
export interface CursorPaginationRequest {
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
direction?: CursorDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed metadata for a cursor-based paginated response.
|
||||
*/
|
||||
export interface CursorPaginationMeta {
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full cursor-based paginated response envelope.
|
||||
*/
|
||||
export interface CursorPaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: CursorPaginationMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single edge in a Relay-style cursor connection.
|
||||
*/
|
||||
export interface Edge<T> {
|
||||
node: T;
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay-style connection wrapping edges and page info.
|
||||
*/
|
||||
export interface Connection<T> {
|
||||
edges: Edge<T>[];
|
||||
pageInfo: CursorPaginationMeta;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyset pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single sort key entry for keyset (seek) pagination.
|
||||
*/
|
||||
export interface KeysetSortKey {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for keyset pagination operations.
|
||||
*/
|
||||
export interface KeysetPaginationOptions {
|
||||
limit: number;
|
||||
sortKeys: Array<{ field: string; direction: SortDirection }>;
|
||||
filter?: FilterConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from a keyset-paginated fetch.
|
||||
*/
|
||||
export interface KeysetPageResult<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
lastKeys: KeysetSortKey[] | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter and query types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sort configuration for adapter queries.
|
||||
*/
|
||||
export interface SortConfig {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter configuration for adapter queries — arbitrary key/value pairs.
|
||||
*/
|
||||
export type FilterConfig = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Generic page result from a data source fetch.
|
||||
*/
|
||||
export interface PageResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for cursor-based pagination using an adapter.
|
||||
*/
|
||||
export interface CursorPaginationOptions {
|
||||
limit: number;
|
||||
sortBy?: string;
|
||||
sortDirection?: SortDirection;
|
||||
filter?: FilterConfig;
|
||||
clock?: Clock;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a cursor-paginated adapter fetch.
|
||||
*/
|
||||
export interface CursorPageResult<T> {
|
||||
items: T[];
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Payload encoded inside an opaque page token.
|
||||
*/
|
||||
export interface PageTokenPayload {
|
||||
/** Cursor value or offset */
|
||||
v: string | number;
|
||||
/** Sort field */
|
||||
s?: string;
|
||||
/** Sort direction */
|
||||
d?: SortDirection;
|
||||
/** Filter hash for cache-invalidation */
|
||||
f?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults and iterators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pagination limit and page defaults.
|
||||
*/
|
||||
export interface PaginationDefaults {
|
||||
defaultLimit: number;
|
||||
maxLimit: number;
|
||||
minLimit: number;
|
||||
defaultPage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value emitted by paginated async iterators.
|
||||
*/
|
||||
export interface PaginatedIteratorResult<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
nextCursor?: string;
|
||||
nextPage?: number;
|
||||
}
|
||||
48
packages/foundation/data/pagination/tests/dep-graph.test.ts
Normal file
48
packages/foundation/data/pagination/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Verify that the runtime dependencies declared in package.json match those
|
||||
* actually imported in the source tree — no undeclared deps, no phantom deps.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = join(import.meta.dirname, "..");
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
|
||||
) as {
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("has @suaveplan/error as a runtime dep", { timeout: 5000 }, () => {
|
||||
expect(pkg.dependencies["@suaveplan/error"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has @suaveplan/types as a runtime dep", { timeout: 5000 }, () => {
|
||||
expect(pkg.dependencies["@suaveplan/types"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has zod as a runtime dep", { timeout: 5000 }, () => {
|
||||
expect(pkg.dependencies["zod"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has tslib as a runtime dep", { timeout: 5000 }, () => {
|
||||
expect(pkg.dependencies["tslib"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has vitest as a devDep only", { timeout: 5000 }, () => {
|
||||
expect(pkg.devDependencies["vitest"]).toBeDefined();
|
||||
expect(pkg.dependencies["vitest"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has fast-check as a devDep only", { timeout: 5000 }, () => {
|
||||
expect(pkg.devDependencies["fast-check"]).toBeDefined();
|
||||
expect(pkg.dependencies["fast-check"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has mitata as a devDep only", { timeout: 5000 }, () => {
|
||||
expect(pkg.devDependencies["mitata"]).toBeDefined();
|
||||
expect(pkg.dependencies["mitata"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Enforce that production source files do not use disallowed globals
|
||||
* (Math.random, Date.now, crypto.randomUUID) that would make the library
|
||||
* non-deterministic or platform-dependent.
|
||||
*
|
||||
* The approved-files set is empty because pagination is pure computation
|
||||
* with no randomness or timestamp requirements.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_ROOT = join(import.meta.dirname, "../src");
|
||||
|
||||
const DISALLOWED = [
|
||||
/\bMath\.random\b/,
|
||||
/\bDate\.now\b/,
|
||||
/\bcrypto\.randomUUID\b/,
|
||||
];
|
||||
|
||||
// No files are in the approved set — pagination has no randomness.
|
||||
const APPROVED_FILES: string[] = [];
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
const result: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
result.push(...walkTs(full));
|
||||
} else if (entry.endsWith(".ts")) {
|
||||
result.push(full);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSourceFiles(): string[] {
|
||||
return walkTs(SRC_ROOT).filter(
|
||||
(f) => !f.includes(".test.") && !f.includes(".bench.")
|
||||
);
|
||||
}
|
||||
|
||||
describe("no-disallowed globals", () => {
|
||||
const sourceFiles = getSourceFiles();
|
||||
|
||||
it("found at least one source file to check", { timeout: 5000 }, () => {
|
||||
expect(sourceFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const pattern of DISALLOWED) {
|
||||
it(`no source file uses ${pattern.source}`, { timeout: 5000 }, () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of sourceFiles) {
|
||||
const relative = file.replace(`${SRC_ROOT}/`, "");
|
||||
if (APPROVED_FILES.some((ap) => file.endsWith(ap))) continue;
|
||||
|
||||
const content = readFileSync(file, "utf-8");
|
||||
if (pattern.test(content)) {
|
||||
violations.push(relative);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
149
packages/foundation/data/pagination/tests/property.test.ts
Normal file
149
packages/foundation/data/pagination/tests/property.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Property-based tests using fast-check.
|
||||
*
|
||||
* Covers:
|
||||
* - decodeCursor(encodeCursor(s)) === s for all non-empty strings and numbers
|
||||
* - decodeKeysetCursor(encodeKeysetCursor(keys)) deep-equals keys
|
||||
* - decodePageToken(encodePageToken(payload)) deep-equals payload
|
||||
* - paginateArray always returns ≤ limit items
|
||||
* - offsetToPage(pageToOffset(p, l), l) === p for all positive p and l
|
||||
*/
|
||||
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { decodeCursor, encodeCursor } from "../src/cursor/cursor-pagination.js";
|
||||
import {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
} from "../src/keyset/keyset-pagination.js";
|
||||
import {
|
||||
offsetToPage,
|
||||
pageToOffset,
|
||||
paginateArray,
|
||||
} from "../src/offset/offset.js";
|
||||
import { decodePageToken, encodePageToken } from "../src/tokens/page-tokens.js";
|
||||
import type {
|
||||
KeysetSortKey,
|
||||
PageTokenPayload,
|
||||
} from "../src/types/pagination.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor encode/decode round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: cursor encode/decode round-trip", () => {
|
||||
it("round-trips any non-empty string", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ minLength: 1 }), (s) => {
|
||||
return decodeCursor(encodeCursor(s)) === s;
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips any safe integer", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer(), (n) => {
|
||||
return decodeCursor(encodeCursor(n)) === n;
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyset cursor round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: keyset cursor encode/decode round-trip", () => {
|
||||
const keysetArb: fc.Arbitrary<KeysetSortKey[]> = fc.array(
|
||||
fc.record({
|
||||
field: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
direction: fc.constantFrom("asc" as const, "desc" as const),
|
||||
value: fc.oneof(
|
||||
fc.string({ minLength: 0, maxLength: 50 }),
|
||||
fc.integer()
|
||||
),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 5 }
|
||||
);
|
||||
|
||||
it("round-trips arbitrary keyset keys", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(keysetArb, (keys) => {
|
||||
const decoded = decodeKeysetCursor(encodeKeysetCursor(keys));
|
||||
return JSON.stringify(decoded) === JSON.stringify(keys);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page token round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: page token encode/decode round-trip", () => {
|
||||
const payloadArb: fc.Arbitrary<PageTokenPayload> = fc.record(
|
||||
{
|
||||
v: fc.oneof(fc.string({ minLength: 1 }), fc.integer()),
|
||||
s: fc.option(fc.string({ minLength: 1 }), { nil: undefined }),
|
||||
d: fc.option(fc.constantFrom("asc" as const, "desc" as const), {
|
||||
nil: undefined,
|
||||
}),
|
||||
f: fc.option(fc.string({ minLength: 1 }), { nil: undefined }),
|
||||
},
|
||||
{ withDeletedKeys: false }
|
||||
);
|
||||
|
||||
it("round-trips arbitrary page token payloads", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(payloadArb, (payload) => {
|
||||
const filtered: PageTokenPayload = { v: payload.v };
|
||||
if (payload.s !== undefined) filtered.s = payload.s;
|
||||
if (payload.d !== undefined) filtered.d = payload.d;
|
||||
if (payload.f !== undefined) filtered.f = payload.f;
|
||||
const decoded = decodePageToken(encodePageToken(filtered));
|
||||
return JSON.stringify(decoded) === JSON.stringify(filtered);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// paginateArray: always returns ≤ limit items
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: paginateArray respects limit", () => {
|
||||
it("always returns ≤ limit items", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer(), { minLength: 0, maxLength: 100 }),
|
||||
fc.integer({ min: 1, max: 20 }),
|
||||
fc.integer({ min: 1, max: 10 }),
|
||||
(items, page, limit) => {
|
||||
const res = paginateArray(items, page, limit);
|
||||
return res.data.length <= limit;
|
||||
}
|
||||
),
|
||||
{ numRuns: 300 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// offsetToPage / pageToOffset round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("property: offsetToPage(pageToOffset(p, l), l) === p", () => {
|
||||
it("round-trips for positive page and limit", { timeout: 10000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 10000 }),
|
||||
fc.integer({ min: 1, max: 1000 }),
|
||||
(page, limit) => {
|
||||
return (
|
||||
offsetToPage(pageToOffset(page, limit), limit) === page
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 300 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
63
packages/foundation/data/pagination/tests/tree-shake.test.ts
Normal file
63
packages/foundation/data/pagination/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Verify tree-shaking readiness:
|
||||
* - sideEffects: false is set in package.json
|
||||
* - At least 5 named subpath exports are declared
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = join(import.meta.dirname, "..");
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
|
||||
) as {
|
||||
sideEffects: boolean | string[];
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("has at least 5 subpath exports (excluding package.json)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const subpaths = Object.keys(pkg.exports).filter(
|
||||
(k) => k !== "./package.json"
|
||||
);
|
||||
expect(subpaths.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("root export '.' is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["."]).toBeDefined();
|
||||
});
|
||||
|
||||
it("cursor subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./cursor"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("offset subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./offset"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("keyset subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./keyset"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("adapters subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./adapters"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("helpers subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./helpers"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("errors subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./errors"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("schemas subpath export is declared", { timeout: 5000 }, () => {
|
||||
expect(pkg.exports["./schemas"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
12
packages/foundation/data/pagination/tsconfig.lib.json
Normal file
12
packages/foundation/data/pagination/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
20
packages/foundation/data/pagination/vite.config.ts
Normal file
20
packages/foundation/data/pagination/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/pagination",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: ["@opentelemetry/api", "@suaveplan/error", "@suaveplan/types", "tslib", "zod", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/foundation/data/pagination/vitest.config.ts
Normal file
28
packages/foundation/data/pagination/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "benchmarks/**", "e2e/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user