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

276 lines
12 KiB
JavaScript

// Apply a persisted plan so this instance converges to the source's desired state.
//
// The upsert (create/update) half is delegated to Saltcorn's own install_pack:
// its two-pass FK-aware ordering (bare tables -> refresh -> fields incl. FKs ->
// constraints -> views -> triggers -> pages -> page_groups) is exactly what we
// want. install_pack is ADDITIVE-only; the surgical delete half lives in
// deletes.js.
//
// Mandatory pre-processing before handing a pack to install_pack (install_pack
// mutates its argument and has non-idempotent appenders):
// * structuredClone the pack (install_pack sorts pack.tables, rewrites min_role,
// reassigns role.id in place -- must not corrupt the persisted plan).
// * name = undefined (skip the non-idempotent installed_packs append).
// * plugins = [] (site-sync never installs plugin config; parity is a pre-flight
// warning only, and plugin_pack carries the secret deploy_private_key).
// * event_logs / model_instances = [] (EventLog.create is append-only).
// * config re-intersected with SYNCABLE_CONFIG_KEYS (never trust the sender;
// install_pack's setConfig clobbers unconditionally).
// * tags are installed in a SECOND, tags-only install_pack call, and only the
// plan's created/updated tags: an updated tag is deleted just before it is
// re-created (install_pack's existing-tag path appends entries without
// dedup), and only after the main install succeeded, so a failure there can
// never leave the target without its tags; unchanged tags are not touched.
//
// Safety flow: take the site-wide apply lock (lib/siteLock.js: one apply or
// rollback per site at a time, across workers and servers), claim the plan
// atomically, re-check the precondition hash (refuse if the target drifted since
// preview -- optimistic concurrency), take a metadata snapshot + restore point,
// apply (checking before every mutating phase that the lock is still held), then
// one non-silent getState().refresh() so sibling cluster workers converge. On
// failure the plan's status is restored and the error carries `planId`, so the
// caller can offer the rollback of a partially applied plan.
const Table = require("@saltcorn/data/models/table");
const capture = require("./capture");
const planLib = require("./plan");
const siteLock = require("./siteLock");
const deletes = require("./deletes");
const rowsLib = require("./rows");
const layoutFiles = require("./layoutFiles");
const { getEnv } = require("./env");
const { PLAN_STATUS } = require("./constants");
// The statuses a plan can be applied from (an applied plan only with opts.force).
const APPLY_FROM = [PLAN_STATUS.PENDING, PLAN_STATUS.ROLLED_BACK];
// Build the install_pack input: a deep clone reduced to the upsert-safe surface.
// Tags are left out (see installTags).
const buildUpsertPack = (desired) => {
const pack = structuredClone(desired);
pack.plugins = [];
pack.event_logs = [];
pack.model_instances = [];
pack.tags = [];
pack.config = capture.pickSyncableConfig(pack.config);
return pack;
};
// Install the plan's created/updated tags with a tags-only install_pack call.
const installTags = async (plan) => {
const tags = plan.entities.tags.create.concat(plan.entities.tags.update.map((u) => u.to));
if (tags.length === 0) {
return;
}
const Tag = require("@saltcorn/data/models/tag");
for (const u of plan.entities.tags.update) {
const existing = await Tag.findOne({ name: u.key });
if (existing) {
await existing.delete();
}
}
const tagPack = { plugins: [], event_logs: [], model_instances: [] };
for (const k of capture.PACK_KINDS.filter((kind) => kind !== "code_pages")) {
tagPack[k] = [];
}
tagPack.tags = structuredClone(tags);
await require("@saltcorn/admin-models/models/pack").install_pack(tagPack, undefined, async () => {}, false);
};
// install_pack's View.update never changes a view's table binding, so re-point
// every updated view whose desired table differs from its current one.
const fixViewTables = async (plan) => {
const View = require("@saltcorn/data/models/view");
for (const u of plan.entities.views.update) {
const cur = View.findOne({ name: u.key });
const tbl = u.to.table ? Table.findOne({ name: u.to.table }) : null;
if (!cur || (u.to.table && !tbl)) {
continue;
}
const wantId = tbl ? tbl.id : null;
if ((cur.table_id || null) !== wantId) {
await View.update({ table_id: wantId }, cur.id);
}
}
};
// Build a content-hash file fetcher for the row/layout file layers: a signed,
// response-verified GET of the file by hash from the plan's source peer.
// `opts.fileFetcher` (a test override, e.g. an in-process resolver) wins. Returns
// null when there is no peer to fetch from (files are then skipped with a warning).
const buildFileFetcher = async (loaded, env, opts) => {
if (typeof opts.fileFetcher === "function") {
return opts.fileFetcher;
}
if (loaded.peer_id === null || loaded.peer_id === undefined) {
return null;
}
const peerRow = await require("./peers").findPeer(loaded.peer_id);
if (!peerRow) {
return null;
}
const { peerBytes, API_PATHS } = require("./transport");
return async (hash) => peerBytes(peerRow, env.env_id, API_PATHS.file(hash));
};
// Flatten every non-fatal problem of an apply into human-readable warnings.
const collectApplyWarnings = (parts) => {
const out = parts.notes.slice();
for (const f of parts.renames.failed) {
out.push(`rename "${f.from}" -> "${f.to}" not applied (${f.error}); handled as create + gated drop`);
}
for (const s of parts.deletes.skipped || []) {
out.push(`${s.kind} "${s.name}" not deleted: ${s.reason}`);
}
return out.concat(parts.fileWarnings, parts.rows.warnings || []);
};
// The apply proper, run while this caller holds the site lock (lease) and the
// plan's APPLYING claim.
const runApply = async (planId, loaded, opts, lease) => {
const env = await getEnv();
const notes = [];
// Optimistic concurrency: refuse if the target changed since the preview.
const before = await capture.buildDesiredState();
if (!opts.force && before.hash !== loaded.precondition_hash) {
throw new Error("target state changed since this plan was previewed; re-preview the plan before applying");
}
// Defense in depth: re-sanitize the desired pack on the mutating side.
const desired = capture.sanitizeDesired(loaded.desired);
const gateOpts = {
onDelete: env.on_delete,
prunable: opts.prunable || [],
confirmDataDrops: !!opts.confirmDataDrops
};
// Saltcorn's own metadata snapshot (best-effort; reported when unavailable).
let snapshotTaken = false;
try {
await require("@saltcorn/admin-models/models/snapshot").take_if_changed(`site-sync pre-apply ${planId}`);
snapshotTaken = true;
} catch (e) {
notes.push(`no Saltcorn snapshot taken: ${e.message}`);
}
// site-sync restore point: pre-apply metadata pack + the rows this apply can
// change or destroy, so it can be one-click reverted. Skipped when we are
// ourselves executing a rollback. A failure only disables rollback (reported).
if (!opts.skipRestorePoint) {
try {
await require("./rollback").saveRestorePoint(planId, before.pack, loaded.plan, gateOpts);
} catch (e) {
notes.push(`no restore point saved (rollback unavailable for this apply): ${e.message}`);
}
}
const doStructural = opts.applyDeletes !== false;
// ---- rename pre-pass (BEFORE install_pack) ----
await lease.assertHeld();
// Data-preserving Table.rename (confirmed renames only) so install_pack then
// UPDATES the renamed table instead of creating a fresh empty one.
let renameResult = { renamed: [], failed: [] };
if (doStructural) {
renameResult = await deletes.applyRenames(loaded.plan, { confirmRenames: opts.confirmRenames });
}
// A content-hash file fetcher for the source peer, reused by both the layout-file
// resolution below and the reference-row sync later.
const fileFetcher = await buildFileFetcher(loaded, env, opts);
const fileWarnings = [];
// ---- upsert half (create + update) ----
const upsertPack = buildUpsertPack(desired);
// Resolve files referenced inside page layouts / view configs / the site logo to
// LOCAL file values BEFORE install_pack writes those layouts (otherwise the
// placeholders would be persisted verbatim). resolvePlaceholders dedups against
// the target's own layout files, and fileIndexSeed (rollback) lets pre-apply
// files (on disk but no longer referenced) resolve without a fetch.
try {
await layoutFiles.resolvePlaceholders(upsertPack, {
fileFetcher,
fileIndexSeed: opts.fileIndexSeed,
warnings: fileWarnings
});
} catch (e) {
fileWarnings.push(`layout-file resolution failed: ${e.message}`);
}
await lease.assertHeld();
await require("@saltcorn/admin-models/models/pack").install_pack(upsertPack, undefined, async () => {}, false);
await fixViewTables(loaded.plan);
await installTags(loaded.plan);
// ---- delete half (mirror pruning) ----
let deleteResult = { applied: false, deleted: 0, skipped: [] };
if (doStructural) {
await lease.assertHeld();
deleteResult = await deletes.applyDeletes(loaded.plan, Object.assign({ renamed: renameResult.renamed }, gateOpts));
}
// ---- reference-row sync (AFTER install_pack so managed schema exists) ----
let rowResult = { applied: false, perTable: {}, warnings: [] };
const manifest = loaded.plan.sourceRows;
if (manifest && Object.keys(manifest).length > 0 && opts.applyRows !== false) {
await lease.assertHeld();
rowResult = await rowsLib.applyRowSync(manifest, { fileFetcher });
rowResult.applied = true;
}
// Cluster convergence: one non-silent refresh so sibling workers pick up the
// new catalog (install_pack refreshes with noSignal=true internally).
try {
const { getState } = require("@saltcorn/data/db/state");
await getState().refresh();
} catch (e) {
notes.push(`cluster refresh failed (other workers may serve stale metadata until restart): ${e.message}`);
}
return {
applied: true,
plan_id: planId,
snapshotTaken,
summary: loaded.plan.summary,
renames: renameResult,
deletes: deleteResult,
rows: rowResult,
warnings: collectApplyWarnings({ notes, renames: renameResult, deletes: deleteResult, fileWarnings, rows: rowResult })
};
};
// Apply a persisted plan under the site lock (siteLock.LockBusyError while another
// apply or rollback runs; opts.lease = run under a lock the caller holds already).
// The plan is claimed atomically first (pending or rolled_back -> applying; also
// applied with opts.force). On success the plan is `applied` and the warnings
// first name any abandoned plan acquiring the lock recovered; on failure its prior
// status is restored and the error is rethrown with `planId` set.
const applyPlan = async (planId, opts = {}) => {
return await siteLock.withSiteLock({ operation: "apply", planId, lease: opts.lease }, async (lease) => {
const loaded = await planLib.loadPlan(planId);
if (!loaded) {
throw new Error(`unknown plan ${planId}`);
}
const startable = APPLY_FROM.concat(opts.force ? [PLAN_STATUS.APPLIED] : []);
if (!(await planLib.claimPlanStatus(planId, startable, PLAN_STATUS.APPLYING))) {
throw new Error(`plan ${planId} cannot be applied: it is ${loaded.status} (re-preview to apply the source again)`);
}
const result = await siteLock.finishClaimed(lease, planId, { prior: loaded.status, done: PLAN_STATUS.APPLIED }, () => runApply(planId, loaded, opts, lease));
result.warnings = siteLock.recoveryWarnings(lease).concat(result.warnings);
return result;
});
};
module.exports = {
APPLY_FROM,
applyPlan
};