feat: add transactional module migration runner
This commit is contained in:
+7
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 +1,2 @@
|
||||
export * from "./modules/index.js";
|
||||
export * from "./migrations/index.js";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./postgres-database.js";
|
||||
export * from "./runner.js";
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user