Study/tests/migrationPreflight.test.ts
Elijah 5db7fc8afd
All checks were successful
Verify and publish container / build-and-push (push) Successful in 1m26s
Harden migration preflight and startup adoption checks
2026-08-08 18:17:17 -07:00

272 lines
8.8 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { copyFileSync, readFileSync } from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { afterEach, describe, expect, it } from "vitest";
import {
applyCommittedMigrations,
createDisposableDatabase,
disposeDisposableDatabase,
type DisposableDatabase,
} from "./helpers/testDatabase";
const GROUP_MIGRATION = "20260807090000_add_material_groups";
const databases: DisposableDatabase[] = [];
afterEach(() => {
for (const database of databases.splice(0)) {
disposeDisposableDatabase(database);
}
});
function create(options: { excludeGroup?: boolean } = {}) {
const database = createDisposableDatabase();
databases.push(database);
applyCommittedMigrations(
database,
options.excludeGroup ? { through: "20260714010000_add_arcade" } : {}
);
return database;
}
function inspect(database: DisposableDatabase, args: string[] = []) {
const result = spawnSync(
process.execPath,
[path.join(process.cwd(), "scripts", "migration-preflight.mjs"), ...args],
{
cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: database.databaseUrl },
encoding: "utf8",
}
);
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);
try {
client.pragma("foreign_keys = ON");
client.prepare('INSERT INTO "Class" ("id", "slug", "name") VALUES (?, ?, ?)').run("class-1", "class", "Class");
client.prepare('INSERT INTO "Deck" ("id", "classId", "name") VALUES (?, ?, ?)').run("deck-1", "class-1", "Deck");
client.prepare('INSERT INTO "QuizSet" ("id", "classId", "name") VALUES (?, ?, ?)').run("quiz-1", "class-1", "Quiz");
client.prepare('INSERT INTO "ShareLink" ("id", "targetType", "deckId") VALUES (?, ?, ?)').run("share-1", "DECK", "deck-1");
client.exec(readFileSync(
path.join(process.cwd(), "prisma", "migrations", GROUP_MIGRATION, "migration.sql"),
"utf8"
));
expect(client.prepare('SELECT COUNT(*) AS count FROM "Deck"').get()).toEqual({ count: 1 });
expect(client.prepare('SELECT COUNT(*) AS count FROM "QuizSet"').get()).toEqual({ count: 1 });
expect(client.prepare('SELECT "deckId", "groupId" FROM "ShareLink" WHERE "id" = ?').get("share-1")).toEqual({
deckId: "deck-1",
groupId: null,
});
expect(client.pragma("foreign_key_check")).toEqual([]);
} finally {
client.close();
}
});
it("classifies a tracked pre-group database for normal apply", () => {
expect(inspect(create({ excludeGroup: true }))).toMatchObject({
status: 0,
output: { classification: "APPLY", priorHistoryComplete: true },
});
});
it("classifies an exact schema-pushed database for explicit adoption", () => {
const database = create({ excludeGroup: true });
const client = new Database(database.databasePath);
client.exec(readFileSync(
path.join(process.cwd(), "prisma", "migrations", GROUP_MIGRATION, "migration.sql"),
"utf8"
));
client.close();
expect(inspect(database)).toMatchObject({
status: 0,
output: {
classification: "ADOPT",
priorHistoryComplete: true,
exactSchema: { adoptedGroup: true },
},
});
});
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);
client.exec('DROP INDEX "ShareLink_groupId_key"');
const before = client
.prepare("SELECT COUNT(*) AS count FROM _prisma_migrations")
.get() as { count: number };
client.close();
expect(inspect(database)).toMatchObject({
status: 2,
output: { classification: "CONFLICT" },
});
const verification = new Database(database.databasePath, { readonly: true });
const after = verification
.prepare("SELECT COUNT(*) AS count FROM _prisma_migrations")
.get() as { count: number };
verification.close();
expect(after.count).toBe(before.count);
});
it("does not accept the source database itself as the adoption backup", () => {
const database = create({ excludeGroup: true });
const client = new Database(database.databasePath);
client.exec(readFileSync(
path.join(process.cwd(), "prisma", "migrations", GROUP_MIGRATION, "migration.sql"),
"utf8"
));
client.close();
const result = spawnSync(
process.execPath,
[
path.join(process.cwd(), "scripts", "migration-preflight.mjs"),
"--resolve",
"--backup",
database.databasePath,
],
{
cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: database.databaseUrl },
encoding: "utf8",
}
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("separate database file");
});
it("recognizes an already applied exact schema", () => {
expect(inspect(create())).toMatchObject({
status: 0,
output: { classification: "CURRENT" },
});
});
});