// 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 `__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 // `__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 // "__uuid" (same suffix); File fields as "__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 };