@dboio/cli 0.20.6 → 0.20.8
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/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/track/.claude-plugin/plugin.json +1 -1
- package/src/commands/clone.js +17 -1
- package/src/commands/diff.js +9 -29
- package/src/commands/pull.js +4 -2
- package/src/commands/push.js +15 -122
- package/src/commands/rm.js +3 -2
- package/src/lib/config.js +6 -2
- package/src/lib/dependencies.js +23 -4
- package/src/lib/toe-stepping.js +14 -44
package/package.json
CHANGED
package/src/commands/clone.js
CHANGED
|
@@ -1284,6 +1284,14 @@ export async function performClone(source, options = {}) {
|
|
|
1284
1284
|
// Step 2: Load the app JSON — retry loop with fallback prompt on failure.
|
|
1285
1285
|
// This runs BEFORE schema/dependency sync so that the login prompt fires
|
|
1286
1286
|
// here if the session is expired (not buried inside a silent dependency clone).
|
|
1287
|
+
|
|
1288
|
+
// In pull mode without --force, pass UpdatedAfter from the last successful fetch
|
|
1289
|
+
// so the server only returns records modified since then (delta fetch).
|
|
1290
|
+
if (options.pullMode && !options.force && !options.updatedAfter) {
|
|
1291
|
+
const syncData = await loadSynchronize();
|
|
1292
|
+
if (syncData._LastGet) options.updatedAfter = syncData._LastGet;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1287
1295
|
let activeSource = source;
|
|
1288
1296
|
while (true) {
|
|
1289
1297
|
try {
|
|
@@ -1667,6 +1675,13 @@ export async function performClone(source, options = {}) {
|
|
|
1667
1675
|
// Step 10: Tag project files with sync status (best-effort, non-blocking)
|
|
1668
1676
|
tagProjectFiles({ verbose: false }).catch(() => {});
|
|
1669
1677
|
|
|
1678
|
+
// Record the time of this fetch for delta tracking on subsequent pulls
|
|
1679
|
+
try {
|
|
1680
|
+
const syncData = await loadSynchronize();
|
|
1681
|
+
syncData._LastGet = new Date().toISOString();
|
|
1682
|
+
await saveSynchronize(syncData);
|
|
1683
|
+
} catch { /* non-critical */ }
|
|
1684
|
+
|
|
1670
1685
|
log.plain('');
|
|
1671
1686
|
const verb = options.pullMode ? 'Pull' : 'Clone';
|
|
1672
1687
|
log.success(entityFilter ? `${verb} complete! (filtered: ${options.entity})` : `${verb} complete!`);
|
|
@@ -1752,7 +1767,8 @@ async function fetchAppFromServer(appShortName, options, config) {
|
|
|
1752
1767
|
|
|
1753
1768
|
let result;
|
|
1754
1769
|
try {
|
|
1755
|
-
|
|
1770
|
+
const params = options.updatedAfter ? { UpdatedAfter: options.updatedAfter } : {};
|
|
1771
|
+
result = await client.get(`/api/app/object/${appShortName}`, params);
|
|
1756
1772
|
} catch (err) {
|
|
1757
1773
|
spinner.fail(`Failed to fetch app "${appShortName}"`);
|
|
1758
1774
|
throw err;
|
package/src/commands/diff.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFile, stat } from 'fs/promises';
|
|
|
3
3
|
import { join, dirname, basename, extname } from 'path';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { DboClient } from '../lib/client.js';
|
|
6
|
-
import { loadConfig, loadAppConfig
|
|
6
|
+
import { loadConfig, loadAppConfig } from '../lib/config.js';
|
|
7
7
|
import { formatError } from '../lib/formatter.js';
|
|
8
8
|
import { log } from '../lib/logger.js';
|
|
9
9
|
import {
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
applyServerChanges,
|
|
14
14
|
} from '../lib/diff.js';
|
|
15
15
|
import { fetchServerRecordsBatch } from '../lib/toe-stepping.js';
|
|
16
|
-
import { findBaselineEntry } from '../lib/delta.js';
|
|
17
16
|
import { isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
|
|
18
17
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
19
18
|
|
|
@@ -42,36 +41,17 @@ export const diffCommand = new Command('diff')
|
|
|
42
41
|
|
|
43
42
|
log.info(`Comparing ${metaFiles.length} record(s) against server...`);
|
|
44
43
|
|
|
45
|
-
// Batch-fetch server records via /api/
|
|
44
|
+
// Batch-fetch server records via /api/object/{appUid}
|
|
46
45
|
const ora = (await import('ora')).default;
|
|
47
46
|
let serverRecordsMap = new Map();
|
|
48
47
|
const appConfig = await loadAppConfig();
|
|
49
|
-
if (appConfig?.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const uid = meta.UID || meta._id;
|
|
57
|
-
const entity = meta._entity;
|
|
58
|
-
if (!uid || !entity || !baseline) continue;
|
|
59
|
-
const entry = findBaselineEntry(baseline, entity, uid);
|
|
60
|
-
if (!entry?._LastUpdated) continue;
|
|
61
|
-
const d = new Date(entry._LastUpdated);
|
|
62
|
-
if (!isNaN(d) && (!oldestDate || d < oldestDate)) oldestDate = d;
|
|
63
|
-
} catch { /* skip unreadable */ }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (oldestDate) {
|
|
67
|
-
const updatedAfter = oldestDate.toISOString();
|
|
68
|
-
const spinner = ora('Fetching server state for comparison...').start();
|
|
69
|
-
serverRecordsMap = await fetchServerRecordsBatch(client, appConfig.AppShortName, updatedAfter);
|
|
70
|
-
if (serverRecordsMap.size > 0) {
|
|
71
|
-
spinner.succeed(`Fetched ${serverRecordsMap.size} record(s) from server`);
|
|
72
|
-
} else {
|
|
73
|
-
spinner.warn('No server records returned');
|
|
74
|
-
}
|
|
48
|
+
if (appConfig?.AppUID) {
|
|
49
|
+
const spinner = ora('Fetching server state for comparison...').start();
|
|
50
|
+
serverRecordsMap = await fetchServerRecordsBatch(client, appConfig.AppUID);
|
|
51
|
+
if (serverRecordsMap.size > 0) {
|
|
52
|
+
spinner.succeed(`Fetched ${serverRecordsMap.size} record(s) from server`);
|
|
53
|
+
} else {
|
|
54
|
+
spinner.warn('No server records returned');
|
|
75
55
|
}
|
|
76
56
|
}
|
|
77
57
|
|
package/src/commands/pull.js
CHANGED
|
@@ -3,7 +3,7 @@ import { access } from 'fs/promises';
|
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { performClone, resolveRecordPaths, resolveMediaPaths, resolveEntityDirPaths, resolveEntityFilter, buildOutputFilename } from './clone.js';
|
|
6
|
-
import { loadConfig, loadClonePlacement } from '../lib/config.js';
|
|
6
|
+
import { loadConfig, loadClonePlacement, loadSynchronize } from '../lib/config.js';
|
|
7
7
|
import { loadStructureFile, resolveBinPath, BINS_DIR, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, resolveEntityDirPath } from '../lib/structure.js';
|
|
8
8
|
import { log } from '../lib/logger.js';
|
|
9
9
|
import { formatError } from '../lib/formatter.js';
|
|
@@ -31,7 +31,9 @@ async function fetchAppJson(config, options) {
|
|
|
31
31
|
|
|
32
32
|
let result;
|
|
33
33
|
try {
|
|
34
|
-
|
|
34
|
+
const syncData = await loadSynchronize();
|
|
35
|
+
const params = (!options.force && syncData._LastGet) ? { UpdatedAfter: syncData._LastGet } : {};
|
|
36
|
+
result = await client.get(`/api/app/object/${appShortName}`, params);
|
|
35
37
|
} catch (err) {
|
|
36
38
|
spinner.fail(`Failed to fetch app "${appShortName}"`);
|
|
37
39
|
throw err;
|
package/src/commands/push.js
CHANGED
|
@@ -6,7 +6,7 @@ import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../li
|
|
|
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, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline, loadScripts, loadScriptsLocal, addDeleteEntry,
|
|
9
|
+
import { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline, loadScripts, loadScriptsLocal, addDeleteEntry, loadRepositoryIntegrationID } from '../lib/config.js';
|
|
10
10
|
import { mergeScriptsConfig, resolveHooks, buildHookEnv, runBuildLifecycle, runPushLifecycle } from '../lib/scripts.js';
|
|
11
11
|
import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket, fetchAndCacheRepositoryIntegration } from '../lib/ticketing.js';
|
|
12
12
|
import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
|
|
@@ -150,6 +150,11 @@ export const pushCommand = new Command('push')
|
|
|
150
150
|
} else {
|
|
151
151
|
await pushSingleFile(targetPath, client, options, modifyKey, transactionKey);
|
|
152
152
|
}
|
|
153
|
+
|
|
154
|
+
// Record the time of this push attempt for toe-stepping and delta tracking
|
|
155
|
+
const syncData = await loadSynchronize();
|
|
156
|
+
syncData._LastPost = new Date().toISOString();
|
|
157
|
+
await saveSynchronize(syncData);
|
|
153
158
|
} catch (err) {
|
|
154
159
|
formatError(err);
|
|
155
160
|
process.exit(1);
|
|
@@ -272,20 +277,20 @@ async function moveWillDeleteToTrash(entry) {
|
|
|
272
277
|
for (const col of (deletedMeta._companionReferenceColumns || deletedMeta._contentColumns || [])) {
|
|
273
278
|
const ref = deletedMeta[col];
|
|
274
279
|
if (ref && String(ref).startsWith('@')) {
|
|
275
|
-
const
|
|
276
|
-
const willDeleteContent = join(
|
|
280
|
+
const companionAbs = resolveReferencePath(String(ref), join(process.cwd(), metaDir));
|
|
281
|
+
const willDeleteContent = join(dirname(companionAbs), `__WILL_DELETE__${basename(companionAbs)}`);
|
|
277
282
|
try {
|
|
278
283
|
await stat(willDeleteContent);
|
|
279
|
-
filesToMove.push({ from: willDeleteContent, to: join(trashDir,
|
|
284
|
+
filesToMove.push({ from: willDeleteContent, to: join(trashDir, basename(companionAbs)) });
|
|
280
285
|
} catch {}
|
|
281
286
|
}
|
|
282
287
|
}
|
|
283
288
|
if (deletedMeta._mediaFile && String(deletedMeta._mediaFile).startsWith('@')) {
|
|
284
|
-
const
|
|
285
|
-
const willDeleteMedia = join(
|
|
289
|
+
const mediaAbs = resolveReferencePath(String(deletedMeta._mediaFile), join(process.cwd(), metaDir));
|
|
290
|
+
const willDeleteMedia = join(dirname(mediaAbs), `__WILL_DELETE__${basename(mediaAbs)}`);
|
|
286
291
|
try {
|
|
287
292
|
await stat(willDeleteMedia);
|
|
288
|
-
filesToMove.push({ from: willDeleteMedia, to: join(trashDir,
|
|
293
|
+
filesToMove.push({ from: willDeleteMedia, to: join(trashDir, basename(mediaAbs)) });
|
|
289
294
|
} catch {}
|
|
290
295
|
}
|
|
291
296
|
} catch {
|
|
@@ -386,7 +391,7 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
|
|
|
386
391
|
if (baseline) {
|
|
387
392
|
const appConfig = await loadAppConfig();
|
|
388
393
|
const cfg = await loadConfig();
|
|
389
|
-
const result = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.
|
|
394
|
+
const result = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppUID, cfg.ServerTimezone);
|
|
390
395
|
if (result === false || result instanceof Set) return;
|
|
391
396
|
}
|
|
392
397
|
}
|
|
@@ -441,115 +446,6 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
|
|
|
441
446
|
}
|
|
442
447
|
return success;
|
|
443
448
|
}
|
|
444
|
-
/**
|
|
445
|
-
* Metadata templates for known root content files.
|
|
446
|
-
* Keyed by filename (case-sensitive). Unknown files in rootContentFiles
|
|
447
|
-
* get a sensible derived template (extension from name, Public: 0).
|
|
448
|
-
*/
|
|
449
|
-
const ROOT_FILE_TEMPLATES = {
|
|
450
|
-
'manifest.json': {
|
|
451
|
-
_entity: 'content',
|
|
452
|
-
_companionReferenceColumns: ['Content'],
|
|
453
|
-
Extension: 'JSON',
|
|
454
|
-
Public: 1,
|
|
455
|
-
Active: 1,
|
|
456
|
-
Title: 'PWA Manifest',
|
|
457
|
-
},
|
|
458
|
-
'CLAUDE.md': {
|
|
459
|
-
_entity: 'content',
|
|
460
|
-
_companionReferenceColumns: ['Content'],
|
|
461
|
-
Extension: 'MD',
|
|
462
|
-
Public: 0,
|
|
463
|
-
Active: 1,
|
|
464
|
-
Title: 'Claude Code Instructions',
|
|
465
|
-
},
|
|
466
|
-
'README.md': {
|
|
467
|
-
_entity: 'content',
|
|
468
|
-
_companionReferenceColumns: ['Content'],
|
|
469
|
-
Extension: 'MD',
|
|
470
|
-
Public: 1,
|
|
471
|
-
Active: 1,
|
|
472
|
-
Title: 'README',
|
|
473
|
-
},
|
|
474
|
-
};
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* For each filename in rootContentFiles, ensure a companion metadata file
|
|
478
|
-
* exists in lib/bins/app/. Skips if the file is absent at root or if metadata
|
|
479
|
-
* already tracks it. Creates metadata with Content: "@/<filename>" so push
|
|
480
|
-
* always reads from the project root.
|
|
481
|
-
*/
|
|
482
|
-
async function ensureRootContentFiles() {
|
|
483
|
-
const rootFiles = await loadRootContentFiles();
|
|
484
|
-
if (!rootFiles.length) return;
|
|
485
|
-
|
|
486
|
-
const ig = await loadIgnore();
|
|
487
|
-
const allMeta = await findMetadataFiles(process.cwd(), ig);
|
|
488
|
-
|
|
489
|
-
// Build a set of already-tracked filenames from existing metadata (lowercase for case-insensitive match).
|
|
490
|
-
// Also strip stale fields (e.g. Descriptor) from content-entity root file metadata.
|
|
491
|
-
const tracked = new Set();
|
|
492
|
-
for (const metaPath of allMeta) {
|
|
493
|
-
try {
|
|
494
|
-
const raw = await readFile(metaPath, 'utf8');
|
|
495
|
-
const parsed = JSON.parse(raw);
|
|
496
|
-
const content = parsed.Content || '';
|
|
497
|
-
const path = parsed.Path || '';
|
|
498
|
-
// Detect both @/filename (root-relative) and @filename (local) references
|
|
499
|
-
const refName = content.startsWith('@/') ? content.slice(2)
|
|
500
|
-
: content.startsWith('@') ? content.slice(1)
|
|
501
|
-
: null;
|
|
502
|
-
if (refName) tracked.add(refName.toLowerCase());
|
|
503
|
-
if (path) tracked.add(path.replace(/^\//, '').toLowerCase());
|
|
504
|
-
|
|
505
|
-
// Clean up stale Descriptor field: content entities never have Descriptor.
|
|
506
|
-
// This fixes metadata written by an earlier buggy version of the tool.
|
|
507
|
-
if (parsed._entity === 'content' && parsed.Descriptor !== undefined) {
|
|
508
|
-
const cleaned = { ...parsed };
|
|
509
|
-
delete cleaned.Descriptor;
|
|
510
|
-
await writeFile(metaPath, JSON.stringify(cleaned, null, 2) + '\n');
|
|
511
|
-
log.dim(` Removed stale Descriptor field from ${basename(metaPath)}`);
|
|
512
|
-
}
|
|
513
|
-
} catch { /* skip unreadable */ }
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const appConfig = await loadAppConfig();
|
|
517
|
-
const structure = await loadStructureFile();
|
|
518
|
-
const appBin = findBinByPath('app', structure);
|
|
519
|
-
const binsAppDir = join(process.cwd(), BINS_DIR, 'app');
|
|
520
|
-
|
|
521
|
-
for (const filename of rootFiles) {
|
|
522
|
-
// Skip if file doesn't exist at project root
|
|
523
|
-
try { await access(join(process.cwd(), filename)); } catch { continue; }
|
|
524
|
-
// Skip if already tracked by existing metadata (case-insensitive)
|
|
525
|
-
if (tracked.has(filename.toLowerCase())) continue;
|
|
526
|
-
|
|
527
|
-
const tmplKey = Object.keys(ROOT_FILE_TEMPLATES).find(k => k.toLowerCase() === filename.toLowerCase());
|
|
528
|
-
const tmpl = (tmplKey ? ROOT_FILE_TEMPLATES[tmplKey] : null) || {
|
|
529
|
-
_entity: 'content',
|
|
530
|
-
_companionReferenceColumns: ['Content'],
|
|
531
|
-
Extension: (filename.includes('.') ? filename.split('.').pop().toUpperCase() : 'TXT'),
|
|
532
|
-
Public: 0,
|
|
533
|
-
Active: 1,
|
|
534
|
-
Title: filename,
|
|
535
|
-
};
|
|
536
|
-
|
|
537
|
-
const meta = {
|
|
538
|
-
...tmpl,
|
|
539
|
-
Content: `@/${filename}`,
|
|
540
|
-
Path: filename,
|
|
541
|
-
Name: filename,
|
|
542
|
-
};
|
|
543
|
-
if (appBin) meta.BinID = appBin.binId;
|
|
544
|
-
if (appConfig.AppID) meta.AppID = appConfig.AppID;
|
|
545
|
-
|
|
546
|
-
await mkdir(binsAppDir, { recursive: true });
|
|
547
|
-
const stem = filename.replace(/\.[^.]+$/, '');
|
|
548
|
-
const metaFilename = `${stem}.metadata.json`;
|
|
549
|
-
await writeFile(join(binsAppDir, metaFilename), JSON.stringify(meta, null, 2) + '\n');
|
|
550
|
-
log.info(`Auto-created ${metaFilename} for ${filename}`);
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
449
|
|
|
554
450
|
/**
|
|
555
451
|
* Collect all non-metadata file absolute paths from a directory (recursive).
|
|
@@ -611,9 +507,6 @@ async function findCrossDirectoryMetadata(dirPath, ig) {
|
|
|
611
507
|
* Push all records found in a directory (recursive)
|
|
612
508
|
*/
|
|
613
509
|
async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID', deletedCount = 0) {
|
|
614
|
-
// Auto-create metadata for root content files (manifest.json, CLAUDE.md, README.md, etc.)
|
|
615
|
-
await ensureRootContentFiles();
|
|
616
|
-
|
|
617
510
|
// AUTO-ADD DISABLED: local-file-first approach is being replaced by server-first workflow.
|
|
618
511
|
// New records should be created via the DBO REST API first, then `dbo pull` to populate locally.
|
|
619
512
|
// The auto-add code (findUnaddedFiles + detectBinFile + submitAdd) has been commented out.
|
|
@@ -815,7 +708,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
815
708
|
if (toCheck.length > 0) {
|
|
816
709
|
const appConfig = await loadAppConfig();
|
|
817
710
|
const cfg = await loadConfig();
|
|
818
|
-
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.
|
|
711
|
+
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppUID, cfg.ServerTimezone);
|
|
819
712
|
if (result === false) return; // user cancelled entirely
|
|
820
713
|
if (result instanceof Set) {
|
|
821
714
|
// Filter out skipped UIDs
|
|
@@ -1162,7 +1055,7 @@ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKe
|
|
|
1162
1055
|
if (toCheck.length > 0) {
|
|
1163
1056
|
const appConfig = await loadAppConfig();
|
|
1164
1057
|
const cfg3 = await loadConfig();
|
|
1165
|
-
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.
|
|
1058
|
+
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppUID, cfg3.ServerTimezone);
|
|
1166
1059
|
if (result === false) return; // user cancelled entirely
|
|
1167
1060
|
if (result instanceof Set) {
|
|
1168
1061
|
// Filter out skipped UIDs from matches
|
package/src/commands/rm.js
CHANGED
|
@@ -9,6 +9,7 @@ import { isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../
|
|
|
9
9
|
import { loadStructureFile, findBinByPath, findChildBins, BINS_DIR } from '../lib/structure.js';
|
|
10
10
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
11
11
|
import { removeDeployEntry } from '../lib/deploy-config.js';
|
|
12
|
+
import { resolveReferencePath } from '../lib/delta.js';
|
|
12
13
|
|
|
13
14
|
export const rmCommand = new Command('rm')
|
|
14
15
|
.description('Remove a file or directory locally and stage server deletions for the next dbo push')
|
|
@@ -94,12 +95,12 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
|
|
|
94
95
|
for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
|
|
95
96
|
const ref = meta[col];
|
|
96
97
|
if (ref && String(ref).startsWith('@')) {
|
|
97
|
-
localFiles.push(
|
|
98
|
+
localFiles.push(resolveReferencePath(String(ref), join(process.cwd(), metaDir)));
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
102
|
-
localFiles.push(
|
|
103
|
+
localFiles.push(resolveReferencePath(String(meta._mediaFile), join(process.cwd(), metaDir)));
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
const displayName = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
package/src/lib/config.js
CHANGED
|
@@ -456,9 +456,13 @@ export async function loadUserProfile() {
|
|
|
456
456
|
export async function loadSynchronize() {
|
|
457
457
|
try {
|
|
458
458
|
const raw = await readFile(synchronizePath(), 'utf8');
|
|
459
|
-
|
|
459
|
+
const data = JSON.parse(raw);
|
|
460
|
+
// Ensure timestamp fields are present for older files
|
|
461
|
+
if (!('_LastGet' in data)) data._LastGet = null;
|
|
462
|
+
if (!('_LastPost' in data)) data._LastPost = null;
|
|
463
|
+
return data;
|
|
460
464
|
} catch {
|
|
461
|
-
return { delete: [], edit: [], add: [] };
|
|
465
|
+
return { delete: [], edit: [], add: [], _LastGet: null, _LastPost: null };
|
|
462
466
|
}
|
|
463
467
|
}
|
|
464
468
|
|
package/src/lib/dependencies.js
CHANGED
|
@@ -208,6 +208,11 @@ export function execDboInDir(dir, args, options = {}) {
|
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
210
|
* Returns true if the dependency needs a fresh clone.
|
|
211
|
+
*
|
|
212
|
+
* Uses the direct /api/object/{appUid} endpoint when the dependency checkout
|
|
213
|
+
* already has a .app/config.json with AppUID (faster, no UpdatedAfter param
|
|
214
|
+
* needed). Falls back to /api/app/object/{shortname}?UpdatedAfter={date} when
|
|
215
|
+
* the checkout hasn't been cloned yet or the config is unreadable.
|
|
211
216
|
*/
|
|
212
217
|
export async function checkDependencyStaleness(shortname, options = {}) {
|
|
213
218
|
const stored = await getDependencyLastUpdated(shortname);
|
|
@@ -217,10 +222,24 @@ export async function checkDependencyStaleness(shortname, options = {}) {
|
|
|
217
222
|
const effectiveDomain = options.domain || domain;
|
|
218
223
|
const client = new DboClient({ domain: effectiveDomain, verbose: options.verbose });
|
|
219
224
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
225
|
+
// Try to read AppUID from the dependency's own .app/config.json
|
|
226
|
+
let appUid = null;
|
|
227
|
+
try {
|
|
228
|
+
const depConfigPath = join(process.cwd(), 'app_dependencies', shortname, '.app', 'config.json');
|
|
229
|
+
const raw = await readFile(depConfigPath, 'utf8');
|
|
230
|
+
appUid = JSON.parse(raw).AppUID || null;
|
|
231
|
+
} catch { /* checkout missing or config unreadable — fall back */ }
|
|
232
|
+
|
|
233
|
+
let result;
|
|
234
|
+
if (appUid) {
|
|
235
|
+
result = await client.get(`/api/object/${appUid}`);
|
|
236
|
+
} else {
|
|
237
|
+
const dateStr = stored.substring(0, 10); // YYYY-MM-DD from ISO string
|
|
238
|
+
result = await client.get(
|
|
239
|
+
`/api/app/object/${encodeURIComponent(shortname)}?UpdatedAfter=${dateStr}`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
224
243
|
if (!result.ok || !result.data) return false; // Can't determine — assume fresh
|
|
225
244
|
|
|
226
245
|
const serverTs = result.data._LastUpdated;
|
package/src/lib/toe-stepping.js
CHANGED
|
@@ -82,29 +82,24 @@ export async function fetchServerRecords(client, requests) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* Fetch server records in bulk using the app object endpoint
|
|
86
|
-
*
|
|
87
|
-
* makes a single HTTP request and the server only returns records modified
|
|
88
|
-
* after the given date.
|
|
85
|
+
* Fetch server records in bulk using the direct app object endpoint by UID.
|
|
86
|
+
* Makes a single HTTP request and returns all records for the app.
|
|
89
87
|
*
|
|
90
88
|
* The response is used ONLY for comparison — it must NOT replace the metadata
|
|
91
89
|
* or baseline files.
|
|
92
90
|
*
|
|
93
91
|
* @param {DboClient} client
|
|
94
|
-
* @param {string}
|
|
95
|
-
* @param {string} updatedAfter - ISO date string (oldest _LastUpdated among records to push)
|
|
92
|
+
* @param {string} appUid - App UID for the /api/object/ endpoint
|
|
96
93
|
* @returns {Promise<Map<string, Object>>} - Map of uid → server record (across all entities)
|
|
97
94
|
*/
|
|
98
|
-
export async function fetchServerRecordsBatch(client,
|
|
95
|
+
export async function fetchServerRecordsBatch(client, appUid) {
|
|
99
96
|
const map = new Map();
|
|
100
97
|
|
|
101
98
|
try {
|
|
102
99
|
// Void cache first so the app object response reflects the latest server state
|
|
103
100
|
await client.voidCache();
|
|
104
101
|
|
|
105
|
-
const result = await client.get(`/api/
|
|
106
|
-
UpdatedAfter: updatedAfter,
|
|
107
|
-
});
|
|
102
|
+
const result = await client.get(`/api/object/${appUid}`);
|
|
108
103
|
|
|
109
104
|
if (!result.ok && !result.successful) return map;
|
|
110
105
|
|
|
@@ -245,34 +240,12 @@ export function displayConflict(label, serverUser, serverTimestamp, diffColumns)
|
|
|
245
240
|
}
|
|
246
241
|
}
|
|
247
242
|
|
|
248
|
-
/**
|
|
249
|
-
* Find the oldest _LastUpdated date among the baseline entries for the
|
|
250
|
-
* records about to be pushed. This is used as the UpdatedAfter parameter
|
|
251
|
-
* to limit the app object response to only recently modified records.
|
|
252
|
-
*
|
|
253
|
-
* @param {Array<{ meta: Object }>} records
|
|
254
|
-
* @param {Object} baseline
|
|
255
|
-
* @returns {string|null} - ISO date string or null if no baseline dates found
|
|
256
|
-
*/
|
|
257
|
-
function findOldestBaselineDate(records, baseline) {
|
|
258
|
-
let oldest = null;
|
|
259
|
-
for (const { meta } of records) {
|
|
260
|
-
if (!meta.UID || !meta._entity) continue;
|
|
261
|
-
const entry = findBaselineEntry(baseline, meta._entity, meta.UID);
|
|
262
|
-
if (!entry?._LastUpdated) continue;
|
|
263
|
-
const d = new Date(entry._LastUpdated);
|
|
264
|
-
if (isNaN(d)) continue;
|
|
265
|
-
if (!oldest || d < oldest) oldest = d;
|
|
266
|
-
}
|
|
267
|
-
return oldest ? oldest.toISOString() : null;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
243
|
/**
|
|
271
244
|
* Main toe-stepping check. Compares each record being pushed against the
|
|
272
245
|
* live server state.
|
|
273
246
|
*
|
|
274
|
-
* When
|
|
275
|
-
* GET /api/
|
|
247
|
+
* When appUid is provided, uses a single bulk fetch via
|
|
248
|
+
* GET /api/object/{appUid}
|
|
276
249
|
* which is far more efficient than per-record fetches. Falls back to
|
|
277
250
|
* per-record GET /api/o/e/{entity}/{uid} when the bulk endpoint is
|
|
278
251
|
* unavailable or returns no data.
|
|
@@ -285,12 +258,12 @@ function findOldestBaselineDate(records, baseline) {
|
|
|
285
258
|
* @param {DboClient} client
|
|
286
259
|
* @param {Object} baseline - Loaded baseline from .app/<shortName>.json (baseline)
|
|
287
260
|
* @param {Object} options - Commander options (options.yes used for auto-accept)
|
|
288
|
-
* @param {string} [
|
|
261
|
+
* @param {string} [appUid] - App UID for bulk fetch via /api/object/ (optional)
|
|
289
262
|
* @param {string} [serverTz] - Server timezone from config (e.g. "America/Chicago")
|
|
290
263
|
* @returns {Promise<boolean|Set<string>>} - true = proceed with all,
|
|
291
264
|
* false = user cancelled entirely, Set<string> = UIDs to skip (proceed with rest)
|
|
292
265
|
*/
|
|
293
|
-
export async function checkToeStepping(records, client, baseline, options,
|
|
266
|
+
export async function checkToeStepping(records, client, baseline, options, appUid, serverTz) {
|
|
294
267
|
// Build list of records to check (skip new records without UID)
|
|
295
268
|
const requests = [];
|
|
296
269
|
for (const { meta } of records) {
|
|
@@ -304,20 +277,17 @@ export async function checkToeStepping(records, client, baseline, options, appSh
|
|
|
304
277
|
const ora = (await import('ora')).default;
|
|
305
278
|
const spinner = ora(`Checking ${requests.length} record(s) for server conflicts...`).start();
|
|
306
279
|
|
|
307
|
-
// Try bulk fetch via /api/
|
|
280
|
+
// Try bulk fetch via /api/object/{appUid} when possible
|
|
308
281
|
let serverRecords;
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
spinner.text = `Fetching server state (UpdatedAfter: ${updatedAfter})...`;
|
|
313
|
-
serverRecords = await fetchServerRecordsBatch(client, appShortName, updatedAfter);
|
|
314
|
-
}
|
|
282
|
+
if (appUid) {
|
|
283
|
+
spinner.text = `Fetching server state via /api/object/${appUid}...`;
|
|
284
|
+
serverRecords = await fetchServerRecordsBatch(client, appUid);
|
|
315
285
|
}
|
|
316
286
|
|
|
317
287
|
// Fall back to per-record fetches if batch returned nothing or wasn't available
|
|
318
288
|
if (!serverRecords || serverRecords.size === 0) {
|
|
319
289
|
// Void cache if batch path was skipped (batch already calls voidCache internally)
|
|
320
|
-
if (!
|
|
290
|
+
if (!appUid) await client.voidCache();
|
|
321
291
|
spinner.text = `Fetching ${requests.length} record(s) from server...`;
|
|
322
292
|
serverRecords = await fetchServerRecords(client, requests);
|
|
323
293
|
}
|