1012 lines
39 KiB
JavaScript
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
|
|
};
|