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
+4
View File
@@ -18,6 +18,10 @@
"./migrations": {
"types": "./dist/migrations/index.d.ts",
"import": "./dist/migrations/index.js"
},
"./diagnostics": {
"types": "./dist/diagnostics/index.d.ts",
"import": "./dist/diagnostics/index.js"
}
},
"scripts": {
+73
View File
@@ -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;
});
}
+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";
+1
View File
@@ -1,2 +1,3 @@
export * from "./diagnostics/index.js";
export * from "./modules/index.js";
export * from "./migrations/index.js";
+72 -2
View File
@@ -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();
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
const discoverModules = vi.fn();
const discoverModuleSchemas = vi.fn();
const discoverModuleMigrations = vi.fn();
const startModuleRuntime = vi.fn();
const createPostgresMigrationDatabase = vi.fn();
const planModuleMigrations = vi.fn();
vi.mock("../src/modules/index.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/modules/index.js")>()),
discoverModuleMigrations,
discoverModules,
discoverModuleSchemas,
startModuleRuntime,
}));
vi.mock("../src/migrations/index.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/migrations/index.js")>()),
createPostgresMigrationDatabase,
planModuleMigrations,
}));
describe("Doctor import safety", () => {
it("does not discover, connect, plan, or print when imported", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
await import("../src/diagnostics/index.js");
await import("../src/cli/doctor.js");
await import("../src/index.js");
expect(discoverModules).not.toHaveBeenCalled();
expect(discoverModuleSchemas).not.toHaveBeenCalled();
expect(discoverModuleMigrations).not.toHaveBeenCalled();
expect(startModuleRuntime).not.toHaveBeenCalled();
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
expect(planModuleMigrations).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
log.mockRestore();
error.mockRestore();
});
});
+173
View File
@@ -0,0 +1,173 @@
import { afterEach, describe, expect, it } from "vitest";
import {
type DoctorDependencies,
diagnoseEkklesia,
formatDoctorReport,
} from "../src/diagnostics/index.js";
import type { MigrationDatabase } from "../src/migrations/index.js";
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
const originalNoColor = process.env.NO_COLOR;
afterEach(() => {
if (originalNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = originalNoColor;
}
});
function zeroSchemaAggregate(): ModuleSchemaAggregate {
return Object.freeze({
discoveredModules: Object.freeze([]),
drizzleSchema: Object.freeze({}),
modules: Object.freeze([]),
tables: Object.freeze([]),
});
}
function zeroMigrationAggregate(): ModuleMigrationAggregate {
return Object.freeze({
discoveredModules: Object.freeze([]),
migrations: Object.freeze([]),
modules: Object.freeze([]),
});
}
function noConfigurationDependencies(): DoctorDependencies {
return {
createMigrationDatabase: () => {
throw new Error("database must not be constructed");
},
discoverMigrations: async () => zeroMigrationAggregate(),
discoverModules: async () => Object.freeze([]),
discoverSchemas: async () => zeroSchemaAggregate(),
planMigrations: async () => {
throw new Error("database must not be planned");
},
runtimeSatisfies: () => true,
};
}
describe("Ekklesia Doctor terminal report", () => {
it("renders the real zero-module and no-database shape exactly", async () => {
const report = await diagnoseEkklesia(
{
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
},
noConfigurationDependencies(),
);
expect(formatDoctorReport(report).join("\n")).toBe(`Ekklesia Doctor
Platform
✓ Runtime version supported
Active Node.js satisfies the declared supported range.
Modules
✓ Module discovery complete
0 modules installed
✓ Dependency graph valid
Dependency order is empty.
Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
0 migrations · checksums valid
PostgreSQL
○ Connection check skipped
DATABASE_URL is not configured.
○ Migration plan skipped
A validated migration inventory and configured PostgreSQL connection are required.
Object storage
○ Check unavailable
Core storage integration has not been implemented yet.
Result: READY WITH WARNINGS`);
});
it("does not depend on NO_COLOR or terminal interactivity", async () => {
const report = await diagnoseEkklesia(
{
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
},
noConfigurationDependencies(),
);
const baseline = formatDoctorReport(report);
const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
try {
process.env.NO_COLOR = "1";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
const interactiveWithoutColor = formatDoctorReport(report);
process.env.NO_COLOR = "";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
const nonInteractive = formatDoctorReport(report);
expect(interactiveWithoutColor).toEqual(baseline);
expect(nonInteractive).toEqual(baseline);
expect(interactiveWithoutColor.some((line) => line.includes("\u001B"))).toBe(false);
expect(Object.isFrozen(interactiveWithoutColor)).toBe(true);
} finally {
if (originalIsTtyDescriptor) {
Object.defineProperty(process.stdout, "isTTY", originalIsTtyDescriptor);
} else {
delete (process.stdout as { isTTY?: boolean }).isTTY;
}
}
});
it("never renders secrets from a hostile database exception", async () => {
const hostile =
"postgresql://admin:[email protected]/db SELECT password FROM users /home/me/.env";
const database = Object.freeze({}) as MigrationDatabase;
const dependencies: DoctorDependencies = {
...noConfigurationDependencies(),
createMigrationDatabase: () => database,
planMigrations: async () => {
throw new Error(hostile);
},
};
const report = await diagnoseEkklesia(
{
databaseUrl: "postgresql://another:[email protected]/db",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
},
dependencies,
);
const output = formatDoctorReport(report).join("\n");
for (const forbidden of [
"admin",
"secret",
"host.invalid",
"SELECT",
"password",
"/home/me",
"postgresql://",
]) {
expect(output).not.toContain(forbidden);
}
});
});
+441
View File
@@ -0,0 +1,441 @@
import type { ModuleManifest } from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest";
import {
type DoctorCheck,
type DoctorDependencies,
type DoctorOptions,
deriveDoctorResult,
diagnoseEkklesia,
doctorExitCode,
} from "../src/diagnostics/index.js";
import {
type MigrationDatabase,
type MigrationDatabaseSession,
type MigrationPlanResult,
MigrationRunnerError,
planModuleMigrations,
} from "../src/migrations/index.js";
import type {
DiscoveredModule,
DiscoveredModuleMigration,
ModuleMigrationAggregate,
ModuleSchemaAggregate,
} from "../src/modules/index.js";
const checksumA = "a".repeat(64);
const checksumB = "b".repeat(64);
const checksumC = "c".repeat(64);
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
return {
directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: {
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
dependencies: {
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} },
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 2,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
version,
} as unknown as ModuleManifest,
manifestPath: `/unpublished/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function migration(
moduleId: string,
tag: string,
checksum: string,
approvalRequired = false,
): DiscoveredModuleMigration {
const index = Number(tag.slice(0, tag.indexOf("_")));
return Object.freeze({
approvalRequired,
breakpoints: true,
checksum,
destructive: Object.freeze(
approvalRequired
? { mode: "approval-required" as const, reason: "Operator review is required." }
: { mode: "none" as const },
),
file: `${tag}.sql`,
identity: `${moduleId}:${tag}`,
index,
moduleId,
sql: "SELECT 1;",
tag,
timestamp: index + 1,
});
}
function migrationAggregate(
migrations: readonly DiscoveredModuleMigration[] = [],
modules: readonly DiscoveredModule[] = [],
): ModuleMigrationAggregate {
const moduleIds = [...new Set(migrations.map((entry) => entry.moduleId))];
return Object.freeze({
discoveredModules: Object.freeze([...modules]),
migrations: Object.freeze([...migrations]),
modules: Object.freeze(
moduleIds.map((moduleId) =>
Object.freeze({
migrations: Object.freeze(migrations.filter((entry) => entry.moduleId === moduleId)),
moduleId,
}),
),
),
});
}
function schemaAggregate(modules: readonly DiscoveredModule[] = []): ModuleSchemaAggregate {
return Object.freeze({
discoveredModules: Object.freeze([...modules]),
drizzleSchema: Object.freeze({}),
modules: Object.freeze([]),
tables: Object.freeze([]),
});
}
function emptyDatabase(): MigrationDatabase {
return Object.freeze({
openSession: vi.fn().mockRejectedValue(new Error("unused database")),
});
}
function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
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: Object.freeze([...entries]),
pendingCount: entries.length - appliedCount,
});
}
function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies {
return {
createMigrationDatabase: vi.fn(() => emptyDatabase()),
discoverMigrations: vi.fn(async () => migrationAggregate()),
discoverModules: vi.fn(async () => Object.freeze([])),
discoverSchemas: vi.fn(async () => schemaAggregate()),
planMigrations: vi.fn(async () => plan()),
runtimeSatisfies: vi.fn(() => true),
...overrides,
};
}
function options(databaseUrl?: string): DoctorOptions {
return {
...(databaseUrl === undefined ? {} : { databaseUrl }),
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
};
}
function check(
report: Awaited<ReturnType<typeof diagnoseEkklesia>>,
id: DoctorCheck["id"],
): DoctorCheck {
const found = report.checks.find((candidate) => candidate.id === id);
if (!found) {
throw new Error(`Missing test check ${id}.`);
}
return found;
}
function planEntry(
identity: string,
checksum: string,
status: "applied" | "approval-blocked" | "pending",
): MigrationPlanResult["entries"][number] {
const [moduleId = "", tag = ""] = identity.split(":");
return Object.freeze({
approvalRequired: status === "approval-blocked",
checksum,
identity,
index: Number(tag.slice(0, tag.indexOf("_"))),
moduleId,
status,
tag,
});
}
describe("Ekklesia Doctor diagnostics", () => {
it("publishes a healthy deterministic static report without database configuration", async () => {
const people = discoveredModule("people");
const calendar = discoveredModule("calendar", "0.2.0");
const inventory = migrationAggregate(
[migration("people", "0000_initial", checksumA)],
[people, calendar],
);
const services = dependencies({
discoverMigrations: vi.fn(async () => inventory),
discoverModules: vi.fn(async () => Object.freeze([people, calendar])),
discoverSchemas: vi.fn(async () => schemaAggregate([people, calendar])),
});
const report = await diagnoseEkklesia(options(), services);
expect(report.checks.map((entry) => entry.id)).toEqual([
"platform.runtime",
"modules.discovery",
"modules.dependencies",
"schemas.discovery",
"migrations.discovery",
"postgresql.connection",
"postgresql.plan",
"object-storage.integration",
]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
expect(check(report, "migrations.discovery").details).toEqual([
"1 migration · checksums valid",
"people:0000_initial · aaaaaaaaaaaa",
]);
expect(JSON.stringify(report)).not.toContain(checksumA);
expect(report.result).toBe("ready-with-warnings");
expect(doctorExitCode(report)).toBe(0);
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
expect(Object.isFrozen(report)).toBe(true);
expect(Object.isFrozen(report.checks)).toBe(true);
expect(report.checks.every(Object.isFrozen)).toBe(true);
expect(report.checks.every((entry) => Object.isFrozen(entry.details))).toBe(true);
});
it("derives aggregate results and exit codes with failure precedence", () => {
const pass = { details: [], id: "platform.runtime", status: "pass", summary: "pass" } as const;
const warning = { ...pass, status: "warning" } as const;
const skipped = { ...pass, status: "skipped" } as const;
const failure = { ...pass, status: "failure" } as const;
expect(deriveDoctorResult([pass])).toBe("ready");
expect(deriveDoctorResult([pass, warning])).toBe("ready-with-warnings");
expect(deriveDoctorResult([pass, skipped])).toBe("ready-with-warnings");
expect(deriveDoctorResult([warning, failure])).toBe("not-ready");
expect(doctorExitCode({ checks: [pass], result: "ready" })).toBe(0);
expect(doctorExitCode({ checks: [warning], result: "ready-with-warnings" })).toBe(0);
expect(doctorExitCode({ checks: [failure], result: "not-ready" })).toBe(1);
});
it("uses the configured production planner seam with finite timeouts", async () => {
const createMigrationDatabase = vi.fn(() => emptyDatabase());
const planMigrations = vi.fn(async () => plan());
const report = await diagnoseEkklesia(
options("postgresql://not-rendered.invalid/ekklesia"),
dependencies({ createMigrationDatabase, planMigrations }),
);
expect(createMigrationDatabase).toHaveBeenCalledWith(
"postgresql://not-rendered.invalid/ekklesia",
{ connectionTimeoutMs: 10_000, operationTimeoutMs: 10_000 },
);
expect(planMigrations).toHaveBeenCalledOnce();
expect(check(report, "postgresql.connection")).toMatchObject({
status: "pass",
summary: "Connection established",
});
expect(check(report, "postgresql.plan")).toMatchObject({
details: ["0 applied · 0 pending · 0 approval-blocked"],
status: "pass",
});
});
it("uses the read-only planner for a fresh database and closes without mutation", async () => {
const pending = migration("people", "0000_initial", checksumA);
const aggregate = migrationAggregate([pending], [discoveredModule("people")]);
const session: MigrationDatabaseSession = {
acquireMigrationLock: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
readAppliedMigrations: vi.fn().mockResolvedValue(Object.freeze([])),
releaseMigrationLock: vi.fn(),
transaction: vi.fn(),
};
const database: MigrationDatabase = Object.freeze({
openSession: vi.fn().mockResolvedValue(session),
});
const report = await diagnoseEkklesia(
options("postgresql://not-rendered.invalid/ekklesia"),
dependencies({
createMigrationDatabase: vi.fn(() => database),
discoverMigrations: vi.fn(async () => aggregate),
planMigrations: planModuleMigrations,
}),
);
expect(session.readAppliedMigrations).toHaveBeenCalledOnce();
expect(session.close).toHaveBeenCalledOnce();
expect(session.acquireMigrationLock).not.toHaveBeenCalled();
expect(session.releaseMigrationLock).not.toHaveBeenCalled();
expect(session.transaction).not.toHaveBeenCalled();
expect(check(report, "postgresql.plan")).toMatchObject({
status: "warning",
summary: "1 migration requires attention",
});
});
it("reports applied, pending, and approval-blocked entries in plan order", async () => {
const entries = Object.freeze([
planEntry("people:0000_initial", checksumA, "applied"),
planEntry("people:0001_add_email", checksumB, "pending"),
planEntry("people:0002_remove_old", checksumC, "approval-blocked"),
]);
const report = await diagnoseEkklesia(
options("postgresql://not-rendered.invalid/ekklesia"),
dependencies({ planMigrations: vi.fn(async () => plan(entries)) }),
);
expect(check(report, "postgresql.plan")).toEqual({
details: [
"1 applied · 1 pending · 1 approval-blocked",
"applied · people:0000_initial · aaaaaaaaaaaa",
"pending · people:0001_add_email · bbbbbbbbbbbb",
"approval-blocked · people:0002_remove_old · cccccccccccc",
],
id: "postgresql.plan",
status: "warning",
summary: "2 migrations require attention",
});
});
it("fails a configured connection without attempting a plan result", async () => {
const report = await diagnoseEkklesia(
options("postgresql://not-rendered.invalid/ekklesia"),
dependencies({
planMigrations: vi
.fn()
.mockRejectedValue(new MigrationRunnerError("MIGRATION_CONNECTION_FAILED")),
}),
);
expect(check(report, "postgresql.connection").status).toBe("failure");
expect(check(report, "postgresql.plan").status).toBe("skipped");
expect(report.result).toBe("not-ready");
expect(doctorExitCode(report)).toBe(1);
});
it("reports invalid history as a planning failure after connectivity succeeds", async () => {
const report = await diagnoseEkklesia(
options("postgresql://not-rendered.invalid/ekklesia"),
dependencies({
planMigrations: vi
.fn()
.mockRejectedValue(
new MigrationRunnerError("MIGRATION_HISTORY_INVALID", "never-render-this"),
),
}),
);
expect(check(report, "postgresql.connection").status).toBe("pass");
expect(check(report, "postgresql.plan")).toMatchObject({
details: ["Applied migration history is invalid."],
status: "failure",
});
expect(JSON.stringify(report)).not.toContain("never-render-this");
});
it("skips every dependent check after atomic module discovery fails", async () => {
const hostile =
"postgresql://admin:[email protected]/db SELECT secret FROM x /home/operator/.env";
const discoverSchemas = vi.fn();
const discoverMigrations = vi.fn();
const createMigrationDatabase = vi.fn();
const report = await diagnoseEkklesia(
options("postgresql://not-rendered.invalid/ekklesia"),
dependencies({
createMigrationDatabase,
discoverMigrations,
discoverModules: vi.fn().mockRejectedValue(new Error(hostile)),
discoverSchemas,
}),
);
expect(check(report, "modules.discovery").status).toBe("failure");
for (const id of [
"modules.dependencies",
"schemas.discovery",
"migrations.discovery",
"postgresql.connection",
"postgresql.plan",
] as const) {
expect(check(report, id).status).toBe("skipped");
}
expect(check(report, "platform.runtime").status).toBe("pass");
expect(check(report, "object-storage.integration").status).toBe("skipped");
expect(discoverSchemas).not.toHaveBeenCalled();
expect(discoverMigrations).not.toHaveBeenCalled();
expect(createMigrationDatabase).not.toHaveBeenCalled();
expect(JSON.stringify(report)).not.toContain(hostile);
expect(JSON.stringify(report)).not.toContain("password");
});
it("continues independent migration and PostgreSQL checks after schema failure", async () => {
const planMigrations = vi.fn(async () => plan());
const report = await diagnoseEkklesia(
options("postgresql://not-rendered.invalid/ekklesia"),
dependencies({
discoverSchemas: vi.fn().mockRejectedValue(new Error("hostile schema detail")),
planMigrations,
}),
);
expect(check(report, "schemas.discovery").status).toBe("failure");
expect(check(report, "migrations.discovery").status).toBe("pass");
expect(check(report, "postgresql.connection").status).toBe("pass");
expect(planMigrations).toHaveBeenCalledOnce();
expect(JSON.stringify(report)).not.toContain("hostile schema detail");
});
it("sanitizes an unexpected configured-database exception", async () => {
const hostile =
"postgresql://root:[email protected]:5432/db -- DROP TABLE x; /srv/secret/.env";
const report = await diagnoseEkklesia(
options("postgresql://also-secret.invalid/ekklesia"),
dependencies({ planMigrations: vi.fn().mockRejectedValue(new Error(hostile)) }),
);
const serialized = JSON.stringify(report);
expect(check(report, "postgresql.connection").status).toBe("failure");
expect(serialized).not.toContain("hunter2");
expect(serialized).not.toContain("private.invalid");
expect(serialized).not.toContain("DROP TABLE");
expect(serialized).not.toContain("/srv/secret");
expect(serialized).not.toContain("also-secret.invalid");
});
it("always reports object storage as unavailable without probing configuration", async () => {
const report = await diagnoseEkklesia(options(), dependencies());
expect(check(report, "object-storage.integration")).toEqual({
details: ["Core storage integration has not been implemented yet."],
id: "object-storage.integration",
status: "skipped",
summary: "Check unavailable",
});
});
it("continues static discovery when runtime validation fails", async () => {
const services = dependencies({ runtimeSatisfies: vi.fn(() => false) });
const report = await diagnoseEkklesia(options(), services);
expect(check(report, "platform.runtime").status).toBe("failure");
expect(check(report, "modules.discovery").status).toBe("pass");
expect(services.discoverSchemas).toHaveBeenCalledOnce();
expect(services.discoverMigrations).toHaveBeenCalledOnce();
});
});
@@ -49,4 +49,64 @@ describe("PostgreSQL migration database composition", () => {
expect(JSON.stringify(database)).not.toContain("do-not-log");
expect(JSON.stringify(database)).not.toContain("example.test");
});
it.each([
["connectionTimeoutMs", 0],
["connectionTimeoutMs", -1],
["connectionTimeoutMs", 1.5],
["connectionTimeoutMs", Number.NaN],
["connectionTimeoutMs", Number.POSITIVE_INFINITY],
["connectionTimeoutMs", 2_147_483_648],
["operationTimeoutMs", 0],
["operationTimeoutMs", -1],
["operationTimeoutMs", 1.5],
["operationTimeoutMs", Number.NaN],
["operationTimeoutMs", Number.POSITIVE_INFINITY],
["operationTimeoutMs", 2_147_483_648],
] as const)("rejects an invalid %s value without constructing a client", (name, value) => {
let captured: unknown;
try {
createPostgresMigrationDatabase("postgresql://example.test/ekklesia", {
[name]: value,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
expect(clientConstructor).not.toHaveBeenCalled();
});
it("passes bounded connection and operation timeouts to the PostgreSQL client", async () => {
const client = {
connect: vi.fn().mockResolvedValue(undefined),
end: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
};
const MockClient = class {
readonly connect = client.connect;
readonly end = client.end;
readonly on = client.on;
};
clientConstructor.mockImplementation(MockClient as unknown as () => typeof client);
const database = createPostgresMigrationDatabase("postgresql://example.test/ekklesia", {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 12_000,
});
const session = await database.openSession();
expect(clientConstructor).toHaveBeenCalledWith({
connectionString: "postgresql://example.test/ekklesia",
connectionTimeoutMillis: 10_000,
query_timeout: 12_000,
statement_timeout: 12_000,
});
expect(client.on).toHaveBeenCalledWith("error", expect.any(Function));
expect(client.connect).toHaveBeenCalledOnce();
await session.close();
expect(client.end).toHaveBeenCalledOnce();
});
});