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