@dboio/cli 0.19.7 → 0.20.3

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.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Filename convention helpers for the metadata~uid convention.
3
- * Metadata files: name.metadata~uid.json
4
- * Companion files: natural names, no UID embedded.
2
+ * Filename convention helpers for the metadata convention.
3
+ * Metadata files: name.metadata.json (UID is stored inside the JSON, not in the filename)
4
+ * Companion files: natural names.
5
5
  */
6
6
 
7
7
  import { rename, unlink, writeFile, readFile, readdir } from 'fs/promises';
@@ -15,6 +15,8 @@ import { join, dirname, basename, extname } from 'path';
15
15
  * @param {string} uid - Server-assigned UID
16
16
  * @param {string} [ext] - File extension WITHOUT leading dot (e.g. 'css', 'png', '')
17
17
  * @returns {string} - e.g. "colors~abc123.css" or "abc123.css" or "colors~abc123"
18
+ * @deprecated Use buildMetaFilename() for metadata files. This function is retained
19
+ * only for legacy detection/migration code.
18
20
  */
19
21
  export function buildUidFilename(name, uid, ext = '') {
20
22
  const base = (name === uid) ? uid : `${name}~${uid}`;
@@ -100,6 +102,7 @@ export function stripUidFromFilename(localName, uid) {
100
102
 
101
103
  /**
102
104
  * Check whether a filename already contains ~<uid>.
105
+ * @deprecated Only used by legacy migration code.
103
106
  */
104
107
  export function hasUidInFilename(filename, uid) {
105
108
  return typeof filename === 'string' && typeof uid === 'string'
@@ -114,25 +117,28 @@ export function hasUidInFilename(filename, uid) {
114
117
  * Returns { name, uid } or null.
115
118
  */
116
119
  export function detectLegacyDotUid(filename) {
117
- const match = filename.match(/^(.+)\.([a-z0-9]{10,})\.metadata\.json$/);
120
+ const match = filename.match(/^(.+)\.([a-z0-9_]{10,})\.metadata\.json$/);
118
121
  return match ? { name: match[1], uid: match[2] } : null;
119
122
  }
120
123
 
121
124
  /**
122
- * Build the new-convention metadata filename.
123
- * Format: <naturalBase>.metadata~<uid>.json
125
+ * Build a metadata filename for a record.
126
+ * Format: <naturalBase>.metadata.json
127
+ * The UID is stored inside the JSON, not in the filename.
124
128
  *
125
- * For content records: naturalBase = "colors" → "colors.metadata~abc123.json"
126
- * For media records: naturalBase = "logo.png" → "logo.png.metadata~abc123.json"
127
- * For output records: naturalBase = "Sales" → "Sales.metadata~abc123.json"
129
+ * For content records: naturalBase = "colors" → "colors.metadata.json"
130
+ * For media records: naturalBase = "logo.png" → "logo.png.metadata.json"
131
+ * For output records: naturalBase = "Sales" → "Sales.metadata.json"
132
+ *
133
+ * Collision avoidance (when two records share a name) is handled by the caller
134
+ * via numbered suffixes: "colors-1.metadata.json", "colors-2.metadata.json", etc.
128
135
  *
129
136
  * @param {string} naturalBase - Name without any ~uid (may include media extension)
130
- * @param {string} uid - Server-assigned UID
131
137
  * @returns {string}
132
138
  */
133
- export function buildMetaFilename(naturalBase, uid) {
139
+ export function buildMetaFilename(naturalBase) {
134
140
  // Guard: strip any trailing .metadata suffix(es) and ~uid fragments from naturalBase
135
- // to prevent double-metadata filenames (e.g., "app.metadata.metadata~app.json")
141
+ // to prevent double-metadata filenames (e.g., "app.metadata.metadata.json")
136
142
  let base = naturalBase;
137
143
  const metaParsed = parseMetaFilename(base + '.json');
138
144
  if (metaParsed) {
@@ -142,34 +148,43 @@ export function buildMetaFilename(naturalBase, uid) {
142
148
  while (base.endsWith('.metadata')) {
143
149
  base = base.substring(0, base.length - 9);
144
150
  }
145
- return `${base}.metadata~${uid}.json`;
151
+ return `${base}.metadata.json`;
146
152
  }
147
153
 
148
154
  /**
149
155
  * Test whether a filename is a metadata file (new or legacy format).
150
156
  *
151
- * New format: name.metadata~uid.json
152
- * Legacy format: name~uid.metadata.json (also accepted during migration)
157
+ * New format: name.metadata.json (UID in JSON content, not filename)
158
+ * Legacy format: name.metadata~uid.json (also accepted during migration)
153
159
  *
154
160
  * @param {string} filename
155
161
  * @returns {boolean}
156
162
  */
157
163
  export function isMetadataFile(filename) {
158
- return /\.metadata~[a-z0-9]+\.json$/i.test(filename) // new: name.metadata~uid.json
159
- || filename.endsWith('.metadata.json') // legacy + pre-UID temp files
160
- || /\.[a-z0-9]{10,}\.metadata\.json$/i.test(filename); // legacy dot-separated: name.uid.metadata.json
164
+ return filename.endsWith('.metadata.json') // new format + legacy pre-UID temp files
165
+ || /\.metadata~[a-z0-9_]+\.json$/i.test(filename) // legacy tilde-uid suffix format
166
+ || /\.[a-z0-9]{10,}\.metadata\.json$/i.test(filename); // legacy dot-separated: name.uid.metadata.json
161
167
  }
162
168
 
163
169
  /**
164
- * Parse a new-format metadata filename into its components.
165
- * Returns null for legacy-format filenames (use detectLegacyTildeMetadata for those).
170
+ * Parse a metadata filename into its components.
171
+ *
172
+ * New format: name.metadata.json → { naturalBase: "name" }
173
+ * Legacy format: name.metadata~uid.json → { naturalBase: "name", uid: "uid" }
166
174
  *
167
- * @param {string} filename - e.g. "colors.metadata~abc123.json"
168
- * @returns {{ naturalBase: string, uid: string } | null}
175
+ * Returns null for non-metadata filenames.
176
+ *
177
+ * @param {string} filename
178
+ * @returns {{ naturalBase: string, uid?: string } | null}
169
179
  */
170
180
  export function parseMetaFilename(filename) {
171
- const m = filename.match(/^(.+)\.metadata~([a-z0-9]+)\.json$/i);
172
- return m ? { naturalBase: m[1], uid: m[2] } : null;
181
+ // Legacy format with uid in suffix (migration target for 008, source for 013)
182
+ const legacy = filename.match(/^(.+)\.metadata~([a-z0-9_]+)\.json$/i);
183
+ if (legacy) return { naturalBase: legacy[1], uid: legacy[2] };
184
+
185
+ // New format: name.metadata.json
186
+ const m = filename.match(/^(.+)\.metadata\.json$/i);
187
+ return m ? { naturalBase: m[1] } : null;
173
188
  }
174
189
 
175
190
  /**
@@ -181,11 +196,11 @@ export function parseMetaFilename(filename) {
181
196
  */
182
197
  export function detectLegacyTildeMetadata(filename) {
183
198
  // Case 1: name~uid.metadata.json (content/entity metadata)
184
- const m1 = filename.match(/^(.+)~([a-z0-9]{10,})\.metadata\.json$/);
199
+ const m1 = filename.match(/^(.+)~([a-z0-9_]{10,})\.metadata\.json$/);
185
200
  if (m1) return { naturalBase: m1[1], uid: m1[2] };
186
201
 
187
202
  // Case 2: name~uid.ext.metadata.json (media metadata)
188
- const m2 = filename.match(/^(.+)~([a-z0-9]{10,})\.([a-z0-9]+)\.metadata\.json$/);
203
+ const m2 = filename.match(/^(.+)~([a-z0-9_]{10,})\.([a-z0-9]+)\.metadata\.json$/);
189
204
  if (m2) return { naturalBase: `${m2[1]}.${m2[3]}`, uid: m2[2] };
190
205
 
191
206
  return null;
@@ -210,7 +225,7 @@ export async function findMetadataForCompanion(companionPath) {
210
225
  let entries;
211
226
  try { entries = await readdir(dir); } catch { return null; }
212
227
 
213
- // 1. Fast path: match naturalBase of new-format metadata files
228
+ // 1. Fast path: match naturalBase of metadata files
214
229
  for (const entry of entries) {
215
230
  const parsed = parseMetaFilename(entry);
216
231
  if (parsed && (parsed.naturalBase === base || parsed.naturalBase === companionName)) {
@@ -218,7 +233,7 @@ export async function findMetadataForCompanion(companionPath) {
218
233
  }
219
234
  }
220
235
 
221
- // 2. Scan all metadata files (new + legacy) for @reference match
236
+ // 2. Scan all metadata files for @reference match
222
237
  for (const entry of entries) {
223
238
  if (!isMetadataFile(entry)) continue;
224
239
  const metaPath = join(dir, entry);
@@ -241,8 +256,10 @@ export async function findMetadataForCompanion(companionPath) {
241
256
  }
242
257
 
243
258
  /**
244
- * Rename a file pair (content + metadata) to the ~uid convention after server assigns a UID.
245
- * Updates @reference values inside the metadata file.
259
+ * Update the UID field in a metadata file in-place.
260
+ * Previously renamed the file to include ~uid in the name; now the UID lives
261
+ * only inside the JSON content, so no rename is needed.
262
+ *
246
263
  * Restores file timestamps from _LastUpdated.
247
264
  *
248
265
  * @param {Object} meta - Current metadata object
@@ -250,57 +267,28 @@ export async function findMetadataForCompanion(companionPath) {
250
267
  * @param {string} uid - Newly assigned UID from server
251
268
  * @param {string} lastUpdated - Server _LastUpdated value
252
269
  * @param {string} serverTz - Timezone for timestamp conversion
253
- * @returns {Promise<{ newMetaPath: string, newFilePath: string|null }>}
270
+ * @returns {Promise<{ newMetaPath: string, newFilePath: null, updatedMeta: Object }>}
254
271
  */
255
272
  export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, serverTz) {
256
- const metaDir = dirname(metaPath);
257
- const metaFilename = basename(metaPath);
258
-
259
- // Already in new format — nothing to do
260
- if (parseMetaFilename(metaFilename)?.uid === uid) {
261
- return { newMetaPath: metaPath, newFilePath: null, updatedMeta: meta };
262
- }
263
-
264
- // Determine naturalBase from the temp/old metadata filename
265
- // Temp format from adopt.js: "colors.metadata.json" → naturalBase = "colors"
266
- // Old tilde format: "colors~uid.metadata.json" → naturalBase = "colors"
267
- let naturalBase;
268
- const legacyParsed = detectLegacyTildeMetadata(metaFilename);
269
- if (legacyParsed) {
270
- naturalBase = legacyParsed.naturalBase;
271
- } else if (metaFilename.endsWith('.metadata.json')) {
272
- naturalBase = metaFilename.slice(0, -'.metadata.json'.length);
273
- } else {
274
- naturalBase = basename(metaFilename, '.json');
275
- }
276
-
277
- const newMetaPath = join(metaDir, buildMetaFilename(naturalBase, uid));
278
-
279
- // Update only the UID field; @reference values are UNCHANGED (companions keep natural names)
280
273
  const updatedMeta = { ...meta, UID: uid };
281
274
 
282
- // Write updated metadata to new path
283
- await writeFile(newMetaPath, JSON.stringify(updatedMeta, null, 2) + '\n');
284
-
285
- // Remove old metadata file (new content already written to newMetaPath above)
286
- if (metaPath !== newMetaPath) {
287
- try { await unlink(metaPath); } catch { /* old file already gone */ }
288
- }
275
+ // Write updated metadata to same path (no rename — UID is in the JSON, not the filename)
276
+ await writeFile(metaPath, JSON.stringify(updatedMeta, null, 2) + '\n');
289
277
 
290
- // Restore timestamps for metadata file and companions (companions are not renamed)
278
+ // Restore timestamps for metadata file and companions
291
279
  if (serverTz && lastUpdated) {
292
280
  const { setFileTimestamps } = await import('./timestamps.js');
293
- try { await setFileTimestamps(newMetaPath, lastUpdated, lastUpdated, serverTz); } catch {}
281
+ try { await setFileTimestamps(metaPath, lastUpdated, lastUpdated, serverTz); } catch {}
294
282
  const contentCols = [...(meta._companionReferenceColumns || meta._contentColumns || [])];
295
283
  if (meta._mediaFile) contentCols.push('_mediaFile');
296
284
  for (const col of contentCols) {
297
285
  const ref = updatedMeta[col];
298
286
  if (ref && String(ref).startsWith('@')) {
299
- const fp = join(metaDir, String(ref).substring(1));
287
+ const fp = join(dirname(metaPath), String(ref).substring(1));
300
288
  try { await setFileTimestamps(fp, lastUpdated, lastUpdated, serverTz); } catch {}
301
289
  }
302
290
  }
303
291
  }
304
292
 
305
- return { newMetaPath, newFilePath: null, updatedMeta };
293
+ return { newMetaPath: metaPath, newFilePath: null, updatedMeta };
306
294
  }
package/src/lib/ignore.js CHANGED
@@ -11,6 +11,9 @@ const DBOIGNORE_FILE = '.dboignore';
11
11
  const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
12
12
  # (gitignore-style syntax — works like .gitignore)
13
13
 
14
+ # Build artifacts
15
+ *.map
16
+
14
17
  # DBO internal
15
18
  .app/
16
19
  .dboignore
@@ -37,12 +37,8 @@ export async function buildInputBody(dataExpressions, extraParams = {}) {
37
37
  }
38
38
 
39
39
  for (const expr of dataExpressions) {
40
- // Split by & to handle multiple ops in one -d string
41
- const ops = expr.split('&');
42
- for (const op of ops) {
43
- const encoded = await encodeInputExpression(op.trim());
44
- parts.push(encoded);
45
- }
40
+ const encoded = await encodeInputExpression(expr.trim());
41
+ parts.push(encoded);
46
42
  }
47
43
 
48
44
  return parts.join('&');
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';
@@ -283,7 +283,8 @@ export async function submitAdd(meta, metaPath, filePath, client, options) {
283
283
 
284
284
  for (const [key, value] of Object.entries(meta)) {
285
285
  if (shouldSkipColumn(key)) continue;
286
- if (key === 'UID') continue; // Never submit UID on add server assigns it
286
+ // UID is included only if the developer manually placed it in the metadata file.
287
+ // The CLI never auto-generates UIDs — the server assigns them on insert.
287
288
  if (value === null || value === undefined) continue;
288
289
 
289
290
  const strValue = String(value);
@@ -366,51 +367,35 @@ export async function submitAdd(meta, metaPath, filePath, client, options) {
366
367
  }
367
368
 
368
369
  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');
370
+ // UID lives in the JSON content — write it in-place, no filename rename needed
371
+ const metaDir = dirname(metaPath);
372
+ meta.UID = returnedUID;
373
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
374
+ log.success(`UID assigned: ${returnedUID}`);
375
+
376
+ // Restore timestamps for metadata and companion files
377
+ const config = await loadConfig();
378
+ const serverTz = config.ServerTimezone;
379
+ if (serverTz && returnedLastUpdated) {
380
+ try {
381
+ await setFileTimestamps(metaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
382
+ for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
383
+ const ref = meta[col];
384
+ if (ref && String(ref).startsWith('@')) {
385
+ const fp = join(metaDir, String(ref).substring(1));
386
+ await upsertDeployEntry(fp, returnedUID, entity, col);
387
+ try { await setFileTimestamps(fp, returnedLastUpdated, returnedLastUpdated, serverTz); } catch {}
407
388
  }
408
- } catch { /* non-critical */ }
409
- }
410
-
411
- // Update .app_baseline.json so subsequent pull/diff recognize this record
412
- await updateBaselineWithNewRecord(meta, returnedUID, returnedLastUpdated);
389
+ }
390
+ if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
391
+ const mediaFp = join(metaDir, String(meta._mediaFile).substring(1));
392
+ await upsertDeployEntry(mediaFp, returnedUID, entity, 'File');
393
+ }
394
+ } catch { /* non-critical */ }
413
395
  }
396
+
397
+ // Update .app_baseline.json so subsequent pull/diff recognize this record
398
+ await updateBaselineWithNewRecord(meta, returnedUID, returnedLastUpdated);
414
399
  log.dim(` Run "dbo pull -e ${entity} ${returnedUID}" to populate all columns`);
415
400
  }
416
401
  }
@@ -497,8 +482,8 @@ export async function findUnaddedFiles(dir, ig, referencedFiles) {
497
482
  const raw = await readFile(join(dir, entry.name), 'utf8');
498
483
  if (!raw.trim()) continue;
499
484
  const meta = JSON.parse(raw);
500
- // Only count records that are on the server (have UID or _CreatedOn)
501
- if (!meta.UID && !meta._CreatedOn) continue;
485
+ // Only count records that are on the server (have _CreatedOn or _LastUpdated)
486
+ if (!meta._CreatedOn && !meta._LastUpdated) continue;
502
487
  collectCompanionRefs(meta, localRefs);
503
488
  } catch { /* skip unreadable */ }
504
489
  }
@@ -566,7 +551,7 @@ async function _scanMetadataRefs(dir, referenced) {
566
551
  const raw = await readFile(fullPath, 'utf8');
567
552
  if (!raw.trim()) continue;
568
553
  const meta = JSON.parse(raw);
569
- if (!meta._CreatedOn && !meta.UID) continue; // only count server-confirmed records
554
+ if (!meta._CreatedOn && !meta._LastUpdated) continue; // only count server-confirmed records
570
555
 
571
556
  // Collect all @ references (including from inline output children)
572
557
  const allRefs = new Set();
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import { readFile, writeFile, mkdir } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { log } from './logger.js';
4
- import { projectDir } from './config.js';
4
+ import { projectDir, saveRepositoryIntegrationID, loadRepositoryIntegrationID } from './config.js';
5
5
 
6
6
  const TICKETING_FILE = 'ticketing.local.json';
7
7
 
@@ -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,10 +302,66 @@ 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
  }
309
310
  return null;
310
311
  }
312
+
313
+ /**
314
+ * Fetch the app object from the server and cache RepositoryIntegrationID in .app/config.json.
315
+ *
316
+ * Uses UpdatedAfter=<today> to keep the response small — children records are filtered
317
+ * but the top-level app record (including RepositoryIntegrationID) is always returned.
318
+ *
319
+ * @param {DboClient} client
320
+ * @param {string} appShortName
321
+ * @returns {Promise<{ id: string|null, fetched: boolean }>}
322
+ * id: the RepositoryIntegrationID value (or null if not set)
323
+ * fetched: true if the server responded successfully, false on network/parse failure
324
+ */
325
+ export async function fetchAndCacheRepositoryIntegration(client, appShortName) {
326
+ if (!appShortName) return { id: null, fetched: false };
327
+
328
+ try {
329
+ const today = new Date().toISOString().substring(0, 10); // YYYY-MM-DD
330
+ const result = await client.get(
331
+ `/api/app/object/${encodeURIComponent(appShortName)}`,
332
+ { UpdatedAfter: today }
333
+ );
334
+
335
+ if (!result.ok && !result.successful) return { id: null, fetched: false };
336
+
337
+ const data = result.payload || result.data;
338
+ if (!data) return { id: null, fetched: false };
339
+
340
+ // Normalize response shape (array, Rows wrapper, or direct object)
341
+ let appRecord;
342
+ if (Array.isArray(data)) {
343
+ appRecord = data.length > 0 ? data[0] : null;
344
+ } else if (data?.Rows?.length > 0) {
345
+ appRecord = data.Rows[0];
346
+ } else if (data?.rows?.length > 0) {
347
+ appRecord = data.rows[0];
348
+ } else if (data && typeof data === 'object' && (data.UID || data.ShortName)) {
349
+ appRecord = data;
350
+ } else {
351
+ return { id: null, fetched: false };
352
+ }
353
+
354
+ if (!appRecord) return { id: null, fetched: false };
355
+
356
+ const id = (appRecord.RepositoryIntegrationID != null && appRecord.RepositoryIntegrationID !== '')
357
+ ? appRecord.RepositoryIntegrationID
358
+ : null;
359
+
360
+ // Persist so push can fall back to this if the next fetch fails
361
+ await saveRepositoryIntegrationID(id);
362
+
363
+ return { id, fetched: true };
364
+ } catch {
365
+ return { id: null, fetched: false };
366
+ }
367
+ }