Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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 |

View 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"
}
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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 };
}

View File

@@ -0,0 +1,6 @@
export {
createMemoryAdapter,
createSortConfig,
MemoryPaginationAdapter,
type PaginationAdapter,
} from "./database-adapter";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}

View 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.

View 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" }]);
});
});

View 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);
}

View File

@@ -0,0 +1,16 @@
export {
calculateCursorMeta,
createConnection,
createCursorResponse,
createEdge,
createEdges,
cursorPaginateArray,
normalizeCursorRequest,
} from "./cursor";
export {
decodeCursor,
encodeCursor,
fetchAfter,
fetchBefore,
validateCursor,
} from "./cursor-pagination";

View 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.

View 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];

View File

@@ -0,0 +1,2 @@
export { PAGINATION_CODES, type PaginationCode } from "./codes";
export { PaginationError } from "./pagination-error";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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);
}
}

View 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.

View 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);
});
});

View 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 };
}

View File

@@ -0,0 +1,9 @@
export {
collectAll,
collectUpTo,
createCursorIterator,
createOffsetIterator,
isPaginatedResponseEmpty,
mapPaginated,
mergeOffsetResponses,
} from "./helpers";

View 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";

View File

@@ -0,0 +1,7 @@
export {
decodeKeysetCursor,
encodeKeysetCursor,
extractSortKeys,
fetchKeysetPage,
keysetPaginate,
} from "./keyset-pagination";

View File

@@ -0,0 +1,103 @@
# Keyset Pagination
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The keyset pagination module implements seek-based (keyset) pagination, which avoids the performance degradation of deep offset pagination by using the sort-key values of the last seen item as the cursor position. Unlike numeric offsets — which require the database to scan and discard all preceding rows — a keyset cursor lets the storage engine jump directly to the next page using an indexed seek.
This module provides both adapter-backed (`fetchKeysetPage`) and in-memory (`keysetPaginate`) variants, plus encoding/decoding utilities for opaque keyset cursor strings.
## Features
- `encodeKeysetCursor` — serialize `KeysetSortKey[]` to a URL-safe base64url string.
- `decodeKeysetCursor` — deserialize and validate a keyset cursor; returns `null` on malformed input.
- `extractSortKeys` — pull sort-key values from a typed item based on sort config.
- `fetchKeysetPage` — fetch a page from an adapter starting after a keyset position.
- `keysetPaginate` — paginate an in-memory array using keyset logic.
## Usage: Basic Example
```typescript
import { keysetPaginate, encodeKeysetCursor, decodeKeysetCursor } from "@suaveplan/pagination/keyset";
const items = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Carol" },
{ id: 4, name: "Dave" },
];
const options = { limit: 2, sortKeys: [{ field: "id", direction: "asc" as const }] };
// First page
const page1 = keysetPaginate(items, null, options);
// page1.items → [{ id: 1 }, { id: 2 }]
// page1.hasMore → true
// Encode position for the next request
const cursor = encodeKeysetCursor(page1.lastKeys!);
// Second page — pass decoded keys
const page2 = keysetPaginate(items, decodeKeysetCursor(cursor), options);
// page2.items → [{ id: 3 }, { id: 4 }]
// page2.hasMore → false
```
## Usage: Advanced Example
```typescript
import { fetchKeysetPage } from "@suaveplan/pagination/keyset";
import { createMemoryAdapter } from "@suaveplan/pagination/adapters";
const adapter = createMemoryAdapter(
Array.from({ length: 100 }, (_, i) => ({ id: i + 1, score: Math.floor(i / 10) }))
);
// Composite sort: score desc, then id asc
const options = {
limit: 5,
sortKeys: [
{ field: "score", direction: "desc" as const },
{ field: "id", direction: "asc" as const },
],
};
let lastKeys = null;
do {
const page = await fetchKeysetPage(adapter, lastKeys, options);
console.log(page.items);
lastKeys = page.lastKeys;
} while (lastKeys !== null);
```
## API Reference
### `encodeKeysetCursor(keys: KeysetSortKey[]): string`
Serialises the keys array to JSON, encodes as base64url. Throws `PaginationError(PAGINATION.INVALID_KEYSET_CURSOR)` only if `JSON.stringify` fails (e.g., circular references).
### `decodeKeysetCursor(cursor: string): KeysetSortKey[] | null`
Decodes a base64url keyset cursor. Validates each element has `field: string`, `direction: "asc"|"desc"`, and `value: string|number`. Returns `null` on any invalid input.
### `extractSortKeys<T>(item, sortKeys): KeysetSortKey[]`
| Parameter | Type |
|-----------|---------------------------------------------------|
| `item` | `T` |
| `sortKeys` | `Array<{ field: string; direction: SortDirection }>` |
Returns `KeysetSortKey[]` with runtime values extracted from the item.
### `fetchKeysetPage<T>(adapter, lastKeys, options): Promise<KeysetPageResult<T>>`
Loads all items from the adapter, filters those after `lastKeys`, returns at most `options.limit`. Returns `{ items: [], hasMore: false, lastKeys: null }` when `sortKeys` is empty.
### `keysetPaginate<T>(items, lastKeys, options): KeysetPageResult<T>`
Sorts `items` by the primary sort key, filters items after `lastKeys`, returns at most `options.limit`. Equivalent to `fetchKeysetPage` but operates on a plain array without async IO.
## Implementation Notes
The comparison logic for `isAfterKeyset` short-circuits on the first discriminating key in a composite sort — exactly matching how a B-tree index seek works. String comparison uses `localeCompare` for locale-aware ordering; numeric comparison uses `<` to avoid IEEE-754 edge cases with `===`. The in-memory `keysetPaginate` sorts a shallow copy of the input array to avoid mutating the caller's data.

View File

@@ -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();
});
});

View File

@@ -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 };
}

View File

@@ -0,0 +1,10 @@
export {
calculateOffsetMeta,
createOffsetResponse,
estimateTotalCount,
getPageRange,
normalizeOffsetRequest,
offsetToPage,
pageToOffset,
paginateArray,
} from "./offset";

View 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.

View 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);
});
});

View 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
);
});
}

View File

@@ -0,0 +1,6 @@
export {
getPaginationInstruments,
type PaginationInstruments,
resetPaginationInstruments,
setPaginationMeterProvider,
} from "./pagination-otel";

View File

@@ -0,0 +1,75 @@
# Pagination OTel Instrumentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The OTel module provides optional OpenTelemetry instrumentation for pagination operations. It exposes three instruments — a counter for request totals, a histogram for page sizes, and a histogram for encode/decode durations — using a lazy-initialisation pattern that gracefully degrades to silent no-op stubs when `@opentelemetry/api` is absent from the runtime. This makes instrumentation completely safe in environments without an OTel exporter and avoids adding a hard peer dependency on the OTel API.
## Features
- `getPaginationInstruments()` — returns the singleton `PaginationInstruments` set, initialising from OTel API on first call.
- `resetPaginationInstruments()` — resets the cached singleton for test isolation.
- `PaginationInstruments` type — exposes `requestTotal` (counter), `pageSize` (histogram), and `encodeDecode` (histogram).
- Graceful no-op fallback — all instruments are safe to call without an exporter installed.
- Named instruments follow the OpenTelemetry semantic-conventions pattern: `pagination.<scope>.<metric>`.
## Usage: Basic Example
```typescript
import { getPaginationInstruments } from "@suaveplan/pagination/otel";
const { requestTotal, pageSize } = getPaginationInstruments();
// Record a successful cursor request
requestTotal.add(1, { strategy: "cursor", outcome: "success" });
// Record that a page returned 10 items
pageSize.record(10, { strategy: "cursor" });
```
## Usage: Advanced Example
```typescript
import { getPaginationInstruments } from "@suaveplan/pagination/otel";
async function trackedFetch(cursor: string | undefined) {
const { requestTotal, pageSize, encodeDecode } = getPaginationInstruments();
const t0 = performance.now();
try {
const result = await fetchPage(cursor);
requestTotal.add(1, { strategy: "cursor", outcome: "success" });
pageSize.record(result.items.length, { strategy: "cursor" });
encodeDecode.record(performance.now() - t0, { direction: "decode", type: "cursor" });
return result;
} catch (err) {
requestTotal.add(1, { strategy: "cursor", outcome: "error" });
throw err;
}
}
```
## API Reference
### `getPaginationInstruments(): PaginationInstruments`
Returns the singleton instrument set. On the first call, attempts to acquire a meter from `@opentelemetry/api` via dynamic `require`. If OTel is absent or has no `metrics` API, all instruments are replaced with silent stubs. The result is cached for subsequent calls.
### `resetPaginationInstruments(): void`
Resets the cached instruments to `null`, forcing re-initialisation on the next `getPaginationInstruments()` call. Intended for test isolation only — do not call in production.
### `PaginationInstruments`
| Field | Type | OTel instrument name |
|---------------|------------------|-----------------------------------------------|
| `requestTotal` | `OtelCounter` | `pagination.request.total` |
| `pageSize` | `OtelHistogram` | `pagination.page.size` |
| `encodeDecode` | `OtelHistogram` | `pagination.encode_decode.duration_ms` |
Instruments support an optional `attributes` record (e.g., `{ strategy: "cursor", outcome: "success" }`).
## Implementation Notes
Dynamic `require` is used (instead of a static import) so the module compiles without `@opentelemetry/api` in `package.json`. The try/catch wraps the entire OTel setup so any initialisation error (missing package, no `metrics` key) silently activates the no-op path. Callers do not need to check whether OTel is available — all four instrument methods (`add`, `record`) are always safe to call.

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,14 @@
export {
CursorPaginatedResponseSchema,
CursorPaginationMetaSchema,
CursorPaginationRequestSchema,
KeysetPageResultSchema,
KeysetPaginationOptionsSchema,
KeysetSortKeySchema,
OffsetPaginatedResponseSchema,
OffsetPaginationMetaSchema,
OffsetPaginationRequestSchema,
PageTokenPayloadSchema,
PaginationDefaultsSchema,
SortConfigSchema,
} from "./pagination.schemas";

View File

@@ -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),
});

View File

@@ -0,0 +1,10 @@
export {
createCursorToken,
createOffsetToken,
decodePageToken,
encodePageToken,
extractCursorValue,
extractSortInfo,
hashFilters,
validateTokenFilter,
} from "./page-tokens";

View File

@@ -0,0 +1,95 @@
# Page Tokens
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The page tokens module provides opaque, URL-safe token strings that carry all the state needed to resume a paginated query: the cursor or offset value, sort field and direction, and an optional filter hash for cache invalidation. Tokens are encoded as base64url JSON so they survive URL query-string round-trips without additional percent-encoding.
This module sits above the raw cursor encoding in `cursor-pagination.ts`: where that module encodes a single value, page tokens encode a structured payload that includes sort and filter metadata — making them suitable for stateless pagination APIs where the client must carry all context.
## Features
- `encodePageToken` / `decodePageToken` — full payload round-trip with type validation.
- `createCursorToken` — create a token from a domain value (ID, score, timestamp) and optional sort/filter info.
- `createOffsetToken` — shortcut for offset-based tokens.
- `extractCursorValue` — pull just the value field from a token.
- `extractSortInfo` — pull sort field and direction from a token.
- `hashFilters` — stable djb2 hash of a filter map for cache-invalidation.
- `validateTokenFilter` — check that a token's filter hash matches an expected value.
## Usage: Basic Example
```typescript
import { createCursorToken, decodePageToken } from "@suaveplan/pagination/tokens";
const token = createCursorToken("user_99", "createdAt", "desc");
// → opaque base64url string
const payload = decodePageToken(token);
// → { v: "user_99", s: "createdAt", d: "desc" }
```
## Usage: Advanced Example
```typescript
import {
createCursorToken,
hashFilters,
validateTokenFilter,
extractSortInfo,
} from "@suaveplan/pagination/tokens";
const filters = { status: "active", role: "admin" };
const hash = hashFilters(filters);
// → stable hash string, e.g. "1q7f9k"
const token = createCursorToken("cursor_abc", "name", "asc", hash);
// On the next request, verify the filter hasn't changed
const valid = validateTokenFilter(token, hash); // → true
const invalid = validateTokenFilter(token, "differenthash"); // → false
// Extract sort metadata to rebuild the query
const sort = extractSortInfo(token);
// → { sortBy: "name", sortDirection: "asc" }
```
## API Reference
### `encodePageToken(payload: PageTokenPayload): string`
Serialises the payload to JSON and encodes as base64url. The payload must have a `v` field (string or number); `s`, `d`, `f` are optional.
### `decodePageToken(token: string): PageTokenPayload | null`
Decodes and validates a token. Returns `null` for any invalid input, never throws.
### `createCursorToken(value, sortBy?, sortDirection?, filterHash?): string`
Builds and encodes a `PageTokenPayload` from a cursor value and optional sort/filter metadata.
### `createOffsetToken(offset, sortBy?, sortDirection?, filterHash?): string`
Alias of `createCursorToken` with `offset: number` as the first argument.
### `extractCursorValue(token: string): string | number | null`
Returns the `v` field of the decoded token, or `null` if the token is invalid.
### `extractSortInfo(token: string): { sortBy?: string; sortDirection?: SortDirection } | null`
Returns the `s` and `d` fields of the decoded token, or `null` if the token is invalid.
### `hashFilters(filters: Record<string, unknown>): string`
Sorts keys lexicographically, concatenates `key=value` pairs, and applies a djb2 hash. Returns the absolute hash as a base-36 string. Suitable for cache-invalidation only — not cryptographic.
### `validateTokenFilter(token: string, expectedFilterHash: string): boolean`
Returns `true` when the token is valid and its `f` field equals `expectedFilterHash`. Returns `true` (unconditional) when the token has no `f` field. Returns `false` for invalid tokens or mismatched hashes.
## Implementation Notes
The djb2 hash in `hashFilters` uses bitwise OR with `0` to keep the accumulator within signed 32-bit integer range — preventing floating-point precision loss for long filter strings. The filter hash is NOT a cryptographic MAC; it cannot prevent token forgery. Use HMAC-signed tokens if forgery resistance is required.

View File

@@ -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);
});
});

View 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;
}

View 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";

View File

@@ -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.

View File

@@ -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;
}

View 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();
});
});

View File

@@ -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([]);
});
}
});

View 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 }
);
});
});

View File

@@ -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;
}

View 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();
});
});

View 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"]
}

View 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:/],
},
},
});

View 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,
},
});