@dboio/cli 0.13.2 → 0.15.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.
@@ -6,14 +6,15 @@ import { DboClient } from '../lib/client.js';
6
6
  import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement } from '../lib/config.js';
7
7
  import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, SCAFFOLD_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir, resolveEntityDirPath } from '../lib/structure.js';
8
8
  import { log } from '../lib/logger.js';
9
- import { buildUidFilename, buildContentFileName, stripUidFromFilename, hasUidInFilename, detectLegacyDotUid } from '../lib/filenames.js';
9
+ import { buildUidFilename, buildContentFileName, buildMetaFilename, isMetadataFile, parseMetaFilename, stripUidFromFilename, hasUidInFilename } from '../lib/filenames.js';
10
10
  import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
11
11
  import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable, loadBaselineForComparison, resetBaselineCache, findMetadataFiles } from '../lib/diff.js';
12
12
  import { loadIgnore } from '../lib/ignore.js';
13
13
  import { checkDomainChange } from '../lib/domain-guard.js';
14
- import { applyTrashIcon, ensureTrashIcon } from '../lib/folder-icon.js';
14
+ import { applyTrashIcon, ensureTrashIcon, tagProjectFiles } from '../lib/tagging.js';
15
15
  import { loadMetadataTemplates, saveMetadataTemplates, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord } from '../lib/metadata-templates.js';
16
16
  import { runPendingMigrations } from '../lib/migrations.js';
17
+ import { upsertDeployEntry } from '../lib/deploy-config.js';
17
18
 
18
19
  /**
19
20
  * Resolve a column value that may be base64-encoded.
@@ -284,9 +285,8 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
284
285
  const uid = String(record.UID || record._id || 'untitled');
285
286
  // Companion: natural name, no UID
286
287
  const filename = sanitizeFilename(buildContentFileName(record, uid));
287
- // Metadata: still uses ~UID
288
- const metaBase = buildUidFilename(name, uid);
289
- const metaPath = join(dir, `${metaBase}.metadata.json`);
288
+ // Metadata: name.metadata~uid.json
289
+ const metaPath = join(dir, buildMetaFilename(name, uid));
290
290
 
291
291
  return { dir, filename, metaPath };
292
292
  }
@@ -315,10 +315,10 @@ export function resolveMediaPaths(record, structure) {
315
315
  dir = dir.replace(/^\/+|\/+$/g, '');
316
316
  if (!dir) dir = BINS_DIR;
317
317
 
318
- // Metadata: name~uid.ext.metadata.json (unchanged format)
318
+ // Metadata: name.ext.metadata~uid.json
319
319
  const uid = String(record.UID || record._id || 'untitled');
320
- const metaBase = buildUidFilename(name, uid);
321
- const metaPath = join(dir, `${metaBase}.${ext}.metadata.json`);
320
+ const naturalMediaBase = `${name}.${ext}`;
321
+ const metaPath = join(dir, buildMetaFilename(naturalMediaBase, uid));
322
322
 
323
323
  return { dir, filename: companionFilename, metaPath };
324
324
  }
@@ -326,6 +326,9 @@ export function resolveMediaPaths(record, structure) {
326
326
  /**
327
327
  * Extract path components for entity-dir records.
328
328
  * Simplified from processEntityDirEntries() for collision detection.
329
+ *
330
+ * Returns `name` (natural, no ~UID) for companion files and
331
+ * `metaPath` (with ~UID) for the metadata file.
329
332
  */
330
333
  export function resolveEntityDirPaths(entityName, record, dirName) {
331
334
  let name;
@@ -338,9 +341,8 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
338
341
  }
339
342
 
340
343
  const uid = record.UID || 'untitled';
341
- const finalName = buildUidFilename(name, uid);
342
- const metaPath = join(dirName, `${finalName}.metadata.json`);
343
- return { dir: dirName, filename: finalName, metaPath };
344
+ const metaPath = join(dirName, buildMetaFilename(name, uid));
345
+ return { dir: dirName, name, metaPath };
344
346
  }
345
347
 
346
348
  /**
@@ -513,7 +515,7 @@ async function trashOrphanedLegacyCompanions() {
513
515
  continue;
514
516
  }
515
517
 
516
- if (entry.name.endsWith('.metadata.json')) {
518
+ if (isMetadataFile(entry.name)) {
517
519
  // Read metadata and collect all @references
518
520
  try {
519
521
  const meta = JSON.parse(await readFile(full, 'utf8'));
@@ -598,9 +600,9 @@ async function buildFileRegistry(appJson, structure, placementPrefs) {
598
600
  registry.get(filePath).push(entry);
599
601
  }
600
602
 
601
- // Note: entity-dir records (Extensions/, Data Sources/, etc.) and generic entities
602
- // are excluded from collision detection they use UID-based naming to handle duplicates.
603
- // Only content and media records in Bins/ are checked for cross-entity collisions.
603
+ // Note: entity-dir records (Extensions/, Data Sources/, etc.) use natural names for
604
+ // companion files with -N suffix collision resolution handled inline during processing.
605
+ // Only content and media records in Bins/ are checked for cross-entity collisions here.
604
606
 
605
607
  return registry;
606
608
  }
@@ -1288,6 +1290,9 @@ export async function performClone(source, options = {}) {
1288
1290
  // Step 9: Trash orphaned legacy ~UID companion files that no metadata references
1289
1291
  await trashOrphanedLegacyCompanions();
1290
1292
 
1293
+ // Step 10: Tag project files with sync status (best-effort, non-blocking)
1294
+ tagProjectFiles({ verbose: false }).catch(() => {});
1295
+
1291
1296
  log.plain('');
1292
1297
  const verb = options.pullMode ? 'Pull' : 'Clone';
1293
1298
  log.success(entityFilter ? `${verb} complete! (filtered: ${options.entity})` : `${verb} complete!`);
@@ -1570,6 +1575,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1570
1575
  const refs = [];
1571
1576
  const bulkAction = { value: null };
1572
1577
  const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
1578
+ const usedNames = new Map(); // name → count, for collision resolution
1573
1579
  const config = await loadConfig();
1574
1580
 
1575
1581
  // Determine filename column: saved preference, or prompt, or default
@@ -1717,19 +1723,27 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1717
1723
  name = sanitizeFilename(String(record.UID || 'untitled'));
1718
1724
  }
1719
1725
 
1720
- // Include UID in filename via tilde convention to ensure uniqueness
1726
+ // Resolve name collisions: second+ record with same name gets -1, -2, etc.
1721
1727
  const uid = record.UID || 'untitled';
1722
- const finalName = buildUidFilename(name, uid);
1723
-
1724
- const metaPath = join(dirName, `${finalName}.metadata.json`);
1725
-
1726
- // Legacy dot-separator detection: rename <name>.<uid>.metadata.json → <name>~<uid>.metadata.json
1727
- const legacyMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
1728
- if (!await fileExists(metaPath) && await fileExists(legacyMetaPath)) {
1728
+ const nameKey = name;
1729
+ const count = usedNames.get(nameKey) || 0;
1730
+ usedNames.set(nameKey, count + 1);
1731
+ if (count > 0) name = `${name}-${count}`;
1732
+
1733
+ // Metadata: name.metadata~uid.json; companion files use natural name
1734
+ const metaPath = join(dirName, buildMetaFilename(name, uid));
1735
+
1736
+ // Legacy detection: rename old-format metadata files to new convention
1737
+ const legacyDotMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
1738
+ const legacyTildeMetaPath = join(dirName, `${buildUidFilename(name, uid)}.metadata.json`);
1739
+ const legacyPath = !await fileExists(metaPath) && await fileExists(legacyDotMetaPath) ? legacyDotMetaPath
1740
+ : !await fileExists(metaPath) && await fileExists(legacyTildeMetaPath) ? legacyTildeMetaPath
1741
+ : null;
1742
+ if (legacyPath) {
1729
1743
  if (options.yes || legacyRenameAction.value === 'rename_all') {
1730
1744
  const { rename: fsRename } = await import('fs/promises');
1731
- await fsRename(legacyMetaPath, metaPath);
1732
- log.dim(` Auto-renamed: ${basename(legacyMetaPath)} → ${basename(metaPath)}`);
1745
+ await fsRename(legacyPath, metaPath);
1746
+ log.dim(` Auto-renamed: ${basename(legacyPath)} → ${basename(metaPath)}`);
1733
1747
  } else if (legacyRenameAction.value === 'skip_all') {
1734
1748
  // skip silently
1735
1749
  } else {
@@ -1737,7 +1751,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1737
1751
  const { action } = await inquirer.prompt([{
1738
1752
  type: 'list',
1739
1753
  name: 'action',
1740
- message: `Found legacy filename "${basename(legacyMetaPath)}" — rename to "${basename(metaPath)}"?`,
1754
+ message: `Found legacy filename "${basename(legacyPath)}" — rename to "${basename(metaPath)}"?`,
1741
1755
  choices: [
1742
1756
  { name: 'Yes', value: 'rename' },
1743
1757
  { name: 'Rename all remaining', value: 'rename_all' },
@@ -1747,8 +1761,8 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1747
1761
  }]);
1748
1762
  if (action === 'rename' || action === 'rename_all') {
1749
1763
  const { rename: fsRename } = await import('fs/promises');
1750
- await fsRename(legacyMetaPath, metaPath);
1751
- log.success(` Renamed: ${basename(legacyMetaPath)} → ${basename(metaPath)}`);
1764
+ await fsRename(legacyPath, metaPath);
1765
+ log.success(` Renamed: ${basename(legacyPath)} → ${basename(metaPath)}`);
1752
1766
  if (action === 'rename_all') legacyRenameAction.value = 'rename_all';
1753
1767
  } else {
1754
1768
  if (action === 'skip_all') legacyRenameAction.value = 'skip_all';
@@ -1765,7 +1779,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1765
1779
  const entityMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteEntityMeta);
1766
1780
  if (entityMetaExists && !options.yes && !hasNewExtractions) {
1767
1781
  if (bulkAction.value === 'skip_all') {
1768
- log.dim(` Skipped ${finalName}`);
1782
+ log.dim(` Skipped ${name}`);
1769
1783
  refs.push({ uid: record.UID, metaPath });
1770
1784
  continue;
1771
1785
  }
@@ -1787,22 +1801,22 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1787
1801
  if (serverNewer) {
1788
1802
  // Incomplete metadata (no _LastUpdated) from dbo add — auto-accept without prompting
1789
1803
  if (localMissingLastUpdated) {
1790
- log.dim(` Completing metadata: ${finalName}`);
1804
+ log.dim(` Completing metadata: ${name}`);
1791
1805
  // Fall through to write
1792
1806
  } else {
1793
- const action = await promptChangeDetection(finalName, record, configWithTz, {
1807
+ const action = await promptChangeDetection(name, record, configWithTz, {
1794
1808
  serverDate,
1795
1809
  localDate: localSyncTime,
1796
1810
  });
1797
1811
 
1798
1812
  if (action === 'skip') {
1799
- log.dim(` Skipped ${finalName}`);
1813
+ log.dim(` Skipped ${name}`);
1800
1814
  refs.push({ uid: record.UID, metaPath });
1801
1815
  continue;
1802
1816
  }
1803
1817
  if (action === 'skip_all') {
1804
1818
  bulkAction.value = 'skip_all';
1805
- log.dim(` Skipped ${finalName}`);
1819
+ log.dim(` Skipped ${name}`);
1806
1820
  refs.push({ uid: record.UID, metaPath });
1807
1821
  continue;
1808
1822
  }
@@ -1818,20 +1832,20 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1818
1832
  } else {
1819
1833
  const locallyModified = await hasLocalModifications(metaPath, configWithTz);
1820
1834
  if (locallyModified) {
1821
- const action = await promptChangeDetection(finalName, record, configWithTz, {
1835
+ const action = await promptChangeDetection(name, record, configWithTz, {
1822
1836
  localIsNewer: true,
1823
1837
  serverDate,
1824
1838
  localDate: localSyncTime,
1825
1839
  });
1826
1840
 
1827
1841
  if (action === 'skip') {
1828
- log.dim(` Kept local: ${finalName}`);
1842
+ log.dim(` Kept local: ${name}`);
1829
1843
  refs.push({ uid: record.UID, metaPath });
1830
1844
  continue;
1831
1845
  }
1832
1846
  if (action === 'skip_all') {
1833
1847
  bulkAction.value = 'skip_all';
1834
- log.dim(` Kept local: ${finalName}`);
1848
+ log.dim(` Kept local: ${name}`);
1835
1849
  refs.push({ uid: record.UID, metaPath });
1836
1850
  continue;
1837
1851
  }
@@ -1844,7 +1858,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1844
1858
  continue;
1845
1859
  }
1846
1860
  } else {
1847
- log.dim(` Up to date: ${finalName}`);
1861
+ log.dim(` Up to date: ${name}`);
1848
1862
  refs.push({ uid: record.UID, metaPath });
1849
1863
  continue;
1850
1864
  }
@@ -1864,9 +1878,10 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1864
1878
  if (extractInfo) {
1865
1879
  const isBase64 = value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64';
1866
1880
  const decoded = isBase64 ? (resolveContentValue(value) ?? '') : (value ?? '');
1867
- const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
1881
+ const colFileName = `${name}.${key}.${extractInfo.ext}`;
1868
1882
  const colFilePath = join(dirName, colFileName);
1869
1883
  await writeFile(colFilePath, decoded);
1884
+ await upsertDeployEntry(colFilePath, record.UID, entityName, key);
1870
1885
  meta[key] = `@${colFileName}`;
1871
1886
  extractedContentCols.push(key);
1872
1887
 
@@ -2198,6 +2213,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2198
2213
  const { filenameCol, contentColsToExtract } = descriptorPrefs.get(descriptor);
2199
2214
  const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
2200
2215
  const mdColInfo = useRootDoc ? contentColsToExtract.find(c => c.ext === 'md') : null;
2216
+ const usedNames = new Map(); // name → count, for collision resolution within this descriptor group
2201
2217
 
2202
2218
  log.info(`Processing ${records.length} "${descriptor}" extension(s) → ${dir}/`);
2203
2219
 
@@ -2212,17 +2228,27 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2212
2228
  name = sanitizeFilename(String(record.UID || 'untitled'));
2213
2229
  }
2214
2230
 
2231
+ // Resolve name collisions: second+ record with same name gets -1, -2, etc.
2215
2232
  const uid = record.UID || 'untitled';
2216
- const finalName = buildUidFilename(name, uid);
2217
- const metaPath = join(dir, `${finalName}.metadata.json`);
2218
-
2219
- // Legacy dot-separator detection: rename <name>.<uid>.metadata.json <name>~<uid>.metadata.json
2220
- const legacyExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
2221
- if (!await fileExists(metaPath) && await fileExists(legacyExtMetaPath)) {
2233
+ const nameKey = name;
2234
+ const nameCount = usedNames.get(nameKey) || 0;
2235
+ usedNames.set(nameKey, nameCount + 1);
2236
+ if (nameCount > 0) name = `${name}-${nameCount}`;
2237
+
2238
+ // Metadata: name.metadata~uid.json; companion files use natural name
2239
+ const metaPath = join(dir, buildMetaFilename(name, uid));
2240
+
2241
+ // Legacy detection: rename old-format metadata files to new convention
2242
+ const legacyDotExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
2243
+ const legacyTildeExtMetaPath = join(dir, `${buildUidFilename(name, uid)}.metadata.json`);
2244
+ const legacyExtPath = !await fileExists(metaPath) && await fileExists(legacyDotExtMetaPath) ? legacyDotExtMetaPath
2245
+ : !await fileExists(metaPath) && await fileExists(legacyTildeExtMetaPath) ? legacyTildeExtMetaPath
2246
+ : null;
2247
+ if (legacyExtPath) {
2222
2248
  if (options.yes || legacyRenameAction.value === 'rename_all') {
2223
2249
  const { rename: fsRename } = await import('fs/promises');
2224
- await fsRename(legacyExtMetaPath, metaPath);
2225
- log.dim(` Auto-renamed: ${basename(legacyExtMetaPath)} → ${basename(metaPath)}`);
2250
+ await fsRename(legacyExtPath, metaPath);
2251
+ log.dim(` Auto-renamed: ${basename(legacyExtPath)} → ${basename(metaPath)}`);
2226
2252
  } else if (legacyRenameAction.value === 'skip_all') {
2227
2253
  // skip silently
2228
2254
  } else {
@@ -2230,7 +2256,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2230
2256
  const { action } = await inquirer.prompt([{
2231
2257
  type: 'list',
2232
2258
  name: 'action',
2233
- message: `Found legacy filename "${basename(legacyExtMetaPath)}" — rename to "${basename(metaPath)}"?`,
2259
+ message: `Found legacy filename "${basename(legacyExtPath)}" — rename to "${basename(metaPath)}"?`,
2234
2260
  choices: [
2235
2261
  { name: 'Yes', value: 'rename' },
2236
2262
  { name: 'Rename all remaining', value: 'rename_all' },
@@ -2240,8 +2266,8 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2240
2266
  }]);
2241
2267
  if (action === 'rename' || action === 'rename_all') {
2242
2268
  const { rename: fsRename } = await import('fs/promises');
2243
- await fsRename(legacyExtMetaPath, metaPath);
2244
- log.success(` Renamed: ${basename(legacyExtMetaPath)} → ${basename(metaPath)}`);
2269
+ await fsRename(legacyExtPath, metaPath);
2270
+ log.success(` Renamed: ${basename(legacyExtPath)} → ${basename(metaPath)}`);
2245
2271
  if (action === 'rename_all') legacyRenameAction.value = 'rename_all';
2246
2272
  } else {
2247
2273
  if (action === 'skip_all') legacyRenameAction.value = 'skip_all';
@@ -2282,7 +2308,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2282
2308
  const extMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteExtMeta);
2283
2309
  if (extMetaExists && !options.yes && !hasNewExtractions) {
2284
2310
  if (bulkAction.value === 'skip_all') {
2285
- log.dim(` Skipped ${finalName}`);
2311
+ log.dim(` Skipped ${name}`);
2286
2312
  refs.push({ uid: record.UID, metaPath });
2287
2313
  continue;
2288
2314
  }
@@ -2293,7 +2319,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2293
2319
  const serverDate = parseServerDate(record._LastUpdated, serverTz);
2294
2320
 
2295
2321
  if (serverNewer) {
2296
- const action = await promptChangeDetection(finalName, record, cfgWithTz, { serverDate, localDate: localSyncTime });
2322
+ const action = await promptChangeDetection(name, record, cfgWithTz, { serverDate, localDate: localSyncTime });
2297
2323
  if (action === 'skip') { refs.push({ uid: record.UID, metaPath }); continue; }
2298
2324
  if (action === 'skip_all') { bulkAction.value = 'skip_all'; refs.push({ uid: record.UID, metaPath }); continue; }
2299
2325
  if (action === 'overwrite_all') { bulkAction.value = 'overwrite_all'; }
@@ -2301,13 +2327,13 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2301
2327
  } else {
2302
2328
  const localModified = await hasLocalModifications(metaPath, cfgWithTz);
2303
2329
  if (localModified) {
2304
- const action = await promptChangeDetection(finalName, record, cfgWithTz, { localIsNewer: true, serverDate, localDate: localSyncTime });
2330
+ const action = await promptChangeDetection(name, record, cfgWithTz, { localIsNewer: true, serverDate, localDate: localSyncTime });
2305
2331
  if (action === 'skip') { refs.push({ uid: record.UID, metaPath }); continue; }
2306
2332
  if (action === 'skip_all') { bulkAction.value = 'skip_all'; refs.push({ uid: record.UID, metaPath }); continue; }
2307
2333
  if (action === 'overwrite_all') { bulkAction.value = 'overwrite_all'; }
2308
2334
  if (action === 'compare') { await inlineDiffAndMerge(record, metaPath, cfgWithTz, { localIsNewer: true }); refs.push({ uid: record.UID, metaPath }); continue; }
2309
2335
  } else {
2310
- log.dim(` Up to date: ${finalName}`);
2336
+ log.dim(` Up to date: ${name}`);
2311
2337
  refs.push({ uid: record.UID, metaPath });
2312
2338
  continue;
2313
2339
  }
@@ -2334,13 +2360,14 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2334
2360
  colFilePath = join(DOCUMENTATION_DIR, docFileName);
2335
2361
  refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
2336
2362
  } else {
2337
- const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
2363
+ const colFileName = `${name}.${key}.${extractInfo.ext}`;
2338
2364
  colFilePath = join(dir, colFileName);
2339
2365
  refValue = `@${colFileName}`;
2340
2366
  }
2341
2367
 
2342
2368
  meta[key] = refValue;
2343
2369
  await writeFile(colFilePath, decoded);
2370
+ await upsertDeployEntry(colFilePath, record.UID, 'extension', key);
2344
2371
  extractedCols.push(key);
2345
2372
  if (serverTz) {
2346
2373
  try { await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz); } catch {}
@@ -2498,9 +2525,9 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2498
2525
  const uid = String(record.UID || record._id || 'untitled');
2499
2526
  const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
2500
2527
  const filePath = join(dir, finalFilename);
2501
- // Metadata: name~uid.ext.metadata.json (unchanged format)
2502
- const metaBase = buildUidFilename(name, uid);
2503
- const metaPath = join(dir, `${metaBase}.${ext}.metadata.json`);
2528
+ // Metadata: name.ext.metadata~uid.json
2529
+ const naturalMediaBase = `${name}.${ext}`;
2530
+ const metaPath = join(dir, buildMetaFilename(naturalMediaBase, uid));
2504
2531
  // usedNames retained for tracking
2505
2532
  const fileKey = `${dir}/${name}.${ext}`;
2506
2533
  usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
@@ -2671,6 +2698,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2671
2698
  meta._mediaFile = `@${finalFilename}`;
2672
2699
 
2673
2700
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
2701
+ await upsertDeployEntry(filePath, record.UID, 'media', 'File');
2674
2702
  log.dim(` → ${metaPath}`);
2675
2703
 
2676
2704
  // Set file timestamps from server dates (independent try-catch so one failure
@@ -2742,7 +2770,6 @@ async function processRecord(entityName, record, structure, options, usedNames,
2742
2770
  try {
2743
2771
  const uid = String(record.UID);
2744
2772
  const sanitized = sanitizeFilename(String(record.Name || uid || 'untitled'));
2745
- const probe = buildUidFilename(sanitized, uid);
2746
2773
  // Resolve the directory the same way the main code does below
2747
2774
  let probeDir = BINS_DIR;
2748
2775
  if (record.BinID && structure[record.BinID]) {
@@ -2753,7 +2780,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
2753
2780
  probeDir = probeDir.replace(/^\/+|\/+$/g, '') || BINS_DIR;
2754
2781
  if (extname(probeDir)) probeDir = probeDir.substring(0, probeDir.lastIndexOf('/')) || BINS_DIR;
2755
2782
 
2756
- const probeMeta = join(probeDir, `${probe}.metadata.json`);
2783
+ const probeMeta = join(probeDir, buildMetaFilename(sanitized, uid));
2757
2784
  const raw = await readFile(probeMeta, 'utf8');
2758
2785
  const localMeta = JSON.parse(raw);
2759
2786
  // Extract extension from Content @reference (e.g. "@Name~uid.html")
@@ -2842,8 +2869,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
2842
2869
  const uid = String(record.UID || record._id || 'untitled');
2843
2870
  // Companion: natural name, no UID (use collision-resolved override if available)
2844
2871
  const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
2845
- // Metadata: still uses ~UID
2846
- const metaBase = buildUidFilename(name, uid);
2872
+ // Metadata: name.metadata~uid.json
2847
2873
  // usedNames retained for non-UID edge case tracking
2848
2874
  const nameKey = `${dir}/${name}`;
2849
2875
  usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
@@ -2856,7 +2882,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
2856
2882
  );
2857
2883
 
2858
2884
  const filePath = join(dir, fileName);
2859
- const metaPath = join(dir, `${metaBase}.metadata.json`);
2885
+ const metaPath = join(dir, buildMetaFilename(name, uid));
2860
2886
 
2861
2887
  // Rename legacy ~UID companion files to natural names if needed
2862
2888
  if (await fileExists(metaPath)) {
@@ -2961,6 +2987,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
2961
2987
  if (hasContent) {
2962
2988
  const decoded = resolveContentValue(contentValue) ?? '';
2963
2989
  await writeFile(filePath, decoded);
2990
+ await upsertDeployEntry(filePath, record.UID, entityName, 'Content');
2964
2991
  log.success(`Saved ${filePath}`);
2965
2992
  }
2966
2993
 
@@ -2976,11 +3003,12 @@ async function processRecord(entityName, record, structure, options, usedNames,
2976
3003
  // Other base64 columns — decode and store inline or as reference
2977
3004
  const decoded = resolveContentValue(value);
2978
3005
  if (decoded && decoded.length > 200) {
2979
- // Large value: save as separate file
3006
+ // Large value: save as separate file (natural name, no ~UID)
2980
3007
  const colExt = guessExtensionForColumn(key);
2981
- const colFileName = `${metaBase}-${key.toLowerCase()}.${colExt}`;
3008
+ const colFileName = `${name}-${key.toLowerCase()}.${colExt}`;
2982
3009
  const colFilePath = join(dir, colFileName);
2983
3010
  await writeFile(colFilePath, decoded);
3011
+ await upsertDeployEntry(colFilePath, record.UID, entityName, key);
2984
3012
  meta[key] = `@${colFileName}`;
2985
3013
  if (!meta._contentColumns) meta._contentColumns = [];
2986
3014
  meta._contentColumns.push(key);
@@ -3233,17 +3261,11 @@ async function resolveOutputFilenameColumns(appJson, options) {
3233
3261
  export function buildOutputFilename(entityType, node, filenameCol, parentChain = []) {
3234
3262
  const uid = node.UID || '';
3235
3263
  const rawName = node[filenameCol];
3236
- const name = rawName ? sanitizeFilename(String(rawName)) : '';
3264
+ const name = rawName ? sanitizeFilename(String(rawName)) : uid;
3237
3265
 
3238
- // Build this entity's segment
3239
- let segment;
3240
- if (entityType === 'output') {
3241
- // Root output: use Name~UID directly (no type prefix)
3242
- segment = (!name || name === uid) ? uid : `${name}~${uid}`;
3243
- } else {
3244
- // Child entities: keep type prefix (column~, join~, filter~)
3245
- segment = (!name || name === uid) ? `${entityType}~${uid}` : `${entityType}~${name}~${uid}`;
3246
- }
3266
+ // Root output: use natural name only (UID goes to .metadata~uid.json, not the stem)
3267
+ // Child entities: docName only, no ~uid (index determines uniqueness)
3268
+ const segment = entityType === 'output' ? name : entityType;
3247
3269
 
3248
3270
  const allSegments = [...parentChain, segment];
3249
3271
  return allSegments.join('.');
@@ -3257,18 +3279,21 @@ const INLINE_DOC_KEYS = ['column', 'join', 'filter'];
3257
3279
 
3258
3280
  /**
3259
3281
  * Build the companion file stem for a child entity within a root output file.
3260
- * e.g. root stem "Sales~abc", entity "output_value", uid "col1"
3261
- * → "Sales~abc.column~col1"
3282
+ * Uses array index for uniqueness (index 0 = no suffix, index N = "-N" suffix).
3283
+ *
3284
+ * e.g. rootStem "Sales", entity "output_value", index 0 → "Sales.column"
3285
+ * rootStem "Sales", entity "output_value", index 1 → "Sales.column-1"
3262
3286
  *
3263
- * @param {string} rootStem - Root output file stem (no extension)
3287
+ * @param {string} rootStem - Root output natural name (e.g. "Sales")
3264
3288
  * @param {string} physicalEntity - Physical entity name ('output_value', etc.)
3265
- * @param {string} uid - Child entity UID
3289
+ * @param {number} index - Zero-based position of this child in its array
3266
3290
  * @param {string} [parentChainStem] - Already-built ancestor stem (for nested children)
3267
3291
  * @returns {string}
3268
3292
  */
3269
- export function getChildCompanionStem(rootStem, physicalEntity, uid, parentChainStem = rootStem) {
3293
+ export function getChildCompanionStem(rootStem, physicalEntity, index, parentChainStem = rootStem) {
3270
3294
  const docName = INLINE_DOC_NAMES[physicalEntity] || physicalEntity;
3271
- return `${parentChainStem}.${docName}~${uid}`;
3295
+ const suffix = index === 0 ? '' : `-${index}`;
3296
+ return `${parentChainStem}.${docName}${suffix}`;
3272
3297
  }
3273
3298
 
3274
3299
  /**
@@ -3305,6 +3330,7 @@ async function extractCustomSqlIfNeeded(entityObj, companionStem, outputDir, ser
3305
3330
  const companionName = `${companionStem}.CustomSQL.sql`;
3306
3331
  const companionPath = join(outputDir, companionName);
3307
3332
  await writeFile(companionPath, hasContent ? decoded : '', 'utf8');
3333
+ await upsertDeployEntry(companionPath, entityObj.UID || entityObj._uid, 'output', 'CustomSQL');
3308
3334
  entityObj.CustomSQL = `@${companionName}`;
3309
3335
  entityObj._contentColumns = entityObj._contentColumns || [];
3310
3336
  if (!entityObj._contentColumns.includes('CustomSQL')) {
@@ -3350,7 +3376,8 @@ async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, s
3350
3376
 
3351
3377
  if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
3352
3378
 
3353
- for (const child of entityArray) {
3379
+ for (let childIdx = 0; childIdx < entityArray.length; childIdx++) {
3380
+ const child = entityArray[childIdx];
3354
3381
  // Build a clean copy without tree-internal fields
3355
3382
  const childObj = { ...child };
3356
3383
  delete childObj._children;
@@ -3366,8 +3393,8 @@ async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, s
3366
3393
  // Ensure _entity is set to physical entity name (for push routing)
3367
3394
  childObj._entity = physicalKey;
3368
3395
 
3369
- // Compute companion stem for this child
3370
- const childStem = getChildCompanionStem(rootStem, physicalKey, child.UID, parentStem);
3396
+ // Compute companion stem for this child (index-based, not UID-based)
3397
+ const childStem = getChildCompanionStem(rootStem, physicalKey, childIdx, parentStem);
3371
3398
 
3372
3399
  // Extract CustomSQL if needed
3373
3400
  const companionFile = await extractCustomSqlIfNeeded(childObj, childStem, outputDir, serverTz);
@@ -3419,8 +3446,8 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
3419
3446
  log.dim(` Trashed orphaned child file: ${f}`);
3420
3447
  } catch { /* non-critical */ }
3421
3448
  }
3422
- // Also trash the legacy root file itself (_output~Name~UID.json or .metadata.json) if new format exists
3423
- if (matchesLegacy === false && (f === `${legacyStem}.json` || f === `${legacyStem}.metadata.json`)) {
3449
+ // Also trash the legacy root file itself (_output~Name~UID.json or .metadata.json)
3450
+ if (!matchesCurrent && (f === `${legacyStem}.json` || f === `${legacyStem}.metadata.json`)) {
3424
3451
  if (!trashCreated) {
3425
3452
  await mkdir(trashDir, { recursive: true });
3426
3453
  trashCreated = true;
@@ -3447,10 +3474,16 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
3447
3474
  * @returns {Object} - { segments: [{entity, name, uid}], rootOutputUid, entityType, uid }
3448
3475
  */
3449
3476
  export function parseOutputHierarchyFile(filename) {
3450
- // Strip .metadata.json or legacy .json extension
3477
+ // Strip metadata or .json extension
3451
3478
  let base = filename;
3452
- if (base.endsWith('.metadata.json')) base = base.substring(0, base.length - 14);
3453
- else if (base.endsWith('.json')) base = base.substring(0, base.length - 5);
3479
+ const metaParsed = parseMetaFilename(filename);
3480
+ if (metaParsed) {
3481
+ base = metaParsed.naturalBase;
3482
+ } else if (base.endsWith('.metadata.json')) {
3483
+ base = base.substring(0, base.length - 14);
3484
+ } else if (base.endsWith('.json')) {
3485
+ base = base.substring(0, base.length - 5);
3486
+ }
3454
3487
 
3455
3488
  // Split into segments by finding entity type boundaries
3456
3489
  // Entity types are: output~ (or legacy _output~), column~, join~, filter~
@@ -3571,15 +3604,26 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
3571
3604
 
3572
3605
  await mkdir(binDir, { recursive: true });
3573
3606
 
3574
- // Build root output filename
3607
+ // Build root output filename (natural name, no UID in stem)
3575
3608
  const rootBasename = buildOutputFilename('output', output, filenameCols.output);
3576
- let rootMetaPath = join(binDir, `${rootBasename}.metadata.json`);
3609
+ const rootUid = output.UID || '';
3610
+ let rootMetaPath = join(binDir, buildMetaFilename(rootBasename, rootUid));
3577
3611
 
3578
- // Legacy fallback: if old .json exists but new .metadata.json doesn't, rename in-place
3612
+ // Legacy fallback: rename old-format metadata to new convention
3613
+ const legacyTildeOutputMeta = join(binDir, `${rootBasename}~${rootUid}.metadata.json`);
3579
3614
  const legacyJsonPath = join(binDir, `${rootBasename}.json`);
3580
- if (!await fileExists(rootMetaPath) && await fileExists(legacyJsonPath)) {
3581
- await rename(legacyJsonPath, rootMetaPath);
3582
- log.dim(` Renamed ${rootBasename}.json → ${rootBasename}.metadata.json`);
3615
+ const legacyOutputMeta = join(binDir, `${rootBasename}.metadata.json`);
3616
+ if (!await fileExists(rootMetaPath)) {
3617
+ if (await fileExists(legacyTildeOutputMeta)) {
3618
+ await rename(legacyTildeOutputMeta, rootMetaPath);
3619
+ log.dim(` Renamed ${basename(legacyTildeOutputMeta)} → ${basename(rootMetaPath)}`);
3620
+ } else if (await fileExists(legacyOutputMeta)) {
3621
+ await rename(legacyOutputMeta, rootMetaPath);
3622
+ log.dim(` Renamed ${basename(legacyOutputMeta)} → ${basename(rootMetaPath)}`);
3623
+ } else if (await fileExists(legacyJsonPath)) {
3624
+ await rename(legacyJsonPath, rootMetaPath);
3625
+ log.dim(` Renamed ${rootBasename}.json → ${basename(rootMetaPath)}`);
3626
+ }
3583
3627
  }
3584
3628
 
3585
3629
  // Detect old-format files that need migration to inline children format.
@@ -57,10 +57,17 @@ export const deployCommand = new Command('deploy')
57
57
  process.exit(1);
58
58
  }
59
59
 
60
+ // If name doesn't match a key directly, try scanning by UID value
61
+ let resolvedName = name;
62
+ if (name && !manifest.deployments[name]) {
63
+ const byUid = Object.entries(manifest.deployments).find(([, v]) => v && v.uid === name);
64
+ if (byUid) resolvedName = byUid[0];
65
+ }
66
+
60
67
  const entries = options.all
61
68
  ? Object.entries(manifest.deployments)
62
- : name
63
- ? [[name, manifest.deployments[name]]]
69
+ : resolvedName
70
+ ? [[resolvedName, manifest.deployments[resolvedName]]]
64
71
  : [];
65
72
 
66
73
  if (entries.length === 0 || (entries.length === 1 && !entries[0][1])) {
@@ -14,7 +14,7 @@ import {
14
14
  } from '../lib/diff.js';
15
15
  import { fetchServerRecordsBatch } from '../lib/toe-stepping.js';
16
16
  import { findBaselineEntry } from '../lib/delta.js';
17
- import { findMetadataForCompanion } from '../lib/filenames.js';
17
+ import { isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
18
18
  import { runPendingMigrations } from '../lib/migrations.js';
19
19
 
20
20
  export const diffCommand = new Command('diff')
@@ -82,7 +82,7 @@ export const diffCommand = new Command('diff')
82
82
  let bulkAction = null;
83
83
 
84
84
  for (const metaPath of metaFiles) {
85
- const metaBase = basename(metaPath, '.metadata.json');
85
+ const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
86
86
 
87
87
  const result = await compareRecord(metaPath, config, serverRecordsMap);
88
88
 
@@ -204,7 +204,7 @@ async function resolveTargetToMetaFiles(targetPath) {
204
204
  pathStat = await stat(targetPath);
205
205
  } catch {
206
206
  // Maybe it's a file without extension — try finding metadata
207
- const metaPath = targetPath.endsWith('.metadata.json')
207
+ const metaPath = (isMetadataFile(basename(targetPath)) || targetPath.endsWith('.metadata.json'))
208
208
  ? targetPath
209
209
  : `${targetPath}.metadata.json`;
210
210
 
@@ -221,7 +221,7 @@ async function resolveTargetToMetaFiles(targetPath) {
221
221
  }
222
222
 
223
223
  // Single file — find its companion metadata
224
- if (targetPath.endsWith('.metadata.json')) {
224
+ if (isMetadataFile(basename(targetPath)) || targetPath.endsWith('.metadata.json')) {
225
225
  return [targetPath];
226
226
  }
227
227