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

1012 lines
39 KiB
JavaScript

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