393 lines
16 KiB
JavaScript
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
|
|
};
|