660 lines
26 KiB
JavaScript
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
|
|
};
|