// Stable UUIDs for Saltcorn metadata entities. // // Saltcorn core identifies entities by integer id and human name. dev-deploy // needs a stable identity that survives across environments and renames, so we // maintain a side-table _dd_entity_ids mapping (kind, current_id) -> uuid. // // First-run "backfill" assigns deterministic UUIDs (hash of namespace + kind // + canonical name) so two environments installed from the same pack converge // on the same UUIDs without a coordination step. const db = require("@saltcorn/data/db"); const Table = require("@saltcorn/data/models/table"); const Field = require("@saltcorn/data/models/field"); const View = require("@saltcorn/data/models/view"); const Page = require("@saltcorn/data/models/page"); const Trigger = require("@saltcorn/data/models/trigger"); const Role = require("@saltcorn/data/models/role"); const Library = require("@saltcorn/data/models/library"); const Tag = require("@saltcorn/data/models/tag"); const TableConstraint = require("@saltcorn/data/models/table_constraints"); const PageGroup = require("@saltcorn/data/models/page_group"); const WorkflowStep = require("@saltcorn/data/models/workflow_step"); const { deterministicUuid, randomUuid } = require("./ids"); const { ENTITY_KINDS } = require("./constants"); const assignNewUuid = async (kind, currentId, currentName, parentUuid) => { const row = { uuid: randomUuid(), kind: kind, current_name: currentName, current_id: currentId, parent_uuid: parentUuid || null, created_at: new Date().toISOString() }; await db.insert("_dd_entity_ids", row, { noid: true }); return row.uuid; }; const canonicalKey = (kind, entity, parentName) => { if (kind === ENTITY_KINDS.FIELD) { return `${parentName || "?"}.${entity.name}`; } return entity.name; }; // Stable fingerprint for a constraint, scoped to its parent table's UUID so // it's globally unique across the journal. Two instances installed from the // same base agree on this fingerprint and so derive identical UUIDs. const constraintFingerprint = (con, parentUuid) => { const cfg = con.configuration || {}; let detail; if (con.type === "Unique" && cfg.fields) { detail = [...cfg.fields].sort().join(","); } else if (con.type === "Index") { detail = cfg.field || ""; } else if (con.type === "Formula") { detail = cfg.formula || ""; } else { detail = JSON.stringify(cfg); } return `${parentUuid || "?"}|${con.type}|${detail}`; }; // Friendly display name for _dd_entity_ids.current_name. Not used for identity. const constraintDisplayName = (con) => { const cfg = con.configuration || {}; if (con.type === "Unique") return `unique(${(cfg.fields || []).join(",")})`; if (con.type === "Index") return `index(${cfg.field || ""})`; if (con.type === "Formula") return `formula`; return `${con.type}_${con.id || "new"}`; }; const ensureUuid = async (kind, currentId, currentName, parentUuid, canonical) => { const existing = await db.selectMaybeOne("_dd_entity_ids", { kind: kind, current_id: currentId }); if (existing) { return existing.uuid; } const uuid = deterministicUuid(kind, canonical || currentName); const row = { uuid: uuid, kind: kind, current_name: currentName, current_id: currentId, parent_uuid: parentUuid || null, created_at: new Date().toISOString() }; await db.insert("_dd_entity_ids", row, { noid: true }); return uuid; }; const lookupByCurrent = async (kind, currentId) => { return await db.selectMaybeOne("_dd_entity_ids", { kind: kind, current_id: currentId }); }; const lookupByUuid = async (uuid) => { return await db.selectMaybeOne("_dd_entity_ids", { uuid: uuid }); }; // Insert a row with a specific UUID (used by apply.js when ingesting an op // that created an entity on a peer — we want to preserve the peer's UUID). const adoptUuid = async (uuid, kind, currentId, currentName, parentUuid) => { const row = { uuid: uuid, kind: kind, current_name: currentName, current_id: currentId, parent_uuid: parentUuid || null, created_at: new Date().toISOString() }; await db.insert("_dd_entity_ids", row, { noid: true }); return uuid; }; const updateName = async (kind, currentId, newName) => { await db.updateWhere("_dd_entity_ids", { current_name: newName }, { kind: kind, current_id: currentId }); }; const removeEntityRow = async (kind, currentId) => { await db.deleteWhere("_dd_entity_ids", { kind: kind, current_id: currentId }); }; // Backfill helpers: each returns the count of new UUIDs inserted. const backfillTables = async () => { const tables = await Table.find({}, { cached: false }); let added = 0; for (const t of tables) { const before = await lookupByCurrent(ENTITY_KINDS.TABLE, t.id); await ensureUuid(ENTITY_KINDS.TABLE, t.id, t.name, null, t.name); if (!before) { added += 1; } } return added; }; const backfillFields = async () => { const tables = await Table.find({}, { cached: false }); let added = 0; for (const t of tables) { const tableUuidRow = await lookupByCurrent(ENTITY_KINDS.TABLE, t.id); if (!tableUuidRow) { continue; } const fields = t.getFields ? t.getFields() : (await Field.find({ table_id: t.id })); for (const f of fields) { const before = await lookupByCurrent(ENTITY_KINDS.FIELD, f.id); await ensureUuid(ENTITY_KINDS.FIELD, f.id, f.name, tableUuidRow.uuid, `${t.name}.${f.name}`); if (!before) { added += 1; } } } return added; }; const backfillViews = async () => { const views = await View.find({}); let added = 0; for (const v of views) { const before = await lookupByCurrent(ENTITY_KINDS.VIEW, v.id); let parentUuid = null; if (v.table_id) { const t = await lookupByCurrent(ENTITY_KINDS.TABLE, v.table_id); parentUuid = t ? t.uuid : null; } await ensureUuid(ENTITY_KINDS.VIEW, v.id, v.name, parentUuid, v.name); if (!before) { added += 1; } } return added; }; const backfillPages = async () => { const pages = await Page.find({}); let added = 0; for (const p of pages) { const before = await lookupByCurrent(ENTITY_KINDS.PAGE, p.id); await ensureUuid(ENTITY_KINDS.PAGE, p.id, p.name, null, p.name); if (!before) { added += 1; } } return added; }; const backfillTriggers = async () => { const triggers = Trigger.find({}); let added = 0; for (const tr of triggers) { const before = await lookupByCurrent(ENTITY_KINDS.TRIGGER, tr.id); let parentUuid = null; if (tr.table_id) { const t = await lookupByCurrent(ENTITY_KINDS.TABLE, tr.table_id); parentUuid = t ? t.uuid : null; } await ensureUuid(ENTITY_KINDS.TRIGGER, tr.id, tr.name || `trigger_${tr.id}`, parentUuid, tr.name || `trigger_${tr.id}`); if (!before) { added += 1; } } return added; }; const backfillRoles = async () => { const roles = await Role.find({}); let added = 0; for (const r of roles) { const before = await lookupByCurrent(ENTITY_KINDS.ROLE, r.id); await ensureUuid(ENTITY_KINDS.ROLE, r.id, r.role, null, r.role); if (!before) { added += 1; } } return added; }; const backfillLibrary = async () => { const items = await Library.find({}); let added = 0; for (const li of items) { const before = await lookupByCurrent(ENTITY_KINDS.LIBRARY, li.id); await ensureUuid(ENTITY_KINDS.LIBRARY, li.id, li.name, null, li.name); if (!before) { added += 1; } } return added; }; const backfillTags = async () => { const tags = await Tag.find({}); let added = 0; for (const tg of tags) { const before = await lookupByCurrent(ENTITY_KINDS.TAG, tg.id); await ensureUuid(ENTITY_KINDS.TAG, tg.id, tg.name, null, tg.name); if (!before) { added += 1; } } return added; }; const backfillTableConstraints = async () => { const cons = await TableConstraint.find({}); let added = 0; for (const c of cons) { const before = await lookupByCurrent(ENTITY_KINDS.CONSTRAINT, c.id); let parentUuid = null; if (c.table_id) { const t = await lookupByCurrent(ENTITY_KINDS.TABLE, c.table_id); parentUuid = t ? t.uuid : null; } const canonical = constraintFingerprint(c, parentUuid); await ensureUuid(ENTITY_KINDS.CONSTRAINT, c.id, constraintDisplayName(c), parentUuid, canonical); if (!before) { added += 1; } } return added; }; const backfillPageGroups = async () => { const groups = await PageGroup.find({}); let added = 0; for (const g of groups) { const before = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, g.id); await ensureUuid(ENTITY_KINDS.PAGE_GROUP, g.id, g.name, null, g.name); if (!before) { added += 1; } } return added; }; const backfillWorkflowSteps = async () => { const steps = await WorkflowStep.find({}); let added = 0; for (const s of steps) { const before = await lookupByCurrent(ENTITY_KINDS.WORKFLOW_STEP, s.id); let parentUuid = null; if (s.trigger_id) { const tr = await lookupByCurrent(ENTITY_KINDS.TRIGGER, s.trigger_id); parentUuid = tr ? tr.uuid : null; } const canonical = `${parentUuid || "?"}|${s.name || s.id}`; await ensureUuid(ENTITY_KINDS.WORKFLOW_STEP, s.id, s.name || `step_${s.id}`, parentUuid, canonical); if (!before) { added += 1; } } return added; }; const backfillAll = async () => { const counts = {}; counts.tables = await backfillTables(); counts.fields = await backfillFields(); counts.views = await backfillViews(); counts.pages = await backfillPages(); counts.triggers = await backfillTriggers(); counts.roles = await backfillRoles(); counts.library = await backfillLibrary(); counts.tags = await backfillTags(); counts.constraints = await backfillTableConstraints(); counts.page_groups = await backfillPageGroups(); counts.workflow_steps = await backfillWorkflowSteps(); return counts; }; module.exports = { backfillAll, ensureUuid, assignNewUuid, lookupByCurrent, lookupByUuid, adoptUuid, updateName, removeEntityRow, canonicalKey, constraintFingerprint, constraintDisplayName };