@dboio/cli 0.10.1 → 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.
- package/README.md +135 -70
- package/bin/postinstall.js +9 -1
- package/package.json +3 -3
- package/plugins/claude/dbo/commands/dbo.md +3 -3
- package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
- package/src/commands/add.js +4 -0
- package/src/commands/clone.js +249 -399
- package/src/commands/content.js +7 -3
- package/src/commands/deploy.js +22 -7
- package/src/commands/diff.js +41 -3
- package/src/commands/init.js +25 -60
- package/src/commands/input.js +5 -0
- package/src/commands/login.js +2 -2
- package/src/commands/mv.js +3 -0
- package/src/commands/output.js +8 -10
- package/src/commands/pull.js +12 -8
- package/src/commands/push.js +317 -42
- package/src/commands/rm.js +3 -0
- package/src/commands/status.js +12 -1
- package/src/commands/sync.js +3 -0
- package/src/lib/client.js +10 -0
- package/src/lib/config.js +31 -0
- package/src/lib/delta.js +92 -0
- package/src/lib/diff.js +143 -19
- package/src/lib/ignore.js +1 -2
- package/src/lib/metadata-templates.js +21 -4
- package/src/lib/migrations.js +75 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/scaffold.js +3 -28
- package/src/lib/structure.js +158 -23
- package/src/lib/toe-stepping.js +381 -0
- package/src/migrations/001-transaction-key-preset-scope.js +35 -0
- package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
- package/src/migrations/003-move-deploy-config.js +50 -0
- package/src/migrations/004-rename-output-files.js +101 -0
package/src/commands/push.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
|
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
|
-
|
|
681
|
-
|
|
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
|
-
|
|
959
|
+
// RowUID (default)
|
|
686
960
|
rowKeyPrefix = 'RowUID';
|
|
687
961
|
rowKeyValue = uid;
|
|
688
962
|
}
|
|
689
963
|
} else {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
//
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/commands/rm.js
CHANGED
|
@@ -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}"`);
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
-
.
|
|
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);
|
package/src/commands/sync.js
CHANGED
|
@@ -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
|
/**
|