release: WRNexusJS 0.8.0
This commit is contained in:
+75
-4
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user