366 lines
14 KiB
JavaScript
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
|
|
};
|