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

291 lines
12 KiB
JavaScript

// The surgical structural-convergence engine: RENAMES and DELETES.
//
// This is the sole reason site-sync differs from install_pack (which is
// additive-only) and why it does NOT use uninstall_pack (too coarse: it drops
// whole tables by name, taking their row data, and name-collides). Everything here
// operates ONLY on entities the plan explicitly identified (current-not-in-source),
// never a name-sweep, and every table/field drop passes a policy gate.
//
// Order matters (reverse dependency, like uninstall_pack):
// RENAMES run first, in apply.js, BEFORE install_pack -- a data-preserving
// Table.rename means install_pack then UPDATES the renamed table instead of
// creating a fresh empty one. Only renames the admin CONFIRMED are applied (a
// structural match can be a false positive). (SQLite can't ALTER RENAME, so it
// falls back to drop+create, where the data-bearing old table is RETAINED by
// the gate below.)
// DELETES run last, after install_pack: page_groups -> pages -> views ->
// triggers -> library -> tags -> models -> code pages -> constraint removals ->
// field drops on surviving tables (so no surviving FK column still points at a
// doomed table) -> FK fields + triggers of EVERY doomed table -> the tables.
//
// Delete gates (the highest data-loss surface in the whole plugin):
// * users / ROW_SYNC_LOCKED_TABLES are NEVER dropped.
// * a metadata-only drop (0 rows) proceeds under mirror mode.
// * a DATA-BEARING drop (table with rows, or a field on a table with rows) is
// allowed only when on_delete != refuse AND the table name was typed into the
// caller's `prunable` allow-list AND (on_delete == auto OR confirmDataDrops).
// Otherwise it is SKIPPED and reported, never silently dropped.
const { isEqual } = require("lodash");
const Table = require("@saltcorn/data/models/table");
const rowsLib = require("./rows");
const { DELETE_POLICY, isInfraName, isLockedTable } = require("./constants");
// Decide whether a data-bearing table/field drop is permitted. Metadata-only drops
// (rowCount 0) are always allowed under mirror.
const gateDrop = ({ name, rowCount, onDelete, prunable, confirmDataDrops }) => {
if ((rowCount || 0) === 0) {
return { allowed: true };
}
if (onDelete === DELETE_POLICY.REFUSE) {
return { allowed: false, reason: `refuse policy: "${name}" holds ${rowCount} row(s)` };
}
if (!prunable.includes(name)) {
return { allowed: false, reason: `"${name}" holds ${rowCount} row(s); type its name into the prunable allow-list to drop it` };
}
if (onDelete === DELETE_POLICY.CONFIRM && !confirmDataDrops) {
return { allowed: false, reason: `"${name}" holds data; the data-loss confirmation is required` };
}
return { allowed: true };
};
// --- rename pre-pass (called from apply.js BEFORE install_pack) ---
// Apply the CONFIRMED data-preserving TABLE renames (opts.confirmRenames = the old
// table names the admin ticked; absent = none). The table's data mode moves with
// it. Returns { renamed:[{from,to}], failed:[{from,to,error}] }. A `from` that is
// not confirmed, no longer exists, or fails to rename is left for the delete pass
// (gated) to handle, so no data is ever lost by a declined or failed rename.
const applyRenames = async (plan, opts = {}) => {
const result = { renamed: [], failed: [] };
const confirmed = new Set(opts.confirmRenames || []);
for (const r of plan.entities.tables.rename) {
if (!confirmed.has(r.from) || isLockedTable(r.from) || isLockedTable(r.to) || isInfraName(r.to)) {
continue;
}
const tbl = Table.findOne({ name: r.from });
if (!tbl) {
continue;
}
try {
await tbl.rename(r.to);
} catch (e) {
// e.g. SQLite (no ALTER RENAME) -- fall back to drop+create; the old
// data-bearing table is retained by the delete gate.
result.failed.push({ from: r.from, to: r.to, error: e.message });
continue;
}
result.renamed.push({ from: r.from, to: r.to });
await rowsLib.renameModeRow(r.from, r.to);
}
return result;
};
// --- delete pass (called from apply.js AFTER install_pack) ---
// Metadata-only kinds, in dependency order (groups reference pages, pages embed
// views, triggers/tags reference views and tables, ...).
const METADATA_DELETE_ORDER = ["page_groups", "pages", "views", "triggers", "library", "tags", "models"];
const deleteEntity = async (kind, entry) => {
const name = entry.name;
let found;
if (kind === "page_groups") {
found = require("@saltcorn/data/models/page_group").findOne({ name });
} else if (kind === "pages") {
found = require("@saltcorn/data/models/page").findOne({ name });
} else if (kind === "views") {
found = require("@saltcorn/data/models/view").findOne({ name });
} else if (kind === "triggers") {
found = require("@saltcorn/data/models/trigger").findOne({ name });
} else if (kind === "library") {
found = await require("@saltcorn/data/models/library").findOne({ name });
} else if (kind === "tags") {
found = await require("@saltcorn/data/models/tag").findOne({ name });
} else if (kind === "models") {
const tbl = Table.findOne({ name: entry.table_name });
found = tbl ? await require("@saltcorn/data/models/model").findOne({ name, table_id: tbl.id }) : null;
}
if (found) {
await found.delete();
}
};
// Remove code pages (server-side JavaScript kept in the function_code_pages /
// function_code_pages_tags config maps), then rebuild the code-page context.
const deleteCodePages = async (names) => {
const { getState } = require("@saltcorn/data/db/state");
const state = getState();
const pages = state.getConfigCopy("function_code_pages", {});
const tags = state.getConfigCopy("function_code_pages_tags", {});
for (const n of names) {
delete pages[n];
delete tags[n];
}
await state.setConfig("function_code_pages", pages);
await state.setConfig("function_code_pages_tags", tags);
await state.refresh_codepages();
};
// Remove table constraints the source no longer has (install_pack only adds).
const removeConstraints = async (tbl, specs, result) => {
if (specs.length === 0) {
return;
}
const TableConstraint = require("@saltcorn/data/models/table_constraints");
const existing = await TableConstraint.find({ table_id: tbl.id });
for (const spec of specs) {
const c = existing.find((x) => x.type === spec.type && isEqual(x.configuration, spec.configuration));
if (!c) {
continue;
}
try {
await c.delete();
result.removedConstraints += 1;
} catch (err) {
result.skipped.push({ kind: "constraint", name: `${tbl.name} ${spec.type}`, reason: err.message });
}
}
};
// Drop fields from a surviving table, gated once by the table's LIVE row count.
const dropFields = async (tbl, fields, gateOpts, result) => {
if (fields.length === 0) {
return;
}
let gate;
try {
gate = gateDrop(Object.assign({ name: tbl.name, rowCount: await tbl.countRows() }, gateOpts));
} catch (err) {
gate = { allowed: false, reason: `could not count rows: ${err.message}` };
}
for (const f of fields) {
const label = `${tbl.name}.${f.name}`;
if (!gate.allowed || isInfraName(f.name)) {
result.skipped.push({ kind: "field", name: label, reason: gate.reason || "infrastructure field" });
continue;
}
try {
const field = Table.findOne({ name: tbl.name }).getField(f.name);
if (field) {
await field.delete();
result.droppedFields += 1;
}
} catch (err) {
result.skipped.push({ kind: "field", name: label, reason: err.message });
}
}
};
// Remove the edges that would block dropping `name`: its own FK fields and its
// triggers. Done for EVERY doomed table before any drop, so doomed tables that
// reference each other never block one another.
const stripTableEdges = async (name) => {
const Trigger = require("@saltcorn/data/models/trigger");
const tbl = Table.findOne({ name });
for (const f of tbl.getFields().filter((x) => x.is_fkey)) {
await f.delete();
}
for (const trg of Trigger.find({ table_id: tbl.id })) {
await trg.delete();
}
};
const applyDeletes = async (plan, opts = {}) => {
const gateOpts = {
onDelete: opts.onDelete || DELETE_POLICY.CONFIRM,
prunable: opts.prunable || [],
confirmDataDrops: !!opts.confirmDataDrops
};
const renamed = new Set((opts.renamed || []).map((r) => r.from));
const result = { applied: true, deleted: 0, droppedFields: 0, removedConstraints: 0, skipped: [] };
for (const kind of METADATA_DELETE_ORDER) {
for (const e of plan.entities[kind].delete) {
try {
await deleteEntity(kind, e);
} catch (err) {
result.skipped.push({ kind, name: e.name, reason: err.message });
}
}
}
const codePages = plan.entities.code_pages.delete.map((c) => c.name);
if (codePages.length > 0) {
await deleteCodePages(codePages);
}
// Tables the source dropped, plus every rename `from` that was not renamed
// (declined, or failed on SQLite). Each is gated on a LIVE row count: the
// precondition hash is metadata-only, so the preview-time count is not
// trustworthy (rows added since would slip through the zero-row fast path).
const dropNames = plan.entities.tables.delete.map((d) => d.name)
.concat(plan.entities.tables.rename.filter((r) => !renamed.has(r.from)).map((r) => r.from));
const doomed = [];
for (const name of dropNames) {
const tbl = Table.findOne({ name });
if (isLockedTable(name)) {
result.skipped.push({ kind: "table", name, reason: "system/locked table -- never dropped" });
} else if (tbl) {
try {
const gate = gateDrop(Object.assign({ name, rowCount: await tbl.countRows() }, gateOpts));
if (gate.allowed) {
doomed.push(name);
} else {
result.skipped.push({ kind: "table", name, reason: gate.reason });
}
} catch (err) {
result.skipped.push({ kind: "table", name, reason: err.message });
}
}
}
// Surviving tables first: constraint removals, then field drops (so no surviving
// FK column still references a doomed table when it is dropped).
for (const u of plan.entities.tables.update) {
const tbl = Table.findOne({ name: u.name });
if (!tbl || isLockedTable(u.name)) {
continue;
}
await removeConstraints(tbl, u.constraints.remove, result);
await dropFields(tbl, u.fields.delete, gateOpts, result);
}
const droppable = [];
for (const name of doomed) {
try {
await stripTableEdges(name);
droppable.push(name);
} catch (err) {
result.skipped.push({ kind: "table", name, reason: err.message });
}
}
for (const name of droppable) {
try {
await Table.findOne({ name }).delete();
result.deleted += 1;
} catch (err) {
result.skipped.push({ kind: "table", name, reason: err.message });
}
}
return result;
};
module.exports = {
applyRenames,
applyDeletes,
gateDrop
};