feat: add Ekklesia doctor command
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { diagnoseEkklesia, doctorExitCode, formatDoctorReport } from "../diagnostics/index.js";
|
||||
|
||||
interface ModuleSdkPackageDocument {
|
||||
readonly version?: unknown;
|
||||
}
|
||||
|
||||
interface RootPackageDocument extends ModuleSdkPackageDocument {
|
||||
readonly engines?: {
|
||||
readonly node?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson(pathname: string): Promise<unknown> {
|
||||
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
function rootPackageDocument(value: unknown): RootPackageDocument {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TypeError("Invalid workspace package metadata.");
|
||||
}
|
||||
return value as RootPackageDocument;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new TypeError("Missing workspace package metadata.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<number> {
|
||||
const [rootValue, moduleSdkValue] = await Promise.all([
|
||||
readJson(path.join(repositoryRoot, "package.json")),
|
||||
readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")),
|
||||
]);
|
||||
const rootPackage = rootPackageDocument(rootValue);
|
||||
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
||||
|
||||
const report = await diagnoseEkklesia({
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
platformVersion: requiredString(rootPackage.version),
|
||||
},
|
||||
runtimeRange: requiredString(rootPackage.engines?.node),
|
||||
runtimeVersion: process.versions.node,
|
||||
});
|
||||
|
||||
console.log(formatDoctorReport(report).join("\n"));
|
||||
return doctorExitCode(report);
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
runDoctorCommand()
|
||||
.then((exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
})
|
||||
.catch(() => {
|
||||
console.error("Ekklesia Doctor failed before diagnostics could be composed.");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { DoctorCheck, DoctorCheckId, DoctorReport, DoctorResult } from "./doctor.js";
|
||||
|
||||
interface DoctorSection {
|
||||
readonly checkIds: readonly DoctorCheckId[];
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSection {
|
||||
return Object.freeze({ checkIds: Object.freeze([...checkIds]), title });
|
||||
}
|
||||
|
||||
const sections: readonly DoctorSection[] = Object.freeze([
|
||||
section("Platform", ["platform.runtime"]),
|
||||
section("Modules", ["modules.discovery", "modules.dependencies"]),
|
||||
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
|
||||
section("Object storage", ["object-storage.integration"]),
|
||||
]);
|
||||
|
||||
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
|
||||
failure: "✗",
|
||||
pass: "✓",
|
||||
skipped: "○",
|
||||
warning: "!",
|
||||
});
|
||||
|
||||
const resultLabels: Readonly<Record<DoctorResult, string>> = Object.freeze({
|
||||
"not-ready": "NOT READY",
|
||||
ready: "READY",
|
||||
"ready-with-warnings": "READY WITH WARNINGS",
|
||||
});
|
||||
|
||||
export function formatDoctorReport(report: DoctorReport): readonly string[] {
|
||||
const checks = new Map(report.checks.map((check) => [check.id, check]));
|
||||
const lines = ["Ekklesia Doctor", ""];
|
||||
|
||||
for (const [sectionIndex, section] of sections.entries()) {
|
||||
lines.push(section.title);
|
||||
for (const checkId of section.checkIds) {
|
||||
const check = checks.get(checkId);
|
||||
if (!check) {
|
||||
continue;
|
||||
}
|
||||
lines.push(` ${statusSymbols[check.status]} ${check.summary}`);
|
||||
for (const detail of check.details) {
|
||||
lines.push(` ${detail}`);
|
||||
}
|
||||
}
|
||||
if (sectionIndex < sections.length - 1) {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("", `Result: ${resultLabels[report.result]}`);
|
||||
return Object.freeze(lines);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { isVersionCompatible, moduleIdSchema, semanticVersionSchema } from "@ekklesia/contracts";
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
type MigrationDatabase,
|
||||
type MigrationPlanResult,
|
||||
MigrationRunnerError,
|
||||
PostgresMigrationDatabaseError,
|
||||
type PostgresMigrationDatabaseOptions,
|
||||
planModuleMigrations,
|
||||
} from "../migrations/index.js";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
discoverModuleMigrations,
|
||||
discoverModuleSchemas,
|
||||
discoverModules,
|
||||
type ModuleDiscoveryOptions,
|
||||
type ModuleMigrationAggregate,
|
||||
type ModuleSchemaAggregate,
|
||||
} from "../modules/index.js";
|
||||
|
||||
export type DoctorCheckId =
|
||||
| "platform.runtime"
|
||||
| "modules.discovery"
|
||||
| "modules.dependencies"
|
||||
| "schemas.discovery"
|
||||
| "migrations.discovery"
|
||||
| "postgresql.connection"
|
||||
| "postgresql.plan"
|
||||
| "object-storage.integration";
|
||||
|
||||
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
|
||||
|
||||
export interface DoctorCheck {
|
||||
readonly details: readonly string[];
|
||||
readonly id: DoctorCheckId;
|
||||
readonly status: DoctorCheckStatus;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
export interface DoctorReport {
|
||||
readonly checks: readonly DoctorCheck[];
|
||||
readonly result: DoctorResult;
|
||||
}
|
||||
|
||||
export interface DoctorOptions {
|
||||
readonly databaseUrl?: string | undefined;
|
||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||
readonly runtimeRange: string;
|
||||
readonly runtimeVersion: string;
|
||||
}
|
||||
|
||||
export interface DoctorDependencies {
|
||||
readonly createMigrationDatabase: (
|
||||
connectionString: string,
|
||||
options: PostgresMigrationDatabaseOptions,
|
||||
) => MigrationDatabase;
|
||||
readonly discoverMigrations: (
|
||||
options: ModuleDiscoveryOptions,
|
||||
) => Promise<ModuleMigrationAggregate>;
|
||||
readonly discoverModules: (
|
||||
options: ModuleDiscoveryOptions,
|
||||
) => Promise<readonly DiscoveredModule[]>;
|
||||
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
|
||||
readonly planMigrations: (options: {
|
||||
readonly aggregate: ModuleMigrationAggregate;
|
||||
readonly database: MigrationDatabase;
|
||||
}) => Promise<MigrationPlanResult>;
|
||||
readonly runtimeSatisfies: (version: string, range: string) => boolean;
|
||||
}
|
||||
|
||||
const doctorPostgresTimeoutMs = 10_000;
|
||||
const checksumPattern = /^[a-f0-9]{64}$/u;
|
||||
const migrationIdentityPattern =
|
||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
|
||||
|
||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||
discoverMigrations: discoverModuleMigrations,
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
runtimeSatisfies: isVersionCompatible,
|
||||
});
|
||||
|
||||
function plural(count: number, singular: string): string {
|
||||
return count === 1 ? singular : `${singular}s`;
|
||||
}
|
||||
|
||||
function freezeCheck(
|
||||
id: DoctorCheckId,
|
||||
status: DoctorCheckStatus,
|
||||
summary: string,
|
||||
details: readonly string[] = [],
|
||||
): DoctorCheck {
|
||||
return Object.freeze({
|
||||
details: Object.freeze([...details]),
|
||||
id,
|
||||
status,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
export function deriveDoctorResult(checks: readonly DoctorCheck[]): DoctorResult {
|
||||
if (checks.some((check) => check.status === "failure")) {
|
||||
return "not-ready";
|
||||
}
|
||||
if (checks.some((check) => check.status === "warning" || check.status === "skipped")) {
|
||||
return "ready-with-warnings";
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
||||
const frozenChecks = Object.freeze([...checks]);
|
||||
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
||||
}
|
||||
|
||||
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||
return modules.map((module) => {
|
||||
const id = module.manifest.id;
|
||||
const version = module.manifest.version;
|
||||
if (
|
||||
!moduleIdSchema.safeParse(id).success ||
|
||||
!semanticVersionSchema.safeParse(version).success
|
||||
) {
|
||||
throw new TypeError("Unsafe module diagnostic value.");
|
||||
}
|
||||
return `${id} ${version}`;
|
||||
});
|
||||
}
|
||||
|
||||
function safeMigrationReference(identity: string, checksum: string): string {
|
||||
if (!migrationIdentityPattern.test(identity) || !checksumPattern.test(checksum)) {
|
||||
throw new TypeError("Unsafe migration diagnostic value.");
|
||||
}
|
||||
return `${identity} · ${checksum.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||
const details =
|
||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
||||
? ["The installed module inventory is invalid."]
|
||||
: ["Static module discovery could not be completed."];
|
||||
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
|
||||
}
|
||||
|
||||
function schemaDiscoveryFailure(): DoctorCheck {
|
||||
return freezeCheck("schemas.discovery", "failure", "Schema validation failed", [
|
||||
"Installed module schemas could not be validated.",
|
||||
]);
|
||||
}
|
||||
|
||||
function migrationDiscoveryFailure(): DoctorCheck {
|
||||
return freezeCheck("migrations.discovery", "failure", "Migration validation failed", [
|
||||
"Installed module migrations could not be validated.",
|
||||
]);
|
||||
}
|
||||
|
||||
function configuredDatabaseFailure(error: unknown): readonly [DoctorCheck, DoctorCheck] {
|
||||
if (error instanceof MigrationRunnerError) {
|
||||
if (error.code === "MIGRATION_CONNECTION_FAILED") {
|
||||
return [
|
||||
freezeCheck("postgresql.connection", "failure", "Connection failed", [
|
||||
"The configured PostgreSQL database could not be reached.",
|
||||
]),
|
||||
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
||||
"A successful PostgreSQL connection is required.",
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
freezeCheck("postgresql.connection", "pass", "Connection established"),
|
||||
freezeCheck("postgresql.plan", "failure", "Migration history validation failed", [
|
||||
migrationPlanFailureDetail(error.code),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
if (error instanceof PostgresMigrationDatabaseError) {
|
||||
return [
|
||||
freezeCheck("postgresql.connection", "failure", "PostgreSQL configuration invalid", [
|
||||
"The configured database connection could not be prepared safely.",
|
||||
]),
|
||||
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
||||
"A valid PostgreSQL configuration is required.",
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
freezeCheck("postgresql.connection", "failure", "PostgreSQL diagnostic failed", [
|
||||
"The configured database could not be inspected safely.",
|
||||
]),
|
||||
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
||||
"PostgreSQL connectivity could not be confirmed.",
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function migrationPlanFailureDetail(code: MigrationRunnerError["code"]): string {
|
||||
switch (code) {
|
||||
case "MIGRATION_CHECKSUM_MISMATCH":
|
||||
return "An applied migration checksum disagrees with packaged inventory.";
|
||||
case "MIGRATION_HISTORY_INVALID":
|
||||
return "Applied migration history is invalid.";
|
||||
case "MIGRATION_HISTORY_READ_FAILED":
|
||||
return "Applied migration history could not be read safely.";
|
||||
case "MIGRATION_UNKNOWN_APPLIED":
|
||||
return "Applied migration history contains an unknown identity.";
|
||||
case "MIGRATION_CLEANUP_FAILED":
|
||||
return "The read-only PostgreSQL session did not close cleanly.";
|
||||
default:
|
||||
return "The read-only migration plan could not be completed safely.";
|
||||
}
|
||||
}
|
||||
|
||||
function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
|
||||
const pendingCount = plan.entries.filter((entry) => entry.status === "pending").length;
|
||||
const blockedCount = plan.entries.filter((entry) => entry.status === "approval-blocked").length;
|
||||
const appliedCount = plan.entries.filter((entry) => entry.status === "applied").length;
|
||||
|
||||
if (
|
||||
appliedCount !== plan.appliedCount ||
|
||||
pendingCount + blockedCount !== plan.pendingCount ||
|
||||
blockedCount !== plan.approvalRequiredCount
|
||||
) {
|
||||
throw new TypeError("Inconsistent migration plan diagnostic value.");
|
||||
}
|
||||
|
||||
const details = [
|
||||
`${appliedCount} applied · ${pendingCount} pending · ${blockedCount} approval-blocked`,
|
||||
...plan.entries.map((entry) => {
|
||||
if (!(["applied", "approval-blocked", "pending"] as const).includes(entry.status)) {
|
||||
throw new TypeError("Unsafe migration plan diagnostic value.");
|
||||
}
|
||||
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
|
||||
}),
|
||||
];
|
||||
|
||||
if (pendingCount + blockedCount === 0) {
|
||||
return freezeCheck("postgresql.plan", "pass", "Migration history current", details);
|
||||
}
|
||||
|
||||
const attentionCount = pendingCount + blockedCount;
|
||||
return freezeCheck(
|
||||
"postgresql.plan",
|
||||
"warning",
|
||||
attentionCount === 1
|
||||
? "1 migration requires attention"
|
||||
: `${attentionCount} migrations require attention`,
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
|
||||
return [
|
||||
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
|
||||
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
||||
"A validated migration inventory and configured PostgreSQL connection are required.",
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function doctorExitCode(report: DoctorReport): 0 | 1 {
|
||||
return report.result === "not-ready" ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function diagnoseEkklesia(
|
||||
options: DoctorOptions,
|
||||
dependencies: DoctorDependencies = productionDependencies,
|
||||
): Promise<DoctorReport> {
|
||||
let runtimeCheck: DoctorCheck;
|
||||
try {
|
||||
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
|
||||
? freezeCheck("platform.runtime", "pass", "Runtime version supported", [
|
||||
"Active Node.js satisfies the declared supported range.",
|
||||
])
|
||||
: freezeCheck("platform.runtime", "failure", "Runtime version unsupported", [
|
||||
"Install a Node.js version supported by this repository.",
|
||||
]);
|
||||
} catch {
|
||||
runtimeCheck = freezeCheck("platform.runtime", "failure", "Runtime validation failed", [
|
||||
"The active Node.js runtime could not be validated safely.",
|
||||
]);
|
||||
}
|
||||
|
||||
let discoveredModules: readonly DiscoveredModule[] | undefined;
|
||||
let moduleCheck: DoctorCheck;
|
||||
let dependencyCheck: DoctorCheck;
|
||||
try {
|
||||
discoveredModules = await dependencies.discoverModules(options.moduleDiscovery);
|
||||
const moduleDetails = safeModuleDetails(discoveredModules);
|
||||
moduleCheck = freezeCheck(
|
||||
"modules.discovery",
|
||||
"pass",
|
||||
"Module discovery complete",
|
||||
discoveredModules.length === 0 ? ["0 modules installed"] : moduleDetails,
|
||||
);
|
||||
dependencyCheck = freezeCheck("modules.dependencies", "pass", "Dependency graph valid", [
|
||||
discoveredModules.length === 0
|
||||
? "Dependency order is empty."
|
||||
: `Order: ${discoveredModules.map((module) => module.manifest.id).join(" → ")}`,
|
||||
]);
|
||||
} catch (error) {
|
||||
moduleCheck = moduleDiscoveryFailure(error);
|
||||
dependencyCheck = freezeCheck("modules.dependencies", "skipped", "Dependency check skipped", [
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
}
|
||||
|
||||
let schemaCheck: DoctorCheck;
|
||||
let migrationCheck: DoctorCheck;
|
||||
let migrations: ModuleMigrationAggregate | undefined;
|
||||
|
||||
if (!discoveredModules) {
|
||||
schemaCheck = freezeCheck("schemas.discovery", "skipped", "Schema validation skipped", [
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
migrationCheck = freezeCheck(
|
||||
"migrations.discovery",
|
||||
"skipped",
|
||||
"Migration validation skipped",
|
||||
["Complete module discovery is required."],
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const schemas = await dependencies.discoverSchemas(options.moduleDiscovery);
|
||||
schemaCheck = freezeCheck("schemas.discovery", "pass", "Schemas validated", [
|
||||
`${schemas.discoveredModules.length} ${plural(schemas.discoveredModules.length, "module")} · ${schemas.tables.length} ${plural(schemas.tables.length, "table")}`,
|
||||
]);
|
||||
} catch {
|
||||
schemaCheck = schemaDiscoveryFailure();
|
||||
}
|
||||
|
||||
try {
|
||||
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
|
||||
const inventory = migrations.migrations.map((migration) =>
|
||||
safeMigrationReference(migration.identity, migration.checksum),
|
||||
);
|
||||
migrationCheck = freezeCheck("migrations.discovery", "pass", "Migrations validated", [
|
||||
`${migrations.migrations.length} ${plural(migrations.migrations.length, "migration")} · checksums valid`,
|
||||
...inventory,
|
||||
]);
|
||||
} catch {
|
||||
migrations = undefined;
|
||||
migrationCheck = migrationDiscoveryFailure();
|
||||
}
|
||||
}
|
||||
|
||||
let postgresConnectionCheck: DoctorCheck;
|
||||
let postgresPlanCheck: DoctorCheck;
|
||||
const databaseUrl = options.databaseUrl;
|
||||
const databaseConfigured = typeof databaseUrl === "string" && databaseUrl.trim() !== "";
|
||||
|
||||
if (!migrations) {
|
||||
[postgresConnectionCheck, postgresPlanCheck] = skippedPostgresChecks(
|
||||
"Complete migration discovery is required.",
|
||||
);
|
||||
} else if (!databaseConfigured) {
|
||||
[postgresConnectionCheck, postgresPlanCheck] = skippedPostgresChecks(
|
||||
"DATABASE_URL is not configured.",
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const database = dependencies.createMigrationDatabase(databaseUrl, {
|
||||
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||
});
|
||||
const plan = await dependencies.planMigrations({ aggregate: migrations, database });
|
||||
postgresConnectionCheck = freezeCheck(
|
||||
"postgresql.connection",
|
||||
"pass",
|
||||
"Connection established",
|
||||
);
|
||||
postgresPlanCheck = migrationPlanCheck(plan);
|
||||
} catch (error) {
|
||||
[postgresConnectionCheck, postgresPlanCheck] = configuredDatabaseFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
||||
"Core storage integration has not been implemented yet.",
|
||||
]);
|
||||
|
||||
return publishReport([
|
||||
runtimeCheck,
|
||||
moduleCheck,
|
||||
dependencyCheck,
|
||||
schemaCheck,
|
||||
migrationCheck,
|
||||
postgresConnectionCheck,
|
||||
postgresPlanCheck,
|
||||
storageCheck,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./doctor.js";
|
||||
export * from "./doctor-report.js";
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./diagnostics/index.js";
|
||||
export * from "./modules/index.js";
|
||||
export * from "./migrations/index.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { Client, type ClientConfig, type QueryResultRow } from "pg";
|
||||
import type {
|
||||
AppliedMigrationRecord,
|
||||
MigrationDatabase,
|
||||
@@ -12,6 +12,7 @@ const historyTableName = "ekklesia_core_migrations";
|
||||
const advisoryLockNamespace = 1_162_562_380;
|
||||
const advisoryLockKey = 1_296_648_018;
|
||||
const advisoryLockPollIntervalMs = 50;
|
||||
const maximumPostgresTimeoutMs = 2_147_483_647;
|
||||
|
||||
const createHistoryTableSql = `
|
||||
CREATE TABLE IF NOT EXISTS "public"."ekklesia_core_migrations" (
|
||||
@@ -72,6 +73,11 @@ export class PostgresMigrationDatabaseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostgresMigrationDatabaseOptions {
|
||||
readonly connectionTimeoutMs?: number;
|
||||
readonly operationTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface HistoryExistenceRow extends QueryResultRow {
|
||||
readonly relation_kind: string;
|
||||
}
|
||||
@@ -124,6 +130,68 @@ function validateConnectionString(connectionString: string | undefined): string
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
function invalidConfiguration(message: string): PostgresMigrationDatabaseError {
|
||||
return databaseError("POSTGRES_MIGRATION_CONFIGURATION_INVALID", message);
|
||||
}
|
||||
|
||||
function validateTimeout(value: unknown, message: string): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value <= 0 ||
|
||||
value > maximumPostgresTimeoutMs
|
||||
) {
|
||||
throw invalidConfiguration(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createClientConfig(
|
||||
connectionString: string,
|
||||
options: PostgresMigrationDatabaseOptions | undefined,
|
||||
): ClientConfig {
|
||||
if (
|
||||
options !== undefined &&
|
||||
(typeof options !== "object" || options === null || Array.isArray(options))
|
||||
) {
|
||||
throw invalidConfiguration("The PostgreSQL migration database options are invalid.");
|
||||
}
|
||||
|
||||
let connectionTimeoutMs: unknown;
|
||||
let operationTimeoutMs: unknown;
|
||||
try {
|
||||
connectionTimeoutMs = options?.connectionTimeoutMs;
|
||||
operationTimeoutMs = options?.operationTimeoutMs;
|
||||
} catch {
|
||||
throw invalidConfiguration("The PostgreSQL migration database options are invalid.");
|
||||
}
|
||||
|
||||
const validatedConnectionTimeoutMs = validateTimeout(
|
||||
connectionTimeoutMs,
|
||||
"The PostgreSQL connection timeout is invalid.",
|
||||
);
|
||||
const validatedOperationTimeoutMs = validateTimeout(
|
||||
operationTimeoutMs,
|
||||
"The PostgreSQL operation timeout is invalid.",
|
||||
);
|
||||
|
||||
return {
|
||||
connectionString,
|
||||
...(validatedConnectionTimeoutMs === undefined
|
||||
? {}
|
||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||
...(validatedOperationTimeoutMs === undefined
|
||||
? {}
|
||||
: {
|
||||
query_timeout: validatedOperationTimeoutMs,
|
||||
statement_timeout: validatedOperationTimeoutMs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function isPostgresMigrationDatabaseError(error: unknown): error is PostgresMigrationDatabaseError {
|
||||
return error instanceof PostgresMigrationDatabaseError;
|
||||
}
|
||||
@@ -440,12 +508,14 @@ class PostgresMigrationDatabaseSession implements MigrationDatabaseSession {
|
||||
|
||||
export function createPostgresMigrationDatabase(
|
||||
connectionString: string | undefined,
|
||||
options?: PostgresMigrationDatabaseOptions,
|
||||
): MigrationDatabase {
|
||||
const validatedConnectionString = validateConnectionString(connectionString);
|
||||
const clientConfig = createClientConfig(validatedConnectionString, options);
|
||||
|
||||
return Object.freeze({
|
||||
async openSession(): Promise<MigrationDatabaseSession> {
|
||||
const client = new Client({ connectionString: validatedConnectionString });
|
||||
const client = new Client(clientConfig);
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
Reference in New Issue
Block a user