@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.20.6",
3
+ "version": "0.20.8",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbo",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "DBO.io CLI integration for Claude Code",
5
5
  "author": {
6
6
  "name": "DBO.io"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "track",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Changelog and Track API logging for Claude Code — automatically logs changes to changelog.md and the remote Track task_log on every session",
5
5
  "author": {
6
6
  "name": "DBO.io"
@@ -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
- result = await client.get(`/api/app/object/${appShortName}`);
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;
@@ -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, loadAppJsonBaseline } from '../lib/config.js';
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/app/object/ with UpdatedAfter
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?.AppShortName) {
50
- // Find oldest baseline _LastUpdated across all files to diff
51
- const baseline = await loadAppJsonBaseline();
52
- let oldestDate = null;
53
- for (const metaPath of metaFiles) {
54
- try {
55
- const meta = JSON.parse(await readFile(metaPath, 'utf8'));
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
 
@@ -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
- result = await client.get(`/api/app/object/${appShortName}`);
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;
@@ -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, loadRootContentFiles, loadRepositoryIntegrationID } from '../lib/config.js';
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 refFile = String(ref).substring(1);
276
- const willDeleteContent = join(metaDir, `__WILL_DELETE__${refFile}`);
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, refFile) });
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 refFile = String(deletedMeta._mediaFile).substring(1);
285
- const willDeleteMedia = join(metaDir, `__WILL_DELETE__${refFile}`);
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, refFile) });
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?.AppShortName, cfg.ServerTimezone);
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?.AppShortName, cfg.ServerTimezone);
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?.AppShortName, cfg3.ServerTimezone);
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
@@ -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(join(metaDir, String(ref).substring(1)));
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(join(metaDir, String(meta._mediaFile).substring(1)));
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
- return JSON.parse(raw);
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
 
@@ -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
- const dateStr = stored.substring(0, 10); // YYYY-MM-DD from ISO string
221
- const result = await client.get(
222
- `/api/app/object/${encodeURIComponent(shortname)}?UpdatedAfter=${dateStr}`
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;
@@ -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 with UpdatedAfter
86
- * filtering. This is far more efficient than per-record fetches because it
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} appShortName - App short name for the /api/app/object/ endpoint
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, appShortName, updatedAfter) {
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/app/object/${appShortName}`, {
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 appShortName is provided, uses a single bulk fetch via
275
- * GET /api/app/object/{appShortName}?UpdatedAfter={date}
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} [appShortName] - App short name for bulk fetch (optional)
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, appShortName, serverTz) {
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/app/object/ with UpdatedAfter when possible
280
+ // Try bulk fetch via /api/object/{appUid} when possible
308
281
  let serverRecords;
309
- if (appShortName) {
310
- const updatedAfter = findOldestBaselineDate(records, baseline);
311
- if (updatedAfter) {
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 (!appShortName) await client.voidCache();
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
  }