import { asc } from "drizzle-orm"; import { type ModuleEnablementRecord, moduleIdSchema } from "@ekklesia/contracts"; import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import type { Pool } from "pg"; import { createValidatedPostgresPool, type PostgresPoolOptions } from "../database/index.js"; import type { ModuleLifecycleConfiguration } from "./lifecycle.js"; import { ekklesiaCoreModuleState, moduleLifecycleSchema } from "./lifecycle-schema.js"; export type ModuleLifecycleDrizzleDatabase = NodePgDatabase; /** * A typed Drizzle view over an externally owned pool, exactly like `createDashboardDrizzleView` and * `createAuthorizationDrizzleView`: it never constructs, registers an error listener on, or closes * a pool. The caller owns the pool once, however many typed views it creates over it. */ export function createModuleLifecycleView(pool: Pool): ModuleLifecycleDrizzleDatabase { return drizzle(pool, { schema: moduleLifecycleSchema }); } export interface ModuleLifecycleDatabaseHandle { readonly close: () => Promise; readonly db: ModuleLifecycleDrizzleDatabase; } /** * A self-contained handle for a caller that needs *only* this domain and owns the connection for * the length of one command — Doctor's read-only lifecycle check and the enable/disable CLI. It * composes the shared, already-validated pool factory rather than repeating a fourth copy of * connection-string and timeout validation. `apps/api`, which shares one physical pool across every * domain, uses `createModuleLifecycleView` instead. */ export function createModuleLifecycleDatabase( connectionString: string, options: PostgresPoolOptions = {}, ): ModuleLifecycleDatabaseHandle { const { close, pool } = createValidatedPostgresPool(connectionString, options); return Object.freeze({ close, db: createModuleLifecycleView(pool) }); } export type ModuleLifecycleStoreErrorCode = | "MODULE_LIFECYCLE_MODULE_ID_INVALID" | "MODULE_LIFECYCLE_READ_FAILED" | "MODULE_LIFECYCLE_WRITE_FAILED"; const storeErrorMessages: Readonly> = Object.freeze({ MODULE_LIFECYCLE_MODULE_ID_INVALID: "A module identifier is invalid.", MODULE_LIFECYCLE_READ_FAILED: "The module lifecycle configuration could not be read.", MODULE_LIFECYCLE_WRITE_FAILED: "The module lifecycle configuration could not be saved.", }); /** Never carries the underlying database error, SQL text, host, connection string, or stack — * matching `AuthorizationStoreError` and `DashboardStoreError` exactly. */ export class ModuleLifecycleStoreError extends Error { readonly code: ModuleLifecycleStoreErrorCode; constructor(code: ModuleLifecycleStoreErrorCode) { super(storeErrorMessages[code]); this.name = "ModuleLifecycleStoreError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } export interface SetModuleEnabledInput { readonly moduleId: string; readonly enabled: boolean; } export interface ModuleLifecycleStore { /** The whole stored configuration, in deterministic module-id order. Small by nature — at most one * row per module ever decided about — so it is read whole rather than paginated. */ readConfiguration(): Promise; /** Records one explicit decision, creating or replacing it. Idempotent: the same input twice * leaves the same single row. Writing configuration never starts, stops, loads, or unloads * anything — the running process is untouched until it is restarted. */ setEnabled(input: SetModuleEnabledInput): Promise; } function assertValidModuleId(value: string): void { if (!moduleIdSchema.safeParse(value).success) { throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_MODULE_ID_INVALID"); } } function rethrowAsStoreError(error: unknown, code: ModuleLifecycleStoreErrorCode): never { if (error instanceof ModuleLifecycleStoreError) { throw error; } throw new ModuleLifecycleStoreError(code); } /** * Core's persistence for which installed modules are enabled. * * Validation lives here rather than only at an HTTP boundary, so the invariant holds for every * caller — the API, the CLI, and any future one: an identifier is checked against * `moduleIdSchema`, the platform's one module-identifier grammar, rather than a second grammar * invented here. * * Nothing in this file reads a manifest, imports module code, resolves a dependency, or decides * whether a module may start. It stores a decision; `resolveModuleEligibility` interprets it. That * separation is what keeps enablement host-owned configuration instead of something a module could * ever influence. */ export function createModuleLifecycleStore( db: ModuleLifecycleDrizzleDatabase, ): ModuleLifecycleStore { return Object.freeze({ async readConfiguration(): Promise { try { const rows = await db .select({ enabled: ekklesiaCoreModuleState.enabled, moduleId: ekklesiaCoreModuleState.moduleId, }) .from(ekklesiaCoreModuleState) .orderBy(asc(ekklesiaCoreModuleState.moduleId)); return Object.freeze({ modules: Object.freeze(rows.map((row) => Object.freeze({ ...row }))), }); } catch (error) { rethrowAsStoreError(error, "MODULE_LIFECYCLE_READ_FAILED"); } }, async setEnabled(input: SetModuleEnabledInput): Promise { assertValidModuleId(input.moduleId); try { const [row] = await db .insert(ekklesiaCoreModuleState) .values({ enabled: input.enabled, moduleId: input.moduleId }) .onConflictDoUpdate({ set: { enabled: input.enabled, updatedAt: new Date() }, target: ekklesiaCoreModuleState.moduleId, }) .returning({ enabled: ekklesiaCoreModuleState.enabled, moduleId: ekklesiaCoreModuleState.moduleId, }); if (!row) { throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED"); } return Object.freeze(row); } catch (error) { rethrowAsStoreError(error, "MODULE_LIFECYCLE_WRITE_FAILED"); } }, }); }