feat: add Ekklesia doctor command
This commit is contained in:
@@ -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
|
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
|
approval matching. A hermetic live PostgreSQL suite now proves history, rollback, statement
|
||||||
breakpoints, advisory-lock concurrency and release, exact destructive approvals, and sanitization
|
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.
|
jobs, physical storage provisioning, surfaces, and feature modules remain later vertical slices.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
@@ -28,11 +30,57 @@ cp .env.example .env
|
|||||||
pnpm infrastructure:up
|
pnpm infrastructure:up
|
||||||
pnpm modules:validate
|
pnpm modules:validate
|
||||||
pnpm migrations:validate
|
pnpm migrations:validate
|
||||||
|
pnpm doctor
|
||||||
pnpm migrations:plan
|
pnpm migrations:plan
|
||||||
pnpm test:migrations:integration
|
pnpm test:migrations:integration
|
||||||
pnpm verify
|
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: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
|
`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
|
advisory lock and one transaction. Review the plan before applying. Destructive migrations are
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
"./migrations": {
|
"./migrations": {
|
||||||
"types": "./dist/migrations/index.d.ts",
|
"types": "./dist/migrations/index.d.ts",
|
||||||
"import": "./dist/migrations/index.js"
|
"import": "./dist/migrations/index.js"
|
||||||
|
},
|
||||||
|
"./diagnostics": {
|
||||||
|
"types": "./dist/diagnostics/index.d.ts",
|
||||||
|
"import": "./dist/diagnostics/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { diagnoseEkklesia, doctorExitCode, formatDoctorReport } from "../diagnostics/index.js";
|
||||||
|
|
||||||
|
interface ModuleSdkPackageDocument {
|
||||||
|
readonly version?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RootPackageDocument extends ModuleSdkPackageDocument {
|
||||||
|
readonly engines?: {
|
||||||
|
readonly node?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(pathname: string): Promise<unknown> {
|
||||||
|
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootPackageDocument(value: unknown): RootPackageDocument {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new TypeError("Invalid workspace package metadata.");
|
||||||
|
}
|
||||||
|
return value as RootPackageDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value: unknown): string {
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw new TypeError("Missing workspace package metadata.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<number> {
|
||||||
|
const [rootValue, moduleSdkValue] = await Promise.all([
|
||||||
|
readJson(path.join(repositoryRoot, "package.json")),
|
||||||
|
readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")),
|
||||||
|
]);
|
||||||
|
const rootPackage = rootPackageDocument(rootValue);
|
||||||
|
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
||||||
|
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
||||||
|
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||||
|
platformVersion: requiredString(rootPackage.version),
|
||||||
|
},
|
||||||
|
runtimeRange: requiredString(rootPackage.engines?.node),
|
||||||
|
runtimeVersion: process.versions.node,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(formatDoctorReport(report).join("\n"));
|
||||||
|
return doctorExitCode(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectExecution(): boolean {
|
||||||
|
const entrypoint = process.argv[1];
|
||||||
|
return (
|
||||||
|
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectExecution()) {
|
||||||
|
runDoctorCommand()
|
||||||
|
.then((exitCode) => {
|
||||||
|
process.exitCode = exitCode;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.error("Ekklesia Doctor failed before diagnostics could be composed.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { DoctorCheck, DoctorCheckId, DoctorReport, DoctorResult } from "./doctor.js";
|
||||||
|
|
||||||
|
interface DoctorSection {
|
||||||
|
readonly checkIds: readonly DoctorCheckId[];
|
||||||
|
readonly title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSection {
|
||||||
|
return Object.freeze({ checkIds: Object.freeze([...checkIds]), title });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: readonly DoctorSection[] = Object.freeze([
|
||||||
|
section("Platform", ["platform.runtime"]),
|
||||||
|
section("Modules", ["modules.discovery", "modules.dependencies"]),
|
||||||
|
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||||
|
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
|
||||||
|
section("Object storage", ["object-storage.integration"]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
|
||||||
|
failure: "✗",
|
||||||
|
pass: "✓",
|
||||||
|
skipped: "○",
|
||||||
|
warning: "!",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultLabels: Readonly<Record<DoctorResult, string>> = Object.freeze({
|
||||||
|
"not-ready": "NOT READY",
|
||||||
|
ready: "READY",
|
||||||
|
"ready-with-warnings": "READY WITH WARNINGS",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDoctorReport(report: DoctorReport): readonly string[] {
|
||||||
|
const checks = new Map(report.checks.map((check) => [check.id, check]));
|
||||||
|
const lines = ["Ekklesia Doctor", ""];
|
||||||
|
|
||||||
|
for (const [sectionIndex, section] of sections.entries()) {
|
||||||
|
lines.push(section.title);
|
||||||
|
for (const checkId of section.checkIds) {
|
||||||
|
const check = checks.get(checkId);
|
||||||
|
if (!check) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(` ${statusSymbols[check.status]} ${check.summary}`);
|
||||||
|
for (const detail of check.details) {
|
||||||
|
lines.push(` ${detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sectionIndex < sections.length - 1) {
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("", `Result: ${resultLabels[report.result]}`);
|
||||||
|
return Object.freeze(lines);
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import { isVersionCompatible, moduleIdSchema, semanticVersionSchema } from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
type MigrationDatabase,
|
||||||
|
type MigrationPlanResult,
|
||||||
|
MigrationRunnerError,
|
||||||
|
PostgresMigrationDatabaseError,
|
||||||
|
type PostgresMigrationDatabaseOptions,
|
||||||
|
planModuleMigrations,
|
||||||
|
} from "../migrations/index.js";
|
||||||
|
import {
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModuleMigrations,
|
||||||
|
discoverModuleSchemas,
|
||||||
|
discoverModules,
|
||||||
|
type ModuleDiscoveryOptions,
|
||||||
|
type ModuleMigrationAggregate,
|
||||||
|
type ModuleSchemaAggregate,
|
||||||
|
} from "../modules/index.js";
|
||||||
|
|
||||||
|
export type DoctorCheckId =
|
||||||
|
| "platform.runtime"
|
||||||
|
| "modules.discovery"
|
||||||
|
| "modules.dependencies"
|
||||||
|
| "schemas.discovery"
|
||||||
|
| "migrations.discovery"
|
||||||
|
| "postgresql.connection"
|
||||||
|
| "postgresql.plan"
|
||||||
|
| "object-storage.integration";
|
||||||
|
|
||||||
|
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||||
|
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
|
||||||
|
|
||||||
|
export interface DoctorCheck {
|
||||||
|
readonly details: readonly string[];
|
||||||
|
readonly id: DoctorCheckId;
|
||||||
|
readonly status: DoctorCheckStatus;
|
||||||
|
readonly summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoctorReport {
|
||||||
|
readonly checks: readonly DoctorCheck[];
|
||||||
|
readonly result: DoctorResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoctorOptions {
|
||||||
|
readonly databaseUrl?: string | undefined;
|
||||||
|
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||||
|
readonly runtimeRange: string;
|
||||||
|
readonly runtimeVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoctorDependencies {
|
||||||
|
readonly createMigrationDatabase: (
|
||||||
|
connectionString: string,
|
||||||
|
options: PostgresMigrationDatabaseOptions,
|
||||||
|
) => MigrationDatabase;
|
||||||
|
readonly discoverMigrations: (
|
||||||
|
options: ModuleDiscoveryOptions,
|
||||||
|
) => Promise<ModuleMigrationAggregate>;
|
||||||
|
readonly discoverModules: (
|
||||||
|
options: ModuleDiscoveryOptions,
|
||||||
|
) => Promise<readonly DiscoveredModule[]>;
|
||||||
|
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
|
||||||
|
readonly planMigrations: (options: {
|
||||||
|
readonly aggregate: ModuleMigrationAggregate;
|
||||||
|
readonly database: MigrationDatabase;
|
||||||
|
}) => Promise<MigrationPlanResult>;
|
||||||
|
readonly runtimeSatisfies: (version: string, range: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doctorPostgresTimeoutMs = 10_000;
|
||||||
|
const checksumPattern = /^[a-f0-9]{64}$/u;
|
||||||
|
const migrationIdentityPattern =
|
||||||
|
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
|
||||||
|
|
||||||
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||||
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||||
|
discoverMigrations: discoverModuleMigrations,
|
||||||
|
discoverModules,
|
||||||
|
discoverSchemas: discoverModuleSchemas,
|
||||||
|
planMigrations: planModuleMigrations,
|
||||||
|
runtimeSatisfies: isVersionCompatible,
|
||||||
|
});
|
||||||
|
|
||||||
|
function plural(count: number, singular: string): string {
|
||||||
|
return count === 1 ? singular : `${singular}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeCheck(
|
||||||
|
id: DoctorCheckId,
|
||||||
|
status: DoctorCheckStatus,
|
||||||
|
summary: string,
|
||||||
|
details: readonly string[] = [],
|
||||||
|
): DoctorCheck {
|
||||||
|
return Object.freeze({
|
||||||
|
details: Object.freeze([...details]),
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveDoctorResult(checks: readonly DoctorCheck[]): DoctorResult {
|
||||||
|
if (checks.some((check) => check.status === "failure")) {
|
||||||
|
return "not-ready";
|
||||||
|
}
|
||||||
|
if (checks.some((check) => check.status === "warning" || check.status === "skipped")) {
|
||||||
|
return "ready-with-warnings";
|
||||||
|
}
|
||||||
|
return "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
||||||
|
const frozenChecks = Object.freeze([...checks]);
|
||||||
|
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||||
|
return modules.map((module) => {
|
||||||
|
const id = module.manifest.id;
|
||||||
|
const version = module.manifest.version;
|
||||||
|
if (
|
||||||
|
!moduleIdSchema.safeParse(id).success ||
|
||||||
|
!semanticVersionSchema.safeParse(version).success
|
||||||
|
) {
|
||||||
|
throw new TypeError("Unsafe module diagnostic value.");
|
||||||
|
}
|
||||||
|
return `${id} ${version}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeMigrationReference(identity: string, checksum: string): string {
|
||||||
|
if (!migrationIdentityPattern.test(identity) || !checksumPattern.test(checksum)) {
|
||||||
|
throw new TypeError("Unsafe migration diagnostic value.");
|
||||||
|
}
|
||||||
|
return `${identity} · ${checksum.slice(0, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||||
|
const details =
|
||||||
|
error instanceof Error && error.name === "ModuleDiscoveryError"
|
||||||
|
? ["The installed module inventory is invalid."]
|
||||||
|
: ["Static module discovery could not be completed."];
|
||||||
|
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaDiscoveryFailure(): DoctorCheck {
|
||||||
|
return freezeCheck("schemas.discovery", "failure", "Schema validation failed", [
|
||||||
|
"Installed module schemas could not be validated.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationDiscoveryFailure(): DoctorCheck {
|
||||||
|
return freezeCheck("migrations.discovery", "failure", "Migration validation failed", [
|
||||||
|
"Installed module migrations could not be validated.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredDatabaseFailure(error: unknown): readonly [DoctorCheck, DoctorCheck] {
|
||||||
|
if (error instanceof MigrationRunnerError) {
|
||||||
|
if (error.code === "MIGRATION_CONNECTION_FAILED") {
|
||||||
|
return [
|
||||||
|
freezeCheck("postgresql.connection", "failure", "Connection failed", [
|
||||||
|
"The configured PostgreSQL database could not be reached.",
|
||||||
|
]),
|
||||||
|
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
||||||
|
"A successful PostgreSQL connection is required.",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
freezeCheck("postgresql.connection", "pass", "Connection established"),
|
||||||
|
freezeCheck("postgresql.plan", "failure", "Migration history validation failed", [
|
||||||
|
migrationPlanFailureDetail(error.code),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof PostgresMigrationDatabaseError) {
|
||||||
|
return [
|
||||||
|
freezeCheck("postgresql.connection", "failure", "PostgreSQL configuration invalid", [
|
||||||
|
"The configured database connection could not be prepared safely.",
|
||||||
|
]),
|
||||||
|
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
||||||
|
"A valid PostgreSQL configuration is required.",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
freezeCheck("postgresql.connection", "failure", "PostgreSQL diagnostic failed", [
|
||||||
|
"The configured database could not be inspected safely.",
|
||||||
|
]),
|
||||||
|
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
||||||
|
"PostgreSQL connectivity could not be confirmed.",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationPlanFailureDetail(code: MigrationRunnerError["code"]): string {
|
||||||
|
switch (code) {
|
||||||
|
case "MIGRATION_CHECKSUM_MISMATCH":
|
||||||
|
return "An applied migration checksum disagrees with packaged inventory.";
|
||||||
|
case "MIGRATION_HISTORY_INVALID":
|
||||||
|
return "Applied migration history is invalid.";
|
||||||
|
case "MIGRATION_HISTORY_READ_FAILED":
|
||||||
|
return "Applied migration history could not be read safely.";
|
||||||
|
case "MIGRATION_UNKNOWN_APPLIED":
|
||||||
|
return "Applied migration history contains an unknown identity.";
|
||||||
|
case "MIGRATION_CLEANUP_FAILED":
|
||||||
|
return "The read-only PostgreSQL session did not close cleanly.";
|
||||||
|
default:
|
||||||
|
return "The read-only migration plan could not be completed safely.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
|
||||||
|
const pendingCount = plan.entries.filter((entry) => entry.status === "pending").length;
|
||||||
|
const blockedCount = plan.entries.filter((entry) => entry.status === "approval-blocked").length;
|
||||||
|
const appliedCount = plan.entries.filter((entry) => entry.status === "applied").length;
|
||||||
|
|
||||||
|
if (
|
||||||
|
appliedCount !== plan.appliedCount ||
|
||||||
|
pendingCount + blockedCount !== plan.pendingCount ||
|
||||||
|
blockedCount !== plan.approvalRequiredCount
|
||||||
|
) {
|
||||||
|
throw new TypeError("Inconsistent migration plan diagnostic value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = [
|
||||||
|
`${appliedCount} applied · ${pendingCount} pending · ${blockedCount} approval-blocked`,
|
||||||
|
...plan.entries.map((entry) => {
|
||||||
|
if (!(["applied", "approval-blocked", "pending"] as const).includes(entry.status)) {
|
||||||
|
throw new TypeError("Unsafe migration plan diagnostic value.");
|
||||||
|
}
|
||||||
|
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (pendingCount + blockedCount === 0) {
|
||||||
|
return freezeCheck("postgresql.plan", "pass", "Migration history current", details);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attentionCount = pendingCount + blockedCount;
|
||||||
|
return freezeCheck(
|
||||||
|
"postgresql.plan",
|
||||||
|
"warning",
|
||||||
|
attentionCount === 1
|
||||||
|
? "1 migration requires attention"
|
||||||
|
: `${attentionCount} migrations require attention`,
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
|
||||||
|
return [
|
||||||
|
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
|
||||||
|
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
||||||
|
"A validated migration inventory and configured PostgreSQL connection are required.",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doctorExitCode(report: DoctorReport): 0 | 1 {
|
||||||
|
return report.result === "not-ready" ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diagnoseEkklesia(
|
||||||
|
options: DoctorOptions,
|
||||||
|
dependencies: DoctorDependencies = productionDependencies,
|
||||||
|
): Promise<DoctorReport> {
|
||||||
|
let runtimeCheck: DoctorCheck;
|
||||||
|
try {
|
||||||
|
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
|
||||||
|
? freezeCheck("platform.runtime", "pass", "Runtime version supported", [
|
||||||
|
"Active Node.js satisfies the declared supported range.",
|
||||||
|
])
|
||||||
|
: freezeCheck("platform.runtime", "failure", "Runtime version unsupported", [
|
||||||
|
"Install a Node.js version supported by this repository.",
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
runtimeCheck = freezeCheck("platform.runtime", "failure", "Runtime validation failed", [
|
||||||
|
"The active Node.js runtime could not be validated safely.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let discoveredModules: readonly DiscoveredModule[] | undefined;
|
||||||
|
let moduleCheck: DoctorCheck;
|
||||||
|
let dependencyCheck: DoctorCheck;
|
||||||
|
try {
|
||||||
|
discoveredModules = await dependencies.discoverModules(options.moduleDiscovery);
|
||||||
|
const moduleDetails = safeModuleDetails(discoveredModules);
|
||||||
|
moduleCheck = freezeCheck(
|
||||||
|
"modules.discovery",
|
||||||
|
"pass",
|
||||||
|
"Module discovery complete",
|
||||||
|
discoveredModules.length === 0 ? ["0 modules installed"] : moduleDetails,
|
||||||
|
);
|
||||||
|
dependencyCheck = freezeCheck("modules.dependencies", "pass", "Dependency graph valid", [
|
||||||
|
discoveredModules.length === 0
|
||||||
|
? "Dependency order is empty."
|
||||||
|
: `Order: ${discoveredModules.map((module) => module.manifest.id).join(" → ")}`,
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
moduleCheck = moduleDiscoveryFailure(error);
|
||||||
|
dependencyCheck = freezeCheck("modules.dependencies", "skipped", "Dependency check skipped", [
|
||||||
|
"Complete module discovery is required.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let schemaCheck: DoctorCheck;
|
||||||
|
let migrationCheck: DoctorCheck;
|
||||||
|
let migrations: ModuleMigrationAggregate | undefined;
|
||||||
|
|
||||||
|
if (!discoveredModules) {
|
||||||
|
schemaCheck = freezeCheck("schemas.discovery", "skipped", "Schema validation skipped", [
|
||||||
|
"Complete module discovery is required.",
|
||||||
|
]);
|
||||||
|
migrationCheck = freezeCheck(
|
||||||
|
"migrations.discovery",
|
||||||
|
"skipped",
|
||||||
|
"Migration validation skipped",
|
||||||
|
["Complete module discovery is required."],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const schemas = await dependencies.discoverSchemas(options.moduleDiscovery);
|
||||||
|
schemaCheck = freezeCheck("schemas.discovery", "pass", "Schemas validated", [
|
||||||
|
`${schemas.discoveredModules.length} ${plural(schemas.discoveredModules.length, "module")} · ${schemas.tables.length} ${plural(schemas.tables.length, "table")}`,
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
schemaCheck = schemaDiscoveryFailure();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
|
||||||
|
const inventory = migrations.migrations.map((migration) =>
|
||||||
|
safeMigrationReference(migration.identity, migration.checksum),
|
||||||
|
);
|
||||||
|
migrationCheck = freezeCheck("migrations.discovery", "pass", "Migrations validated", [
|
||||||
|
`${migrations.migrations.length} ${plural(migrations.migrations.length, "migration")} · checksums valid`,
|
||||||
|
...inventory,
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
migrations = undefined;
|
||||||
|
migrationCheck = migrationDiscoveryFailure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let postgresConnectionCheck: DoctorCheck;
|
||||||
|
let postgresPlanCheck: DoctorCheck;
|
||||||
|
const databaseUrl = options.databaseUrl;
|
||||||
|
const databaseConfigured = typeof databaseUrl === "string" && databaseUrl.trim() !== "";
|
||||||
|
|
||||||
|
if (!migrations) {
|
||||||
|
[postgresConnectionCheck, postgresPlanCheck] = skippedPostgresChecks(
|
||||||
|
"Complete migration discovery is required.",
|
||||||
|
);
|
||||||
|
} else if (!databaseConfigured) {
|
||||||
|
[postgresConnectionCheck, postgresPlanCheck] = skippedPostgresChecks(
|
||||||
|
"DATABASE_URL is not configured.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const database = dependencies.createMigrationDatabase(databaseUrl, {
|
||||||
|
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
|
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||||
|
});
|
||||||
|
const plan = await dependencies.planMigrations({ aggregate: migrations, database });
|
||||||
|
postgresConnectionCheck = freezeCheck(
|
||||||
|
"postgresql.connection",
|
||||||
|
"pass",
|
||||||
|
"Connection established",
|
||||||
|
);
|
||||||
|
postgresPlanCheck = migrationPlanCheck(plan);
|
||||||
|
} catch (error) {
|
||||||
|
[postgresConnectionCheck, postgresPlanCheck] = configuredDatabaseFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
||||||
|
"Core storage integration has not been implemented yet.",
|
||||||
|
]);
|
||||||
|
|
||||||
|
return publishReport([
|
||||||
|
runtimeCheck,
|
||||||
|
moduleCheck,
|
||||||
|
dependencyCheck,
|
||||||
|
schemaCheck,
|
||||||
|
migrationCheck,
|
||||||
|
postgresConnectionCheck,
|
||||||
|
postgresPlanCheck,
|
||||||
|
storageCheck,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./doctor.js";
|
||||||
|
export * from "./doctor-report.js";
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from "./diagnostics/index.js";
|
||||||
export * from "./modules/index.js";
|
export * from "./modules/index.js";
|
||||||
export * from "./migrations/index.js";
|
export * from "./migrations/index.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Client, type QueryResultRow } from "pg";
|
import { Client, type ClientConfig, type QueryResultRow } from "pg";
|
||||||
import type {
|
import type {
|
||||||
AppliedMigrationRecord,
|
AppliedMigrationRecord,
|
||||||
MigrationDatabase,
|
MigrationDatabase,
|
||||||
@@ -12,6 +12,7 @@ const historyTableName = "ekklesia_core_migrations";
|
|||||||
const advisoryLockNamespace = 1_162_562_380;
|
const advisoryLockNamespace = 1_162_562_380;
|
||||||
const advisoryLockKey = 1_296_648_018;
|
const advisoryLockKey = 1_296_648_018;
|
||||||
const advisoryLockPollIntervalMs = 50;
|
const advisoryLockPollIntervalMs = 50;
|
||||||
|
const maximumPostgresTimeoutMs = 2_147_483_647;
|
||||||
|
|
||||||
const createHistoryTableSql = `
|
const createHistoryTableSql = `
|
||||||
CREATE TABLE IF NOT EXISTS "public"."ekklesia_core_migrations" (
|
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 {
|
interface HistoryExistenceRow extends QueryResultRow {
|
||||||
readonly relation_kind: string;
|
readonly relation_kind: string;
|
||||||
}
|
}
|
||||||
@@ -124,6 +130,68 @@ function validateConnectionString(connectionString: string | undefined): string
|
|||||||
return connectionString;
|
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 {
|
function isPostgresMigrationDatabaseError(error: unknown): error is PostgresMigrationDatabaseError {
|
||||||
return error instanceof PostgresMigrationDatabaseError;
|
return error instanceof PostgresMigrationDatabaseError;
|
||||||
}
|
}
|
||||||
@@ -440,12 +508,14 @@ class PostgresMigrationDatabaseSession implements MigrationDatabaseSession {
|
|||||||
|
|
||||||
export function createPostgresMigrationDatabase(
|
export function createPostgresMigrationDatabase(
|
||||||
connectionString: string | undefined,
|
connectionString: string | undefined,
|
||||||
|
options?: PostgresMigrationDatabaseOptions,
|
||||||
): MigrationDatabase {
|
): MigrationDatabase {
|
||||||
const validatedConnectionString = validateConnectionString(connectionString);
|
const validatedConnectionString = validateConnectionString(connectionString);
|
||||||
|
const clientConfig = createClientConfig(validatedConnectionString, options);
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
async openSession(): Promise<MigrationDatabaseSession> {
|
async openSession(): Promise<MigrationDatabaseSession> {
|
||||||
const client = new Client({ connectionString: validatedConnectionString });
|
const client = new Client(clientConfig);
|
||||||
client.on("error", () => undefined);
|
client.on("error", () => undefined);
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("do-not-log");
|
||||||
expect(JSON.stringify(database)).not.toContain("example.test");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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,
|
The next architectural phase is Phase 4 identity with Better Auth. Other target architecture not
|
||||||
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
|
yet implemented includes production approval-provider composition, permission enforcement,
|
||||||
and action contracts, API server, dashboard, Builder, and reference feature modules.
|
physical storage-bucket provisioning, Surface SDK, richer public resource and action contracts,
|
||||||
|
API server, dashboard, Builder, and reference feature modules.
|
||||||
|
|||||||
@@ -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:validate # offline module migration inventory and exact checksums
|
||||||
pnpm migrations:plan # read-only comparison with PostgreSQL applied history
|
pnpm migrations:plan # read-only comparison with PostgreSQL applied history
|
||||||
pnpm migrations:apply # locked, transactional application of the pending batch
|
pnpm migrations:apply # locked, transactional application of the pending batch
|
||||||
|
pnpm doctor # build-first, read-only human readiness dashboard
|
||||||
pnpm typecheck # strict TypeScript
|
pnpm typecheck # strict TypeScript
|
||||||
pnpm test # Vitest
|
pnpm test # Vitest
|
||||||
pnpm test:migrations:integration # production runner against disposable PostgreSQL
|
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
|
inventories and exact SHA-256 checksums without importing module code, connecting to PostgreSQL, or
|
||||||
executing SQL. Modules with no migration history are valid.
|
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`
|
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
|
||||||
created from `.env.example`:
|
created from `.env.example`:
|
||||||
|
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ From the repository root, run:
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm modules:validate
|
pnpm modules:validate
|
||||||
pnpm migrations:validate
|
pnpm migrations:validate
|
||||||
|
pnpm doctor
|
||||||
pnpm migrations:plan
|
pnpm migrations:plan
|
||||||
pnpm verify
|
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
|
sanitized failures. It remains outside the Docker-free `pnpm verify` gate; see the
|
||||||
[testing guide](../development/testing.md).
|
[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
|
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
|
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
|
staged capability registry, and starts them in the same order. Shutdown is reverse-order. If a later
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"boundaries": "node scripts/check-boundaries.ts",
|
"boundaries": "node scripts/check-boundaries.ts",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"ci:verify": "pnpm verify && pnpm infrastructure:config",
|
"ci:verify": "pnpm verify && pnpm infrastructure:config",
|
||||||
|
"doctor": "node scripts/run-doctor.ts",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome format .",
|
"format:check": "biome format .",
|
||||||
"infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet",
|
"infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet",
|
||||||
|
|||||||
@@ -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<number> {
|
||||||
|
return await new Promise<number>((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<number> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -172,6 +172,10 @@ describe("development infrastructure", () => {
|
|||||||
|
|
||||||
it("dispatches root and future package quality tasks through Turborepo", async () => {
|
it("dispatches root and future package quality tasks through Turborepo", async () => {
|
||||||
const packageSource = await readFile(path.join(repositoryRoot, "package.json"), "utf8");
|
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 turboSource = await readFile(path.join(repositoryRoot, "turbo.json"), "utf8");
|
||||||
const packageDocument = JSON.parse(packageSource) as PackageDocument;
|
const packageDocument = JSON.parse(packageSource) as PackageDocument;
|
||||||
const turboDocument = JSON.parse(turboSource) as TurboDocument;
|
const turboDocument = JSON.parse(turboSource) as TurboDocument;
|
||||||
@@ -182,6 +186,9 @@ describe("development infrastructure", () => {
|
|||||||
expect(packageDocument.scripts?.["modules:validate"]).toBe(
|
expect(packageDocument.scripts?.["modules:validate"]).toBe(
|
||||||
"turbo run build && node core/dist/cli/validate-modules.js",
|
"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(packageDocument.scripts?.verify).toContain("pnpm modules:validate");
|
||||||
expect(turboDocument.tasks).toHaveProperty("//#lint:root");
|
expect(turboDocument.tasks).toHaveProperty("//#lint:root");
|
||||||
expect(turboDocument.tasks).toHaveProperty("//#test:root");
|
expect(turboDocument.tasks).toHaveProperty("//#test:root");
|
||||||
|
|||||||
Reference in New Issue
Block a user