import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { applyMigrations } from "@/lib/db/migrate"; const temporaryDirectories: string[] = []; describe("applyMigrations", () => { afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => import("node:fs/promises").then(({ rm }) => rm(directory, { recursive: true, force: true })), ), ); }); it("applies every pending migration once in one transaction", async () => { const directory = join("/tmp", `ai-control-migrations-${crypto.randomUUID()}`); temporaryDirectories.push(directory); await mkdir(directory, { recursive: true }); await writeFile(join(directory, "001_initial.sql"), "CREATE TABLE users (id text);"); const database = new RecordingDatabase(); await applyMigrations(database, directory); await applyMigrations(database, directory); expect(database.queries.map((query) => query.text)).toEqual( expect.arrayContaining(["BEGIN", "COMMIT"]), ); expect( database.queries.filter((query) => query.text.includes("CREATE TABLE users")), ).toHaveLength(1); expect( database.queries.filter((query) => query.values?.[0] === "001_initial.sql"), ).toHaveLength(1); }); it("migrates model columns to stable Gate slugs without rewriting legacy values", async () => { const database = new RecordingDatabase(); await applyMigrations(database, join(process.cwd(), "db", "migrations")); const migration = database.queries.find((query) => query.text.includes("users_last_model_id_slug_check"), )?.text; expect(migration).toBeDefined(); expect(migration).toContain("ALTER COLUMN last_model_id SET DEFAULT 'auto'"); expect(migration).toContain("ALTER COLUMN model_id SET DEFAULT 'auto'"); expect(migration).toContain("conversations_model_id_slug_check"); expect(migration).toContain("^[a-z0-9][a-z0-9._-]{0,127}$"); expect(migration).toContain("pg_constraint"); expect(migration).toContain("att.attname = 'last_model_id'"); expect(migration).toContain("att.attname = 'model_id'"); expect(migration).not.toMatch(/UPDATE\s+(users|conversations)/i); for (const legacy of ["chatgpt", "deepseek", "qwen"]) { expect(/^[a-z0-9][a-z0-9._-]{0,127}$/.test(legacy)).toBe(true); } }); it("adds opaque Gate file IDs while retaining legacy object storage rows", async () => { const database = new RecordingDatabase(); await applyMigrations(database, join(process.cwd(), "db", "migrations")); const migration = database.queries.find((query) => query.text.includes("chat_attachments_gate_file_id_key"), )?.text; expect(migration).toBeDefined(); expect(migration).toContain("ADD COLUMN gate_file_id text"); expect(migration).toContain("ALTER COLUMN object_key DROP NOT NULL"); expect(migration).toContain("UNIQUE (gate_file_id)"); expect(migration).toContain("gate_file_id IS NOT NULL OR object_key IS NOT NULL"); expect(migration).not.toMatch(/DROP\s+COLUMN\s+object_key/i); expect(migration).not.toMatch(/DELETE\s+FROM\s+chat_attachments/i); }); }); class RecordingDatabase { applied = new Set(); queries: Array<{ text: string; values?: readonly unknown[] }> = []; async query(text: string, values?: readonly unknown[]) { this.queries.push({ text, values }); if (text.includes("SELECT name FROM schema_migrations")) { return { rows: [...this.applied].map((name) => ({ name })) } as { rows: T[] }; } if (text.includes("INSERT INTO schema_migrations") && typeof values?.[0] === "string") { this.applied.add(values[0]); } return { rows: [] } as { rows: T[] }; } }