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

162 lines
6.6 KiB
JavaScript

// Portable row payloads: translate cross-site references so a reference row means
// the same thing on both instances.
//
// * The local primary key is dropped (reassigned per site).
// * The stable identity `_ss_row_uuid` travels as `__uuid` (the match key).
// * A foreign key into a managed/starter table is replaced with the referenced
// row's `_ss_row_uuid` under `<field>__uuid`; on apply it is resolved back to
// the local id. An FK into a USER-mode table (no cross-site identity) is nulled
// with a warning -- reference identity only exists for managed/starter tables.
// * File-typed fields travel as a CONTENT-ADDRESSED placeholder under
// `<field>__file` ({hash, filename, mime, min_role_read}); the row engine
// fetches the bytes by hash and materializes a local file.
// * Other infra columns (any other "_"-prefixed field, e.g. dev-deploy's
// _dd_row_uuid) are per-instance bookkeeping and never travel.
//
// This module is the single source of truth for the portable key format.
const File = require("@saltcorn/data/models/file");
const { ROW_UUID_FIELD, findIdByRowUuid, getRowUuidById } = require("./rowIdentity");
const { isInfraName, isSyncedMode } = require("./constants");
const { hashFileContents } = require("./common/fileStore");
// The row identity travels under PORTABLE_UUID_KEY; FK columns as
// "<field>__uuid" (same suffix); File fields as "<field>__file".
const UUID_SUFFIX = "__uuid";
const PORTABLE_UUID_KEY = UUID_SUFFIX;
const FILE_SUFFIX = "__file";
const isFkUuidKey = (key) => key !== PORTABLE_UUID_KEY && key.endsWith(UUID_SUFFIX);
const isFileKey = (key) => key.endsWith(FILE_SUFFIX);
const fileFieldOf = (key) => key.slice(0, -FILE_SUFFIX.length);
// Row -> portable. `modeOf(tableName)` yields the local data mode of a table so we
// know whether an FK target participates in cross-site identity.
const rowToPortable = async (row, table, modeOf) => {
const portable = {};
const warnings = [];
const pk = table.pk_name;
for (const field of table.getFields()) {
const name = field.name;
if (name === pk) {
continue;
}
if (name === ROW_UUID_FIELD) {
portable[PORTABLE_UUID_KEY] = row[name];
continue;
}
if (isInfraName(name)) {
continue;
}
const value = row[name];
if (field.type === "File") {
// Ship a CONTENT-ADDRESSED placeholder: the file's sha256 hash plus the
// metadata needed to recreate it. The bytes are fetched separately by the
// target from GET /api/file/:hash. Nulled (with a warning) if the file is
// missing on disk.
if (value === null || value === undefined || value === "") {
portable[`${name}${FILE_SUFFIX}`] = null;
} else {
try {
const file = await File.findOne(value);
if (file) {
portable[`${name}${FILE_SUFFIX}`] = {
hash: await hashFileContents(file),
filename: file.filename,
mime: file.mimetype,
min_role_read: file.min_role_read
};
} else {
warnings.push(`file field ${table.name}.${name} value "${value}" not found on disk; nulled`);
portable[`${name}${FILE_SUFFIX}`] = null;
}
} catch (e) {
warnings.push(`file field ${table.name}.${name}: ${e.message}`);
portable[`${name}${FILE_SUFFIX}`] = null;
}
}
continue;
}
if (field.is_fkey) {
const reftable = field.reftable_name;
if (reftable && isSyncedMode(modeOf(reftable))) {
if (value === null || value === undefined) {
portable[`${name}${UUID_SUFFIX}`] = null;
} else {
const refUuid = await getRowUuidById(reftable, value);
if (!refUuid) {
warnings.push(`FK ${table.name}.${name} -> ${reftable} could not resolve a row uuid`);
}
portable[`${name}${UUID_SUFFIX}`] = refUuid || null;
}
} else {
if (value !== null && value !== undefined) {
warnings.push(`FK ${table.name}.${name} into non-synced table ${reftable || "?"} nulled`);
}
portable[name] = null;
}
continue;
}
portable[name] = value;
}
return { portable, warnings };
};
// Portable -> row (local ids resolved). Returns { row, warnings }. The returned row
// includes _ss_row_uuid so a plain insertRow/updateRow carries the identity.
// `resolveRef(reftable, uuid)` maps a referenced row's uuid to its local pk (or
// null); the row engine passes a cached resolver, the default queries per call.
// Infra keys from a peer (other than the identity) are never written.
const portableToRow = async (portable, table, resolveRef = findIdByRowUuid) => {
const row = {};
const warnings = [];
for (const key of Object.keys(portable)) {
if (key === PORTABLE_UUID_KEY) {
row[ROW_UUID_FIELD] = portable[key];
continue;
}
if (isFileKey(key)) {
// File placeholders are materialized by the row engine (needs the peer
// transport to fetch bytes); skip here.
continue;
}
if (isFkUuidKey(key)) {
const fieldName = key.slice(0, -UUID_SUFFIX.length);
const field = table.getField(fieldName);
if (!field) {
continue;
}
const refUuid = portable[key];
if (refUuid === null || refUuid === undefined) {
row[fieldName] = null;
continue;
}
const localId = await resolveRef(field.reftable_name, refUuid);
if (localId === null) {
warnings.push(`FK ${table.name}.${fieldName} unresolved (referenced row not synced yet); set null`);
row[fieldName] = null;
} else {
row[fieldName] = localId;
}
continue;
}
if (isInfraName(key)) {
continue;
}
row[key] = portable[key];
}
return { row, warnings };
};
module.exports = {
PORTABLE_UUID_KEY,
isFileKey,
fileFieldOf,
rowToPortable,
portableToRow
};