import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; import { afterEach, describe, expect, it } from "vitest"; import { applyCommittedMigrations, createDisposableDatabase, disposeDisposableDatabase, type DisposableDatabase, } from "./helpers/testDatabase"; const GROUP_MIGRATION = "20260807090000_add_material_groups"; const databases: DisposableDatabase[] = []; afterEach(() => { for (const database of databases.splice(0)) { disposeDisposableDatabase(database); } }); function create(options: { excludeGroup?: boolean } = {}) { const database = createDisposableDatabase(); databases.push(database); applyCommittedMigrations( database, options.excludeGroup ? { through: "20260714010000_add_arcade" } : {} ); return database; } function inspect(database: DisposableDatabase) { const result = spawnSync( process.execPath, [path.join(process.cwd(), "scripts", "migration-preflight.mjs")], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: database.databaseUrl }, encoding: "utf8", } ); return { status: result.status, output: JSON.parse(result.stdout) }; } describe("material group migration preflight", () => { it("preserves populated pre-group rows and relationships", () => { const database = create({ excludeGroup: true }); const client = new Database(database.databasePath); try { client.pragma("foreign_keys = ON"); client.prepare('INSERT INTO "Class" ("id", "slug", "name") VALUES (?, ?, ?)').run("class-1", "class", "Class"); client.prepare('INSERT INTO "Deck" ("id", "classId", "name") VALUES (?, ?, ?)').run("deck-1", "class-1", "Deck"); client.prepare('INSERT INTO "QuizSet" ("id", "classId", "name") VALUES (?, ?, ?)').run("quiz-1", "class-1", "Quiz"); client.prepare('INSERT INTO "ShareLink" ("id", "targetType", "deckId") VALUES (?, ?, ?)').run("share-1", "DECK", "deck-1"); client.exec(readFileSync( path.join(process.cwd(), "prisma", "migrations", GROUP_MIGRATION, "migration.sql"), "utf8" )); expect(client.prepare('SELECT COUNT(*) AS count FROM "Deck"').get()).toEqual({ count: 1 }); expect(client.prepare('SELECT COUNT(*) AS count FROM "QuizSet"').get()).toEqual({ count: 1 }); expect(client.prepare('SELECT "deckId", "groupId" FROM "ShareLink" WHERE "id" = ?').get("share-1")).toEqual({ deckId: "deck-1", groupId: null, }); expect(client.pragma("foreign_key_check")).toEqual([]); } finally { client.close(); } }); it("classifies a tracked pre-group database for normal apply", () => { expect(inspect(create({ excludeGroup: true }))).toMatchObject({ status: 0, output: { classification: "APPLY", priorHistoryComplete: true }, }); }); it("classifies an exact schema-pushed database for explicit adoption", () => { const database = create({ excludeGroup: true }); const client = new Database(database.databasePath); client.exec(readFileSync( path.join(process.cwd(), "prisma", "migrations", GROUP_MIGRATION, "migration.sql"), "utf8" )); client.close(); expect(inspect(database)).toMatchObject({ status: 0, output: { classification: "ADOPT", priorHistoryComplete: true, exactSchema: { adoptedGroup: true }, }, }); }); it("rejects a partial schema without modifying it", () => { const database = create(); const client = new Database(database.databasePath); client.exec('DROP INDEX "ShareLink_groupId_key"'); const before = client .prepare("SELECT COUNT(*) AS count FROM _prisma_migrations") .get() as { count: number }; client.close(); expect(inspect(database)).toMatchObject({ status: 2, output: { classification: "CONFLICT" }, }); const verification = new Database(database.databasePath, { readonly: true }); const after = verification .prepare("SELECT COUNT(*) AS count FROM _prisma_migrations") .get() as { count: number }; verification.close(); expect(after.count).toBe(before.count); }); it("does not accept the source database itself as the adoption backup", () => { const database = create({ excludeGroup: true }); const client = new Database(database.databasePath); client.exec(readFileSync( path.join(process.cwd(), "prisma", "migrations", GROUP_MIGRATION, "migration.sql"), "utf8" )); client.close(); const result = spawnSync( process.execPath, [ path.join(process.cwd(), "scripts", "migration-preflight.mjs"), "--resolve", "--backup", database.databasePath, ], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: database.databaseUrl }, encoding: "utf8", } ); expect(result.status).not.toBe(0); expect(result.stderr).toContain("separate database file"); }); it("recognizes an already applied exact schema", () => { expect(inspect(create())).toMatchObject({ status: 0, output: { classification: "CURRENT" }, }); }); });