feat: add transactional module migration runner
This commit is contained in:
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user