feat: add transactional module migration runner
CI / verify (pull_request) Successful in 19s
CI / infrastructure-smoke (pull_request) Successful in 20s

This commit is contained in:
ekklesia
2026-07-21 08:52:41 -05:00
parent dc37e59035
commit 0b282865cb
22 changed files with 2589 additions and 47 deletions
+943
View File
@@ -0,0 +1,943 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
applyModuleMigrations,
type AppliedMigrationRecord,
type MigrationApproval,
type MigrationApprovalProvider,
type MigrationDatabase,
type MigrationDatabaseSession,
type MigrationHistoryInsert,
MigrationRunnerError,
type MigrationRunnerErrorCode,
type MigrationTransaction,
planModuleMigrations,
} from "../src/migrations/runner.js";
import type {
DiscoveredModuleMigration,
ModuleMigrationAggregate,
} from "../src/modules/migration-discovery.js";
const appliedAt = "2026-07-21T12:00:00.000Z";
const hostileDatabaseError = new Error(
'postgresql://admin:[email protected]/ekklesia failed at /home/ekklesia/private.sql while running DROP TABLE "people_records"',
);
interface MigrationFixtureOptions {
readonly approvalRequired?: boolean;
readonly sql?: string;
readonly tag?: string;
}
interface ModuleFixture {
readonly id: string;
readonly migrations: readonly DiscoveredModuleMigration[];
}
function migration(
moduleId: string,
index: number,
options: MigrationFixtureOptions = {},
): DiscoveredModuleMigration {
const tag = options.tag ?? `${index.toString().padStart(4, "0")}_change`;
const sql = options.sql ?? `SELECT ${index + 1};\n`;
const approvalRequired = options.approvalRequired ?? false;
return Object.freeze({
approvalRequired,
breakpoints: true,
checksum: createHash("sha256").update(sql).digest("hex"),
destructive: Object.freeze(
approvalRequired
? {
mode: "approval-required" as const,
reason: "An administrator must approve this destructive fixture migration.",
}
: { mode: "none" as const },
),
file: `${tag}.sql`,
identity: `${moduleId}:${tag}`,
index,
moduleId,
sql,
tag,
timestamp: 1_700_000_000_000 + index,
});
}
function aggregate(...modules: readonly ModuleFixture[]): ModuleMigrationAggregate {
const frozenModules = Object.freeze(
modules.map((module) =>
Object.freeze({
migrations: Object.freeze([...module.migrations]),
moduleId: module.id,
}),
),
);
return Object.freeze({
discoveredModules: Object.freeze([]),
migrations: Object.freeze(frozenModules.flatMap((module) => module.migrations)),
modules: frozenModules,
});
}
function historyRecord(
source: DiscoveredModuleMigration,
appliedOrder: number,
overrides: Partial<AppliedMigrationRecord> = {},
): AppliedMigrationRecord {
return Object.freeze({
appliedAt,
appliedOrder,
checksum: source.checksum,
identity: source.identity,
index: source.index,
moduleId: source.moduleId,
tag: source.tag,
...overrides,
});
}
type FailurePoint =
| "close"
| "commit"
| "ensure-history"
| "execute"
| "history-insert"
| "lock"
| "open"
| "read-history"
| "release-lock";
interface FakeDatabaseOptions {
readonly failures?: readonly FailurePoint[];
readonly history?: readonly AppliedMigrationRecord[];
readonly historyAfterLock?: readonly AppliedMigrationRecord[];
readonly historyTableExists?: boolean;
readonly lockResult?: boolean;
readonly statementFailureIndex?: number;
}
class FakeMigrationDatabase implements MigrationDatabase {
readonly events: string[] = [];
readonly executedStatements: string[] = [];
readonly failures: ReadonlySet<FailurePoint>;
readonly historyAfterLock: readonly AppliedMigrationRecord[] | undefined;
readonly insertedMigrations: MigrationHistoryInsert[] = [];
readonly lockResult: boolean;
readonly statementFailureIndex: number | undefined;
committedHistory: AppliedMigrationRecord[];
historyTableExists: boolean;
constructor(options: FakeDatabaseOptions = {}) {
this.committedHistory = [...(options.history ?? [])];
this.failures = new Set(options.failures ?? []);
this.historyAfterLock = options.historyAfterLock;
this.historyTableExists = options.historyTableExists ?? true;
this.lockResult = options.lockResult ?? true;
this.statementFailureIndex = options.statementFailureIndex;
}
async openSession(): Promise<MigrationDatabaseSession> {
this.events.push("session:open");
if (this.failures.has("open")) {
throw hostileDatabaseError;
}
return new FakeMigrationDatabaseSession(this);
}
}
class FakeMigrationDatabaseSession implements MigrationDatabaseSession {
readonly #database: FakeMigrationDatabase;
#statementCount = 0;
constructor(database: FakeMigrationDatabase) {
this.#database = database;
}
async acquireMigrationLock(timeoutMs: number): Promise<boolean> {
this.#database.events.push(`lock:acquire:${timeoutMs}`);
if (this.#database.failures.has("lock")) {
throw hostileDatabaseError;
}
if (this.#database.lockResult && this.#database.historyAfterLock) {
this.#database.committedHistory = [...this.#database.historyAfterLock];
}
return this.#database.lockResult;
}
async close(): Promise<void> {
this.#database.events.push("session:close");
if (this.#database.failures.has("close")) {
throw hostileDatabaseError;
}
}
async readAppliedMigrations(): Promise<readonly AppliedMigrationRecord[]> {
this.#database.events.push("history:read");
if (this.#database.failures.has("read-history")) {
throw hostileDatabaseError;
}
return Object.freeze([...this.#database.committedHistory]);
}
async releaseMigrationLock(): Promise<void> {
this.#database.events.push("lock:release");
if (this.#database.failures.has("release-lock")) {
throw hostileDatabaseError;
}
}
async transaction<T>(operation: (transaction: MigrationTransaction) => Promise<T>): Promise<T> {
this.#database.events.push("transaction:begin");
const stagedHistory = [...this.#database.committedHistory];
const stagedInserts: MigrationHistoryInsert[] = [];
let stagedTableExists = this.#database.historyTableExists;
const transaction = Object.freeze<MigrationTransaction>({
ensureHistoryTable: async () => {
this.#database.events.push("history:ensure");
if (this.#database.failures.has("ensure-history")) {
throw hostileDatabaseError;
}
stagedTableExists = true;
},
executeStatement: async (sql) => {
this.#database.events.push("sql:execute");
this.#database.executedStatements.push(sql);
const statementIndex = this.#statementCount;
this.#statementCount += 1;
if (
this.#database.failures.has("execute") ||
this.#database.statementFailureIndex === statementIndex
) {
throw hostileDatabaseError;
}
},
insertAppliedMigration: async (insert) => {
this.#database.events.push(`history:insert:${insert.identity}`);
if (this.#database.failures.has("history-insert")) {
throw hostileDatabaseError;
}
const frozenInsert = Object.freeze({ ...insert });
stagedInserts.push(frozenInsert);
stagedHistory.push(
Object.freeze({
...frozenInsert,
appliedAt,
appliedOrder: stagedHistory.length + 1,
}),
);
},
});
try {
const result = await operation(transaction);
if (this.#database.failures.has("commit")) {
throw hostileDatabaseError;
}
this.#database.committedHistory = stagedHistory;
this.#database.historyTableExists = stagedTableExists;
this.#database.insertedMigrations.push(...stagedInserts);
this.#database.events.push("transaction:commit");
return result;
} catch (error) {
this.#database.events.push("transaction:rollback");
throw error;
}
}
}
class CapturingApprovalProvider implements MigrationApprovalProvider {
readonly requests: (readonly MigrationApproval[])[] = [];
readonly #response: readonly MigrationApproval[];
readonly #throwError: boolean;
constructor(response: readonly MigrationApproval[], throwError = false) {
this.#response = response;
this.#throwError = throwError;
}
async getApprovals(
required: readonly MigrationApproval[],
): Promise<readonly MigrationApproval[]> {
this.requests.push(required);
if (this.#throwError) {
throw hostileDatabaseError;
}
return this.#response;
}
}
function approvalFor(source: DiscoveredModuleMigration): MigrationApproval {
return Object.freeze({
checksum: source.checksum,
identity: source.identity,
moduleId: source.moduleId,
});
}
async function captureRunnerError(
operation: Promise<unknown>,
code: MigrationRunnerErrorCode,
): Promise<MigrationRunnerError> {
try {
await operation;
} catch (error) {
expect(error).toBeInstanceOf(MigrationRunnerError);
const runnerError = error as MigrationRunnerError;
expect(runnerError.code).toBe(code);
expect(Object.isFrozen(runnerError)).toBe(true);
return runnerError;
}
throw new Error(`Expected ${code}.`);
}
function expectNoPersistentMutation(database: FakeMigrationDatabase): void {
expect(database.events).not.toContain("transaction:begin");
expect(database.events).not.toContain("history:ensure");
expect(database.events).not.toContain("sql:execute");
expect(database.insertedMigrations).toEqual([]);
}
describe("module migration planning", () => {
it("treats a fresh database with a missing history table as empty without writing", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ historyTableExists: false });
const result = await planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(result).toEqual({
appliedCount: 0,
approvalRequiredCount: 0,
entries: [
{
approvalRequired: false,
checksum: initial.checksum,
identity: initial.identity,
index: 0,
moduleId: "people",
status: "pending",
tag: initial.tag,
},
],
pendingCount: 1,
});
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
expect(database.historyTableExists).toBe(false);
expectNoPersistentMutation(database);
});
it("returns an empty frozen plan when no installed module owns migrations", async () => {
const database = new FakeMigrationDatabase({ historyTableExists: false });
const inventory = aggregate({ id: "announcements", migrations: [] });
const result = await planModuleMigrations({
aggregate: inventory,
database,
});
const applyResult = await applyModuleMigrations({ aggregate: inventory, database });
expect(result).toEqual({
appliedCount: 0,
approvalRequiredCount: 0,
entries: [],
pendingCount: 0,
});
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.entries)).toBe(true);
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(database.historyTableExists).toBe(false);
expect(database.events).not.toContain("transaction:begin");
});
it("reports applied, pending, and approval-blocked entries in aggregate order", async () => {
const peopleInitial = migration("people", 0, { tag: "0000_initial" });
const peopleArchive = migration("people", 1, {
approvalRequired: true,
tag: "0001_archive",
});
const calendarInitial = migration("calendar", 0, { tag: "0000_initial" });
const inventory = aggregate(
{ id: "people", migrations: [peopleInitial, peopleArchive] },
{ id: "calendar", migrations: [calendarInitial] },
);
const result = await planModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({ history: [historyRecord(peopleInitial, 1)] }),
});
expect(result.entries.map((entry) => [entry.identity, entry.status])).toEqual([
[peopleInitial.identity, "applied"],
[peopleArchive.identity, "approval-blocked"],
[calendarInitial.identity, "pending"],
]);
expect(result).toMatchObject({
appliedCount: 1,
approvalRequiredCount: 1,
pendingCount: 2,
});
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.entries)).toBe(true);
expect(result.entries.every(Object.isFrozen)).toBe(true);
});
it("accepts historical interleaving while enforcing each module's journal prefix", async () => {
const peopleInitial = migration("people", 0);
const peopleLater = migration("people", 1);
const calendarInitial = migration("calendar", 0);
const inventory = aggregate(
{ id: "people", migrations: [peopleInitial, peopleLater] },
{ id: "calendar", migrations: [calendarInitial] },
);
const database = new FakeMigrationDatabase({
history: [
historyRecord(peopleInitial, 1),
historyRecord(calendarInitial, 2),
historyRecord(peopleLater, 3),
],
});
const result = await planModuleMigrations({ aggregate: inventory, database });
expect(result.entries.every((entry) => entry.status === "applied")).toBe(true);
expect(result.pendingCount).toBe(0);
});
it.each([
[
"a journal gap",
(_first: DiscoveredModuleMigration, second: DiscoveredModuleMigration) => [
historyRecord(second, 1),
],
],
[
"out-of-order module history",
(first: DiscoveredModuleMigration, second: DiscoveredModuleMigration) => [
historyRecord(second, 1),
historyRecord(first, 2),
],
],
])("rejects per-module history with %s", async (_label, makeHistory) => {
const first = migration("people", 0);
const second = migration("people", 1);
const database = new FakeMigrationDatabase({ history: makeHistory(first, second) });
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [first, second] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
expect(database.events.at(-1)).toBe("session:close");
});
it("allows gaps in database-assigned applied order", async () => {
const peopleInitial = migration("people", 0);
const peopleLater = migration("people", 1);
const calendarInitial = migration("calendar", 0);
const inventory = aggregate(
{ id: "people", migrations: [peopleInitial, peopleLater] },
{ id: "calendar", migrations: [calendarInitial] },
);
const result = await planModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({
history: [
historyRecord(peopleInitial, 7),
historyRecord(calendarInitial, 40),
historyRecord(peopleLater, 99),
],
}),
});
expect(result.appliedCount).toBe(3);
expect(result.pendingCount).toBe(0);
});
it.each([
[
"duplicate identity",
(source: DiscoveredModuleMigration) => [historyRecord(source, 1), historyRecord(source, 2)],
],
[
"inconsistent known metadata",
(source: DiscoveredModuleMigration) => [historyRecord(source, 1, { moduleId: "calendar" })],
],
])("rejects %s records without repairing history", async (_label, makeHistory) => {
const initial = migration("people", 0);
const history = makeHistory(initial);
const database = new FakeMigrationDatabase({ history });
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
expect(database.committedHistory).toEqual(history);
});
it("distinguishes exact checksum mismatches from unknown applied identities", async () => {
const initial = migration("people", 0);
const inventory = aggregate({ id: "people", migrations: [initial] });
const mismatch = new FakeMigrationDatabase({
history: [historyRecord(initial, 1, { checksum: "0".repeat(64) })],
});
const unknown = new FakeMigrationDatabase({
history: [
historyRecord(initial, 1, {
identity: "hostile:postgresql-admin-secret-private-path",
}),
],
});
const mismatchError = await captureRunnerError(
planModuleMigrations({ aggregate: inventory, database: mismatch }),
"MIGRATION_CHECKSUM_MISMATCH",
);
const unknownError = await captureRunnerError(
planModuleMigrations({ aggregate: inventory, database: unknown }),
"MIGRATION_UNKNOWN_APPLIED",
);
expect(mismatchError.migrationIdentity).toBe(initial.identity);
expect(unknownError.message).not.toContain("hostile");
expect(unknownError.message).not.toContain("private-path");
});
});
describe("module migration apply", () => {
it("acquires the lock before reading history and applies missing-history work atomically", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ historyTableExists: false });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(database.events).toEqual([
"session:open",
"lock:acquire:10000",
"history:read",
"transaction:begin",
"history:ensure",
"sql:execute",
`history:insert:${initial.identity}`,
"transaction:commit",
"lock:release",
"session:close",
]);
expect(database.historyTableExists).toBe(true);
expect(database.committedHistory.map((record) => record.identity)).toEqual([initial.identity]);
expect(result).toEqual({
applied: [
{
checksum: initial.checksum,
identity: initial.identity,
index: initial.index,
moduleId: initial.moduleId,
tag: initial.tag,
},
],
appliedCount: 1,
noOp: false,
});
});
it("re-reads history under the lock before deciding which migrations remain", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({
history: [],
historyAfterLock: [historyRecord(initial, 1)],
});
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
});
expect(result.applied.map((entry) => entry.identity)).toEqual([later.identity]);
expect(database.executedStatements).toEqual([later.sql]);
expect(database.events.indexOf("lock:acquire:10000")).toBeLessThan(
database.events.indexOf("history:read"),
);
});
it("returns a genuine frozen no-op when history is already current", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.applied)).toBe(true);
expectNoPersistentMutation(database);
expect(database.events).toEqual([
"session:open",
"lock:acquire:10000",
"history:read",
"lock:release",
"session:close",
]);
});
it("returns a locked no-op without creating history for an inventory with no migrations", async () => {
const database = new FakeMigrationDatabase({ historyTableExists: false });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "announcements", migrations: [] }),
database,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(database.historyTableExists).toBe(false);
expect(database.events).toEqual([
"session:open",
"lock:acquire:10000",
"history:read",
"lock:release",
"session:close",
]);
});
it("applies a later migration after a valid existing prefix", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
});
expect(result.applied.map((entry) => entry.identity)).toEqual([later.identity]);
expect(database.committedHistory.map((record) => record.identity)).toEqual([
initial.identity,
later.identity,
]);
});
it("executes dependency-first aggregate order and native journal order in one batch", async () => {
const peopleInitial = migration("people", 0);
const peopleLater = migration("people", 1);
const calendarInitial = migration("calendar", 0);
const database = new FakeMigrationDatabase();
const result = await applyModuleMigrations({
aggregate: aggregate(
{ id: "people", migrations: [peopleInitial, peopleLater] },
{ id: "calendar", migrations: [calendarInitial] },
),
database,
});
expect(result.applied.map((entry) => entry.identity)).toEqual([
peopleInitial.identity,
peopleLater.identity,
calendarInitial.identity,
]);
expect(database.events.filter((event) => event === "transaction:begin")).toHaveLength(1);
expect(database.events.filter((event) => event === "transaction:commit")).toHaveLength(1);
expect(database.insertedMigrations.map((entry) => entry.identity)).toEqual(
result.applied.map((entry) => entry.identity),
);
});
it("preserves exact Drizzle breakpoints without naively splitting semicolons", async () => {
const sql =
'CREATE TABLE "people_records" ("id" integer);\n--> statement-breakpoint\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;';
const initial = migration("people", 0, { sql });
const database = new FakeMigrationDatabase();
await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(database.executedStatements).toEqual([
'CREATE TABLE "people_records" ("id" integer);\n',
'\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;',
]);
expect(database.events.indexOf(`history:insert:${initial.identity}`)).toBeGreaterThan(
database.events.lastIndexOf("sql:execute"),
);
});
it("rolls back the whole pending batch on a later SQL failure and preserves prior history", async () => {
const prior = migration("people", 0);
const pendingFirst = migration("people", 1);
const pendingSecond = migration("people", 2);
const database = new FakeMigrationDatabase({
history: [historyRecord(prior, 1)],
statementFailureIndex: 1,
});
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [prior, pendingFirst, pendingSecond] }),
database,
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(error.migrationIdentity).toBe(pendingSecond.identity);
expect(database.committedHistory).toEqual([historyRecord(prior, 1)]);
expect(database.insertedMigrations).toEqual([]);
expect(database.events).toContain(`history:insert:${pendingFirst.identity}`);
expect(database.events).toContain("transaction:rollback");
expect(database.events).not.toContain("transaction:commit");
expect(database.events.slice(-2)).toEqual(["lock:release", "session:close"]);
});
it("rolls back SQL when the matching history insertion fails", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ failures: ["history-insert"] });
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(database.executedStatements).toEqual([initial.sql]);
expect(database.committedHistory).toEqual([]);
expect(database.events).toContain("transaction:rollback");
});
it("publishes no success when commit fails and still releases the session", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ failures: ["commit"] });
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(database.committedHistory).toEqual([]);
expect(database.insertedMigrations).toEqual([]);
expect(database.events).toContain("transaction:rollback");
expect(database.events.slice(-2)).toEqual(["lock:release", "session:close"]);
});
it("times out without reading history or trying to release an unheld lock", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ lockResult: false });
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
lockTimeoutMs: 250,
}),
"MIGRATION_LOCK_TIMEOUT",
);
expect(database.events).toEqual(["session:open", "lock:acquire:250", "session:close"]);
expectNoPersistentMutation(database);
});
it("does not ask for approval for ordinary migrations", async () => {
const initial = migration("people", 0);
const approvals = new CapturingApprovalProvider([]);
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
approvals,
database: new FakeMigrationDatabase(),
});
expect(result.appliedCount).toBe(1);
expect(approvals.requests).toEqual([]);
});
it("accepts an exact destructive approval bound to module, identity, and checksum", async () => {
const destructive = migration("people", 0, { approvalRequired: true });
const approvals = new CapturingApprovalProvider([approvalFor(destructive)]);
const database = new FakeMigrationDatabase();
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [destructive] }),
approvals,
database,
});
expect(result.applied.map((entry) => entry.identity)).toEqual([destructive.identity]);
expect(approvals.requests).toHaveLength(1);
expect(approvals.requests[0]).toEqual([approvalFor(destructive)]);
expect(Object.isFrozen(approvals.requests[0])).toBe(true);
expect(approvals.requests[0]?.every(Object.isFrozen)).toBe(true);
});
it.each([
["missing", []],
["wildcard-like", [{ moduleId: "*", identity: "*", checksum: "*" }]],
["wrong identity", [{ moduleId: "people", identity: "people:*", checksum: "CHECKSUM" }]],
["wrong module", [{ moduleId: "calendar", identity: "IDENTITY", checksum: "CHECKSUM" }]],
["wrong checksum", [{ moduleId: "people", identity: "IDENTITY", checksum: "0".repeat(64) }]],
])("blocks a destructive migration for a %s approval before mutation", async (_label, raw) => {
const destructive = migration("people", 0, { approvalRequired: true });
const supplied = raw.map((entry) =>
Object.freeze({
checksum: entry.checksum === "CHECKSUM" ? destructive.checksum : entry.checksum,
identity: entry.identity === "IDENTITY" ? destructive.identity : entry.identity,
moduleId: entry.moduleId,
}),
);
const database = new FakeMigrationDatabase();
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [destructive] }),
approvals: new CapturingApprovalProvider(supplied),
database,
}),
"MIGRATION_APPROVAL_REQUIRED",
);
expect(error.migrationIdentity).toBe(destructive.identity);
expectNoPersistentMutation(database);
expect(database.events.slice(-2)).toEqual(["lock:release", "session:close"]);
});
it("preflights every destructive approval before starting any persistent mutation", async () => {
const first = migration("people", 0, { approvalRequired: true });
const second = migration("people", 1, { approvalRequired: true });
const approvals = new CapturingApprovalProvider([approvalFor(first)]);
const database = new FakeMigrationDatabase();
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [first, second] }),
approvals,
database,
}),
"MIGRATION_APPROVAL_REQUIRED",
);
expect(approvals.requests[0]).toEqual([approvalFor(first), approvalFor(second)]);
expectNoPersistentMutation(database);
});
it("returns frozen deterministic applied results", async () => {
const first = migration("people", 0);
const second = migration("people", 1);
const inventory = aggregate({ id: "people", migrations: [first, second] });
const [left, right] = await Promise.all([
applyModuleMigrations({ aggregate: inventory, database: new FakeMigrationDatabase() }),
applyModuleMigrations({ aggregate: inventory, database: new FakeMigrationDatabase() }),
]);
expect(left).toEqual(right);
expect(Object.isFrozen(left)).toBe(true);
expect(Object.isFrozen(left.applied)).toBe(true);
expect(left.applied.every(Object.isFrozen)).toBe(true);
});
});
describe("module migration failures and side effects", () => {
it("sanitizes hostile connection, history, approval, and execution failures", async () => {
const ordinary = migration("people", 0);
const destructive = migration("people", 0, { approvalRequired: true });
const inventory = aggregate({ id: "people", migrations: [ordinary] });
const cases: readonly [Promise<unknown>, MigrationRunnerErrorCode][] = [
[
planModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({ failures: ["open"] }),
}),
"MIGRATION_CONNECTION_FAILED",
],
[
planModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({ failures: ["read-history"] }),
}),
"MIGRATION_HISTORY_READ_FAILED",
],
[
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [destructive] }),
approvals: new CapturingApprovalProvider([], true),
database: new FakeMigrationDatabase(),
}),
"MIGRATION_APPROVAL_LOOKUP_FAILED",
],
[
applyModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({ failures: ["execute"] }),
}),
"MIGRATION_EXECUTION_FAILED",
],
];
for (const [operation, code] of cases) {
const error = await captureRunnerError(operation, code);
const publicText = `${error.message}\n${error.stack ?? ""}`;
expect(publicText).not.toContain("super-secret");
expect(publicText).not.toContain("private-db.internal");
expect(publicText).not.toContain("/home/ekklesia");
expect(publicText).not.toContain("DROP TABLE");
expect("cause" in error).toBe(false);
}
});
it("always closes sessions and preserves a primary error when cleanup also fails", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({
failures: ["read-history", "release-lock", "close"],
});
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_HISTORY_READ_FAILED",
);
expect(error.code).toBe("MIGRATION_HISTORY_READ_FAILED");
expect(database.events.slice(-2)).toEqual(["lock:release", "session:close"]);
});
it("reports cleanup failure when successful work cannot release its resources", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ failures: ["release-lock"] });
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_CLEANUP_FAILED",
);
expect(database.events.at(-1)).toBe("session:close");
});
it("does not connect or execute SQL merely by importing the runner", async () => {
const database = new FakeMigrationDatabase();
await import("../src/migrations/runner.js");
expect(database.events).toEqual([]);
expect(database.executedStatements).toEqual([]);
expect(database.committedHistory).toEqual([]);
});
});