// Row identity for reference-data sync. // // Reference/lookup rows are matched across sites by a plugin-managed stable // identity column, `_ss_row_uuid`, NEVER by the local integer primary key // (reassigned per site) and NEVER by fragile business-key guessing. When a table // is opted into managed/starter mode we add `_ss_row_uuid` as a REGISTERED Saltcorn // Field (not a raw ALTER) -- registration is what makes the column survive // Saltcorn backup/restore; a raw column would be dumped by backup but absent from // the restored field list and roll back the whole table's restore = app-data loss. // // Because it is a real Field, insertRow/updateRow carry its value directly -- no // separate raw write needed. The uuid is unique per table by construction: every // ensureManagedSchema pass re-keys duplicates (a row copied with all its fields, // e.g. by a "duplicate row" action, would otherwise collapse two source rows into // one on the target). const db = require("@saltcorn/data/db"); const Table = require("@saltcorn/data/models/table"); const Field = require("@saltcorn/data/models/field"); const { randomUuid } = require("./ids"); const { DATA_MODES, isInfraName, isLockedTable } = require("./constants"); const ROW_UUID_FIELD = "_ss_row_uuid"; // Schema-qualified, quoted PHYSICAL table reference for the few raw queries we // need. Saltcorn names the physical table sqlsanitize(name) (e.g. "Blog Posts" -> // "BlogPosts"), so the raw name must never be interpolated directly. const tableSqlRef = (name) => `${db.getTenantSchemaPrefix()}"${db.sqlsanitize(name)}"`; // Quoted physical column reference (primary-key names come from tbl.pk_name). const columnSqlRef = (name) => `"${db.sqlsanitize(name)}"`; // Name of the uuid index created before 0.0.2 (unquoted, so Postgres folded it to // lower case; SQLite index names compare case-insensitively). const legacyIndexName = (tableName) => `${tableName}_ss_row_uuid`.toLowerCase(); // Exactly one uuid lookup index per table: drop the pre-0.0.2 one, create the // current (quoted, sanitized) one. const ensureIndex = async (tableName) => { await db.query(`DROP INDEX IF EXISTS ${db.getTenantSchemaPrefix()}"${legacyIndexName(tableName)}"`); await db.query( `CREATE INDEX IF NOT EXISTS "${db.sqlsanitize(tableName)}_ss_row_uuid_idx" ON ${tableSqlRef(tableName)} (${ROW_UUID_FIELD})` ); }; // Give every row sharing a uuid with a lower-pk row a fresh uuid. const rekeyDuplicateUuids = async (tableName, pk) => { const ref = tableSqlRef(tableName); const pkRef = columnSqlRef(pk); const dups = (await db.query( `SELECT ${pkRef} AS pk, ${ROW_UUID_FIELD} AS u FROM ${ref} WHERE ${ROW_UUID_FIELD} IN ` + `(SELECT ${ROW_UUID_FIELD} FROM ${ref} GROUP BY ${ROW_UUID_FIELD} HAVING COUNT(*) > 1) ORDER BY ${pkRef}` )).rows; const seen = new Set(); for (const r of dups) { if (seen.has(r.u)) { await db.query(`UPDATE ${ref} SET ${ROW_UUID_FIELD} = $1 WHERE ${pkRef} = $2`, [randomUuid(), r.pk]); } else { seen.add(r.u); } } return dups.length - seen.size; }; // Ensure `tableName` carries a registered _ss_row_uuid String field and every row // has a value, unique within the table. Idempotent. Returns { added, backfilled, rekeyed }. const ensureManagedSchema = async (tableName) => { const tbl = Table.findOne({ name: tableName }); if (!tbl) { throw new Error(`ensureManagedSchema: no such table ${tableName}`); } let added = false; if (!tbl.getField(ROW_UUID_FIELD)) { await Field.create({ table: tbl, name: ROW_UUID_FIELD, label: ROW_UUID_FIELD, type: "String", required: false, is_unique: false, attributes: { hidden: true } }); added = true; } // Backfill any NULL uuids (existing rows created before opt-in), then make the // identity unique. const fresh = Table.findOne({ name: tableName }); const pkRef = columnSqlRef(fresh.pk_name); const nulls = (await db.query( `SELECT ${pkRef} AS pk FROM ${tableSqlRef(tableName)} WHERE ${ROW_UUID_FIELD} IS NULL` )).rows; for (const r of nulls) { await db.query( `UPDATE ${tableSqlRef(tableName)} SET ${ROW_UUID_FIELD} = $1 WHERE ${pkRef} = $2`, [randomUuid(), r.pk] ); } const rekeyed = await rekeyDuplicateUuids(tableName, fresh.pk_name); await ensureIndex(tableName); return { added, backfilled: nulls.length, rekeyed }; }; // uuid -> local primary key for every identified row of a table (empty when the // table has no identity column yet). One query instead of a lookup per row. const localUuidMap = async (tableName) => { const tbl = Table.findOne({ name: tableName }); const map = new Map(); if (!tbl || !tbl.getField(ROW_UUID_FIELD)) { return map; } const rs = await db.query( `SELECT ${columnSqlRef(tbl.pk_name)} AS pk, ${ROW_UUID_FIELD} AS u FROM ${tableSqlRef(tableName)} WHERE ${ROW_UUID_FIELD} IS NOT NULL` ); for (const r of rs.rows) { map.set(r.u, r.pk); } return map; }; // Resolve a portable row uuid to the LOCAL primary-key value, or null. const findIdByRowUuid = async (tableName, uuid) => { const tbl = Table.findOne({ name: tableName }); if (!tbl || !tbl.getField(ROW_UUID_FIELD)) { return null; } const rs = await db.query( `SELECT ${columnSqlRef(tbl.pk_name)} AS pk FROM ${tableSqlRef(tableName)} WHERE ${ROW_UUID_FIELD} = $1 LIMIT 1`, [uuid] ); return rs.rows.length > 0 ? rs.rows[0].pk : null; }; // Resolve a LOCAL primary-key value to its portable row uuid, or null. Used when // translating an FK column value to the referenced row's stable identity. const getRowUuidById = async (tableName, id) => { const tbl = Table.findOne({ name: tableName }); if (!tbl || !tbl.getField(ROW_UUID_FIELD) || id === null || id === undefined) { return null; } const rs = await db.query( `SELECT ${ROW_UUID_FIELD} AS u FROM ${tableSqlRef(tableName)} WHERE ${columnSqlRef(tbl.pk_name)} = $1 LIMIT 1`, [id] ); return rs.rows.length > 0 ? rs.rows[0].u : null; }; // A shallow copy of `row` without its primary-key column. const withoutPk = (row, pkName) => { const out = Object.assign({}, row); delete out[pkName]; return out; }; // Order `names` so a table comes after every other listed table it references by // FK (parents first). Reverse it for deletes (children first). FK cycles keep // their input order. const parentFirstOrder = (names) => { const wanted = new Set(names); const done = new Set(); const out = []; const visit = (name, path) => { if (done.has(name) || path.has(name)) { return; } path.add(name); const tbl = Table.findOne({ name }); for (const f of (tbl ? tbl.getFields() : [])) { if (f.is_fkey && wanted.has(f.reftable_name) && f.reftable_name !== name) { visit(f.reftable_name, path); } } path.delete(name); done.add(name); out.push(name); }; for (const n of names) { visit(n, new Set()); } return out; }; // Receiving-side gate: a peer-supplied row op is refused unless THIS instance has // the table in managed/starter mode and the table is not hard-locked. Never trust // the sender. `mode` is the target's local data mode for the table. const assertRowSyncAllowed = (tableName, mode) => { if (isLockedTable(tableName)) { throw new Error(`row sync refused: "${tableName}" is a locked system table`); } if (mode !== DATA_MODES.MANAGED && mode !== DATA_MODES.STARTER) { throw new Error(`row sync refused: "${tableName}" is not in managed/starter mode locally`); } }; // Guard for setting a table's mode: infra tables never get a data mode, and a // locked table may only ever be `user`. const assertManagedModeAllowed = (tableName, mode) => { if (isInfraName(tableName)) { throw new Error(`"${tableName}" is plugin/system infrastructure and has no data mode`); } if (isLockedTable(tableName) && mode !== DATA_MODES.USER) { throw new Error(`"${tableName}" is a locked system table and cannot be managed/starter`); } }; module.exports = { ROW_UUID_FIELD, ensureManagedSchema, localUuidMap, findIdByRowUuid, getRowUuidById, withoutPk, parentFirstOrder, assertRowSyncAllowed, assertManagedModeAllowed };