/** * Cursor pagination. * * Cursors are base64url(JSON({id, position})). List endpoints accept * `?cursor=&limit=` (default 50, max 200) and respond `{data, nextCursor}` * with `nextCursor: null` on the last page. * * The `paginate` helper fetches limit+1 rows through a caller-supplied * query function (so ordering + cursor predicates live next to the SQL), * drops the extra row, and derives nextCursor from the last kept row. */ import { ApiError } from "../common/errors.js"; export interface CursorPair { id: string; position: string; } export interface ParsedPagination { cursor?: CursorPair; limit: number; } export const DEFAULT_PAGE_SIZE = 50; export const MAX_PAGE_SIZE = 200; /** Parse + validate `cursor`/`limit` query params. */ export function parsePagination(input: { cursor?: string | null; limit?: string | number | null } = {}): ParsedPagination { let limit = DEFAULT_PAGE_SIZE; if (input.limit !== undefined && input.limit !== null && input.limit !== "") { const n = typeof input.limit === "number" ? input.limit : Number(input.limit); if (!Number.isInteger(n) || n < 1) { throw new ApiError("VALIDATION_ERROR", 400, "limit must be a positive integer", [ { field: "limit", issue: "positive integer", received: String(input.limit) } ]); } limit = Math.min(n, MAX_PAGE_SIZE); } let cursor: CursorPair | undefined; if (input.cursor) { cursor = decodeCursor(input.cursor); } return { ...(cursor ? { cursor } : {}), limit }; } /** Base64url-encode a cursor payload. */ export function encodeCursor(id: string, position: string): string { const payload = JSON.stringify({ id, position }); return Buffer.from(payload, "utf8").toString("base64url"); } /** Decode + validate a cursor; throws VALIDATION_ERROR on garbage. */ export function decodeCursor(cursor: string): CursorPair { try { const obj = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); if ( typeof obj !== "object" || obj === null || typeof obj.id !== "string" || typeof obj.position !== "string" ) { throw new Error("bad cursor shape"); } return { id: obj.id, position: obj.position }; } catch { throw new ApiError("VALIDATION_ERROR", 400, "invalid cursor", [ { field: "cursor", issue: "malformed cursor" } ]); } } export interface PaginatedResult { data: T[]; nextCursor: string | null; } export interface PaginateOptions { cursor?: CursorPair; limit: number; } /** * Run a cursor-paginated query. * * `query(where, cursor, limit)` must return up to `limit` rows in stable * order, applying `cursor` as an exclusive positional predicate. The helper * passes `limit + 1` so it can detect "more pages remain". */ export async function paginate( query: (where: Record, cursor: CursorPair | undefined, limit: number) => Promise, opts: PaginateOptions, baseWhere: Record = {} ): Promise> { const rows = await query(baseWhere, opts.cursor, opts.limit + 1); if (rows.length > opts.limit) { const kept = rows.slice(0, opts.limit); const last = kept[opts.limit - 1]; if (!last) throw new Error("paginate: empty page beyond limit"); return { data: kept, nextCursor: encodeCursor(last.id, last.position) }; } return { data: rows, nextCursor: null }; }