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