/** * @factory/database-engine/query — opaque cursor pagination (TASK-0066 R1). * * Cursor = base64url JSON `{position, id}` over the FINAL (post-filter, * post-sort) stable order. Keyset: a row is "after" the cursor when * (position > cursor.position) OR (position == cursor.position AND id > * cursor.id). Stable under inserts between pages (the fractional position * keeps the row in place); a deleted cursor row just resumes after it. */ export interface Cursor { position: string; id: string; } export function encodeCursor(cursor: Cursor): string { return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); } export function decodeCursor(opaque: string): Cursor | null { try { const obj = JSON.parse(Buffer.from(opaque, "base64url").toString("utf8")) as { position?: unknown; id?: unknown; }; if (typeof obj.position !== "string" || typeof obj.id !== "string") return null; return { position: obj.position, id: obj.id }; } catch { return null; } } export interface Paged { items: T[]; cursor: string | null; total: number; } interface Keyed { position: string; id: string; } /** * Page `rows` (already filter+sorted) with a keyset cursor. Returns the * items for this page, the next cursor (null when there is no next page), * and the total count. `pageSize` is clamped to [1, 500]. */ export function paginateRows( rows: T[], cursor: string | undefined, pageSize: number, ): Paged { const size = Math.min(Math.max(Math.trunc(pageSize) || 50, 1), 500); let start = 0; if (cursor) { const c = decodeCursor(cursor); if (c) { // find the first row strictly after the cursor start = rows.findIndex( (r) => r.position > c.position || (r.position === c.position && r.id > c.id), ); if (start < 0) start = rows.length; // cursor row gone (deleted) } } const items = rows.slice(start, start + size); const nextStart = start + items.length; const hasMore = nextStart < rows.length; const next = hasMore ? items[items.length - 1] : undefined; return { items, cursor: next ? encodeCursor({ position: next.position, id: next.id }) : null, total: rows.length, }; }