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]>
77 lines
3.1 KiB
TypeScript
77 lines
3.1 KiB
TypeScript
import type { ModuleEnablementRecord } from "@ekklesia/contracts";
|
|
import type {
|
|
DiscoveredModule,
|
|
ModuleLifecycleConfiguration,
|
|
ModuleLifecycleSnapshot,
|
|
ModuleLifecycleStore,
|
|
SetModuleEnabledInput,
|
|
} from "@ekklesia/core/modules";
|
|
import { ModuleLifecycleStoreError, resolveModuleEligibility } from "@ekklesia/core/modules";
|
|
|
|
/**
|
|
* An in-memory `ModuleLifecycleStore` for tests that exercise **routing and resolution**, not
|
|
* persistence.
|
|
*
|
|
* It reproduces only what those tests depend on — records survive a write and read back in
|
|
* module-id order, and an invalid identifier is rejected — and deliberately reproduces nothing
|
|
* else. The real table's constraints, the upsert's conflict target, and the fact that a record for
|
|
* an undeployed module is retained are proven against real PostgreSQL in
|
|
* `core/tests/module-lifecycle-store.integration.ts`, so this double can never quietly stand in for
|
|
* a persistence guarantee nobody checked.
|
|
*/
|
|
export function createInMemoryModuleLifecycleStore(
|
|
seed: readonly ModuleEnablementRecord[] = [],
|
|
): ModuleLifecycleStore {
|
|
const records = new Map<string, boolean>(
|
|
seed.map((record) => [record.moduleId, record.enabled] as const),
|
|
);
|
|
return Object.freeze({
|
|
async readConfiguration(): Promise<ModuleLifecycleConfiguration> {
|
|
return Object.freeze({
|
|
modules: Object.freeze(
|
|
[...records.entries()]
|
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
.map(([moduleId, enabled]) => Object.freeze({ enabled, moduleId })),
|
|
),
|
|
});
|
|
},
|
|
async setEnabled(input: SetModuleEnabledInput): Promise<ModuleEnablementRecord> {
|
|
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(input.moduleId)) {
|
|
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID");
|
|
}
|
|
records.set(input.moduleId, input.enabled);
|
|
return Object.freeze({ enabled: input.enabled, moduleId: input.moduleId });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds the boot snapshot the same way `startApi` does — resolve eligibility from the discovered
|
|
* inventory and the stored configuration, then record what would have started — rather than
|
|
* hand-writing one, so a test can never assert against a snapshot the real composition would never
|
|
* produce.
|
|
*/
|
|
export function moduleLifecycleSnapshotFor(
|
|
discoveredModules: readonly DiscoveredModule[],
|
|
configuration: ModuleLifecycleConfiguration = { modules: [] },
|
|
): ModuleLifecycleSnapshot {
|
|
const resolution = resolveModuleEligibility(discoveredModules, configuration);
|
|
return Object.freeze({
|
|
discoveredModules,
|
|
eligibility: resolution.entries,
|
|
startedModuleIds: Object.freeze(resolution.eligibleModules.map((module) => module.manifest.id)),
|
|
});
|
|
}
|
|
|
|
/** The lifecycle option for a `createApiApp` caller that exercises no modules at all — the explicit
|
|
* "no modules" statement the required option asks for, never an implicit default. */
|
|
export function emptyModuleLifecycle(): {
|
|
readonly snapshot: ModuleLifecycleSnapshot;
|
|
readonly store: ModuleLifecycleStore;
|
|
} {
|
|
return {
|
|
snapshot: moduleLifecycleSnapshotFor([]),
|
|
store: createInMemoryModuleLifecycleStore(),
|
|
};
|
|
}
|