From 8282403ee8a79e8bc4c6371eeae1c6d0b29bd803 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:39:58 +0000 Subject: [PATCH] add cursor pagination function --- .../database/pagination/cursor-pagination.ts | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 apps/server/src/database/pagination/cursor-pagination.ts diff --git a/apps/server/src/database/pagination/cursor-pagination.ts b/apps/server/src/database/pagination/cursor-pagination.ts new file mode 100644 index 00000000..33b2f811 --- /dev/null +++ b/apps/server/src/database/pagination/cursor-pagination.ts @@ -0,0 +1,337 @@ +// source: https://github.com/charlie-hadden/kysely-paginate/blob/main/src/cursor.ts - MIT +import { + OrderByDirectionExpression, + ReferenceExpression, + SelectQueryBuilder, + StringReference, +} from "kysely"; + +type SortField = + | { + expression: + | (StringReference & keyof O & string) + | (StringReference & `${string}.${keyof O & string}`); + direction: OrderByDirectionExpression; + key?: keyof O & string; +} + | { + expression: ReferenceExpression; + direction: OrderByDirectionExpression; + key: keyof O & string; +}; + +type ExtractSortFieldKey< + DB, + TB extends keyof DB, + O, + T extends SortField, +> = T["key"] extends keyof O & string + ? T["key"] + : T["expression"] extends keyof O & string + ? T["expression"] + : T["expression"] extends `${string}.${infer K}` + ? K extends keyof O & string + ? K + : never + : never; + +type Fields = ReadonlyArray< + Readonly> +>; + +type FieldNames> = { + [TIndex in keyof T]: ExtractSortFieldKey; +}; + +type EncodeCursorValues< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = { + [TIndex in keyof T]: [ + ExtractSortFieldKey, + O[ExtractSortFieldKey], + ]; +}; + +export type CursorEncoder< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = (values: EncodeCursorValues) => string; + +type DecodedCursor> = { + [TField in ExtractSortFieldKey]: string; +}; + +export type CursorDecoder< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = ( + cursor: string, + fields: FieldNames, +) => DecodedCursor; + +type ParsedCursorValues< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = { + [TField in ExtractSortFieldKey]: O[TField]; +}; + +export type CursorParser< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = (cursor: DecodedCursor) => ParsedCursorValues; + +type CursorPaginationResultRow< + TRow, + TCursorKey extends string | boolean | undefined, +> = TRow & { + [K in TCursorKey extends undefined + ? never + : TCursorKey extends false + ? never + : TCursorKey extends true + ? "$cursor" + : TCursorKey]: string; +}; + +export type CursorPaginationResult< + TRow, + TCursorKey extends string | boolean | undefined, +> = { + startCursor: string | undefined; + endCursor: string | undefined; + hasNextPage?: boolean; + hasPrevPage?: boolean; + rows: CursorPaginationResultRow[]; +}; + +export async function executeWithCursorPagination< + DB, + TB extends keyof DB, + O, + const TFields extends Fields, + TCursorKey extends string | boolean | undefined = undefined, +>( + qb: SelectQueryBuilder, + opts: { + perPage: number; + after?: string; + before?: string; + cursorPerRow?: TCursorKey; + fields: TFields; + encodeCursor?: CursorEncoder; + decodeCursor?: CursorDecoder; + parseCursor: + | CursorParser + | { parse: CursorParser }; + }, +): Promise> { + const encodeCursor = opts.encodeCursor ?? defaultEncodeCursor; + const decodeCursor = opts.decodeCursor ?? defaultDecodeCursor; + + const parseCursor = + typeof opts.parseCursor === "function" + ? opts.parseCursor + : opts.parseCursor.parse; + + const fields = opts.fields.map((field) => { + let key = field.key; + + if (!key && typeof field.expression === "string") { + const expressionParts = field.expression.split("."); + + key = (expressionParts[1] ?? expressionParts[0]) as + | (keyof O & string) + | undefined; + } + + if (!key) throw new Error("missing key"); + + return { ...field, key }; + }); + + function generateCursor(row: O): string { + const cursorFieldValues = fields.map(({ key }) => [ + key, + row[key], + ]) as EncodeCursorValues; + + return encodeCursor(cursorFieldValues); + } + + const fieldNames = fields.map((field) => field.key) as FieldNames< + DB, + TB, + O, + TFields + >; + + function applyCursor( + qb: SelectQueryBuilder, + encoded: string, + defaultDirection: "asc" | "desc", + ) { + const decoded = decodeCursor(encoded, fieldNames); + const cursor = parseCursor(decoded); + + return qb.where(({ and, or, eb }) => { + let expression; + + for (let i = fields.length - 1; i >= 0; --i) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const field = fields[i]!; + + const comparison = field.direction === defaultDirection ? ">" : "<"; + const value = cursor[field.key as keyof typeof cursor]; + + const conditions = [eb(field.expression, comparison, value)]; + + if (expression) { + conditions.push(and([eb(field.expression, "=", value), expression])); + } + + expression = or(conditions); + } + + if (!expression) { + throw new Error("Error building cursor expression"); + } + + return expression; + }); + } + + if (opts.after) qb = applyCursor(qb, opts.after, "asc"); + if (opts.before) qb = applyCursor(qb, opts.before, "desc"); + + const reversed = !!opts.before && !opts.after; + + for (const { expression, direction } of fields) { + qb = qb.orderBy( + expression, + reversed ? (direction === "asc" ? "desc" : "asc") : direction, + ); + } + + const rows = await qb.limit(opts.perPage + 1).execute(); + + const hasNextPage = reversed ? undefined : rows.length > opts.perPage; + const hasPrevPage = !reversed ? undefined : rows.length > opts.perPage; + + // If we fetched an extra row to determine if we have a next page, that + // shouldn't be in the returned results + if (rows.length > opts.perPage) rows.pop(); + + if (reversed) rows.reverse(); + + const startRow = rows[0]; + const endRow = rows[rows.length - 1]; + + const startCursor = startRow ? generateCursor(startRow) : undefined; + const endCursor = endRow ? generateCursor(endRow) : undefined; + + return { + startCursor, + endCursor, + hasNextPage, + hasPrevPage, + rows: rows.map((row) => { + if (opts.cursorPerRow) { + const cursorKey = + typeof opts.cursorPerRow === "string" ? opts.cursorPerRow : "$cursor"; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + (row as any)[cursorKey] = generateCursor(row); + } + + return row as CursorPaginationResultRow; + }), + }; +} + +export function defaultEncodeCursor< + DB, + TB extends keyof DB, + O, + T extends Fields, +>(values: EncodeCursorValues) { + const cursor = new URLSearchParams(); + + for (const [key, value] of values) { + switch (typeof value) { + case "string": + cursor.set(key, value); + break; + + case "number": + case "bigint": + cursor.set(key, value.toString(10)); + break; + + case "object": { + if (value instanceof Date) { + cursor.set(key, value.toISOString()); + break; + } + } + + // eslint-disable-next-line no-fallthrough + default: + throw new Error(`Unable to encode '${key.toString()}'`); + } + } + + return Buffer.from(cursor.toString(), "utf8").toString("base64url"); +} + +export function defaultDecodeCursor< + DB, + TB extends keyof DB, + O, + T extends Fields, +>( + cursor: string, + fields: FieldNames, +): DecodedCursor { + let parsed; + + try { + parsed = [ + ...new URLSearchParams( + Buffer.from(cursor, "base64url").toString("utf8"), + ).entries(), + ]; + } catch { + throw new Error("Unparsable cursor"); + } + + if (parsed.length !== fields.length) { + throw new Error("Unexpected number of fields"); + } + + for (let i = 0; i < fields.length; i++) { + const field = parsed[i]; + const expectedName = fields[i]; + + if (!field) { + throw new Error("Unable to find field"); + } + + if (field[0] !== expectedName) { + throw new Error("Unexpected field name"); + } + } + + return Object.fromEntries(parsed) as DecodedCursor; +}