rollback ran a migration's down section when it had one and then deleted its tracking row either way. A migration that changed the schema without a `-- +down` section was therefore recorded as rolled back while its change stayed in the database, and the next migrate replayed statements such as ALTER TABLE ADD COLUMN that cannot run twice, leaving the database stuck between the two states with no way forward or back. It now refuses that case with WRN-DB-MIGRATION-IRREVERSIBLE and leaves the tracking row alone, so the database stays consistent and the operator chooses: add a down section, or write a new migration that reverses the change. The check runs before the lock is taken, so a dry run reports the same refusal rather than promising a rollback that would fail. A migration whose up is empty still rolls back by dropping its tracking row, since there is nothing to undo. That keeps placeholder migrations working, and `wrnexus db rollback` prints the refusal as a message rather than a stack trace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
import { test, expect } from "bun:test";
|
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
v,
|
|
table,
|
|
createDb,
|
|
createTableSql,
|
|
parseMigration,
|
|
applyMigrations,
|
|
appliedMigrations,
|
|
migrate,
|
|
status,
|
|
rollback,
|
|
parseQueries,
|
|
generateQueriesFile,
|
|
} 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";
|
|
|
|
const users = table<{ id: number; email: string; name: string; active: boolean }>("users", {
|
|
id: v.id(),
|
|
email: v.string().unique(),
|
|
name: v.string(),
|
|
active: v.boolean().default(true),
|
|
});
|
|
|
|
test("createTableSql renders dialect-specific DDL", () => {
|
|
expect(createTableSql(users, "sqlite")).toContain("INTEGER PRIMARY KEY AUTOINCREMENT");
|
|
const pg = createTableSql(users, "postgres");
|
|
expect(pg).toContain("SERIAL PRIMARY KEY");
|
|
expect(pg).toContain("BOOLEAN");
|
|
});
|
|
|
|
test("model.parse coerces DB rows to typed values", () => {
|
|
const row = users.parse({ id: "1", email: "a@b.com", name: "Ann", active: 1 });
|
|
expect(row.id).toBe(1);
|
|
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")],
|
|
] as const) {
|
|
test(`CRUD + transactions [${label}]`, async () => {
|
|
const db = createDb(driver());
|
|
await db.createTable(users);
|
|
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
|
|
"a@b.com",
|
|
"Ann",
|
|
true,
|
|
]);
|
|
// rollback
|
|
try {
|
|
await db.tx(async (t) => {
|
|
await t.exec("INSERT INTO users (email, name) VALUES (?, ?)", ["x@y.com", "X"]);
|
|
throw new Error("boom");
|
|
});
|
|
} catch {
|
|
/* expected */
|
|
}
|
|
// commit
|
|
await db.tx(async (t) => {
|
|
await t.exec("INSERT INTO users (email, name) VALUES (?, ?)", ["c@d.com", "Cy"]);
|
|
});
|
|
const rows = await db.all("SELECT * FROM users ORDER BY id", [], users);
|
|
expect(rows.map((r) => r.name)).toEqual(["Ann", "Cy"]);
|
|
expect(typeof rows[0]!.active).toBe("boolean");
|
|
db.close();
|
|
});
|
|
}
|
|
|
|
test("migration runner: parse, migrate, status, rollback", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-"));
|
|
writeFileSync(
|
|
join(dir, "0001_init.sql"),
|
|
"-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY, n TEXT);\n-- +down\nDROP TABLE t;",
|
|
);
|
|
const parsed = parseMigration(
|
|
"0001_init",
|
|
"-- +up\nCREATE TABLE t (id INTEGER);\n-- +down\nDROP TABLE t;",
|
|
);
|
|
expect(parsed.up).toContain("CREATE TABLE t");
|
|
expect(parsed.down).toContain("DROP TABLE t");
|
|
|
|
const db = createDb(sqlite());
|
|
expect(await migrate(db, dir)).toEqual(["0001_init"]);
|
|
expect(await migrate(db, dir)).toEqual([]); // idempotent
|
|
expect((await status(db, dir))[0]).toEqual({ name: "0001_init", applied: true });
|
|
const tablesAfter = await db.all<{ name: string }>(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='t'",
|
|
);
|
|
expect(tablesAfter.length).toBe(1);
|
|
expect(await rollback(db, dir)).toBe("0001_init");
|
|
expect((await status(db, dir))[0]!.applied).toBe(false);
|
|
db.close();
|
|
});
|
|
|
|
test("an empty migration set does not touch the database", async () => {
|
|
const unreachable = async () => {
|
|
throw new Error("database should remain lazy");
|
|
};
|
|
const db = {
|
|
all: unreachable,
|
|
one: unreachable,
|
|
exec: unreachable,
|
|
tx: unreachable,
|
|
} as unknown as Db;
|
|
|
|
expect(await applyMigrations(db, [])).toEqual([]);
|
|
});
|
|
|
|
test("rollback refuses a schema change that defines no down section", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-nodown-"));
|
|
writeFileSync(join(dir, "0001_add.sql"), "-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY);");
|
|
|
|
const db = createDb(sqlite());
|
|
expect(await migrate(db, dir)).toEqual(["0001_add"]);
|
|
|
|
// Rolling this back would drop the record and leave the table, and the next
|
|
// migrate would replay a CREATE that cannot run twice.
|
|
await expect(rollback(db, dir)).rejects.toThrow(/WRN-DB-MIGRATION-IRREVERSIBLE/);
|
|
|
|
// The refusal leaves the record alone, so the database stays consistent.
|
|
expect(await appliedMigrations(db)).toEqual(["0001_add"]);
|
|
expect((await status(db, dir))[0]!.applied).toBe(true);
|
|
db.close();
|
|
});
|
|
|
|
test("a dry run reports the same refusal instead of promising a rollback", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-dry-"));
|
|
writeFileSync(join(dir, "0001_add.sql"), "-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY);");
|
|
|
|
const db = createDb(sqlite());
|
|
await migrate(db, dir);
|
|
await expect(rollback(db, dir, { dryRun: true })).rejects.toThrow(
|
|
/WRN-DB-MIGRATION-IRREVERSIBLE/,
|
|
);
|
|
db.close();
|
|
});
|
|
|
|
test("rollback still undoes a migration that defines a down section", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-down-"));
|
|
writeFileSync(
|
|
join(dir, "0001_add.sql"),
|
|
"-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY);\n-- +down\nDROP TABLE t;",
|
|
);
|
|
|
|
const db = createDb(sqlite());
|
|
await migrate(db, dir);
|
|
expect(await rollback(db, dir)).toBe("0001_add");
|
|
expect(await appliedMigrations(db)).toEqual([]);
|
|
const remaining = await db.all<{ name: string }>(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='t'",
|
|
);
|
|
expect(remaining.length).toBe(0);
|
|
db.close();
|
|
});
|
|
|
|
test("a migration whose up is only comments has nothing to undo and rolls back", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "wrn-mig-noop-"));
|
|
writeFileSync(
|
|
join(dir, "0001_placeholder.sql"),
|
|
"-- +up\n-- Nothing yet.\n-- +down\n-- Nothing to undo.",
|
|
);
|
|
|
|
const db = createDb(sqlite());
|
|
await migrate(db, dir);
|
|
expect(await rollback(db, dir)).toBe("0001_placeholder");
|
|
expect(await appliedMigrations(db)).toEqual([]);
|
|
db.close();
|
|
});
|
|
|
|
test("comment-only migrations are recorded without executing empty SQL", async () => {
|
|
const db = createDb(sqlite());
|
|
const migrations = [
|
|
{
|
|
name: "0001_placeholder",
|
|
up: "-- Create application tables here.\n/* No schema yet. */\n;",
|
|
down: "-- Nothing to roll back.",
|
|
},
|
|
];
|
|
|
|
expect(await applyMigrations(db, migrations)).toEqual(["0001_placeholder"]);
|
|
expect(await appliedMigrations(db)).toEqual(["0001_placeholder"]);
|
|
expect(await rollback(db, ".", { lock: false })).toBe("0001_placeholder");
|
|
await db.close();
|
|
});
|
|
|
|
test("add-column migrations recover when the column exists but the migration record does not", async () => {
|
|
const db = createDb(sqlite());
|
|
await db.exec("CREATE TABLE otp_challenges (id TEXT PRIMARY KEY, purpose TEXT NOT NULL)");
|
|
const migrations = [
|
|
{
|
|
name: "0002_otp_purpose",
|
|
up: "ALTER TABLE otp_challenges ADD COLUMN purpose TEXT NOT NULL DEFAULT 'verification';",
|
|
down: "ALTER TABLE otp_challenges DROP COLUMN purpose;",
|
|
},
|
|
];
|
|
|
|
expect(await applyMigrations(db, migrations)).toEqual(["0002_otp_purpose"]);
|
|
expect(await appliedMigrations(db)).toContain("0002_otp_purpose");
|
|
const columns = await db.all<{ name: string }>("PRAGMA table_info(otp_challenges)");
|
|
expect(columns.filter(({ name }) => name === "purpose")).toHaveLength(1);
|
|
await db.close();
|
|
});
|
|
|
|
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 _wrn_migration_locks (name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at TEXT NOT NULL)",
|
|
);
|
|
await db.exec("INSERT INTO _wrn_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 _wrn_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" +
|
|
"-- name: CountActive :one\nSELECT COUNT(*) AS n FROM users WHERE active = :active;\n" +
|
|
"-- name: Create :exec\nINSERT INTO users (email, name) VALUES (:email, :name);",
|
|
);
|
|
expect(q.map((x) => x.name)).toEqual(["GetByEmail", "CountActive", "Create"]);
|
|
const code = generateQueriesFile(q, [{ varName: "users", model: users }], "sqlite");
|
|
expect(code).toContain("GetByEmail(db: Db, args: { email: string })");
|
|
expect(code).toContain(
|
|
"CountActive(db: Db, args: { active: boolean }): Promise<{ n: number } | null>",
|
|
);
|
|
expect(code).toContain(
|
|
"Create(db: Db, args: { email: string; name: string }): Promise<ExecResult>",
|
|
);
|
|
});
|
|
|
|
test("query generator omits ExecResult when there are no exec queries", () => {
|
|
const queries = parseQueries("-- name: ListUsers :many\nSELECT * FROM users;");
|
|
const code = generateQueriesFile(queries, [{ varName: "users", model: users }], "sqlite");
|
|
expect(code).toContain('import type { Db } from "@wrnexus/db";');
|
|
expect(code).not.toContain("ExecResult");
|
|
});
|
|
|
|
test("paginate returns a page window with correct metadata", async () => {
|
|
const { paginate } = await import("../src/index.ts");
|
|
const db = createDb(sqlite());
|
|
await db.createTable(users);
|
|
for (let i = 1; i <= 25; i++) {
|
|
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
|
|
`u${i}@x.com`,
|
|
`U${i}`,
|
|
true,
|
|
]);
|
|
}
|
|
const p2 = await paginate(
|
|
db,
|
|
{ sql: "SELECT * FROM users ORDER BY id", model: users },
|
|
{ page: 2, perPage: 10 },
|
|
);
|
|
expect(p2.total).toBe(25);
|
|
expect(p2.totalPages).toBe(3);
|
|
expect(p2.items.length).toBe(10);
|
|
expect(p2.items[0]!.name).toBe("U11");
|
|
expect(p2.hasNext).toBe(true);
|
|
expect(p2.hasPrev).toBe(true);
|
|
|
|
const p3 = await paginate(
|
|
db,
|
|
{ sql: "SELECT * FROM users ORDER BY id" },
|
|
{ page: 3, perPage: 10 },
|
|
);
|
|
expect(p3.items.length).toBe(5);
|
|
expect(p3.hasNext).toBe(false);
|
|
await db.close();
|
|
});
|
|
|
|
test("loadRelated batches children onto parents (no N+1)", async () => {
|
|
const { loadRelated } = await import("../src/index.ts");
|
|
const db = createDb(sqlite());
|
|
await db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
|
|
await db.exec("CREATE TABLE posts (id INTEGER PRIMARY KEY, userId INTEGER, title TEXT)");
|
|
await db.exec("INSERT INTO users (id, name) VALUES (1, 'Ann'), (2, 'Bob')");
|
|
await db.exec(
|
|
"INSERT INTO posts (id, userId, title) VALUES (1, 1, 'a'), (2, 1, 'b'), (3, 2, 'c')",
|
|
);
|
|
|
|
const parents = await db.all<{ id: number; name: string }>("SELECT * FROM users ORDER BY id");
|
|
const withPosts = await loadRelated(db, parents, {
|
|
table: "posts",
|
|
foreignKey: "userId",
|
|
as: "posts",
|
|
});
|
|
expect((withPosts[0]!.posts as unknown[]).length).toBe(2);
|
|
expect((withPosts[1]!.posts as unknown[]).length).toBe(1);
|
|
await db.close();
|
|
});
|
|
|
|
test("loadRelated rejects unsafe identifiers", async () => {
|
|
const { loadRelated } = await import("../src/index.ts");
|
|
const db = createDb(sqlite());
|
|
await expect(
|
|
loadRelated(db, [{ id: 1 }], {
|
|
table: "posts; DROP TABLE users",
|
|
foreignKey: "userId",
|
|
as: "x",
|
|
}),
|
|
).rejects.toThrow("Unsafe table name");
|
|
await db.close();
|
|
});
|
|
|
|
test("sqliteSessionStore persists sessions (get/set/delete/gc)", async () => {
|
|
const { sqliteSessionStore } = await import("../src/session-store.ts");
|
|
const store = sqliteSessionStore(":memory:");
|
|
expect(store.get("s1")).toBeUndefined();
|
|
store.set("s1", { data: { user: 7 }, expiresAt: Date.now() + 10_000 });
|
|
expect(store.get("s1")!.data).toEqual({ user: 7 });
|
|
store.set("s1", { data: { user: 8 }, expiresAt: Date.now() + 10_000 }); // upsert
|
|
expect(store.get("s1")!.data).toEqual({ user: 8 });
|
|
store.set("old", { data: {}, expiresAt: Date.now() - 1 });
|
|
store.gc!(Date.now());
|
|
expect(store.get("old")).toBeUndefined();
|
|
store.delete("s1");
|
|
expect(store.get("s1")).toBeUndefined();
|
|
});
|