feat: add transactional module migration runner
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatMigrationApplyReport,
|
||||
formatMigrationPlanReport,
|
||||
} from "../src/cli/migration-report.js";
|
||||
import type { MigrationApplyResult, MigrationPlanResult } from "../src/migrations/index.js";
|
||||
|
||||
describe("migration command reports", () => {
|
||||
it("reports a deterministic safe plan and its stale-plan warning", () => {
|
||||
const plan: MigrationPlanResult = Object.freeze({
|
||||
appliedCount: 1,
|
||||
approvalRequiredCount: 1,
|
||||
entries: Object.freeze([
|
||||
Object.freeze({
|
||||
approvalRequired: false,
|
||||
checksum: "a".repeat(64),
|
||||
identity: "people:0000_initial",
|
||||
index: 0,
|
||||
moduleId: "people",
|
||||
status: "applied",
|
||||
tag: "0000_initial",
|
||||
}),
|
||||
Object.freeze({
|
||||
approvalRequired: false,
|
||||
checksum: "b".repeat(64),
|
||||
identity: "people:0001_add_email",
|
||||
index: 1,
|
||||
moduleId: "people",
|
||||
status: "pending",
|
||||
tag: "0001_add_email",
|
||||
}),
|
||||
Object.freeze({
|
||||
approvalRequired: true,
|
||||
checksum: "c".repeat(64),
|
||||
identity: "calendar:0000_replace_events",
|
||||
index: 0,
|
||||
moduleId: "calendar",
|
||||
status: "approval-blocked",
|
||||
tag: "0000_replace_events",
|
||||
}),
|
||||
]),
|
||||
pendingCount: 2,
|
||||
});
|
||||
|
||||
expect(formatMigrationPlanReport(plan)).toEqual([
|
||||
"Migration plan (1 applied, 1 pending, 1 approval-blocked):",
|
||||
`- [applied] people:0000_initial sha256=${"a".repeat(64)}`,
|
||||
`- [pending] people:0001_add_email sha256=${"b".repeat(64)}`,
|
||||
`- [approval-blocked] calendar:0000_replace_events sha256=${"c".repeat(64)}`,
|
||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports applied identities without SQL and handles a genuine no-op", () => {
|
||||
const applied: MigrationApplyResult = Object.freeze({
|
||||
applied: Object.freeze([
|
||||
Object.freeze({
|
||||
checksum: "d".repeat(64),
|
||||
identity: "people:0000_initial",
|
||||
index: 0,
|
||||
moduleId: "people",
|
||||
tag: "0000_initial",
|
||||
}),
|
||||
]),
|
||||
appliedCount: 1,
|
||||
noOp: false,
|
||||
});
|
||||
|
||||
expect(formatMigrationApplyReport(applied)).toEqual([
|
||||
"Migration apply completed (1 migration applied):",
|
||||
`- people:0000_initial sha256=${"d".repeat(64)}`,
|
||||
]);
|
||||
expect(
|
||||
formatMigrationApplyReport(
|
||||
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true }),
|
||||
),
|
||||
).toEqual(["Migration apply completed: database is already current (no changes)."]);
|
||||
});
|
||||
|
||||
it("renders an empty installed inventory explicitly", () => {
|
||||
expect(
|
||||
formatMigrationPlanReport(
|
||||
Object.freeze({
|
||||
appliedCount: 0,
|
||||
approvalRequiredCount: 0,
|
||||
entries: Object.freeze([]),
|
||||
pendingCount: 0,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
"Migration plan (0 applied, 0 pending, 0 approval-blocked):",
|
||||
"- No installed module migrations.",
|
||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const clientConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Client: clientConstructor }));
|
||||
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
PostgresMigrationDatabaseError,
|
||||
} from "../src/migrations/postgres-database.js";
|
||||
|
||||
describe("PostgreSQL migration database composition", () => {
|
||||
it("does not construct a client or execute SQL merely because the adapter is imported", () => {
|
||||
expect(clientConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"not-a-database-url",
|
||||
"https://admin:[email protected]/database",
|
||||
])("rejects missing or invalid configuration without exposing it (%s)", (databaseUrl) => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createPostgresMigrationDatabase(databaseUrl);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}\n${JSON.stringify(captured)}`;
|
||||
if (databaseUrl) {
|
||||
expect(publicText).not.toContain(databaseUrl);
|
||||
}
|
||||
expect(clientConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a lazy database composition without opening a connection", () => {
|
||||
const database = createPostgresMigrationDatabase(
|
||||
"postgresql://operator:[email protected]/ekklesia",
|
||||
);
|
||||
|
||||
expect(Object.isFrozen(database)).toBe(true);
|
||||
expect(clientConstructor).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(database)).not.toContain("operator");
|
||||
expect(JSON.stringify(database)).not.toContain("do-not-log");
|
||||
expect(JSON.stringify(database)).not.toContain("example.test");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user