sc-dev-deploy/lib/rowPayload.js
2026-06-01 16:43:43 -05:00

144 lines
5.5 KiB
JavaScript

// Row payload translation: FK ids -> row uuids (on journal) and back (on apply).
//
// For each FK field on the row's table (field.is_fkey), look up the referenced
// row's _dd_row_uuid. Store under "<fieldname>__uuid" to avoid colliding with
// the original field name. On apply, resolve back to the local row id.
//
// FKs to user-mode tables are NULLed on the target, with a warning attached
// to the payload so it surfaces in the admin UI.
const db = require("@saltcorn/data/db");
const { getRowUuid, findIdByRowUuid, COLUMN_NAME } = require("./rowIdentity");
const { lookupByCurrent } = require("./entityIds");
const UUID_SUFFIX = "__uuid";
// Returns data_mode for the table with given current_id (Saltcorn table id).
// 'user' (default), 'starter', or 'managed'.
const tableModeByCurrentId = async (tableId) => {
const ent = await lookupByCurrent("table", tableId);
if (!ent) return "user";
const row = await db.selectMaybeOne("_dd_table_modes", { table_uuid: ent.uuid });
return row ? row.data_mode : "user";
};
const tableModeByUuid = async (tableUuid) => {
if (!tableUuid) return "user";
const row = await db.selectMaybeOne("_dd_table_modes", { table_uuid: tableUuid });
return row ? row.data_mode : "user";
};
// rowData: a plain row from the table.
// table: the Saltcorn Table instance whose fields define the FK shape.
// Returns { portable: {...with __uuid keys for FKs}, warnings: [...] }.
const rowToPortable = async (rowData, table) => {
const portable = {};
const warnings = [];
for (const field of table.fields || []) {
if (field.name === COLUMN_NAME || field.name === "id") continue;
const v = rowData[field.name];
if (field.is_fkey && field.reftable_name && v !== null && v !== undefined) {
const refMode = await tableModeByCurrentId_byName(field.reftable_name);
if (refMode === "managed" || refMode === "starter") {
const refUuid = await getRowUuid(field.reftable_name, v);
if (refUuid) {
portable[field.name + UUID_SUFFIX] = refUuid;
} else {
portable[field.name + UUID_SUFFIX] = null;
warnings.push(`${field.name}: source row references ${field.reftable_name}.id=${v} but it has no _dd_row_uuid yet`);
}
} else {
// FK into a user-mode table — can't translate. Drop and warn.
portable[field.name + UUID_SUFFIX] = null;
warnings.push(`${field.name} → user-mode table ${field.reftable_name}; FK will be null on target`);
}
} else {
portable[field.name] = v;
}
}
return { portable: portable, warnings: warnings };
};
// portable: payload from a journaled row op.
// table: the Saltcorn Table instance on the local (target) side.
const portableToRow = async (portable, table) => {
const row = {};
for (const field of table.fields || []) {
if (field.name === COLUMN_NAME || field.name === "id") continue;
const uuidKey = field.name + UUID_SUFFIX;
if (uuidKey in portable) {
const refUuid = portable[uuidKey];
if (!refUuid) {
row[field.name] = null;
} else if (field.is_fkey && field.reftable_name) {
const localId = await findIdByRowUuid(field.reftable_name, refUuid);
row[field.name] = localId; // may be null if target doesn't have that row yet
} else {
row[field.name] = null;
}
} else if (field.name in portable) {
row[field.name] = portable[field.name];
}
}
return row;
};
// Helper: lookup mode by table name (via entity_ids).
const tableModeByCurrentId_byName = async (tableName) => {
const Table = require("@saltcorn/data/models/table");
const t = Table.findOne({ name: tableName });
if (!t) return "user";
return await tableModeByCurrentId(t.id);
};
// True if a starter table has already had its initial-ship completed; managed
// tables ignore this (they always journal).
const isStarterShipped = async (tableUuid) => {
const row = await db.selectMaybeOne("_dd_table_modes", { table_uuid: tableUuid });
return !!(row && row.starter_shipped_at);
};
const markStarterShipped = async (tableUuid) => {
await db.updateWhere("_dd_table_modes", { starter_shipped_at: new Date().toISOString() }, { table_uuid: tableUuid });
};
// For a given Saltcorn Table id, returns { mode, tableUuid, shouldJournal }.
// shouldJournal is the wrap-level decision: true → record the row op; false →
// pass through silently.
const journalDecision = async (tableId) => {
const { lookupByCurrent } = require("./entityIds");
const ent = await lookupByCurrent("table", tableId);
if (!ent) return { mode: "user", tableUuid: null, shouldJournal: false };
const mode = await tableModeByUuid(ent.uuid);
if (mode === "managed") {
return { mode: mode, tableUuid: ent.uuid, shouldJournal: true };
}
if (mode === "starter") {
const shipped = await isStarterShipped(ent.uuid);
return { mode: mode, tableUuid: ent.uuid, shouldJournal: !shipped };
}
return { mode: "user", tableUuid: ent.uuid, shouldJournal: false };
};
module.exports = {
rowToPortable,
portableToRow,
tableModeByCurrentId,
tableModeByUuid,
tableModeByCurrentId_byName,
isStarterShipped,
markStarterShipped,
journalDecision,
UUID_SUFFIX
};