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((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", ); }); 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(); });