import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { pathToFileURL } from "node:url"; import Database from "better-sqlite3"; import { databasePathFromUrl } from "./databasePath.mjs"; export const GROUP_MIGRATION = "20260807090000_add_material_groups"; function tableExists(database, table) { return Boolean( database .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") .get(table) ); } function hasColumn(database, table, column) { if (!tableExists(database, table)) return false; return database.pragma(`table_info(${table})`).some((item) => item.name === column); } function hasForeignKey(database, table, from, target, onDelete) { return database .pragma(`foreign_key_list(${table})`) .some( (item) => item.from === from && item.table === target && item.on_delete === onDelete ); } function hasUniqueIndex(database, table, indexName, column) { const index = database .pragma(`index_list(${table})`) .find((item) => item.name === indexName && item.unique === 1); if (!index) return false; const columns = database.pragma(`index_info(${indexName})`); return columns.length === 1 && columns[0].name === column; } function schemaSnapshot(database) { const tables = database .prepare( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_prisma_migrations' ORDER BY name" ) .all() .map((row) => row.name); return tables.map((table) => ({ table, columns: database.pragma(`table_info(${JSON.stringify(table)})`).map((column) => ({ name: column.name, type: column.type, notnull: column.notnull, defaultValue: column.dflt_value, primaryKey: column.pk, })), foreignKeys: database .pragma(`foreign_key_list(${JSON.stringify(table)})`) .map((foreignKey) => ({ id: foreignKey.id, sequence: foreignKey.seq, target: foreignKey.table, from: foreignKey.from, to: foreignKey.to, onUpdate: foreignKey.on_update, onDelete: foreignKey.on_delete, match: foreignKey.match, })) .sort((left, right) => left.id - right.id || left.sequence - right.sequence), indexes: database .pragma(`index_list(${JSON.stringify(table)})`) .filter((index) => !index.name.startsWith("sqlite_autoindex_")) .map((index) => ({ name: index.name, unique: index.unique, partial: index.partial, columns: database .pragma(`index_info(${JSON.stringify(index.name)})`) .map((column) => column.name), })) .sort((left, right) => left.name.localeCompare(right.name)), })); } function expectedSchemaSnapshot(migrationNames) { const database = new Database(":memory:"); try { database.pragma("foreign_keys = ON"); for (const migrationName of migrationNames) { database.exec( readFileSync( path.join( process.cwd(), "prisma", "migrations", migrationName, "migration.sql" ), "utf8" ) ); } return schemaSnapshot(database); } finally { database.close(); } } export function inspectMigrationState(databasePath) { if (!existsSync(databasePath)) { throw new Error(`Database does not exist: ${databasePath}`); } const database = new Database(databasePath, { readonly: true, fileMustExist: true }); try { const migrationNames = tableExists(database, "_prisma_migrations") ? database .prepare( 'SELECT "migration_name" FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL' ) .all() .map((row) => row.migration_name) : []; const committedMigrations = readdirSync( path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true } ) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(); const priorMigrations = committedMigrations.filter( (name) => name < GROUP_MIGRATION ); const laterMigrations = committedMigrations.filter( (name) => name > GROUP_MIGRATION ); const artifacts = { materialGroup: tableExists(database, "MaterialGroup"), deckColumn: hasColumn(database, "Deck", "groupId"), quizColumn: hasColumn(database, "QuizSet", "groupId"), shareColumn: hasColumn(database, "ShareLink", "groupId"), deckForeignKey: hasForeignKey( database, "Deck", "groupId", "MaterialGroup", "SET NULL" ), quizForeignKey: hasForeignKey( database, "QuizSet", "groupId", "MaterialGroup", "SET NULL" ), shareForeignKey: hasForeignKey( database, "ShareLink", "groupId", "MaterialGroup", "CASCADE" ), shareUnique: hasUniqueIndex( database, "ShareLink", "ShareLink_groupId_key", "groupId" ), }; const values = Object.values(artifacts); const groupArtifactsPresent = values.every(Boolean); const groupArtifactsAbsent = values.every((value) => !value); const groupMigrationApplied = migrationNames.includes(GROUP_MIGRATION); const priorHistoryComplete = priorMigrations.every((name) => migrationNames.includes(name) ); const knownMigrationNames = new Set(committedMigrations); const historyHasUnknown = migrationNames.some( (name) => !knownMigrationNames.has(name) ); const laterHistoryPresent = laterMigrations.some((name) => migrationNames.includes(name) ); const actualSchema = schemaSnapshot(database); const matches = (expected) => JSON.stringify(actualSchema) === JSON.stringify(expected); const preGroupSchemaExact = matches(expectedSchemaSnapshot(priorMigrations)); const adoptedGroupSchemaExact = matches( expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION]) ); const appliedPrefix = committedMigrations.filter((name, index) => committedMigrations.slice(0, index + 1).every((candidate) => migrationNames.includes(candidate) ) ); const appliedHistoryExact = !historyHasUnknown && migrationNames.length === appliedPrefix.length && migrationNames.every((name) => appliedPrefix.includes(name)); const trackedSchemaExact = appliedHistoryExact && matches(expectedSchemaSnapshot(appliedPrefix)); let classification = "CONFLICT"; if (groupMigrationApplied && trackedSchemaExact) classification = "CURRENT"; else if ( !groupMigrationApplied && !laterHistoryPresent && priorHistoryComplete && migrationNames.length === priorMigrations.length && groupArtifactsAbsent && preGroupSchemaExact ) classification = "APPLY"; else if ( !groupMigrationApplied && !laterHistoryPresent && priorHistoryComplete && migrationNames.length === priorMigrations.length && groupArtifactsPresent && adoptedGroupSchemaExact ) classification = "ADOPT"; return { classification, artifacts, migrationNames, priorHistoryComplete, exactSchema: { preGroup: preGroupSchemaExact, adoptedGroup: adoptedGroupSchemaExact, tracked: trackedSchemaExact, }, }; } finally { database.close(); } } function verifyBackup(backupPath, sourcePath) { if (!backupPath || !existsSync(backupPath)) { throw new Error("--resolve requires an existing verified --backup file"); } if (path.resolve(backupPath) === path.resolve(sourcePath)) { throw new Error("--backup must be a separate database file, not the source database"); } const backup = new Database(backupPath, { readonly: true, fileMustExist: true }); const source = new Database(sourcePath, { readonly: true, fileMustExist: true }); try { if (backup.pragma("integrity_check", { simple: true }) !== "ok") { throw new Error("Backup failed SQLite integrity_check"); } if (JSON.stringify(schemaSnapshot(backup)) !== JSON.stringify(schemaSnapshot(source))) { throw new Error("Backup schema does not match the source database"); } const sourceCounts = Object.fromEntries( schemaSnapshot(source).map(({ table }) => [ table, source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, ]) ); const backupCounts = Object.fromEntries( schemaSnapshot(backup).map(({ table }) => [ table, backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, ]) ); if (JSON.stringify(sourceCounts) !== JSON.stringify(backupCounts)) { throw new Error("Backup row counts do not match the source database"); } } finally { backup.close(); source.close(); } } function resolveMigration(databaseUrl) { const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js"); const result = spawnSync( process.execPath, [cli, "migrate", "resolve", "--applied", GROUP_MIGRATION], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl }, encoding: "utf8", } ); if (result.status !== 0) { throw new Error(`Prisma migration resolve failed:\n${result.stdout}\n${result.stderr}`); } } if (import.meta.url === pathToFileURL(process.argv[1]).href) { const databaseUrl = process.env.DATABASE_URL; const databasePath = databasePathFromUrl(databaseUrl); const state = inspectMigrationState(databasePath); console.info(JSON.stringify(state, null, 2)); if (process.argv.includes("--resolve")) { if (state.classification !== "ADOPT") { throw new Error(`Refusing adoption for ${state.classification} database state`); } const backupIndex = process.argv.indexOf("--backup"); verifyBackup( backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined, databasePath ); resolveMigration(databaseUrl); console.info(`Marked ${GROUP_MIGRATION} applied after exact-schema preflight.`); } else if (state.classification === "CONFLICT") { process.exitCode = 2; } }