import type { Db, Row } from "./driver.ts"; import type { Model } from "./schema.ts"; import type { Dialect } from "./sql.ts"; const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/; function ident(value: string): string { if (!IDENT.test(value)) throw new Error(`Unsafe identifier: ${value}`); return value; } function ph(dialect: Dialect, index: number): string { return dialect === "postgres" ? `$${index}` : "?"; } function encode(value: unknown): string { return btoa(unescape(encodeURIComponent(JSON.stringify(value)))) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } function decode(value: string): T { const pad = value.length % 4 ? "=".repeat(4 - (value.length % 4)) : ""; return JSON.parse( decodeURIComponent(escape(atob(value.replace(/-/g, "+").replace(/_/g, "/") + pad))), ) as T; } export interface CursorPageOptions { limit?: number; after?: string; before?: string; column?: string; direction?: "asc" | "desc"; maxLimit?: number; } export interface CursorPage { items: T[]; nextCursor?: string; previousCursor?: string; hasMore: boolean; } interface CursorValue { value: unknown; direction: "asc" | "desc"; } export async function cursorPaginate( db: Db, query: { sql: string; params?: unknown[]; model?: Model }, options: CursorPageOptions = {}, ): Promise> { const column = ident(options.column ?? "id"); const direction = options.direction ?? "asc"; const max = options.maxLimit ?? 100; const limit = Math.min(max, Math.max(1, Math.floor(options.limit ?? 20))); const params = [...(query.params ?? [])]; const cursor = options.after ? decode(options.after) : options.before ? decode(options.before) : undefined; const comparison = options.before ? direction === "asc" ? "<" : ">" : direction === "asc" ? ">" : "<"; const reverse = !!options.before; let sql = `SELECT * FROM (${query.sql}) AS __wrn_cursor`; if (cursor) { params.push(cursor.value); sql += ` WHERE ${column} ${comparison} ${ph(db.driver.dialect, params.length)}`; } const order = reverse ? (direction === "asc" ? "DESC" : "ASC") : direction.toUpperCase(); params.push(limit + 1); sql += ` ORDER BY ${column} ${order} LIMIT ${ph(db.driver.dialect, params.length)}`; let rows = await db.all(sql, params, query.model); const hasMore = rows.length > limit; if (hasMore) rows = rows.slice(0, limit); if (reverse) rows.reverse(); const first = rows[0]?.[column]; const last = rows.at(-1)?.[column]; return { items: rows, hasMore, ...(last !== undefined && (hasMore || rows.length === limit) ? { nextCursor: encode({ value: last, direction }) } : {}), ...(first !== undefined && (options.after || options.before) ? { previousCursor: encode({ value: first, direction }) } : {}), }; } export async function optimisticUpdate( db: Db, input: { table: string; idColumn?: string; id: unknown; versionColumn?: string; version: number; values: Record; }, ): Promise { const table = ident(input.table); const idColumn = ident(input.idColumn ?? "id"); const versionColumn = ident(input.versionColumn ?? "version"); const entries = Object.entries(input.values); if (!entries.length) return input.version; for (const [column] of entries) ident(column); const params = entries.map(([, value]) => value); const assignments = entries.map( ([column], index) => `${column} = ${ph(db.driver.dialect, index + 1)}`, ); assignments.push(`${versionColumn} = ${versionColumn} + 1`); params.push(input.id, input.version); const result = await db.exec( `UPDATE ${table} SET ${assignments.join(", ")} WHERE ${idColumn} = ${ph(db.driver.dialect, params.length - 1)} AND ${versionColumn} = ${ph(db.driver.dialect, params.length)}`, params, ); if (result.changes !== 1) throw new Error("WRN-DB-OPTIMISTIC-LOCK"); return input.version + 1; } export function tenantScope( sql: string, tenantId: unknown, dialect: Dialect, existingParams = 0, column = "tenantId", ): { sql: string; params: unknown[] } { ident(column); const wrapped = `SELECT * FROM (${sql}) AS __wrn_tenant WHERE ${column} = ${ph(dialect, existingParams + 1)}`; return { sql: wrapped, params: [tenantId] }; } export function softDeleteClause(column = "deletedAt"): string { return `${ident(column)} IS NULL`; }