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