Harden migration preflight and startup adoption checks
All checks were successful
Verify and publish container / build-and-push (push) Successful in 1m26s

This commit is contained in:
Elijah 2026-08-08 18:17:17 -07:00
parent 931e674814
commit 5db7fc8afd
4 changed files with 228 additions and 54 deletions

View file

@ -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 - `APPLY`: migration history is complete and the group schema is absent; run
`prisma migrate deploy` normally. `prisma migrate deploy` normally.
- `ADOPT`: prior migrations are tracked and the database exactly matches the - `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 <backup-path>`. `npm run db:preflight -- --resolve --backup <backup-path>`.
- `CURRENT`: migration and schema already agree. - `CURRENT`: migration and schema already agree.
- `CONFLICT`: partial or unknown state. Stop and recover manually; do not use - `CONFLICT`: partial or unknown state. Stop and recover manually; do not use
`db push`, reset, edit applied migrations, or mark anything applied. `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 Restore drill: stop Study Desk, keep the damaged database as evidence, restore
the verified backup to a new path, run SQLite `integrity_check` plus the verified backup to a new path, run SQLite `integrity_check` plus
`npm run db:preflight`, start the same prior application image against the `npm run db:preflight`, start the same prior application image against the

View file

@ -8,5 +8,6 @@ if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SE
exit 1 exit 1
fi fi
node scripts/migration-preflight.mjs --startup
./node_modules/.bin/prisma migrate deploy ./node_modules/.bin/prisma migrate deploy
exec node server.js exec node server.js

View file

@ -38,10 +38,14 @@ function hasUniqueIndex(database, table, indexName, column) {
return columns.length === 1 && columns[0].name === column; return columns.length === 1 && columns[0].name === column;
} }
function schemaSnapshot(database) { function schemaSnapshot(database, includeMigrationTable = false) {
const tables = database const tables = database
.prepare( .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() .all()
.map((row) => row.name); .map((row) => row.name);
@ -113,14 +117,20 @@ export function inspectMigrationState(databasePath) {
} }
const database = new Database(databasePath, { readonly: true, fileMustExist: true }); const database = new Database(databasePath, { readonly: true, fileMustExist: true });
try { try {
const migrationNames = tableExists(database, "_prisma_migrations") const migrationRecords = tableExists(database, "_prisma_migrations")
? database ? database
.prepare( .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() .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( const committedMigrations = readdirSync(
path.join(process.cwd(), "prisma", "migrations"), path.join(process.cwd(), "prisma", "migrations"),
{ withFileTypes: true } { withFileTypes: true }
@ -131,9 +141,7 @@ export function inspectMigrationState(databasePath) {
const priorMigrations = committedMigrations.filter( const priorMigrations = committedMigrations.filter(
(name) => name < GROUP_MIGRATION (name) => name < GROUP_MIGRATION
); );
const laterMigrations = committedMigrations.filter( const groupMigrationIndex = committedMigrations.indexOf(GROUP_MIGRATION);
(name) => name > GROUP_MIGRATION
);
const artifacts = { const artifacts = {
materialGroup: tableExists(database, "MaterialGroup"), materialGroup: tableExists(database, "MaterialGroup"),
@ -168,19 +176,12 @@ export function inspectMigrationState(databasePath) {
"groupId" "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) => const priorHistoryComplete = priorMigrations.every((name) =>
migrationNames.includes(name) migrationNames.includes(name)
); );
const knownMigrationNames = new Set(committedMigrations); const knownMigrationNames = new Set(committedMigrations);
const historyHasUnknown = migrationNames.some( const historyHasUnknown = activeRecords.some(
(name) => !knownMigrationNames.has(name) (row) => !knownMigrationNames.has(row.migration_name)
);
const laterHistoryPresent = laterMigrations.some((name) =>
migrationNames.includes(name)
); );
const actualSchema = schemaSnapshot(database); const actualSchema = schemaSnapshot(database);
const matches = (expected) => const matches = (expected) =>
@ -189,42 +190,66 @@ export function inspectMigrationState(databasePath) {
const adoptedGroupSchemaExact = matches( const adoptedGroupSchemaExact = matches(
expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION]) expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION])
); );
const appliedPrefix = committedMigrations.filter((name, index) => let historyPrefixLength = 0;
committedMigrations.slice(0, index + 1).every((candidate) => while (
migrationNames.includes(candidate) historyPrefixLength < committedMigrations.length &&
) migrationNames.includes(committedMigrations[historyPrefixLength])
); ) {
const appliedHistoryExact = historyPrefixLength += 1;
}
const historyIsExactPrefix =
!historyHasUnknown && !historyHasUnknown &&
migrationNames.length === appliedPrefix.length && migrationNames.length === historyPrefixLength &&
migrationNames.every((name) => appliedPrefix.includes(name)); migrationNames.every((name) =>
committedMigrations.slice(0, historyPrefixLength).includes(name)
);
const trackedSchemaExact = 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"; let classification = "CONFLICT";
if (groupMigrationApplied && trackedSchemaExact) classification = "CURRENT"; if (
else if ( historyIsExactPrefix &&
!groupMigrationApplied && failedMigrationNames.length === 0 &&
!laterHistoryPresent && schemaPrefixLength === historyPrefixLength
) {
classification =
historyPrefixLength === committedMigrations.length ? "CURRENT" : "APPLY";
} else if (
historyIsExactPrefix &&
priorHistoryComplete && priorHistoryComplete &&
migrationNames.length === priorMigrations.length && historyPrefixLength >= groupMigrationIndex &&
groupArtifactsAbsent && schemaPrefixLength > historyPrefixLength &&
preGroupSchemaExact failuresAreAdoptable
) classification = "APPLY"; ) {
else if ( classification = "ADOPT";
!groupMigrationApplied && }
!laterHistoryPresent &&
priorHistoryComplete &&
migrationNames.length === priorMigrations.length &&
groupArtifactsPresent &&
adoptedGroupSchemaExact
) classification = "ADOPT";
return { return {
classification, classification,
artifacts, artifacts,
migrationNames, migrationNames,
failedMigrationNames,
migrationsToAdopt,
priorHistoryComplete, priorHistoryComplete,
schemaMigrationPrefix:
schemaPrefixLength >= 0
? committedMigrations[schemaPrefixLength - 1] ?? null
: null,
exactSchema: { exactSchema: {
preGroup: preGroupSchemaExact, preGroup: preGroupSchemaExact,
adoptedGroup: adoptedGroupSchemaExact, adoptedGroup: adoptedGroupSchemaExact,
@ -249,17 +274,20 @@ function verifyBackup(backupPath, sourcePath) {
if (backup.pragma("integrity_check", { simple: true }) !== "ok") { if (backup.pragma("integrity_check", { simple: true }) !== "ok") {
throw new Error("Backup failed SQLite integrity_check"); 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"); throw new Error("Backup schema does not match the source database");
} }
const sourceCounts = Object.fromEntries( const sourceCounts = Object.fromEntries(
schemaSnapshot(source).map(({ table }) => [ schemaSnapshot(source, true).map(({ table }) => [
table, table,
source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
]) ])
); );
const backupCounts = Object.fromEntries( const backupCounts = Object.fromEntries(
schemaSnapshot(backup).map(({ table }) => [ schemaSnapshot(backup, true).map(({ table }) => [
table, table,
backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, 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 cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js");
const result = spawnSync( const result = spawnSync(
process.execPath, process.execPath,
[cli, "migrate", "resolve", "--applied", GROUP_MIGRATION], [cli, "migrate", "resolve", "--applied", migrationName],
{ {
cwd: process.cwd(), cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: databaseUrl }, env: { ...process.env, DATABASE_URL: databaseUrl },
@ -292,7 +320,16 @@ function resolveMigration(databaseUrl) {
if (import.meta.url === pathToFileURL(process.argv[1]).href) { if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
const databasePath = databasePathFromUrl(databaseUrl); 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)); console.info(JSON.stringify(state, null, 2));
if (process.argv.includes("--resolve")) { 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, backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined,
databasePath databasePath
); );
resolveMigration(databaseUrl); for (const migrationName of state.migrationsToAdopt) {
console.info(`Marked ${GROUP_MIGRATION} applied after exact-schema preflight.`); 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 <backup-path>."
);
process.exitCode = 3;
} else if (state.classification === "CONFLICT") { } else if (state.classification === "CONFLICT") {
process.exitCode = 2; process.exitCode = 2;
} }

View file

@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs"; import { copyFileSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@ -29,10 +29,10 @@ function create(options: { excludeGroup?: boolean } = {}) {
return database; return database;
} }
function inspect(database: DisposableDatabase) { function inspect(database: DisposableDatabase, args: string[] = []) {
const result = spawnSync( const result = spawnSync(
process.execPath, process.execPath,
[path.join(process.cwd(), "scripts", "migration-preflight.mjs")], [path.join(process.cwd(), "scripts", "migration-preflight.mjs"), ...args],
{ {
cwd: process.cwd(), cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: database.databaseUrl }, env: { ...process.env, DATABASE_URL: database.databaseUrl },
@ -42,7 +42,29 @@ function inspect(database: DisposableDatabase) {
return { status: result.status, output: JSON.parse(result.stdout) }; 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", () => { 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", () => { it("preserves populated pre-group rows and relationships", () => {
const database = create({ excludeGroup: true }); const database = create({ excludeGroup: true });
const client = new Database(database.databasePath); 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", () => { it("rejects a partial schema without modifying it", () => {
const database = create(); const database = create();
const client = new Database(database.databasePath); const client = new Database(database.databasePath);