@dboio/cli 0.10.1 → 0.11.2

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.
@@ -12,11 +12,13 @@ import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/m
12
12
  import { resolveTransactionKey } from '../lib/transaction-key.js';
13
13
  import { setFileTimestamps } from '../lib/timestamps.js';
14
14
  import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, buildUidFilename } from '../lib/filenames.js';
15
- import { findMetadataFiles } from '../lib/diff.js';
15
+ import { findMetadataFiles, findFileInProject, findByUID } from '../lib/diff.js';
16
16
  import { loadIgnore } from '../lib/ignore.js';
17
- import { detectChangedColumns, findBaselineEntry, detectOutputChanges, getAllUserColumns, isReference, resolveReferencePath } from '../lib/delta.js';
17
+ import { detectChangedColumns, findBaselineEntry, detectOutputChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
18
18
  import { BINS_DIR, ENTITY_DIR_NAMES, loadStructureFile, findBinByPath } from '../lib/structure.js';
19
19
  import { ensureTrashIcon } from '../lib/folder-icon.js';
20
+ import { checkToeStepping } from '../lib/toe-stepping.js';
21
+ import { runPendingMigrations } from '../lib/migrations.js';
20
22
 
21
23
  /**
22
24
  * Resolve an @reference file path to an absolute filesystem path.
@@ -29,6 +31,15 @@ function resolveAtReference(refFile, metaDir) {
29
31
  }
30
32
  return join(metaDir, refFile);
31
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
+
32
43
  import { ENTITY_DEPENDENCIES } from '../lib/dependencies.js';
33
44
 
34
45
  export const pushCommand = new Command('push')
@@ -40,13 +51,16 @@ export const pushCommand = new Command('push')
40
51
  .option('--meta-only', 'Only push metadata changes, skip file content')
41
52
  .option('--content-only', 'Only push file content, skip metadata columns')
42
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')
43
55
  .option('--row-key <type>', 'Override row key type for this invocation (RowUID or RowID)')
44
56
  .option('--json', 'Output raw JSON')
45
57
  .option('--jq <expr>', 'Filter JSON response')
46
58
  .option('-v, --verbose', 'Show HTTP request details')
47
59
  .option('--domain <host>', 'Override domain')
60
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
48
61
  .action(async (targetPath, options) => {
49
62
  try {
63
+ await runPendingMigrations(options);
50
64
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
51
65
 
52
66
  // ModifyKey guard — check once before any submissions
@@ -63,7 +77,62 @@ export const pushCommand = new Command('push')
63
77
  // Process pending deletions from synchronize.json
64
78
  await processPendingDeletes(client, options, modifyKey, transactionKey);
65
79
 
66
- 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
+ }
67
136
 
68
137
  if (pathStat.isDirectory()) {
69
138
  await pushDirectory(targetPath, client, options, modifyKey, transactionKey);
@@ -214,9 +283,15 @@ async function moveWillDeleteToTrash(entry) {
214
283
  */
215
284
  async function pushSingleFile(filePath, client, options, modifyKey = null, transactionKey = 'RowUID') {
216
285
  // Find the metadata file
217
- const dir = dirname(filePath);
218
- const base = basename(filePath, extname(filePath));
219
- 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
+ }
220
295
 
221
296
  let meta;
222
297
  try {
@@ -226,6 +301,16 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
226
301
  process.exit(1);
227
302
  }
228
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
+
229
314
  await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
230
315
  }
231
316
 
@@ -298,13 +383,6 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
298
383
  const ig = await loadIgnore();
299
384
  const metaFiles = await findMetadataFiles(dirPath, ig);
300
385
 
301
- if (metaFiles.length === 0) {
302
- log.warn(`No .metadata.json files found in "${dirPath}".`);
303
- return;
304
- }
305
-
306
- log.info(`Found ${metaFiles.length} record(s) to push`);
307
-
308
386
  // Load baseline for delta detection
309
387
  const baseline = await loadAppJsonBaseline();
310
388
 
@@ -398,17 +476,51 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
398
476
  toPush.push({ meta, metaPath, changedColumns, isNew: isNewRecord });
399
477
  }
400
478
 
401
- if (toPush.length === 0 && outputCompoundFiles.length === 0) {
402
- 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
+ }
403
512
  return;
404
513
  }
405
514
 
515
+ log.info(`Found ${metaFiles.length} record(s) to push`);
516
+
406
517
  // Pre-flight ticket validation (only if no --ticket flag)
407
- const totalRecords = toPush.length + outputCompoundFiles.length;
518
+ const totalRecords = toPush.length + outputCompoundFiles.length + binPushItems.length;
408
519
  if (!options.ticket && totalRecords > 0) {
409
520
  const recordSummary = [
410
521
  ...toPush.map(r => basename(r.metaPath, '.metadata.json')),
411
522
  ...outputCompoundFiles.map(r => basename(r.metaPath, '.json')),
523
+ ...binPushItems.map(r => `bin:${r.meta.Name}`),
412
524
  ].join(', ');
413
525
  const ticketCheck = await checkStoredTicket(options, `${totalRecords} record(s): ${recordSummary}`);
414
526
  if (ticketCheck.cancel) {
@@ -497,6 +609,28 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
497
609
  }
498
610
  }
499
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
+
500
634
  // Update baseline after successful pushes
501
635
  if (baseline && successfulPushes.length > 0) {
502
636
  await updateBaselineAfterPush(baseline, successfulPushes);
@@ -505,6 +639,140 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
505
639
  log.info(`Push complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped`);
506
640
  }
507
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
+
508
776
  /**
509
777
  * Submit a new record (add) from metadata that has no UID yet.
510
778
  * Builds RowID:add1 expressions, submits, then renames files with the returned ~UID.
@@ -675,40 +943,37 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
675
943
  const contentCols = new Set(meta._contentColumns || []);
676
944
  const metaDir = dirname(metaPath);
677
945
 
678
- // 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.
679
949
  let rowKeyPrefix, rowKeyValue;
680
- if (transactionKey === 'RowID') {
681
- 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`);
682
956
  rowKeyPrefix = 'RowID';
683
957
  rowKeyValue = id;
684
958
  } else {
685
- log.warn(` ⚠ Preset is RowID but no _id found in ${basename(metaPath)} — falling back to RowUID`);
959
+ // RowUID (default)
686
960
  rowKeyPrefix = 'RowUID';
687
961
  rowKeyValue = uid;
688
962
  }
689
963
  } else {
690
- if (uid) {
691
- rowKeyPrefix = 'RowUID';
692
- rowKeyValue = uid;
693
- } else if (id) {
694
- log.warn(` ⚠ Preset is RowUID but no UID found in ${basename(metaPath)} — falling back to RowID`);
695
- rowKeyPrefix = 'RowID';
696
- rowKeyValue = id;
697
- }
698
- }
699
-
700
- if (!rowKeyValue) {
701
- 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;
702
968
  }
703
969
  if (!entity) {
704
970
  throw new Error(`No _entity found in ${metaPath}`);
705
971
  }
706
972
 
707
- // Detect path mismatch (only for content, media, output, bin entities
708
- // entity-dir types use structural directories unrelated to server Path)
709
- if (meta.Path) {
710
- await checkPathMismatch(meta, metaPath, entity, options);
711
- }
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.
712
977
 
713
978
  const dataExprs = [];
714
979
  let metaUpdated = false;
@@ -720,7 +985,10 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
720
985
  if (shouldSkipColumn(key)) continue;
721
986
  if (key === 'UID') continue; // UID is the identifier, not a column to update
722
987
  if (key === 'children') continue; // Output hierarchy structural field, not a server column
723
- 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;
724
992
 
725
993
  // Delta sync: skip columns not in changedColumns
726
994
  if (columnsToProcess && !columnsToProcess.has(key)) continue;
@@ -732,7 +1000,8 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
732
1000
  // --content-only: skip non-content columns
733
1001
  if (options.contentOnly && !isContentCol) continue;
734
1002
 
735
- 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);
736
1005
 
737
1006
  if (strValue.startsWith('@')) {
738
1007
  // @filename reference — resolve to actual file path
@@ -1188,9 +1457,9 @@ async function _submitOutputEntity(entity, physicalEntity, changedColumns, metaD
1188
1457
  if (col === 'UID' || col === 'children') continue;
1189
1458
 
1190
1459
  const val = entity[col];
1191
- if (val === null || val === undefined) continue;
1192
1460
 
1193
- const strValue = String(val);
1461
+ // Null values in changedColumns → send empty string to clear on server
1462
+ const strValue = (val === null || val === undefined) ? '' : String(val);
1194
1463
  if (isReference(strValue)) {
1195
1464
  const refPath = resolveReferencePath(strValue, metaDir);
1196
1465
  dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}@${refPath}`);
@@ -1281,7 +1550,13 @@ async function updateBaselineAfterPush(baseline, successfulPushes) {
1281
1550
 
1282
1551
  for (const col of columnsToUpdate) {
1283
1552
  const value = meta[col];
1284
- 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
+ }
1285
1560
 
1286
1561
  const strValue = String(value);
1287
1562
 
@@ -6,6 +6,7 @@ import { formatError } from '../lib/formatter.js';
6
6
  import { addDeleteEntry, removeAppJsonReference } from '../lib/config.js';
7
7
  import { findMetadataFiles } from '../lib/diff.js';
8
8
  import { loadStructureFile, findBinByPath, findChildBins, BINS_DIR } from '../lib/structure.js';
9
+ import { runPendingMigrations } from '../lib/migrations.js';
9
10
 
10
11
  export const rmCommand = new Command('rm')
11
12
  .description('Remove a file or directory locally and stage server deletions for the next dbo push')
@@ -13,8 +14,10 @@ export const rmCommand = new Command('rm')
13
14
  .option('-f, --force', 'Skip confirmation prompts')
14
15
  .option('--keep-local', 'Only stage server deletion, do not delete local files')
15
16
  .option('--hard', 'Immediately delete local files (no Trash; legacy behavior)')
17
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
16
18
  .action(async (targetPath, options) => {
17
19
  try {
20
+ await runPendingMigrations(options);
18
21
  const pathStat = await stat(targetPath).catch(() => null);
19
22
  if (!pathStat) {
20
23
  log.error(`Path not found: "${targetPath}"`);
@@ -5,10 +5,12 @@ import { access } from 'fs/promises';
5
5
  import { join } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { log } from '../lib/logger.js';
8
+ import { runPendingMigrations, countPendingMigrations } from '../lib/migrations.js';
8
9
 
9
10
  export const statusCommand = new Command('status')
10
11
  .description('Show current DBO CLI configuration and session status')
11
- .action(async () => {
12
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
13
+ .action(async (options) => {
12
14
  try {
13
15
  const initialized = await isInitialized();
14
16
  const config = await loadConfig();
@@ -40,6 +42,15 @@ export const statusCommand = new Command('status')
40
42
  const transactionKeyPreset = await loadTransactionKeyPreset();
41
43
  log.label('Transaction Key', transactionKeyPreset || '(not set — defaults to RowUID)');
42
44
 
45
+ // Run pending migrations (no-op if --no-migrate)
46
+ await runPendingMigrations(options);
47
+
48
+ // Report pending count (after running, so completed ones are excluded)
49
+ const pending = await countPendingMigrations();
50
+ if (pending > 0) {
51
+ log.label('Pending migrations', `${pending} (will run on next command; use --no-migrate to skip)`);
52
+ }
53
+
43
54
  // Display plugin status
44
55
  const scopes = await getAllPluginScopes();
45
56
  const pluginNames = Object.keys(scopes);
@@ -3,11 +3,14 @@ import { log } from '../lib/logger.js';
3
3
  import { loadConfig, loadAppConfig, saveAppJsonBaseline } from '../lib/config.js';
4
4
  import { DboClient } from '../lib/client.js';
5
5
  import { decodeBase64Fields } from './clone.js';
6
+ import { runPendingMigrations } from '../lib/migrations.js';
6
7
 
7
8
  export const syncCommand = new Command('sync')
8
9
  .description('Synchronise local state with the server')
9
10
  .option('--baseline', 'Re-fetch server state and update .dbo/.app_baseline.json (does not modify local files)')
11
+ .option('--no-migrate', 'Skip pending migrations for this invocation')
10
12
  .action(async (options) => {
13
+ await runPendingMigrations(options);
11
14
  if (!options.baseline) {
12
15
  log.warn('No sync mode specified. Use --baseline to reset the baseline file.');
13
16
  process.exit(1);
package/src/lib/client.js CHANGED
@@ -123,6 +123,16 @@ export class DboClient {
123
123
  return this._parseResponse(response);
124
124
  }
125
125
 
126
+ /**
127
+ * Clear the server-side cache. Must be called after POST transactions so that
128
+ * subsequent GET requests (diff, pull, toe-stepping) return fresh data.
129
+ */
130
+ async voidCache() {
131
+ try {
132
+ await this.request('/?voidcache=true');
133
+ } catch { /* best-effort — don't block on failure */ }
134
+ }
135
+
126
136
  /**
127
137
  * Fetch a URL and return the raw response as a Buffer (for binary downloads).
128
138
  */
package/src/lib/config.js CHANGED
@@ -525,6 +525,37 @@ export async function getAllPluginScopes() {
525
525
  return result;
526
526
  }
527
527
 
528
+ // ─── Migration tracking (config.local.json._completedMigrations) ──────────
529
+
530
+ /**
531
+ * Load the list of completed migration IDs from .dbo/config.local.json.
532
+ * Returns an empty array if the file does not exist or the key is absent.
533
+ * @returns {Promise<string[]>}
534
+ */
535
+ export async function loadCompletedMigrations() {
536
+ try {
537
+ const local = await loadLocalConfig();
538
+ const ids = local._completedMigrations;
539
+ return Array.isArray(ids) ? ids : [];
540
+ } catch {
541
+ return [];
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Append a migration ID to .dbo/config.local.json._completedMigrations.
547
+ * Deduplicates: if the ID is already present, no-op.
548
+ * @param {string} id - Three-digit migration ID, e.g. '001'
549
+ */
550
+ export async function saveCompletedMigration(id) {
551
+ const local = await loadLocalConfig();
552
+ const existing = new Set(Array.isArray(local._completedMigrations) ? local._completedMigrations : []);
553
+ if (existing.has(id)) return; // already recorded — idempotent
554
+ existing.add(id);
555
+ local._completedMigrations = [...existing].sort();
556
+ await saveLocalConfig(local);
557
+ }
558
+
528
559
  // ─── Output Hierarchy Filename Preferences ────────────────────────────────
529
560
 
530
561
  /**