/* Pure functions behind the config sync: the effective config of a server (the baseline plus that server's local values), comparisons that leave the local fields out, and the diffs the console shows. No I/O here. */ import { createHash } from 'node:crypto' import { jsonDiff } from './json-diff.ts' import type { Overrides } from './store.ts' export const MAX_CONFIG = 1024 * 1024 export const MAX_PATHS = 50 const MAX_LINES = 2000 type Json = unknown export type Parsed = { ok: true; value: Json } | { ok: false; error: string } export function parseJson(text: string): Parsed { try { return { ok: true, value: JSON.parse(text) } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : 'invalid JSON' } } } /** A config that starts like JSON must be JSON; anything else is treated as plain text. */ export const looksLikeJson = (text: string): boolean => /^\s*[{[]/.test(text) /* ---- Paths: `a.b.0.c`; a numeric segment indexes an array. ---- */ const SEGMENT = /^[^.\s,[\]]+$/ export function parsePath(path: string): string[] | null { if (path.length === 0 || path.length > 200) return null const segments = path.split('.') return segments.every((s) => SEGMENT.test(s)) ? segments : null } export function validatePaths(paths: unknown): string[] { if (!Array.isArray(paths)) throw new Error('paths must be a list.') if (paths.length > MAX_PATHS) throw new Error(`At most ${MAX_PATHS} local fields.`) const seen = new Set() for (const p of paths) { if (typeof p !== 'string' || parsePath(p) === null) throw new Error(`"${String(p)}" is not a field path such as a.b.0.c.`) if (seen.has(p)) throw new Error(`"${p}" is listed twice.`) seen.add(p) } return paths as string[] } const isContainer = (v: Json): v is Record | Json[] => v !== null && typeof v === 'object' const isIndex = (s: string): boolean => /^\d+$/.test(s) export function getAt(root: Json, segments: string[]): { found: boolean; value?: Json } { let at: Json = root for (const s of segments) { if (!isContainer(at)) return { found: false } if (Array.isArray(at)) { if (!isIndex(s) || Number(s) >= at.length) return { found: false } at = at[Number(s)] } else { if (!Object.hasOwn(at, s)) return { found: false } at = at[s] } } return { found: true, value: at } } /* Sets a value, making the containers on the way when they are missing. Returns false if the path cannot be set. */ export function setAt(root: Json, segments: string[], value: Json): boolean { let at: Json = root for (let i = 0; i < segments.length; i++) { const s = segments[i] const last = i === segments.length - 1 if (!isContainer(at)) return false if (Array.isArray(at)) { if (!isIndex(s)) return false const index = Number(s) if (index > at.length) return false if (last) at[index] = value else { if (at[index] === undefined) at[index] = isIndex(segments[i + 1]) ? [] : {} at = at[index] } } else { if (s === '__proto__') return false if (last) at[s] = value else { if (!Object.hasOwn(at, s) || !isContainer(at[s])) at[s] = isIndex(segments[i + 1]) ? [] : {} at = at[s] } } } return true } export function removeAt(root: Json, segments: string[]): void { const parent = getAt(root, segments.slice(0, -1)) const last = segments[segments.length - 1] if (!parent.found || !isContainer(parent.value)) return if (Array.isArray(parent.value)) { if (isIndex(last) && Number(last) < parent.value.length) parent.value.splice(Number(last), 1) } else delete parent.value[last] } const clone = (value: T): T => structuredClone(value) /* A copy of the value without the local fields. Removing array items shifts the rest, so the deepest and highest go first. */ export function stripLocal(value: Json, paths: string[]): Json { const copy = clone(value) const sorted = paths .map((p) => parsePath(p)) .filter((s): s is string[] => s !== null) .sort((a, b) => b.length - a.length || b.join('.').localeCompare(a.join('.'), undefined, { numeric: true })) for (const segments of sorted) removeAt(copy, segments) return copy } function sortKeys(value: Json): Json { if (Array.isArray(value)) return value.map(sortKeys) if (isContainer(value)) return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys((value as Record)[k])])) return value } /* A string two configs can be compared by: for JSON, the config without the local fields with its keys sorted; for anything else, the text. Without local fields it is the text itself, as before. */ export function comparable(text: string, paths: string[]): string { if (paths.length === 0) return text const parsed = parseJson(text) if (!parsed.ok) return text return JSON.stringify(sortKeys(stripLocal(parsed.value, paths))) } export const textHash = (text: string): string => createHash('sha256').update(text).digest('hex').slice(0, 16) /* ---- The effective config of one server ---- */ export interface Effective { text: string warnings: string[] } export function effectiveConfig(baselineText: string, overrides: Overrides, server: string): Effective { const parsed = parseJson(baselineText) if (!parsed.ok || !looksLikeJson(baselineText)) { const warnings = overrides.paths.length > 0 ? ['The baseline is plain text, so local fields cannot be applied; the baseline is used as it is.'] : [] return { text: baselineText, warnings } } const value = clone(parsed.value) const warnings: string[] = [] const mine = overrides.values[server] ?? {} for (const path of overrides.paths) { const segments = parsePath(path) if (segments === null) continue if (Object.hasOwn(mine, path)) { if (!setAt(value, segments, clone(mine[path]))) warnings.push(`Local field "${path}" does not fit the baseline structure and was skipped.`) } else if (getAt(value, segments).found) { warnings.push(`No local value set for "${path}" on ${server}; the baseline value is used.`) } // No local value and not in the baseline: nothing to remove, the field stays absent. } return { text: JSON.stringify(value, null, 2), warnings } } /* The same config? JSON is compared by meaning (key order and spacing do not count), anything else by text. */ export function sameConfig(a: string, b: string, paths: string[] = []): boolean { const [pa, pb] = [parseJson(a), parseJson(b)] if (pa.ok && pb.ok && looksLikeJson(a) && looksLikeJson(b)) return jsonDiff(stripLocal(pa.value, paths), stripLocal(pb.value, paths)).length === 0 return a.replace(/\r\n/g, '\n').trimEnd() === b.replace(/\r\n/g, '\n').trimEnd() } /* ---- Diffs ---- */ export interface FieldChange { path: string type: 'added' | 'removed' | 'changed' a?: Json b?: Json } export interface LineOp { op: 'same' | 'add' | 'remove' text: string } export type Diff = | { kind: 'json'; changes: FieldChange[] } | { kind: 'text'; lines: LineOp[]; truncated: boolean; changed: number } /* A to B: `added` means B has it and A does not. Local fields are left out of both. */ export function diffConfigs(a: string, b: string, localPaths: string[] = []): Diff { const [pa, pb] = [parseJson(a), parseJson(b)] if (pa.ok && pb.ok && looksLikeJson(a) && looksLikeJson(b)) { const changes = jsonDiff(stripLocal(pa.value, localPaths), stripLocal(pb.value, localPaths)).map((d): FieldChange => { const type = d.a === undefined ? 'added' : d.b === undefined ? 'removed' : 'changed' return { path: d.path === '' ? '(whole config)' : d.path, type, a: d.a, b: d.b } }) return { kind: 'json', changes } } return { kind: 'text', ...lineDiff(a, b) } } export function lineDiff(a: string, b: string): { lines: LineOp[]; truncated: boolean; changed: number } { const [x, y] = [a.replace(/\r\n/g, '\n').split('\n'), b.replace(/\r\n/g, '\n').split('\n')] if (x.length > MAX_LINES || y.length > MAX_LINES) { const set = new Set(x) const changed = y.filter((l) => !set.has(l)).length + x.filter((l) => !new Set(y).has(l)).length return { lines: [], truncated: true, changed } } // Longest common subsequence table, filled from the end. const w = y.length + 1 const table = new Uint16Array((x.length + 1) * w) for (let i = x.length - 1; i >= 0; i--) { for (let j = y.length - 1; j >= 0; j--) { table[i * w + j] = x[i] === y[j] ? table[(i + 1) * w + j + 1] + 1 : Math.max(table[(i + 1) * w + j], table[i * w + j + 1]) } } const lines: LineOp[] = [] let i = 0 let j = 0 while (i < x.length || j < y.length) { if (i < x.length && j < y.length && x[i] === y[j]) { lines.push({ op: 'same', text: x[i] }) i++ j++ } else if (j < y.length && (i === x.length || table[i * w + j + 1] > table[(i + 1) * w + j])) { lines.push({ op: 'add', text: y[j++] }) } else lines.push({ op: 'remove', text: x[i++] }) } return { lines, truncated: false, changed: lines.filter((l) => l.op !== 'same').length } } export const diffIsEmpty = (d: Diff): boolean => (d.kind === 'json' ? d.changes.length === 0 : d.changed === 0) /* ---- Local values ---- */ /* Fills each server's current value of each local path from its live config. A server whose config is not JSON, or that has no such field, gets no entry for it. */ export function importLocalValues(configs: Record, paths: string[]): Overrides['values'] { const out: Overrides['values'] = {} for (const [server, text] of Object.entries(configs)) { out[server] = {} if (text === null) continue const parsed = parseJson(text) if (!parsed.ok) continue for (const path of paths) { const segments = parsePath(path) if (segments === null) continue const found = getAt(parsed.value, segments) if (found.found) out[server][path] = clone(found.value) } } return out } /* "Accept as baseline": the baseline becomes this server's config, except that the local fields keep the baseline's old values, and this server's own values for them move into the overrides. */ export function acceptServerConfig( serverText: string, oldBaselineText: string | null, overrides: Overrides, server: string, ): { baselineText: string; overrides: Overrides } { if (overrides.paths.length === 0) return { baselineText: serverText, overrides } const parsed = parseJson(serverText) if (!parsed.ok || !looksLikeJson(serverText)) throw new Error('The config is plain text, so its local fields cannot be separated out.') const value = clone(parsed.value) const old = oldBaselineText === null ? null : parseJson(oldBaselineText) const mine: Record = {} for (const path of overrides.paths) { const segments = parsePath(path) if (segments === null) continue const here = getAt(value, segments) if (here.found) mine[path] = clone(here.value) const before = old !== null && old.ok ? getAt(old.value, segments) : { found: false } if (before.found) setAt(value, segments, clone(before.value)) else removeAt(value, segments) } return { baselineText: JSON.stringify(value, null, 2), overrides: { paths: overrides.paths, values: { ...overrides.values, [server]: { ...(overrides.values[server] ?? {}), ...mine } } }, } } /* Local values for paths that are listed, from servers that exist; anything else is dropped. */ export function cleanValues(values: unknown, paths: string[], servers: string[]): Overrides['values'] { if (values === null || typeof values !== 'object' || Array.isArray(values)) throw new Error('values must be an object of servers.') const out: Overrides['values'] = {} for (const [server, mine] of Object.entries(values as Record)) { if (!servers.includes(server)) throw new Error(`Unknown server: ${server}.`) if (mine === null || typeof mine !== 'object' || Array.isArray(mine)) throw new Error(`The values of ${server} must be an object of paths.`) out[server] = {} for (const [path, value] of Object.entries(mine as Record)) { if (paths.includes(path)) out[server][path] = value } } return out }