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

320 lines
15 KiB
JavaScript

// 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(`<div><span class="badge bg-${label === "delete" ? "danger" : label === "rename" ? "info" : label === "create" ? "success" : "secondary"}">${label} ${arr.length}</span> ` +
arr.map(render).map((s) => `<code>${escapeHtml(s)}</code>`).join(" ") + `</div>`);
};
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 `<div class="mb-2"><strong>${escapeHtml(title)}</strong>${parts.join("")}</div>`;
};
const renderPlanHtml = (planId, plan, peerLabel) => {
const s = plan.summary;
let html = "";
html += `<p>Plan <code>${escapeHtml(planId)}</code> to make this instance match <strong>${escapeHtml(peerLabel || plan.source_env_id || "source")}</strong> ` +
`(sync mode <span class="badge bg-info">${escapeHtml(plan.sync_mode)}</span>).</p>`;
if (diff.isEmptyPlan(plan)) {
html += `<div class="alert alert-success">Already in sync -- nothing to apply.</div>`;
return html;
}
const chip = (label, n) => n > 0 ? `<span class="badge bg-secondary me-1">${escapeHtml(label)}: ${n}</span>` : "";
html += `<p>${diff.PLAN_KINDS.concat(["config"]).map((k) => chip(k, s[k])).join("")}</p>`;
if (plan.destructive.length > 0) {
const items = plan.destructive.map((x) =>
`<li>${x.dataBearing ? '<span class="badge bg-danger">DATA</span> ' : ""}${escapeHtml(x.text)}</li>`).join("");
html += `<div class="alert alert-warning"><strong>Destructive operations (${plan.destructive.length}, ${s.dataBearing} touch data):</strong><ul class="mb-0">${items}</ul></div>`;
}
if (plan.warnings.length > 0) {
html += `<div class="alert alert-info"><strong>Warnings:</strong><ul class="mb-0">${plan.warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join("")}</ul></div>`;
}
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 += `<div class="ms-3 text-muted"><code>${escapeHtml(u.name)}</code>: ${escapeHtml(bits.join(", "))}</div>`;
}
for (const kind of diff.PLAN_KINDS.filter((k) => k !== "tables")) {
html += kindRows(kind.replace(/_/g, " "), plan.entities[kind]);
}
if (s.config > 0) {
html += `<div class="mb-2"><strong>Config</strong><div><span class="badge bg-secondary">overwrite ${s.config}</span> <code>${escapeHtml(Object.keys(plan.config.sets).join(", "))}</code></div></div>`;
}
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]) => `<div><code>${escapeHtml(name)}</code> <span class="badge bg-info">${escapeHtml(c.mode)}</span> ` +
`+${c.inserts} ~${c.updates} -${c.deletes}</div>`).join("");
if (rowBits) {
html += `<div class="mb-2"><strong>Reference rows</strong>${rowBits}</div>`;
}
}
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 method="post" action="${ADMIN_URLS.APPLY}">`;
form += csrfHtml || "";
form += `<input type="hidden" name="plan_id" value="${escapeHtml(planId)}">`;
renames.forEach((r, i) => {
form += `<div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="confirm_rename" value="${escapeHtml(r.from)}" id="ssRename${i}">` +
`<label class="form-check-label" for="ssRename${i}">Rename table <code>${escapeHtml(r.from)}</code> to <code>${escapeHtml(r.to)}</code>, keeping its ${r.rowCount} row(s)</label></div>`;
});
if (dataDrops.length > 0) {
form += `<div class="mb-2"><label class="form-label text-danger fw-bold" for="ssPrunable">` +
`${dataDrops.length} data-bearing drop(s) in: ${dropNames.map((n) => `<code>${escapeHtml(n)}</code>`).join(", ")}. ` +
`Type the names of the tables you allow to be dropped (comma-separated); their rows will be destroyed:</label>` +
`<input class="form-control" name="prunable" id="ssPrunable" autocomplete="off"></div>`;
form += `<div class="form-check mb-3"><input class="form-check-input" type="checkbox" name="confirm_data" value="1" id="ssConfirmData">` +
`<label class="form-check-label" for="ssConfirmData">I understand this will permanently destroy data in the tables I typed above.</label></div>`;
}
form += `<button class="btn btn-danger" type="submit">Apply this plan</button></form>`;
return form;
};
module.exports = {
PLAN_RETENTION,
buildPlan,
loadPlan,
listPlans,
setPlanStatus,
claimPlanStatus,
dataDropTables,
renderPlanHtml,
renderApplyForm
};