1
12
13import { ApiError } from "../common/errors.js";
14
15export interface CursorPair {
16 id: string;
17 position: string;
18}
19
20export interface ParsedPagination {
21 cursor?: CursorPair;
22 limit: number;
23}
24
25export const DEFAULT_PAGE_SIZE = 50;
26export const MAX_PAGE_SIZE = 200;
27
28
29export function parsePagination(input: { cursor?: string | null; limit?: string | number | null } = {}): ParsedPagination {
30 let limit = DEFAULT_PAGE_SIZE;
31 if (input.limit !== undefined && input.limit !== null && input.limit !== "") {
32 const n = typeof input.limit === "number" ? input.limit : Number(input.limit);
33 if (!Number.isInteger(n) || n < 1) {
34 throw new ApiError("VALIDATION_ERROR", 400, "limit must be a positive integer", [
35 { field: "limit", issue: "positive integer", received: String(input.limit) }
36 ]);
37 }
38 limit = Math.min(n, MAX_PAGE_SIZE);
39 }
40 let cursor: CursorPair | undefined;
41 if (input.cursor) {
42 cursor = decodeCursor(input.cursor);
43 }
44 return { ...(cursor ? { cursor } : {}), limit };
45}
46
47
48export function encodeCursor(id: string, position: string): string {
49 const payload = JSON.stringify({ id, position });
50 return Buffer.from(payload, "utf8").toString("base64url");
51}
52
53
54export function decodeCursor(cursor: string): CursorPair {
55 try {
56 const obj = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
57 if (
58 typeof obj !== "object" ||
59 obj === null ||
60 typeof obj.id !== "string" ||
61 typeof obj.position !== "string"
62 ) {
63 throw new Error("bad cursor shape");
64 }
65 return { id: obj.id, position: obj.position };
66 } catch {
67 throw new ApiError("VALIDATION_ERROR", 400, "invalid cursor", [
68 { field: "cursor", issue: "malformed cursor" }
69 ]);
70 }
71}
72
73export interface PaginatedResult<T> {
74 data: T[];
75 nextCursor: string | null;
76}
77
78export interface PaginateOptions {
79 cursor?: CursorPair;
80 limit: number;
81}
82
83
90export async function paginate<T extends { id: string; position: string }>(
91 query: (where: Record<string, unknown>, cursor: CursorPair | undefined, limit: number) => Promise<T[]>,
92 opts: PaginateOptions,
93 baseWhere: Record<string, unknown> = {}
94): Promise<PaginatedResult<T>> {
95 const rows = await query(baseWhere, opts.cursor, opts.limit + 1);
96 if (rows.length > opts.limit) {
97 const kept = rows.slice(0, opts.limit);
98 const last = kept[opts.limit - 1];
99 if (!last) throw new Error("paginate: empty page beyond limit");
100 return { data: kept, nextCursor: encodeCursor(last.id, last.position) };
101 }
102 return { data: rows, nextCursor: null };
103}
104