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

This commit is contained in:
ekklesia
2026-07-21 08:52:41 -05:00
parent dc37e59035
commit 0b282865cb
22 changed files with 2589 additions and 47 deletions
+7 -1
View File
@@ -14,6 +14,10 @@
"./modules": {
"types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js"
},
"./migrations": {
"types": "./dist/migrations/index.d.ts",
"import": "./dist/migrations/index.js"
}
},
"scripts": {
@@ -24,11 +28,13 @@
},
"dependencies": {
"@ekklesia/contracts": "workspace:*",
"drizzle-orm": "0.45.2"
"drizzle-orm": "0.45.2",
"pg": "8.22.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"@types/pg": "8.20.0",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+19
View File
@@ -0,0 +1,19 @@
import { formatMigrationApplyReport } from "./migration-report.js";
import { discoverRepositoryMigrations } from "./migration-command-support.js";
import { reportMigrationCommandError } from "./migration-command-errors.js";
import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js";
async function main(): Promise<void> {
const aggregate = await discoverRepositoryMigrations();
const database = createPostgresMigrationDatabase(process.env.DATABASE_URL);
const result = await applyModuleMigrations({ aggregate, database });
for (const line of formatMigrationApplyReport(result)) {
console.log(line);
}
}
main().catch((error: unknown) => {
reportMigrationCommandError(error);
process.exitCode = 1;
});
+37
View File
@@ -0,0 +1,37 @@
import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js";
import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js";
export function reportMigrationCommandError(error: unknown): void {
if (error instanceof ModuleDiscoveryError) {
console.error(error.message);
for (const issue of error.issues) {
const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
}
return;
}
if (error instanceof ModuleMigrationDiscoveryError) {
console.error(error.message);
for (const issue of error.issues) {
const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`);
}
return;
}
if (error instanceof MigrationRunnerError) {
console.error(`[${error.code}] ${error.message}`);
return;
}
if (
error instanceof PostgresMigrationDatabaseError &&
error.code === "POSTGRES_MIGRATION_CONFIGURATION_INVALID"
) {
console.error("[MIGRATION_CONFIGURATION_INVALID] Migration configuration is invalid.");
return;
}
console.error("Migration command failed unexpectedly.");
}
+30
View File
@@ -0,0 +1,30 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { discoverModuleMigrations, type ModuleMigrationAggregate } from "../modules/index.js";
interface PackageVersion {
readonly version?: unknown;
}
async function readPackageVersion(packagePath: string): Promise<string> {
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
if (typeof packageData.version !== "string") {
throw new TypeError("A required workspace package does not declare a version.");
}
return packageData.version;
}
export async function discoverRepositoryMigrations(
repositoryRoot = process.cwd(),
): Promise<ModuleMigrationAggregate> {
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
return discoverModuleMigrations({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
});
}
+34
View File
@@ -1,4 +1,5 @@
import type { ModuleMigrationAggregate } from "../modules/index.js";
import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
function plural(count: number, singular: string): string {
return count === 1 ? singular : `${singular}s`;
@@ -26,3 +27,36 @@ export function formatMigrationValidationReport(
return Object.freeze(lines);
}
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
const lines = [
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
];
for (const entry of plan.entries) {
lines.push(`- [${entry.status}] ${entry.identity} sha256=${entry.checksum}`);
}
if (plan.entries.length === 0) {
lines.push("- No installed module migrations.");
}
lines.push(
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
);
return Object.freeze(lines);
}
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
if (result.noOp) {
return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
}
const lines = [
`Migration apply completed (${result.appliedCount} ${plural(result.appliedCount, "migration")} applied):`,
];
for (const migration of result.applied) {
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
}
return Object.freeze(lines);
}
+19
View File
@@ -0,0 +1,19 @@
import { formatMigrationPlanReport } from "./migration-report.js";
import { discoverRepositoryMigrations } from "./migration-command-support.js";
import { reportMigrationCommandError } from "./migration-command-errors.js";
import { createPostgresMigrationDatabase, planModuleMigrations } from "../migrations/index.js";
async function main(): Promise<void> {
const aggregate = await discoverRepositoryMigrations();
const database = createPostgresMigrationDatabase(process.env.DATABASE_URL);
const plan = await planModuleMigrations({ aggregate, database });
for (const line of formatMigrationPlanReport(plan)) {
console.log(line);
}
}
main().catch((error: unknown) => {
reportMigrationCommandError(error);
process.exitCode = 1;
});
+3 -29
View File
@@ -1,35 +1,9 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { formatMigrationValidationReport } from "./migration-report.js";
import {
discoverModuleMigrations,
ModuleDiscoveryError,
ModuleMigrationDiscoveryError,
} from "../modules/index.js";
interface PackageVersion {
version?: unknown;
}
async function readPackageVersion(packagePath: string): Promise<string> {
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
if (typeof packageData.version !== "string") {
throw new TypeError("A required workspace package does not declare a version.");
}
return packageData.version;
}
import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js";
import { discoverRepositoryMigrations } from "./migration-command-support.js";
async function main(): Promise<void> {
const repositoryRoot = process.cwd();
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
const aggregate = await discoverModuleMigrations({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
});
const aggregate = await discoverRepositoryMigrations();
for (const line of formatMigrationValidationReport(aggregate)) {
console.log(line);
+1
View File
@@ -1 +1,2 @@
export * from "./modules/index.js";
export * from "./migrations/index.js";
+2
View File
@@ -0,0 +1,2 @@
export * from "./postgres-database.js";
export * from "./runner.js";
+466
View File
@@ -0,0 +1,466 @@
import { Client, type QueryResultRow } from "pg";
import type {
AppliedMigrationRecord,
MigrationDatabase,
MigrationDatabaseSession,
MigrationHistoryInsert,
MigrationTransaction,
} from "./runner.js";
const historyTableSchema = "public";
const historyTableName = "ekklesia_core_migrations";
const advisoryLockNamespace = 1_162_562_380;
const advisoryLockKey = 1_296_648_018;
const advisoryLockPollIntervalMs = 50;
const createHistoryTableSql = `
CREATE TABLE IF NOT EXISTS "public"."ekklesia_core_migrations" (
"applied_order" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"identity" text NOT NULL UNIQUE,
"module_id" text NOT NULL,
"migration_tag" text NOT NULL,
"migration_index" integer NOT NULL,
"checksum" text NOT NULL,
"applied_at" timestamptz NOT NULL DEFAULT clock_timestamp(),
UNIQUE ("module_id", "migration_tag"),
UNIQUE ("module_id", "migration_index")
)
`;
const readHistorySql = `
SELECT
"applied_order",
"identity",
"module_id",
"migration_tag",
"migration_index",
"checksum",
"applied_at"
FROM "public"."ekklesia_core_migrations"
ORDER BY "applied_order" ASC
`;
const insertHistorySql = `
INSERT INTO "public"."ekklesia_core_migrations" (
"identity",
"module_id",
"migration_tag",
"migration_index",
"checksum"
)
VALUES ($1, $2, $3, $4, $5)
`;
export type PostgresMigrationDatabaseErrorCode =
| "POSTGRES_MIGRATION_CONFIGURATION_INVALID"
| "POSTGRES_MIGRATION_CONNECTION_FAILED"
| "POSTGRES_MIGRATION_EXECUTION_FAILED"
| "POSTGRES_MIGRATION_HISTORY_READ_FAILED"
| "POSTGRES_MIGRATION_HISTORY_WRITE_FAILED"
| "POSTGRES_MIGRATION_LOCK_FAILED"
| "POSTGRES_MIGRATION_SESSION_CLOSE_FAILED"
| "POSTGRES_MIGRATION_TRANSACTION_FAILED";
export class PostgresMigrationDatabaseError extends Error {
readonly code: PostgresMigrationDatabaseErrorCode;
constructor(code: PostgresMigrationDatabaseErrorCode, message: string) {
super(message);
this.name = "PostgresMigrationDatabaseError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
interface HistoryExistenceRow extends QueryResultRow {
readonly relation_kind: string;
}
interface HistoryRow extends QueryResultRow {
readonly applied_at: Date | string;
readonly applied_order: number | string;
readonly checksum: string;
readonly identity: string;
readonly migration_index: number;
readonly migration_tag: string;
readonly module_id: string;
}
interface AdvisoryLockRow extends QueryResultRow {
readonly acquired: boolean;
}
interface AdvisoryUnlockRow extends QueryResultRow {
readonly released: boolean;
}
function databaseError(
code: PostgresMigrationDatabaseErrorCode,
message: string,
): PostgresMigrationDatabaseError {
return new PostgresMigrationDatabaseError(code, message);
}
function validateConnectionString(connectionString: string | undefined): string {
if (typeof connectionString !== "string" || connectionString.trim() === "") {
throw databaseError(
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
"A PostgreSQL database URL is required.",
);
}
try {
const url = new URL(connectionString);
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
throw new Error("unsupported protocol");
}
} catch {
throw databaseError(
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
"The PostgreSQL database URL is invalid.",
);
}
return connectionString;
}
function isPostgresMigrationDatabaseError(error: unknown): error is PostgresMigrationDatabaseError {
return error instanceof PostgresMigrationDatabaseError;
}
function parseAppliedOrder(value: number | string): number | undefined {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function parseAppliedAt(value: Date | string): string | undefined {
const parsed = value instanceof Date ? value : new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}
function toAppliedMigrationRecord(row: HistoryRow): AppliedMigrationRecord {
const appliedOrder = parseAppliedOrder(row.applied_order);
const appliedAt = parseAppliedAt(row.applied_at);
if (
appliedOrder === undefined ||
appliedAt === undefined ||
typeof row.identity !== "string" ||
typeof row.module_id !== "string" ||
typeof row.migration_tag !== "string" ||
!Number.isSafeInteger(row.migration_index) ||
typeof row.checksum !== "string"
) {
throw databaseError(
"POSTGRES_MIGRATION_HISTORY_READ_FAILED",
"The Core migration history could not be read.",
);
}
return Object.freeze({
appliedAt,
appliedOrder,
checksum: row.checksum,
identity: row.identity,
index: row.migration_index,
moduleId: row.module_id,
tag: row.migration_tag,
});
}
async function pause(milliseconds: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
}
class PostgresMigrationDatabaseSession implements MigrationDatabaseSession {
readonly #client: Client;
#closed = false;
#lockHeld = false;
#transactionActive = false;
constructor(client: Client) {
this.#client = client;
}
async readAppliedMigrations(): Promise<readonly AppliedMigrationRecord[]> {
this.#assertOpen("POSTGRES_MIGRATION_HISTORY_READ_FAILED");
try {
const existence = await this.#client.query<HistoryExistenceRow>(
`
SELECT relation.relkind AS relation_kind
FROM pg_catalog.pg_class AS relation
INNER JOIN pg_catalog.pg_namespace AS namespace
ON namespace.oid = relation.relnamespace
WHERE namespace.nspname = $1 AND relation.relname = $2
`,
[historyTableSchema, historyTableName],
);
const relationKind = existence.rows[0]?.relation_kind;
if (relationKind === undefined) {
return Object.freeze([]);
}
if (relationKind !== "r") {
throw databaseError(
"POSTGRES_MIGRATION_HISTORY_READ_FAILED",
"The Core migration history could not be read.",
);
}
const result = await this.#client.query<HistoryRow>(readHistorySql);
return Object.freeze(result.rows.map(toAppliedMigrationRecord));
} catch (error) {
if (isPostgresMigrationDatabaseError(error)) {
throw error;
}
throw databaseError(
"POSTGRES_MIGRATION_HISTORY_READ_FAILED",
"The Core migration history could not be read.",
);
}
}
async acquireMigrationLock(timeoutMs: number): Promise<boolean> {
this.#assertOpen("POSTGRES_MIGRATION_LOCK_FAILED");
if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 2_147_483_647) {
throw databaseError(
"POSTGRES_MIGRATION_LOCK_FAILED",
"The migration lock timeout is invalid.",
);
}
if (this.#lockHeld) {
return true;
}
const deadline = performance.now() + timeoutMs;
try {
while (true) {
const result = await this.#client.query<AdvisoryLockRow>(
"SELECT pg_try_advisory_lock($1::integer, $2::integer) AS acquired",
[advisoryLockNamespace, advisoryLockKey],
);
const acquired = result.rows[0]?.acquired;
if (typeof acquired !== "boolean") {
throw databaseError(
"POSTGRES_MIGRATION_LOCK_FAILED",
"The migration advisory lock could not be acquired.",
);
}
if (acquired) {
this.#lockHeld = true;
return true;
}
const remaining = deadline - performance.now();
if (remaining <= 0) {
return false;
}
await pause(Math.min(advisoryLockPollIntervalMs, remaining));
}
} catch (error) {
if (isPostgresMigrationDatabaseError(error)) {
throw error;
}
throw databaseError(
"POSTGRES_MIGRATION_LOCK_FAILED",
"The migration advisory lock could not be acquired.",
);
}
}
async releaseMigrationLock(): Promise<void> {
this.#assertOpen("POSTGRES_MIGRATION_LOCK_FAILED");
if (!this.#lockHeld) {
return;
}
try {
const result = await this.#client.query<AdvisoryUnlockRow>(
"SELECT pg_advisory_unlock($1::integer, $2::integer) AS released",
[advisoryLockNamespace, advisoryLockKey],
);
if (result.rows[0]?.released !== true) {
throw databaseError(
"POSTGRES_MIGRATION_LOCK_FAILED",
"The migration advisory lock could not be released.",
);
}
this.#lockHeld = false;
} catch (error) {
if (isPostgresMigrationDatabaseError(error)) {
throw error;
}
throw databaseError(
"POSTGRES_MIGRATION_LOCK_FAILED",
"The migration advisory lock could not be released.",
);
}
}
async transaction<T>(operation: (transaction: MigrationTransaction) => Promise<T>): Promise<T> {
this.#assertOpen("POSTGRES_MIGRATION_TRANSACTION_FAILED");
if (this.#transactionActive) {
throw databaseError(
"POSTGRES_MIGRATION_TRANSACTION_FAILED",
"Nested migration transactions are not supported.",
);
}
this.#transactionActive = true;
try {
await this.#client.query("BEGIN");
} catch {
this.#transactionActive = false;
throw databaseError(
"POSTGRES_MIGRATION_TRANSACTION_FAILED",
"The migration transaction could not be started.",
);
}
const transaction = Object.freeze<MigrationTransaction>({
ensureHistoryTable: async () => this.#ensureHistoryTable(),
executeStatement: async (sql) => this.#executeStatement(sql),
insertAppliedMigration: async (migration) => this.#insertAppliedMigration(migration),
});
try {
const result = await operation(transaction);
await this.#client.query("COMMIT");
return result;
} catch (error) {
try {
await this.#client.query("ROLLBACK");
} catch {
throw databaseError(
"POSTGRES_MIGRATION_TRANSACTION_FAILED",
"The migration transaction could not be rolled back.",
);
}
if (isPostgresMigrationDatabaseError(error)) {
throw error;
}
throw databaseError(
"POSTGRES_MIGRATION_TRANSACTION_FAILED",
"The migration transaction failed.",
);
} finally {
this.#transactionActive = false;
}
}
async close(): Promise<void> {
if (this.#closed) {
return;
}
let cleanupFailed = false;
if (this.#lockHeld) {
try {
const result = await this.#client.query<AdvisoryUnlockRow>(
"SELECT pg_advisory_unlock($1::integer, $2::integer) AS released",
[advisoryLockNamespace, advisoryLockKey],
);
cleanupFailed = result.rows[0]?.released !== true;
} catch {
cleanupFailed = true;
}
}
try {
await this.#client.end();
} catch {
cleanupFailed = true;
} finally {
this.#closed = true;
this.#lockHeld = false;
this.#transactionActive = false;
}
if (cleanupFailed) {
throw databaseError(
"POSTGRES_MIGRATION_SESSION_CLOSE_FAILED",
"The PostgreSQL migration session could not be closed cleanly.",
);
}
}
async #ensureHistoryTable(): Promise<void> {
this.#assertTransactionActive("POSTGRES_MIGRATION_HISTORY_WRITE_FAILED");
try {
await this.#client.query(createHistoryTableSql);
} catch {
throw databaseError(
"POSTGRES_MIGRATION_HISTORY_WRITE_FAILED",
"The Core migration history table could not be created.",
);
}
}
async #executeStatement(sql: string): Promise<void> {
this.#assertTransactionActive("POSTGRES_MIGRATION_EXECUTION_FAILED");
try {
await this.#client.query(sql);
} catch {
throw databaseError(
"POSTGRES_MIGRATION_EXECUTION_FAILED",
"A module migration statement failed.",
);
}
}
async #insertAppliedMigration(migration: MigrationHistoryInsert): Promise<void> {
this.#assertTransactionActive("POSTGRES_MIGRATION_HISTORY_WRITE_FAILED");
try {
await this.#client.query(insertHistorySql, [
migration.identity,
migration.moduleId,
migration.tag,
migration.index,
migration.checksum,
]);
} catch {
throw databaseError(
"POSTGRES_MIGRATION_HISTORY_WRITE_FAILED",
"The applied migration could not be recorded.",
);
}
}
#assertOpen(code: PostgresMigrationDatabaseErrorCode): void {
if (this.#closed) {
throw databaseError(code, "The PostgreSQL migration session is closed.");
}
}
#assertTransactionActive(code: PostgresMigrationDatabaseErrorCode): void {
if (!this.#transactionActive) {
throw databaseError(code, "A migration transaction is not active.");
}
}
}
export function createPostgresMigrationDatabase(
connectionString: string | undefined,
): MigrationDatabase {
const validatedConnectionString = validateConnectionString(connectionString);
return Object.freeze({
async openSession(): Promise<MigrationDatabaseSession> {
const client = new Client({ connectionString: validatedConnectionString });
client.on("error", () => undefined);
try {
await client.connect();
} catch {
try {
await client.end();
} catch {
// The public error stays fixed even when driver cleanup also fails.
}
throw databaseError(
"POSTGRES_MIGRATION_CONNECTION_FAILED",
"The PostgreSQL migration connection could not be established.",
);
}
return new PostgresMigrationDatabaseSession(client);
},
});
}
+509
View File
@@ -0,0 +1,509 @@
import type {
DiscoveredModuleMigration,
ModuleMigrationAggregate,
} from "../modules/migration-discovery.js";
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
const DRIZZLE_STATEMENT_BREAKPOINT = "--> statement-breakpoint";
export type MigrationRunnerErrorCode =
| "MIGRATION_APPROVAL_LOOKUP_FAILED"
| "MIGRATION_APPROVAL_REQUIRED"
| "MIGRATION_CHECKSUM_MISMATCH"
| "MIGRATION_CLEANUP_FAILED"
| "MIGRATION_CONFIGURATION_INVALID"
| "MIGRATION_CONNECTION_FAILED"
| "MIGRATION_EXECUTION_FAILED"
| "MIGRATION_HISTORY_INVALID"
| "MIGRATION_HISTORY_READ_FAILED"
| "MIGRATION_LOCK_FAILED"
| "MIGRATION_LOCK_TIMEOUT"
| "MIGRATION_UNKNOWN_APPLIED";
export interface AppliedMigrationRecord {
readonly appliedAt: string;
readonly appliedOrder: number;
readonly checksum: string;
readonly identity: string;
readonly index: number;
readonly moduleId: string;
readonly tag: string;
}
export interface MigrationHistoryInsert {
readonly checksum: string;
readonly identity: string;
readonly index: number;
readonly moduleId: string;
readonly tag: string;
}
export interface MigrationTransaction {
ensureHistoryTable(): Promise<void>;
executeStatement(sql: string): Promise<void>;
insertAppliedMigration(migration: MigrationHistoryInsert): Promise<void>;
}
export interface MigrationDatabaseSession {
acquireMigrationLock(timeoutMs: number): Promise<boolean>;
close(): Promise<void>;
readAppliedMigrations(): Promise<readonly AppliedMigrationRecord[]>;
releaseMigrationLock(): Promise<void>;
transaction<T>(operation: (transaction: MigrationTransaction) => Promise<T>): Promise<T>;
}
export interface MigrationDatabase {
openSession(): Promise<MigrationDatabaseSession>;
}
export interface MigrationApproval {
readonly checksum: string;
readonly identity: string;
readonly moduleId: string;
}
export interface MigrationApprovalProvider {
getApprovals(required: readonly MigrationApproval[]): Promise<readonly MigrationApproval[]>;
}
export const denyAllMigrationApprovals: MigrationApprovalProvider = Object.freeze({
getApprovals: async (): Promise<readonly MigrationApproval[]> => Object.freeze([]),
});
export type MigrationPlanStatus = "applied" | "approval-blocked" | "pending";
export interface MigrationPlanEntry {
readonly approvalRequired: boolean;
readonly checksum: string;
readonly identity: string;
readonly index: number;
readonly moduleId: string;
readonly status: MigrationPlanStatus;
readonly tag: string;
}
export interface MigrationPlanResult {
readonly appliedCount: number;
readonly approvalRequiredCount: number;
readonly entries: readonly MigrationPlanEntry[];
readonly pendingCount: number;
}
export interface AppliedMigrationResult {
readonly checksum: string;
readonly identity: string;
readonly index: number;
readonly moduleId: string;
readonly tag: string;
}
export interface MigrationApplyResult {
readonly applied: readonly AppliedMigrationResult[];
readonly appliedCount: number;
readonly noOp: boolean;
}
export interface PlanModuleMigrationsOptions {
readonly aggregate: ModuleMigrationAggregate;
readonly database: MigrationDatabase;
}
export interface ApplyModuleMigrationsOptions extends PlanModuleMigrationsOptions {
readonly approvals?: MigrationApprovalProvider;
readonly lockTimeoutMs?: number;
}
const errorMessages: Readonly<Record<MigrationRunnerErrorCode, string>> = Object.freeze({
MIGRATION_APPROVAL_LOOKUP_FAILED: "Migration approval lookup failed.",
MIGRATION_APPROVAL_REQUIRED: "A pending migration requires an exact destructive-change approval.",
MIGRATION_CHECKSUM_MISMATCH: "An applied migration checksum does not match packaged inventory.",
MIGRATION_CLEANUP_FAILED: "Migration database cleanup failed.",
MIGRATION_CONFIGURATION_INVALID: "Migration configuration is invalid.",
MIGRATION_CONNECTION_FAILED: "The migration database connection failed.",
MIGRATION_EXECUTION_FAILED: "Migration execution failed.",
MIGRATION_HISTORY_INVALID: "Applied migration history is invalid.",
MIGRATION_HISTORY_READ_FAILED: "Applied migration history could not be read.",
MIGRATION_LOCK_FAILED: "The Ekklesia migration lock operation failed.",
MIGRATION_LOCK_TIMEOUT: "Timed out while waiting for the Ekklesia migration lock.",
MIGRATION_UNKNOWN_APPLIED:
"Applied migration history contains an identity absent from packaged inventory.",
});
export class MigrationRunnerError extends Error {
readonly code: MigrationRunnerErrorCode;
readonly migrationIdentity?: string;
constructor(code: MigrationRunnerErrorCode, migrationIdentity?: string) {
const suffix = migrationIdentity === undefined ? "" : ` Migration: ${migrationIdentity}.`;
super(`${errorMessages[code]}${suffix}`);
this.name = "MigrationRunnerError";
this.code = code;
if (migrationIdentity !== undefined) {
this.migrationIdentity = migrationIdentity;
}
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function runnerError(
code: MigrationRunnerErrorCode,
migrationIdentity?: string,
): MigrationRunnerError {
return new MigrationRunnerError(code, migrationIdentity);
}
function freezeHistoryInsert(migration: DiscoveredModuleMigration): MigrationHistoryInsert {
return Object.freeze({
checksum: migration.checksum,
identity: migration.identity,
index: migration.index,
moduleId: migration.moduleId,
tag: migration.tag,
});
}
function freezeResultMigration(migration: DiscoveredModuleMigration): AppliedMigrationResult {
return Object.freeze({
checksum: migration.checksum,
identity: migration.identity,
index: migration.index,
moduleId: migration.moduleId,
tag: migration.tag,
});
}
function knownMigration(
inventory: ReadonlyMap<string, DiscoveredModuleMigration>,
identity: unknown,
): DiscoveredModuleMigration | undefined {
return typeof identity === "string" ? inventory.get(identity) : undefined;
}
function validateHistoryRecordShape(record: AppliedMigrationRecord): void {
if (
typeof record !== "object" ||
record === null ||
typeof record.appliedAt !== "string" ||
record.appliedAt.length === 0 ||
Number.isNaN(Date.parse(record.appliedAt)) ||
!Number.isSafeInteger(record.appliedOrder) ||
record.appliedOrder < 1 ||
typeof record.checksum !== "string" ||
typeof record.identity !== "string" ||
!Number.isSafeInteger(record.index) ||
record.index < 0 ||
typeof record.moduleId !== "string" ||
typeof record.tag !== "string"
) {
throw runnerError("MIGRATION_HISTORY_INVALID");
}
}
function validateAppliedHistoryUnchecked(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> {
if (!Array.isArray(history)) {
throw runnerError("MIGRATION_HISTORY_INVALID");
}
const inventory = new Map(
aggregate.migrations.map((migration) => [migration.identity, migration]),
);
const seenOrders = new Set<number>();
const seenIdentities = new Set<string>();
const seenModuleIndexes = new Set<string>();
const seenModuleTags = new Set<string>();
let previousOrder = 0;
for (const record of history) {
validateHistoryRecordShape(record);
if (
record.appliedOrder <= previousOrder ||
seenOrders.has(record.appliedOrder) ||
seenIdentities.has(record.identity) ||
seenModuleIndexes.has(`${record.moduleId}\u0000${record.index}`) ||
seenModuleTags.has(`${record.moduleId}\u0000${record.tag}`)
) {
throw runnerError("MIGRATION_HISTORY_INVALID");
}
previousOrder = record.appliedOrder;
seenOrders.add(record.appliedOrder);
seenIdentities.add(record.identity);
seenModuleIndexes.add(`${record.moduleId}\u0000${record.index}`);
seenModuleTags.add(`${record.moduleId}\u0000${record.tag}`);
const migration = knownMigration(inventory, record.identity);
if (!migration) {
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
}
if (
record.moduleId !== migration.moduleId ||
record.tag !== migration.tag ||
record.index !== migration.index
) {
throw runnerError("MIGRATION_HISTORY_INVALID", migration.identity);
}
if (record.checksum !== migration.checksum) {
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
}
}
for (const module of aggregate.modules) {
const applied = history.filter((record) => record.moduleId === module.moduleId);
for (const [position, record] of applied.entries()) {
if (record.identity !== module.migrations[position]?.identity) {
const migration = knownMigration(inventory, record.identity);
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.identity);
}
}
}
return seenIdentities;
}
function validateAppliedHistory(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> {
try {
return validateAppliedHistoryUnchecked(aggregate, history);
} catch (error) {
if (error instanceof MigrationRunnerError) {
throw error;
}
throw runnerError("MIGRATION_HISTORY_INVALID");
}
}
function planFromHistory(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): MigrationPlanResult {
const applied = validateAppliedHistory(aggregate, history);
const entries = Object.freeze(
aggregate.migrations.map((migration): MigrationPlanEntry => {
const isApplied = applied.has(migration.identity);
const status: MigrationPlanStatus = isApplied
? "applied"
: migration.approvalRequired
? "approval-blocked"
: "pending";
return Object.freeze({
approvalRequired: migration.approvalRequired,
checksum: migration.checksum,
identity: migration.identity,
index: migration.index,
moduleId: migration.moduleId,
status,
tag: migration.tag,
});
}),
);
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
const approvalRequiredCount = entries.filter(
(entry) => entry.status === "approval-blocked",
).length;
return Object.freeze({
appliedCount,
approvalRequiredCount,
entries,
pendingCount: entries.length - appliedCount,
});
}
async function closeSession(
session: MigrationDatabaseSession,
primaryError: unknown,
): Promise<void> {
try {
await session.close();
} catch {
if (primaryError === undefined) {
throw runnerError("MIGRATION_CLEANUP_FAILED");
}
}
}
export async function planModuleMigrations(
options: PlanModuleMigrationsOptions,
): Promise<MigrationPlanResult> {
let session: MigrationDatabaseSession;
try {
session = await options.database.openSession();
} catch {
throw runnerError("MIGRATION_CONNECTION_FAILED");
}
let primaryError: unknown;
try {
let history: readonly AppliedMigrationRecord[];
try {
history = await session.readAppliedMigrations();
} catch {
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
}
return planFromHistory(options.aggregate, history);
} catch (error) {
primaryError = error;
throw error;
} finally {
await closeSession(session, primaryError);
}
}
function validateLockTimeout(lockTimeoutMs: number): void {
if (!Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs <= 0) {
throw runnerError("MIGRATION_CONFIGURATION_INVALID");
}
}
function approvalRequest(migration: DiscoveredModuleMigration): MigrationApproval {
return Object.freeze({
checksum: migration.checksum,
identity: migration.identity,
moduleId: migration.moduleId,
});
}
async function preflightApprovals(
pending: readonly DiscoveredModuleMigration[],
provider: MigrationApprovalProvider,
): Promise<void> {
const required = Object.freeze(
pending.filter((migration) => migration.approvalRequired).map(approvalRequest),
);
if (required.length === 0) {
return;
}
let supplied: readonly MigrationApproval[];
try {
supplied = await provider.getApprovals(required);
} catch {
throw runnerError("MIGRATION_APPROVAL_LOOKUP_FAILED");
}
const approvalKeys = new Set<string>();
try {
if (!Array.isArray(supplied)) {
throw new TypeError("Invalid migration approval response.");
}
for (const approval of supplied) {
if (
typeof approval === "object" &&
approval !== null &&
typeof approval.moduleId === "string" &&
typeof approval.identity === "string" &&
typeof approval.checksum === "string"
) {
approvalKeys.add(
`${approval.moduleId}\u0000${approval.identity}\u0000${approval.checksum}`,
);
}
}
} catch {
throw runnerError("MIGRATION_APPROVAL_LOOKUP_FAILED");
}
for (const request of required) {
const key = `${request.moduleId}\u0000${request.identity}\u0000${request.checksum}`;
if (!approvalKeys.has(key)) {
throw runnerError("MIGRATION_APPROVAL_REQUIRED", request.identity);
}
}
}
async function cleanupApplySession(
session: MigrationDatabaseSession,
lockAcquired: boolean,
primaryError: unknown,
): Promise<void> {
let cleanupFailed = false;
if (lockAcquired) {
try {
await session.releaseMigrationLock();
} catch {
cleanupFailed = true;
}
}
try {
await session.close();
} catch {
cleanupFailed = true;
}
if (cleanupFailed && primaryError === undefined) {
throw runnerError("MIGRATION_CLEANUP_FAILED");
}
}
export async function applyModuleMigrations(
options: ApplyModuleMigrationsOptions,
): Promise<MigrationApplyResult> {
const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_MIGRATION_LOCK_TIMEOUT_MS;
validateLockTimeout(lockTimeoutMs);
let session: MigrationDatabaseSession;
try {
session = await options.database.openSession();
} catch {
throw runnerError("MIGRATION_CONNECTION_FAILED");
}
let lockAcquired = false;
let primaryError: unknown;
try {
try {
lockAcquired = await session.acquireMigrationLock(lockTimeoutMs);
} catch {
throw runnerError("MIGRATION_LOCK_FAILED");
}
if (!lockAcquired) {
throw runnerError("MIGRATION_LOCK_TIMEOUT");
}
let history: readonly AppliedMigrationRecord[];
try {
history = await session.readAppliedMigrations();
} catch {
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
}
const applied = validateAppliedHistory(options.aggregate, history);
const pending = Object.freeze(
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
);
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
if (pending.length === 0) {
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true });
}
let executingIdentity: string | undefined;
try {
await session.transaction(async (transaction) => {
await transaction.ensureHistoryTable();
for (const migration of pending) {
executingIdentity = migration.identity;
for (const statement of migration.sql.split(DRIZZLE_STATEMENT_BREAKPOINT)) {
await transaction.executeStatement(statement);
}
await transaction.insertAppliedMigration(freezeHistoryInsert(migration));
}
});
} catch {
throw runnerError("MIGRATION_EXECUTION_FAILED", executingIdentity);
}
const resultMigrations = Object.freeze(pending.map(freezeResultMigration));
return Object.freeze({
applied: resultMigrations,
appliedCount: resultMigrations.length,
noOp: false,
});
} catch (error) {
primaryError = error;
throw error;
} finally {
await cleanupApplySession(session, lockAcquired, primaryError);
}
}
+96
View File
@@ -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.",
]);
});
});
+943
View File
@@ -0,0 +1,943 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
applyModuleMigrations,
type AppliedMigrationRecord,
type MigrationApproval,
type MigrationApprovalProvider,
type MigrationDatabase,
type MigrationDatabaseSession,
type MigrationHistoryInsert,
MigrationRunnerError,
type MigrationRunnerErrorCode,
type MigrationTransaction,
planModuleMigrations,
} from "../src/migrations/runner.js";
import type {
DiscoveredModuleMigration,
ModuleMigrationAggregate,
} from "../src/modules/migration-discovery.js";
const appliedAt = "2026-07-21T12:00:00.000Z";
const hostileDatabaseError = new Error(
'postgresql://admin:[email protected]/ekklesia failed at /home/ekklesia/private.sql while running DROP TABLE "people_records"',
);
interface MigrationFixtureOptions {
readonly approvalRequired?: boolean;
readonly sql?: string;
readonly tag?: string;
}
interface ModuleFixture {
readonly id: string;
readonly migrations: readonly DiscoveredModuleMigration[];
}
function migration(
moduleId: string,
index: number,
options: MigrationFixtureOptions = {},
): DiscoveredModuleMigration {
const tag = options.tag ?? `${index.toString().padStart(4, "0")}_change`;
const sql = options.sql ?? `SELECT ${index + 1};\n`;
const approvalRequired = options.approvalRequired ?? false;
return Object.freeze({
approvalRequired,
breakpoints: true,
checksum: createHash("sha256").update(sql).digest("hex"),
destructive: Object.freeze(
approvalRequired
? {
mode: "approval-required" as const,
reason: "An administrator must approve this destructive fixture migration.",
}
: { mode: "none" as const },
),
file: `${tag}.sql`,
identity: `${moduleId}:${tag}`,
index,
moduleId,
sql,
tag,
timestamp: 1_700_000_000_000 + index,
});
}
function aggregate(...modules: readonly ModuleFixture[]): ModuleMigrationAggregate {
const frozenModules = Object.freeze(
modules.map((module) =>
Object.freeze({
migrations: Object.freeze([...module.migrations]),
moduleId: module.id,
}),
),
);
return Object.freeze({
discoveredModules: Object.freeze([]),
migrations: Object.freeze(frozenModules.flatMap((module) => module.migrations)),
modules: frozenModules,
});
}
function historyRecord(
source: DiscoveredModuleMigration,
appliedOrder: number,
overrides: Partial<AppliedMigrationRecord> = {},
): AppliedMigrationRecord {
return Object.freeze({
appliedAt,
appliedOrder,
checksum: source.checksum,
identity: source.identity,
index: source.index,
moduleId: source.moduleId,
tag: source.tag,
...overrides,
});
}
type FailurePoint =
| "close"
| "commit"
| "ensure-history"
| "execute"
| "history-insert"
| "lock"
| "open"
| "read-history"
| "release-lock";
interface FakeDatabaseOptions {
readonly failures?: readonly FailurePoint[];
readonly history?: readonly AppliedMigrationRecord[];
readonly historyAfterLock?: readonly AppliedMigrationRecord[];
readonly historyTableExists?: boolean;
readonly lockResult?: boolean;
readonly statementFailureIndex?: number;
}
class FakeMigrationDatabase implements MigrationDatabase {
readonly events: string[] = [];
readonly executedStatements: string[] = [];
readonly failures: ReadonlySet<FailurePoint>;
readonly historyAfterLock: readonly AppliedMigrationRecord[] | undefined;
readonly insertedMigrations: MigrationHistoryInsert[] = [];
readonly lockResult: boolean;
readonly statementFailureIndex: number | undefined;
committedHistory: AppliedMigrationRecord[];
historyTableExists: boolean;
constructor(options: FakeDatabaseOptions = {}) {
this.committedHistory = [...(options.history ?? [])];
this.failures = new Set(options.failures ?? []);
this.historyAfterLock = options.historyAfterLock;
this.historyTableExists = options.historyTableExists ?? true;
this.lockResult = options.lockResult ?? true;
this.statementFailureIndex = options.statementFailureIndex;
}
async openSession(): Promise<MigrationDatabaseSession> {
this.events.push("session:open");
if (this.failures.has("open")) {
throw hostileDatabaseError;
}
return new FakeMigrationDatabaseSession(this);
}
}
class FakeMigrationDatabaseSession implements MigrationDatabaseSession {
readonly #database: FakeMigrationDatabase;
#statementCount = 0;
constructor(database: FakeMigrationDatabase) {
this.#database = database;
}
async acquireMigrationLock(timeoutMs: number): Promise<boolean> {
this.#database.events.push(`lock:acquire:${timeoutMs}`);
if (this.#database.failures.has("lock")) {
throw hostileDatabaseError;
}
if (this.#database.lockResult && this.#database.historyAfterLock) {
this.#database.committedHistory = [...this.#database.historyAfterLock];
}
return this.#database.lockResult;
}
async close(): Promise<void> {
this.#database.events.push("session:close");
if (this.#database.failures.has("close")) {
throw hostileDatabaseError;
}
}
async readAppliedMigrations(): Promise<readonly AppliedMigrationRecord[]> {
this.#database.events.push("history:read");
if (this.#database.failures.has("read-history")) {
throw hostileDatabaseError;
}
return Object.freeze([...this.#database.committedHistory]);
}
async releaseMigrationLock(): Promise<void> {
this.#database.events.push("lock:release");
if (this.#database.failures.has("release-lock")) {
throw hostileDatabaseError;
}
}
async transaction<T>(operation: (transaction: MigrationTransaction) => Promise<T>): Promise<T> {
this.#database.events.push("transaction:begin");
const stagedHistory = [...this.#database.committedHistory];
const stagedInserts: MigrationHistoryInsert[] = [];
let stagedTableExists = this.#database.historyTableExists;
const transaction = Object.freeze<MigrationTransaction>({
ensureHistoryTable: async () => {
this.#database.events.push("history:ensure");
if (this.#database.failures.has("ensure-history")) {
throw hostileDatabaseError;
}
stagedTableExists = true;
},
executeStatement: async (sql) => {
this.#database.events.push("sql:execute");
this.#database.executedStatements.push(sql);
const statementIndex = this.#statementCount;
this.#statementCount += 1;
if (
this.#database.failures.has("execute") ||
this.#database.statementFailureIndex === statementIndex
) {
throw hostileDatabaseError;
}
},
insertAppliedMigration: async (insert) => {
this.#database.events.push(`history:insert:${insert.identity}`);
if (this.#database.failures.has("history-insert")) {
throw hostileDatabaseError;
}
const frozenInsert = Object.freeze({ ...insert });
stagedInserts.push(frozenInsert);
stagedHistory.push(
Object.freeze({
...frozenInsert,
appliedAt,
appliedOrder: stagedHistory.length + 1,
}),
);
},
});
try {
const result = await operation(transaction);
if (this.#database.failures.has("commit")) {
throw hostileDatabaseError;
}
this.#database.committedHistory = stagedHistory;
this.#database.historyTableExists = stagedTableExists;
this.#database.insertedMigrations.push(...stagedInserts);
this.#database.events.push("transaction:commit");
return result;
} catch (error) {
this.#database.events.push("transaction:rollback");
throw error;
}
}
}
class CapturingApprovalProvider implements MigrationApprovalProvider {
readonly requests: (readonly MigrationApproval[])[] = [];
readonly #response: readonly MigrationApproval[];
readonly #throwError: boolean;
constructor(response: readonly MigrationApproval[], throwError = false) {
this.#response = response;
this.#throwError = throwError;
}
async getApprovals(
required: readonly MigrationApproval[],
): Promise<readonly MigrationApproval[]> {
this.requests.push(required);
if (this.#throwError) {
throw hostileDatabaseError;
}
return this.#response;
}
}
function approvalFor(source: DiscoveredModuleMigration): MigrationApproval {
return Object.freeze({
checksum: source.checksum,
identity: source.identity,
moduleId: source.moduleId,
});
}
async function captureRunnerError(
operation: Promise<unknown>,
code: MigrationRunnerErrorCode,
): Promise<MigrationRunnerError> {
try {
await operation;
} catch (error) {
expect(error).toBeInstanceOf(MigrationRunnerError);
const runnerError = error as MigrationRunnerError;
expect(runnerError.code).toBe(code);
expect(Object.isFrozen(runnerError)).toBe(true);
return runnerError;
}
throw new Error(`Expected ${code}.`);
}
function expectNoPersistentMutation(database: FakeMigrationDatabase): void {
expect(database.events).not.toContain("transaction:begin");
expect(database.events).not.toContain("history:ensure");
expect(database.events).not.toContain("sql:execute");
expect(database.insertedMigrations).toEqual([]);
}
describe("module migration planning", () => {
it("treats a fresh database with a missing history table as empty without writing", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ historyTableExists: false });
const result = await planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(result).toEqual({
appliedCount: 0,
approvalRequiredCount: 0,
entries: [
{
approvalRequired: false,
checksum: initial.checksum,
identity: initial.identity,
index: 0,
moduleId: "people",
status: "pending",
tag: initial.tag,
},
],
pendingCount: 1,
});
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
expect(database.historyTableExists).toBe(false);
expectNoPersistentMutation(database);
});
it("returns an empty frozen plan when no installed module owns migrations", async () => {
const database = new FakeMigrationDatabase({ historyTableExists: false });
const inventory = aggregate({ id: "announcements", migrations: [] });
const result = await planModuleMigrations({
aggregate: inventory,
database,
});
const applyResult = await applyModuleMigrations({ aggregate: inventory, database });
expect(result).toEqual({
appliedCount: 0,
approvalRequiredCount: 0,
entries: [],
pendingCount: 0,
});
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.entries)).toBe(true);
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(database.historyTableExists).toBe(false);
expect(database.events).not.toContain("transaction:begin");
});
it("reports applied, pending, and approval-blocked entries in aggregate order", async () => {
const peopleInitial = migration("people", 0, { tag: "0000_initial" });
const peopleArchive = migration("people", 1, {
approvalRequired: true,
tag: "0001_archive",
});
const calendarInitial = migration("calendar", 0, { tag: "0000_initial" });
const inventory = aggregate(
{ id: "people", migrations: [peopleInitial, peopleArchive] },
{ id: "calendar", migrations: [calendarInitial] },
);
const result = await planModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({ history: [historyRecord(peopleInitial, 1)] }),
});
expect(result.entries.map((entry) => [entry.identity, entry.status])).toEqual([
[peopleInitial.identity, "applied"],
[peopleArchive.identity, "approval-blocked"],
[calendarInitial.identity, "pending"],
]);
expect(result).toMatchObject({
appliedCount: 1,
approvalRequiredCount: 1,
pendingCount: 2,
});
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.entries)).toBe(true);
expect(result.entries.every(Object.isFrozen)).toBe(true);
});
it("accepts historical interleaving while enforcing each module's journal prefix", async () => {
const peopleInitial = migration("people", 0);
const peopleLater = migration("people", 1);
const calendarInitial = migration("calendar", 0);
const inventory = aggregate(
{ id: "people", migrations: [peopleInitial, peopleLater] },
{ id: "calendar", migrations: [calendarInitial] },
);
const database = new FakeMigrationDatabase({
history: [
historyRecord(peopleInitial, 1),
historyRecord(calendarInitial, 2),
historyRecord(peopleLater, 3),
],
});
const result = await planModuleMigrations({ aggregate: inventory, database });
expect(result.entries.every((entry) => entry.status === "applied")).toBe(true);
expect(result.pendingCount).toBe(0);
});
it.each([
[
"a journal gap",
(_first: DiscoveredModuleMigration, second: DiscoveredModuleMigration) => [
historyRecord(second, 1),
],
],
[
"out-of-order module history",
(first: DiscoveredModuleMigration, second: DiscoveredModuleMigration) => [
historyRecord(second, 1),
historyRecord(first, 2),
],
],
])("rejects per-module history with %s", async (_label, makeHistory) => {
const first = migration("people", 0);
const second = migration("people", 1);
const database = new FakeMigrationDatabase({ history: makeHistory(first, second) });
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [first, second] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
expect(database.events.at(-1)).toBe("session:close");
});
it("allows gaps in database-assigned applied order", async () => {
const peopleInitial = migration("people", 0);
const peopleLater = migration("people", 1);
const calendarInitial = migration("calendar", 0);
const inventory = aggregate(
{ id: "people", migrations: [peopleInitial, peopleLater] },
{ id: "calendar", migrations: [calendarInitial] },
);
const result = await planModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({
history: [
historyRecord(peopleInitial, 7),
historyRecord(calendarInitial, 40),
historyRecord(peopleLater, 99),
],
}),
});
expect(result.appliedCount).toBe(3);
expect(result.pendingCount).toBe(0);
});
it.each([
[
"duplicate identity",
(source: DiscoveredModuleMigration) => [historyRecord(source, 1), historyRecord(source, 2)],
],
[
"inconsistent known metadata",
(source: DiscoveredModuleMigration) => [historyRecord(source, 1, { moduleId: "calendar" })],
],
])("rejects %s records without repairing history", async (_label, makeHistory) => {
const initial = migration("people", 0);
const history = makeHistory(initial);
const database = new FakeMigrationDatabase({ history });
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
expect(database.committedHistory).toEqual(history);
});
it("distinguishes exact checksum mismatches from unknown applied identities", async () => {
const initial = migration("people", 0);
const inventory = aggregate({ id: "people", migrations: [initial] });
const mismatch = new FakeMigrationDatabase({
history: [historyRecord(initial, 1, { checksum: "0".repeat(64) })],
});
const unknown = new FakeMigrationDatabase({
history: [
historyRecord(initial, 1, {
identity: "hostile:postgresql-admin-secret-private-path",
}),
],
});
const mismatchError = await captureRunnerError(
planModuleMigrations({ aggregate: inventory, database: mismatch }),
"MIGRATION_CHECKSUM_MISMATCH",
);
const unknownError = await captureRunnerError(
planModuleMigrations({ aggregate: inventory, database: unknown }),
"MIGRATION_UNKNOWN_APPLIED",
);
expect(mismatchError.migrationIdentity).toBe(initial.identity);
expect(unknownError.message).not.toContain("hostile");
expect(unknownError.message).not.toContain("private-path");
});
});
describe("module migration apply", () => {
it("acquires the lock before reading history and applies missing-history work atomically", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ historyTableExists: false });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(database.events).toEqual([
"session:open",
"lock:acquire:10000",
"history:read",
"transaction:begin",
"history:ensure",
"sql:execute",
`history:insert:${initial.identity}`,
"transaction:commit",
"lock:release",
"session:close",
]);
expect(database.historyTableExists).toBe(true);
expect(database.committedHistory.map((record) => record.identity)).toEqual([initial.identity]);
expect(result).toEqual({
applied: [
{
checksum: initial.checksum,
identity: initial.identity,
index: initial.index,
moduleId: initial.moduleId,
tag: initial.tag,
},
],
appliedCount: 1,
noOp: false,
});
});
it("re-reads history under the lock before deciding which migrations remain", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({
history: [],
historyAfterLock: [historyRecord(initial, 1)],
});
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
});
expect(result.applied.map((entry) => entry.identity)).toEqual([later.identity]);
expect(database.executedStatements).toEqual([later.sql]);
expect(database.events.indexOf("lock:acquire:10000")).toBeLessThan(
database.events.indexOf("history:read"),
);
});
it("returns a genuine frozen no-op when history is already current", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.applied)).toBe(true);
expectNoPersistentMutation(database);
expect(database.events).toEqual([
"session:open",
"lock:acquire:10000",
"history:read",
"lock:release",
"session:close",
]);
});
it("returns a locked no-op without creating history for an inventory with no migrations", async () => {
const database = new FakeMigrationDatabase({ historyTableExists: false });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "announcements", migrations: [] }),
database,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(database.historyTableExists).toBe(false);
expect(database.events).toEqual([
"session:open",
"lock:acquire:10000",
"history:read",
"lock:release",
"session:close",
]);
});
it("applies a later migration after a valid existing prefix", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
});
expect(result.applied.map((entry) => entry.identity)).toEqual([later.identity]);
expect(database.committedHistory.map((record) => record.identity)).toEqual([
initial.identity,
later.identity,
]);
});
it("executes dependency-first aggregate order and native journal order in one batch", async () => {
const peopleInitial = migration("people", 0);
const peopleLater = migration("people", 1);
const calendarInitial = migration("calendar", 0);
const database = new FakeMigrationDatabase();
const result = await applyModuleMigrations({
aggregate: aggregate(
{ id: "people", migrations: [peopleInitial, peopleLater] },
{ id: "calendar", migrations: [calendarInitial] },
),
database,
});
expect(result.applied.map((entry) => entry.identity)).toEqual([
peopleInitial.identity,
peopleLater.identity,
calendarInitial.identity,
]);
expect(database.events.filter((event) => event === "transaction:begin")).toHaveLength(1);
expect(database.events.filter((event) => event === "transaction:commit")).toHaveLength(1);
expect(database.insertedMigrations.map((entry) => entry.identity)).toEqual(
result.applied.map((entry) => entry.identity),
);
});
it("preserves exact Drizzle breakpoints without naively splitting semicolons", async () => {
const sql =
'CREATE TABLE "people_records" ("id" integer);\n--> statement-breakpoint\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;';
const initial = migration("people", 0, { sql });
const database = new FakeMigrationDatabase();
await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(database.executedStatements).toEqual([
'CREATE TABLE "people_records" ("id" integer);\n',
'\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;',
]);
expect(database.events.indexOf(`history:insert:${initial.identity}`)).toBeGreaterThan(
database.events.lastIndexOf("sql:execute"),
);
});
it("rolls back the whole pending batch on a later SQL failure and preserves prior history", async () => {
const prior = migration("people", 0);
const pendingFirst = migration("people", 1);
const pendingSecond = migration("people", 2);
const database = new FakeMigrationDatabase({
history: [historyRecord(prior, 1)],
statementFailureIndex: 1,
});
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [prior, pendingFirst, pendingSecond] }),
database,
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(error.migrationIdentity).toBe(pendingSecond.identity);
expect(database.committedHistory).toEqual([historyRecord(prior, 1)]);
expect(database.insertedMigrations).toEqual([]);
expect(database.events).toContain(`history:insert:${pendingFirst.identity}`);
expect(database.events).toContain("transaction:rollback");
expect(database.events).not.toContain("transaction:commit");
expect(database.events.slice(-2)).toEqual(["lock:release", "session:close"]);
});
it("rolls back SQL when the matching history insertion fails", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ failures: ["history-insert"] });
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(database.executedStatements).toEqual([initial.sql]);
expect(database.committedHistory).toEqual([]);
expect(database.events).toContain("transaction:rollback");
});
it("publishes no success when commit fails and still releases the session", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ failures: ["commit"] });
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(database.committedHistory).toEqual([]);
expect(database.insertedMigrations).toEqual([]);
expect(database.events).toContain("transaction:rollback");
expect(database.events.slice(-2)).toEqual(["lock:release", "session:close"]);
});
it("times out without reading history or trying to release an unheld lock", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ lockResult: false });
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
lockTimeoutMs: 250,
}),
"MIGRATION_LOCK_TIMEOUT",
);
expect(database.events).toEqual(["session:open", "lock:acquire:250", "session:close"]);
expectNoPersistentMutation(database);
});
it("does not ask for approval for ordinary migrations", async () => {
const initial = migration("people", 0);
const approvals = new CapturingApprovalProvider([]);
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
approvals,
database: new FakeMigrationDatabase(),
});
expect(result.appliedCount).toBe(1);
expect(approvals.requests).toEqual([]);
});
it("accepts an exact destructive approval bound to module, identity, and checksum", async () => {
const destructive = migration("people", 0, { approvalRequired: true });
const approvals = new CapturingApprovalProvider([approvalFor(destructive)]);
const database = new FakeMigrationDatabase();
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [destructive] }),
approvals,
database,
});
expect(result.applied.map((entry) => entry.identity)).toEqual([destructive.identity]);
expect(approvals.requests).toHaveLength(1);
expect(approvals.requests[0]).toEqual([approvalFor(destructive)]);
expect(Object.isFrozen(approvals.requests[0])).toBe(true);
expect(approvals.requests[0]?.every(Object.isFrozen)).toBe(true);
});
it.each([
["missing", []],
["wildcard-like", [{ moduleId: "*", identity: "*", checksum: "*" }]],
["wrong identity", [{ moduleId: "people", identity: "people:*", checksum: "CHECKSUM" }]],
["wrong module", [{ moduleId: "calendar", identity: "IDENTITY", checksum: "CHECKSUM" }]],
["wrong checksum", [{ moduleId: "people", identity: "IDENTITY", checksum: "0".repeat(64) }]],
])("blocks a destructive migration for a %s approval before mutation", async (_label, raw) => {
const destructive = migration("people", 0, { approvalRequired: true });
const supplied = raw.map((entry) =>
Object.freeze({
checksum: entry.checksum === "CHECKSUM" ? destructive.checksum : entry.checksum,
identity: entry.identity === "IDENTITY" ? destructive.identity : entry.identity,
moduleId: entry.moduleId,
}),
);
const database = new FakeMigrationDatabase();
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [destructive] }),
approvals: new CapturingApprovalProvider(supplied),
database,
}),
"MIGRATION_APPROVAL_REQUIRED",
);
expect(error.migrationIdentity).toBe(destructive.identity);
expectNoPersistentMutation(database);
expect(database.events.slice(-2)).toEqual(["lock:release", "session:close"]);
});
it("preflights every destructive approval before starting any persistent mutation", async () => {
const first = migration("people", 0, { approvalRequired: true });
const second = migration("people", 1, { approvalRequired: true });
const approvals = new CapturingApprovalProvider([approvalFor(first)]);
const database = new FakeMigrationDatabase();
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [first, second] }),
approvals,
database,
}),
"MIGRATION_APPROVAL_REQUIRED",
);
expect(approvals.requests[0]).toEqual([approvalFor(first), approvalFor(second)]);
expectNoPersistentMutation(database);
});
it("returns frozen deterministic applied results", async () => {
const first = migration("people", 0);
const second = migration("people", 1);
const inventory = aggregate({ id: "people", migrations: [first, second] });
const [left, right] = await Promise.all([
applyModuleMigrations({ aggregate: inventory, database: new FakeMigrationDatabase() }),
applyModuleMigrations({ aggregate: inventory, database: new FakeMigrationDatabase() }),
]);
expect(left).toEqual(right);
expect(Object.isFrozen(left)).toBe(true);
expect(Object.isFrozen(left.applied)).toBe(true);
expect(left.applied.every(Object.isFrozen)).toBe(true);
});
});
describe("module migration failures and side effects", () => {
it("sanitizes hostile connection, history, approval, and execution failures", async () => {
const ordinary = migration("people", 0);
const destructive = migration("people", 0, { approvalRequired: true });
const inventory = aggregate({ id: "people", migrations: [ordinary] });
const cases: readonly [Promise<unknown>, MigrationRunnerErrorCode][] = [
[
planModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({ failures: ["open"] }),
}),
"MIGRATION_CONNECTION_FAILED",
],
[
planModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({ failures: ["read-history"] }),
}),
"MIGRATION_HISTORY_READ_FAILED",
],
[
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [destructive] }),
approvals: new CapturingApprovalProvider([], true),
database: new FakeMigrationDatabase(),
}),
"MIGRATION_APPROVAL_LOOKUP_FAILED",
],
[
applyModuleMigrations({
aggregate: inventory,
database: new FakeMigrationDatabase({ failures: ["execute"] }),
}),
"MIGRATION_EXECUTION_FAILED",
],
];
for (const [operation, code] of cases) {
const error = await captureRunnerError(operation, code);
const publicText = `${error.message}\n${error.stack ?? ""}`;
expect(publicText).not.toContain("super-secret");
expect(publicText).not.toContain("private-db.internal");
expect(publicText).not.toContain("/home/ekklesia");
expect(publicText).not.toContain("DROP TABLE");
expect("cause" in error).toBe(false);
}
});
it("always closes sessions and preserves a primary error when cleanup also fails", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({
failures: ["read-history", "release-lock", "close"],
});
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_HISTORY_READ_FAILED",
);
expect(error.code).toBe("MIGRATION_HISTORY_READ_FAILED");
expect(database.events.slice(-2)).toEqual(["lock:release", "session:close"]);
});
it("reports cleanup failure when successful work cannot release its resources", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ failures: ["release-lock"] });
await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
}),
"MIGRATION_CLEANUP_FAILED",
);
expect(database.events.at(-1)).toBe("session:close");
});
it("does not connect or execute SQL merely by importing the runner", async () => {
const database = new FakeMigrationDatabase();
await import("../src/migrations/runner.js");
expect(database.events).toEqual([]);
expect(database.executedStatements).toEqual([]);
expect(database.committedHistory).toEqual([]);
});
});
@@ -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");
});
});