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
+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);
}
}