From 5db7fc8afd2004ed6d91649601ef1e17d4fcd249 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 8 Aug 2026 18:17:17 -0700 Subject: [PATCH] Harden migration preflight and startup adoption checks --- README.md | 8 +- docker-entrypoint.sh | 1 + scripts/migration-preflight.mjs | 146 ++++++++++++++++++++----------- tests/migrationPreflight.test.ts | 127 ++++++++++++++++++++++++++- 4 files changed, 228 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 17eac58..459a4a2 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,18 @@ The material-group preflight reports one of four states: - `APPLY`: migration history is complete and the group schema is absent; run `prisma migrate deploy` normally. - `ADOPT`: prior migrations are tracked and the database exactly matches the - intended group schema. After reviewing the verified backup, explicitly run + intended schema of one or more unrecorded committed migrations. This includes + recovery from P3018 when an already schema-pushed table or column exists. + After reviewing the verified backup, explicitly run `npm run db:preflight -- --resolve --backup `. - `CURRENT`: migration and schema already agree. - `CONFLICT`: partial or unknown state. Stop and recover manually; do not use `db push`, reset, edit applied migrations, or mark anything applied. +The production entrypoint runs this preflight before `prisma migrate deploy`. +It refuses `ADOPT` and `CONFLICT` states without changing migration history; +adoption always remains an explicit backup-gated operator action. + Restore drill: stop Study Desk, keep the damaged database as evidence, restore the verified backup to a new path, run SQLite `integrity_check` plus `npm run db:preflight`, start the same prior application image against the diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0678c2c..dcba759 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -8,5 +8,6 @@ if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SE exit 1 fi +node scripts/migration-preflight.mjs --startup ./node_modules/.bin/prisma migrate deploy exec node server.js diff --git a/scripts/migration-preflight.mjs b/scripts/migration-preflight.mjs index a058181..4a762e8 100644 --- a/scripts/migration-preflight.mjs +++ b/scripts/migration-preflight.mjs @@ -38,10 +38,14 @@ function hasUniqueIndex(database, table, indexName, column) { return columns.length === 1 && columns[0].name === column; } -function schemaSnapshot(database) { +function schemaSnapshot(database, includeMigrationTable = false) { const tables = database .prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_prisma_migrations' ORDER BY name" + `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); @@ -113,14 +117,20 @@ export function inspectMigrationState(databasePath) { } const database = new Database(databasePath, { readonly: true, fileMustExist: true }); try { - const migrationNames = tableExists(database, "_prisma_migrations") + const migrationRecords = tableExists(database, "_prisma_migrations") ? database .prepare( - 'SELECT "migration_name" FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL' + 'SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations"' ) .all() - .map((row) => row.migration_name) : []; + 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 } @@ -131,9 +141,7 @@ export function inspectMigrationState(databasePath) { const priorMigrations = committedMigrations.filter( (name) => name < GROUP_MIGRATION ); - const laterMigrations = committedMigrations.filter( - (name) => name > GROUP_MIGRATION - ); + const groupMigrationIndex = committedMigrations.indexOf(GROUP_MIGRATION); const artifacts = { materialGroup: tableExists(database, "MaterialGroup"), @@ -168,19 +176,12 @@ export function inspectMigrationState(databasePath) { "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 historyHasUnknown = activeRecords.some( + (row) => !knownMigrationNames.has(row.migration_name) ); const actualSchema = schemaSnapshot(database); const matches = (expected) => @@ -189,42 +190,66 @@ export function inspectMigrationState(databasePath) { 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 = + let historyPrefixLength = 0; + while ( + historyPrefixLength < committedMigrations.length && + migrationNames.includes(committedMigrations[historyPrefixLength]) + ) { + historyPrefixLength += 1; + } + const historyIsExactPrefix = !historyHasUnknown && - migrationNames.length === appliedPrefix.length && - migrationNames.every((name) => appliedPrefix.includes(name)); + migrationNames.length === historyPrefixLength && + migrationNames.every((name) => + committedMigrations.slice(0, historyPrefixLength).includes(name) + ); const trackedSchemaExact = - appliedHistoryExact && matches(expectedSchemaSnapshot(appliedPrefix)); + 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 (groupMigrationApplied && trackedSchemaExact) classification = "CURRENT"; - else if ( - !groupMigrationApplied && - !laterHistoryPresent && + if ( + historyIsExactPrefix && + failedMigrationNames.length === 0 && + schemaPrefixLength === historyPrefixLength + ) { + classification = + historyPrefixLength === committedMigrations.length ? "CURRENT" : "APPLY"; + } else if ( + historyIsExactPrefix && priorHistoryComplete && - migrationNames.length === priorMigrations.length && - groupArtifactsAbsent && - preGroupSchemaExact - ) classification = "APPLY"; - else if ( - !groupMigrationApplied && - !laterHistoryPresent && - priorHistoryComplete && - migrationNames.length === priorMigrations.length && - groupArtifactsPresent && - adoptedGroupSchemaExact - ) classification = "ADOPT"; + 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, @@ -249,17 +274,20 @@ function verifyBackup(backupPath, sourcePath) { 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))) { + 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).map(({ table }) => [ + schemaSnapshot(source, true).map(({ table }) => [ table, source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, ]) ); const backupCounts = Object.fromEntries( - schemaSnapshot(backup).map(({ table }) => [ + schemaSnapshot(backup, true).map(({ table }) => [ table, backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, ]) @@ -273,11 +301,11 @@ function verifyBackup(backupPath, sourcePath) { } } -function resolveMigration(databaseUrl) { +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", GROUP_MIGRATION], + [cli, "migrate", "resolve", "--applied", migrationName], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl }, @@ -292,7 +320,16 @@ function resolveMigration(databaseUrl) { if (import.meta.url === pathToFileURL(process.argv[1]).href) { const databaseUrl = process.env.DATABASE_URL; const databasePath = databasePathFromUrl(databaseUrl); - const state = inspectMigrationState(databasePath); + 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")) { @@ -304,8 +341,17 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) { backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined, databasePath ); - resolveMigration(databaseUrl); - console.info(`Marked ${GROUP_MIGRATION} applied after exact-schema preflight.`); + 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; } diff --git a/tests/migrationPreflight.test.ts b/tests/migrationPreflight.test.ts index f41f4cc..ddd768e 100644 --- a/tests/migrationPreflight.test.ts +++ b/tests/migrationPreflight.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { copyFileSync, readFileSync } from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; import { afterEach, describe, expect, it } from "vitest"; @@ -29,10 +29,10 @@ function create(options: { excludeGroup?: boolean } = {}) { return database; } -function inspect(database: DisposableDatabase) { +function inspect(database: DisposableDatabase, args: string[] = []) { const result = spawnSync( process.execPath, - [path.join(process.cwd(), "scripts", "migration-preflight.mjs")], + [path.join(process.cwd(), "scripts", "migration-preflight.mjs"), ...args], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: database.databaseUrl }, @@ -42,7 +42,29 @@ function inspect(database: DisposableDatabase) { 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); @@ -93,6 +115,105 @@ describe("material group migration preflight", () => { }); }); + 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);