// Plan assembly, persistence, and preview rendering. // // A plan is computed on the MUTATING (target) side: we capture the target's own // current state, gather live row counts (so data-bearing drops are flagged // accurately), diff against the source's desired state, fold in can_install_pack // warnings, and persist the whole thing (plan + desired pack + a precondition hash // of the target's current state) into _ss_plans under a plan_id. Apply later // re-checks that precondition hash and refuses if the target drifted since preview // (optimistic concurrency). Plans are raw/regenerable -- a lost plan just has to be // re-previewed. const db = require("@saltcorn/data/db"); const Table = require("@saltcorn/data/models/table"); const capture = require("./capture"); const diff = require("./diff"); const rows = require("./rows"); const { randomUuid } = require("./ids"); const { escapeHtml, ADMIN_URLS } = require("./render"); const { PLAN_STATUS } = require("./constants"); // Keep at most this many recent plans (one row is written per preview). const PLAN_RETENTION = 200; // Count rows for every current (non-infra) table so the diff can flag which // deletes / field-drops actually destroy data. COUNT(*) per table -- acceptable // for a plan preview. A table that cannot be counted is reported as a warning // (apply re-counts live before any drop, so the preview is never the last gate). const gatherRowCounts = async (currentPack, warnings) => { const counts = {}; for (const t of currentPack.tables) { const tbl = Table.findOne({ name: t.name }); try { counts[t.name] = tbl ? await tbl.countRows() : 0; } catch (e) { counts[t.name] = 0; warnings.push(`could not count rows of "${t.name}" (${e.message}); its drop is re-checked at apply time`); } } return counts; }; // Best-effort advisory warnings from Saltcorn's own pre-flight validator. const collectWarnings = async (desired) => { const warnings = []; try { const pack = require("@saltcorn/admin-models/models/pack"); const res = await pack.can_install_pack(desired); if (res && res.warning) { warnings.push(res.warning); } } catch (e) { warnings.push(`could not run pre-flight check: ${e.message}`); } return warnings; }; // Compute + persist a plan for making THIS instance's state match `desired`. // Returns { plan_id, plan }. const buildPlan = async ({ peerRow, env, desired, desiredHash, sourceEnvId, direction, extraWarnings, sourceRows }) => { // NEVER trust the sender's sanitization: re-run it on the target before the // pack influences the diff or the persisted plan (drops infra tables the peer // may have injected, and any non-allow-listed config key). desired = capture.sanitizeDesired(desired); const current = await capture.buildDesiredState(); const countWarnings = []; const rowCounts = await gatherRowCounts(current.pack, countWarnings); const plan = diff.computePlan(desired, current.pack, { syncMode: env.sync_mode, onDelete: env.on_delete, rowCounts: rowCounts }); // Reference-row plan (opt-in managed/starter tables only). The counts feed the // preview; the source manifest is stashed on the plan so apply can replay it. // Note: install_pack must run before rows on apply, so the schema exists. const rowManifest = sourceRows || {}; const rowPreview = await rows.previewRowSync(rowManifest); plan.rows = rowPreview; plan.sourceRows = rowManifest; const rowOps = Object.values(rowPreview.perTable || {}).reduce((a, t) => a + t.inserts + t.updates + t.deletes, 0); plan.summary.rows = rowOps; plan.summary.total = (plan.summary.total || 0) + rowOps; plan.source_env_id = sourceEnvId || (peerRow && peerRow.env_id) || null; plan.target_env_id = env.env_id; plan.source_hash = desiredHash; plan.precondition_hash = current.hash; plan.generated_at = new Date().toISOString(); plan.warnings = plan.warnings.concat(countWarnings, rowPreview.warnings, extraWarnings || [], await collectWarnings(desired)); const planId = randomUuid(); const schema = db.getTenantSchemaPrefix(); await db.query( `INSERT INTO ${schema}_ss_plans (plan_id, peer_id, source_env_id, direction, desired_pack, plan_json, precondition_hash, source_hash, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, [ planId, peerRow ? peerRow.peer_id : null, plan.source_env_id, direction || "pull", JSON.stringify(desired), JSON.stringify(plan), plan.precondition_hash, plan.source_hash, PLAN_STATUS.PENDING, plan.generated_at ] ); // Retention: _ss_plans gets a row on every preview, so cap it to the most recent // PLAN_RETENTION (the just-inserted plan is newest, so never pruned). A plan // whose restore point is still kept (_ss_rollback) stays too: rollback needs // both, so a burst of previews must never cost a recent apply its rollback. await db.query( `DELETE FROM ${schema}_ss_plans WHERE plan_id NOT IN ` + `(SELECT plan_id FROM ${schema}_ss_plans ORDER BY created_at DESC LIMIT ${PLAN_RETENTION}) ` + `AND plan_id NOT IN (SELECT plan_id FROM ${schema}_ss_rollback)` ).catch(() => {}); return { plan_id: planId, plan }; }; const loadPlan = async (planId) => { const schema = db.getTenantSchemaPrefix(); const rs = await db.query(`SELECT * FROM ${schema}_ss_plans WHERE plan_id = $1`, [planId]); if (rs.rows.length === 0) { return null; } const row = rs.rows[0]; return { plan_id: row.plan_id, peer_id: row.peer_id, source_env_id: row.source_env_id, direction: row.direction, desired: JSON.parse(row.desired_pack), plan: JSON.parse(row.plan_json), precondition_hash: row.precondition_hash, source_hash: row.source_hash, status: row.status, claimed_from: row.claimed_from, created_at: row.created_at }; }; // The most recent plans (newest first), optionally only those in `status`: what // a listing needs, without the (large) desired pack and plan JSON. const listPlans = async ({ status, limit }) => { const schema = db.getTenantSchemaPrefix(); const where = status ? "WHERE status = $1" : ""; const params = status ? [status, limit] : [limit]; const rs = await db.query( `SELECT plan_id, peer_id, source_env_id, direction, precondition_hash, status, claimed_from, created_at ` + `FROM ${schema}_ss_plans ${where} ORDER BY created_at DESC LIMIT $${params.length}`, params ); return rs.rows; }; const setPlanStatus = async (planId, status) => { const schema = db.getTenantSchemaPrefix(); await db.query(`UPDATE ${schema}_ss_plans SET status = $1 WHERE plan_id = $2`, [status, planId]); }; // Atomically move a plan from one of `fromStatuses` to `toStatus`, recording the // status it left in claimed_from (SET expressions read the row as it was, on // SQLite and Postgres alike). Returns true only for the single caller that won the // transition (RETURNING is used because Saltcorn's SQLite query() exposes no // affected-row count), so a double-submitted apply/rollback cannot run twice. const claimPlanStatus = async (planId, fromStatuses, toStatus) => { const schema = db.getTenantSchemaPrefix(); const placeholders = fromStatuses.map((_, i) => `$${i + 3}`).join(","); const rs = await db.query( `UPDATE ${schema}_ss_plans SET status = $1, claimed_from = status WHERE plan_id = $2 AND status IN (${placeholders}) RETURNING plan_id`, [toStatus, planId].concat(fromStatuses) ); return rs.rows.length === 1; }; // The tables whose data a plan's apply drops (whole tables or fields of populated // tables): each must be named in the apply's `prunable` allow-list to be dropped. const dataDropTables = (plan) => { return [...new Set(plan.destructive.filter((x) => x.dataBearing).map((x) => x.table))]; }; // --- preview rendering (returns an HTML string; the route wraps it) --- const kindRows = (title, bucket) => { const parts = []; const list = (label, arr, render) => { if (!arr || arr.length === 0) { return; } parts.push(`
${label} ${arr.length} ` + arr.map(render).map((s) => `${escapeHtml(s)}`).join(" ") + `
`); }; list("create", bucket.create, (e) => e.name || e.key || e.role || JSON.stringify(e).slice(0, 30)); list("update", bucket.update, (e) => e.name || e.key || (e.to && (e.to.name || e.to.role)) || JSON.stringify(e).slice(0, 30)); list("delete", bucket.delete, (e) => e.name || e.key || JSON.stringify(e).slice(0, 30)); list("rename", bucket.rename, (e) => `${e.from} -> ${e.to}`); if (parts.length === 0) { return ""; } return `
${escapeHtml(title)}${parts.join("")}
`; }; const renderPlanHtml = (planId, plan, peerLabel) => { const s = plan.summary; let html = ""; html += `

Plan ${escapeHtml(planId)} to make this instance match ${escapeHtml(peerLabel || plan.source_env_id || "source")} ` + `(sync mode ${escapeHtml(plan.sync_mode)}).

`; if (diff.isEmptyPlan(plan)) { html += `
Already in sync -- nothing to apply.
`; return html; } const chip = (label, n) => n > 0 ? `${escapeHtml(label)}: ${n}` : ""; html += `

${diff.PLAN_KINDS.concat(["config"]).map((k) => chip(k, s[k])).join("")}

`; if (plan.destructive.length > 0) { const items = plan.destructive.map((x) => `
  • ${x.dataBearing ? 'DATA ' : ""}${escapeHtml(x.text)}
  • `).join(""); html += `
    Destructive operations (${plan.destructive.length}, ${s.dataBearing} touch data):
    `; } if (plan.warnings.length > 0) { html += `
    Warnings:
    `; } html += kindRows("Tables", plan.entities.tables); // Nested table field/constraint changes. for (const u of plan.entities.tables.update) { const bits = []; const counted = [ [u.fields.create.length, "+", "field(s)"], [u.fields.update.length, "~", "field(s)"], [u.fields.delete.length, "-", "field(s)"], [u.constraints.add.length, "+", "constraint(s)"], [u.constraints.remove.length, "-", "constraint(s)"] ]; if (u.propsChanged) { bits.push("table props"); } for (const [n, sign, what] of counted) { if (n > 0) { bits.push(`${sign}${n} ${what}`); } } html += `
    ${escapeHtml(u.name)}: ${escapeHtml(bits.join(", "))}
    `; } for (const kind of diff.PLAN_KINDS.filter((k) => k !== "tables")) { html += kindRows(kind.replace(/_/g, " "), plan.entities[kind]); } if (s.config > 0) { html += `
    Config
    overwrite ${s.config} ${escapeHtml(Object.keys(plan.config.sets).join(", "))}
    `; } if (plan.rows && plan.rows.perTable && Object.keys(plan.rows.perTable).length > 0) { const rowBits = Object.entries(plan.rows.perTable) .filter(([, c]) => c.inserts + c.updates + c.deletes > 0) .map(([name, c]) => `
    ${escapeHtml(name)} ${escapeHtml(c.mode)} ` + `+${c.inserts} ~${c.updates} -${c.deletes}
    `).join(""); if (rowBits) { html += `
    Reference rows${rowBits}
    `; } } return html; }; // The apply form. Detected table renames are offered as UNCHECKED checkboxes // (`confirm_rename`, value = old table name): an unconfirmed rename is applied as // create + (gated) drop, so a false-positive structural match never moves data. // Data-bearing drops are listed as text and the admin must TYPE each table name // into `prunable` (deliberately never pre-filled -- the server matches the typed // names exactly) and tick `confirm_data` before applyDeletes will drop them. const renderApplyForm = (planId, plan, csrfHtml) => { const renames = plan.entities.tables.rename; const dataDrops = plan.destructive.filter((x) => x.dataBearing); const dropNames = dataDropTables(plan); let form = `
    `; form += csrfHtml || ""; form += ``; renames.forEach((r, i) => { form += `
    ` + `
    `; }); if (dataDrops.length > 0) { form += `
    ` + `
    `; form += `
    ` + `
    `; } form += `
    `; return form; }; module.exports = { PLAN_RETENTION, buildPlan, loadPlan, listPlans, setPlanStatus, claimPlanStatus, dataDropTables, renderPlanHtml, renderApplyForm };