Files
technitium-console/hub/apps-sync.ts
T
DenozordecandClaude Opus 5.5 0e05039202 Hub stage 3: compare and sync app configs across servers
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]>
2026-10-31 16:46:15 +07:00

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
}