Every saved config, and every earlier baseline, is listed per app. You can view it, diff it against a server's current config, and roll it back through preview and apply. Earlier baselines can be restored. A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with its effective config. Drift shows in the header and the matrix, with Accept as baseline and Revert to baseline. All baselines export to one file. Importing that file shows a dry run before it writes. Rolling back preselects, and diffs against, the server the version came from. The line-diff op "del" is renamed to "remove" for the language lint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
12 KiB
TypeScript
305 lines
12 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' | '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<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
|
|
}
|
|
|
|
/*
|
|
"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<string, Json> = {}
|
|
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<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
|
|
}
|