@dboio/cli 0.20.0 → 0.20.4

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.
@@ -1,11 +1,11 @@
1
1
  import chalk from 'chalk';
2
2
  import { dirname, basename, join } from 'path';
3
- import { readFile } from 'fs/promises';
4
- import { findBaselineEntry, shouldSkipColumn, normalizeValue, isReference, resolveReferencePath } from './delta.js';
3
+ import { readFile, writeFile } from 'fs/promises';
4
+ import { findBaselineEntry, shouldSkipColumn, normalizeValue, isReference, resolveReferencePath, saveBaseline } from './delta.js';
5
5
  import { resolveContentValue } from '../commands/clone.js';
6
6
  import { computeLineDiff, formatDiff } from './diff.js';
7
7
  import { parseMetaFilename } from './filenames.js';
8
- import { parseServerDate } from './timestamps.js';
8
+ import { parseServerDate, setFileTimestamps } from './timestamps.js';
9
9
  import { log } from './logger.js';
10
10
 
11
11
  /**
@@ -335,6 +335,7 @@ export async function checkToeStepping(records, client, baseline, options, appSh
335
335
  let skippedUIDs = new Set();
336
336
  let bulkAction = null; // 'push_all' | 'skip_all'
337
337
  let hasConflicts = false;
338
+ let baselineModified = false;
338
339
 
339
340
  for (const { meta, metaPath } of records) {
340
341
  const uid = meta.UID;
@@ -381,6 +382,14 @@ export async function checkToeStepping(records, client, baseline, options, appSh
381
382
  if (options.yes || bulkAction === 'push_all') {
382
383
  continue; // push this record
383
384
  }
385
+ if (bulkAction === 'pull_all') {
386
+ await applyServerToLocal(serverEntry, meta, metaPath, serverTz);
387
+ _updateBaselineEntry(baseline, entity, uid, serverEntry);
388
+ baselineModified = true;
389
+ log.success(` Pulled server version of "${label}" to local`);
390
+ skippedUIDs.add(uid);
391
+ continue;
392
+ }
384
393
  if (bulkAction === 'skip_all') {
385
394
  skippedUIDs.add(uid);
386
395
  continue;
@@ -395,9 +404,11 @@ export async function checkToeStepping(records, client, baseline, options, appSh
395
404
  message: `"${label}" has server changes. How to proceed?`,
396
405
  choices: [
397
406
  { name: 'Push anyway (overwrite server changes)', value: 'push' },
407
+ { name: 'Pull from server (overwrite local changes)', value: 'pull' },
398
408
  { name: 'Compare differences', value: 'compare' },
399
409
  { name: 'Skip this record', value: 'skip' },
400
410
  { name: 'Push all remaining (overwrite all)', value: 'push_all' },
411
+ { name: 'Pull all remaining (overwrite all local)', value: 'pull_all' },
401
412
  { name: 'Skip all remaining', value: 'skip_all' },
402
413
  { name: 'Cancel entire push', value: 'cancel' },
403
414
  ],
@@ -414,6 +425,14 @@ export async function checkToeStepping(records, client, baseline, options, appSh
414
425
  log.info('Push cancelled. Run "dbo pull" to fetch server changes first.');
415
426
  return false;
416
427
  }
428
+ if (action === 'pull' || action === 'pull_all') {
429
+ await applyServerToLocal(serverEntry, meta, metaPath, serverTz);
430
+ _updateBaselineEntry(baseline, entity, uid, serverEntry);
431
+ baselineModified = true;
432
+ log.success(` Pulled server version of "${label}" to local`);
433
+ skippedUIDs.add(uid);
434
+ if (action === 'pull_all') bulkAction = 'pull_all';
435
+ }
417
436
  if (action === 'skip' || action === 'skip_all') {
418
437
  skippedUIDs.add(uid);
419
438
  if (action === 'skip_all') bulkAction = 'skip_all';
@@ -423,6 +442,12 @@ export async function checkToeStepping(records, client, baseline, options, appSh
423
442
  }
424
443
  }
425
444
 
445
+ if (baselineModified) {
446
+ try {
447
+ await saveBaseline(baseline);
448
+ } catch { /* non-critical — next push will re-detect */ }
449
+ }
450
+
426
451
  if (!hasConflicts) return true;
427
452
 
428
453
  // Return skipped UIDs so the caller can filter them out
@@ -496,3 +521,78 @@ async function showPushDiff(serverEntry, localMeta, metaPath) {
496
521
 
497
522
  log.plain('');
498
523
  }
524
+
525
+ /**
526
+ * Update the in-memory baseline entry for a record with the server's current
527
+ * values. Called after pulling from server so the next toe-stepping check
528
+ * sees the pulled state as the new baseline and does not re-raise the conflict.
529
+ *
530
+ * @param {Object} baseline - Loaded baseline object (mutated in place)
531
+ * @param {string} entity - Entity type (e.g., "content")
532
+ * @param {string} uid - Record UID
533
+ * @param {Object} serverEntry - Live server record
534
+ */
535
+ function _updateBaselineEntry(baseline, entity, uid, serverEntry) {
536
+ if (!baseline?.children) return;
537
+ const arr = baseline.children[entity];
538
+ if (!Array.isArray(arr)) return;
539
+ const idx = arr.findIndex(e => e.UID === uid);
540
+ if (idx < 0) return;
541
+
542
+ const SKIP = new Set(['_entity', '_companionReferenceColumns', '_contentColumns',
543
+ '_mediaFile', '_pathConfirmed', 'children', '_id']);
544
+ for (const [col, rawVal] of Object.entries(serverEntry)) {
545
+ if (SKIP.has(col)) continue;
546
+ const decoded = resolveContentValue(rawVal);
547
+ arr[idx][col] = decoded !== null ? decoded : rawVal;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Overwrite local metadata and companion content files with server values.
553
+ *
554
+ * Called when the user chooses "Pull from server" during conflict resolution.
555
+ * Updates:
556
+ * - companion content files (columns listed in _companionReferenceColumns)
557
+ * - all non-system metadata fields
558
+ * - _LastUpdated / _CreatedOn timestamps in the metadata JSON
559
+ * - file timestamps on both the metadata file and any companion files
560
+ *
561
+ * @param {Object} serverEntry - Live server record (from fetchServerRecord*)
562
+ * @param {Object} localMeta - Currently loaded metadata object
563
+ * @param {string} metaPath - Absolute path to the .metadata.json file
564
+ * @param {string} [serverTz] - Server timezone string (e.g. "America/Chicago")
565
+ */
566
+ async function applyServerToLocal(serverEntry, localMeta, metaPath, serverTz) {
567
+ const metaDir = dirname(metaPath);
568
+ const companions = new Set(localMeta._companionReferenceColumns || []);
569
+
570
+ // Write companion content files from server values
571
+ for (const col of companions) {
572
+ const ref = localMeta[col];
573
+ if (!ref || !String(ref).startsWith('@')) continue;
574
+ const filePath = join(metaDir, String(ref).substring(1));
575
+ const serverValue = resolveContentValue(serverEntry[col]);
576
+ if (serverValue !== null) {
577
+ await writeFile(filePath, serverValue, 'utf8');
578
+ try {
579
+ await setFileTimestamps(filePath, serverEntry._CreatedOn, serverEntry._LastUpdated, serverTz);
580
+ } catch { /* non-critical */ }
581
+ }
582
+ }
583
+
584
+ // Merge non-system, non-companion server columns into localMeta
585
+ const skipMeta = new Set(['_entity', '_companionReferenceColumns', '_contentColumns', '_mediaFile',
586
+ '_pathConfirmed', 'children', '_id']);
587
+ for (const [col, rawVal] of Object.entries(serverEntry)) {
588
+ if (skipMeta.has(col)) continue;
589
+ if (companions.has(col)) continue; // companion already handled as a file
590
+ const decoded = resolveContentValue(rawVal);
591
+ localMeta[col] = decoded !== null ? decoded : rawVal;
592
+ }
593
+
594
+ await writeFile(metaPath, JSON.stringify(localMeta, null, 2) + '\n');
595
+ try {
596
+ await setFileTimestamps(metaPath, serverEntry._CreatedOn, serverEntry._LastUpdated, serverTz);
597
+ } catch { /* non-critical */ }
598
+ }