release: WRNexusJS 0.8.0
This commit is contained in:
@@ -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