Files
WRNexusJS/packages/db/src/advanced.ts
T
2026-07-27 12:42:18 +05:30

140 lines
4.4 KiB
TypeScript

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<T>(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<T> {
items: T[];
nextCursor?: string;
previousCursor?: string;
hasMore: boolean;
}
interface CursorValue {
value: unknown;
direction: "asc" | "desc";
}
export async function cursorPaginate<T extends Row = Row>(
db: Db,
query: { sql: string; params?: unknown[]; model?: Model<T> },
options: CursorPageOptions = {},
): Promise<CursorPage<T>> {
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<CursorValue>(options.after)
: options.before
? decode<CursorValue>(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<T>(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<string, unknown>;
},
): Promise<number> {
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`;
}