import { MODULE_CONFIGURATION_VALUE_MAX_LENGTH, moduleConfigurationKeySchema, moduleIdSchema, } from "@ekklesia/contracts"; import { asc, eq } from "drizzle-orm"; import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import type { Pool } from "pg"; import { createValidatedPostgresPool, type PostgresPoolOptions } from "../database/index.js"; import type { ModuleConfigurationRecord, ModuleConfigurationSettings } from "./configuration.js"; import { ekklesiaCoreModuleConfiguration, moduleConfigurationSchema, } from "./configuration-schema.js"; export type ModuleConfigurationDrizzleDatabase = NodePgDatabase; /** A typed Drizzle view over an externally owned pool, exactly like `createModuleLifecycleView` and * `createDashboardDrizzleView`: it never constructs, registers an error listener on, or closes a * pool. */ export function createModuleConfigurationView(pool: Pool): ModuleConfigurationDrizzleDatabase { return drizzle(pool, { schema: moduleConfigurationSchema }); } export interface ModuleConfigurationDatabaseHandle { readonly close: () => Promise; readonly db: ModuleConfigurationDrizzleDatabase; } /** A self-contained handle for a caller that needs only this domain for the length of one command — * Doctor's read-only configuration check. Composes the shared validated pool factory rather than * repeating connection-string and timeout validation. `apps/api` shares one pool and uses * `createModuleConfigurationView` instead. */ export function createModuleConfigurationDatabase( connectionString: string, options: PostgresPoolOptions = {}, ): ModuleConfigurationDatabaseHandle { const { close, pool } = createValidatedPostgresPool(connectionString, options); return Object.freeze({ close, db: createModuleConfigurationView(pool) }); } export type ModuleConfigurationStoreErrorCode = | "MODULE_CONFIGURATION_KEY_INVALID" | "MODULE_CONFIGURATION_MODULE_ID_INVALID" | "MODULE_CONFIGURATION_READ_FAILED" | "MODULE_CONFIGURATION_VALUE_INVALID" | "MODULE_CONFIGURATION_WRITE_FAILED"; const storeErrorMessages: Readonly> = Object.freeze({ MODULE_CONFIGURATION_KEY_INVALID: "A module configuration key is invalid.", MODULE_CONFIGURATION_MODULE_ID_INVALID: "A module identifier is invalid.", MODULE_CONFIGURATION_READ_FAILED: "The module configuration could not be read.", MODULE_CONFIGURATION_VALUE_INVALID: "A module configuration value is invalid.", MODULE_CONFIGURATION_WRITE_FAILED: "The module configuration could not be saved.", }); /** * Never carries the underlying database error, SQL text, host, connection string, stack — **or the * offending value**. * * That last exclusion is specific to this domain and deliberate: a configuration value can be * operator-entered text of any kind, and an error message is one of the easiest ways for a value to * end up in a log. The key is safe to name (it is declared in a public manifest); the value never * is. */ export class ModuleConfigurationStoreError extends Error { readonly code: ModuleConfigurationStoreErrorCode; constructor(code: ModuleConfigurationStoreErrorCode) { super(storeErrorMessages[code]); this.name = "ModuleConfigurationStoreError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } export interface ReplaceModuleConfigurationInput { readonly moduleId: string; /** The complete set of ordinary values for this module. Replaces, rather than merges: a key * absent here is deleted, so an operator clearing a value does not have to discover a separate * delete verb, and the stored document always matches what was last submitted. */ readonly values: readonly { readonly key: string; readonly value: string }[]; } export interface ModuleConfigurationStore { /** Every stored value, in deterministic (module, key) order. Small by nature — a handful of rows * per configured module — so it is read whole rather than paginated. */ readSettings(): Promise; /** * Replaces one module's stored values atomically. * * Scoped to a single module id by signature: there is no call shape that writes across modules, * and no verb that takes a raw table, column, or predicate. Writing configuration never starts, * stops, or reconfigures anything in the running process — the change is applied by the next * restart. */ replace(input: ReplaceModuleConfigurationInput): Promise; } function assertValidModuleId(value: string): void { if (!moduleIdSchema.safeParse(value).success) { throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_MODULE_ID_INVALID"); } } function assertValidKey(value: string): void { if (!moduleConfigurationKeySchema.safeParse(value).success) { throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID"); } } function assertValidValue(value: string): void { if (typeof value !== "string" || value.length > MODULE_CONFIGURATION_VALUE_MAX_LENGTH) { throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_VALUE_INVALID"); } } function rethrowAsStoreError(error: unknown, code: ModuleConfigurationStoreErrorCode): never { if (error instanceof ModuleConfigurationStoreError) { throw error; } throw new ModuleConfigurationStoreError(code); } /** * Core's persistence for ordinary module configuration. * * Validation lives here rather than only at the HTTP boundary, so the invariants hold for every * caller: identifiers are checked against the platform's one module-id grammar and one * configuration-key grammar rather than second copies invented here, and values are bounded exactly * as the contract and the database bound them. * * Nothing in this file reads a manifest, resolves a declaration, applies a default, or decides * whether a value is *meaningful* — it stores text under a validated key. * `resolveModuleConfigurationFor` is the one place a value is interpreted, which is what keeps the * store from acquiring a second, drifting copy of the type rules. It equally never sees a secret: * the HTTP boundary rejects a `secret` key before a write reaches here, and secret material has no * storage path at all. */ export function createModuleConfigurationStore( db: ModuleConfigurationDrizzleDatabase, ): ModuleConfigurationStore { return Object.freeze({ async readSettings(): Promise { try { const rows = await db .select({ key: ekklesiaCoreModuleConfiguration.key, moduleId: ekklesiaCoreModuleConfiguration.moduleId, value: ekklesiaCoreModuleConfiguration.value, }) .from(ekklesiaCoreModuleConfiguration) .orderBy( asc(ekklesiaCoreModuleConfiguration.moduleId), asc(ekklesiaCoreModuleConfiguration.key), ); return Object.freeze({ records: Object.freeze(rows.map((row) => Object.freeze({ ...row }))), }); } catch (error) { rethrowAsStoreError(error, "MODULE_CONFIGURATION_READ_FAILED"); } }, async replace( input: ReplaceModuleConfigurationInput, ): Promise { assertValidModuleId(input.moduleId); const seen = new Set(); for (const entry of input.values) { assertValidKey(entry.key); assertValidValue(entry.value); if (seen.has(entry.key)) { throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_KEY_INVALID"); } seen.add(entry.key); } try { // One transaction, so a replace is never observed half-applied: a concurrent boot either // sees the previous complete document or the new one, never a mixture that would validate // differently from both. return await db.transaction(async (tx) => { // Delete this module's rows, then insert the submitted ones. Expressed as "delete all, // insert submitted" rather than a key-wise diff: the whole document is being replaced, so // the outcome is identical and there is one predicate to audit instead of two. The // predicate is scoped to `input.moduleId`, so another module's rows are unreachable from // here by construction, not by care. await tx .delete(ekklesiaCoreModuleConfiguration) .where(eq(ekklesiaCoreModuleConfiguration.moduleId, input.moduleId)); if (input.values.length === 0) { return Object.freeze([]); } const inserted = await tx .insert(ekklesiaCoreModuleConfiguration) .values( input.values.map((entry) => ({ key: entry.key, moduleId: input.moduleId, value: entry.value, })), ) .returning({ key: ekklesiaCoreModuleConfiguration.key, moduleId: ekklesiaCoreModuleConfiguration.moduleId, value: ekklesiaCoreModuleConfiguration.value, }); return Object.freeze( [...inserted] .sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) .map((row) => Object.freeze({ ...row })), ); }); } catch (error) { rethrowAsStoreError(error, "MODULE_CONFIGURATION_WRITE_FAILED"); } }, }); }