release: WRNexusJS 0.8.0
This commit is contained in:
@@ -70,8 +70,9 @@ export function databaseNames(): string[] {
|
||||
|
||||
/** Close every configured database and clear the registry. */
|
||||
export async function closeDatabases(): Promise<void> {
|
||||
for (const entry of registry.values()) {
|
||||
if (entry.db) await entry.db.close();
|
||||
}
|
||||
const databases = [...registry.values()].flatMap((entry) => (entry.db ? [entry.db] : []));
|
||||
registry.clear();
|
||||
const results = await Promise.allSettled(databases.map((db) => db.close()));
|
||||
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length) throw new AggregateError(errors, "One or more databases failed to close");
|
||||
}
|
||||
|
||||
@@ -50,12 +50,32 @@ export interface Db {
|
||||
close(): void | Promise<void>;
|
||||
}
|
||||
|
||||
interface DbLifecycle {
|
||||
closing: boolean;
|
||||
active: Set<Promise<unknown>>;
|
||||
closePromise?: Promise<void>;
|
||||
}
|
||||
|
||||
/** Build a `Db` over a query runner (the driver at top level, or a transaction). */
|
||||
function dbOver(runner: TxHandle, driver: Driver): Db {
|
||||
function dbOver(
|
||||
runner: TxHandle,
|
||||
driver: Driver,
|
||||
lifecycle: DbLifecycle,
|
||||
transactionScope = false,
|
||||
): Db {
|
||||
function run<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (lifecycle.closing && !transactionScope) {
|
||||
return Promise.reject(new Error("WRN-DB-CLOSED: database is closing or closed"));
|
||||
}
|
||||
const promise = Promise.resolve().then(operation);
|
||||
lifecycle.active.add(promise);
|
||||
void promise.finally(() => lifecycle.active.delete(promise)).catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
const db: Db = {
|
||||
driver,
|
||||
async all(sql, params = [], model) {
|
||||
const rows = await runner.query(sql, params);
|
||||
const rows = await run(() => runner.query(sql, params));
|
||||
return (model ? rows.map((r) => model.parse(r)) : rows) as never;
|
||||
},
|
||||
async one(sql, params = [], model) {
|
||||
@@ -63,18 +83,25 @@ function dbOver(runner: TxHandle, driver: Driver): Db {
|
||||
return (rows[0] ?? null) as never;
|
||||
},
|
||||
exec(sql, params = []) {
|
||||
return runner.exec(sql, params);
|
||||
return run(() => runner.exec(sql, params));
|
||||
},
|
||||
async tx(fn) {
|
||||
// Top level opens a real transaction; inside one, reuse the current tx.
|
||||
if (runner === driver) return driver.transaction((tx) => fn(dbOver(tx, driver)));
|
||||
if (runner === driver)
|
||||
return run(() => driver.transaction((tx) => fn(dbOver(tx, driver, lifecycle, true))));
|
||||
return fn(db);
|
||||
},
|
||||
async createTable(model) {
|
||||
await runner.exec(createTableSql(model, driver.dialect));
|
||||
await run(() => runner.exec(createTableSql(model, driver.dialect)));
|
||||
},
|
||||
close() {
|
||||
return driver.close();
|
||||
if (lifecycle.closePromise) return lifecycle.closePromise;
|
||||
lifecycle.closing = true;
|
||||
lifecycle.closePromise = (async () => {
|
||||
await Promise.allSettled([...lifecycle.active]);
|
||||
await driver.close();
|
||||
})();
|
||||
return lifecycle.closePromise;
|
||||
},
|
||||
};
|
||||
return db;
|
||||
@@ -82,5 +109,5 @@ function dbOver(runner: TxHandle, driver: Driver): Db {
|
||||
|
||||
/** Build a `Db` client from a driver. */
|
||||
export function createDb(driver: Driver): Db {
|
||||
return dbOver(driver, driver);
|
||||
return dbOver(driver, driver, { closing: false, active: new Set() });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -31,7 +31,9 @@ export {
|
||||
status,
|
||||
scaffoldMigration,
|
||||
} from "./migrate.ts";
|
||||
export type { Migration } from "./migrate.ts";
|
||||
export type { Migration, MigrationRunOptions } from "./migrate.ts";
|
||||
export { analyzeMigrationSafety, analyzeMigrations } from "./migration-safety.ts";
|
||||
export type { MigrationSafetyIssue } from "./migration-safety.ts";
|
||||
export { parseQueries, generateQueriesFile } from "./generate.ts";
|
||||
export type { QueryDef, QueryKind, ModelRef } from "./generate.ts";
|
||||
export { paginate, loadRelated } from "./query.ts";
|
||||
@@ -39,5 +41,22 @@ export type { Paginated, PageOptions, RelationOptions } from "./query.ts";
|
||||
export { cursorPaginate, optimisticUpdate, tenantScope, softDeleteClause } from "./advanced.ts";
|
||||
export type { CursorPage, CursorPageOptions } from "./advanced.ts";
|
||||
|
||||
export { instrumentDb, queryOperation } from "./performance.ts";
|
||||
export {
|
||||
instrumentDb,
|
||||
queryOperation,
|
||||
getDbPerformanceSnapshot,
|
||||
resetDbPerformanceSnapshot,
|
||||
} from "./performance.ts";
|
||||
export type { QueryIssue, QueryPolicy, QueryRecord } from "./performance.ts";
|
||||
export {
|
||||
RecordNotFoundError,
|
||||
firstOrThrow,
|
||||
exists,
|
||||
countRows,
|
||||
withTransaction,
|
||||
retryTransaction,
|
||||
batch,
|
||||
databaseHealth,
|
||||
createRepository,
|
||||
} from "./helpers.ts";
|
||||
export type { Repository } from "./helpers.ts";
|
||||
|
||||
+104
-17
@@ -20,6 +20,18 @@ export interface Migration {
|
||||
}
|
||||
|
||||
const MIGRATIONS_TABLE = "_wire_migrations";
|
||||
const MIGRATION_LOCKS_TABLE = "_wire_migration_locks";
|
||||
|
||||
export interface MigrationRunOptions {
|
||||
/** Return pending migration names without executing their SQL. */
|
||||
dryRun?: boolean;
|
||||
/** Stop safely between migrations. Active database statements cannot be interrupted portably. */
|
||||
signal?: AbortSignal;
|
||||
/** Coordinate migration runners through the database. Default true. */
|
||||
lock?: boolean;
|
||||
/** Allow recovery of a lock left by a crashed process. Default 5 minutes. */
|
||||
lockTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Split a migration file into its `up` and `down` SQL sections. */
|
||||
export function parseMigration(name: string, content: string): Migration {
|
||||
@@ -50,7 +62,7 @@ export function loadMigrations(dir: string): Migration[] {
|
||||
|
||||
async function ensureTable(db: Db): Promise<void> {
|
||||
await db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,38 +75,113 @@ export async function appliedMigrations(db: Db): Promise<string[]> {
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw signal.reason ?? new DOMException("Migration aborted", "AbortError");
|
||||
}
|
||||
|
||||
async function acquireMigrationLock(db: Db, timeoutMs: number): Promise<() => Promise<void>> {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new RangeError("migration lockTimeoutMs must be a positive number");
|
||||
}
|
||||
await db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS ${MIGRATION_LOCKS_TABLE} (name VARCHAR(255) PRIMARY KEY, owner VARCHAR(255) NOT NULL, expires_at VARCHAR(40) NOT NULL)`,
|
||||
);
|
||||
const owner = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
await db.exec(`DELETE FROM ${MIGRATION_LOCKS_TABLE} WHERE name = ? AND expires_at <= ?`, [
|
||||
"global",
|
||||
now.toISOString(),
|
||||
]);
|
||||
try {
|
||||
await db.exec(
|
||||
`INSERT INTO ${MIGRATION_LOCKS_TABLE} (name, owner, expires_at) VALUES (?, ?, ?)`,
|
||||
["global", owner, new Date(now.getTime() + timeoutMs).toISOString()],
|
||||
);
|
||||
} catch (error) {
|
||||
const held = await db.all(`SELECT owner FROM ${MIGRATION_LOCKS_TABLE} WHERE name = ?`, [
|
||||
"global",
|
||||
]);
|
||||
if (held.length === 0) throw error;
|
||||
throw new Error("WRN-DB-MIGRATION-LOCKED: another process is running database migrations", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return async () => {
|
||||
await db.exec(`DELETE FROM ${MIGRATION_LOCKS_TABLE} WHERE name = ? AND owner = ?`, [
|
||||
"global",
|
||||
owner,
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply an ordered migration list (each in a transaction). Returns applied names. */
|
||||
export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise<string[]> {
|
||||
export async function applyMigrations(
|
||||
db: Db,
|
||||
migrations: readonly Migration[],
|
||||
options: MigrationRunOptions = {},
|
||||
): Promise<string[]> {
|
||||
if (migrations.length === 0) return [];
|
||||
throwIfAborted(options.signal);
|
||||
const applied = new Set(await appliedMigrations(db));
|
||||
const pending = migrations.filter((migration) => !applied.has(migration.name));
|
||||
if (options.dryRun || pending.length === 0) return pending.map(({ name }) => name);
|
||||
const release =
|
||||
options.lock === false
|
||||
? undefined
|
||||
: await acquireMigrationLock(db, options.lockTimeoutMs ?? 300_000);
|
||||
const done: string[] = [];
|
||||
for (const migration of pending) {
|
||||
await db.tx(async (tx) => {
|
||||
if (migration.up) await tx.exec(migration.up);
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
|
||||
});
|
||||
done.push(migration.name);
|
||||
try {
|
||||
// Re-read after locking because another runner may have completed while we waited.
|
||||
const current = new Set(await appliedMigrations(db));
|
||||
for (const migration of pending.filter(({ name }) => !current.has(name))) {
|
||||
throwIfAborted(options.signal);
|
||||
await db.tx(async (tx) => {
|
||||
if (migration.up) await tx.exec(migration.up);
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
|
||||
});
|
||||
done.push(migration.name);
|
||||
}
|
||||
return done;
|
||||
} finally {
|
||||
await release?.();
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/** Apply all pending migrations from a directory. */
|
||||
export async function migrate(db: Db, dir: string): Promise<string[]> {
|
||||
return applyMigrations(db, loadMigrations(dir));
|
||||
export async function migrate(
|
||||
db: Db,
|
||||
dir: string,
|
||||
options: MigrationRunOptions = {},
|
||||
): Promise<string[]> {
|
||||
return applyMigrations(db, loadMigrations(dir), options);
|
||||
}
|
||||
|
||||
/** Roll back the most recently applied migration. Returns its name, or null. */
|
||||
export async function rollback(db: Db, dir: string): Promise<string | null> {
|
||||
export async function rollback(
|
||||
db: Db,
|
||||
dir: string,
|
||||
options: Omit<MigrationRunOptions, "dryRun"> & { dryRun?: boolean } = {},
|
||||
): Promise<string | null> {
|
||||
throwIfAborted(options.signal);
|
||||
const applied = await appliedMigrations(db);
|
||||
const last = applied[applied.length - 1];
|
||||
if (!last) return null;
|
||||
if (options.dryRun) return last;
|
||||
const release =
|
||||
options.lock === false
|
||||
? undefined
|
||||
: await acquireMigrationLock(db, options.lockTimeoutMs ?? 300_000);
|
||||
const migration = loadMigrations(dir).find((m) => m.name === last);
|
||||
await db.tx(async (tx) => {
|
||||
if (migration?.down) await tx.exec(migration.down);
|
||||
await tx.exec(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ?`, [last]);
|
||||
});
|
||||
return last;
|
||||
try {
|
||||
throwIfAborted(options.signal);
|
||||
await db.tx(async (tx) => {
|
||||
if (migration?.down) await tx.exec(migration.down);
|
||||
await tx.exec(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ?`, [last]);
|
||||
});
|
||||
return last;
|
||||
} finally {
|
||||
await release?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Full status: every migration file with whether it has been applied. */
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Migration } from "./migrate.ts";
|
||||
|
||||
export interface MigrationSafetyIssue {
|
||||
code:
|
||||
| "WRN-DB-DROP-TABLE"
|
||||
| "WRN-DB-DROP-COLUMN"
|
||||
| "WRN-DB-RENAME"
|
||||
| "WRN-DB-TYPE-CHANGE"
|
||||
| "WRN-DB-SET-NOT-NULL"
|
||||
| "WRN-DB-ADD-REQUIRED"
|
||||
| "WRN-DB-BLOCKING-INDEX";
|
||||
severity: "error" | "warning";
|
||||
migration: string;
|
||||
statement: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
function statements(sql: string): string[] {
|
||||
return sql
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.split(";")
|
||||
.map((statement) =>
|
||||
statement
|
||||
.replace(/--[^\r\n]*/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function analyzeMigrationSafety(migration: Migration): MigrationSafetyIssue[] {
|
||||
const issues: MigrationSafetyIssue[] = [];
|
||||
const add = (
|
||||
code: MigrationSafetyIssue["code"],
|
||||
severity: MigrationSafetyIssue["severity"],
|
||||
statement: string,
|
||||
recommendation: string,
|
||||
): void => {
|
||||
issues.push({ code, severity, migration: migration.name, statement, recommendation });
|
||||
};
|
||||
for (const statement of statements(migration.up)) {
|
||||
if (/\bDROP\s+TABLE\b/i.test(statement))
|
||||
add(
|
||||
"WRN-DB-DROP-TABLE",
|
||||
"error",
|
||||
statement,
|
||||
"Deprecate reads/writes first; drop in a later contract release.",
|
||||
);
|
||||
if (
|
||||
/\bDROP\s+(?:COLUMN\s+)?[A-Za-z_][\w$]*/i.test(statement) &&
|
||||
/\bALTER\s+TABLE\b/i.test(statement)
|
||||
)
|
||||
add(
|
||||
"WRN-DB-DROP-COLUMN",
|
||||
"error",
|
||||
statement,
|
||||
"Stop all old-version reads before a separate contract migration.",
|
||||
);
|
||||
if (/\bRENAME\s+(?:COLUMN\s+)?\b|\bRENAME\s+TO\b/i.test(statement))
|
||||
add(
|
||||
"WRN-DB-RENAME",
|
||||
"error",
|
||||
statement,
|
||||
"Add the new name, dual-write/backfill, switch readers, then remove the old name.",
|
||||
);
|
||||
if (
|
||||
/\bALTER\s+(?:COLUMN\s+)?[A-Za-z_][\w$]*\s+(?:TYPE|SET\s+DATA\s+TYPE)\b|\bMODIFY\s+(?:COLUMN\s+)?[A-Za-z_]/i.test(
|
||||
statement,
|
||||
)
|
||||
)
|
||||
add(
|
||||
"WRN-DB-TYPE-CHANGE",
|
||||
"error",
|
||||
statement,
|
||||
"Add a compatible column and backfill before switching readers.",
|
||||
);
|
||||
if (/\bALTER\s+(?:COLUMN\s+)?[A-Za-z_][\w$]*\s+SET\s+NOT\s+NULL\b/i.test(statement))
|
||||
add(
|
||||
"WRN-DB-SET-NOT-NULL",
|
||||
"error",
|
||||
statement,
|
||||
"Backfill and validate existing rows before enforcing NOT NULL.",
|
||||
);
|
||||
if (
|
||||
/\bADD\s+(?:COLUMN\s+)?[A-Za-z_][\w$]*[\s\S]*\bNOT\s+NULL\b/i.test(statement) &&
|
||||
!/\bDEFAULT\b/i.test(statement)
|
||||
)
|
||||
add(
|
||||
"WRN-DB-ADD-REQUIRED",
|
||||
"error",
|
||||
statement,
|
||||
"Add nullable, backfill in batches, then enforce the constraint.",
|
||||
);
|
||||
if (/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/i.test(statement) && !/\bCONCURRENTLY\b/i.test(statement))
|
||||
add(
|
||||
"WRN-DB-BLOCKING-INDEX",
|
||||
"warning",
|
||||
statement,
|
||||
"Use an online/concurrent index operation when the database supports it.",
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function analyzeMigrations(migrations: readonly Migration[]): MigrationSafetyIssue[] {
|
||||
return migrations.flatMap(analyzeMigrationSafety);
|
||||
}
|
||||
@@ -28,6 +28,24 @@ export interface QueryPolicy {
|
||||
onIssue?: (issue: QueryIssue) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const recentQueries: QueryRecord[] = [];
|
||||
const recentQueryIssues: QueryIssue[] = [];
|
||||
const TELEMETRY_LIMIT = 200;
|
||||
|
||||
export function getDbPerformanceSnapshot(): { queries: QueryRecord[]; issues: QueryIssue[] } {
|
||||
return { queries: structuredClone(recentQueries), issues: structuredClone(recentQueryIssues) };
|
||||
}
|
||||
|
||||
export function resetDbPerformanceSnapshot(): void {
|
||||
recentQueries.length = 0;
|
||||
recentQueryIssues.length = 0;
|
||||
}
|
||||
|
||||
function remember<T>(items: T[], value: T): void {
|
||||
items.push(structuredClone(value));
|
||||
if (items.length > TELEMETRY_LIMIT) items.splice(0, items.length - TELEMETRY_LIMIT);
|
||||
}
|
||||
|
||||
function normalizedSql(sql: string): string {
|
||||
return sql
|
||||
.replace(/--.*$/gm, " ")
|
||||
@@ -70,12 +88,14 @@ export function instrumentDb(db: Db, policy: QueryPolicy = {}): Db {
|
||||
const inspect = async (sql: string): Promise<void> => {
|
||||
const normalized = normalizedSql(sql);
|
||||
if (policy.warnSelectStar !== false && /^SELECT\s+\*/i.test(normalized)) {
|
||||
await policy.onIssue?.({
|
||||
const issue: QueryIssue = {
|
||||
code: "WRN-DB-SELECT-STAR",
|
||||
severity: "warning",
|
||||
message: "Avoid SELECT * in production queries.",
|
||||
sql,
|
||||
});
|
||||
};
|
||||
remember(recentQueryIssues, issue);
|
||||
await policy.onIssue?.(issue);
|
||||
}
|
||||
if (
|
||||
policy.warnUnboundedSelect !== false &&
|
||||
@@ -83,12 +103,14 @@ export function instrumentDb(db: Db, policy: QueryPolicy = {}): Db {
|
||||
!/\bLIMIT\b/i.test(normalized) &&
|
||||
!/\bCOUNT\s*\(/i.test(normalized)
|
||||
) {
|
||||
await policy.onIssue?.({
|
||||
const issue: QueryIssue = {
|
||||
code: "WRN-DB-UNBOUNDED-SELECT",
|
||||
severity: "warning",
|
||||
message: "SELECT query has no LIMIT. Prefer cursor pagination for large datasets.",
|
||||
sql,
|
||||
});
|
||||
};
|
||||
remember(recentQueryIssues, issue);
|
||||
await policy.onIssue?.(issue);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,22 +132,27 @@ export function instrumentDb(db: Db, policy: QueryPolicy = {}): Db {
|
||||
operation,
|
||||
duplicateCount,
|
||||
};
|
||||
remember(recentQueries, queryRecord);
|
||||
await policy.onQuery?.(queryRecord);
|
||||
if (durationMs >= slowQueryMs) {
|
||||
await policy.onIssue?.({
|
||||
const issue: QueryIssue = {
|
||||
code: "WRN-DB-SLOW-QUERY",
|
||||
severity: durationMs >= slowQueryMs * 5 ? "error" : "warning",
|
||||
message: `Query took ${durationMs.toFixed(2)} ms.`,
|
||||
sql,
|
||||
});
|
||||
};
|
||||
remember(recentQueryIssues, issue);
|
||||
await policy.onIssue?.(issue);
|
||||
}
|
||||
if (duplicateCount === duplicateWarningCount) {
|
||||
await policy.onIssue?.({
|
||||
const issue: QueryIssue = {
|
||||
code: "WRN-DB-DUPLICATE-QUERY",
|
||||
severity: "warning",
|
||||
message: `The same query ran ${duplicateCount} times in one request scope (possible N+1).`,
|
||||
sql,
|
||||
});
|
||||
};
|
||||
remember(recentQueryIssues, issue);
|
||||
await policy.onIssue?.(issue);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user