/** * Module configuration service * Handles storage and retrieval of module configuration values * Supports both primitive types (string, number, boolean) and complex types (arrays, objects) */ import { readFile } from 'node:fs/promises'; import { and, eq } from 'drizzle-orm'; import { type DbClient, getDb } from '../db/client'; import { moduleConfigs, modules } from '../db/schema'; import { formatManifestValidationErrors, validateAgainstManifest } from './manifest-validation'; import { formatValidationErrors, validateConfigValue } from './schema-validation'; export interface ConfigValue { key: string; value: string | number | boolean | unknown[] | Record; isPrimitive: boolean; } /** * Check if a value is a complex type (array or object) */ export function isComplexValue(value: unknown): boolean { if (value === null || typeof value !== 'object') { return false; } // Arrays are complex if (Array.isArray(value)) { return true; } // Non-empty objects are complex return Object.keys(value).length > 0; } /** * Parse a configuration value * Handles @file syntax for reading from files * Handles JSON strings for complex types */ export async function parseConfigValue( valueStr: string, ): Promise> { // Handle @file syntax if (valueStr.startsWith('@')) { const filePath = valueStr.slice(1); const fileContent = await readFile(filePath, 'utf-8'); return JSON.parse(fileContent); } // Try to parse as JSON (for complex types or JSON strings) try { const parsed = JSON.parse(valueStr); return parsed; } catch { // Not valid JSON, treat as string // Check for boolean values if (valueStr === 'true') return true; if (valueStr === 'false') return false; // Check for number values const num = Number(valueStr); if (!Number.isNaN(num) && valueStr.trim() !== '') { return num; } // Return as string return valueStr; } } /** * Upsert a `module_configs` row. THE ONLY supported write path. * * Direct `db.insert(moduleConfigs).values({...})` calls are an * anti-pattern — they bypass valueJson population and leave reads * unable to recover the original type. Defect 1 was exactly this: * primitive writes that set `value` but not `valueJson`, so reads * had to guess (and got it wrong for stringly-looking-numeric * values, etc.). Every write site routes through here now. * * Callers pass the canonical JS value (`number`, `boolean`, * `string`, array, object); we JSON-stringify into `valueJson` * (the typed canonical) and produce a human-readable form for * `value` (used only for CLI display). */ export function upsertModuleConfig( db: DbClient, moduleId: string, key: string, value: string | number | boolean | unknown[] | Record | null, source?: 'hook', ): void { if (value === null || value === undefined) { throw new Error(`upsertModuleConfig(${moduleId}, ${key}): value must not be null/undefined`); } const valueJson = JSON.stringify(value); const displayValue = isComplexValue(value) ? valueJson : String(value); // `source` records who owns the row (hook-owned-state design D7 — the same // column `derived-value-recomputation` reads). Absent keeps the legacy // pre-column shape for every existing caller: operator and interview writes // stay sourceless until that change gives them their own value to write. // Only the hook config store passes 'hook', and only a hook may: the // manifest refuses `module config set` on any source: hook variable. db.insert(moduleConfigs) .values({ moduleId, key, value: displayValue, valueJson, ...(source !== undefined ? { source } : {}), }) .onConflictDoUpdate({ target: [moduleConfigs.moduleId, moduleConfigs.key], set: { value: displayValue, valueJson, updatedAt: new Date(), ...(source !== undefined ? { source } : {}), }, }) .run(); } /** * Delete a module config row if present (no-op if absent). Used to drop a * derived/cached key that should no longer persist — e.g. `__infra_target_node`, * now resolved live from Proxmox each generate rather than cached in the DB * (ISS-0090: "the DB stores intent, Proxmox reports reality"). */ export function deleteModuleConfig(db: DbClient, moduleId: string, key: string): void { db.delete(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key))) .run(); } /** * Parse a stored module_configs row into its canonical typed value. * Throws if valueJson is null — that's a row written before the * always-populate-valueJson invariant, and we no longer support it. * See schema.ts module_configs doc comment for context. * * Exported so other read sites (hook config loader, variables * context, ansible inventory builder, etc.) can share one parsing * path — a single source of truth for "DB row → typed value". */ export function parseStoredConfigValue( row: typeof moduleConfigs.$inferSelect, ): string | number | boolean | unknown[] | Record { if (row.valueJson === null || row.valueJson === undefined) { throw new Error( `module_configs row ${row.moduleId}.${row.key} has null valueJson — ` + `pre-Defect-1 row, no longer supported. Re-run \`celilo module config set ${row.moduleId} ${row.key} \` to populate.`, ); } try { return JSON.parse(row.valueJson) as | string | number | boolean | unknown[] | Record; } catch (error) { throw new Error( `Failed to parse valueJson for ${row.moduleId}.${row.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`, ); } } /** * A module's whole operator config as a plain key → typed-value map — the shape * every policy resolver takes. */ export function loadModuleConfigs(db: DbClient, moduleId: string): Record { const configs: Record = {}; for (const row of db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all()) { configs[row.key] = parseStoredConfigValue(row); } return configs; } /** * The raw string form of an operator override from an already-loaded config * map, or undefined when the key is unset. * * Framework policy keys (cadences, retention, upgrade controls) are stored * through the same typed path as any other config, so a cadence arrives as a * string and a retention count as a number. Every resolver takes the string * form and parses it itself, so this is the one place that flattening happens. */ export function configOverride( configs: Record | undefined, key: string, ): string | undefined { const raw = configs?.[key]; return raw === undefined || raw === null ? undefined : String(raw); } /** * Get module configuration value * Returns parsed value (primitive or complex type) */ export function getModuleConfigValue( moduleId: string, key: string, db: DbClient = getDb(), ): ConfigValue | null { const config = db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key))) .get(); if (!config) { return null; } const parsed = parseStoredConfigValue(config); return { key: config.key, value: parsed, isPrimitive: !isComplexValue(parsed), }; } /** * Get all module configuration values */ export function getAllModuleConfigValues(moduleId: string, db: DbClient = getDb()): ConfigValue[] { const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all(); return configs.map((config: typeof moduleConfigs.$inferSelect) => { const parsed = parseStoredConfigValue(config); return { key: config.key, value: parsed, isPrimitive: !isComplexValue(parsed), }; }); } /** * Set module configuration value * Automatically detects primitive vs complex types and stores appropriately */ export async function setModuleConfigValue( moduleId: string, key: string, valueStr: string, db: DbClient = getDb(), ): Promise { // Parse the value const parsedValue = await parseConfigValue(valueStr); // Get module manifest for validation const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { throw new Error(`Module not found: ${moduleId}`); } const manifest = module.manifestData as Record; const variables = manifest.variables as | { owns?: Array<{ name: string; type?: string; minimum?: number; maximum?: number; pattern?: string; }>; } | undefined; const declaredVars = variables?.owns || []; const variable = declaredVars.find((v) => v.name === key); // Validate against manifest type (if type is declared) if (variable?.type) { const manifestValidation = validateAgainstManifest(parsedValue, { name: variable.name, type: variable.type as 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'object', minimum: variable.minimum, maximum: variable.maximum, pattern: variable.pattern, }); if (!manifestValidation.valid) { throw new Error(formatManifestValidationErrors(manifestValidation.errors || [])); } } // Validate against JSON Schema (if schema exists) // This provides deeper validation for complex types const validation = await validateConfigValue(moduleId, key, parsedValue, db); if (!validation.valid) { throw new Error(formatValidationErrors(validation.errors || [])); } // Delegate to the shared upsert helper — single storage path, // single place where the valueJson invariant lives. upsertModuleConfig(db, moduleId, key, parsedValue); } /** * Format a config value for display */ export function formatConfigValue(configValue: ConfigValue): string { if (configValue.isPrimitive) { return String(configValue.value); } // Complex type - format as JSON return JSON.stringify(configValue.value, null, 2); }