// 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 };