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

@ -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);