@dboio/cli 0.19.4 → 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.
@@ -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
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
+ }