140 lines
4.4 KiB
TypeScript
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`;
|
|
}
|