Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
55 lines
2.3 KiB
TypeScript
55 lines
2.3 KiB
TypeScript
import type { ModuleDatabase } from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationDrizzleDatabase,
|
|
createAuthorizationDrizzleView,
|
|
} from "@ekklesia/core/authorization";
|
|
import {
|
|
createDashboardDrizzleView,
|
|
type DashboardDrizzleDatabase,
|
|
} from "@ekklesia/core/dashboard";
|
|
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
|
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
|
import {
|
|
createModuleDatabaseView,
|
|
createModuleLifecycleView,
|
|
type ModuleLifecycleDrizzleDatabase,
|
|
} from "@ekklesia/core/modules";
|
|
|
|
export interface ApiDatabaseHandle {
|
|
readonly authorization: AuthorizationDrizzleDatabase;
|
|
readonly dashboard: DashboardDrizzleDatabase;
|
|
readonly identity: IdentityDrizzleDatabase;
|
|
readonly module: ModuleDatabase;
|
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
|
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
|
readonly close: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
|
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by independent
|
|
* typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
|
* `createDashboardDrizzleView`, `createModuleLifecycleView`, `createModuleDatabaseView`). No domain
|
|
* owns or closes the pool in this composition — `close()`
|
|
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
|
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
|
* for callers that only need one domain.
|
|
*/
|
|
export function createApiDatabase(
|
|
databaseUrl: string,
|
|
options: PostgresPoolOptions = {},
|
|
): ApiDatabaseHandle {
|
|
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
|
|
|
|
return Object.freeze({
|
|
authorization: createAuthorizationDrizzleView(pool),
|
|
close,
|
|
dashboard: createDashboardDrizzleView(pool),
|
|
identity: createIdentityDrizzleView(pool),
|
|
module: createModuleDatabaseView(pool),
|
|
moduleLifecycle: createModuleLifecycleView(pool),
|
|
});
|
|
}
|