4.3 KiB
Keyset Pagination
(no submodules)
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— serializeKeysetSortKey[]to a URL-safe base64url string.decodeKeysetCursor— deserialize and validate a keyset cursor; returnsnullon 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
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
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.