sc-dev-deploy/lib/revert.js
2026-06-01 16:43:43 -05:00

393 lines
16 KiB
JavaScript

// Revert: append a compensating op for any local op_id.
//
// create_X → drop the entity that was created
// drop_X → recreate the entity from payload.before
// update_X → re-apply payload.before as a new patch
// set_X → restore the prior value (payload.before / before_mode)
// insert_row → delete the row; drop_row → re-insert payload.before
//
// The revert calls the same Saltcorn model methods (or wrapped setters) the
// admin UI does, so the existing CRUD wraps fire and journal the inverse op
// naturally with this instance's env_id. The inverse op then promotes to peers
// like any other op.
//
// Dispatch keys off the op's stored `entity_kind` column (reliable) for the
// kind and the op_type prefix for the action -- NOT a naive op_type split,
// which mis-parses multi-word kinds (e.g. page_group_member -> "page").
//
// Where the original op did not retain enough to rebuild the prior state
// (e.g. a dropped file's bytes, an old op journaled before before-capture was
// added), revert returns a clear { status: "unsupported", reason } rather than
// throwing a cryptic error.
//
// Caveats:
// - Reverting a drop produces a *new* entity with a new random UUID. The
// original UUID is gone for good. Content is restored; identity is fresh.
// - Reverting a field/view/trigger/constraint drop requires the parent table
// to still exist locally. parent_uuid was captured by the drop wrap; we
// resolve it to the current local id.
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const View = require("@saltcorn/data/models/view");
const Page = require("@saltcorn/data/models/page");
const Trigger = require("@saltcorn/data/models/trigger");
const Role = require("@saltcorn/data/models/role");
const Library = require("@saltcorn/data/models/library");
const Tag = require("@saltcorn/data/models/tag");
const TableConstraint = require("@saltcorn/data/models/table_constraints");
const PageGroup = require("@saltcorn/data/models/page_group");
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
const File = require("@saltcorn/data/models/file");
const db = require("@saltcorn/data/db");
const { lookupByUuid } = require("./entityIds");
const { ENTITY_KINDS, DATA_MODES } = require("./constants");
const { refreshState } = require("./state");
const { randomUuid } = require("./ids");
const { enterOp } = require("./context");
const { recordOpSafely } = require("./ops");
const { portableToRow } = require("./rowPayload");
const { ensureManagedSchema, findIdByRowUuid } = require("./rowIdentity");
const MODELS = {
[ENTITY_KINDS.TABLE]: Table,
[ENTITY_KINDS.FIELD]: Field,
[ENTITY_KINDS.VIEW]: View,
[ENTITY_KINDS.PAGE]: Page,
[ENTITY_KINDS.TRIGGER]: Trigger,
[ENTITY_KINDS.ROLE]: Role,
[ENTITY_KINDS.LIBRARY]: Library,
[ENTITY_KINDS.TAG]: Tag,
[ENTITY_KINDS.CONSTRAINT]: TableConstraint,
[ENTITY_KINDS.PAGE_GROUP]: PageGroup,
[ENTITY_KINDS.WORKFLOW_STEP]: WorkflowStep
};
const stripIds = (obj) => {
const out = { ...(obj || {}) };
for (const k of ["id", "table_id", "view_id", "page_id"]) delete out[k];
return out;
};
const unsupported = (reason) => {
return { status: "unsupported", reason: reason };
};
const findLocalInstance = async (kind, entityUuid) => {
const m = await lookupByUuid(entityUuid);
if (!m) return null;
const Cls = MODELS[kind];
if (!Cls || typeof Cls.findOne !== "function") return null;
await refreshState();
const inst = await Cls.findOne({ id: m.current_id });
return inst || null;
};
const resolveParentLocalId = async (parentUuid, kind) => {
if (!parentUuid) return null;
const parent = await lookupByUuid(parentUuid);
if (!parent) {
throw new Error(`parent uuid=${parentUuid} not present locally; cannot recreate ${kind}`);
}
return parent.current_id;
};
// --- Drop the entity (revert of create_X) ---
const revertCreate = async (kind, op) => {
const inst = await findLocalInstance(kind, op.entity_uuid);
if (!inst) {
return { status: "noop", reason: "entity no longer present" };
}
await inst.delete();
return { status: "dropped" };
};
// --- Recreate from before-snapshot (revert of drop_X) ---
const recreateFromSnapshot = async (kind, payload) => {
const before = stripIds(payload.before || {});
if (Object.keys(before).length === 0) {
return unsupported(`drop_${kind} did not retain a before-snapshot`);
}
const parentLocalId = await resolveParentLocalId(payload.parent_uuid, kind);
switch (kind) {
case ENTITY_KINDS.TABLE: {
if (!before.name) throw new Error("missing before.name");
const opts = { ...before };
const name = opts.name;
delete opts.name;
await Table.create(name, opts);
return { status: "recreated" };
}
case ENTITY_KINDS.FIELD: {
await Field.create({ ...before, table_id: parentLocalId });
return { status: "recreated" };
}
case ENTITY_KINDS.VIEW: {
const cfg = { ...before };
if (parentLocalId) cfg.table_id = parentLocalId;
await View.create(cfg);
return { status: "recreated" };
}
case ENTITY_KINDS.PAGE: {
await Page.create(before);
return { status: "recreated" };
}
case ENTITY_KINDS.TRIGGER: {
const cfg = { ...before };
if (parentLocalId) cfg.table_id = parentLocalId;
await Trigger.create(cfg);
return { status: "recreated" };
}
case ENTITY_KINDS.ROLE: {
if (!before.id || !before.role) throw new Error("missing role identity");
await Role.create({ id: before.id, role: before.role });
return { status: "recreated" };
}
case ENTITY_KINDS.LIBRARY: {
await Library.create(before);
return { status: "recreated" };
}
case ENTITY_KINDS.TAG: {
await Tag.create(before);
return { status: "recreated" };
}
case ENTITY_KINDS.CONSTRAINT: {
if (parentLocalId === null) throw new Error("constraint recreate needs parent table");
if (!before.type) return unsupported("drop_constraint snapshot missing type");
await TableConstraint.create({
table_id: parentLocalId,
type: before.type,
configuration: before.configuration || {}
});
return { status: "recreated" };
}
case ENTITY_KINDS.PAGE_GROUP: {
if (!before.name) return unsupported("drop_page_group snapshot missing name");
const cfg = { ...before };
cfg.members = [];
await PageGroup.create(cfg);
return { status: "recreated" };
}
case ENTITY_KINDS.WORKFLOW_STEP: {
const cfg = { ...before };
if (parentLocalId) cfg.trigger_id = parentLocalId;
await WorkflowStep.create(cfg);
return { status: "recreated" };
}
default:
return unsupported(`recreate not implemented for kind '${kind}'`);
}
};
// --- Re-apply payload.before as a new patch (revert of update_X) ---
const reapplyBefore = async (kind, op, payload) => {
const inst = await findLocalInstance(kind, op.entity_uuid);
if (!inst) {
throw new Error(`entity_uuid=${op.entity_uuid} (${kind}) not present locally`);
}
const patch = stripIds(payload.before || {});
if (Object.keys(patch).length === 0) {
return unsupported(`update_${kind} did not retain a before-snapshot`);
}
switch (kind) {
case ENTITY_KINDS.TABLE:
case ENTITY_KINDS.FIELD:
case ENTITY_KINDS.ROLE:
case ENTITY_KINDS.LIBRARY:
case ENTITY_KINDS.TAG:
await inst.update(patch);
return { status: "updated" };
case ENTITY_KINDS.VIEW:
await View.update(patch, inst.id);
return { status: "updated" };
case ENTITY_KINDS.PAGE:
await Page.update(inst.id, patch);
return { status: "updated" };
case ENTITY_KINDS.TRIGGER:
await Trigger.update(inst.id, patch);
return { status: "updated" };
case ENTITY_KINDS.PAGE_GROUP:
await PageGroup.update(inst.id, patch);
return { status: "updated" };
case ENTITY_KINDS.WORKFLOW_STEP:
await inst.update(patch);
return { status: "updated" };
default:
return unsupported(`reapply not implemented for kind '${kind}'`);
}
};
// --- Table rows: insert<->delete, update<->before, drop<->reinsert ---
const findLocalTableByUuid = async (tableUuid) => {
const ent = await lookupByUuid(tableUuid);
if (!ent || ent.kind !== ENTITY_KINDS.TABLE) return null;
await refreshState();
return Table.findOne({ id: ent.current_id });
};
const revertRow = async (action, op, payload) => {
const tableUuid = payload.table_uuid;
if (!tableUuid) return unsupported("row op missing table_uuid");
const tbl = await findLocalTableByUuid(tableUuid);
if (!tbl) return { status: "noop", reason: "table not present locally" };
await ensureManagedSchema(tbl.name);
const localId = await findIdByRowUuid(tbl.name, op.entity_uuid);
if (action === "insert") {
// Revert of insert_row = delete the row (wrapped deleteRows journals drop_row).
if (!localId) return { status: "noop", reason: "row already gone" };
await tbl.deleteRows({ id: localId });
return { status: "dropped", local_id: localId };
}
if (action === "drop") {
// Revert of drop_row = re-insert payload.before (fresh row uuid, per caveat).
const before = payload.before || {};
if (Object.keys(before).length === 0) return unsupported("drop_row did not retain a before-snapshot");
if (localId) return { status: "noop", reason: "row already present" };
const rowData = await portableToRow(before, tbl);
await tbl.insertRow(rowData);
return { status: "reinserted" };
}
if (action === "update") {
// Revert of update_row = re-apply payload.before (wrapped updateRow journals update_row).
const before = payload.before || {};
if (Object.keys(before).length === 0) return unsupported("update_row did not retain a before-snapshot");
if (!localId) return { status: "noop", reason: "row not present locally" };
const patch = await portableToRow(before, tbl);
await tbl.updateRow(patch, localId);
return { status: "updated", local_id: localId };
}
return unsupported(`unknown row action '${action}'`);
};
// --- set_table_mode: restore before_mode + journal the compensating op ---
const revertTableMode = async (op, payload) => {
const tableUuid = payload.table_uuid;
if (!tableUuid) return unsupported("set_table_mode missing table_uuid");
const beforeMode = payload.before_mode;
if (!beforeMode) return unsupported("set_table_mode did not retain the prior mode");
if (beforeMode === DATA_MODES.MANAGED || beforeMode === DATA_MODES.STARTER) {
const tbl = await findLocalTableByUuid(tableUuid);
if (tbl) await ensureManagedSchema(tbl.name);
}
const now = new Date().toISOString();
const existing = await db.selectMaybeOne("_dd_table_modes", { table_uuid: tableUuid });
if (existing) {
await db.updateWhere("_dd_table_modes", { data_mode: beforeMode, updated_at: now }, { table_uuid: tableUuid });
} else {
await db.insert("_dd_table_modes", { table_uuid: tableUuid, data_mode: beforeMode, updated_at: now }, { noid: true });
}
const opId = randomUuid();
await enterOp(opId, async () => {
await recordOpSafely({
op_id: opId,
op_type: "set_table_mode",
entity_kind: "table_mode",
entity_uuid: tableUuid,
payload: { table_uuid: tableUuid, data_mode: beforeMode, before_mode: payload.data_mode }
});
});
return { status: "set", mode: beforeMode };
};
// --- set_config: restore the prior value via the wrapped setter (which journals) ---
const revertConfig = async (payload) => {
if (!payload.key) return unsupported("set_config missing key");
if (!("before" in payload)) return unsupported("set_config did not retain the prior value");
const { getState } = require("@saltcorn/data/db/state");
await getState().setConfig(payload.key, payload.before);
return { status: "restored", key: payload.key };
};
// --- update_plugin_config: restore prior configuration via wrapped upsert ---
const revertPluginConfig = async (payload) => {
if (!payload.name) return unsupported("update_plugin_config missing name");
if (!("before_configuration" in payload)) {
return unsupported("update_plugin_config did not retain the prior configuration");
}
const Plugin = require("@saltcorn/data/models/plugin");
const plugin = await Plugin.findOne({ name: payload.name });
if (!plugin) return { status: "noop", reason: `plugin ${payload.name} not installed` };
plugin.configuration = payload.before_configuration || {};
await plugin.upsert();
return { status: "restored", plugin: payload.name };
};
// --- file: create<->delete; drop content cannot be recovered ---
const revertFile = async (action, op) => {
if (action === "create") {
const inst = await findLocalInstance(ENTITY_KINDS.FILE, op.entity_uuid);
if (!inst) return { status: "noop", reason: "file no longer present" };
await inst.delete();
return { status: "dropped" };
}
if (action === "drop") {
return unsupported("revert of drop_file is not supported: file content was not retained (restore the file manually)");
}
return unsupported(`unknown file action '${action}'`);
};
const ACTION_HANDLERS = {
create: revertCreate,
drop: recreateFromSnapshot,
update: reapplyBefore
};
// Look up the original op, parse its payload, dispatch to the inverse action.
// The local wrap journals the inverse as a new op with a fresh op_id (except
// the bespoke table_mode path, which records its own compensating op).
const revertOp = async (opId) => {
const orig = await db.selectMaybeOne("_dd_ops", { op_id: opId });
if (!orig) {
throw new Error(`op ${opId} not found`);
}
const opType = orig.op_type || "";
const us = opType.indexOf("_");
const action = us < 0 ? opType : opType.slice(0, us);
const kind = orig.entity_kind || (us < 0 ? "" : opType.slice(us + 1));
const payload = typeof orig.payload === "string" ? JSON.parse(orig.payload) : (orig.payload || {});
// Bespoke (non entity-model) kinds.
if (kind === "table_row") return await revertRow(action, orig, payload);
if (kind === "table_mode") return await revertTableMode(orig, payload);
if (kind === "config") return await revertConfig(payload);
if (kind === "plugin_config") return await revertPluginConfig(payload);
if (kind === ENTITY_KINDS.FILE) return await revertFile(action, orig);
// Entity-model kinds (create/update/drop via the model classes).
const handler = ACTION_HANDLERS[action];
if (!handler) {
return unsupported(`no revert for action '${action}' (op_type=${opType})`);
}
if (!MODELS[kind]) {
return unsupported(`no revert for entity kind '${kind}' (op_type=${opType})`);
}
if (action === "drop") {
return await handler(kind, payload);
}
return await handler(kind, orig, payload);
};
module.exports = {
revertOp
};