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]>
61 lines
3.2 KiB
TypeScript
61 lines
3.2 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { boolean, check, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
|
|
/**
|
|
* Core-owned storage for the one operational decision an administrator makes about an installed
|
|
* module: should it run?
|
|
*
|
|
* This is **configuration, not runtime state**. Nothing about what happened during a boot is stored
|
|
* here — not a start failure, not a last-seen timestamp, not a health result. A restart naturally
|
|
* retries every enabled module, which is the behavior an operator expects and which persisting a
|
|
* transient failure would quietly break (ADR 0028).
|
|
*
|
|
* It is also **not an installation registry**. A module is "installed" by being a valid module
|
|
* package on disk that discovery accepts; this table records nothing about where it came from, how
|
|
* it got there, or what version it is. There is deliberately no install source, download URL,
|
|
* update channel, signature, marketplace metadata, or free-form JSON column: none of those concepts
|
|
* exist in this platform, and a column for one is how an installer gets built by accident.
|
|
*
|
|
* Platform-global, for the same reason the Dashboard tables are (ADR 0027): Ekklesia has no
|
|
* organization or tenant model to scope it to. When one exists, this table gains a scope column.
|
|
*
|
|
* Table and constraint names are explicit and namespaced exactly like
|
|
* `core/src/authorization/schema.ts` and `core/src/dashboard/schema.ts`, so ownership is
|
|
* unambiguous inside the shared public PostgreSQL schema.
|
|
*/
|
|
|
|
/**
|
|
* One explicit enablement decision.
|
|
*
|
|
* **A row exists only because somebody decided.** `enabled` has no column default on purpose: the
|
|
* platform's default for a newly discovered module is expressed by the *absence* of a row and
|
|
* resolved in `resolveModuleEligibility`, not by a default that would silently materialize a
|
|
* decision nobody made. Storing `enabled = true` explicitly is still meaningful — it records that
|
|
* an administrator looked at this module and left it on.
|
|
*
|
|
* `module_id` carries **no foreign key to any module**, because modules are packages discovered on
|
|
* disk, not rows — the same reasoning `ekklesia_core_dashboard_placement.module_id` already
|
|
* follows. A record for a module that is not currently deployed is therefore normal and is
|
|
* retained, so redeploying a module restores the decision instead of resetting it.
|
|
* `resolveModuleEligibility` ignores such a record entirely.
|
|
*/
|
|
export const ekklesiaCoreModuleState = pgTable(
|
|
"ekklesia_core_module_state",
|
|
{
|
|
moduleId: text("module_id").primaryKey(),
|
|
enabled: boolean("enabled").notNull(),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
},
|
|
(table) => [
|
|
// Defense in depth beneath the application-layer `moduleIdSchema` contract, which never
|
|
// produces this: an empty module id would be an unaddressable, permanently inert record.
|
|
check("ekklesia_core_module_state_module_check", sql`length(${table.moduleId}) > 0`),
|
|
],
|
|
);
|
|
|
|
/** Explicit map passed to Drizzle, mirroring `authorizationSchema` and `dashboardSchema`. */
|
|
export const moduleLifecycleSchema = {
|
|
moduleState: ekklesiaCoreModuleState,
|
|
} as const;
|