Files
skills/docs/foundation/data/pagination/src/keyset/keyset-pagination.md
2026-05-29 08:20:56 +01:00

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

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.