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

660 lines
26 KiB
JavaScript

// Reference-data (row) sync: the opt-in user/starter/managed layer.
//
// user (default) -- rows belong to the target; sync never touches them.
// starter -- rows are SEEDED once into a fresh table, then the target
// owns them (no later update/delete).
// managed -- the source is canonical: rows are mirrored (insert new,
// update changed, delete rows the source no longer has),
// matched by the stable _ss_row_uuid.
//
// Modes are stored per table in _ss_table_modes. The users table (and any
// ROW_SYNC_LOCKED_TABLES) is hard-locked to `user`. Both the produce side (which
// tables a source ships) and the apply side (which tables a target accepts) gate
// on the LOCAL mode, and the apply side re-enforces the lock -- never trusting the
// sender. FK columns are carried as the referenced row's uuid and resolved back to
// a local id in a second pass so cross-table ordering does not strand FKs.
//
// Re-applying an unchanged source is a no-op: a managed row is only written when
// its resolved values differ from the local row (updateRow fires Update triggers
// and writes history on versioned tables, so blind rewrites are not harmless).
const { isEqual } = require("lodash");
const db = require("@saltcorn/data/db");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const File = require("@saltcorn/data/models/file");
const rowId = require("./rowIdentity");
const { PORTABLE_UUID_KEY, isFileKey, fileFieldOf, rowToPortable, portableToRow } = require("./rowPayload");
const { sha256Hex } = require("./common/hash");
const { DATA_MODES, ADMIN_ROLE_ID, ADMIN_USER_ID, isInfraName, isLockedTable, isSyncedMode } = require("./constants");
const layoutFiles = require("./layoutFiles");
// Content hash of the file a File-field value points at (or null if missing);
// stat-cached, so re-syncing unchanged files does not re-read their bytes.
const hashOfFieldValue = layoutFiles.localFileHash;
const { withoutPk } = rowId;
// A table a target will consider for row sync: synced mode, not infra/locked.
const isRowSyncCandidate = (name, mode) => isSyncedMode(mode) && !isLockedTable(name) && !isInfraName(name);
// Resolve ONE File placeholder to a LOCAL File-field value: keep the existing file
// when its content already matches (idempotent re-sync), else fetch the bytes by
// hash via opts.fileFetcher, verify integrity, and File.from_contents it into this
// tenant's store. On any failure the existing value is KEPT (a transient fetch
// error must not blank a working reference) and a warning is pushed.
const materializeFile = async (spec, tbl, field, opts, existingValue) => {
const keep = existingValue === undefined ? null : existingValue;
if (!spec || !spec.hash) {
return null;
}
if (existingValue && (await hashOfFieldValue(existingValue)) === spec.hash) {
return existingValue;
}
if (typeof opts.fileFetcher !== "function") {
opts.warnings.push(`no file transport available; ${tbl.name}.${field} not updated`);
return keep;
}
let bytes;
try {
bytes = await opts.fileFetcher(spec.hash);
} catch (e) {
opts.warnings.push(`fetch of ${tbl.name}.${field} (hash ${layoutFiles.shortHash(spec.hash)}) failed: ${e.message}`);
return keep;
}
if (sha256Hex(bytes) !== spec.hash) {
opts.warnings.push(`content hash mismatch for ${tbl.name}.${field}; refusing`);
return keep;
}
try {
// File ownership is not portable across two independent user tables, so a
// synced reference file is owned by the admin user by design; min_role_read
// (the access gate that IS meaningful) is carried.
const file = await File.from_contents(spec.filename || "file", spec.mime || "application/octet-stream", bytes, ADMIN_USER_ID, spec.min_role_read || ADMIN_ROLE_ID, "/");
return file.field_value;
} catch (e) {
opts.warnings.push(`could not store ${tbl.name}.${field}: ${e.message}`);
return keep;
}
};
// Resolve a portable row's "<field>__file" placeholders (all of them, or only the
// fields in `onlyFields`) to a { <field>: fieldValue } patch to merge into the row.
const materializeFiles = async (portable, tbl, opts, existingRow, onlyFields) => {
const patch = {};
for (const key of Object.keys(portable).filter(isFileKey)) {
const field = fileFieldOf(key);
if (onlyFields && !onlyFields.has(field)) {
continue;
}
patch[field] = await materializeFile(portable[key], tbl, field, opts, existingRow ? existingRow[field] : undefined);
}
return patch;
};
// Would applying portable `desired` (already FK-resolved, File placeholders
// excluded) change `existing`? Values are compared in their JSON form, which is how
// they travel (Date -> ISO string, booleans/JSON as parsed by getRows).
const toJsonValue = (v) => (v === undefined || v === null ? null : JSON.parse(JSON.stringify(v)));
const rowDiffers = (existing, desired) => {
return Object.keys(desired).some((k) => !isEqual(toJsonValue(existing[k]), toJsonValue(desired[k])));
};
// Does any "<field>__file" placeholder point at different content than `existing`?
const filesDiffer = async (portable, existing) => {
for (const key of Object.keys(portable).filter(isFileKey)) {
const spec = portable[key];
const have = existing[fileFieldOf(key)];
const want = spec && spec.hash ? spec.hash : null;
if (want !== (have ? await hashOfFieldValue(have) : null)) {
return true;
}
}
return false;
};
// FK resolution with a per-table uuid->pk cache, kept current as rows are inserted
// (so the insert fixpoint does not issue a lookup query per row per round).
const makeRefResolver = () => {
const maps = new Map();
const mapOf = async (tableName) => {
if (!maps.has(tableName)) {
maps.set(tableName, await rowId.localUuidMap(tableName));
}
return maps.get(tableName);
};
return {
mapOf,
resolve: async (tableName, uuid) => {
const pk = (await mapOf(tableName)).get(uuid);
return pk === undefined ? null : pk;
},
add: async (tableName, uuid, pk) => {
(await mapOf(tableName)).set(uuid, pk);
}
};
};
// --- source-side file serving (content-addressed) ---
// hash -> File-field value, cached per tenant with a short TTL. Content-addressing
// makes the cache safe (a given hash always maps to identical bytes). The TTL bounds
// two things: (1) an authenticated peer spamming UNKNOWN hashes can force at most one
// index rebuild per window (a miss does NOT rebuild if the cache is fresh), and (2)
// staleness after a managed file changes -- the entry is refreshed within the window.
const fileIndexCache = {};
const FILE_INDEX_TTL_MS = 5000;
const buildFileIndex = async () => {
const modes = await modeMap();
const map = new Map();
for (const [name, mode] of Object.entries(modes)) {
if (!isRowSyncCandidate(name, mode)) {
continue;
}
const tbl = Table.findOne({ name });
if (!tbl) {
continue;
}
const fileFields = tbl.getFields().filter((f) => f.type === "File");
if (fileFields.length === 0) {
continue;
}
const rowsData = await tbl.getRows({}, {});
for (const r of rowsData) {
for (const f of fileFields) {
const fv = r[f.name];
if (!fv) {
continue;
}
const h = await hashOfFieldValue(fv);
if (h) {
map.set(h, fv);
}
}
}
}
// Also index files referenced inside page layouts / view configs / the site logo.
await layoutFiles.indexLocalFiles(map);
return map;
};
const resolveFileByHash = async (hash) => {
const schema = db.getTenantSchema();
const entry = fileIndexCache[schema];
const fresh = entry && (Date.now() - entry.builtAt) < FILE_INDEX_TTL_MS;
// Rebuild only when there is no cache, or the hash is missing AND the cache is
// stale. An unknown hash against a fresh cache returns null WITHOUT a rebuild,
// so unknown-hash spam cannot force a scan on every request.
if (!entry || (!entry.map.has(hash) && !fresh)) {
fileIndexCache[schema] = { map: await buildFileIndex(), builtAt: Date.now() };
}
return fileIndexCache[schema].map.get(hash) || null;
};
// --- data-mode storage (_ss_table_modes) ---
const modeRow = async (tableName) => {
return await db.selectMaybeOne("_ss_table_modes", { table_name: tableName });
};
const dataModeOf = async (tableName) => {
const row = await modeRow(tableName);
return (row && row.data_mode) || DATA_MODES.USER;
};
// name -> stored mode row ({ table_name, data_mode, starter_shipped_at, ... }) for
// every table that has one.
const modeRecords = async () => {
const rows = await db.select("_ss_table_modes", {});
return new Map(rows.map((r) => [r.table_name, r]));
};
// name -> mode for every non-user table (used to decide FK-target eligibility).
const modeMap = async () => {
const m = {};
for (const [name, r] of await modeRecords()) {
m[name] = r.data_mode;
}
return m;
};
const setDataMode = async (tableName, mode) => {
if (!Object.values(DATA_MODES).includes(mode)) {
throw new Error(`invalid data mode: ${mode} (one of ${Object.values(DATA_MODES).join(", ")})`);
}
rowId.assertManagedModeAllowed(tableName, mode);
if (!Table.findOne({ name: tableName })) {
throw new Error(`no such table ${tableName}`);
}
const now = new Date().toISOString();
const existing = await modeRow(tableName);
if (existing) {
await db.updateWhere("_ss_table_modes", { data_mode: mode, updated_at: now }, { table_name: tableName });
} else {
await db.insert("_ss_table_modes", { table_name: tableName, data_mode: mode, updated_at: now, starter_shipped_at: null }, { noid: true });
}
if (isSyncedMode(mode)) {
await rowId.ensureManagedSchema(tableName);
}
};
const markStarterShipped = async (tableName) => {
await db.updateWhere("_ss_table_modes", { starter_shipped_at: new Date().toISOString() }, { table_name: tableName });
};
// Forget that a starter table was seeded (rollback of the apply that seeded it), so
// the next apply seeds it again.
const clearStarterShipped = async (tableName) => {
await db.updateWhere("_ss_table_modes", { starter_shipped_at: null }, { table_name: tableName });
};
// Carry a table's data mode across a Table.rename (modes are keyed by name).
const renameModeRow = async (fromName, toName) => {
if (!(await modeRow(fromName))) {
return;
}
await db.deleteWhere("_ss_table_modes", { table_name: toName });
await db.updateWhere("_ss_table_modes", { table_name: toName, updated_at: new Date().toISOString() }, { table_name: fromName });
};
// --- produce side: the row manifest a source ships ---
// The present managed/starter tables a source ships (from a modeMap), and a
// warning for each synced table that no longer exists.
const shippedTables = (modes) => {
const candidates = Object.entries(modes)
.filter(([name, mode]) => isRowSyncCandidate(name, mode))
.map(([name]) => name);
const names = candidates.filter((name) => Table.findOne({ name }));
const warnings = candidates
.filter((name) => !names.includes(name))
.map((name) => `"${name}" is ${modes[name]} but no longer exists (renamed or dropped?); its rows are not shipped -- set the data mode on the current table`);
return { names, warnings };
};
// { counts: { tableName: { mode, rows } }, warnings } for the tables
// buildRowManifest would ship -- counted, not read, and without its identity
// backfill, so it changes nothing.
const shippedRowCounts = async () => {
const modes = await modeMap();
const { names, warnings } = shippedTables(modes);
const counts = {};
for (const name of names) {
counts[name] = { mode: modes[name], rows: await Table.findOne({ name }).countRows() };
}
return { counts, warnings };
};
// { tableName: { mode, rows: [portableRow] } } for every locally managed/starter
// table. Rows are read with no user (full system read). A PRE-PASS ensures every
// synced table has _ss_row_uuid backfilled BEFORE any row is read, so translating a
// row's FK to the referenced row's uuid never sees a not-yet-backfilled target
// (which would null the FK just because of table iteration order).
const buildRowManifest = async () => {
const modes = await modeMap();
const modeOf = (t) => modes[t] || DATA_MODES.USER;
const { names: syncedNames, warnings } = shippedTables(modes);
for (const name of syncedNames) {
await rowId.ensureManagedSchema(name);
}
const manifest = {};
for (const name of syncedNames) {
const fresh = Table.findOne({ name });
const rows = await fresh.getRows({}, {});
const portableRows = [];
for (const r of rows) {
const { portable, warnings: w } = await rowToPortable(r, fresh, modeOf);
portableRows.push(portable);
warnings.push(...w);
}
manifest[name] = { mode: modes[name], rows: portableRows };
}
return { manifest, warnings };
};
// Which manifest tables this target will actually sync, and why the rest are skipped.
const eligibleTables = async (manifest) => {
const eligible = [];
const skipped = [];
const warnings = [];
for (const name of Object.keys(manifest || {})) {
if (isLockedTable(name) || isInfraName(name)) {
skipped.push({ name, reason: "locked system / infrastructure table" });
continue;
}
const mode = await dataModeOf(name);
if (!isSyncedMode(mode)) {
skipped.push({ name, reason: "not managed/starter on this target" });
continue;
}
if (!Table.findOne({ name })) {
// The mode says managed/starter but the table isn't present locally (e.g.
// it wasn't created yet, or was dropped). Skip rather than crash.
skipped.push({ name, reason: "table not present locally" });
continue;
}
eligible.push({ name, mode, rows: identifiedRows(name, manifest[name].rows || [], warnings) });
}
return { eligible, skipped, warnings };
};
// The manifest rows that carry an identity, first occurrence per uuid. A row with
// no (or a repeated) uuid could only be inserted again on every apply, so it is
// skipped with a warning.
const identifiedRows = (name, rows, warnings) => {
const seen = new Set();
const out = [];
for (const p of rows) {
const uuid = p[PORTABLE_UUID_KEY];
if (!uuid || seen.has(uuid)) {
warnings.push(`a "${name}" row without a unique identity was skipped`);
continue;
}
seen.add(uuid);
out.push(p);
}
return out;
};
// --- apply side ---
const requiredFileFields = (tbl) => new Set(tbl.getFields().filter((f) => f.type === "File" && f.required).map((f) => f.name));
// INSERT FIXPOINT: insert rows whose uuid has no local id yet. A row with an
// unresolved REQUIRED FK fails tryInsertRow this round and is retried in later
// rounds once its parent exists (parent->child ordering to arbitrary depth); the
// loop ends when a round inserts nothing, and every row still pending is reported.
// Files are normally materialized only in the converge pass (once the row exists);
// a REQUIRED File field cannot be inserted empty, so it is materialized here ONCE
// per row (cached across rounds, so retries never write another copy).
// Returns name -> Set(uuid) of the rows inserted.
const insertMissingRows = async (active, resolver, fileOpts, result) => {
const pending = new Map();
const inserted = new Map();
const lastError = new Map();
const filePatch = new Map();
for (const t of active) {
const local = await resolver.mapOf(t.name);
pending.set(t.name, t.rows.filter((p) => !local.has(p[PORTABLE_UUID_KEY])));
inserted.set(t.name, new Set());
}
let progress = true;
while (progress) {
progress = false;
for (const t of active) {
const tbl = Table.findOne({ name: t.name });
const requiredFiles = requiredFileFields(tbl);
const left = [];
for (const portable of pending.get(t.name)) {
const uuid = portable[PORTABLE_UUID_KEY];
const { row } = await portableToRow(portable, tbl, resolver.resolve);
if (requiredFiles.size > 0) {
if (!filePatch.has(uuid)) {
filePatch.set(uuid, await materializeFiles(portable, tbl, fileOpts, null, requiredFiles));
}
Object.assign(row, filePatch.get(uuid));
}
const r = await tbl.tryInsertRow(withoutPk(row, tbl.pk_name));
if (r.success === undefined) {
lastError.set(uuid, r.error);
left.push(portable);
continue;
}
await resolver.add(t.name, uuid, r.success);
inserted.get(t.name).add(uuid);
result.perTable[t.name].inserted += 1;
progress = true;
}
pending.set(t.name, left);
}
}
for (const [name, left] of pending) {
for (const p of left) {
result.warnings.push(`"${name}" row ${p[PORTABLE_UUID_KEY]} could not be inserted: ${lastError.get(p[PORTABLE_UUID_KEY])}`);
}
}
return inserted;
};
// CONVERGE pass: now that all rows exist, resolve FKs, materialize files, and write
// a row only when its resolved values differ from the local row. Managed rows
// always converge; starter rows only in the run that seeds them (afterwards the
// target owns them). One getRows per table supplies the comparison baseline.
const convergeRows = async (active, resolver, fileOpts, inserted, result) => {
for (const t of active) {
const tbl = Table.findOne({ name: t.name });
const pk = tbl.pk_name;
const local = await resolver.mapOf(t.name);
const fresh = inserted.get(t.name);
const existingByPk = new Map((await tbl.getRows({}, {})).map((r) => [r[pk], r]));
for (const portable of t.rows) {
const uuid = portable[PORTABLE_UUID_KEY];
const existing = existingByPk.get(local.get(uuid));
if (!existing || (t.mode === DATA_MODES.STARTER && !fresh.has(uuid))) {
continue;
}
const { row, warnings } = await portableToRow(portable, tbl, resolver.resolve);
result.warnings.push(...warnings);
Object.assign(row, await materializeFiles(portable, tbl, fileOpts, existing));
if (!rowDiffers(existing, row)) {
continue;
}
const err = await tbl.updateRow(withoutPk(row, pk), existing[pk]);
if (typeof err === "string") {
result.warnings.push(`"${t.name}" row ${uuid} was not updated: ${err}`);
continue;
}
if (!fresh.has(uuid)) {
result.perTable[t.name].updated += 1;
}
}
}
};
// Tables OUTSIDE `pruning` whose FK columns reference `tableName`. A managed row
// they still point at is never pruned: a RESTRICT FK would abort the apply, and a
// CASCADE / SET NULL FK would silently change user-mode data.
const protectingRefs = async (tableName, pruning) => {
const refs = [];
for (const f of await Field.find({ reftable_name: tableName })) {
const refTbl = Table.findOne({ id: f.table_id });
if (refTbl && !pruning.has(refTbl.name)) {
refs.push({ tbl: refTbl, field: f.name });
}
}
return refs;
};
const referencingTable = async (refs, pk) => {
for (const r of refs) {
if ((await r.tbl.countRows({ [r.field]: pk })) > 0) {
return r.tbl.name;
}
}
return null;
};
// PRUNE: for each target { name, keep: Set(uuid) }, delete the identified local
// rows whose uuid is not kept -- children-first, retrying failed deletes until a
// round makes no progress (resolves FK chains between the pruned tables, including
// self-references). Each failure is isolated and reported -- a prune error never
// aborts the caller. Returns name -> number of rows deleted. Shared by the managed
// row sync and rollback (removing rows an apply inserted).
const pruneRowsNotIn = async (targets, warnings) => {
const order = rowId.parentFirstOrder(targets.map((t) => t.name)).reverse();
const pruning = new Set(order);
const deleted = new Map(order.map((n) => [n, 0]));
let pending = [];
for (const name of order) {
const keep = targets.find((t) => t.name === name).keep;
const tbl = Table.findOne({ name });
const refs = await protectingRefs(name, pruning);
for (const [uuid, pk] of await rowId.localUuidMap(name)) {
if (keep.has(uuid)) {
continue;
}
const holder = await referencingTable(refs, pk);
if (holder) {
warnings.push(`"${name}" row ${uuid} is still referenced by "${holder}"; not removed`);
continue;
}
pending.push({ tbl, pk, uuid, error: null });
}
}
let progress = true;
while (pending.length > 0 && progress) {
progress = false;
const round = pending;
pending = [];
for (const p of round) {
try {
await p.tbl.deleteRows({ [p.tbl.pk_name]: p.pk });
deleted.set(p.tbl.name, deleted.get(p.tbl.name) + 1);
progress = true;
} catch (e) {
p.error = e.message;
pending.push(p);
}
}
}
for (const p of pending) {
warnings.push(`"${p.tbl.name}" row ${p.uuid} could not be removed: ${p.error}`);
}
return deleted;
};
const applyRowSync = async (manifest, opts = {}) => {
const { eligible, skipped, warnings } = await eligibleTables(manifest);
const result = { perTable: {}, skipped, warnings };
for (const t of eligible) {
// Never trust the sender: re-enforce the lock + local mode gate.
rowId.assertRowSyncAllowed(t.name, t.mode);
await rowId.ensureManagedSchema(t.name);
}
// Which tables are actively synced this run (starters already shipped are inert).
const active = [];
for (const t of eligible) {
const info = await modeRow(t.name);
if (t.mode === DATA_MODES.STARTER && info && info.starter_shipped_at) {
result.perTable[t.name] = { mode: t.mode, inserted: 0, updated: 0, deleted: 0, note: "starter already shipped" };
continue;
}
result.perTable[t.name] = { mode: t.mode, inserted: 0, updated: 0, deleted: 0 };
active.push(t);
}
const fileOpts = { fileFetcher: opts.fileFetcher, warnings: result.warnings };
const resolver = makeRefResolver();
const inserted = await insertMissingRows(active, resolver, fileOpts, result);
await convergeRows(active, resolver, fileOpts, inserted, result);
const managed = eligible.filter((t) => t.mode === DATA_MODES.MANAGED);
const pruned = await pruneRowsNotIn(managed.map((t) => ({ name: t.name, keep: new Set(t.rows.map((p) => p[PORTABLE_UUID_KEY])) })), result.warnings);
for (const [name, n] of pruned) {
result.perTable[name].deleted = n;
}
// A starter is marked shipped (never touched again) only once EVERY one of its
// rows is present; an incomplete seed is retried on the next apply.
for (const t of active.filter((x) => x.mode === DATA_MODES.STARTER)) {
const local = await resolver.mapOf(t.name);
const missing = t.rows.filter((p) => !local.has(p[PORTABLE_UUID_KEY])).length;
if (missing === 0) {
await markStarterShipped(t.name);
} else {
result.warnings.push(`starter "${t.name}": ${missing} row(s) not seeded; the seed is retried on the next apply`);
}
}
return result;
};
// Preview: per-table insert/update/delete counts, computed exactly as apply would
// decide them but WITHOUT mutating (no uuid backfill: rows that have no identity
// yet are backfilled at apply time and then pruned, so they count as deletes).
const previewRowSync = async (manifest) => {
const { eligible, skipped, warnings } = await eligibleTables(manifest);
const perTable = {};
const resolver = makeRefResolver();
for (const t of eligible) {
const info = await modeRow(t.name);
if (t.mode === DATA_MODES.STARTER && info && info.starter_shipped_at) {
perTable[t.name] = { mode: t.mode, inserts: 0, updates: 0, deletes: 0 };
continue;
}
const tbl = Table.findOne({ name: t.name });
const local = await resolver.mapOf(t.name);
const managed = t.mode === DATA_MODES.MANAGED;
const existingByPk = managed ? new Map((await tbl.getRows({}, {})).map((r) => [r[tbl.pk_name], r])) : null;
let inserts = 0;
let updates = 0;
let deletes = 0;
for (const portable of t.rows) {
const localId = local.get(portable[PORTABLE_UUID_KEY]);
if (localId === undefined) {
inserts += 1;
} else if (managed) {
const existing = existingByPk.get(localId);
const { row } = await portableToRow(portable, tbl, resolver.resolve);
if (rowDiffers(existing, row) || (await filesDiffer(portable, existing))) {
updates += 1;
}
}
}
if (managed) {
const sourceUuids = new Set(t.rows.map((p) => p[PORTABLE_UUID_KEY]));
deletes = [...local.keys()].filter((u) => !sourceUuids.has(u)).length + (existingByPk.size - local.size);
}
perTable[t.name] = { mode: t.mode, inserts, updates, deletes };
}
return { perTable, skipped, warnings };
};
module.exports = {
dataModeOf,
modeRecords,
modeMap,
setDataMode,
clearStarterShipped,
renameModeRow,
buildRowManifest,
shippedRowCounts,
applyRowSync,
previewRowSync,
pruneRowsNotIn,
rowDiffers,
resolveFileByHash
};