sc-site-sync/lib/diff.js
2026-10-19 01:25:16 -05:00

366 lines
14 KiB
JavaScript

// The diff engine: compute a desired-vs-current PLAN.
//
// Both inputs are sanitized packs produced by capture.buildDesiredState (source's
// desired state and the target's own current state), so they are already
// infra-filtered, config-allow-listed, and name-keyed. This module is PURE: it
// takes the two packs plus a row-count map and options, and returns a plain plan
// object. All live-DB work (gathering row counts, can_install_pack warnings,
// persistence) lives in plan.js; all mutation lives in apply.js / deletes.js.
//
// Identity: every kind is keyed by NAME except ROLES (keyed by numeric id -- a
// privilege level, the key install_pack itself upserts on) and MODELS (name +
// table). Classification per kind: in-source-only = CREATE, in-both-but-differing
// (lodash isEqual after normalization) = UPDATE, in-target-only = DELETE (only
// under mirror mode). Tables additionally get a nested field-level and
// constraint-level diff. Probable TABLE renames (a delete whose structural
// signature uniquely matches a create's) are lifted out of create/delete into
// `rename`, so the admin can confirm a data-preserving Table.rename instead of
// drop+recreate. Other kinds carry no row data, so they are planned exactly as apply
// executes them: create + delete.
const { isEqual } = require("lodash");
const { SYNC_MODES, DELETE_POLICY } = require("./constants");
// --- normalization (avoid false diffs) ---
// A workflow step embedded in a trigger pack keeps the source DB's numeric
// trigger_id (a name-vs-id leak that is meaningless on the target). Strip it
// before comparing so a matching trigger doesn't look changed.
const normalizeTrigger = (trig) => {
if (!trig || !Array.isArray(trig.steps)) {
return trig;
}
const steps = trig.steps.map((s) => {
const { trigger_id, ...rest } = s;
return rest;
});
return Object.assign({}, trig, { steps });
};
// A page pack carries root_page_for_roles (which roles use this page as their home).
// That is per-instance home-page CONFIG (like base_url), not design, and install_pack
// only sets a role home when it is currently empty (non-clobbering) -- so a differing
// home would make the page diff forever without converging. Strip it from the compare.
const normalizePage = (page) => {
if (!page || page.root_page_for_roles === undefined) {
return page;
}
const { root_page_for_roles, ...rest } = page;
return rest;
};
const normalizeByKind = (kind, entry) => {
if (kind === "triggers") {
return normalizeTrigger(entry);
}
if (kind === "pages") {
return normalizePage(entry);
}
return entry;
};
// --- structural signatures for rename detection ---
const sortByName = (arr) => (arr || []).slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
const tableSignature = (t) => {
const fields = sortByName(t.fields).map((f) => ({
name: f.name, type: f.type, reftable_name: f.reftable_name,
required: !!f.required, is_unique: !!f.is_unique, primary_key: !!f.primary_key,
attributes: f.attributes
}));
const constraints = (t.constraints || []).map((c) => ({ type: c.type, configuration: c.configuration }))
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
return JSON.stringify({ fields, constraints });
};
// --- generic classification ---
const indexBy = (arr, keyOf) => {
const m = new Map();
for (const e of (arr || [])) {
m.set(keyOf(e), e);
}
return m;
};
// Classify a kind's entries into create / update / delete buckets. `deletable`
// and `mirror` gate whether target-only entries become deletes. (Locked tables
// such as users never reach here: capture excludes them from both packs.)
const classify = (desiredArr, currentArr, keyOf, kind, { deletable, mirror }) => {
const desired = indexBy(desiredArr, keyOf);
const current = indexBy(currentArr, keyOf);
const bucket = { create: [], update: [], delete: [], rename: [] };
for (const [key, dEntry] of desired) {
if (!current.has(key)) {
bucket.create.push(dEntry);
} else if (!isEqual(normalizeByKind(kind, dEntry), normalizeByKind(kind, current.get(key)))) {
bucket.update.push({ key, from: current.get(key), to: dEntry });
}
}
if (deletable && mirror) {
for (const [key, cEntry] of current) {
if (!desired.has(key)) {
bucket.delete.push(cEntry);
}
}
}
return bucket;
};
// Lift probable renames out of a bucket: a delete whose signature uniquely matches
// exactly one create's signature (and vice versa) is treated as a rename. Mutates
// and returns the bucket. keyOf identifies the human key for the plan.
const detectRenames = (bucket, signatureOf, keyOf) => {
if (bucket.create.length === 0 || bucket.delete.length === 0) {
return bucket;
}
const sigCount = (arr) => {
const m = new Map();
for (const e of arr) {
const s = signatureOf(e);
m.set(s, (m.get(s) || 0) + 1);
}
return m;
};
const createSig = sigCount(bucket.create);
const deleteSig = sigCount(bucket.delete);
const usedCreate = new Set();
const usedDelete = new Set();
for (let di = 0; di < bucket.delete.length; di++) {
const dEntry = bucket.delete[di];
const s = signatureOf(dEntry);
// Only an unambiguous 1:1 signature match is a rename.
if (createSig.get(s) !== 1 || deleteSig.get(s) !== 1) {
continue;
}
const ci = bucket.create.findIndex((c, idx) => !usedCreate.has(idx) && signatureOf(c) === s);
if (ci >= 0) {
usedCreate.add(ci);
usedDelete.add(di);
bucket.rename.push({ from: keyOf(dEntry), to: keyOf(bucket.create[ci]), fromEntry: dEntry, toEntry: bucket.create[ci] });
}
}
bucket.create = bucket.create.filter((_, idx) => !usedCreate.has(idx));
bucket.delete = bucket.delete.filter((_, idx) => !usedDelete.has(idx));
return bucket;
};
// --- nested table diff (fields + constraints + table-level props) ---
const TABLE_PROP_KEYS = [
"description", "is_user_group", "min_role_read", "min_role_write", "versioned",
"has_sync_info", "provider_name", "provider_cfg", "ownership_formula", "ownership_field_name"
];
const tablePropsDiffer = (from, to) => {
for (const k of TABLE_PROP_KEYS) {
if (!isEqual(from[k], to[k])) {
return true;
}
}
return false;
};
const diffConstraints = (fromArr, toArr) => {
const key = (c) => JSON.stringify({ type: c.type, configuration: c.configuration });
const fromSet = new Set((fromArr || []).map(key));
const toSet = new Set((toArr || []).map(key));
const add = (toArr || []).filter((c) => !fromSet.has(key(c)));
const remove = (fromArr || []).filter((c) => !toSet.has(key(c)));
return { add, remove };
};
// Diff one table (present on both sides). Returns null when nothing changed.
const diffTable = (fromT, toT, rowCount) => {
const fromF = indexBy(fromT.fields, (f) => f.name);
const toF = indexBy(toT.fields, (f) => f.name);
const fields = { create: [], update: [], delete: [] };
for (const [name, f] of toF) {
if (!fromF.has(name)) {
fields.create.push(f);
} else if (!isEqual(fromF.get(name), f)) {
fields.update.push({ name, from: fromF.get(name), to: f });
}
}
for (const [name, f] of fromF) {
if (!toF.has(name)) {
fields.delete.push(f);
}
}
const constraints = diffConstraints(fromT.constraints, toT.constraints);
const propsChanged = tablePropsDiffer(fromT, toT);
const nFieldChanges = fields.create.length + fields.update.length + fields.delete.length;
const nConstr = constraints.add.length + constraints.remove.length;
if (!propsChanged && nFieldChanges === 0 && nConstr === 0) {
return null;
}
return {
name: toT.name,
propsChanged,
fields,
constraints,
rowCount,
dataBearing: rowCount > 0 && fields.delete.length > 0
};
};
// --- top-level plan ---
// A model is identified by (name, table); JSON keeps the pair unambiguous.
const modelKey = (m) => JSON.stringify([m.name, m.table_name]);
const KIND_SPECS = [
{ key: "views", keyOf: (v) => v.name, deletable: true },
{ key: "pages", keyOf: (p) => p.name, deletable: true },
{ key: "page_groups", keyOf: (g) => g.name, deletable: true },
{ key: "triggers", keyOf: (t) => t.name, deletable: true },
// Roles are ADDITIVE ONLY: a role id is a privilege level referenced by every
// entity's min_role, so pruning one would strand those references. Create/update
// by id; never delete.
{ key: "roles", keyOf: (r) => String(r.id), deletable: false },
{ key: "library", keyOf: (l) => l.name, deletable: true },
{ key: "tags", keyOf: (t) => t.name, deletable: true },
{ key: "models", keyOf: modelKey, deletable: true },
// Code pages are server-side JavaScript (install_pack writes them into
// function_code_pages); previewed + prunable like any other kind.
{ key: "code_pages", keyOf: (c) => c.name, deletable: true }
];
// Every kind a plan carries, tables first (the preview + summary order).
const PLAN_KINDS = ["tables"].concat(KIND_SPECS.map((s) => s.key));
// The identity key of an entry of a plan kind (the key its plan update carries).
const keyOfKind = (kind, entry) => {
return kind === "tables" ? entry.name : KIND_SPECS.find((s) => s.key === kind).keyOf(entry);
};
const computePlan = (desired, current, opts = {}) => {
const syncMode = opts.syncMode || SYNC_MODES.MIRROR;
const onDelete = opts.onDelete || DELETE_POLICY.CONFIRM;
const rowCounts = opts.rowCounts || {};
const mirror = syncMode === SYNC_MODES.MIRROR;
const rowsOf = (name) => rowCounts[name] || 0;
const entities = {};
// Tables: classify by name, detect renames, then compute nested diffs for the
// tables present on both sides.
const tblBucket = classify(desired.tables, current.tables, (t) => t.name, "tables", { deletable: true, mirror });
detectRenames(tblBucket, tableSignature, (t) => t.name);
const tableUpdates = [];
for (const u of tblBucket.update) {
const nested = diffTable(u.from, u.to, rowsOf(u.key));
if (nested) {
tableUpdates.push(nested);
}
}
entities.tables = {
create: tblBucket.create,
update: tableUpdates,
delete: tblBucket.delete.map((t) => ({ name: t.name, rowCount: rowsOf(t.name), dataBearing: rowsOf(t.name) > 0 })),
rename: tblBucket.rename.map((r) => ({ from: r.from, to: r.to, rowCount: rowsOf(r.from) }))
};
// Other kinds: generic classification (no rename detection -- they hold no
// row data, and apply executes them as create + delete).
for (const spec of KIND_SPECS) {
const bucket = classify(desired[spec.key], current[spec.key], spec.keyOf, spec.key, { deletable: spec.deletable, mirror });
entities[spec.key] = {
create: bucket.create,
update: bucket.update.map((u) => ({ key: u.key, to: u.to })),
delete: bucket.delete
};
}
// Config: converge-by-overwrite; never delete a target config key.
const configSets = {};
for (const [k, v] of Object.entries(desired.config || {})) {
if (!isEqual((current.config || {})[k], v)) {
configSets[k] = v;
}
}
const plan = {
sync_mode: syncMode,
on_delete: onDelete,
entities,
config: { sets: configSets },
warnings: [],
destructive: []
};
annotate(plan);
return plan;
};
// Fill plan.destructive (human-readable), code-page warnings, and plan.summary
// (counts). Destructive = drops (whole tables, fields, entities) and any
// data-bearing operation. A detected rename is listed too: unless the admin
// confirms it, apply treats its old table as a drop. Config overwrites are not
// destructive.
const annotate = (plan) => {
const d = [];
const tableDrop = (name, rowCount, text) => ({ text, dataBearing: rowCount > 0, table: name, rowCount });
const rowsText = (rowCount) => (rowCount > 0 ? `${rowCount} row(s) of data` : "no rows");
for (const t of plan.entities.tables.delete) {
d.push(tableDrop(t.name, t.rowCount, `drop table "${t.name}" (${rowsText(t.rowCount)})`));
}
for (const r of plan.entities.tables.rename) {
d.push(tableDrop(r.from, r.rowCount, `drop table "${r.from}" (${rowsText(r.rowCount)}) unless the rename "${r.from}" -> "${r.to}" is confirmed`));
}
for (const u of plan.entities.tables.update) {
for (const f of u.fields.delete) {
d.push({ text: `drop field "${u.name}.${f.name}"${u.rowCount > 0 ? ` (table has ${u.rowCount} row(s))` : ""}`,
dataBearing: u.rowCount > 0, table: u.name, field: f.name, rowCount: u.rowCount });
}
}
for (const spec of KIND_SPECS.filter((s) => s.deletable)) {
for (const e of plan.entities[spec.key].delete) {
d.push({ text: `delete ${spec.key.replace(/s$/, "")} "${e.name}"`, dataBearing: false });
}
}
plan.destructive = d;
const codeChanges = plan.entities.code_pages.create.map((c) => c.name).concat(plan.entities.code_pages.update.map((u) => u.key));
if (codeChanges.length > 0) {
plan.warnings.push(`installs/changes ${codeChanges.length} code page(s) -- server-side JavaScript that runs on this instance: ${codeChanges.join(", ")}`);
}
const count = (b) => b.create.length + b.update.length + b.delete.length + (b.rename ? b.rename.length : 0);
plan.summary = {};
for (const kind of PLAN_KINDS) {
plan.summary[kind] = count(plan.entities[kind]);
}
plan.summary.config = Object.keys(plan.config.sets).length;
plan.summary.total = Object.values(plan.summary).reduce((a, v) => a + v, 0);
plan.summary.destructive = plan.destructive.length;
plan.summary.dataBearing = plan.destructive.filter((x) => x.dataBearing).length;
};
// Is the plan a no-op (nothing to apply)?
const isEmptyPlan = (plan) => plan && plan.summary && plan.summary.total === 0;
module.exports = {
computePlan,
isEmptyPlan,
keyOfKind,
PLAN_KINDS
};