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
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]>
1234 lines
43 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|