// WHAT `POST /api/ops/settings` REFUSES, as one pure function — testable // without a settings file, a schema or a server. // Keys another writer owns. Writing one raw would skip what that writer keeps // true, so the refusal names the writer: // - channelPriority: the priority write compiles the four lanes' trees in the // SAME write (saveSettings.ts' header); a raw one leaves them disagreeing. // - autoQueue: a lane's tree is its policy editor's, or compiled from the // channel priorities — and the editor refuses a tree edit while they rule. // - workers: /workers validates each worker (templates, URLs) on save. // - storage: a location is probed and its volume recorded when added or // re-pointed (/storage); a raw root would skip both. export const OWNED_KEYS: Readonly> = { channelPriority: "it is written with the lanes' trees — use `pnpm ops channel-priority`", autoQueue: "a lane's switch, hold and runner are `pnpm ops lane`; its tree is the policy editor's on /operations, or compiled from channel priorities", workers: "workers are saved on /workers; `pnpm ops workers` enables and disables them", storage: "locations are added and re-pointed on /storage, which probes them; a channel's media moves with `pnpm ops relocate`", }; function isObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } // Every leaf of `sent` whose value in `saved` is not the same, as // "path: sent X, would be saved as Y". An object in `sent` is compared key by // key (a key it leaves out is not compared); an array must match in length and // element by element. export function coercedLeaves(sent: unknown, saved: unknown, at: string): string[] { if (isObject(sent)) { if (!isObject(saved)) return [`${at}: sent an object, would be saved as ${JSON.stringify(saved)}`]; return Object.keys(sent).flatMap((k) => coercedLeaves(sent[k], saved[k], `${at}.${k}`)); } if (Array.isArray(sent)) { if (!Array.isArray(saved) || saved.length !== sent.length) { return [`${at}: sent ${JSON.stringify(sent)}, would be saved as ${JSON.stringify(saved)}`]; } return sent.flatMap((v, i) => coercedLeaves(v, saved[i], `${at}[${i}]`)); } return Object.is(sent, saved) ? [] : [`${at}: sent ${JSON.stringify(sent)}, would be saved as ${JSON.stringify(saved)}`]; } // The sentence to refuse `patch` with, or null. `known` is the schema's // top-level keys; `parsed` is the schema's reading of the merged settings. export function settingsPatchProblem( patch: Record, known: readonly string[], parsed: Record, ): string | null { const keys = Object.keys(patch); const unknown = keys.filter((k) => !known.includes(k)); if (unknown.length) { return `unknown settings key(s): ${unknown.join(", ")} — SETTINGS.md lists them`; } const owned = keys.filter((k) => Object.hasOwn(OWNED_KEYS, k)); if (owned.length) { return owned.map((k) => `"${k}" is not patched here: ${OWNED_KEYS[k]}`).join("; "); } const coerced = keys.flatMap((k) => coercedLeaves(patch[k], parsed[k], k)); if (coerced.length) { return `not saved — the schema would not keep ${coerced.length === 1 ? "this value" : "these values"} as sent: ${coerced.join("; ")}`; } return null; }