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