// --------------------------------------------------------------------------- // ConfigFileSink — best-effort write-back of source changes to executor.jsonc. // // Plugins (openapi, graphql, mcp) call `sink.upsertSource` after their DB // writes so the committable file stays in sync with runtime state. Errors // are logged and swallowed — a failed file write must never fail a DB // mutation, and the next successful mutation (or a boot-time sync) will // eventually reconcile. // // The FileSystem layer is injected so library code here doesn't pick a // platform binding. The host app provides NodeFileSystem (or BunFileSystem). // --------------------------------------------------------------------------- import { Effect } from "effect"; import type { Layer } from "effect"; import type { FileSystem } from "effect"; import { SECRET_REF_PREFIX, type ConfigHeaderValue, type SourceConfig } from "./schema"; import { addSourceToConfig, removeSourceFromConfig } from "./write"; // Translate a plugin-side header value (`{ secretId, prefix? }` for secret // refs) into the config file's `secret-public-ref:` string form. type PluginHeaderValue = string | { secretId: string; prefix?: string }; export const headerToConfigValue = (value: PluginHeaderValue): ConfigHeaderValue => { if (typeof value === "string") return value; const ref = `${SECRET_REF_PREFIX}${value.secretId}`; return value.prefix ? { value: ref, prefix: value.prefix } : ref; }; export const headersToConfigValues = ( headers: Record | undefined, ): Record | undefined => { if (!headers) return undefined; const out: Record = {}; for (const [k, v] of Object.entries(headers)) out[k] = headerToConfigValue(v); return out; }; export interface ConfigFileSink { readonly upsertSource: (source: SourceConfig) => Effect.Effect; readonly removeSource: (namespace: string) => Effect.Effect; } export interface ConfigFileSinkOptions { readonly path: string; readonly fsLayer: Layer.Layer; /** Called when a file operation fails. Defaults to console.warn. */ readonly onError?: (op: "upsert" | "remove", err: unknown) => void; } const defaultOnError = (op: "upsert" | "remove", err: unknown): void => { console.warn(`[config-sink] ${op} failed`, err); }; export const makeFileConfigSink = (options: ConfigFileSinkOptions): ConfigFileSink => { const { path, fsLayer, onError = defaultOnError } = options; return { upsertSource: (source) => addSourceToConfig(path, source).pipe( Effect.provide(fsLayer), Effect.catch((err: unknown) => Effect.sync(() => onError("upsert", err))), ), removeSource: (namespace) => removeSourceFromConfig(path, namespace).pipe( Effect.provide(fsLayer), Effect.catch((err: unknown) => Effect.sync(() => onError("remove", err))), ), }; };