The hub keeps a baseline per app in /data/apps/<app>/baseline.json. Local fields (overrides.json) are left out of the sync and out of the matrix comparison. Each server gets the baseline plus its own values. Applying goes target picker, preview with per-server diffs and warnings, then apply. Apply refuses a server whose config changed since the preview, saves the old config to history, then reads the new one back to check it. Large config bodies are read only for signed-in callers. The App config dialog has Compare, Baseline and Local fields tabs and an empty state that offers an import. Co-Authored-By: Claude Opus 5.5 <[email protected]>
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
/*
|
|
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<string>()
|
|
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<string, Json> | 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 = <T>(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<string, Json>)[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' | 'del'
|
|
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: 'del', 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<string, string | null>, 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
|
|
}
|
|
|
|
/* 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<string, unknown>)) {
|
|
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<string, unknown>)) {
|
|
if (paths.includes(path)) out[server][path] = value
|
|
}
|
|
}
|
|
return out
|
|
}
|