@dboio/cli 0.10.1 → 0.11.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.
@@ -3,24 +3,55 @@ import { join } from 'path';
3
3
 
4
4
  const STRUCTURE_FILE = '.dbo/structure.json';
5
5
 
6
- /** All bin-placed files go under this directory at project root */
7
- export const BINS_DIR = 'bins';
6
+ /** All server-managed directories live under this subdirectory */
7
+ export const LIB_DIR = 'lib';
8
8
 
9
- /** Default top-level directories created at project root during clone */
10
- export const DEFAULT_PROJECT_DIRS = [
11
- 'bins',
12
- 'automation',
13
- 'app_version',
9
+ /** All bin-placed files go under this directory */
10
+ export const BINS_DIR = 'lib/bins';
11
+
12
+ /**
13
+ * Directories always created by `dbo init --scaffold` and `dbo clone`
14
+ * regardless of whether records exist for that entity type.
15
+ */
16
+ export const SCAFFOLD_DIRS = [
17
+ 'lib/bins',
18
+ 'lib/automation',
19
+ 'lib/app_version',
20
+ 'lib/entity',
21
+ 'lib/entity_column',
22
+ 'lib/entity_column_value',
23
+ 'lib/extension',
24
+ 'lib/integration',
25
+ 'lib/security',
26
+ 'lib/security_column',
27
+ 'src',
28
+ 'test',
29
+ 'trash',
14
30
  'docs',
15
- 'site',
16
- 'media',
17
- 'extension',
31
+ ];
32
+
33
+ /**
34
+ * Entity names whose lib/<name> directories are only created on-demand
35
+ * when records of that entity type actually exist (during clone/pull).
36
+ * These are NOT created by scaffold — only by processEntityDirEntries().
37
+ */
38
+ export const ON_DEMAND_ENTITY_DIRS = new Set([
18
39
  'data_source',
19
40
  'group',
20
- 'integration',
21
- 'src',
22
- 'tests',
23
- 'trash',
41
+ 'site',
42
+ 'redirect',
43
+ ]);
44
+
45
+ /**
46
+ * Union of SCAFFOLD_DIRS and on-demand dirs.
47
+ * Used for membership checks ("is this path a known project directory?").
48
+ */
49
+ export const DEFAULT_PROJECT_DIRS = [
50
+ ...SCAFFOLD_DIRS,
51
+ 'lib/data_source',
52
+ 'lib/group',
53
+ 'lib/redirect',
54
+ 'lib/site',
24
55
  ];
25
56
 
26
57
  /** Map from physical output table names → documentation/display names */
@@ -41,16 +72,103 @@ export const OUTPUT_HIERARCHY_ENTITIES = [
41
72
 
42
73
  /** Entity keys that correspond to project directories (key IS the dir name) */
43
74
  export const ENTITY_DIR_NAMES = new Set([
44
- 'extension',
75
+ 'automation',
45
76
  'app_version',
46
77
  'data_source',
47
- 'media',
48
- 'site',
78
+ 'entity',
79
+ 'entity_column',
80
+ 'entity_column_value',
81
+ 'extension',
49
82
  'group',
50
83
  'integration',
51
- 'automation',
84
+ 'redirect',
85
+ 'security',
86
+ 'security_column',
87
+ 'site',
52
88
  ]);
53
89
 
90
+ /**
91
+ * Resolve the local directory path for an entity-dir type.
92
+ * Always returns "lib/<entityName>" (e.g. "lib/extension", "lib/site").
93
+ * Use this instead of bare entity name concatenation everywhere.
94
+ *
95
+ * @param {string} entityName - Entity key from ENTITY_DIR_NAMES (e.g. "extension")
96
+ * @returns {string} - Path relative to project root (e.g. "lib/extension")
97
+ */
98
+ export function resolveEntityDirPath(entityName) {
99
+ return `${LIB_DIR}/${entityName}`;
100
+ }
101
+
102
+ // ─── Core Asset Entity Classification ─────────────────────────────────────
103
+ //
104
+ // Core assets are entities that carry a UID, are tracked by index triggers
105
+ // (AFTER INSERT/UPDATE/DELETE → index_insert_*), and participate in the
106
+ // versioning / revision system.
107
+ //
108
+ // "Exportable" assets are part of an app's export package — a builder creates
109
+ // and manages these. "Non-exportable" assets have UIDs and index triggers
110
+ // but are system-managed or instance-specific (not included in app exports).
111
+ //
112
+ // "Data" entities (user, audit, authentication, message, etc.) do NOT carry
113
+ // auto-generated UIDs and are NOT tracked by index triggers.
114
+
115
+ /**
116
+ * Exportable core asset entities (20).
117
+ * Part of app export packages. Builder-managed. All have UID (NOT NULL, UNIQUE)
118
+ * and index triggers.
119
+ */
120
+ export const EXPORTABLE_ENTITIES = [
121
+ 'app',
122
+ 'automation',
123
+ 'bin',
124
+ 'content',
125
+ 'data_source',
126
+ 'entity',
127
+ 'entity_column',
128
+ 'entity_column_value',
129
+ 'extension',
130
+ 'group',
131
+ 'integration',
132
+ 'media',
133
+ 'output',
134
+ 'output_value',
135
+ 'output_value_entity_column_rel',
136
+ 'output_value_filter',
137
+ 'redirect',
138
+ 'security',
139
+ 'security_column',
140
+ 'site',
141
+ ];
142
+
143
+ /**
144
+ * Non-exportable core asset entities (3).
145
+ * Have UIDs and index triggers but are NOT part of app export packages.
146
+ * System-managed or instance-specific.
147
+ */
148
+ export const NON_EXPORTABLE_ENTITIES = [
149
+ 'app_version',
150
+ 'mail_server',
151
+ 'media_server',
152
+ ];
153
+
154
+ /** All core asset entities (exportable + non-exportable). 23 total. */
155
+ export const CORE_ENTITIES = [...EXPORTABLE_ENTITIES, ...NON_EXPORTABLE_ENTITIES];
156
+
157
+ /** Set variant for O(1) lookups */
158
+ export const CORE_ENTITIES_SET = new Set(CORE_ENTITIES);
159
+ export const EXPORTABLE_ENTITIES_SET = new Set(EXPORTABLE_ENTITIES);
160
+ export const NON_EXPORTABLE_ENTITIES_SET = new Set(NON_EXPORTABLE_ENTITIES);
161
+
162
+ /** Check whether an entity name is a core asset */
163
+ export function isCoreEntity(entityName) {
164
+ return CORE_ENTITIES_SET.has(entityName);
165
+ }
166
+
167
+ /** Check whether an entity name is an exportable core asset */
168
+ export function isExportableEntity(entityName) {
169
+ return EXPORTABLE_ENTITIES_SET.has(entityName);
170
+ }
171
+
54
172
  /**
55
173
  * Build a bin hierarchy from an array of bin objects.
56
174
  * Filters by targetAppId and resolves full directory paths via ParentBinID traversal.
@@ -72,6 +190,21 @@ export function buildBinHierarchy(bins, targetAppId) {
72
190
  // Build lookup by BinID
73
191
  const byId = {};
74
192
  for (const bin of filtered) {
193
+ // bin.Name=null is legacy — these bins map directly to bins/ root.
194
+ // Never split bin.Path into sub-directories for legacy null-Name bins.
195
+ if (!bin.Name) {
196
+ byId[bin.BinID] = {
197
+ name: null,
198
+ path: bin.Path,
199
+ segment: '',
200
+ parentBinID: null,
201
+ binId: bin.BinID,
202
+ uid: bin.UID,
203
+ fullPath: '',
204
+ };
205
+ continue;
206
+ }
207
+
75
208
  // Use Path as the segment name, but only the last part if it contains slashes
76
209
  // (Path often stores the full path, e.g. "assets/css/vendor" for a bin named "vendor")
77
210
  const rawPath = bin.Path || bin.Name;
@@ -116,7 +249,9 @@ export function buildBinHierarchy(bins, targetAppId) {
116
249
  */
117
250
  export function resolveBinPath(binId, structure) {
118
251
  const entry = structure[binId];
119
- return entry ? `${BINS_DIR}/${entry.fullPath}` : null;
252
+ if (!entry) return null;
253
+ // Legacy bins (Name=null) have empty fullPath → resolve to bins/ root
254
+ return entry.fullPath ? `${BINS_DIR}/${entry.fullPath}` : BINS_DIR;
120
255
  }
121
256
 
122
257
  /**
@@ -189,12 +324,12 @@ export function findChildBins(binId, structure) {
189
324
  // ─── Extension Descriptor Sub-directory Support ───────────────────────────
190
325
 
191
326
  /** Root for all extension descriptor-grouped sub-directories */
192
- export const EXTENSION_DESCRIPTORS_DIR = 'extension';
327
+ export const EXTENSION_DESCRIPTORS_DIR = 'lib/extension';
193
328
 
194
- /** Extensions that cannot be mapped go here (always created, even if empty) */
195
- export const EXTENSION_UNSUPPORTED_DIR = 'extension/_unsupported';
329
+ /** Extensions that cannot be mapped go here */
330
+ export const EXTENSION_UNSUPPORTED_DIR = 'lib/extension/_unsupported';
196
331
 
197
- /** Root-level documentation directory for alternate placement */
332
+ /** Root-level documentation directory (remains at project root) */
198
333
  export const DOCUMENTATION_DIR = 'docs';
199
334
 
200
335
  /**
@@ -0,0 +1,381 @@
1
+ import { dirname, basename } from 'path';
2
+ import { readFile } from 'fs/promises';
3
+ import { findBaselineEntry, shouldSkipColumn, normalizeValue, isReference, resolveReferencePath } from './delta.js';
4
+ import { resolveContentValue } from '../commands/clone.js';
5
+ import { log } from './logger.js';
6
+
7
+ /**
8
+ * Columns to include in the diff even though shouldSkipColumn() would normally
9
+ * skip them. _LastUpdated and _LastUpdatedUserID are valuable conflict
10
+ * indicators: _LastUpdated reveals a newer server edit, and _LastUpdatedUserID
11
+ * identifies *who* made it.
12
+ */
13
+ const DIFF_ALLOW = new Set(['_LastUpdated', '_LastUpdatedUserID']);
14
+
15
+ /**
16
+ * Fetch a single record from the server by entity name + row UID.
17
+ *
18
+ * Uses the lightweight entity endpoint:
19
+ * GET /api/o/e/{entity}/{rowUID}?_template=json_raw
20
+ *
21
+ * Returns the parsed record object, or null on failure (network, 404, auth).
22
+ *
23
+ * @param {DboClient} client
24
+ * @param {string} entity - Entity name (e.g., "content", "media", "output")
25
+ * @param {string} uid - Row UID
26
+ * @returns {Promise<Object|null>}
27
+ */
28
+ export async function fetchServerRecord(client, entity, uid) {
29
+ try {
30
+ const result = await client.get(`/api/o/e/${entity}/${uid}?_template=json_raw`);
31
+ if (!result.ok) return null;
32
+
33
+ // json_raw returns { Rows: [...] } or { rows: [...] } or an array
34
+ const data = result.payload || result.data;
35
+ if (Array.isArray(data)) return data.length > 0 ? data[0] : null;
36
+ if (data?.Rows?.length > 0) return data.Rows[0];
37
+ if (data?.rows?.length > 0) return data.rows[0];
38
+ // Direct object with UID → single record response
39
+ if (data && typeof data === 'object' && data.UID) return data;
40
+ return null;
41
+ } catch {
42
+ return null; // network or parse failure — degrade gracefully
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Fetch multiple server records in parallel.
48
+ *
49
+ * Each record is fetched independently via fetchServerRecord().
50
+ * Uses Promise.allSettled() so one failure doesn't block others.
51
+ *
52
+ * @param {DboClient} client
53
+ * @param {Array<{ entity: string, uid: string }>} requests
54
+ * @returns {Promise<Map<string, Object>>} - Map of uid → server record (only successful fetches)
55
+ */
56
+ export async function fetchServerRecords(client, requests) {
57
+ const results = await Promise.allSettled(
58
+ requests.map(({ entity, uid }) =>
59
+ fetchServerRecord(client, entity, uid).then(record => ({ uid, record }))
60
+ )
61
+ );
62
+
63
+ const map = new Map();
64
+ for (const result of results) {
65
+ if (result.status === 'fulfilled' && result.value.record) {
66
+ map.set(result.value.uid, result.value.record);
67
+ }
68
+ }
69
+ return map;
70
+ }
71
+
72
+ /**
73
+ * Fetch server records in bulk using the app object endpoint with UpdatedAfter
74
+ * filtering. This is far more efficient than per-record fetches because it
75
+ * makes a single HTTP request and the server only returns records modified
76
+ * after the given date.
77
+ *
78
+ * The response is used ONLY for comparison — it must NOT replace app.json or
79
+ * app_baseline.json.
80
+ *
81
+ * @param {DboClient} client
82
+ * @param {string} appShortName - App short name for the /api/app/object/ endpoint
83
+ * @param {string} updatedAfter - ISO date string (oldest _LastUpdated among records to push)
84
+ * @returns {Promise<Map<string, Object>>} - Map of uid → server record (across all entities)
85
+ */
86
+ export async function fetchServerRecordsBatch(client, appShortName, updatedAfter) {
87
+ const map = new Map();
88
+
89
+ try {
90
+ // Void cache first so the app object response reflects the latest server state
91
+ await client.voidCache();
92
+
93
+ const result = await client.get(`/api/app/object/${appShortName}`, {
94
+ UpdatedAfter: updatedAfter,
95
+ });
96
+
97
+ if (!result.ok && !result.successful) return map;
98
+
99
+ const data = result.payload || result.data;
100
+ if (!data) return map;
101
+
102
+ // Normalize: the response may be a single app object or wrapped in an array
103
+ let appRecord;
104
+ if (Array.isArray(data)) {
105
+ appRecord = data.length > 0 ? data[0] : null;
106
+ } else if (data?.Rows?.length > 0) {
107
+ appRecord = data.Rows[0];
108
+ } else if (data && typeof data === 'object' && (data.UID || data.children)) {
109
+ appRecord = data;
110
+ } else {
111
+ return map;
112
+ }
113
+
114
+ if (!appRecord?.children) return map;
115
+
116
+ // Walk children hierarchy and index every record by UID
117
+ _indexChildren(appRecord.children, map);
118
+ } catch {
119
+ // degrade gracefully — caller will fall back to per-record fetches
120
+ }
121
+
122
+ return map;
123
+ }
124
+
125
+ /**
126
+ * Recursively index all records in a children hierarchy into a Map<UID, record>.
127
+ * Handles nested children (e.g. output records contain children.output_value).
128
+ */
129
+ function _indexChildren(children, map) {
130
+ for (const [, entityArray] of Object.entries(children)) {
131
+ if (!Array.isArray(entityArray)) continue;
132
+ for (const record of entityArray) {
133
+ if (record.UID) {
134
+ map.set(record.UID, record);
135
+ }
136
+ // Recurse into nested children (e.g. output → output_value)
137
+ if (record.children) {
138
+ _indexChildren(record.children, map);
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Decode a server column value that may be base64-encoded.
146
+ * The json_raw format may return large text as:
147
+ * { bytes: N, value: "base64string", encoding: "base64" }
148
+ * Delegates to resolveContentValue() from clone.js.
149
+ *
150
+ * @param {*} value
151
+ * @returns {string|null}
152
+ */
153
+ function decodeServerValue(value) {
154
+ return resolveContentValue(value);
155
+ }
156
+
157
+ /**
158
+ * Compare a server record against local metadata and baseline to
159
+ * build a diff description.
160
+ *
161
+ * Returns an array of conflict columns:
162
+ * [{ col, serverValue, localValue, baselineValue }]
163
+ *
164
+ * A "conflict column" is one where the server value differs from the baseline
165
+ * (meaning the server changed since we last pulled/pushed).
166
+ *
167
+ * _LastUpdated and _LastUpdatedUserID are intentionally included in the diff
168
+ * because they carry meaningful conflict information: _LastUpdated indicates
169
+ * a newer server edit, and _LastUpdatedUserID identifies who made the change.
170
+ *
171
+ * @param {Object} serverEntry - Record from per-record server fetch
172
+ * @param {Object} baselineEntry - Record from .app_baseline.json
173
+ * @param {Object} localMeta - Local .metadata.json object
174
+ * @param {string} metaDir - Absolute directory of the metadata file
175
+ * @returns {Promise<Array<{ col: string, serverValue: string, localValue: string, baselineValue: string }>>}
176
+ */
177
+ export async function buildRecordDiff(serverEntry, baselineEntry, localMeta, metaDir) {
178
+ const conflicts = [];
179
+
180
+ // Compare all columns present in the server entry
181
+ for (const [col, rawServerVal] of Object.entries(serverEntry)) {
182
+ // Allow _LastUpdated and _LastUpdatedUserID through; skip other system cols
183
+ if (!DIFF_ALLOW.has(col) && shouldSkipColumn(col)) continue;
184
+
185
+ const serverValue = normalizeValue(decodeServerValue(rawServerVal));
186
+ const baselineValue = normalizeValue(baselineEntry ? baselineEntry[col] : undefined);
187
+
188
+ // If server value equals baseline value, no server-side change for this col
189
+ if (serverValue === baselineValue) continue;
190
+
191
+ // Server changed this column — record what's local too
192
+ const localRaw = localMeta[col];
193
+ let localValue;
194
+ if (localRaw && isReference(String(localRaw))) {
195
+ // Read file content for display (truncate for readability)
196
+ try {
197
+ const refPath = resolveReferencePath(String(localRaw), metaDir);
198
+ const content = await readFile(refPath, 'utf8');
199
+ localValue = content.substring(0, 120) + (content.length > 120 ? '…' : '');
200
+ } catch {
201
+ localValue = String(localRaw);
202
+ }
203
+ } else {
204
+ localValue = normalizeValue(localRaw);
205
+ }
206
+
207
+ conflicts.push({ col, serverValue: serverValue.substring(0, 120), localValue, baselineValue: baselineValue.substring(0, 120) });
208
+ }
209
+
210
+ return conflicts;
211
+ }
212
+
213
+ /**
214
+ * Print a conflict summary for a record to the terminal.
215
+ *
216
+ * @param {string} label - Human-readable record label (filename without extension)
217
+ * @param {string} serverUser - _LastUpdatedUserID from server
218
+ * @param {string} serverTimestamp - _LastUpdated from server
219
+ * @param {Array} diffColumns - Output of buildRecordDiff()
220
+ */
221
+ export function displayConflict(label, serverUser, serverTimestamp, diffColumns) {
222
+ log.warn('');
223
+ log.warn(` Server conflict: "${label}"`);
224
+ log.label(' Changed by', serverUser || 'unknown');
225
+ log.label(' Server time', serverTimestamp || 'unknown');
226
+ if (diffColumns.length > 0) {
227
+ log.dim(' Column changes on server:');
228
+ for (const { col, serverValue, baselineValue } of diffColumns) {
229
+ log.dim(` ${col}:`);
230
+ if (baselineValue) log.dim(` was: ${baselineValue}`);
231
+ log.dim(` now: ${serverValue}`);
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Find the oldest _LastUpdated date among the baseline entries for the
238
+ * records about to be pushed. This is used as the UpdatedAfter parameter
239
+ * to limit the app object response to only recently modified records.
240
+ *
241
+ * @param {Array<{ meta: Object }>} records
242
+ * @param {Object} baseline
243
+ * @returns {string|null} - ISO date string or null if no baseline dates found
244
+ */
245
+ function findOldestBaselineDate(records, baseline) {
246
+ let oldest = null;
247
+ for (const { meta } of records) {
248
+ if (!meta.UID || !meta._entity) continue;
249
+ const entry = findBaselineEntry(baseline, meta._entity, meta.UID);
250
+ if (!entry?._LastUpdated) continue;
251
+ const d = new Date(entry._LastUpdated);
252
+ if (isNaN(d)) continue;
253
+ if (!oldest || d < oldest) oldest = d;
254
+ }
255
+ return oldest ? oldest.toISOString() : null;
256
+ }
257
+
258
+ /**
259
+ * Main toe-stepping check. Compares each record being pushed against the
260
+ * live server state.
261
+ *
262
+ * When appShortName is provided, uses a single bulk fetch via
263
+ * GET /api/app/object/{appShortName}?UpdatedAfter={date}
264
+ * which is far more efficient than per-record fetches. Falls back to
265
+ * per-record GET /api/o/e/{entity}/{uid} when the bulk endpoint is
266
+ * unavailable or returns no data.
267
+ *
268
+ * The bulk response is used ONLY for comparison — it does NOT replace
269
+ * app.json or app_baseline.json.
270
+ *
271
+ * @param {Array<{ meta: Object, metaPath: string }>} records
272
+ * Records about to be pushed. Each must have meta.UID, meta._entity.
273
+ * @param {DboClient} client
274
+ * @param {Object} baseline - Loaded baseline from .dbo/.app_baseline.json
275
+ * @param {Object} options - Commander options (options.yes used for auto-accept)
276
+ * @param {string} [appShortName] - App short name for bulk fetch (optional)
277
+ * @returns {Promise<boolean>} - true = proceed, false = user cancelled
278
+ */
279
+ export async function checkToeStepping(records, client, baseline, options, appShortName) {
280
+ // Build list of records to check (skip new records without UID)
281
+ const requests = [];
282
+ for (const { meta } of records) {
283
+ if (meta.UID && meta._entity) {
284
+ requests.push({ entity: meta._entity, uid: meta.UID });
285
+ }
286
+ }
287
+
288
+ if (requests.length === 0) return true; // nothing to check
289
+
290
+ const ora = (await import('ora')).default;
291
+ const spinner = ora(`Checking ${requests.length} record(s) for server conflicts...`).start();
292
+
293
+ // Try bulk fetch via /api/app/object/ with UpdatedAfter when possible
294
+ let serverRecords;
295
+ if (appShortName) {
296
+ const updatedAfter = findOldestBaselineDate(records, baseline);
297
+ if (updatedAfter) {
298
+ spinner.text = `Fetching server state (UpdatedAfter: ${updatedAfter})...`;
299
+ serverRecords = await fetchServerRecordsBatch(client, appShortName, updatedAfter);
300
+ }
301
+ }
302
+
303
+ // Fall back to per-record fetches if batch returned nothing or wasn't available
304
+ if (!serverRecords || serverRecords.size === 0) {
305
+ spinner.text = `Fetching ${requests.length} record(s) from server...`;
306
+ serverRecords = await fetchServerRecords(client, requests);
307
+ }
308
+
309
+ if (serverRecords.size === 0) {
310
+ spinner.stop();
311
+ log.dim(' Toe-stepping: no server records fetched — skipping conflict check');
312
+ return true; // degrade gracefully
313
+ }
314
+
315
+ spinner.succeed(`Fetched ${serverRecords.size} record(s) from server`);
316
+
317
+ const conflicts = [];
318
+
319
+ for (const { meta, metaPath } of records) {
320
+ const uid = meta.UID;
321
+ const entity = meta._entity;
322
+ if (!uid || !entity) continue;
323
+
324
+ const serverEntry = serverRecords.get(uid);
325
+ if (!serverEntry) continue; // fetch failed or record not on server — skip
326
+
327
+ const baselineEntry = findBaselineEntry(baseline, entity, uid);
328
+
329
+ // Compare _LastUpdated: server newer than baseline → someone else changed it
330
+ const serverTs = serverEntry._LastUpdated;
331
+ const baselineTs = baselineEntry?._LastUpdated;
332
+
333
+ if (!serverTs || !baselineTs) continue; // missing timestamps — skip safely
334
+
335
+ // Parse both as dates (ISO 8601 strings or server-format timestamps)
336
+ const serverDate = new Date(serverTs);
337
+ const baselineDate = new Date(baselineTs);
338
+
339
+ if (isNaN(serverDate) || isNaN(baselineDate)) continue; // unparseable — skip
340
+ if (serverDate <= baselineDate) continue; // server is same or older — no conflict
341
+
342
+ // Conflict detected: server changed since our baseline
343
+ const metaDir = dirname(metaPath);
344
+ const label = basename(metaPath, '.metadata.json');
345
+ const diffColumns = await buildRecordDiff(serverEntry, baselineEntry, meta, metaDir);
346
+ const serverUser = serverEntry._LastUpdatedUserID || 'unknown';
347
+
348
+ displayConflict(label, serverUser, serverTs, diffColumns);
349
+ conflicts.push({ label, serverUser });
350
+ }
351
+
352
+ if (conflicts.length === 0) return true; // no conflicts
353
+
354
+ log.warn('');
355
+ log.warn(` ${conflicts.length} record(s) have server changes that would be overwritten.`);
356
+ log.warn('');
357
+
358
+ if (options.yes) {
359
+ log.dim(' --yes flag: proceeding despite server conflicts');
360
+ return true;
361
+ }
362
+
363
+ const inquirer = (await import('inquirer')).default;
364
+ const { action } = await inquirer.prompt([{
365
+ type: 'list',
366
+ name: 'action',
367
+ message: 'Server has newer changes. How would you like to proceed?',
368
+ choices: [
369
+ { name: 'Overwrite server changes (push anyway)', value: 'overwrite' },
370
+ { name: 'Cancel — pull server changes first', value: 'cancel' },
371
+ ],
372
+ }]);
373
+
374
+ if (action === 'cancel') {
375
+ log.info('Push cancelled. Run "dbo pull" to fetch server changes first.');
376
+ return false;
377
+ }
378
+
379
+ log.dim(' Proceeding — local changes will overwrite server state.');
380
+ return true;
381
+ }
@@ -0,0 +1,35 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { log } from '../lib/logger.js';
4
+
5
+ export const description = 'Restrict TransactionKeyPreset to records with a UID column';
6
+
7
+ /**
8
+ * Migration 001 — TransactionKeyPreset scope correction.
9
+ *
10
+ * The pushFromMetadata() function now only consults TransactionKeyPreset when
11
+ * a record carries a UID column (meta.UID is non-null and non-empty).
12
+ * Records without a UID column (data entities) always use RowID regardless of
13
+ * the preset. This migration notifies users of the behaviour change.
14
+ *
15
+ * @param {object} _options - Command options (unused)
16
+ */
17
+ export default async function run(_options) {
18
+ let preset;
19
+ try {
20
+ const configPath = join(process.cwd(), '.dbo', 'config.json');
21
+ const raw = await readFile(configPath, 'utf8');
22
+ preset = JSON.parse(raw).TransactionKeyPreset;
23
+ } catch {
24
+ // Not in a project dir or config unreadable — nothing to notify
25
+ return;
26
+ }
27
+
28
+ if (!preset) return; // Key not set — no notice needed
29
+
30
+ log.plain('');
31
+ log.dim(' TransactionKeyPreset now applies only to records with a UID column.');
32
+ log.dim(' Data records without a UID always use RowID (no preset applied).');
33
+ log.dim(` Your current preset: ${preset} — no config change required.`);
34
+ log.plain('');
35
+ }