@dboio/cli 0.9.8 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +172 -70
  2. package/bin/dbo.js +2 -0
  3. package/bin/postinstall.js +9 -1
  4. package/package.json +3 -3
  5. package/plugins/claude/dbo/commands/dbo.md +3 -3
  6. package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
  7. package/src/commands/add.js +50 -0
  8. package/src/commands/clone.js +720 -552
  9. package/src/commands/content.js +7 -3
  10. package/src/commands/deploy.js +22 -7
  11. package/src/commands/diff.js +41 -3
  12. package/src/commands/init.js +42 -79
  13. package/src/commands/input.js +5 -0
  14. package/src/commands/login.js +2 -2
  15. package/src/commands/mv.js +3 -0
  16. package/src/commands/output.js +8 -10
  17. package/src/commands/pull.js +268 -87
  18. package/src/commands/push.js +814 -94
  19. package/src/commands/rm.js +4 -1
  20. package/src/commands/status.js +12 -1
  21. package/src/commands/sync.js +71 -0
  22. package/src/lib/client.js +10 -0
  23. package/src/lib/config.js +80 -8
  24. package/src/lib/delta.js +178 -25
  25. package/src/lib/diff.js +150 -20
  26. package/src/lib/folder-icon.js +120 -0
  27. package/src/lib/ignore.js +2 -3
  28. package/src/lib/input-parser.js +37 -10
  29. package/src/lib/metadata-templates.js +21 -4
  30. package/src/lib/migrations.js +75 -0
  31. package/src/lib/save-to-disk.js +1 -1
  32. package/src/lib/scaffold.js +58 -3
  33. package/src/lib/structure.js +158 -21
  34. package/src/lib/toe-stepping.js +381 -0
  35. package/src/migrations/001-transaction-key-preset-scope.js +35 -0
  36. package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
  37. package/src/migrations/003-move-deploy-config.js +50 -0
  38. package/src/migrations/004-rename-output-files.js +101 -0
@@ -1,21 +1,24 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, stat, writeFile, rename as fsRename, mkdir } from 'fs/promises';
2
+ import { readFile, readdir, stat, writeFile, rename as fsRename, mkdir, access } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
5
  import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../lib/input-parser.js';
6
6
  import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
9
- import { loadConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
9
+ import { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
10
10
  import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
11
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
12
12
  import { resolveTransactionKey } from '../lib/transaction-key.js';
13
13
  import { setFileTimestamps } from '../lib/timestamps.js';
14
- import { stripUidFromFilename, renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
15
- import { findMetadataFiles } from '../lib/diff.js';
14
+ import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, buildUidFilename } from '../lib/filenames.js';
15
+ import { findMetadataFiles, findFileInProject, findByUID } from '../lib/diff.js';
16
16
  import { loadIgnore } from '../lib/ignore.js';
17
- import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
18
- import { BINS_DIR, ENTITY_DIR_NAMES } from '../lib/structure.js';
17
+ import { detectChangedColumns, findBaselineEntry, detectOutputChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
18
+ import { BINS_DIR, ENTITY_DIR_NAMES, loadStructureFile, findBinByPath } from '../lib/structure.js';
19
+ import { ensureTrashIcon } from '../lib/folder-icon.js';
20
+ import { checkToeStepping } from '../lib/toe-stepping.js';
21
+ import { runPendingMigrations } from '../lib/migrations.js';
19
22
 
20
23
  /**
21
24
  * Resolve an @reference file path to an absolute filesystem path.
@@ -28,6 +31,15 @@ function resolveAtReference(refFile, metaDir) {
28
31
  }
29
32
  return join(metaDir, refFile);
30
33
  }
34
+ /**
35
+ * Resolve whether toe-stepping is enabled.
36
+ * --toe-stepping false (or '0', 'no') disables the server conflict check.
37
+ */
38
+ function isToeStepping(options) {
39
+ const v = String(options.toeStepping ?? 'true').toLowerCase();
40
+ return v !== 'false' && v !== '0' && v !== 'no';
41
+ }
42
+
31
43
  import { ENTITY_DEPENDENCIES } from '../lib/dependencies.js';
32
44
 
33
45
  export const pushCommand = new Command('push')
@@ -39,13 +51,16 @@ export const pushCommand = new Command('push')
39
51
  .option('--meta-only', 'Only push metadata changes, skip file content')
40
52
  .option('--content-only', 'Only push file content, skip metadata columns')
41
53
  .option('-y, --yes', 'Auto-accept all prompts (path refactoring, etc.)')
54
+ .option('--toe-stepping <value>', 'Check for server conflicts before push: true (default) or false', 'true')
42
55
  .option('--row-key <type>', 'Override row key type for this invocation (RowUID or RowID)')
43
56
  .option('--json', 'Output raw JSON')
44
57
  .option('--jq <expr>', 'Filter JSON response')
45
58
  .option('-v, --verbose', 'Show HTTP request details')
46
59
  .option('--domain <host>', 'Override domain')
60
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
47
61
  .action(async (targetPath, options) => {
48
62
  try {
63
+ await runPendingMigrations(options);
49
64
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
50
65
 
51
66
  // ModifyKey guard — check once before any submissions
@@ -62,7 +77,62 @@ export const pushCommand = new Command('push')
62
77
  // Process pending deletions from synchronize.json
63
78
  await processPendingDeletes(client, options, modifyKey, transactionKey);
64
79
 
65
- const pathStat = await stat(targetPath);
80
+ // ── Resolution order ──────────────────────────────────────────
81
+ // 1. Commas → UID list
82
+ // 2. stat() → file/directory (existing behaviour)
83
+ // 3. stat fails:
84
+ // a. No extension + no path separator → search by UID
85
+ // b. Otherwise → bare filename search via findFileInProject()
86
+
87
+ // 1. Comma-separated → treat as UID list
88
+ if (targetPath.includes(',')) {
89
+ const uids = targetPath.split(',').map(u => u.trim()).filter(Boolean);
90
+ await pushByUIDs(uids, client, options, modifyKey, transactionKey);
91
+ return;
92
+ }
93
+
94
+ // 2. Try stat (existing path)
95
+ let pathStat;
96
+ try {
97
+ pathStat = await stat(targetPath);
98
+ } catch {
99
+ // stat failed — try smart resolution
100
+ const hasPathSep = targetPath.includes('/') || targetPath.includes('\\');
101
+ const hasExt = extname(targetPath) !== '';
102
+
103
+ if (!hasPathSep && !hasExt) {
104
+ // 3a. Looks like a UID (no extension, no path separator)
105
+ await pushByUIDs([targetPath], client, options, modifyKey, transactionKey);
106
+ return;
107
+ }
108
+
109
+ if (!hasPathSep) {
110
+ // 3b. Bare filename — search project
111
+ const matches = await findFileInProject(targetPath);
112
+ if (matches.length === 1) {
113
+ const resolved = matches[0];
114
+ log.dim(` Found: ${relative(process.cwd(), resolved)}`);
115
+ const resolvedStat = await stat(resolved);
116
+ if (resolvedStat.isDirectory()) {
117
+ await pushDirectory(resolved, client, options, modifyKey, transactionKey);
118
+ } else {
119
+ await pushSingleFile(resolved, client, options, modifyKey, transactionKey);
120
+ }
121
+ return;
122
+ } else if (matches.length > 1) {
123
+ log.error(`Multiple matches for "${targetPath}":`);
124
+ for (const m of matches) {
125
+ log.plain(` ${relative(process.cwd(), m)}`);
126
+ }
127
+ log.info('Please specify the full path.');
128
+ process.exit(1);
129
+ }
130
+ }
131
+
132
+ // No match found
133
+ log.error(`Path not found: "${targetPath}"`);
134
+ process.exit(1);
135
+ }
66
136
 
67
137
  if (pathStat.isDirectory()) {
68
138
  await pushDirectory(targetPath, client, options, modifyKey, transactionKey);
@@ -101,37 +171,16 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
101
171
  try {
102
172
  const result = await client.postUrlEncoded('/api/input/submit', body);
103
173
 
104
- // Retry with prompted params if needed
105
- const errorResult = await checkSubmitErrors(result);
106
- if (errorResult) {
107
- if (errorResult.skipRecord) {
108
- log.warn(` Skipping deletion of "${entry.name}"`);
109
- remaining.push(entry);
110
- continue;
111
- }
112
- if (errorResult.skipAll) {
113
- log.warn(` Skipping deletion of "${entry.name}" and all remaining`);
114
- remaining.push(entry);
115
- // Push all remaining entries too
116
- const currentIdx = sync.delete.indexOf(entry);
117
- for (let i = currentIdx + 1; i < sync.delete.length; i++) {
118
- remaining.push(sync.delete[i]);
119
- }
120
- break;
121
- }
122
- const params = errorResult.retryParams || errorResult;
123
- Object.assign(extraParams, params);
124
- const retryBody = await buildInputBody([entry.expression], extraParams);
125
- const retryResponse = await client.postUrlEncoded('/api/input/submit', retryBody);
126
- if (retryResponse.successful) {
127
- log.success(` Deleted "${entry.name}" from server`);
128
- deletedUids.push(entry.UID);
129
- } else {
130
- log.error(` Failed to delete "${entry.name}"`);
131
- formatResponse(retryResponse, { json: options.json, jq: options.jq, verbose: options.verbose });
132
- remaining.push(entry);
133
- }
134
- } else if (result.successful) {
174
+ // Deletes never require ticketing treat ticket-only errors as success
175
+ const deleteMessages = (result.messages || result.data?.Messages || [])
176
+ .filter(m => typeof m === 'string');
177
+ const isTicketOnlyError = !result.successful
178
+ && deleteMessages.length > 0
179
+ && deleteMessages.every(m => m.includes('ticket_error') || m.includes('ticket_lookup'));
180
+ const deleteOk = result.successful || isTicketOnlyError;
181
+
182
+ if (deleteOk) {
183
+ if (isTicketOnlyError) log.dim(' (Ticket error ignored for delete)');
135
184
  log.success(` Deleted "${entry.name}" from server`);
136
185
  deletedUids.push(entry.UID);
137
186
  } else {
@@ -222,6 +271,11 @@ async function moveWillDeleteToTrash(entry) {
222
271
  log.warn(` Could not move to trash: ${from} — ${err.message}`);
223
272
  }
224
273
  }
274
+
275
+ // Re-apply trash icon if files were moved (self-heals after user clears trash)
276
+ if (filesToMove.length > 0) {
277
+ await ensureTrashIcon(trashDir);
278
+ }
225
279
  }
226
280
 
227
281
  /**
@@ -229,9 +283,15 @@ async function moveWillDeleteToTrash(entry) {
229
283
  */
230
284
  async function pushSingleFile(filePath, client, options, modifyKey = null, transactionKey = 'RowUID') {
231
285
  // Find the metadata file
232
- const dir = dirname(filePath);
233
- const base = basename(filePath, extname(filePath));
234
- const metaPath = join(dir, `${base}.metadata.json`);
286
+ let metaPath;
287
+ if (filePath.endsWith('.metadata.json')) {
288
+ // User passed the metadata file directly — use it as-is
289
+ metaPath = filePath;
290
+ } else {
291
+ const dir = dirname(filePath);
292
+ const base = basename(filePath, extname(filePath));
293
+ metaPath = join(dir, `${base}.metadata.json`);
294
+ }
235
295
 
236
296
  let meta;
237
297
  try {
@@ -241,23 +301,88 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
241
301
  process.exit(1);
242
302
  }
243
303
 
304
+ // Toe-stepping check for single-file push
305
+ if (isToeStepping(options) && meta.UID) {
306
+ const baseline = await loadAppJsonBaseline();
307
+ if (baseline) {
308
+ const appConfig = await loadAppConfig();
309
+ const proceed = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName);
310
+ if (!proceed) return;
311
+ }
312
+ }
313
+
244
314
  await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
245
315
  }
246
316
 
317
+ /**
318
+ * Ensure manifest.json at project root has companion metadata in bins/app/.
319
+ * If manifest.json exists but no manifest*.metadata.json is in bins/app/,
320
+ * auto-create the metadata so the push flow picks it up.
321
+ */
322
+ async function ensureManifestMetadata() {
323
+ // Check if manifest.json exists at project root
324
+ try {
325
+ await access(join(process.cwd(), 'manifest.json'));
326
+ } catch {
327
+ return; // No manifest.json — nothing to do
328
+ }
329
+
330
+ // Check if bins/app/ already has metadata that references @/manifest.json.
331
+ // A filename-only check (startsWith('manifest')) is insufficient because
332
+ // the metadata may have been renamed with a ~UID suffix or prefixed with
333
+ // __WILL_DELETE__. Instead, scan actual metadata content for the reference.
334
+ const binsAppDir = join(process.cwd(), 'bins', 'app');
335
+ try {
336
+ const entries = await readdir(binsAppDir);
337
+ const metaEntries = entries.filter(e => e.endsWith('.metadata.json'));
338
+ for (const entry of metaEntries) {
339
+ try {
340
+ const raw = await readFile(join(binsAppDir, entry), 'utf8');
341
+ const parsed = JSON.parse(raw);
342
+ if (parsed.Content === '@/manifest.json') return; // Already tracked
343
+ } catch { /* skip unreadable files */ }
344
+ }
345
+ } catch {
346
+ // bins/app/ doesn't exist — will create it
347
+ }
348
+
349
+ // Auto-create manifest.metadata.json
350
+ const appConfig = await loadAppConfig();
351
+ const structure = await loadStructureFile();
352
+ const appBin = findBinByPath('app', structure);
353
+
354
+ await mkdir(binsAppDir, { recursive: true });
355
+
356
+ const meta = {
357
+ _entity: 'content',
358
+ _contentColumns: ['Content'],
359
+ Content: '@/manifest.json',
360
+ Path: 'manifest.json',
361
+ Name: 'manifest.json',
362
+ Extension: 'JSON',
363
+ Public: 1,
364
+ Active: 1,
365
+ Title: 'PWA Manifest',
366
+ };
367
+
368
+ if (appBin) meta.BinID = appBin.binId;
369
+ if (appConfig.AppID) meta.AppID = appConfig.AppID;
370
+
371
+ const metaPath = join(binsAppDir, 'manifest.metadata.json');
372
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
373
+ log.info('Auto-created manifest.metadata.json for manifest.json');
374
+ }
375
+
247
376
  /**
248
377
  * Push all records found in a directory (recursive)
249
378
  */
250
379
  async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID') {
380
+ // Auto-create manifest.metadata.json if manifest.json exists at root without companion metadata
381
+ await ensureManifestMetadata();
382
+
251
383
  const ig = await loadIgnore();
252
384
  const metaFiles = await findMetadataFiles(dirPath, ig);
253
385
 
254
- if (metaFiles.length === 0) {
255
- log.warn(`No .metadata.json files found in "${dirPath}".`);
256
- return;
257
- }
258
-
259
- log.info(`Found ${metaFiles.length} record(s) to push`);
260
-
261
386
  // Load baseline for delta detection
262
387
  const baseline = await loadAppJsonBaseline();
263
388
 
@@ -267,6 +392,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
267
392
 
268
393
  // Collect metadata with detected changes
269
394
  const toPush = [];
395
+ const outputCompoundFiles = [];
270
396
  let skipped = 0;
271
397
 
272
398
  for (const metaPath of metaFiles) {
@@ -279,18 +405,21 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
279
405
  continue;
280
406
  }
281
407
 
282
- if (!meta.UID && !meta._id) {
283
- log.warn(`Skipping "${metaPath}": no UID or _id found`);
408
+ if (!meta._entity) {
409
+ log.warn(`Skipping "${metaPath}": no _entity found`);
284
410
  skipped++;
285
411
  continue;
286
412
  }
287
413
 
288
- if (!meta._entity) {
289
- log.warn(`Skipping "${metaPath}": no _entity found`);
290
- skipped++;
414
+ // Compound output files: handle root + all inline children together
415
+ // These have _entity='output' and inline children under .children
416
+ if (meta._entity === 'output' && meta.children) {
417
+ outputCompoundFiles.push({ meta, metaPath });
291
418
  continue;
292
419
  }
293
420
 
421
+ const isNewRecord = !meta.UID && !meta._id;
422
+
294
423
  // Verify @file references exist
295
424
  const contentCols = meta._contentColumns || [];
296
425
  let missingFiles = false;
@@ -329,9 +458,9 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
329
458
  if (contentIgnored) { skipped++; continue; }
330
459
  }
331
460
 
332
- // Detect changed columns (delta detection)
461
+ // Detect changed columns (delta detection) — skip for new records
333
462
  let changedColumns = null;
334
- if (baseline) {
463
+ if (!isNewRecord && baseline) {
335
464
  try {
336
465
  changedColumns = await detectChangedColumns(metaPath, baseline);
337
466
  if (changedColumns.length === 0) {
@@ -344,18 +473,56 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
344
473
  }
345
474
  }
346
475
 
347
- toPush.push({ meta, metaPath, changedColumns });
476
+ toPush.push({ meta, metaPath, changedColumns, isNew: isNewRecord });
348
477
  }
349
478
 
350
- if (toPush.length === 0) {
351
- log.info('No changes to push');
479
+ // Toe-stepping: check for server-side conflicts before submitting
480
+ if (isToeStepping(options) && baseline && toPush.length > 0) {
481
+ const toCheck = toPush.filter(item => !item.isNew);
482
+ if (toCheck.length > 0) {
483
+ const appConfig = await loadAppConfig();
484
+ const proceed = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
485
+ if (!proceed) return;
486
+ }
487
+ }
488
+
489
+ // ── Bin entity push: check if directory maps to a bin ──────────────
490
+ const binPushItems = [];
491
+ try {
492
+ const structure = await loadStructureFile();
493
+ const relDir = relative(process.cwd(), dirPath).replace(/\\/g, '/');
494
+ const binEntry = findBinByPath(relDir, structure);
495
+ if (binEntry && binEntry.uid && baseline) {
496
+ const changedBinCols = detectBinChanges(binEntry, baseline);
497
+ if (changedBinCols.length > 0) {
498
+ const appConfig = await loadAppConfig();
499
+ const binMeta = synthesizeBinMetadata(binEntry, appConfig.AppID);
500
+ binPushItems.push({ meta: binMeta, binEntry, changedColumns: changedBinCols });
501
+ log.info(`Bin "${binEntry.name}" has ${changedBinCols.length} changed column(s): ${changedBinCols.join(', ')}`);
502
+ }
503
+ }
504
+ } catch { /* structure file missing or bin lookup failed — skip */ }
505
+
506
+ if (toPush.length === 0 && outputCompoundFiles.length === 0 && binPushItems.length === 0) {
507
+ if (metaFiles.length === 0) {
508
+ log.warn(`No .metadata.json files found in "${dirPath}".`);
509
+ } else {
510
+ log.info('No changes to push');
511
+ }
352
512
  return;
353
513
  }
354
514
 
515
+ log.info(`Found ${metaFiles.length} record(s) to push`);
516
+
355
517
  // Pre-flight ticket validation (only if no --ticket flag)
356
- if (!options.ticket && toPush.length > 0) {
357
- const recordSummary = toPush.map(r => basename(r.metaPath, '.metadata.json')).join(', ');
358
- const ticketCheck = await checkStoredTicket(options, `${toPush.length} record(s): ${recordSummary}`);
518
+ const totalRecords = toPush.length + outputCompoundFiles.length + binPushItems.length;
519
+ if (!options.ticket && totalRecords > 0) {
520
+ const recordSummary = [
521
+ ...toPush.map(r => basename(r.metaPath, '.metadata.json')),
522
+ ...outputCompoundFiles.map(r => basename(r.metaPath, '.json')),
523
+ ...binPushItems.map(r => `bin:${r.meta.Name}`),
524
+ ].join(', ');
525
+ const ticketCheck = await checkStoredTicket(options, `${totalRecords} record(s): ${recordSummary}`);
359
526
  if (ticketCheck.cancel) {
360
527
  log.info('Submission cancelled');
361
528
  return;
@@ -366,19 +533,48 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
366
533
  }
367
534
  }
368
535
 
369
- // Sort by dependency level: children first (ascending level) for add/edit operations
370
- toPush.sort((a, b) => {
536
+ // Separate new records (adds) from existing records (edits)
537
+ const toAdd = toPush.filter(item => item.isNew);
538
+ const toEdit = toPush.filter(item => !item.isNew);
539
+
540
+ // Sort each group by dependency level
541
+ const sortByDependency = (a, b) => {
371
542
  const levelA = ENTITY_DEPENDENCIES[a.meta._entity] || 0;
372
543
  const levelB = ENTITY_DEPENDENCIES[b.meta._entity] || 0;
373
544
  return levelA - levelB;
374
- });
545
+ };
546
+ toAdd.sort(sortByDependency);
547
+ toEdit.sort(sortByDependency);
375
548
 
376
- // Process in dependency order
377
549
  let succeeded = 0;
378
550
  let failed = 0;
379
551
  const successfulPushes = [];
380
552
 
381
- for (const item of toPush) {
553
+ // Process adds first
554
+ if (toAdd.length > 0) {
555
+ log.info(`Adding ${toAdd.length} new record(s)...`);
556
+ }
557
+ for (const item of toAdd) {
558
+ try {
559
+ const success = await addFromMetadata(item.meta, item.metaPath, client, options, modifyKey);
560
+ if (success) {
561
+ succeeded++;
562
+ successfulPushes.push(item);
563
+ } else {
564
+ failed++;
565
+ }
566
+ } catch (err) {
567
+ if (err.message === 'SKIP_ALL') {
568
+ log.info('Skipping remaining records');
569
+ break;
570
+ }
571
+ log.error(`Failed to add: ${item.metaPath} — ${err.message}`);
572
+ failed++;
573
+ }
574
+ }
575
+
576
+ // Then process edits
577
+ for (const item of toEdit) {
382
578
  try {
383
579
  const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey, transactionKey);
384
580
  if (success) {
@@ -397,6 +593,44 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
397
593
  }
398
594
  }
399
595
 
596
+ // Process compound output files (root + inline children)
597
+ for (const { meta, metaPath } of outputCompoundFiles) {
598
+ try {
599
+ const result = await pushOutputCompound(meta, metaPath, client, options, baseline, modifyKey, transactionKey);
600
+ if (result.pushed > 0) {
601
+ succeeded++;
602
+ successfulPushes.push({ meta, metaPath, changedColumns: null });
603
+ } else {
604
+ skipped++;
605
+ }
606
+ } catch (err) {
607
+ log.error(`Failed compound output push: ${metaPath} — ${err.message}`);
608
+ failed++;
609
+ }
610
+ }
611
+
612
+ // Process bin entity changes
613
+ for (const binItem of binPushItems) {
614
+ try {
615
+ // Synthesize a temporary metadata file path for pushFromMetadata
616
+ // (bin records have no .metadata.json — we pass the data inline)
617
+ const success = await pushBinEntity(binItem.meta, binItem.changedColumns, client, options, modifyKey, transactionKey);
618
+ if (success) {
619
+ succeeded++;
620
+ } else {
621
+ failed++;
622
+ }
623
+ } catch (err) {
624
+ log.error(`Failed bin push: ${binItem.meta.Name} — ${err.message}`);
625
+ failed++;
626
+ }
627
+ }
628
+
629
+ // Clear server cache so subsequent GETs (diff, pull, toe-stepping) return fresh data
630
+ if (successfulPushes.length > 0) {
631
+ await client.voidCache();
632
+ }
633
+
400
634
  // Update baseline after successful pushes
401
635
  if (baseline && successfulPushes.length > 0) {
402
636
  await updateBaselineAfterPush(baseline, successfulPushes);
@@ -405,6 +639,294 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
405
639
  log.info(`Push complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped`);
406
640
  }
407
641
 
642
+ /**
643
+ * Push a bin entity record (synthesized metadata, no .metadata.json file).
644
+ * Uses pushFromMetadata with a temporary in-memory metadata path.
645
+ */
646
+ async function pushBinEntity(binMeta, changedColumns, client, options, modifyKey = null, transactionKey = 'RowUID') {
647
+ const entity = binMeta._entity;
648
+ const uid = binMeta.UID;
649
+ const id = binMeta._id;
650
+
651
+ // Determine row key
652
+ let rowKeyPrefix, rowKeyValue;
653
+ if (uid) {
654
+ rowKeyPrefix = 'RowUID';
655
+ rowKeyValue = uid;
656
+ } else if (id) {
657
+ rowKeyPrefix = 'RowID';
658
+ rowKeyValue = id;
659
+ } else {
660
+ log.warn(`Bin "${binMeta.Name}" has no UID or ID — skipping`);
661
+ return false;
662
+ }
663
+
664
+ const dataExprs = [];
665
+ for (const col of changedColumns) {
666
+ const value = binMeta[col];
667
+ const strValue = value !== null && value !== undefined ? String(value) : '';
668
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${col}=${strValue}`);
669
+ }
670
+
671
+ if (dataExprs.length === 0) {
672
+ log.warn(`Nothing to push for bin "${binMeta.Name}"`);
673
+ return false;
674
+ }
675
+
676
+ log.info(`Pushing bin "${binMeta.Name}" (${entity}:${rowKeyValue}) — ${dataExprs.length} changed field(s)`);
677
+
678
+ const extraParams = { '_confirm': options.confirm || 'true' };
679
+ if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
680
+ if (modifyKey) extraParams['_modify_key'] = modifyKey;
681
+ const cachedUser = getSessionUserOverride();
682
+ if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
683
+
684
+ const body = await buildInputBody(dataExprs, extraParams);
685
+ const result = await client.postUrlEncoded('/api/input/submit', body);
686
+
687
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
688
+
689
+ if (!result.successful) {
690
+ return false;
691
+ }
692
+
693
+ log.success(` Pushed bin "${binMeta.Name}"`);
694
+ return true;
695
+ }
696
+
697
+ /**
698
+ * Push records by UID(s). Searches metadata files and structure.json bins.
699
+ */
700
+ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKey = 'RowUID') {
701
+ const matches = await findByUID(uids);
702
+
703
+ if (matches.length === 0) {
704
+ log.error(`No records found for UID(s): ${uids.join(', ')}`);
705
+ process.exit(1);
706
+ }
707
+
708
+ // Report unmatched UIDs
709
+ const foundUids = new Set(matches.map(m => m.uid));
710
+ for (const uid of uids) {
711
+ if (!foundUids.has(uid)) {
712
+ log.warn(`UID not found: ${uid}`);
713
+ }
714
+ }
715
+
716
+ const baseline = await loadAppJsonBaseline();
717
+
718
+ // Toe-stepping check for UID-targeted push
719
+ if (isToeStepping(options) && baseline) {
720
+ const toCheck = [];
721
+ for (const match of matches) {
722
+ if (match.metaPath && match.meta) {
723
+ toCheck.push({ meta: match.meta, metaPath: match.metaPath });
724
+ }
725
+ }
726
+ if (toCheck.length > 0) {
727
+ const appConfig = await loadAppConfig();
728
+ const proceed = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
729
+ if (!proceed) return;
730
+ }
731
+ }
732
+
733
+ let succeeded = 0;
734
+ let failed = 0;
735
+
736
+ for (const match of matches) {
737
+ if (match.metaPath) {
738
+ // Regular record — push via pushSingleFile path
739
+ try {
740
+ const success = await pushSingleFile(match.metaPath, client, options, modifyKey, transactionKey);
741
+ if (success !== false) succeeded++;
742
+ else failed++;
743
+ } catch (err) {
744
+ log.error(`Failed to push ${match.uid}: ${err.message}`);
745
+ failed++;
746
+ }
747
+ } else if (match.binEntry) {
748
+ // Bin entity — detect changes and push
749
+ try {
750
+ const changedCols = baseline
751
+ ? detectBinChanges(match.binEntry, baseline)
752
+ : ['Name', 'Path', 'ParentBinID'];
753
+
754
+ if (changedCols.length === 0) {
755
+ log.dim(` Bin "${match.binEntry.name}" — no changes detected`);
756
+ continue;
757
+ }
758
+
759
+ const appConfig = await loadAppConfig();
760
+ const binMeta = synthesizeBinMetadata(match.binEntry, appConfig.AppID);
761
+ const success = await pushBinEntity(binMeta, changedCols, client, options, modifyKey, transactionKey);
762
+ if (success) succeeded++;
763
+ else failed++;
764
+ } catch (err) {
765
+ log.error(`Failed to push bin ${match.uid}: ${err.message}`);
766
+ failed++;
767
+ }
768
+ }
769
+ }
770
+
771
+ if (matches.length > 1) {
772
+ log.info(`Push complete: ${succeeded} succeeded, ${failed} failed`);
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Submit a new record (add) from metadata that has no UID yet.
778
+ * Builds RowID:add1 expressions, submits, then renames files with the returned ~UID.
779
+ */
780
+ async function addFromMetadata(meta, metaPath, client, options, modifyKey = null) {
781
+ const entity = meta._entity;
782
+ const contentCols = new Set(meta._contentColumns || []);
783
+ const metaDir = dirname(metaPath);
784
+
785
+ const dataExprs = [];
786
+ const addIndex = 1;
787
+
788
+ for (const [key, value] of Object.entries(meta)) {
789
+ if (shouldSkipColumn(key)) continue;
790
+ if (key === 'UID') continue;
791
+ if (value === null || value === undefined) continue;
792
+
793
+ const strValue = String(value);
794
+
795
+ if (strValue.startsWith('@')) {
796
+ const refFile = strValue.substring(1);
797
+ const refPath = resolveAtReference(refFile, metaDir);
798
+ dataExprs.push(`RowID:add${addIndex};column:${entity}.${key}@${refPath}`);
799
+ } else {
800
+ dataExprs.push(`RowID:add${addIndex};column:${entity}.${key}=${strValue}`);
801
+ }
802
+ }
803
+
804
+ if (dataExprs.length === 0) {
805
+ log.warn(`Nothing to submit for ${basename(metaPath)}`);
806
+ return false;
807
+ }
808
+
809
+ log.info(`Adding ${basename(metaPath)} (${entity}) — ${dataExprs.length} field(s)`);
810
+
811
+ // Apply stored ticket — add operations always use RowID (not RowUID)
812
+ let storedTicket = null;
813
+ if (!options.ticket) {
814
+ const globalTicket = await (await import('../lib/ticketing.js')).getGlobalTicket();
815
+ if (globalTicket) {
816
+ dataExprs.push(`RowID:add${addIndex};column:${entity}._LastUpdatedTicketID=${globalTicket}`);
817
+ log.dim(` Applying ticket: ${globalTicket}`);
818
+ storedTicket = globalTicket;
819
+ }
820
+ }
821
+
822
+ const extraParams = { '_confirm': options.confirm || 'true' };
823
+ if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
824
+ else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
825
+ if (modifyKey) extraParams['_modify_key'] = modifyKey;
826
+ const cachedUser = getSessionUserOverride();
827
+ if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
828
+
829
+ let body = await buildInputBody(dataExprs, extraParams);
830
+ let result = await client.postUrlEncoded('/api/input/submit', body);
831
+
832
+ // Reactive ModifyKey retry
833
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
834
+ const retryMK = await handleModifyKeyError();
835
+ if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
836
+ extraParams['_modify_key'] = retryMK.modifyKey;
837
+ body = await buildInputBody(dataExprs, extraParams);
838
+ result = await client.postUrlEncoded('/api/input/submit', body);
839
+ }
840
+
841
+ // Retry with prompted params if needed
842
+ const retryResult = await checkSubmitErrors(result);
843
+ if (retryResult) {
844
+ if (retryResult.skipRecord) { log.warn(' Skipping record'); return false; }
845
+ if (retryResult.skipAll) throw new Error('SKIP_ALL');
846
+ if (retryResult.ticketExpressions?.length > 0) dataExprs.push(...retryResult.ticketExpressions);
847
+ const params = retryResult.retryParams || retryResult;
848
+ Object.assign(extraParams, params);
849
+ body = await buildInputBody(dataExprs, extraParams);
850
+ result = await client.postUrlEncoded('/api/input/submit', body);
851
+ }
852
+
853
+ if (!result.successful) {
854
+ const msgs = result.messages || result.data?.Messages || [];
855
+ log.error(`Add failed for ${basename(metaPath)}`);
856
+ if (msgs.length > 0) {
857
+ for (const m of msgs) log.dim(` ${typeof m === 'string' ? m : JSON.stringify(m)}`);
858
+ }
859
+ return false;
860
+ }
861
+
862
+ // Extract UID from response and rename files to ~uid convention
863
+ const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
864
+ if (addResults.length > 0) {
865
+ const returnedUID = addResults[0].UID;
866
+ const returnedLastUpdated = addResults[0]._LastUpdated;
867
+
868
+ if (returnedUID) {
869
+ meta.UID = returnedUID;
870
+
871
+ // Store numeric ID for delete operations (RowID:del<id>)
872
+ const entityIdKey = entity.charAt(0).toUpperCase() + entity.slice(1) + 'ID';
873
+ const returnedId = addResults[0][entityIdKey] || addResults[0]._id || addResults[0].ID;
874
+ if (returnedId) meta._id = returnedId;
875
+
876
+ const currentMetaBase = basename(metaPath, '.metadata.json');
877
+
878
+ // Guard: don't append UID if it's already in the filename
879
+ if (hasUidInFilename(currentMetaBase, returnedUID)) {
880
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
881
+ log.success(`UID ${returnedUID} already in filename`);
882
+ return true;
883
+ }
884
+
885
+ const newBase = buildUidFilename(currentMetaBase, returnedUID);
886
+ const newMetaPath = join(metaDir, `${newBase}.metadata.json`);
887
+
888
+ // Update @references in metadata to include ~UID for non-root references
889
+ for (const col of (meta._contentColumns || [])) {
890
+ const ref = meta[col];
891
+ if (ref && String(ref).startsWith('@') && !String(ref).startsWith('@/')) {
892
+ // Local file reference — rename it too
893
+ const oldRefFile = String(ref).substring(1);
894
+ const refExt = extname(oldRefFile);
895
+ const refBase = basename(oldRefFile, refExt);
896
+ const newRefBase = buildUidFilename(refBase, returnedUID);
897
+ const newRefFile = refExt ? `${newRefBase}${refExt}` : newRefBase;
898
+
899
+ const oldRefPath = join(metaDir, oldRefFile);
900
+ const newRefPath = join(metaDir, newRefFile);
901
+ try {
902
+ await fsRename(oldRefPath, newRefPath);
903
+ meta[col] = `@${newRefFile}`;
904
+ } catch { /* content file may be root-relative */ }
905
+ }
906
+ }
907
+
908
+ // Rename old metadata file, then write updated content
909
+ if (metaPath !== newMetaPath) {
910
+ try { await fsRename(metaPath, newMetaPath); } catch { /* ignore if same */ }
911
+ }
912
+ await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
913
+
914
+ // Set timestamps from server
915
+ const config = await loadConfig();
916
+ const serverTz = config.ServerTimezone;
917
+ if (serverTz && returnedLastUpdated) {
918
+ try {
919
+ await setFileTimestamps(newMetaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
920
+ } catch { /* non-critical */ }
921
+ }
922
+
923
+ log.success(`Added: ${basename(metaPath)} → UID ${returnedUID}`);
924
+ }
925
+ }
926
+
927
+ return true;
928
+ }
929
+
408
930
  /**
409
931
  * Build and submit input expressions from a metadata object
410
932
  * @param {Object} meta - Metadata object
@@ -421,40 +943,37 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
421
943
  const contentCols = new Set(meta._contentColumns || []);
422
944
  const metaDir = dirname(metaPath);
423
945
 
424
- // Determine the row key prefix and value based on the preset
946
+ // Determine the row key. TransactionKeyPreset only applies when the record
947
+ // carries a UID column (core assets). Data records without a UID always use
948
+ // RowID directly — no preset, no fallback warning.
425
949
  let rowKeyPrefix, rowKeyValue;
426
- if (transactionKey === 'RowID') {
427
- if (id) {
950
+ const hasUid = uid != null && uid !== '';
951
+
952
+ if (hasUid) {
953
+ // Core asset: honour the TransactionKeyPreset
954
+ if (transactionKey === 'RowID') {
955
+ if (!id) throw new Error(`No _id found in ${basename(metaPath)} — required when TransactionKeyPreset is RowID`);
428
956
  rowKeyPrefix = 'RowID';
429
957
  rowKeyValue = id;
430
958
  } else {
431
- log.warn(` ⚠ Preset is RowID but no _id found in ${basename(metaPath)} — falling back to RowUID`);
959
+ // RowUID (default)
432
960
  rowKeyPrefix = 'RowUID';
433
961
  rowKeyValue = uid;
434
962
  }
435
963
  } else {
436
- if (uid) {
437
- rowKeyPrefix = 'RowUID';
438
- rowKeyValue = uid;
439
- } else if (id) {
440
- log.warn(` ⚠ Preset is RowUID but no UID found in ${basename(metaPath)} — falling back to RowID`);
441
- rowKeyPrefix = 'RowID';
442
- rowKeyValue = id;
443
- }
444
- }
445
-
446
- if (!rowKeyValue) {
447
- throw new Error(`No UID or _id found in ${metaPath}`);
964
+ // Data record: no UID column — always RowID
965
+ if (!id) throw new Error(`No UID or _id found in ${metaPath}`);
966
+ rowKeyPrefix = 'RowID';
967
+ rowKeyValue = id;
448
968
  }
449
969
  if (!entity) {
450
970
  throw new Error(`No _entity found in ${metaPath}`);
451
971
  }
452
972
 
453
- // Detect path mismatch (only for content, media, output, bin entities
454
- // entity-dir types use structural directories unrelated to server Path)
455
- if (meta.Path) {
456
- await checkPathMismatch(meta, metaPath, entity, options);
457
- }
973
+ // Path mismatch check disabled: the metadata Path column reflects the
974
+ // server-side path and must not be overwritten by local directory structure.
975
+ // Local bin/lib directory placement is an organizational choice independent
976
+ // of the server Path value.
458
977
 
459
978
  const dataExprs = [];
460
979
  let metaUpdated = false;
@@ -466,7 +985,10 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
466
985
  if (shouldSkipColumn(key)) continue;
467
986
  if (key === 'UID') continue; // UID is the identifier, not a column to update
468
987
  if (key === 'children') continue; // Output hierarchy structural field, not a server column
469
- if (value === null || value === undefined) continue;
988
+
989
+ // Skip null/undefined values UNLESS delta detected them as changed
990
+ // (user explicitly set a column to null to clear it on server)
991
+ if ((value === null || value === undefined) && !(columnsToProcess && columnsToProcess.has(key))) continue;
470
992
 
471
993
  // Delta sync: skip columns not in changedColumns
472
994
  if (columnsToProcess && !columnsToProcess.has(key)) continue;
@@ -478,7 +1000,8 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
478
1000
  // --content-only: skip non-content columns
479
1001
  if (options.contentOnly && !isContentCol) continue;
480
1002
 
481
- const strValue = String(value);
1003
+ // Null values that passed delta check → send as empty string to clear on server
1004
+ const strValue = (value === null || value === undefined) ? '' : String(value);
482
1005
 
483
1006
  if (strValue.startsWith('@')) {
484
1007
  // @filename reference — resolve to actual file path
@@ -800,6 +1323,194 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
800
1323
  }
801
1324
  }
802
1325
 
1326
+ // ─── Compound Output Push ───────────────────────────────────────────────────
1327
+
1328
+ const _COMPOUND_DOC_KEYS = ['column', 'join', 'filter'];
1329
+
1330
+ /**
1331
+ * Push a compound output file (root + inline children) to the server.
1332
+ * Handles delta detection, dependency ordering, FK preservation,
1333
+ * CustomSQL @reference resolution, and root _lastUpdated stamping.
1334
+ *
1335
+ * @param {Object} meta - Parsed root output JSON
1336
+ * @param {string} metaPath - Absolute path to root output JSON file
1337
+ * @param {DboClient} client - API client
1338
+ * @param {Object} options - Push options
1339
+ * @param {Object} baseline - Loaded baseline
1340
+ * @param {string|null} modifyKey - ModifyKey value
1341
+ * @param {string} transactionKey - RowUID or RowID
1342
+ * @returns {Promise<{ pushed: number }>} - Count of entities pushed
1343
+ */
1344
+ async function pushOutputCompound(meta, metaPath, client, options, baseline, modifyKey = null, transactionKey = 'RowUID') {
1345
+ const metaDir = dirname(metaPath);
1346
+
1347
+ // Delta detection for compound output
1348
+ let rootChanges, childChanges;
1349
+ if (baseline) {
1350
+ try {
1351
+ const delta = await detectOutputChanges(metaPath, baseline);
1352
+ rootChanges = delta.root;
1353
+ childChanges = delta.children;
1354
+ } catch (err) {
1355
+ log.warn(`Compound output delta detection failed for ${metaPath}: ${err.message} — performing full push`);
1356
+ rootChanges = getAllUserColumns(meta);
1357
+ childChanges = null; // null = push all children
1358
+ }
1359
+ } else {
1360
+ rootChanges = getAllUserColumns(meta);
1361
+ childChanges = null;
1362
+ }
1363
+
1364
+ const totalChanges = rootChanges.length +
1365
+ (childChanges ? Object.values(childChanges).reduce((s, c) => s + c.length, 0) : 999);
1366
+
1367
+ if (totalChanges === 0) {
1368
+ log.dim(` Skipping ${basename(metaPath)} — no changes detected`);
1369
+ return { pushed: 0 };
1370
+ }
1371
+
1372
+ // Flatten all inline children with depth annotation
1373
+ const allChildren = [];
1374
+ _flattenOutputChildren(meta.children || {}, allChildren);
1375
+
1376
+ // Separate adds (no baseline entry) from edits
1377
+ const adds = [];
1378
+ const edits = [];
1379
+ for (const child of allChildren) {
1380
+ const entry = baseline ? findBaselineEntry(baseline, child._entity, child.UID) : null;
1381
+ const changes = childChanges ? (childChanges[child.UID] || []) : getAllUserColumns(child);
1382
+ if (!entry) {
1383
+ adds.push({ child, changes: getAllUserColumns(child) });
1384
+ } else if (changes.length > 0) {
1385
+ edits.push({ child, changes });
1386
+ }
1387
+ }
1388
+
1389
+ // Check if root itself is new
1390
+ const rootEntry = baseline ? findBaselineEntry(baseline, 'output', meta.UID) : null;
1391
+ const rootIsNew = !rootEntry;
1392
+
1393
+ let pushed = 0;
1394
+
1395
+ // EDIT ORDER: deepest children first (highest _depth)
1396
+ edits.sort((a, b) => b.child._depth - a.child._depth);
1397
+ for (const { child, changes } of edits) {
1398
+ const success = await _submitOutputEntity(child, child._entity, changes, metaDir, client, options, modifyKey, transactionKey);
1399
+ if (success) pushed++;
1400
+ }
1401
+
1402
+ // ADD ORDER: root first (if new), then children shallowest→deepest
1403
+ if (rootIsNew && rootChanges.length > 0) {
1404
+ const success = await _submitOutputEntity(meta, 'output', rootChanges, metaDir, client, options, modifyKey, transactionKey);
1405
+ if (success) pushed++;
1406
+ }
1407
+ adds.sort((a, b) => a.child._depth - b.child._depth);
1408
+ for (const { child, changes } of adds) {
1409
+ const success = await _submitOutputEntity(child, child._entity, changes, metaDir, client, options, modifyKey, transactionKey);
1410
+ if (success) pushed++;
1411
+ }
1412
+
1413
+ // Always update root (with _lastUpdated) — submit root changes or just touch it
1414
+ if (!rootIsNew && (rootChanges.length > 0 || edits.length > 0 || adds.length > 0)) {
1415
+ const success = await _submitOutputEntity(meta, 'output', rootChanges.length > 0 ? rootChanges : ['Name'], metaDir, client, options, modifyKey, transactionKey);
1416
+ if (success) pushed++;
1417
+ }
1418
+
1419
+ log.info(`Compound output push: ${basename(metaPath)} — ${pushed} entity submission(s)`);
1420
+ return { pushed };
1421
+ }
1422
+
1423
+ /**
1424
+ * Flatten children object ({ column, join, filter }) into a flat array.
1425
+ * Annotates each child with _depth (1 = direct child of root, 2 = grandchild, etc.)
1426
+ */
1427
+ function _flattenOutputChildren(childrenObj, result, depth = 1) {
1428
+ for (const docKey of _COMPOUND_DOC_KEYS) {
1429
+ const entityArray = childrenObj[docKey];
1430
+ if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
1431
+ for (const child of entityArray) {
1432
+ child._depth = depth;
1433
+ result.push(child);
1434
+ if (child.children) _flattenOutputChildren(child.children, result, depth + 1);
1435
+ }
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * Submit a single output hierarchy entity to the server.
1441
+ * Resolves @reference values, builds data expressions, and submits.
1442
+ */
1443
+ async function _submitOutputEntity(entity, physicalEntity, changedColumns, metaDir, client, options, modifyKey, transactionKey) {
1444
+ const uid = entity.UID;
1445
+ if (!uid) {
1446
+ log.warn(` Output entity ${physicalEntity} has no UID — skipping`);
1447
+ return false;
1448
+ }
1449
+
1450
+ const rowKeyPrefix = transactionKey === 'RowID' && entity._id ? 'RowID' : 'RowUID';
1451
+ const rowKeyValue = rowKeyPrefix === 'RowID' ? entity._id : uid;
1452
+
1453
+ const dataExprs = [];
1454
+
1455
+ for (const col of changedColumns) {
1456
+ if (shouldSkipColumn(col)) continue;
1457
+ if (col === 'UID' || col === 'children') continue;
1458
+
1459
+ const val = entity[col];
1460
+
1461
+ // Null values in changedColumns → send empty string to clear on server
1462
+ const strValue = (val === null || val === undefined) ? '' : String(val);
1463
+ if (isReference(strValue)) {
1464
+ const refPath = resolveReferencePath(strValue, metaDir);
1465
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}@${refPath}`);
1466
+ } else {
1467
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}=${strValue}`);
1468
+ }
1469
+ }
1470
+
1471
+ if (dataExprs.length === 0) return false;
1472
+
1473
+ log.info(` Pushing ${physicalEntity}:${uid} — ${dataExprs.length} field(s)`);
1474
+
1475
+ const storedTicket = await applyStoredTicketToSubmission(dataExprs, physicalEntity, uid, uid, options);
1476
+
1477
+ const extraParams = { '_confirm': options.confirm || 'true' };
1478
+ if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
1479
+ else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
1480
+ if (modifyKey) extraParams['_modify_key'] = modifyKey;
1481
+ const cachedUser = getSessionUserOverride();
1482
+ if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
1483
+
1484
+ const body = await buildInputBody(dataExprs, extraParams);
1485
+ let result = await client.postUrlEncoded('/api/input/submit', body);
1486
+
1487
+ // Reactive ModifyKey retry
1488
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
1489
+ const retryMK = await handleModifyKeyError();
1490
+ if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
1491
+ extraParams['_modify_key'] = retryMK.modifyKey;
1492
+ const retryBody = await buildInputBody(dataExprs, extraParams);
1493
+ result = await client.postUrlEncoded('/api/input/submit', retryBody);
1494
+ }
1495
+
1496
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
1497
+
1498
+ if (!result.successful) return false;
1499
+
1500
+ // Update metadata _LastUpdated from server response
1501
+ try {
1502
+ const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
1503
+ if (editResults.length > 0) {
1504
+ const updated = editResults[0]._LastUpdated || editResults[0].LastUpdated;
1505
+ if (updated) entity._LastUpdated = updated;
1506
+ }
1507
+ } catch { /* non-critical */ }
1508
+
1509
+ return true;
1510
+ }
1511
+
1512
+ // ─── Baseline Update ────────────────────────────────────────────────────────
1513
+
803
1514
  /**
804
1515
  * Update baseline file (.app.json) after successful pushes.
805
1516
  * Syncs changed column values and timestamps from metadata to baseline.
@@ -814,11 +1525,14 @@ async function updateBaselineAfterPush(baseline, successfulPushes) {
814
1525
  const uid = meta.UID || meta._id;
815
1526
  const entity = meta._entity;
816
1527
 
817
- // Find the baseline entry
818
- const baselineEntry = findBaselineEntry(baseline, entity, uid);
1528
+ // Find or create the baseline entry
1529
+ let baselineEntry = findBaselineEntry(baseline, entity, uid);
819
1530
  if (!baselineEntry) {
820
- log.warn(` Baseline entry not found for ${entity}:${uid} skipping baseline update`);
821
- continue;
1531
+ // New record (from add)insert into baseline
1532
+ if (!baseline.children) baseline.children = {};
1533
+ if (!Array.isArray(baseline.children[entity])) baseline.children[entity] = [];
1534
+ baselineEntry = { UID: uid };
1535
+ baseline.children[entity].push(baselineEntry);
822
1536
  }
823
1537
 
824
1538
  // Update _LastUpdated and _LastUpdatedUserID from metadata
@@ -836,7 +1550,13 @@ async function updateBaselineAfterPush(baseline, successfulPushes) {
836
1550
 
837
1551
  for (const col of columnsToUpdate) {
838
1552
  const value = meta[col];
839
- if (value === null || value === undefined) continue;
1553
+
1554
+ // Null/undefined values: store null in baseline (field was cleared)
1555
+ if (value === null || value === undefined) {
1556
+ baselineEntry[col] = null;
1557
+ modified = true;
1558
+ continue;
1559
+ }
840
1560
 
841
1561
  const strValue = String(value);
842
1562