231 lines
8.5 KiB
JavaScript
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
|
|
};
|