From 02ee3ccd801405ffcef555b4b5822bf24ccc0fe3 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 21 Jul 2026 10:42:21 -0500 Subject: [PATCH] feat: add Ekklesia doctor command --- README.md | 50 +- core/package.json | 4 + core/src/cli/doctor.ts | 73 +++ core/src/diagnostics/doctor-report.ts | 56 +++ core/src/diagnostics/doctor.ts | 397 ++++++++++++++++ core/src/diagnostics/index.ts | 2 + core/src/index.ts | 1 + core/src/migrations/postgres-database.ts | 74 ++- core/tests/doctor-import.test.ts | 45 ++ core/tests/doctor-report.test.ts | 173 +++++++ core/tests/doctor.test.ts | 441 ++++++++++++++++++ .../tests/postgres-migration-database.test.ts | 60 +++ docs/architecture/platform-architecture.md | 15 +- docs/development/repository-workflow.md | 71 +++ docs/module-development/README.md | 11 + package.json | 1 + scripts/run-doctor.ts | 72 +++ tests/infrastructure.test.ts | 7 + 18 files changed, 1546 insertions(+), 7 deletions(-) create mode 100644 core/src/cli/doctor.ts create mode 100644 core/src/diagnostics/doctor-report.ts create mode 100644 core/src/diagnostics/doctor.ts create mode 100644 core/src/diagnostics/index.ts create mode 100644 core/tests/doctor-import.test.ts create mode 100644 core/tests/doctor-report.test.ts create mode 100644 core/tests/doctor.test.ts create mode 100644 scripts/run-doctor.ts diff --git a/README.md b/README.md index 680ea7d..fca2a3a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ destructive-change declarations, and exact checksums. Core now provides read-onl planning and a locked, whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A hermetic live PostgreSQL suite now proves history, rollback, statement breakpoints, advisory-lock concurrency and release, exact destructive approvals, and sanitization -against the production adapter. Production approval-provider composition, identity, HTTP routing, +against the production adapter. A Core-owned `pnpm doctor` dashboard now combines the existing +static discovery and read-only migration-planning services into one concise readiness report. +Phase 4 identity with Better Auth is next; production approval-provider composition, HTTP routing, jobs, physical storage provisioning, surfaces, and feature modules remain later vertical slices. ## Quick start @@ -28,11 +30,57 @@ cp .env.example .env pnpm infrastructure:up pnpm modules:validate pnpm migrations:validate +pnpm doctor pnpm migrations:plan pnpm test:migrations:integration pnpm verify ``` +`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed +modules and dependency order, schema and migration inventories, optional read-only PostgreSQL +planning, and the current object-storage integration state. It never applies migrations, creates a +history table, starts module servers, or performs storage operations. Schema discovery does import +compiled schema-definition entrypoints; those entrypoints are contractually side-effect-free. + +With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped +like this: + +```text +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 +``` + +Checks have stable pass, warning, failure, or skipped states. Warnings and skipped optional checks +produce `READY WITH WARNINGS` and exit zero; any failure produces `NOT READY` and exit one. The +dashboard is human-readable local guidance, not a replacement for automated CI evidence. + `pnpm migrations:plan` uses `DATABASE_URL` from the uncommitted `.env` and is read-only. `pnpm migrations:apply` uses the same validated inventory but mutates PostgreSQL under Core's advisory lock and one transaction. Review the plan before applying. Destructive migrations are diff --git a/core/package.json b/core/package.json index d7b101c..9d53074 100644 --- a/core/package.json +++ b/core/package.json @@ -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": { diff --git a/core/src/cli/doctor.ts b/core/src/cli/doctor.ts new file mode 100644 index 0000000..ac01541 --- /dev/null +++ b/core/src/cli/doctor.ts @@ -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 { + 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 { + 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; + }); +} diff --git a/core/src/diagnostics/doctor-report.ts b/core/src/diagnostics/doctor-report.ts new file mode 100644 index 0000000..921bf05 --- /dev/null +++ b/core/src/diagnostics/doctor-report.ts @@ -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> = Object.freeze({ + failure: "✗", + pass: "✓", + skipped: "○", + warning: "!", +}); + +const resultLabels: Readonly> = 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); +} diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts new file mode 100644 index 0000000..c8969f0 --- /dev/null +++ b/core/src/diagnostics/doctor.ts @@ -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; + readonly discoverModules: ( + options: ModuleDiscoveryOptions, + ) => Promise; + readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise; + readonly planMigrations: (options: { + readonly aggregate: ModuleMigrationAggregate; + readonly database: MigrationDatabase; + }) => Promise; + 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 { + 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, + ]); +} diff --git a/core/src/diagnostics/index.ts b/core/src/diagnostics/index.ts new file mode 100644 index 0000000..09b6357 --- /dev/null +++ b/core/src/diagnostics/index.ts @@ -0,0 +1,2 @@ +export * from "./doctor.js"; +export * from "./doctor-report.js"; diff --git a/core/src/index.ts b/core/src/index.ts index 569dc7a..b3e3cb9 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,2 +1,3 @@ +export * from "./diagnostics/index.js"; export * from "./modules/index.js"; export * from "./migrations/index.js"; diff --git a/core/src/migrations/postgres-database.ts b/core/src/migrations/postgres-database.ts index a6b77de..f730831 100644 --- a/core/src/migrations/postgres-database.ts +++ b/core/src/migrations/postgres-database.ts @@ -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 { - const client = new Client({ connectionString: validatedConnectionString }); + const client = new Client(clientConfig); client.on("error", () => undefined); try { await client.connect(); diff --git a/core/tests/doctor-import.test.ts b/core/tests/doctor-import.test.ts new file mode 100644 index 0000000..47ebf72 --- /dev/null +++ b/core/tests/doctor-import.test.ts @@ -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()), + discoverModuleMigrations, + discoverModules, + discoverModuleSchemas, + startModuleRuntime, +})); + +vi.mock("../src/migrations/index.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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(); + }); +}); diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts new file mode 100644 index 0000000..eeb6a92 --- /dev/null +++ b/core/tests/doctor-report.test.ts @@ -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:secret@host.invalid/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:secret@host.invalid/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); + } + }); +}); diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts new file mode 100644 index 0000000..748c15d --- /dev/null +++ b/core/tests/doctor.test.ts @@ -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 { + 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>, + 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:password@host.invalid/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:hunter2@private.invalid: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(); + }); +}); diff --git a/core/tests/postgres-migration-database.test.ts b/core/tests/postgres-migration-database.test.ts index 1f46e8b..86f0206 100644 --- a/core/tests/postgres-migration-database.test.ts +++ b/core/tests/postgres-migration-database.test.ts @@ -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(); + }); }); diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 761b9d7..cefd0db 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -70,8 +70,15 @@ now compare that inventory with its fixed PostgreSQL history table, produce a re apply the complete pending batch under a session advisory lock and one transaction. Destructive migrations require an exact externally supplied approval bound to module, identity, and checksum. Hermetic integration coverage now exercises those contracts through the production adapter against -the pinned PostgreSQL container, including real DDL rollback and advisory-lock concurrency. +the pinned PostgreSQL container, including real DDL rollback and advisory-lock concurrency. The +Core-owned Doctor composes those existing services into a deterministic terminal readiness report: +static module, schema, and migration validation always run when their prerequisites succeed, while +PostgreSQL history planning runs only when configured. Schema inspection evaluates compiled schema +definition entrypoints, which the module contract requires to be side-effect-free; Doctor never +imports or starts module server entrypoints. It does not apply migrations or modify PostgreSQL, and +it honestly skips object storage because no production Core storage service exists yet. -Target architecture, not yet implemented: production approval-provider composition, Better Auth, -permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource -and action contracts, API server, dashboard, Builder, and reference feature modules. +The next architectural phase is Phase 4 identity with Better Auth. Other target architecture not +yet implemented includes production approval-provider composition, permission enforcement, +physical storage-bucket provisioning, Surface SDK, richer public resource and action contracts, +API server, dashboard, Builder, and reference feature modules. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index eb9223b..4e59eac 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -22,6 +22,7 @@ pnpm modules:validate # installed module manifests, compatibility, and dependenc pnpm migrations:validate # offline module migration inventory and exact checksums pnpm migrations:plan # read-only comparison with PostgreSQL applied history pnpm migrations:apply # locked, transactional application of the pending batch +pnpm doctor # build-first, read-only human readiness dashboard pnpm typecheck # strict TypeScript pnpm test # Vitest pnpm test:migrations:integration # production runner against disposable PostgreSQL @@ -44,6 +45,76 @@ checks every declared stable Drizzle migration directory. It reports dependency- inventories and exact SHA-256 checksums without importing module code, connecting to PostgreSQL, or executing SQL. Modules with no migration history are valid. +## Developer Doctor + +Run the complete local readiness dashboard with: + +```bash +pnpm doctor +``` + +The root command follows the repository's build-first convention, so it works from a clean checkout +without preexisting ignored `dist` output. The build itself may create ignored compiled artifacts; +the diagnostics that follow are operationally read-only. Doctor never edits configuration, applies +migrations, creates the Core history table, acquires the migration apply lock, starts module server +entrypoints, provisions storage, or invokes Docker. + +Doctor reports a fixed, deterministic set of checks: + +- The active Node.js version against the repository's declared engine range. +- Static module discovery and dependency-first ordering. +- Schema discovery, including validated module and table counts. This imports compiled schema + definitions, which the module contract requires to perform no configuration reads, I/O, or + database connection. +- Offline migration discovery, with safe identities and consistently truncated checksums. +- PostgreSQL connectivity and read-only applied-history planning when `DATABASE_URL` is configured. +- Object storage as skipped while Core has no production storage client or configuration contract. + +Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent +checks continue after a failure; a failed prerequisite causes only its dependent checks to be +skipped. Pending or approval-blocked migrations are warnings and are never applied. Invalid +history, checksum disagreement, unknown applied migrations, or a failed configured connection are +failures. If `DATABASE_URL` is missing, PostgreSQL is skipped and all static checks still run. + +`READY` means every check passed. `READY WITH WARNINGS` means no check failed but at least one check +warned or was skipped. Both results exit zero. `NOT READY` means at least one check failed and exits +one. Meaning never depends on color, a TTY, timestamps, or interactive input. + +For the current repository with no installed modules and no `DATABASE_URL`, the concise inventory +ends like this: + +```text +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 +``` + +Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke, +and migration smoke remain the authoritative automated evidence because they exercise formatting, +boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does +not repeat. Phase 4 identity with Better Auth is the next architectural phase; Doctor does not +diagnose identity, authorization, HTTP routing, runtime module health, or production storage yet. + Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env` created from `.env.example`: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index bdaf8fc..6dcbddf 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -241,6 +241,7 @@ From the repository root, run: pnpm install pnpm modules:validate pnpm migrations:validate +pnpm doctor pnpm migrations:plan pnpm verify ``` @@ -281,6 +282,16 @@ PostgreSQL, including DDL rollback, advisory-lock contention, history behavior, sanitized failures. It remains outside the Docker-free `pnpm verify` gate; see the [testing guide](../development/testing.md). +`pnpm doctor` builds first and presents the same production discovery results as a concise, +read-only dashboard. It lists discovered module IDs and versions in dependency order, reports +schema and migration counts, and shows safe migration identities with truncated checksums. Schema +discovery imports the compiled schema-definition entrypoint, so its no-I/O and no-configuration +contract still applies. Doctor does not import or start module server entrypoints, apply migrations, +or exercise runtime hooks; lifecycle behavior remains covered by automated tests. PostgreSQL +planning is skipped when `DATABASE_URL` is absent, and object storage is skipped until Core has a +production storage integration. A skip or pending migration is visible as `READY WITH WARNINGS`, +not misreported as a successful check. + Core completes discovery before importing any server entrypoint. It then loads and verifies every runtime definition before running hooks, registers all modules dependency-first into an unpublished staged capability registry, and starts them in the same order. Shutdown is reverse-order. If a later diff --git a/package.json b/package.json index 902cf7e..696ba97 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "boundaries": "node scripts/check-boundaries.ts", "build": "turbo run build", "ci:verify": "pnpm verify && pnpm infrastructure:config", + "doctor": "node scripts/run-doctor.ts", "format": "biome format --write .", "format:check": "biome format .", "infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet", diff --git a/scripts/run-doctor.ts b/scripts/run-doctor.ts new file mode 100644 index 0000000..e3da218 --- /dev/null +++ b/scripts/run-doctor.ts @@ -0,0 +1,72 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { loadEnvFile } from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { readonly code?: unknown }).code === "ENOENT" + ); +} + +async function runCommand(command: string, arguments_: readonly string[]): Promise { + return await new Promise((resolve) => { + const child = spawn(command, arguments_, { + cwd: repositoryRoot, + stdio: "ignore", + }); + child.once("error", () => resolve(1)); + child.once("exit", (code) => resolve(code ?? 1)); + }); +} + +export async function runDoctor(): Promise { + const buildExitCode = await runCommand("turbo", ["run", "build"]); + if (buildExitCode !== 0) { + console.error("Ekklesia Doctor build preparation failed. Run pnpm build for details."); + return 1; + } + + try { + loadEnvFile(path.join(repositoryRoot, ".env")); + } catch (error) { + if (!isMissingFileError(error)) { + console.error("Ekklesia Doctor configuration could not be loaded safely."); + return 1; + } + } + + const moduleUrl = pathToFileURL( + path.join(repositoryRoot, "core", "dist", "cli", "doctor.js"), + ).href; + const doctorModule = (await import(moduleUrl)) as { + readonly runDoctorCommand?: unknown; + }; + if (typeof doctorModule.runDoctorCommand !== "function") { + throw new TypeError("Invalid Doctor command module."); + } + return await doctorModule.runDoctorCommand(repositoryRoot); +} + +function isDirectExecution(): boolean { + const entrypoint = process.argv[1]; + return ( + entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href + ); +} + +if (isDirectExecution()) { + runDoctor() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch(() => { + console.error("Ekklesia Doctor failed before diagnostics could be composed."); + process.exitCode = 1; + }); +} diff --git a/tests/infrastructure.test.ts b/tests/infrastructure.test.ts index 63694c3..2f98820 100644 --- a/tests/infrastructure.test.ts +++ b/tests/infrastructure.test.ts @@ -172,6 +172,10 @@ describe("development infrastructure", () => { it("dispatches root and future package quality tasks through Turborepo", async () => { const packageSource = await readFile(path.join(repositoryRoot, "package.json"), "utf8"); + const doctorSource = await readFile( + path.join(repositoryRoot, "scripts", "run-doctor.ts"), + "utf8", + ); const turboSource = await readFile(path.join(repositoryRoot, "turbo.json"), "utf8"); const packageDocument = JSON.parse(packageSource) as PackageDocument; const turboDocument = JSON.parse(turboSource) as TurboDocument; @@ -182,6 +186,9 @@ describe("development infrastructure", () => { expect(packageDocument.scripts?.["modules:validate"]).toBe( "turbo run build && node core/dist/cli/validate-modules.js", ); + expect(packageDocument.scripts?.doctor).toBe("node scripts/run-doctor.ts"); + expect(doctorSource).toContain('runCommand("turbo", ["run", "build"])'); + expect(doctorSource).toContain('path.join(repositoryRoot, "core", "dist", "cli", "doctor.js")'); expect(packageDocument.scripts?.verify).toContain("pnpm modules:validate"); expect(turboDocument.tasks).toHaveProperty("//#lint:root"); expect(turboDocument.tasks).toHaveProperty("//#test:root");