import { spawnSync } from "node:child_process"; import { copyFileSync, 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, args: string[] = []) { const result = spawnSync( process.execPath, [path.join(process.cwd(), "scripts", "migration-preflight.mjs"), ...args], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: database.databaseUrl }, encoding: "utf8", } ); return { status: result.status, output: JSON.parse(result.stdout) }; } function migrationSql(migrationName: string) { return readFileSync( path.join( process.cwd(), "prisma", "migrations", migrationName, "migration.sql" ), "utf8" ); } describe("material group migration preflight", () => { it("allows startup when a fresh database does not exist yet", () => { const database = createDisposableDatabase(); databases.push(database); expect(inspect(database, ["--startup"])).toMatchObject({ status: 0, output: { classification: "EMPTY", migrationsToAdopt: [] }, }); }); 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("blocks startup and explicitly adopts a fully schema-pushed database after P3018", () => { const database = create({ excludeGroup: true }); const missingMigrations = [ GROUP_MIGRATION, "20260807091000_add_quiz_attempt_snapshot", "20260807092000_add_progress_revisions", ]; const client = new Database(database.databasePath); try { for (const migrationName of missingMigrations) { client.exec(migrationSql(migrationName)); } client.prepare(` INSERT INTO "_prisma_migrations" ("id", "checksum", "finished_at", "migration_name", "logs", "applied_steps_count") VALUES (?, ?, NULL, ?, ?, 0) `).run("failed-group-migration", "failed", GROUP_MIGRATION, "P3018"); } finally { client.close(); } const startup = inspect(database, ["--startup"]); expect(startup).toMatchObject({ status: 3, output: { classification: "ADOPT", migrationsToAdopt: missingMigrations, }, }); const backupPath = path.join(database.directory, "adoption-backup.test.db"); copyFileSync(database.databasePath, backupPath); const resolve = spawnSync( process.execPath, [ path.join(process.cwd(), "scripts", "migration-preflight.mjs"), "--resolve", "--backup", backupPath, ], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: database.databaseUrl }, encoding: "utf8", } ); expect(resolve.status, resolve.stderr).toBe(0); const deploy = spawnSync( process.execPath, [ path.join(process.cwd(), "node_modules", "prisma", "build", "index.js"), "migrate", "deploy", ], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: database.databaseUrl }, encoding: "utf8", } ); expect(deploy.status, `${deploy.stdout}\n${deploy.stderr}`).toBe(0); expect(inspect(database)).toMatchObject({ status: 0, output: { classification: "CURRENT", migrationsToAdopt: [] }, }); }, 30_000); it("can resume exact adoption after some missing migrations were recorded", () => { const database = create({ excludeGroup: true }); const client = new Database(database.databasePath); try { for (const migrationName of [ GROUP_MIGRATION, "20260807091000_add_quiz_attempt_snapshot", "20260807092000_add_progress_revisions", ]) { client.exec(migrationSql(migrationName)); } client.prepare(` INSERT INTO "_prisma_migrations" ("id", "checksum", "finished_at", "migration_name", "applied_steps_count") VALUES (?, ?, current_timestamp, ?, 1) `).run("adopted-group-migration", "adopted", GROUP_MIGRATION); } finally { client.close(); } expect(inspect(database, ["--startup"])).toMatchObject({ status: 3, output: { classification: "ADOPT", migrationsToAdopt: [ "20260807091000_add_quiz_attempt_snapshot", "20260807092000_add_progress_revisions", ], }, }); }); 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" }, }); }); });