320 lines
15 KiB
JavaScript
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
|
|
};
|