// Monkey-patches Saltcorn metadata model classes to journal every CRUD action. // // Each wrap: // 1. Pre-generates an op_id and enters an AsyncLocalStorage scope so any // child mutations triggered by the original method (e.g. cascading field // deletes from a table delete) see this op as their parent. // 2. Optionally captures pre-state via a "before" hook. // 3. Invokes the original method. // 4. On success, runs an "after" hook to compute entity uuid + payload and // appends the op to _dd_ops. // // Failures inside the after-hook are logged but do not throw -- we never want // the journal to corrupt user-facing operations. Failures inside the original // method propagate normally (no op is recorded for an aborted mutation). 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 File = require("@saltcorn/data/models/file"); const PageGroup = require("@saltcorn/data/models/page_group"); const PageGroupMember = require("@saltcorn/data/models/page_group_member"); const WorkflowStep = require("@saltcorn/data/models/workflow_step"); const Plugin = require("@saltcorn/data/models/plugin"); const db = require("@saltcorn/data/db"); const { randomUuid } = require("./ids"); const { enterOp, isSuppressed } = require("./context"); const { recordOpSafely } = require("./ops"); const { assignNewUuid, lookupByCurrent, updateName, removeEntityRow, constraintDisplayName } = require("./entityIds"); const { ENTITY_KINDS, fileLocationToId } = require("./constants"); const { sha256File, toRelativePath } = require("./files"); const { toPlaceholders } = require("./payloadRefs"); const { setRowUuid, getRowUuid, newRowUuid, COLUMN_NAME: ROW_UUID_COL } = require("./rowIdentity"); const { rowToPortable, journalDecision } = require("./rowPayload"); const snapshotInstance = (inst, keys) => { const out = {}; for (const k of keys) { if (inst[k] !== undefined) { out[k] = inst[k]; } } return out; }; // Shared before/after hooks for instance `delete()` methods. // Captures the entity's UUID, parent UUID (if any), and snapshot, then removes // the entity_ids row after the original delete completes so reused integer ids // don't collide. parent_uuid is preserved so revert can find the parent later. const standardDropHooks = (kind, keys) => ({ before: async function () { const existing = await lookupByCurrent(kind, this.id); return { uuid: existing ? existing.uuid : null, currentId: this.id, parentUuid: existing ? existing.parent_uuid : null, snapshot: snapshotInstance(this, keys) }; }, after: async function ({ before }) { if (!before || !before.uuid) return null; await removeEntityRow(kind, before.currentId); return { entityUuid: before.uuid, payload: { before: before.snapshot, parent_uuid: before.parentUuid } }; } }); const wrap = (target, method, kind, action, hooks) => { const original = target[method]; if (typeof original !== "function") { // eslint-disable-next-line no-console console.warn(`[dev-deploy] no method ${kind}.${method} to wrap`); return; } if (original.__ddWrapped) { return; } const wrapped = async function (...args) { if (isSuppressed()) { return await original.apply(this, args); } const opId = randomUuid(); const beforeCtx = hooks.before ? await safeCall(hooks.before, this, [args]) : null; return await enterOp(opId, async () => { const result = await original.apply(this, args); const details = await safeCall(hooks.after, this, [{ args: args, result: result, before: beforeCtx }]); if (details) { // Translate any local file refs (numeric ids or paths) in the // payload to portable __dd_file_ref:: placeholders so // the journal stores cross-environment-stable references. if (details.payload) { try { await toPlaceholders(details.payload); } catch (e) { // best-effort; payload still journals as-is } } await recordOpSafely({ op_id: opId, op_type: `${action}_${kind}`, entity_kind: kind, entity_uuid: details.entityUuid, payload: details.payload }); } return result; }); }; wrapped.__ddWrapped = true; wrapped.__ddOriginal = original; target[method] = wrapped; }; const safeCall = async (fn, ctx, args) => { if (!fn) { return null; } try { return await fn.apply(ctx, args); } catch (err) { // eslint-disable-next-line no-console console.error(`[dev-deploy] hook error:`, err); return null; } }; // Common keys to snapshot for each entity. Skips heavy/derived fields. const TABLE_KEYS = ["id", "name", "min_role_read", "min_role_write", "versioned", "description", "ownership_field_id", "ownership_formula", "external", "provider_name", "provider_cfg"]; const FIELD_KEYS = ["id", "name", "label", "type", "table_id", "required", "is_unique", "calculated", "stored", "expression", "reftable_name", "reftype", "refname", "primary_key", "attributes"]; const VIEW_KEYS = ["id", "name", "viewtemplate", "table_id", "configuration", "min_role", "default_render_page", "exttable_name", "description", "slug"]; const PAGE_KEYS = ["id", "name", "title", "description", "min_role", "layout", "fixed_states", "menu_label"]; const TRIGGER_KEYS = ["id", "name", "action", "when_trigger", "table_id", "configuration", "min_role", "description", "channel"]; const ROLE_KEYS = ["id", "role"]; const LIBRARY_KEYS = ["id", "name", "icon", "layout"]; const TAG_KEYS = ["id", "name"]; const CONSTRAINT_KEYS = ["id", "type", "table_id", "configuration"]; const PAGE_GROUP_KEYS = ["id", "name", "description", "min_role", "random_allocation"]; const WORKFLOW_STEP_KEYS = ["id", "name", "trigger_id", "action_name", "next_step", "initial_step", "configuration", "only_if"]; // ----- Table ----- const wrapTable = () => { wrap(Table, "create", ENTITY_KINDS.TABLE, "create", { after: async ({ result }) => { if (!result || !result.id) return null; const uuid = await assignNewUuid(ENTITY_KINDS.TABLE, result.id, result.name, null); return { entityUuid: uuid, payload: { after: snapshotInstance(result, TABLE_KEYS) } }; } }); wrap(Table.prototype, "update", ENTITY_KINDS.TABLE, "update", { before: async function (args) { const existing = await lookupByCurrent(ENTITY_KINDS.TABLE, this.id); return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.name, snapshot: snapshotInstance(this, TABLE_KEYS) }; }, after: async function ({ args, before }) { if (!before || !before.uuid) return null; const patch = args[0] || {}; const newName = patch.name !== undefined ? patch.name : before.oldName; if (newName !== before.oldName) { await updateName(ENTITY_KINDS.TABLE, this.id, newName); } return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch, after: snapshotInstance(this, TABLE_KEYS) } }; } }); wrap(Table.prototype, "delete", ENTITY_KINDS.TABLE, "drop", { before: async function (args) { const existing = await lookupByCurrent(ENTITY_KINDS.TABLE, this.id); return { uuid: existing ? existing.uuid : null, currentId: this.id, snapshot: snapshotInstance(this, TABLE_KEYS), onlyForget: !!args[0] }; }, after: async function ({ before }) { if (!before || !before.uuid) return null; await removeEntityRow(ENTITY_KINDS.TABLE, before.currentId); return { entityUuid: before.uuid, payload: { before: before.snapshot, only_forget: before.onlyForget } }; } }); }; // ----- Field ----- const wrapField = () => { wrap(Field, "create", ENTITY_KINDS.FIELD, "create", { after: async ({ args, result }) => { if (!result || !result.id) return null; let parentUuid = null; if (result.table_id) { const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id); parentUuid = t ? t.uuid : null; } const uuid = await assignNewUuid(ENTITY_KINDS.FIELD, result.id, result.name, parentUuid); return { entityUuid: uuid, payload: { after: snapshotInstance(result, FIELD_KEYS), parent_uuid: parentUuid } }; } }); wrap(Field.prototype, "update", ENTITY_KINDS.FIELD, "update", { before: async function (args) { const existing = await lookupByCurrent(ENTITY_KINDS.FIELD, this.id); return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.name, snapshot: snapshotInstance(this, FIELD_KEYS) }; }, after: async function ({ args, before }) { if (!before || !before.uuid) return null; const patch = args[0] || {}; const newName = patch.name !== undefined ? patch.name : before.oldName; if (newName !== before.oldName) { await updateName(ENTITY_KINDS.FIELD, this.id, newName); } return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } }; } }); wrap(Field.prototype, "delete", ENTITY_KINDS.FIELD, "drop", standardDropHooks(ENTITY_KINDS.FIELD, FIELD_KEYS)); }; // ----- View ----- const wrapView = () => { wrap(View, "create", ENTITY_KINDS.VIEW, "create", { after: async ({ result }) => { if (!result || !result.id) return null; let parentUuid = null; if (result.table_id) { const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id); parentUuid = t ? t.uuid : null; } const uuid = await assignNewUuid(ENTITY_KINDS.VIEW, result.id, result.name, parentUuid); return { entityUuid: uuid, payload: { after: snapshotInstance(result, VIEW_KEYS), parent_uuid: parentUuid } }; } }); // View.update is static (v, id) wrap(View, "update", ENTITY_KINDS.VIEW, "update", { before: async ({ args }) => { const patch = args[0] || {}; const id = args[1]; const existing = await lookupByCurrent(ENTITY_KINDS.VIEW, id); return { id: id, uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : null, patch: patch }; }, after: async ({ before }) => { if (!before || !before.uuid) return null; const newName = before.patch.name !== undefined ? before.patch.name : before.oldName; if (newName !== before.oldName) { await updateName(ENTITY_KINDS.VIEW, before.id, newName); } return { entityUuid: before.uuid, payload: { patch: before.patch } }; } }); wrap(View.prototype, "delete", ENTITY_KINDS.VIEW, "drop", standardDropHooks(ENTITY_KINDS.VIEW, VIEW_KEYS)); }; // ----- Page ----- const wrapPage = () => { wrap(Page, "create", ENTITY_KINDS.PAGE, "create", { after: async ({ result }) => { if (!result || !result.id) return null; const uuid = await assignNewUuid(ENTITY_KINDS.PAGE, result.id, result.name, null); return { entityUuid: uuid, payload: { after: snapshotInstance(result, PAGE_KEYS) } }; } }); // Page.update is static (id, row) wrap(Page, "update", ENTITY_KINDS.PAGE, "update", { before: async ({ args }) => { const id = args[0]; const row = args[1] || {}; const existing = await lookupByCurrent(ENTITY_KINDS.PAGE, id); return { id: id, uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : null, patch: row }; }, after: async ({ before }) => { if (!before || !before.uuid) return null; const newName = before.patch.name !== undefined ? before.patch.name : before.oldName; if (newName !== before.oldName) { await updateName(ENTITY_KINDS.PAGE, before.id, newName); } return { entityUuid: before.uuid, payload: { patch: before.patch } }; } }); wrap(Page.prototype, "delete", ENTITY_KINDS.PAGE, "drop", standardDropHooks(ENTITY_KINDS.PAGE, PAGE_KEYS)); }; // ----- Trigger ----- const wrapTrigger = () => { wrap(Trigger, "create", ENTITY_KINDS.TRIGGER, "create", { after: async ({ result }) => { if (!result || !result.id) return null; let parentUuid = null; if (result.table_id) { const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id); parentUuid = t ? t.uuid : null; } const name = result.name || `trigger_${result.id}`; const uuid = await assignNewUuid(ENTITY_KINDS.TRIGGER, result.id, name, parentUuid); return { entityUuid: uuid, payload: { after: snapshotInstance(result, TRIGGER_KEYS), parent_uuid: parentUuid } }; } }); // Trigger.update is static (id, row) wrap(Trigger, "update", ENTITY_KINDS.TRIGGER, "update", { before: async ({ args }) => { const id = args[0]; const row = args[1] || {}; const existing = await lookupByCurrent(ENTITY_KINDS.TRIGGER, id); return { id: id, uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : null, patch: row }; }, after: async ({ before }) => { if (!before || !before.uuid) return null; const newName = before.patch.name !== undefined ? before.patch.name : before.oldName; if (newName && newName !== before.oldName) { await updateName(ENTITY_KINDS.TRIGGER, before.id, newName); } return { entityUuid: before.uuid, payload: { patch: before.patch } }; } }); wrap(Trigger.prototype, "delete", ENTITY_KINDS.TRIGGER, "drop", standardDropHooks(ENTITY_KINDS.TRIGGER, TRIGGER_KEYS)); }; // ----- Role ----- const wrapRole = () => { wrap(Role, "create", ENTITY_KINDS.ROLE, "create", { after: async ({ args, result }) => { // Role.create returns inserted row; id is in result. Some return shapes vary. const id = result && result.id ? result.id : (args[0] && args[0].id); const role = result && result.role ? result.role : (args[0] && args[0].role); if (!id || !role) return null; const uuid = await assignNewUuid(ENTITY_KINDS.ROLE, id, role, null); return { entityUuid: uuid, payload: { after: { id: id, role: role } } }; } }); wrap(Role.prototype, "update", ENTITY_KINDS.ROLE, "update", { before: async function () { const existing = await lookupByCurrent(ENTITY_KINDS.ROLE, this.id); return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.role, snapshot: snapshotInstance(this, ROLE_KEYS) }; }, after: async function ({ args, before }) { if (!before || !before.uuid) return null; const patch = args[0] || {}; const newName = patch.role !== undefined ? patch.role : before.oldName; if (newName !== before.oldName) { await updateName(ENTITY_KINDS.ROLE, this.id, newName); } return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } }; } }); wrap(Role.prototype, "delete", ENTITY_KINDS.ROLE, "drop", standardDropHooks(ENTITY_KINDS.ROLE, ROLE_KEYS)); }; // ----- Library ----- const wrapLibrary = () => { wrap(Library, "create", ENTITY_KINDS.LIBRARY, "create", { after: async ({ args, result }) => { // Library.create returns void; we look up by name from args const cfg = args[0] || {}; if (!cfg.name) return null; const Library = require("@saltcorn/data/models/library"); const lib = await Library.findOne({ name: cfg.name }); if (!lib) return null; const uuid = await assignNewUuid(ENTITY_KINDS.LIBRARY, lib.id, lib.name, null); return { entityUuid: uuid, payload: { after: snapshotInstance(lib, LIBRARY_KEYS) } }; } }); wrap(Library.prototype, "update", ENTITY_KINDS.LIBRARY, "update", { before: async function () { const existing = await lookupByCurrent(ENTITY_KINDS.LIBRARY, this.id); return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.name, snapshot: snapshotInstance(this, LIBRARY_KEYS) }; }, after: async function ({ args, before }) { if (!before || !before.uuid) return null; const patch = args[0] || {}; const newName = patch.name !== undefined ? patch.name : before.oldName; if (newName !== before.oldName) { await updateName(ENTITY_KINDS.LIBRARY, this.id, newName); } return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } }; } }); wrap(Library.prototype, "delete", ENTITY_KINDS.LIBRARY, "drop", standardDropHooks(ENTITY_KINDS.LIBRARY, LIBRARY_KEYS)); }; // ----- Tag ----- const wrapTag = () => { wrap(Tag, "create", ENTITY_KINDS.TAG, "create", { after: async ({ result }) => { if (!result || !result.id) return null; const uuid = await assignNewUuid(ENTITY_KINDS.TAG, result.id, result.name, null); return { entityUuid: uuid, payload: { after: snapshotInstance(result, TAG_KEYS) } }; } }); wrap(Tag.prototype, "update", ENTITY_KINDS.TAG, "update", { before: async function () { const existing = await lookupByCurrent(ENTITY_KINDS.TAG, this.id); return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.name, snapshot: snapshotInstance(this, TAG_KEYS) }; }, after: async function ({ args, before }) { if (!before || !before.uuid) return null; const patch = args[0] || {}; const newName = patch.name !== undefined ? patch.name : before.oldName; if (newName !== before.oldName) { await updateName(ENTITY_KINDS.TAG, this.id, newName); } return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } }; } }); wrap(Tag.prototype, "delete", ENTITY_KINDS.TAG, "drop", standardDropHooks(ENTITY_KINDS.TAG, TAG_KEYS)); }; // ----- File ----- const FILE_KEYS = ["filename", "location", "mime_super", "mime_sub", "size_kb", "min_role_read"]; const wrapFile = () => { wrap(File, "create", ENTITY_KINDS.FILE, "create", { after: async ({ result }) => { if (!result || !result.location) return null; const relPath = toRelativePath(File, result.location); const synthId = fileLocationToId(relPath); let contentHash = null; try { contentHash = await sha256File(result.location); } catch (e) { // best-effort; some create paths may set up metadata only } const uuid = await assignNewUuid(ENTITY_KINDS.FILE, synthId, relPath, null); return { entityUuid: uuid, payload: { after: { filename: result.filename, relative_path: relPath, mime_super: result.mime_super, mime_sub: result.mime_sub, size_kb: result.size_kb, min_role_read: result.min_role_read, content_hash: contentHash } } }; } }); wrap(File.prototype, "delete", ENTITY_KINDS.FILE, "drop", { before: async function () { const relPath = toRelativePath(File, this.location); const synthId = fileLocationToId(relPath); const existing = await lookupByCurrent(ENTITY_KINDS.FILE, synthId); return { uuid: existing ? existing.uuid : null, currentId: synthId, relPath: relPath, parentUuid: null, snapshot: snapshotInstance(this, FILE_KEYS) }; }, after: async function ({ before }) { if (!before || !before.uuid) return null; await removeEntityRow(ENTITY_KINDS.FILE, before.currentId); return { entityUuid: before.uuid, payload: { before: { ...before.snapshot, relative_path: before.relPath } } }; } }); }; // ----- TableConstraint ----- const wrapTableConstraint = () => { wrap(TableConstraint, "create", ENTITY_KINDS.CONSTRAINT, "create", { after: async ({ result }) => { if (!result || !result.id) return null; // Guard against double-firing (e.g. the wrap may be installed in // multiple module-resolution contexts when sc-exec uses createRequire). const already = await lookupByCurrent(ENTITY_KINDS.CONSTRAINT, result.id); if (already) return null; let parentUuid = null; if (result.table_id) { const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id); parentUuid = t ? t.uuid : null; } const name = constraintDisplayName(result); const uuid = await assignNewUuid(ENTITY_KINDS.CONSTRAINT, result.id, name, parentUuid); return { entityUuid: uuid, payload: { after: snapshotInstance(result, CONSTRAINT_KEYS), parent_uuid: parentUuid } }; } }); wrap(TableConstraint.prototype, "delete", ENTITY_KINDS.CONSTRAINT, "drop", standardDropHooks(ENTITY_KINDS.CONSTRAINT, CONSTRAINT_KEYS)); }; // ----- Table row CRUD (managed/starter data_mode) ----- // // Wraps Table.prototype.insertRow / updateRow / deleteRows. Each checks the // table's data_mode (looked up via _dd_table_modes). 'user' tables pass // through silently. 'managed' tables always journal. 'starter' tables journal // only during their initial ship (when starter_shipped_at is NULL); subsequent // row changes pass through. const safeJournal = async (opType, entityUuid, payload) => { try { const opId = randomUuid(); await enterOp(opId, async () => { await recordOpSafely({ op_id: opId, op_type: opType, entity_kind: "table_row", entity_uuid: entityUuid, payload: payload }); }); } catch (e) { // eslint-disable-next-line no-console console.error(`[dev-deploy] ${opType} journal failed:`, e); } }; const wrapTableRows = () => { // --- insertRow --- const origInsert = Table.prototype.insertRow; if (origInsert && !origInsert.__ddWrapped) { const wrapped = async function (...args) { if (isSuppressed()) { return await origInsert.apply(this, args); } const decision = await journalDecision(this.id); if (!decision.shouldJournal) { return await origInsert.apply(this, args); } const v_in = args[0] || {}; const newId = await origInsert.apply(this, args); if (!newId) return newId; const rowUuid = newRowUuid(); await setRowUuid(this.name, newId, rowUuid); const { portable, warnings } = await rowToPortable({ ...v_in, id: newId }, this); await safeJournal("insert_row", rowUuid, { table_uuid: decision.tableUuid, after: portable, warnings: warnings.length > 0 ? warnings : undefined }); return newId; }; wrapped.__ddWrapped = true; wrapped.__ddOriginal = origInsert; Table.prototype.insertRow = wrapped; } // --- updateRow --- const origUpdate = Table.prototype.updateRow; if (origUpdate && !origUpdate.__ddWrapped) { const wrapped = async function (v_in, id_in, ...rest) { if (isSuppressed()) { return await origUpdate.apply(this, [v_in, id_in, ...rest]); } const decision = await journalDecision(this.id); if (!decision.shouldJournal) { return await origUpdate.apply(this, [v_in, id_in, ...rest]); } const id = typeof id_in === "object" && id_in !== null ? id_in.id : id_in; const rowUuid = await getRowUuid(this.name, id); // Capture the row's prior values (portable) BEFORE the update so the // op is revertible (revert re-applies this as the inverse patch). let beforePortable = null; if (rowUuid) { const priorRows = await db.select(this.name, { id: id }); if (priorRows && priorRows[0]) { beforePortable = (await rowToPortable(priorRows[0], this)).portable; } } const result = await origUpdate.apply(this, [v_in, id_in, ...rest]); if (rowUuid) { const { portable } = await rowToPortable({ ...v_in, id: id }, this); await safeJournal("update_row", rowUuid, { table_uuid: decision.tableUuid, patch: portable, before: beforePortable || undefined }); } return result; }; wrapped.__ddWrapped = true; wrapped.__ddOriginal = origUpdate; Table.prototype.updateRow = wrapped; } // --- deleteRows --- const origDelete = Table.prototype.deleteRows; if (origDelete && !origDelete.__ddWrapped) { const wrapped = async function (where, ...rest) { if (isSuppressed()) { return await origDelete.apply(this, [where, ...rest]); } const decision = await journalDecision(this.id); if (!decision.shouldJournal) { return await origDelete.apply(this, [where, ...rest]); } // Capture rows BEFORE deletion so we can journal their uuids. const rows = await db.select(this.name, where || {}); const result = await origDelete.apply(this, [where, ...rest]); for (const row of rows) { const rowUuid = row[ROW_UUID_COL]; if (!rowUuid) continue; const { portable } = await rowToPortable(row, this); await safeJournal("drop_row", rowUuid, { table_uuid: decision.tableUuid, before: portable }); } return result; }; wrapped.__ddWrapped = true; wrapped.__ddOriginal = origDelete; Table.prototype.deleteRows = wrapped; } }; // ----- Plugin configuration ----- // Wrap Plugin.prototype.upsert to journal configuration changes. Skip the // dev-deploy plugin itself (no self-reference). Skip cases where the upsert // doesn't change the configuration — Saltcorn calls upsert on every plugin // load, which would otherwise flood the journal. const wrapPlugin = () => { wrap(Plugin.prototype, "upsert", "plugin_config", "update", { before: async function () { if (!this.name || this.name === "dev-deploy") return null; let priorConfig = null; if (this.id) { const existing = await db.selectMaybeOne("_sc_plugins", { id: this.id }); if (existing) { try { priorConfig = typeof existing.configuration === "string" ? JSON.parse(existing.configuration) : (existing.configuration || null); } catch (e) { priorConfig = null; } } } return { name: this.name, priorConfig: priorConfig }; }, after: async function ({ before }) { if (!before) return null; const newConfig = this.configuration || {}; if (JSON.stringify(before.priorConfig || {}) === JSON.stringify(newConfig)) { return null; } return { entityUuid: null, payload: { name: before.name, configuration: newConfig, before_configuration: before.priorConfig || {} } }; } }); }; // ----- Config keys (menu, etc.) ----- // Config keys we journal when state.setConfig is called. The op carries the // full key+value pair; apply replays it on the target via setConfig. // menu_items references pages/views by NAME (not id), which is naturally // stable across instances, so no UUID translation is needed. const TRACKED_CONFIG_KEYS = new Set([ "menu_items", "site_name", "site_logo_id", "base_url" ]); const wrapSetConfig = () => { const { getState } = require("@saltcorn/data/db/state"); const state = getState(); if (!state || typeof state.setConfig !== "function") return; if (state.setConfig.__ddWrapped) return; const original = state.setConfig.bind(state); const wrapped = async function (key, value) { if (isSuppressed()) { return await original(key, value); } // Capture the prior value BEFORE setting so the op is revertible. const beforeValue = (typeof state.getConfig === "function") ? state.getConfig(key) : undefined; const result = await original(key, value); if (!TRACKED_CONFIG_KEYS.has(key)) { return result; } const opId = randomUuid(); await enterOp(opId, async () => { await recordOpSafely({ op_id: opId, op_type: "set_config", entity_kind: "config", entity_uuid: null, // config keys don't have UUIDs payload: { key: key, value: value, before: beforeValue } }); }); return result; }; wrapped.__ddWrapped = true; wrapped.__ddOriginal = original; state.setConfig = wrapped; }; // ----- PageGroup ----- // Members of a PageGroup reference page_id (integer). For cross-env identity // we translate page_id <-> page_uuid in payloads. const translateMembersToPortable = async (members) => { const out = []; for (const m of members || []) { const pageEnt = m.page_id ? await lookupByCurrent(ENTITY_KINDS.PAGE, m.page_id) : null; out.push({ page_uuid: pageEnt ? pageEnt.uuid : null, eligible_formula: m.eligible_formula, description: m.description, sequence: m.sequence }); } return out; }; const wrapPageGroup = () => { wrap(PageGroup, "create", ENTITY_KINDS.PAGE_GROUP, "create", { after: async ({ result }) => { if (!result || !result.id) return null; const already = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, result.id); if (already) return null; const uuid = await assignNewUuid(ENTITY_KINDS.PAGE_GROUP, result.id, result.name, null); const members = await translateMembersToPortable(result.members || []); return { entityUuid: uuid, payload: { after: snapshotInstance(result, PAGE_GROUP_KEYS), members: members } }; } }); wrap(PageGroup, "update", ENTITY_KINDS.PAGE_GROUP, "update", { before: async ({ args }) => { const id = args[0]; const row = args[1] || {}; const existing = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, id); return { id: id, uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : null, patch: row }; }, after: async ({ before }) => { if (!before || !before.uuid) return null; const newName = before.patch.name !== undefined ? before.patch.name : before.oldName; if (newName && newName !== before.oldName) { await updateName(ENTITY_KINDS.PAGE_GROUP, before.id, newName); } return { entityUuid: before.uuid, payload: { patch: before.patch } }; } }); wrap(PageGroup.prototype, "delete", ENTITY_KINDS.PAGE_GROUP, "drop", standardDropHooks(ENTITY_KINDS.PAGE_GROUP, PAGE_GROUP_KEYS)); }; // ----- PageGroupMember ----- const wrapPageGroupMember = () => { wrap(PageGroupMember, "create", ENTITY_KINDS.PAGE_GROUP_MEMBER, "create", { after: async ({ result }) => { if (!result || !result.id) return null; const already = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP_MEMBER, result.id); if (already) return null; let groupUuid = null; if (result.page_group_id) { const g = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, result.page_group_id); groupUuid = g ? g.uuid : null; } let pageUuid = null; if (result.page_id) { const p = await lookupByCurrent(ENTITY_KINDS.PAGE, result.page_id); pageUuid = p ? p.uuid : null; } const uuid = await assignNewUuid(ENTITY_KINDS.PAGE_GROUP_MEMBER, result.id, `member_${result.id}`, groupUuid); return { entityUuid: uuid, payload: { after: { page_uuid: pageUuid, eligible_formula: result.eligible_formula, description: result.description, sequence: result.sequence }, parent_uuid: groupUuid } }; } }); wrap(PageGroupMember, "delete", ENTITY_KINDS.PAGE_GROUP_MEMBER, "drop", { before: async ({ args }) => { const id = args[0]; const existing = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP_MEMBER, id); return { uuid: existing ? existing.uuid : null, currentId: id, parentUuid: existing ? existing.parent_uuid : null }; }, after: async ({ before }) => { if (!before || !before.uuid) return null; await removeEntityRow(ENTITY_KINDS.PAGE_GROUP_MEMBER, before.currentId); return { entityUuid: before.uuid, payload: { before: { id: before.currentId }, parent_uuid: before.parentUuid } }; } }); }; // ----- WorkflowStep ----- const wrapWorkflowStep = () => { wrap(WorkflowStep, "create", ENTITY_KINDS.WORKFLOW_STEP, "create", { after: async ({ args, result }) => { // WorkflowStep.create returns the inserted integer id (db.insert), // not an instance. Use the args + id to reconstruct the snapshot. const id = typeof result === "number" ? result : (result && result.id); if (!id) return null; const already = await lookupByCurrent(ENTITY_KINDS.WORKFLOW_STEP, id); if (already) return null; const step = await WorkflowStep.findOne({ id: id }); if (!step) return null; let parentUuid = null; if (step.trigger_id) { const tr = await lookupByCurrent(ENTITY_KINDS.TRIGGER, step.trigger_id); parentUuid = tr ? tr.uuid : null; } const name = step.name || `step_${id}`; const uuid = await assignNewUuid(ENTITY_KINDS.WORKFLOW_STEP, id, name, parentUuid); return { entityUuid: uuid, payload: { after: snapshotInstance(step, WORKFLOW_STEP_KEYS), parent_uuid: parentUuid } }; } }); wrap(WorkflowStep.prototype, "update", ENTITY_KINDS.WORKFLOW_STEP, "update", { before: async function () { const existing = await lookupByCurrent(ENTITY_KINDS.WORKFLOW_STEP, this.id); return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.name, snapshot: snapshotInstance(this, WORKFLOW_STEP_KEYS) }; }, after: async function ({ args, before }) { if (!before || !before.uuid) return null; const patch = args[0] || {}; const newName = patch.name !== undefined ? patch.name : before.oldName; if (newName && newName !== before.oldName) { await updateName(ENTITY_KINDS.WORKFLOW_STEP, this.id, newName); } return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } }; } }); wrap(WorkflowStep.prototype, "delete", ENTITY_KINDS.WORKFLOW_STEP, "drop", standardDropHooks(ENTITY_KINDS.WORKFLOW_STEP, WORKFLOW_STEP_KEYS)); }; const installAllWraps = () => { wrapTable(); wrapField(); wrapView(); wrapPage(); wrapTrigger(); wrapRole(); wrapLibrary(); wrapTag(); wrapTableConstraint(); wrapFile(); wrapPageGroup(); wrapPageGroupMember(); wrapWorkflowStep(); wrapSetConfig(); wrapPlugin(); wrapTableRows(); }; module.exports = { installAllWraps };