// 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 };