Files
Ekklesia/core/tests/module-migration-runner.test.ts
T
ekklesiaandClaude Opus 5.5 e36b70d8d4
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.

- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
  and validate, module validation, module enablement, and Doctor all resolve it
  the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
  from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
  is not installed, and plans and applies Core's and present modules' migrations
  around it. Core's history and every installed module's history stay strict: a
  missing or changed applied migration, or a downgrade, still fails. No down
  migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
  template; .dockerignore excludes modules/ and the build refuses a context that
  contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
  sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
  and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
  modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
  modules, installs a self-contained test module packaged from the template
  (test-only), migrates and restarts, removes it (Core runs, planning reports the
  retained history, data survives), and restores it with its data.

Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 08:38:14 -05:00

1234 lines
43 KiB
TypeScript

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 { DiscoveredModule } from "../src/modules/discovery.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;
},
executeMigrationSql: 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,
retained: [],
});
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,
retained: [],
});
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.entries)).toBe(true);
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
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,
retained: [],
});
});
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, retained: [] });
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, retained: [] });
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("sends each migration's complete SQL to PostgreSQL in exactly one call, byte for byte", 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,
});
// One call, unmodified: the runner never splits, trims, normalizes, or rewrites migration SQL.
// PostgreSQL parses the batch, and Drizzle's marker lines are ordinary `--` comments to it.
expect(database.executedStatements).toEqual([sql]);
expect(database.events.indexOf(`history:insert:${initial.identity}`)).toBeGreaterThan(
database.events.lastIndexOf("sql:execute"),
);
});
it.each([
[
"a string literal containing the marker bytes",
`INSERT INTO "notes" ("body") VALUES ('keep this: --> statement-breakpoint intact');`,
],
[
"a dollar-quoted function body containing the marker and semicolons",
`CREATE FUNCTION touch() RETURNS void AS $$\nBEGIN\n -- --> statement-breakpoint\n PERFORM 1;\n PERFORM 2;\nEND;\n$$ LANGUAGE plpgsql;`,
],
["a line comment containing the marker", `-- note: --> statement-breakpoint\nSELECT 1;`],
["a block comment containing the marker", `/* --> statement-breakpoint */ SELECT 1;`],
[
"a quoted identifier containing the marker",
`CREATE TABLE "odd--> statement-breakpoint name" ("id" integer);`,
],
])("never fragments %s", async (_label, sql) => {
const initial = migration("people", 0, { sql });
const database = new FakeMigrationDatabase();
await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(database.executedStatements).toEqual([sql]);
});
it("sends several generated migrations as one call each, in order", async () => {
const first = migration("people", 0, { sql: `SELECT 1;\n--> statement-breakpoint\nSELECT 2;` });
const second = migration("people", 1, { sql: `SELECT 3;` });
const database = new FakeMigrationDatabase();
await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [first, second] }),
database,
});
expect(database.executedStatements).toEqual([first.sql, second.sql]);
});
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([]);
});
});
/**
* Marks modules as present in the discovered inventory without giving them migrations, the way a
* real aggregate lists every discovered module whether or not it owns migration history. Only the
* manifest id is read by the runner.
*/
function withDiscovered(
inventory: ModuleMigrationAggregate,
...moduleIds: readonly string[]
): ModuleMigrationAggregate {
return Object.freeze({
...inventory,
discoveredModules: Object.freeze(
moduleIds.map((id) => ({ manifest: { id } }) as unknown as DiscoveredModule),
),
});
}
describe("retained history for absent modules (ADR 0048)", () => {
it("retains and reports an absent module's applied history without failing the plan", async () => {
const coreInitial = migration("core", 0);
const calendarInitial = migration("calendar", 0);
const calendarLater = migration("calendar", 1);
const history = [
historyRecord(coreInitial, 1),
historyRecord(calendarInitial, 2),
historyRecord(calendarLater, 3),
];
const database = new FakeMigrationDatabase({ history });
const result = await planModuleMigrations({
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
database,
});
expect(result.appliedCount).toBe(1);
expect(result.pendingCount).toBe(0);
expect(result.entries.map((entry) => entry.identity)).toEqual([coreInitial.identity]);
expect(result.retained).toEqual([{ appliedCount: 2, moduleId: "calendar" }]);
expect(Object.isFrozen(result.retained)).toBe(true);
expect(Object.isFrozen(result.retained[0])).toBe(true);
expect(database.committedHistory).toEqual(history);
expectNoPersistentMutation(database);
});
it("applies a later Core migration while a module is absent, leaving its history intact", async () => {
const coreInitial = migration("core", 0);
const coreLater = migration("core", 1);
const calendarInitial = migration("calendar", 0);
const history = [historyRecord(coreInitial, 1), historyRecord(calendarInitial, 2)];
const database = new FakeMigrationDatabase({ history });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "core", migrations: [coreInitial, coreLater] }),
database,
});
expect(result.applied.map((applied) => applied.identity)).toEqual([coreLater.identity]);
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
expect(database.executedStatements).toEqual([coreLater.sql]);
expect(database.committedHistory.map((record) => record.identity)).toEqual([
coreInitial.identity,
calendarInitial.identity,
coreLater.identity,
]);
});
it("applies a present module's migrations while another module is absent", async () => {
const peopleInitial = migration("people", 0);
const calendarInitial = migration("calendar", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(calendarInitial, 1)] });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
database,
});
expect(result.applied.map((applied) => applied.identity)).toEqual([peopleInitial.identity]);
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
});
it("reports retained modules in a stable order with exact counts", async () => {
const zeta = [migration("zeta", 0), migration("zeta", 1), migration("zeta", 2)];
const alpha = migration("alpha", 0);
const database = new FakeMigrationDatabase({
history: [
historyRecord(zeta[0] as DiscoveredModuleMigration, 1),
historyRecord(alpha, 2),
historyRecord(zeta[1] as DiscoveredModuleMigration, 3),
historyRecord(zeta[2] as DiscoveredModuleMigration, 4),
],
});
const result = await planModuleMigrations({ aggregate: aggregate(), database });
expect(result.retained).toEqual([
{ appliedCount: 1, moduleId: "alpha" },
{ appliedCount: 3, moduleId: "zeta" },
]);
});
it("returns a module to strict validation: valid history plans cleanly and nothing is retained", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const result = await planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
});
expect(result.retained).toEqual([]);
expect(result.entries.map((entry) => [entry.identity, entry.status])).toEqual([
[initial.identity, "applied"],
[later.identity, "pending"],
]);
});
it("fails when a returning module no longer ships a previously applied migration", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({
history: [historyRecord(initial, 1), historyRecord(later, 2)],
});
const downgraded = aggregate({ id: "people", migrations: [initial] });
await captureRunnerError(
planModuleMigrations({ aggregate: downgraded, database }),
"MIGRATION_UNKNOWN_APPLIED",
);
await captureRunnerError(
applyModuleMigrations({ aggregate: downgraded, database }),
"MIGRATION_UNKNOWN_APPLIED",
);
expect(database.executedStatements).toEqual([]);
});
it("fails when a returning module's applied migration has different contents", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const edited = aggregate({
id: "people",
migrations: [migration("people", 0, { sql: "SELECT 'edited';\n" })],
});
const error = await captureRunnerError(
planModuleMigrations({ aggregate: edited, database }),
"MIGRATION_CHECKSUM_MISMATCH",
);
expect(error.migrationIdentity).toBe(initial.identity);
});
it("fails when a returning module's history is no longer a prefix of its journal", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({ history: [historyRecord(later, 1)] });
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
});
it("keeps a present module that no longer ships any migrations strict", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
await captureRunnerError(
planModuleMigrations({ aggregate: withDiscovered(aggregate(), "people"), database }),
"MIGRATION_UNKNOWN_APPLIED",
);
});
it("never treats Core's history as an absent module's", async () => {
const coreInitial = migration("core", 0);
const coreGhost = migration("core", 1);
// An inventory with no Core source at all: Core's rows are still strict, never retained.
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [] }),
database: new FakeMigrationDatabase({ history: [historyRecord(coreInitial, 1)] }),
}),
"MIGRATION_UNKNOWN_APPLIED",
);
// A Core row missing from Core's own inventory.
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
database: new FakeMigrationDatabase({
history: [historyRecord(coreInitial, 1), historyRecord(coreGhost, 2)],
}),
}),
"MIGRATION_UNKNOWN_APPLIED",
);
});
it.each([
[
"an identity that does not name its own module and tag",
(source: DiscoveredModuleMigration) => [
historyRecord(source, 1, { identity: "calendar:0099_elsewhere" }),
],
],
[
"a duplicate applied order",
(source: DiscoveredModuleMigration) => [
historyRecord(source, 1),
historyRecord(migration("calendar", 1), 1),
],
],
[
"a duplicate index",
(source: DiscoveredModuleMigration) => [
historyRecord(source, 1),
historyRecord(migration("calendar", 1), 2, { index: 0 }),
],
],
])("still rejects retained rows with %s", async (_label, makeHistory) => {
const database = new FakeMigrationDatabase({
history: makeHistory(migration("calendar", 0)),
});
await captureRunnerError(
planModuleMigrations({ aggregate: aggregate(), database }),
"MIGRATION_HISTORY_INVALID",
);
});
it("rejects a retained row that claims a present module's migration identity", async () => {
const peopleInitial = migration("people", 0);
const database = new FakeMigrationDatabase({
history: [historyRecord(peopleInitial, 1, { moduleId: "calendar" })],
});
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
});
});