release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+75 -4
View File
@@ -1,5 +1,14 @@
# @wrnexus/db
## Rollout-safe migrations
Run `wrnexus db check` in CI before deployment. The analyzer reports stable
diagnostics for drops, renames, type changes, new/enforced required columns,
and potentially blocking index creation, with an expand/backfill/switch/contract
recommendation. `wrnexus db migrate` blocks critical issues in pending
migrations. `--allow-breaking` is an explicit operator override; already-applied
migrations do not block later releases.
> The database layer for WrNexus: TS models as the single source of truth for DDL, validation, and result typing, plus a driver-based `Db` client, migrations, and a sqlc-style query generator.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
@@ -85,7 +94,8 @@ A `Driver` (implemented by adapters) exposes `dialect`, `query(sql, params?)`,
- `exec(sql, params?)``Promise<ExecResult>` (`{ changes, lastInsertId? }`).
- `tx(fn)` — run `fn(db)` in a transaction; rolls back on throw. Nested `tx` reuses the current transaction.
- `createTable(model)` — runs the model's `CREATE TABLE IF NOT EXISTS` DDL.
- `close()`.
- `close()` — idempotently rejects new top-level work, drains active queries and
transactions, then closes the underlying pool.
Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)`
renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`.
@@ -98,7 +108,9 @@ A process-wide registry the runtime configures at startup from `wrnexus.config.t
- `setDb(db)` / `setDb(name, db)` — set the default or a named connection.
- `registerDb(name, db)` — alias of `setDb(name, db)`.
- `getDb(name = "default")` — the default or a named `Db` (throws if unconfigured).
- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`.
- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`. Registry shutdown clears
registrations first, attempts every open database, and reports close failures
together with `AggregateError` instead of leaking later pools.
```ts
const users = await getDb().all("SELECT * FROM users");
@@ -121,8 +133,8 @@ Migrations are `.sql` files (in e.g. `app/db/migrations`), each split into
- `parseMigration(name, content)``Migration` (`{ name, up, down }`).
- `loadMigrations(dir)` — parse all `.sql` files, sorted by filename.
- `appliedMigrations(db)` — applied names, oldest first.
- `migrate(db, dir)` — apply all pending (each in a transaction); returns applied names.
- `rollback(db, dir)` — roll back the most recent; returns its name or `null`.
- `migrate(db, dir, options?)` — apply all pending (each in a transaction); returns applied names.
- `rollback(db, dir, options?)` — roll back the most recent; returns its name or `null`.
- `status(db, dir)``{ name, applied }[]` for every migration file.
- `scaffoldMigration(dir, name, dialect, models?)` — write a new numbered migration; with `models` it generates `CREATE`/`DROP` for every table (referenced tables first via topological sort). Returns the file path.
@@ -199,6 +211,21 @@ const pageTwo = await paginate(
);
```
For deployments, `{ dryRun: true }` reports pending names without applying
their SQL, `signal` cancels safely between migrations, and the default
database-backed lock prevents concurrent deploy runners. A live lock produces
`WRN-DB-MIGRATION-LOCKED`; crash-stale locks expire after `lockTimeoutMs` (five
minutes by default). Disable it with `lock: false` only when an external deploy
coordinator already guarantees exclusivity.
```ts
const pending = await migrate(db, "app/db/migrations", { dryRun: true });
await migrate(db, "app/db/migrations", {
signal: shutdownController.signal,
lockTimeoutMs: 10 * 60_000,
});
```
MongoDB (document API):
```ts
@@ -226,3 +253,47 @@ SQL driver — use `@wrnexus/db/mongo` directly.
`wrnexus.config.ts`.
- The `mongodb` npm package is an optional, lazily-imported peer — install it
only if you use `@wrnexus/db/mongo`. The core package stays dependency-free.
## Repository and transaction helpers
Repositories accept an immutable equality `scope`, normally `{ column: "tenant_id", value:
ctx.tenant.id }`. The scope is injected into every read, count, update and delete, while create
overwrites any caller-supplied tenant value. This makes accidental cross-tenant CRUD through the
repository API fail closed.
```ts
import { createRepository, retryTransaction, databaseHealth, batch } from "@wrnexus/db";
const users = createRepository<User>(db, {
table: "users",
allowedColumns: ["email", "name", "active"],
});
const user = await users.require(42);
await users.update(42, { active: true });
```
Repository identifiers are validated, writes may be restricted to an allowlist, and values always use query parameters. Infrastructure packages remain helper-only and do not add UI dependencies to server code.
## 0.8 repository and transaction helpers
```ts
import { createRepository, databaseHealth, firstOrThrow, retryTransaction } from "@wrnexus/db";
const usersRepo = createRepository<User>(db, {
table: "users",
allowedColumns: ["email", "name", "active"],
maxListLimit: 250,
});
const users = await usersRepo.all({
orderBy: "name",
direction: "asc",
limit: 50,
offset: 0,
});
```
Repository SQL identifiers are validated and values remain parameterized. Placeholder generation is dialect-aware: PostgreSQL uses `$1`, `$2`, and SQLite/MySQL use `?`. List limits are bounded.
`retryTransaction()` retries recognized serialization, deadlock, and database-lock errors by default. Supply `shouldRetry` for application-specific retryable errors; ordinary validation or business errors are not retried automatically.
+17 -2
View File
@@ -1,9 +1,9 @@
{
"name": "@wrnexus/db",
"version": "0.7.0",
"version": "0.8.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./connect": "./src/connect.ts",
@@ -12,5 +12,20 @@
"./postgres": "./src/adapters/postgres.ts",
"./mysql": "./src/adapters/mysql.ts",
"./mongo": "./src/adapters/mongo.ts"
},
"description": "Typed database drivers, migrations, instrumentation, repositories, pagination, and transaction helpers.",
"types": "./src/index.ts",
"files": [
"src",
"README.md"
],
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
"check": "bun run typecheck && bun run test"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.2"
}
}
+4 -3
View File
@@ -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");
}
+34 -7
View File
@@ -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() });
}
+274
View File
@@ -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),
};
}
+21 -2
View File
@@ -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
View File
@@ -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. */
+107
View File
@@ -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);
}
+35 -8
View File
@@ -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);
}
};
+69 -1
View File
@@ -15,7 +15,7 @@ import {
parseQueries,
generateQueriesFile,
} from "../src/index.ts";
import type { Db } from "../src/index.ts";
import type { Db, Driver } from "../src/index.ts";
import { sqlite } from "../src/adapters/sqlite.ts";
import { bunSql } from "../src/adapters/bunsql.ts";
@@ -39,6 +39,39 @@ test("model.parse coerces DB rows to typed values", () => {
expect(row.active).toBe(true);
});
test("database close drains active work, rejects new queries, and is idempotent", async () => {
let release!: () => void;
const pending = new Promise<void>((resolve) => (release = resolve));
let closes = 0;
const driver: Driver = {
dialect: "sqlite",
async query() {
await pending;
return [{ ok: true }];
},
async exec() {
return { changes: 0 };
},
async transaction(fn) {
return fn(this);
},
close() {
closes++;
},
};
const db = createDb(driver);
const query = db.all("SELECT 1");
await Promise.resolve();
const closing = Promise.resolve(db.close());
await expect(db.all("SELECT 2")).rejects.toThrow("WRN-DB-CLOSED");
expect(closes).toBe(0);
release();
expect(await query).toEqual([{ ok: true }]);
await closing;
await db.close();
expect(closes).toBe(1);
});
for (const [label, driver] of [
["bun:sqlite", () => sqlite()],
["Bun.sql/sqlite", () => bunSql("sqlite://:memory:", "sqlite")],
@@ -111,6 +144,41 @@ test("an empty migration set does not touch the database", async () => {
expect(await applyMigrations(db, [])).toEqual([]);
});
test("migration dry-run plans changes without applying schema and honors cancellation", async () => {
const db = createDb(sqlite());
const migrations = [
{ name: "0001_plan", up: "CREATE TABLE planned (id INTEGER)", down: "DROP TABLE planned" },
];
expect(await applyMigrations(db, migrations, { dryRun: true })).toEqual(["0001_plan"]);
expect(
await db.all("SELECT name FROM sqlite_master WHERE type='table' AND name='planned'"),
).toHaveLength(0);
const controller = new AbortController();
controller.abort(new Error("deploy cancelled"));
await expect(applyMigrations(db, migrations, { signal: controller.signal })).rejects.toThrow(
"deploy cancelled",
);
await db.close();
});
test("migration lock rejects a concurrent runner and recovers expired locks", async () => {
const db = createDb(sqlite());
const migrations = [{ name: "0001_lock", up: "CREATE TABLE locked_test (id INTEGER)", down: "" }];
await db.exec(
"CREATE TABLE _wire_migration_locks (name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at TEXT NOT NULL)",
);
await db.exec("INSERT INTO _wire_migration_locks (name, owner, expires_at) VALUES (?, ?, ?)", [
"global",
"other",
new Date(Date.now() + 60_000).toISOString(),
]);
await expect(applyMigrations(db, migrations)).rejects.toThrow("WRN-DB-MIGRATION-LOCKED");
await db.exec("UPDATE _wire_migration_locks SET expires_at = ?", [new Date(0).toISOString()]);
expect(await applyMigrations(db, migrations)).toEqual(["0001_lock"]);
await db.close();
});
test("query generator infers params and result types", () => {
const q = parseQueries(
"-- name: GetByEmail :one\nSELECT * FROM users WHERE email = :email;\n" +
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test";
import {
batch,
createDb,
createRepository,
databaseHealth,
retryTransaction,
} from "../src/index.ts";
import type { Driver, Row } from "../src/index.ts";
function memoryDriver(): Driver {
const rows: Row[] = [{ id: 1, name: "One" }];
return {
dialect: "sqlite",
async query(sql) {
if (/COUNT/.test(sql)) return [{ count: rows.length }];
if (/SELECT 1 AS healthy/.test(sql)) return [{ healthy: 1 }];
return rows;
},
async exec() {
return { changes: 1, lastInsertId: 2 };
},
async transaction(callback) {
return callback(this);
},
close() {},
};
}
describe("database helper kit", () => {
test("provides repository CRUD helpers and health checks", async () => {
const db = createDb(memoryDriver());
const repository = createRepository<{ id: number; name: string }>(db, {
table: "items",
allowedColumns: ["name"],
});
expect((await repository.find(1))?.name).toBe("One");
expect(await repository.count()).toBe(1);
expect((await databaseHealth(db)).ok).toBe(true);
expect(batch([1, 2, 3], 2)).toEqual([[1, 2], [3]]);
});
test("retries transaction callbacks with bounded attempts", async () => {
const db = createDb(memoryDriver());
let calls = 0;
const value = await retryTransaction(
db,
async () => {
calls += 1;
if (calls < 2) throw new Error("retry");
return "done";
},
{ attempts: 2, baseDelayMs: 0, shouldRetry: () => true },
);
expect(value).toBe("done");
expect(calls).toBe(2);
});
test("uses dialect-aware placeholders and bounded list options", async () => {
const queries: string[] = [];
const driver = memoryDriver();
driver.dialect = "postgres";
const originalQuery = driver.query;
driver.query = async (sql, params) => {
queries.push(sql);
return originalQuery.call(driver, sql, params);
};
const originalExec = driver.exec;
driver.exec = async (sql, params) => {
queries.push(sql);
return originalExec.call(driver, sql, params);
};
const repository = createRepository<{ id: number; name: string }>(createDb(driver), {
table: "items",
allowedColumns: ["name"],
maxListLimit: 50,
});
await repository.find(1);
await repository.create({ name: "Two" });
await repository.update(1, { name: "Changed" });
await repository.all({ limit: 10, offset: 5, orderBy: "name", direction: "desc" });
expect(queries.some((sql) => sql.includes("id = $1"))).toBe(true);
expect(queries.some((sql) => sql.includes("VALUES ($1)"))).toBe(true);
expect(queries.some((sql) => sql.includes("LIMIT $1 OFFSET $2"))).toBe(true);
});
test("automatically applies an immutable tenant scope to every repository operation", async () => {
const calls: Array<{ sql: string; params: unknown[] }> = [];
const driver: Driver = {
dialect: "sqlite",
async query(sql, params = []) {
calls.push({ sql, params: [...params] });
return /COUNT/.test(sql) ? [{ count: 0 }] : [];
},
async exec(sql, params = []) {
calls.push({ sql, params: [...params] });
return { changes: 1 };
},
async transaction(callback) {
return callback(this);
},
close() {},
};
type RecordRow = { id: number; name: string; tenant_id: string };
const repository = createRepository<RecordRow>(createDb(driver), {
table: "records",
allowedColumns: ["name"],
scope: { column: "tenant_id", value: "acme" },
});
await repository.all();
await repository.find(1);
await repository.create({ name: "A", tenant_id: "other" });
await repository.update(1, { name: "B" });
await repository.remove(1);
await repository.count();
expect(calls.every((call) => call.sql.includes("tenant_id"))).toBe(true);
expect(calls.every((call) => call.params.includes("acme"))).toBe(true);
expect(calls.some((call) => call.params.includes("other"))).toBe(false);
});
test("rejects unsafe repository identifiers", () => {
const db = createDb(memoryDriver());
expect(() => createRepository(db, { table: "items; DROP TABLE items" })).toThrow(
"Unsafe SQL identifier",
);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { analyzeMigrationSafety } from "../src/index.ts";
describe("expand and contract migration analysis", () => {
test("detects destructive and rollout-unsafe SQL with guidance", () => {
const issues = analyzeMigrationSafety({
name: "0002_breaking",
up: `ALTER TABLE users RENAME COLUMN name TO full_name;
ALTER TABLE users ADD COLUMN tenant_id UUID NOT NULL;
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
DROP TABLE legacy_users;
CREATE INDEX users_email_idx ON users(email);`,
down: "",
});
expect(issues.map((issue) => issue.code)).toEqual([
"WRN-DB-RENAME",
"WRN-DB-ADD-REQUIRED",
"WRN-DB-TYPE-CHANGE",
"WRN-DB-DROP-TABLE",
"WRN-DB-BLOCKING-INDEX",
]);
expect(issues.every((issue) => issue.recommendation.length > 20)).toBe(true);
});
test("accepts the additive phase of an expand/contract rollout", () => {
expect(
analyzeMigrationSafety({
name: "0002_expand",
up: "ALTER TABLE users ADD COLUMN full_name TEXT;",
down: "",
}),
).toEqual([]);
});
});
+14
View File
@@ -10,6 +10,7 @@ import {
closeDatabases,
} from "../src/index.ts";
import { sqlite } from "../src/adapters/sqlite.ts";
import type { Db } from "../src/index.ts";
test("multi-database registry: default + named connections", async () => {
await closeDatabases(); // isolate from any prior state
@@ -78,3 +79,16 @@ test("closing the registry does not instantiate unused lazy databases", async ()
await closeDatabases();
expect(calls).toBe(0);
});
test("registry closes every database and clears itself when one close fails", async () => {
await closeDatabases();
let secondClosed = false;
const broken = { close: async () => Promise.reject(new Error("close failed")) } as Db;
const healthy = { close: () => void (secondClosed = true) } as Db;
setDb(broken);
registerDb("healthy", healthy);
await expect(closeDatabases()).rejects.toThrow("databases failed to close");
expect(secondClosed).toBe(true);
expect(databaseNames()).toEqual([]);
});