162 lines
6.6 KiB
JavaScript
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
|
|
};
|