feat: add Ekklesia doctor command
CI / verify (pull_request) Successful in 20s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s

This commit is contained in:
ekklesia
2026-07-21 10:42:21 -05:00
parent f89d575dd4
commit 02ee3ccd80
18 changed files with 1546 additions and 7 deletions
+56
View File
@@ -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);
}
+397
View File
@@ -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,
]);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./doctor.js";
export * from "./doctor-report.js";