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, includeMigrationTable = false) { const tables = database .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ${includeMigrationTable ? "" : "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 migrationRecords = tableExists(database, "_prisma_migrations") ? database .prepare( 'SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations"' ) .all() : []; const activeRecords = migrationRecords.filter((row) => row.rolled_back_at === null); const migrationNames = activeRecords .filter((row) => row.finished_at !== null) .map((row) => row.migration_name); const failedMigrationNames = activeRecords .filter((row) => row.finished_at === null) .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 groupMigrationIndex = committedMigrations.indexOf(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 priorHistoryComplete = priorMigrations.every((name) => migrationNames.includes(name) ); const knownMigrationNames = new Set(committedMigrations); const historyHasUnknown = activeRecords.some( (row) => !knownMigrationNames.has(row.migration_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]) ); let historyPrefixLength = 0; while ( historyPrefixLength < committedMigrations.length && migrationNames.includes(committedMigrations[historyPrefixLength]) ) { historyPrefixLength += 1; } const historyIsExactPrefix = !historyHasUnknown && migrationNames.length === historyPrefixLength && migrationNames.every((name) => committedMigrations.slice(0, historyPrefixLength).includes(name) ); const trackedSchemaExact = historyIsExactPrefix && matches(expectedSchemaSnapshot(committedMigrations.slice(0, historyPrefixLength))); let schemaPrefixLength = -1; for (let index = 0; index <= committedMigrations.length; index += 1) { if (matches(expectedSchemaSnapshot(committedMigrations.slice(0, index)))) { schemaPrefixLength = index; break; } } const migrationsToAdopt = schemaPrefixLength > historyPrefixLength ? committedMigrations.slice(historyPrefixLength, schemaPrefixLength) : []; const failuresAreAdoptable = failedMigrationNames.every((name) => migrationsToAdopt.includes(name) ); let classification = "CONFLICT"; if ( historyIsExactPrefix && failedMigrationNames.length === 0 && schemaPrefixLength === historyPrefixLength ) { classification = historyPrefixLength === committedMigrations.length ? "CURRENT" : "APPLY"; } else if ( historyIsExactPrefix && priorHistoryComplete && historyPrefixLength >= groupMigrationIndex && schemaPrefixLength > historyPrefixLength && failuresAreAdoptable ) { classification = "ADOPT"; } return { classification, artifacts, migrationNames, failedMigrationNames, migrationsToAdopt, priorHistoryComplete, schemaMigrationPrefix: schemaPrefixLength >= 0 ? committedMigrations[schemaPrefixLength - 1] ?? null : null, 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, true)) !== JSON.stringify(schemaSnapshot(source, true)) ) { throw new Error("Backup schema does not match the source database"); } const sourceCounts = Object.fromEntries( schemaSnapshot(source, true).map(({ table }) => [ table, source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, ]) ); const backupCounts = Object.fromEntries( schemaSnapshot(backup, true).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, migrationName) { const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js"); const result = spawnSync( process.execPath, [cli, "migrate", "resolve", "--applied", migrationName], { 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 startup = process.argv.includes("--startup"); const state = startup && !existsSync(databasePath) ? { classification: "EMPTY", migrationNames: [], failedMigrationNames: [], migrationsToAdopt: [], } : 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 ); for (const migrationName of state.migrationsToAdopt) { resolveMigration(databaseUrl, migrationName); } console.info( `Marked ${state.migrationsToAdopt.join(", ")} applied after exact-schema preflight.` ); } else if (startup && state.classification === "ADOPT") { console.error( "Database schema requires explicit migration adoption. Stop, create and verify a backup, then run db:preflight -- --resolve --backup ." ); process.exitCode = 3; } else if (state.classification === "CONFLICT") { process.exitCode = 2; } }