@dboio/cli 0.16.2 → 0.19.0

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.
Files changed (45) hide show
  1. package/README.md +175 -138
  2. package/bin/dbo.js +2 -2
  3. package/package.json +1 -1
  4. package/plugins/claude/dbo/docs/dbo-cli-readme.md +175 -138
  5. package/src/commands/adopt.js +534 -0
  6. package/src/commands/build.js +3 -3
  7. package/src/commands/clone.js +209 -75
  8. package/src/commands/deploy.js +3 -3
  9. package/src/commands/init.js +11 -11
  10. package/src/commands/install.js +3 -3
  11. package/src/commands/login.js +2 -2
  12. package/src/commands/mv.js +15 -15
  13. package/src/commands/pull.js +1 -1
  14. package/src/commands/push.js +194 -15
  15. package/src/commands/rm.js +2 -2
  16. package/src/commands/run.js +4 -4
  17. package/src/commands/status.js +1 -1
  18. package/src/commands/sync.js +2 -2
  19. package/src/lib/config.js +186 -135
  20. package/src/lib/delta.js +119 -17
  21. package/src/lib/dependencies.js +51 -24
  22. package/src/lib/deploy-config.js +4 -4
  23. package/src/lib/domain-guard.js +8 -9
  24. package/src/lib/filenames.js +13 -2
  25. package/src/lib/ignore.js +2 -3
  26. package/src/{commands/add.js → lib/insert.js} +127 -472
  27. package/src/lib/metadata-schema.js +14 -20
  28. package/src/lib/metadata-templates.js +4 -4
  29. package/src/lib/migrations.js +1 -1
  30. package/src/lib/modify-key.js +1 -1
  31. package/src/lib/scaffold.js +5 -12
  32. package/src/lib/schema.js +67 -37
  33. package/src/lib/structure.js +6 -6
  34. package/src/lib/tagging.js +2 -2
  35. package/src/lib/ticketing.js +3 -7
  36. package/src/lib/toe-stepping.js +5 -5
  37. package/src/lib/transaction-key.js +1 -1
  38. package/src/migrations/004-rename-output-files.js +2 -2
  39. package/src/migrations/005-rename-output-metadata.js +2 -2
  40. package/src/migrations/006-remove-uid-companion-filenames.js +1 -1
  41. package/src/migrations/007-natural-entity-companion-filenames.js +1 -1
  42. package/src/migrations/008-metadata-uid-in-suffix.js +1 -1
  43. package/src/migrations/009-fix-media-collision-metadata-names.js +1 -1
  44. package/src/migrations/010-delete-paren-media-orphans.js +1 -1
  45. package/src/migrations/012-project-dir-restructure.js +211 -0
@@ -9,7 +9,7 @@ import { log } from '../lib/logger.js';
9
9
  import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
10
10
  import { performLogin } from './login.js';
11
11
  import { runPendingMigrations } from '../lib/migrations.js';
12
- import { fetchSchema, saveSchema, SCHEMA_FILE } from '../lib/schema.js';
12
+ import { fetchSchema, saveSchema } from '../lib/schema.js';
13
13
  import { loadMetadataSchema, saveMetadataSchema, generateMetadataFromSchema } from '../lib/metadata-schema.js';
14
14
  import { syncDependencies } from '../lib/dependencies.js';
15
15
  import { mergeDependencies } from '../lib/config.js';
@@ -108,7 +108,7 @@ export const initCommand = new Command('init')
108
108
  }
109
109
 
110
110
  // Ensure sensitive files are gitignored
111
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', '.dbo/scripts.local.json', '.dbo/errors.log', 'trash/', 'Icon\\r', 'schema.json', '.dbo/dependencies/']);
111
+ await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json', '.app/scripts.local.json', '.app/errors.log', 'trash/', 'Icon\\r', 'app_dependencies/']);
112
112
 
113
113
  const createdIgnore = await createDboignore();
114
114
  if (createdIgnore) log.dim(' Created .dboignore');
@@ -122,19 +122,19 @@ export const initCommand = new Command('init')
122
122
 
123
123
  // Create empty scripts.json and scripts.local.json if they don't exist
124
124
  const emptyScripts = JSON.stringify({ scripts: {}, targets: {}, entities: {} }, null, 2) + '\n';
125
- const dboDir = join(process.cwd(), '.dbo');
126
- const scriptsPath = join(dboDir, 'scripts.json');
127
- const scriptsLocalPath = join(dboDir, 'scripts.local.json');
125
+ const appDir = join(process.cwd(), '.app');
126
+ const scriptsPath = join(appDir, 'scripts.json');
127
+ const scriptsLocalPath = join(appDir, 'scripts.local.json');
128
128
  try { await access(scriptsPath); } catch {
129
129
  await writeFile(scriptsPath, emptyScripts);
130
- log.dim(' Created .dbo/scripts.json');
130
+ log.dim(' Created .app/scripts.json');
131
131
  }
132
132
  try { await access(scriptsLocalPath); } catch {
133
133
  await writeFile(scriptsLocalPath, emptyScripts);
134
- log.dim(' Created .dbo/scripts.local.json');
134
+ log.dim(' Created .app/scripts.local.json');
135
135
  }
136
136
 
137
- log.success(`Initialized .dbo/ for ${domain}`);
137
+ log.success(`Initialized .app/ for ${domain}`);
138
138
 
139
139
  // Authenticate early so the session is ready for subsequent operations
140
140
  if (!options.nonInteractive && username) {
@@ -145,12 +145,12 @@ export const initCommand = new Command('init')
145
145
  try {
146
146
  const schemaData = await fetchSchema({ domain, verbose: options.verbose });
147
147
  await saveSchema(schemaData);
148
- log.dim(` Saved ${SCHEMA_FILE}`);
148
+ log.dim(` Refreshed _system dependency baseline`);
149
149
 
150
150
  const existing = await loadMetadataSchema();
151
151
  const updated = generateMetadataFromSchema(schemaData, existing ?? {});
152
152
  await saveMetadataSchema(updated);
153
- log.dim(` Updated .dbo/metadata_schema.json`);
153
+ log.dim(` Updated metadata schema`);
154
154
  } catch (err) {
155
155
  log.warn(` Could not fetch schema (${err.message}) — run 'dbo clone --schema' after login.`);
156
156
  }
@@ -168,7 +168,7 @@ export const initCommand = new Command('init')
168
168
  domain,
169
169
  force: explicitDeps ? true : undefined,
170
170
  verbose: options.verbose,
171
- systemSchemaPath: join(process.cwd(), SCHEMA_FILE),
171
+ systemSchemaPath: join(process.cwd(), 'app_dependencies', '_system', '.app', '_system.json'),
172
172
  only: explicitDeps || undefined,
173
173
  });
174
174
  } catch (err) {
@@ -248,7 +248,7 @@ async function resolvePluginScope(pluginName, options) {
248
248
  if (storedScope) return storedScope;
249
249
 
250
250
  // Infer from existing installation — avoids re-prompting on re-installs
251
- // (e.g. postinstall after npm install when .dbo/ isn't in cwd)
251
+ // (e.g. postinstall after npm install when .app/ isn't in cwd)
252
252
  const registry = await readPluginRegistry();
253
253
  const key = `${pluginName}@${PLUGIN_MARKETPLACE}`;
254
254
  if (registry.plugins[key]) return 'global';
@@ -735,7 +735,7 @@ export async function installOrUpdateClaudeCommands(options = {}) {
735
735
  version: pluginVersion,
736
736
  });
737
737
  } else if (targetScope === 'global' && !hasProject) {
738
- log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
738
+ log.warn(`Cannot persist scope preference (no .app/ directory). Run "dbo init" first.`);
739
739
  }
740
740
 
741
741
  // Clean up legacy command files (but not ones we just extracted from commands/)
@@ -933,7 +933,7 @@ async function installOrUpdateSpecificCommand(name, options = {}) {
933
933
  version: pluginVersion,
934
934
  });
935
935
  } else if (targetScope === 'global' && !hasProject) {
936
- log.warn(`Cannot persist scope preference (no .dbo/ directory). Run "dbo init" first.`);
936
+ log.warn(`Cannot persist scope preference (no .app/ directory). Run "dbo init" first.`);
937
937
  }
938
938
 
939
939
  // Clean up legacy command files (but not ones we just extracted from commands/)
@@ -49,7 +49,7 @@ export async function performLogin(domain, knownUsername) {
49
49
  }
50
50
 
51
51
  log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
52
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/ticketing.local.json']);
52
+ await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json']);
53
53
 
54
54
  // Fetch and store user info (non-critical)
55
55
  try {
@@ -143,7 +143,7 @@ export const loginCommand = new Command('login')
143
143
  log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
144
144
 
145
145
  // Ensure sensitive files are gitignored
146
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/ticketing.local.json']);
146
+ await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json']);
147
147
 
148
148
  // Fetch current user info to store ID for future submissions
149
149
  try {
@@ -3,7 +3,7 @@ import { readFile, writeFile, stat, rename, mkdir, utimes } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative, isAbsolute } from 'path';
4
4
  import { log } from '../lib/logger.js';
5
5
  import { formatError } from '../lib/formatter.js';
6
- import { loadSynchronize, saveSynchronize } from '../lib/config.js';
6
+ import { loadSynchronize, saveSynchronize, appMetadataPath } from '../lib/config.js';
7
7
  import {
8
8
  loadStructureFile,
9
9
  saveStructureFile,
@@ -65,7 +65,7 @@ export const mvCommand = new Command('mv')
65
65
 
66
66
  // Validate target exists in structure
67
67
  if (!structure[targetBinId]) {
68
- log.error(`BinID ${targetBinId} not found in .dbo/structure.json`);
68
+ log.error(`BinID ${targetBinId} not found in .app/directories.json`);
69
69
  log.dim(' Run "dbo clone" to refresh project structure.');
70
70
  process.exit(1);
71
71
  }
@@ -127,7 +127,7 @@ async function promptForBin(structure) {
127
127
  .sort((a, b) => (a.fullPath || '').localeCompare(b.fullPath || ''));
128
128
 
129
129
  if (entries.length === 0) {
130
- log.error('No bins found in .dbo/structure.json');
130
+ log.error('No bins found in .app/directories.json');
131
131
  log.dim(' Run "dbo clone" to refresh project structure.');
132
132
  process.exit(1);
133
133
  }
@@ -371,23 +371,23 @@ async function stageEdit(entry, options) {
371
371
  await saveSynchronize(data);
372
372
  }
373
373
 
374
- // ── App.json updates ─────────────────────────────────────────────────────
374
+ // ── Metadata file updates ────────────────────────────────────────────────
375
375
 
376
376
  /**
377
- * Update a single @path reference in app.json.
377
+ * Update a single @path reference in the metadata file.
378
378
  */
379
379
  async function updateAppJsonPath(oldMetaPath, newMetaPath, options) {
380
380
  if (options.dryRun) {
381
- log.info(`[DRY RUN] Would update app.json: @${oldMetaPath} → @${newMetaPath}`);
381
+ log.info(`[DRY RUN] Would update metadata: @${oldMetaPath} → @${newMetaPath}`);
382
382
  return;
383
383
  }
384
384
 
385
- const appJsonPath = join(process.cwd(), 'app.json');
385
+ const appJsonPath = await appMetadataPath();
386
386
  let appJson;
387
387
  try {
388
388
  appJson = JSON.parse(await readFile(appJsonPath, 'utf8'));
389
389
  } catch {
390
- return; // no app.json
390
+ return; // no metadata file
391
391
  }
392
392
 
393
393
  if (!appJson.children) return;
@@ -408,20 +408,20 @@ async function updateAppJsonPath(oldMetaPath, newMetaPath, options) {
408
408
 
409
409
  if (changed) {
410
410
  await writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
411
- if (options.verbose) log.verbose(`app.json: ${oldRef} → ${newRef}`);
411
+ if (options.verbose) log.verbose(`metadata: ${oldRef} → ${newRef}`);
412
412
  }
413
413
  }
414
414
 
415
415
  /**
416
- * Update all @path references that start with a given directory prefix in app.json.
416
+ * Update all @path references that start with a given directory prefix in the metadata file.
417
417
  */
418
418
  async function updateAppJsonForDirectoryMove(oldDirPath, newDirPath, options) {
419
419
  if (options.dryRun) {
420
- log.info(`[DRY RUN] Would update app.json refs: @${oldDirPath}/... → @${newDirPath}/...`);
420
+ log.info(`[DRY RUN] Would update metadata refs: @${oldDirPath}/... → @${newDirPath}/...`);
421
421
  return;
422
422
  }
423
423
 
424
- const appJsonPath = join(process.cwd(), 'app.json');
424
+ const appJsonPath = await appMetadataPath();
425
425
  let appJson;
426
426
  try {
427
427
  appJson = JSON.parse(await readFile(appJsonPath, 'utf8'));
@@ -447,7 +447,7 @@ async function updateAppJsonForDirectoryMove(oldDirPath, newDirPath, options) {
447
447
 
448
448
  if (changed) {
449
449
  await writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
450
- if (options.verbose) log.verbose(`app.json: updated directory refs ${oldDirPath} → ${newDirPath}`);
450
+ if (options.verbose) log.verbose(`metadata: updated directory refs ${oldDirPath} → ${newDirPath}`);
451
451
  }
452
452
  }
453
453
 
@@ -630,7 +630,7 @@ async function mvFile(sourceFile, targetBinId, structure, options) {
630
630
  targetValue: targetBinId,
631
631
  }, options);
632
632
 
633
- // 3. Update app.json reference
633
+ // 3. Update metadata file reference
634
634
  await updateAppJsonPath(metaPath, newMetaPath, options);
635
635
 
636
636
  // 4. Physically move files
@@ -826,7 +826,7 @@ async function mvBin(sourcePath, targetBinId, structure, options) {
826
826
  targetValue: targetBinId,
827
827
  }, options);
828
828
 
829
- // 5. Update app.json for all affected file paths
829
+ // 5. Update metadata file for all affected file paths
830
830
  await updateAppJsonForDirectoryMove(oldDirPath, newDirPath, options);
831
831
 
832
832
  // 6. Save updated structure.json
@@ -267,7 +267,7 @@ export const pullCommand = new Command('pull')
267
267
  const config = await loadConfig();
268
268
 
269
269
  if (!config.AppShortName) {
270
- log.error('No AppShortName found in .dbo/config.json.');
270
+ log.error('No AppShortName found in .app/config.json.');
271
271
  log.dim(' Run "dbo clone" first to set up the project.');
272
272
  process.exit(1);
273
273
  }
@@ -1,26 +1,26 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, stat, writeFile, rename as fsRename, mkdir, access } from 'fs/promises';
2
+ import { readFile, stat, writeFile, rename as fsRename, mkdir, access, utimes } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
5
  import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../lib/input-parser.js';
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 } from '../lib/config.js';
9
+ import { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline, loadScripts, loadScriptsLocal, addDeleteEntry } from '../lib/config.js';
10
10
  import { mergeScriptsConfig, resolveHooks, buildHookEnv, runBuildLifecycle, runPushLifecycle } from '../lib/scripts.js';
11
11
  import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
12
12
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
13
13
  import { resolveTransactionKey } from '../lib/transaction-key.js';
14
- import { setFileTimestamps } from '../lib/timestamps.js';
14
+ import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
15
15
  import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
16
16
  import { findMetadataFiles, findFileInProject, findByUID } from '../lib/diff.js';
17
17
  import { loadIgnore } from '../lib/ignore.js';
18
- import { detectChangedColumns, findBaselineEntry, detectOutputChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
18
+ import { detectChangedColumns, findBaselineEntry, detectOutputChanges, detectEntityChildrenChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
19
19
  import { BINS_DIR, ENTITY_DIR_NAMES, loadStructureFile, findBinByPath } from '../lib/structure.js';
20
20
  import { ensureTrashIcon, setFileTag } from '../lib/tagging.js';
21
21
  import { checkToeStepping } from '../lib/toe-stepping.js';
22
22
  import { runPendingMigrations } from '../lib/migrations.js';
23
- // AUTO-ADD DISABLED: import { findUnaddedFiles, detectBinFile, submitAdd } from './add.js';
23
+ // AUTO-ADD DISABLED: import { findUnaddedFiles, detectBinFile, submitAdd } from '../lib/insert.js';
24
24
 
25
25
  /**
26
26
  * Resolve an @reference file path to an absolute filesystem path.
@@ -157,7 +157,7 @@ export const pushCommand = new Command('push')
157
157
  });
158
158
 
159
159
  /**
160
- * Process pending delete entries from .dbo/synchronize.json
160
+ * Process pending delete entries from .app/synchronize.json
161
161
  */
162
162
  async function processPendingDeletes(client, options, modifyKey = null, transactionKey = 'RowUID') {
163
163
  const sync = await loadSynchronize();
@@ -532,7 +532,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
532
532
  const baseline = await loadAppJsonBaseline();
533
533
 
534
534
  if (!baseline) {
535
- log.warn('No .app.json baseline found — performing full push (run "dbo clone" to enable delta sync)');
535
+ log.warn('No baseline found — performing full push (run "dbo clone" to enable delta sync)');
536
536
  }
537
537
 
538
538
  // Load server timezone for delta date comparisons
@@ -626,10 +626,19 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
626
626
 
627
627
  // Detect changed columns (delta detection) — skip for new records
628
628
  let changedColumns = null;
629
+ let childChanges = null;
629
630
  if (!isNewRecord && baseline) {
630
631
  try {
631
632
  changedColumns = await detectChangedColumns(metaPath, baseline, serverTz);
632
- if (changedColumns.length === 0) {
633
+
634
+ // Also detect child-level changes for entity dir metadata with embedded children
635
+ if (meta.children && typeof meta.children === 'object'
636
+ && !['output', 'output_value', 'output_value_filter', 'output_value_entity_column_rel'].includes(meta._entity)) {
637
+ childChanges = await detectEntityChildrenChanges(metaPath, baseline);
638
+ }
639
+ const hasChildChanges = childChanges && Object.keys(childChanges).length > 0;
640
+
641
+ if (changedColumns.length === 0 && !hasChildChanges) {
633
642
  log.dim(` Skipping ${basename(metaPath)} — no changes detected`);
634
643
  skipped++;
635
644
  continue;
@@ -639,7 +648,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
639
648
  }
640
649
  }
641
650
 
642
- toPush.push({ meta, metaPath, changedColumns, isNew: isNewRecord });
651
+ toPush.push({ meta, metaPath, changedColumns, isNew: isNewRecord, childChanges });
643
652
  }
644
653
 
645
654
  // Toe-stepping: check for server-side conflicts before submitting
@@ -797,12 +806,24 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
797
806
  }
798
807
  }
799
808
 
800
- const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey, transactionKey);
801
- if (success) {
809
+ // Push entity children first (if any changed/added/removed)
810
+ if (item.childChanges && Object.keys(item.childChanges).length > 0) {
811
+ await pushEntityChildren(item.meta, item.metaPath, item.childChanges, client, options, baseline, modifyKey, transactionKey);
812
+ }
813
+
814
+ // Only push parent if parent columns changed (skip for child-only changes)
815
+ if (item.changedColumns && item.changedColumns.length === 0) {
816
+ // Child-only change — parent was already handled above
802
817
  succeeded++;
803
818
  successfulPushes.push(item);
804
819
  } else {
805
- failed++;
820
+ const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey, transactionKey);
821
+ if (success) {
822
+ succeeded++;
823
+ successfulPushes.push(item);
824
+ } else {
825
+ failed++;
826
+ }
806
827
  }
807
828
  } catch (err) {
808
829
  if (err.message === 'SKIP_ALL') {
@@ -1525,7 +1546,7 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
1525
1546
 
1526
1547
  // ─── Compound Output Push ───────────────────────────────────────────────────
1527
1548
 
1528
- const _COMPOUND_DOC_KEYS = ['column', 'join', 'filter'];
1549
+ const _COMPOUND_CHILD_KEYS = ['output_value', 'output_value_filter', 'output_value_entity_column_rel'];
1529
1550
 
1530
1551
  /**
1531
1552
  * Push a compound output file (root + inline children) to the server.
@@ -1625,7 +1646,7 @@ async function pushOutputCompound(meta, metaPath, client, options, baseline, mod
1625
1646
  * Annotates each child with _depth (1 = direct child of root, 2 = grandchild, etc.)
1626
1647
  */
1627
1648
  function _flattenOutputChildren(childrenObj, result, depth = 1) {
1628
- for (const docKey of _COMPOUND_DOC_KEYS) {
1649
+ for (const docKey of _COMPOUND_CHILD_KEYS) {
1629
1650
  const entityArray = childrenObj[docKey];
1630
1651
  if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
1631
1652
  for (const child of entityArray) {
@@ -1714,10 +1735,151 @@ async function _submitOutputEntity(entity, physicalEntity, changedColumns, metaD
1714
1735
  return true;
1715
1736
  }
1716
1737
 
1738
+ // ─── Entity Children Push ────────────────────────────────────────────────────
1739
+
1740
+ /**
1741
+ * Push entity dir child record changes to the server.
1742
+ * Handles edits and removals (staged as deletes). New children are warned and skipped.
1743
+ * After all successful edits, rewrites the metadata file with updated child _LastUpdated
1744
+ * values and restores the parent's mtime.
1745
+ *
1746
+ * @param {Object} meta - Parsed parent metadata JSON
1747
+ * @param {string} metaPath - Absolute path to parent metadata file
1748
+ * @param {Object} childChanges - From detectEntityChildrenChanges(): { uid: { childEntity, changedColumns, isNew, isRemoved } }
1749
+ * @param {DboClient} client - API client
1750
+ * @param {Object} options - Push options
1751
+ * @param {Object} baseline - Loaded baseline
1752
+ * @param {string|null} modifyKey - ModifyKey value
1753
+ * @param {string} transactionKey - 'RowUID' or 'RowID'
1754
+ */
1755
+ async function pushEntityChildren(meta, metaPath, childChanges, client, options, baseline, modifyKey = null, transactionKey = 'RowUID') {
1756
+ let pushed = 0;
1757
+
1758
+ for (const [uid, info] of Object.entries(childChanges)) {
1759
+ if (info.isNew) {
1760
+ log.warn(` Child ${info.childEntity}:${uid} has no baseline entry — new children are not supported in this version. Skipping.`);
1761
+ continue;
1762
+ }
1763
+
1764
+ if (info.isRemoved) {
1765
+ // Stage as delete in synchronize.json
1766
+ await addDeleteEntry({
1767
+ UID: uid,
1768
+ entity: info.childEntity,
1769
+ name: `${info.childEntity}:${uid}`,
1770
+ expression: `RowUID:${uid};entity:${info.childEntity}=true`,
1771
+ });
1772
+ log.dim(` Staged delete for ${info.childEntity}:${uid}`);
1773
+ continue;
1774
+ }
1775
+
1776
+ // Edit: find the child object in meta.children
1777
+ let childObj = null;
1778
+ for (const childArray of Object.values(meta.children || {})) {
1779
+ if (Array.isArray(childArray)) {
1780
+ childObj = childArray.find(c => c.UID === uid);
1781
+ if (childObj) break;
1782
+ }
1783
+ }
1784
+ if (!childObj) {
1785
+ log.warn(` Could not find child ${uid} in metadata — skipping`);
1786
+ continue;
1787
+ }
1788
+
1789
+ const success = await _submitEntityChild(childObj, info.childEntity, info.changedColumns,
1790
+ client, options, modifyKey, transactionKey);
1791
+ if (success) pushed++;
1792
+ }
1793
+
1794
+ if (pushed > 0) {
1795
+ // Rewrite metadata file with updated child _LastUpdated values, then restore parent mtime
1796
+ const parentLastUpdated = meta._LastUpdated;
1797
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
1798
+ if (parentLastUpdated) {
1799
+ try {
1800
+ const cfg = await loadConfig();
1801
+ const ts = parseServerDate(parentLastUpdated, cfg.ServerTimezone);
1802
+ if (ts) await utimes(metaPath, ts, ts);
1803
+ } catch { /* non-critical */ }
1804
+ }
1805
+ log.dim(` ${pushed} child record(s) pushed; metadata file updated`);
1806
+ }
1807
+ }
1808
+
1809
+ /**
1810
+ * Submit a single entity child record edit to the server.
1811
+ * Mutates child._LastUpdated from the server response on success.
1812
+ *
1813
+ * @param {Object} child - Child record object (mutated in place on success)
1814
+ * @param {string} physicalEntity - Server entity name (e.g. 'entity_column')
1815
+ * @param {string[]} changedColumns - Column names to submit
1816
+ * @param {DboClient} client
1817
+ * @param {Object} options
1818
+ * @param {string|null} modifyKey
1819
+ * @param {string} transactionKey
1820
+ * @returns {Promise<boolean>} - True if submitted successfully
1821
+ */
1822
+ async function _submitEntityChild(child, physicalEntity, changedColumns, client, options, modifyKey = null, transactionKey = 'RowUID') {
1823
+ const uid = child.UID;
1824
+ if (!uid) {
1825
+ log.warn(` ${physicalEntity} child has no UID — skipping`);
1826
+ return false;
1827
+ }
1828
+
1829
+ const rowKeyPrefix = transactionKey === 'RowID' && child._id ? 'RowID' : 'RowUID';
1830
+ const rowKeyValue = rowKeyPrefix === 'RowID' ? child._id : uid;
1831
+
1832
+ const dataExprs = [];
1833
+ for (const col of changedColumns) {
1834
+ if (shouldSkipColumn(col) || col === 'UID' || col === 'children') continue;
1835
+ const val = child[col];
1836
+ const strValue = (val === null || val === undefined) ? '' : String(val);
1837
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}=${strValue}`);
1838
+ }
1839
+
1840
+ if (dataExprs.length === 0) return false;
1841
+
1842
+ log.info(` Pushing ${physicalEntity}:${uid} — ${dataExprs.length} field(s)`);
1843
+
1844
+ const storedTicket = await applyStoredTicketToSubmission(dataExprs, physicalEntity, uid, uid, options);
1845
+ const extraParams = { '_confirm': options.confirm || 'true' };
1846
+ if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
1847
+ else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
1848
+ if (modifyKey) extraParams['_modify_key'] = modifyKey;
1849
+ const cachedUser = getSessionUserOverride();
1850
+ if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
1851
+
1852
+ const body = await buildInputBody(dataExprs, extraParams);
1853
+ let result = await client.postUrlEncoded('/api/input/submit', body);
1854
+
1855
+ if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
1856
+ const retryMK = await handleModifyKeyError();
1857
+ if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
1858
+ extraParams['_modify_key'] = retryMK.modifyKey;
1859
+ const retryBody = await buildInputBody(dataExprs, extraParams);
1860
+ result = await client.postUrlEncoded('/api/input/submit', retryBody);
1861
+ }
1862
+
1863
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
1864
+
1865
+ if (!result.successful) return false;
1866
+
1867
+ // Update child _LastUpdated from server response (mutates child in place)
1868
+ try {
1869
+ const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
1870
+ if (editResults.length > 0) {
1871
+ const updated = editResults[0]._LastUpdated || editResults[0].LastUpdated;
1872
+ if (updated) child._LastUpdated = updated;
1873
+ }
1874
+ } catch { /* non-critical */ }
1875
+
1876
+ return true;
1877
+ }
1878
+
1717
1879
  // ─── Baseline Update ────────────────────────────────────────────────────────
1718
1880
 
1719
1881
  /**
1720
- * Update baseline file (.app.json) after successful pushes.
1882
+ * Update baseline after successful pushes.
1721
1883
  * Syncs changed column values and timestamps from metadata to baseline.
1722
1884
  *
1723
1885
  * @param {Object} baseline - The baseline JSON object
@@ -1782,6 +1944,23 @@ async function updateBaselineAfterPush(baseline, successfulPushes) {
1782
1944
  modified = true;
1783
1945
  }
1784
1946
  }
1947
+
1948
+ // Update child _LastUpdated values in baseline (from entity children push)
1949
+ if (meta.children && typeof meta.children === 'object' && baselineEntry.children) {
1950
+ for (const [childEntityName, childArray] of Object.entries(meta.children)) {
1951
+ if (!Array.isArray(childArray)) continue;
1952
+ const baselineChildArray = baselineEntry.children?.[childEntityName];
1953
+ if (!Array.isArray(baselineChildArray)) continue;
1954
+ for (const child of childArray) {
1955
+ if (!child.UID || !child._LastUpdated) continue;
1956
+ const baselineChild = baselineChildArray.find(bc => bc.UID === child.UID);
1957
+ if (baselineChild && baselineChild._LastUpdated !== child._LastUpdated) {
1958
+ baselineChild._LastUpdated = child._LastUpdated;
1959
+ modified = true;
1960
+ }
1961
+ }
1962
+ }
1963
+ }
1785
1964
  }
1786
1965
 
1787
1966
  // Save updated baseline
@@ -66,7 +66,7 @@ function getRowId(meta) {
66
66
  }
67
67
 
68
68
  /**
69
- * Remove a single file record: stage deletion, remove from app.json, delete local files.
69
+ * Remove a single file record: stage deletion, remove from metadata, delete local files.
70
70
  * Returns true if removed, false if skipped.
71
71
  */
72
72
  async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
@@ -125,7 +125,7 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
125
125
  await removeDeployEntry(uid);
126
126
  log.success(` Staged: ${displayName} → ${expression}`);
127
127
 
128
- // Remove from app.json
128
+ // Remove from metadata
129
129
  await removeAppJsonReference(metaPath);
130
130
 
131
131
  // Handle local files
@@ -5,7 +5,7 @@ import { mergeScriptsConfig, buildHookEnv, runHook } from '../lib/scripts.js';
5
5
  import { log } from '../lib/logger.js';
6
6
 
7
7
  export const runCommand = new Command('run')
8
- .description('Run a named script from .dbo/scripts.json (like npm run)')
8
+ .description('Run a named script from .app/scripts.json (like npm run)')
9
9
  .argument('[script-name]', 'Name of the script to run (omit to list all scripts)')
10
10
  .action(async (scriptName, options) => {
11
11
  try {
@@ -13,7 +13,7 @@ export const runCommand = new Command('run')
13
13
  const local = await loadScriptsLocal();
14
14
 
15
15
  if (!base && !local) {
16
- log.error('No .dbo/scripts.json found');
16
+ log.error('No .app/scripts.json found');
17
17
  process.exit(1);
18
18
  }
19
19
 
@@ -24,7 +24,7 @@ export const runCommand = new Command('run')
24
24
  // List all script names
25
25
  const allNames = Object.keys(scripts);
26
26
  if (allNames.length === 0) {
27
- log.info('No scripts defined in .dbo/scripts.json');
27
+ log.info('No scripts defined in .app/scripts.json');
28
28
  return;
29
29
  }
30
30
  log.info('Available scripts:');
@@ -37,7 +37,7 @@ export const runCommand = new Command('run')
37
37
  }
38
38
 
39
39
  if (!(scriptName in scripts)) {
40
- log.error(`Script "${scriptName}" not found in .dbo/scripts.json`);
40
+ log.error(`Script "${scriptName}" not found in .app/scripts.json`);
41
41
  process.exit(1);
42
42
  }
43
43
 
@@ -15,7 +15,7 @@ export const statusCommand = new Command('status')
15
15
  const initialized = await isInitialized();
16
16
  const config = await loadConfig();
17
17
 
18
- log.label('Initialized', initialized ? 'Yes (.dbo/)' : 'No');
18
+ log.label('Initialized', initialized ? 'Yes (.app/)' : 'No');
19
19
  log.label('Domain', config.domain || '(not set)');
20
20
  log.label('Username', config.username || '(not set)');
21
21
  const userInfo = await loadUserInfo();
@@ -7,7 +7,7 @@ import { runPendingMigrations } from '../lib/migrations.js';
7
7
 
8
8
  export const syncCommand = new Command('sync')
9
9
  .description('Synchronise local state with the server')
10
- .option('--baseline', 'Re-fetch server state and update .dbo/.app_baseline.json (does not modify local files)')
10
+ .option('--baseline', 'Re-fetch server state and update the baseline (does not modify local files)')
11
11
  .option('--no-migrate', 'Skip pending migrations for this invocation')
12
12
  .action(async (options) => {
13
13
  await runPendingMigrations(options);
@@ -66,6 +66,6 @@ export const syncCommand = new Command('sync')
66
66
  decodeBase64Fields(baseline);
67
67
 
68
68
  await saveAppJsonBaseline(baseline);
69
- spinner.succeed('.dbo/.app_baseline.json updated from server');
69
+ spinner.succeed('Baseline updated from server');
70
70
  log.dim(' Run "dbo push" to sync local changes against the new baseline');
71
71
  });