release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import type { Db, ExecResult, Row } from "./driver.ts";
|
||||
import type { Model } from "./schema.ts";
|
||||
import type { Dialect } from "./sql.ts";
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function identifier(value: string): string {
|
||||
if (!IDENTIFIER.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function placeholder(dialect: Dialect, index: number): string {
|
||||
return dialect === "postgres" ? `$${index}` : "?";
|
||||
}
|
||||
|
||||
function placeholders(dialect: Dialect, count: number, start = 1): string[] {
|
||||
return Array.from({ length: count }, (_value, index) => placeholder(dialect, start + index));
|
||||
}
|
||||
|
||||
function finiteInteger(value: number | undefined, fallback: number, minimum: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
if (!Number.isFinite(value)) throw new RangeError("Expected a finite integer.");
|
||||
return Math.max(minimum, Math.floor(value));
|
||||
}
|
||||
|
||||
export class RecordNotFoundError extends Error {
|
||||
constructor(message = "Record not found") {
|
||||
super(message);
|
||||
this.name = "RecordNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function firstOrThrow<T>(
|
||||
db: Db,
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
model?: Model<T>,
|
||||
message?: string,
|
||||
): Promise<T> {
|
||||
const row = await db.one(sql, params, model);
|
||||
if (row === null) throw new RecordNotFoundError(message);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function exists(db: Db, sql: string, params: unknown[] = []): Promise<boolean> {
|
||||
return (await db.one(sql, params)) !== null;
|
||||
}
|
||||
|
||||
export async function countRows(
|
||||
db: Db,
|
||||
table: string,
|
||||
where = "",
|
||||
params: unknown[] = [],
|
||||
): Promise<number> {
|
||||
const row = await db.one<{ count: number | string }>(
|
||||
`SELECT COUNT(*) AS count FROM ${identifier(table)}${where ? ` WHERE ${where}` : ""}`,
|
||||
params,
|
||||
);
|
||||
return Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
export function withTransaction<T>(db: Db, callback: (tx: Db) => Promise<T>): Promise<T> {
|
||||
return db.tx(callback);
|
||||
}
|
||||
|
||||
/** Conservative default classifier for deadlock/serialization retry errors. */
|
||||
export function isRetryableTransactionError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const value = error as { code?: unknown; errno?: unknown; message?: unknown };
|
||||
const code = String(value.code ?? value.errno ?? "").toUpperCase();
|
||||
if (["40001", "40P01", "SQLITE_BUSY", "SQLITE_LOCKED", "1213", "1205"].includes(code)) {
|
||||
return true;
|
||||
}
|
||||
const message = String(value.message ?? "").toLowerCase();
|
||||
return /deadlock|serialization failure|database is locked|lock wait timeout/.test(message);
|
||||
}
|
||||
|
||||
export async function retryTransaction<T>(
|
||||
db: Db,
|
||||
callback: (tx: Db, attempt: number) => Promise<T>,
|
||||
options: {
|
||||
attempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
jitter?: boolean;
|
||||
shouldRetry?: (error: unknown) => boolean;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const attempts = finiteInteger(options.attempts, 3, 1);
|
||||
const baseDelayMs = finiteInteger(options.baseDelayMs, 25, 0);
|
||||
const maxDelayMs = finiteInteger(options.maxDelayMs, 1_000, 0);
|
||||
const shouldRetry = options.shouldRetry ?? isRetryableTransactionError;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
return await db.tx((tx) => callback(tx, attempt));
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= attempts || !shouldRetry(error)) throw error;
|
||||
const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
|
||||
const delay =
|
||||
options.jitter === false
|
||||
? exponential
|
||||
: Math.round(exponential * (0.5 + Math.random() * 0.5));
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export function batch<T>(values: readonly T[], size = 100): T[][] {
|
||||
const chunkSize = finiteInteger(size, 100, 1);
|
||||
const chunks: T[][] = [];
|
||||
for (let index = 0; index < values.length; index += chunkSize) {
|
||||
chunks.push(values.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export async function databaseHealth(
|
||||
db: Db,
|
||||
): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
await db.one("SELECT 1 AS healthy");
|
||||
return { ok: true, latencyMs: Math.round((performance.now() - start) * 100) / 100 };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: Math.round((performance.now() - start) * 100) / 100,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface RepositoryListOptions<T extends Row> {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: keyof T & string;
|
||||
direction?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface Repository<T extends Row> {
|
||||
all(options?: RepositoryListOptions<T>): Promise<T[]>;
|
||||
find(id: string | number): Promise<T | null>;
|
||||
require(id: string | number): Promise<T>;
|
||||
create(values: Partial<T>): Promise<ExecResult>;
|
||||
update(id: string | number, values: Partial<T>): Promise<ExecResult>;
|
||||
remove(id: string | number): Promise<ExecResult>;
|
||||
exists(id: string | number): Promise<boolean>;
|
||||
count(): Promise<number>;
|
||||
}
|
||||
|
||||
export function createRepository<T extends Row>(
|
||||
db: Db,
|
||||
input: {
|
||||
table: string;
|
||||
idColumn?: string;
|
||||
model?: Model<T>;
|
||||
allowedColumns?: readonly (keyof T & string)[];
|
||||
maxListLimit?: number;
|
||||
/** Immutable equality scope (normally tenant_id) applied to every operation. */
|
||||
scope?: { column: keyof T & string; value: unknown };
|
||||
},
|
||||
): Repository<T> {
|
||||
const table = identifier(input.table);
|
||||
const idColumn = identifier(input.idColumn ?? "id");
|
||||
const allowed = input.allowedColumns ? new Set(input.allowedColumns.map(identifier)) : null;
|
||||
const dialect = db.driver.dialect;
|
||||
const maxListLimit = finiteInteger(input.maxListLimit, 1_000, 1);
|
||||
const scopeColumn = input.scope ? identifier(input.scope.column) : null;
|
||||
const valuesOf = (values: Partial<T>) => {
|
||||
const entries = Object.entries(values).filter(([key, value]) => {
|
||||
if (value === undefined || key === idColumn) return false;
|
||||
identifier(key);
|
||||
return !allowed || allowed.has(key) || key === scopeColumn;
|
||||
});
|
||||
if (!entries.length)
|
||||
throw new TypeError("Repository write requires at least one allowed column.");
|
||||
return entries;
|
||||
};
|
||||
const allowedReadColumn = (value: string): string => {
|
||||
const safe = identifier(value);
|
||||
if (allowed && safe !== idColumn && !allowed.has(safe)) {
|
||||
throw new TypeError(`Repository column is not allowed: ${safe}`);
|
||||
}
|
||||
return safe;
|
||||
};
|
||||
|
||||
return {
|
||||
all: (options = {}) => {
|
||||
const clauses: string[] = scopeColumn
|
||||
? [`WHERE ${scopeColumn} = ${placeholder(dialect, 1)}`]
|
||||
: [];
|
||||
const params: unknown[] = scopeColumn ? [input.scope!.value] : [];
|
||||
if (options.orderBy) {
|
||||
clauses.push(
|
||||
`ORDER BY ${allowedReadColumn(options.orderBy)} ${(options.direction ?? "asc").toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
if (options.limit !== undefined) {
|
||||
const limit = Math.min(maxListLimit, finiteInteger(options.limit, maxListLimit, 1));
|
||||
params.push(limit);
|
||||
clauses.push(`LIMIT ${placeholder(dialect, params.length)}`);
|
||||
}
|
||||
if (options.offset !== undefined) {
|
||||
const offset = finiteInteger(options.offset, 0, 0);
|
||||
if (options.limit === undefined) {
|
||||
params.push(maxListLimit);
|
||||
clauses.push(`LIMIT ${placeholder(dialect, params.length)}`);
|
||||
}
|
||||
params.push(offset);
|
||||
clauses.push(`OFFSET ${placeholder(dialect, params.length)}`);
|
||||
}
|
||||
return db.all<T>(
|
||||
`SELECT * FROM ${table}${clauses.length ? ` ${clauses.join(" ")}` : ""}`,
|
||||
params,
|
||||
input.model,
|
||||
);
|
||||
},
|
||||
find: (id) =>
|
||||
db.one<T>(
|
||||
`SELECT * FROM ${table} WHERE ${idColumn} = ${placeholder(dialect, 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, 2)}` : ""} LIMIT 1`,
|
||||
scopeColumn ? [id, input.scope!.value] : [id],
|
||||
input.model,
|
||||
),
|
||||
require: (id) =>
|
||||
firstOrThrow<T>(
|
||||
db,
|
||||
`SELECT * FROM ${table} WHERE ${idColumn} = ${placeholder(dialect, 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, 2)}` : ""} LIMIT 1`,
|
||||
scopeColumn ? [id, input.scope!.value] : [id],
|
||||
input.model,
|
||||
),
|
||||
async create(values) {
|
||||
const scoped = scopeColumn ? { ...values, [scopeColumn]: input.scope!.value } : values;
|
||||
const entries = valuesOf(scoped);
|
||||
return db.exec(
|
||||
`INSERT INTO ${table} (${entries.map(([key]) => identifier(key)).join(", ")}) VALUES (${placeholders(dialect, entries.length).join(", ")})`,
|
||||
entries.map(([, value]) => value),
|
||||
);
|
||||
},
|
||||
async update(id, values) {
|
||||
const entries = valuesOf(values);
|
||||
return db.exec(
|
||||
`UPDATE ${table} SET ${entries
|
||||
.map(([key], index) => `${identifier(key)} = ${placeholder(dialect, index + 1)}`)
|
||||
.join(
|
||||
", ",
|
||||
)} WHERE ${idColumn} = ${placeholder(dialect, entries.length + 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, entries.length + 2)}` : ""}`,
|
||||
[...entries.map(([, value]) => value), id, ...(scopeColumn ? [input.scope!.value] : [])],
|
||||
);
|
||||
},
|
||||
remove: (id) =>
|
||||
db.exec(
|
||||
`DELETE FROM ${table} WHERE ${idColumn} = ${placeholder(dialect, 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, 2)}` : ""}`,
|
||||
scopeColumn ? [id, input.scope!.value] : [id],
|
||||
),
|
||||
exists: (id) =>
|
||||
exists(
|
||||
db,
|
||||
`SELECT 1 FROM ${table} WHERE ${idColumn} = ${placeholder(dialect, 1)}${scopeColumn ? ` AND ${scopeColumn} = ${placeholder(dialect, 2)}` : ""} LIMIT 1`,
|
||||
scopeColumn ? [id, input.scope!.value] : [id],
|
||||
),
|
||||
count: () =>
|
||||
scopeColumn
|
||||
? db
|
||||
.one<{ count: number | string }>(
|
||||
`SELECT COUNT(*) AS count FROM ${table} WHERE ${scopeColumn} = ${placeholder(dialect, 1)}`,
|
||||
[input.scope!.value],
|
||||
)
|
||||
.then((row) => Number(row?.count ?? 0))
|
||||
: countRows(db, table),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user