322 lines
11 KiB
TypeScript
322 lines
11 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("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();
|
|
});
|