sc-site-sync/lib/rowIdentity.js
2026-10-19 01:25:16 -05:00

231 lines
8.5 KiB
JavaScript

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