first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

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.