feat: add the minimal operational module lifecycle
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
+65
-16
@@ -9,8 +9,11 @@ import {
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
type ModuleEligibilityResolution,
|
||||
resolveModuleEligibility,
|
||||
type StartedModuleRuntime,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
@@ -321,12 +324,15 @@ function bindServer(
|
||||
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
||||
* then the Core identity runtime, authorization store, and evaluator.
|
||||
* 4. Start the module runtime from that exact same discovered inventory
|
||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
|
||||
* same services object every module's `start`/`stop` hook receives.
|
||||
* 5. Create the Hono application, mounting the started module runtime's published routes and
|
||||
* passing the exact same services object to every module HTTP handler.
|
||||
* 6. Bind the Node HTTP server.
|
||||
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
||||
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
||||
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
||||
* rediscovering modules a second time — passing it the same services object every module's
|
||||
* `start`/`stop` hook receives.
|
||||
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
|
||||
* UI for that same eligible set, and passing the exact same services object to every module HTTP
|
||||
* handler.
|
||||
* 7. Bind the Node HTTP server.
|
||||
*
|
||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
* entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid
|
||||
@@ -336,10 +342,20 @@ function bindServer(
|
||||
* module database facade, identity, authorization) exists before any executable module lifecycle
|
||||
* code runs, and every module route is published only after the entire module runtime has
|
||||
* registered and started successfully, so no request can ever reach a module before every module
|
||||
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point
|
||||
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
|
||||
* applies migrations, generates schema, or issues a module database query merely because modules
|
||||
* were discovered — the database must already be migrated through `pnpm migrations:apply`.
|
||||
* has started. A module that is disabled, or blocked by a disabled required dependency, is never
|
||||
* imported at all: no entrypoint evaluation, no lifecycle hook, no route, no capability, and no
|
||||
* published UI artifact — there is no per-request "is this module enabled?" check anywhere, because
|
||||
* there is nothing mounted to check.
|
||||
*
|
||||
* Step 4 makes startup depend on a reachable, migrated database. If the enablement configuration
|
||||
* cannot be read, startup fails closed with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED` rather
|
||||
* than starting every discovered module: the platform cannot know what an administrator decided, and
|
||||
* starting a module they disabled is worse than not starting at all.
|
||||
*
|
||||
* Returns a handle with an idempotent `shutdown()` so both the executable entry point and tests can
|
||||
* stop the server, the module runtime, and the shared pool deterministically. Never applies
|
||||
* migrations, generates schema, or issues a module database query merely because modules were
|
||||
* discovered — the database must already be migrated through `pnpm migrations:apply`.
|
||||
*/
|
||||
export async function startApi(
|
||||
config: ApiRuntimeConfig,
|
||||
@@ -384,12 +400,33 @@ export async function startApi(
|
||||
platformOwners: authorizationStore,
|
||||
});
|
||||
|
||||
// Read the administrator's enablement decisions and resolve which discovered modules may actually
|
||||
// start, before a single module entrypoint is imported. A failure here fails startup rather than
|
||||
// falling back to "start everything": the platform cannot know what it was told, and starting a
|
||||
// module an administrator disabled is worse than not starting at all.
|
||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||
let eligibility: ModuleEligibilityResolution;
|
||||
try {
|
||||
eligibility = resolveModuleEligibility(
|
||||
inventory.modules,
|
||||
await moduleLifecycleStore.readConfiguration(),
|
||||
);
|
||||
} catch (error) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Module load/register/start failures use Core's own existing rollback (already-started
|
||||
// modules are stopped, in reverse order, before this rejects) — there is no separate runtime
|
||||
// handle to stop here, only the pool this failure leaves open.
|
||||
//
|
||||
// Only eligible modules are started, and startup remains all-or-nothing across *that* set: an
|
||||
// ineligible module is never imported, so its `register`/`start` hooks never run, it publishes no
|
||||
// routes and no capabilities, and it cannot take startup down. Eligibility filtering preserves
|
||||
// discovery's topological order, so a started module's required dependencies still precede it.
|
||||
let moduleRuntime: StartedModuleRuntime;
|
||||
try {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(eligibility.eligibleModules, services, {
|
||||
...(options.moduleHookTimeoutMsOverride === undefined
|
||||
? {}
|
||||
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
|
||||
@@ -409,11 +446,23 @@ export async function startApi(
|
||||
},
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleServices: services,
|
||||
// Built from the exact same discovered inventory, and only once the module runtime has
|
||||
// started — module startup is all-or-nothing, so every module in this list is a running
|
||||
// module, and a module that failed to load, register, or start never reaches this line at
|
||||
// all. Modules declaring no `entrypoints.ui` are simply absent from the registry.
|
||||
moduleUi: createModuleUiPublicationRegistry(inventory.modules),
|
||||
// Built from the exact **eligible** set the runtime just started, never the raw discovered
|
||||
// inventory, and only once startup has succeeded — so the published set is the running set. A
|
||||
// disabled or dependency-blocked module therefore publishes no artifact, appears in no browser
|
||||
// inventory, and appears in no resolved Dashboard navigation, by construction rather than by a
|
||||
// filter somewhere downstream. Modules declaring no `entrypoints.ui` are simply absent.
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
moduleLifecycle: {
|
||||
// The boot snapshot: what this process discovered, what it resolved, and what the runtime
|
||||
// reports it actually started — read from the runtime rather than assumed equal to the
|
||||
// eligible set.
|
||||
snapshot: Object.freeze({
|
||||
discoveredModules: inventory.modules,
|
||||
eligibility: eligibility.entries,
|
||||
startedModuleIds: Object.freeze(moduleRuntime.modules.map((module) => module.id)),
|
||||
}),
|
||||
store: moduleLifecycleStore,
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
runtime,
|
||||
|
||||
Reference in New Issue
Block a user