// 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(`
${escapeHtml(s)}`).join(" ") + `Plan ${escapeHtml(planId)} to make this instance match ${escapeHtml(peerLabel || plan.source_env_id || "source")} ` +
`(sync mode ${escapeHtml(plan.sync_mode)}).
${diff.PLAN_KINDS.concat(["config"]).map((k) => chip(k, s[k])).join("")}
`; if (plan.destructive.length > 0) { const items = plan.destructive.map((x) => `${escapeHtml(u.name)}: ${escapeHtml(bits.join(", "))}${escapeHtml(Object.keys(plan.config.sets).join(", "))}${escapeHtml(name)} ${escapeHtml(c.mode)} ` +
`+${c.inserts} ~${c.updates} -${c.deletes}