@dboio/cli 0.19.7 → 0.20.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.
package/src/lib/insert.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile, readdir, writeFile, mkdir, unlink } from 'fs/promises';
1
+ import { readFile, readdir, writeFile, mkdir } from 'fs/promises';
2
2
  import { join, dirname, basename, extname, relative } from 'path';
3
3
  import { DboClient } from './client.js';
4
4
  import { buildInputBody, checkSubmitErrors } from './input-parser.js';
@@ -7,7 +7,7 @@ import { log } from './logger.js';
7
7
  import { shouldSkipColumn } from './columns.js';
8
8
  import { loadAppConfig, loadAppJsonBaseline, saveAppJsonBaseline, loadExtensionDocumentationMDPlacement, loadDescriptorFilenamePreference, loadConfig } from './config.js';
9
9
  import { loadMetadataSchema, saveMetadataSchema } from './metadata-schema.js';
10
- import { hasUidInFilename, buildMetaFilename, isMetadataFile } from './filenames.js';
10
+ import { isMetadataFile } from './filenames.js';
11
11
  import { setFileTimestamps } from './timestamps.js';
12
12
  import { checkStoredTicket, clearGlobalTicket } from './ticketing.js';
13
13
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from './modify-key.js';
@@ -366,51 +366,35 @@ export async function submitAdd(meta, metaPath, filePath, client, options) {
366
366
  }
367
367
 
368
368
  if (returnedUID) {
369
- // Check if UID is already embedded in the filename (idempotency guard)
370
- const currentMetaBase = basename(metaPath);
371
- if (hasUidInFilename(currentMetaBase, returnedUID)) {
372
- meta.UID = returnedUID;
373
- await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
374
- log.success(`UID already in filename: ${returnedUID}`);
375
- } else {
376
- // Companion file NOT renamed — only update UID in metadata and rename metadata file
377
- const metaDir = dirname(metaPath);
378
- const metaBase = basename(metaPath, '.metadata.json');
379
- const newMetaPath = join(metaDir, buildMetaFilename(metaBase, returnedUID));
380
-
381
- meta.UID = returnedUID;
382
-
383
- await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
384
- if (metaPath !== newMetaPath) {
385
- try { await unlink(metaPath); } catch { /* old file already gone */ }
386
- }
387
- log.dim(` Renamed metadata: ${basename(metaPath)} → ${basename(newMetaPath)}`);
388
- log.success(`UID assigned: ${returnedUID}`);
389
-
390
- // Restore timestamps for metadata and companion files
391
- const config = await loadConfig();
392
- const serverTz = config.ServerTimezone;
393
- if (serverTz && returnedLastUpdated) {
394
- try {
395
- await setFileTimestamps(newMetaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
396
- for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
397
- const ref = meta[col];
398
- if (ref && String(ref).startsWith('@')) {
399
- const fp = join(metaDir, String(ref).substring(1));
400
- await upsertDeployEntry(fp, returnedUID, entity, col);
401
- try { await setFileTimestamps(fp, returnedLastUpdated, returnedLastUpdated, serverTz); } catch {}
402
- }
403
- }
404
- if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
405
- const mediaFp = join(metaDir, String(meta._mediaFile).substring(1));
406
- await upsertDeployEntry(mediaFp, returnedUID, entity, 'File');
369
+ // UID lives in the JSON content — write it in-place, no filename rename needed
370
+ const metaDir = dirname(metaPath);
371
+ meta.UID = returnedUID;
372
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
373
+ log.success(`UID assigned: ${returnedUID}`);
374
+
375
+ // Restore timestamps for metadata and companion files
376
+ const config = await loadConfig();
377
+ const serverTz = config.ServerTimezone;
378
+ if (serverTz && returnedLastUpdated) {
379
+ try {
380
+ await setFileTimestamps(metaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
381
+ for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
382
+ const ref = meta[col];
383
+ if (ref && String(ref).startsWith('@')) {
384
+ const fp = join(metaDir, String(ref).substring(1));
385
+ await upsertDeployEntry(fp, returnedUID, entity, col);
386
+ try { await setFileTimestamps(fp, returnedLastUpdated, returnedLastUpdated, serverTz); } catch {}
407
387
  }
408
- } catch { /* non-critical */ }
409
- }
410
-
411
- // Update .app_baseline.json so subsequent pull/diff recognize this record
412
- await updateBaselineWithNewRecord(meta, returnedUID, returnedLastUpdated);
388
+ }
389
+ if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
390
+ const mediaFp = join(metaDir, String(meta._mediaFile).substring(1));
391
+ await upsertDeployEntry(mediaFp, returnedUID, entity, 'File');
392
+ }
393
+ } catch { /* non-critical */ }
413
394
  }
395
+
396
+ // Update .app_baseline.json so subsequent pull/diff recognize this record
397
+ await updateBaselineWithNewRecord(meta, returnedUID, returnedLastUpdated);
414
398
  log.dim(` Run "dbo pull -e ${entity} ${returnedUID}" to populate all columns`);
415
399
  }
416
400
  }
@@ -16,7 +16,7 @@ export const BINS_DIR = 'lib/bins';
16
16
  export const SCAFFOLD_DIRS = [
17
17
  'lib/bins',
18
18
  'lib/app_version',
19
- 'lib/entity',
19
+ 'lib/data_source',
20
20
  'lib/extension',
21
21
  'lib/security',
22
22
  'src',
@@ -32,7 +32,6 @@ export const SCAFFOLD_DIRS = [
32
32
  */
33
33
  export const ON_DEMAND_ENTITY_DIRS = new Set([
34
34
  'automation',
35
- 'data_source',
36
35
  'entity_column',
37
36
  'entity_column_value',
38
37
  'group',
@@ -48,7 +47,6 @@ export const ON_DEMAND_ENTITY_DIRS = new Set([
48
47
  */
49
48
  export const DEFAULT_PROJECT_DIRS = [
50
49
  ...SCAFFOLD_DIRS,
51
- 'lib/data_source',
52
50
  'lib/group',
53
51
  'lib/redirect',
54
52
  'lib/site',
@@ -87,16 +85,26 @@ export const ENTITY_DIR_NAMES = new Set([
87
85
  'site',
88
86
  ]);
89
87
 
88
+ /**
89
+ * Entity types that are co-located in another entity's directory.
90
+ * Maps entity name → directory name (both live under lib/<dirName>/).
91
+ */
92
+ const ENTITY_DIR_REMAP = {
93
+ entity: 'data_source',
94
+ };
95
+
90
96
  /**
91
97
  * Resolve the local directory path for an entity-dir type.
92
- * Always returns "lib/<entityName>" (e.g. "lib/extension", "lib/site").
98
+ * Most entities map to "lib/<entityName>"; remapped entities (e.g. "entity"
99
+ * → "lib/data_source") are defined in ENTITY_DIR_REMAP.
93
100
  * Use this instead of bare entity name concatenation everywhere.
94
101
  *
95
102
  * @param {string} entityName - Entity key from ENTITY_DIR_NAMES (e.g. "extension")
96
103
  * @returns {string} - Path relative to project root (e.g. "lib/extension")
97
104
  */
98
105
  export function resolveEntityDirPath(entityName) {
99
- return `${LIB_DIR}/${entityName}`;
106
+ const dirName = ENTITY_DIR_REMAP[entityName] ?? entityName;
107
+ return `${LIB_DIR}/${dirName}`;
100
108
  }
101
109
 
102
110
  // ─── Core Asset Entity Classification ─────────────────────────────────────
@@ -421,7 +429,9 @@ export async function loadDescriptorMapping() {
421
429
 
422
430
  /**
423
431
  * Resolve the sub-directory path for a single extension record.
424
- * Returns "extension/<MappedName>" or "extension/_unsupported".
432
+ * Returns "lib/extension/<MappedName>" for mapped descriptors,
433
+ * "lib/extension/<descriptor>" for unmapped descriptors with a value,
434
+ * or "lib/extension" for records with no descriptor.
425
435
  *
426
436
  * @param {Object} record - Extension record with a .Descriptor field
427
437
  * @param {Object} mapping - descriptor key → dir name (from buildDescriptorMapping)
@@ -429,8 +439,13 @@ export async function loadDescriptorMapping() {
429
439
  */
430
440
  export function resolveExtensionSubDir(record, mapping) {
431
441
  const descriptor = record.Descriptor;
432
- if (!descriptor || !mapping[descriptor]) {
442
+ if (!descriptor) {
433
443
  return EXTENSION_DESCRIPTORS_DIR;
434
444
  }
435
- return `${EXTENSION_DESCRIPTORS_DIR}/${mapping[descriptor]}`;
445
+ if (mapping[descriptor]) {
446
+ return `${EXTENSION_DESCRIPTORS_DIR}/${mapping[descriptor]}`;
447
+ }
448
+ // Unmapped descriptor (no descriptor_definition record): use the raw descriptor
449
+ // value as the directory name — matches the directory created by buildDescriptorPrePass.
450
+ return `${EXTENSION_DESCRIPTORS_DIR}/${descriptor}`;
436
451
  }
@@ -283,13 +283,14 @@ export async function checkStoredTicket(options, context = '') {
283
283
  }
284
284
 
285
285
  /**
286
- * Apply a stored ticket to submission data expressions if no --ticket flag is set.
287
- * Checks per-record ticket first, then global ticket.
286
+ * Look up and return the active stored ticket for a submission, if no --ticket flag is set.
287
+ * Checks per-record ticket first, then global ticket. Callers pass the returned value
288
+ * to _OverrideTicketID in extraParams.
288
289
  *
289
- * @param {string[]} dataExprs - The data expressions array (mutated in place)
290
- * @param {string} entity - Entity name
291
- * @param {string|number} rowId - Row ID or UID used in the submission
292
- * @param {string} uid - Record UID for per-record lookup
290
+ * @param {string[]} dataExprs - Unused; kept for backwards compatibility
291
+ * @param {string} entity - Unused; kept for backwards compatibility
292
+ * @param {string|number} rowId - Unused; kept for backwards compatibility
293
+ * @param {string} uid - Record UID for per-record ticket lookup
293
294
  * @param {Object} options - Command options
294
295
  * @param {string|null} [sessionOverride] - One-time ticket override from pre-flight prompt
295
296
  */
@@ -301,8 +302,8 @@ export async function applyStoredTicketToSubmission(dataExprs, entity, rowId, ui
301
302
  const ticketToUse = sessionOverride || recordTicket || globalTicket;
302
303
 
303
304
  if (ticketToUse) {
304
- const ticketExpr = buildTicketExpression(entity, rowId, ticketToUse);
305
- dataExprs.push(ticketExpr);
305
+ // Don't add _LastUpdatedTicketID to dataExprs — callers set _OverrideTicketID in extraParams,
306
+ // which is sufficient and avoids server-side RowUID lookup failures on some entities (e.g. extension).
306
307
  log.dim(` Applying ticket: ${ticketToUse}`);
307
308
  return ticketToUse;
308
309
  }
@@ -1,7 +1,7 @@
1
1
  import { readdir, rename, access } from 'fs/promises';
2
2
  import { join, basename, dirname } from 'path';
3
3
  import { log } from '../lib/logger.js';
4
- import { detectLegacyTildeMetadata, buildMetaFilename } from '../lib/filenames.js';
4
+ import { detectLegacyTildeMetadata } from '../lib/filenames.js';
5
5
 
6
6
  export const description = 'Rename metadata files from name~uid.metadata.json to name.metadata~uid.json';
7
7
 
@@ -32,7 +32,9 @@ export default async function run(_options) {
32
32
  if (!parsed) continue;
33
33
 
34
34
  const { naturalBase, uid } = parsed;
35
- const newFilename = buildMetaFilename(naturalBase, uid);
35
+ // Migration 008 target is the intermediate name.metadata~uid.json format;
36
+ // migration 013 will later remove the uid suffix entirely.
37
+ const newFilename = `${naturalBase}.metadata~${uid}.json`;
36
38
  const newPath = join(dir, newFilename);
37
39
 
38
40
  // Skip if target already exists (avoid overwrite)
@@ -1,7 +1,13 @@
1
1
  import { readdir, readFile, rename, unlink, access } from 'fs/promises';
2
2
  import { join, basename, dirname, extname } from 'path';
3
3
  import { log } from '../lib/logger.js';
4
- import { parseMetaFilename, buildMetaFilename } from '../lib/filenames.js';
4
+ import { parseMetaFilename } from '../lib/filenames.js';
5
+
6
+ // Build the intermediate name.metadata~uid.json format this migration targets.
7
+ // (Migration 013 will later strip the uid from filenames entirely.)
8
+ function buildLegacySuffixFilename(naturalBase, uid) {
9
+ return `${naturalBase}.metadata~${uid}.json`;
10
+ }
5
11
 
6
12
  export const description = 'Fix media collision suffix: rename (media) → _media and fix mismatched metadata filenames';
7
13
 
@@ -87,7 +93,7 @@ export default async function run(_options) {
87
93
  // Rename metadata file itself if it contains (media)
88
94
  if (parsed.naturalBase.includes('(media)')) {
89
95
  const newBase = parsed.naturalBase.replace('(media)', '_media');
90
- const newMetaFilename = buildMetaFilename(newBase, parsed.uid);
96
+ const newMetaFilename = buildLegacySuffixFilename(newBase, parsed.uid);
91
97
  const newMetaPath = join(dirname(metaPath), newMetaFilename);
92
98
  try { await access(newMetaPath); } catch {
93
99
  await rename(metaPath, newMetaPath);
@@ -112,7 +118,7 @@ export default async function run(_options) {
112
118
  // Already correct
113
119
  if (currentParsed.naturalBase === refFilename) continue;
114
120
 
115
- const correctFilename = buildMetaFilename(refFilename, currentParsed.uid);
121
+ const correctFilename = buildLegacySuffixFilename(refFilename, currentParsed.uid);
116
122
  const correctPath = join(dirname(metaPath), correctFilename);
117
123
 
118
124
  // If correct metadata already exists, this one is an orphan
@@ -0,0 +1,117 @@
1
+ import { readFile, writeFile, rename, readdir, access } from 'fs/promises';
2
+ import { join, basename, dirname } from 'path';
3
+
4
+ export const description = 'Rename metadata files from name.metadata~uid.json to name.metadata.json';
5
+
6
+ /**
7
+ * Migration 013 — Remove UID from metadata filenames.
8
+ *
9
+ * Old format: colors.metadata~abc123.json
10
+ * New format: colors.metadata.json
11
+ *
12
+ * The UID is already stored inside the JSON as the "UID" field — no information is lost.
13
+ *
14
+ * Collision resolution uses entity priority:
15
+ * content > output > everything else
16
+ *
17
+ * Within the same entity type, the first record (alphabetically by old filename) wins
18
+ * the unsuffixed slot; subsequent ones get -1, -2, etc.
19
+ *
20
+ * Does NOT rename companion files (they use natural names already).
21
+ */
22
+ export default async function run(_options) {
23
+ const cwd = process.cwd();
24
+ let totalRenamed = 0;
25
+
26
+ const legacyFiles = await findLegacySuffixMetadataFiles(cwd);
27
+ if (legacyFiles.length === 0) return;
28
+
29
+ // Group by directory so we can resolve collisions per-dir
30
+ const byDir = new Map();
31
+ for (const filePath of legacyFiles) {
32
+ const dir = dirname(filePath);
33
+ if (!byDir.has(dir)) byDir.set(dir, []);
34
+ byDir.get(dir).push(filePath);
35
+ }
36
+
37
+ for (const [dir, files] of byDir) {
38
+ // Read entity type from each file to apply priority ordering
39
+ const withMeta = [];
40
+ for (const filePath of files) {
41
+ let entity = 'other';
42
+ let uid = null;
43
+ try {
44
+ const content = JSON.parse(await readFile(filePath, 'utf8'));
45
+ entity = content._entity || 'other';
46
+ uid = content.UID || null;
47
+ } catch { /* use defaults */ }
48
+
49
+ // Parse naturalBase from legacy filename: name.metadata~uid.json
50
+ const filename = basename(filePath);
51
+ const m = filename.match(/^(.+)\.metadata~([a-z0-9_]+)\.json$/i);
52
+ const naturalBase = m ? m[1] : filename.replace(/\.metadata~[^.]+\.json$/i, '');
53
+
54
+ withMeta.push({ filePath, naturalBase, entity, uid });
55
+ }
56
+
57
+ // Sort by entity priority: content first, output second, then others (alphabetically within tier)
58
+ const PRIORITY = { content: 0, output: 1 };
59
+ withMeta.sort((a, b) => {
60
+ const pa = PRIORITY[a.entity] ?? 2;
61
+ const pb = PRIORITY[b.entity] ?? 2;
62
+ if (pa !== pb) return pa - pb;
63
+ return a.naturalBase.localeCompare(b.naturalBase);
64
+ });
65
+
66
+ // Assign new filenames with collision resolution
67
+ const usedBases = new Map(); // naturalBase (lowercase) → count of times used
68
+
69
+ for (const { filePath, naturalBase, entity } of withMeta) {
70
+ const baseKey = naturalBase.toLowerCase();
71
+ const count = usedBases.get(baseKey) || 0;
72
+ usedBases.set(baseKey, count + 1);
73
+
74
+ const newFilename = count === 0
75
+ ? `${naturalBase}.metadata.json`
76
+ : `${naturalBase}-${count}.metadata.json`;
77
+
78
+ const newPath = join(dir, newFilename);
79
+
80
+ // Skip if target already exists (safe guard against re-running migration)
81
+ try { await access(newPath); continue; } catch { /* doesn't exist — safe to rename */ }
82
+
83
+ try {
84
+ await rename(filePath, newPath);
85
+ if (newFilename !== basename(filePath)) {
86
+ console.log(` [${entity}] ${basename(filePath)} → ${newFilename}`);
87
+ totalRenamed++;
88
+ }
89
+ } catch (err) {
90
+ console.warn(` (skip) Could not rename ${basename(filePath)}: ${err.message}`);
91
+ }
92
+ }
93
+ }
94
+
95
+ if (totalRenamed > 0) {
96
+ console.log(` Renamed ${totalRenamed} metadata file(s) — UID now stored in JSON only`);
97
+ }
98
+ }
99
+
100
+ const SKIP = new Set(['.app', 'node_modules', 'trash', '.git', '.claude', 'app_dependencies']);
101
+
102
+ async function findLegacySuffixMetadataFiles(dir) {
103
+ const results = [];
104
+ try {
105
+ const entries = await readdir(dir, { withFileTypes: true });
106
+ for (const entry of entries) {
107
+ if (SKIP.has(entry.name)) continue;
108
+ const full = join(dir, entry.name);
109
+ if (entry.isDirectory()) {
110
+ results.push(...await findLegacySuffixMetadataFiles(full));
111
+ } else if (/\.metadata~[a-z0-9_]+\.json$/i.test(entry.name)) {
112
+ results.push(full);
113
+ }
114
+ }
115
+ } catch { /* skip unreadable dirs */ }
116
+ return results;
117
+ }
@@ -0,0 +1,68 @@
1
+ import { readdir, rename, mkdir, rmdir, access } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { log } from '../lib/logger.js';
4
+
5
+ export const description = 'Move lib/entity/ files into lib/data_source/';
6
+
7
+ /**
8
+ * Migration 014 — Relocate entity records from lib/entity/ into lib/data_source/.
9
+ *
10
+ * Entity (table-definition) records are now co-located with data source records
11
+ * under lib/data_source/. Files keep their _entity: "entity" metadata value —
12
+ * only their directory changes.
13
+ *
14
+ * If lib/data_source/ already contains a file with the same name, the source
15
+ * file is left in place and a warning is emitted; no data is overwritten.
16
+ */
17
+ export default async function run() {
18
+ const cwd = process.cwd();
19
+ const srcDir = join(cwd, 'lib', 'entity');
20
+
21
+ // Nothing to do if lib/entity/ doesn't exist
22
+ try {
23
+ await access(srcDir);
24
+ } catch {
25
+ return;
26
+ }
27
+
28
+ const entries = await readdir(srcDir, { withFileTypes: true });
29
+ if (entries.length === 0) {
30
+ // Empty directory — just remove it
31
+ try { await rmdir(srcDir); } catch { /* ignore */ }
32
+ return;
33
+ }
34
+
35
+ const destDir = join(cwd, 'lib', 'data_source');
36
+ await mkdir(destDir, { recursive: true });
37
+
38
+ let movedCount = 0;
39
+ let skippedCount = 0;
40
+
41
+ for (const entry of entries) {
42
+ const srcPath = join(srcDir, entry.name);
43
+ const destPath = join(destDir, entry.name);
44
+
45
+ // Check for collision
46
+ try {
47
+ await access(destPath);
48
+ log.warn(` Migration 014: skipped ${entry.name} — already exists in lib/data_source/`);
49
+ skippedCount++;
50
+ continue;
51
+ } catch { /* dest absent — safe to move */ }
52
+
53
+ await rename(srcPath, destPath);
54
+ movedCount++;
55
+ }
56
+
57
+ // Remove lib/entity/ if now empty
58
+ try {
59
+ const remaining = await readdir(srcDir);
60
+ if (remaining.length === 0) {
61
+ await rmdir(srcDir);
62
+ }
63
+ } catch { /* ignore */ }
64
+
65
+ if (movedCount > 0) {
66
+ log.success(` Migration 014: moved ${movedCount} file(s) from lib/entity/ → lib/data_source/${skippedCount > 0 ? ` (${skippedCount} skipped — collision)` : ''}`);
67
+ }
68
+ }