@dboio/cli 0.10.1 → 0.11.2

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/delta.js CHANGED
@@ -147,6 +147,17 @@ export async function detectChangedColumns(metaPath, baseline) {
147
147
  }
148
148
  }
149
149
 
150
+ // Check for columns present in baseline but removed from local metadata
151
+ // (user deleted the key or set it to null → should clear on server)
152
+ for (const [columnName, baselineVal] of Object.entries(baselineEntry)) {
153
+ if (shouldSkipColumn(columnName)) continue;
154
+ if (columnName in metadata) continue; // already compared above
155
+ // Baseline has a non-null value but local metadata doesn't have this key at all
156
+ if (baselineVal !== null && baselineVal !== undefined && normalizeValue(baselineVal) !== '') {
157
+ changedColumns.push(columnName);
158
+ }
159
+ }
160
+
150
161
  // Check _mediaFile for binary file changes (media entities)
151
162
  if (metadata._mediaFile && isReference(metadata._mediaFile)) {
152
163
  const mediaPath = resolveReferencePath(metadata._mediaFile, metaDir);
@@ -285,9 +296,90 @@ async function _detectEntityChanges(entity, baseline, metaDir) {
285
296
  }
286
297
  }
287
298
  }
299
+ // Check for columns removed from entity but present in baseline
300
+ for (const [col, baselineVal] of Object.entries(baselineEntry)) {
301
+ if (shouldSkipColumn(col) || col === 'children') continue;
302
+ if (col in entity) continue;
303
+ if (baselineVal !== null && baselineVal !== undefined && normalizeValue(baselineVal) !== '') {
304
+ changed.push(col);
305
+ }
306
+ }
288
307
  return changed;
289
308
  }
290
309
 
310
+ // ─── Bin Delta Detection ─────────────────────────────────────────────────────
311
+
312
+ /** Columns tracked for bin change detection */
313
+ const BIN_TRACKED_COLUMNS = ['Name', 'Path', 'ParentBinID', 'Active', 'Public'];
314
+
315
+ /**
316
+ * Detect changes between a current bin entry (from structure.json) and the
317
+ * baseline (from app.json children.bin array).
318
+ *
319
+ * @param {Object} binEntry - Current bin entry from structure.json (with binId, name, path, etc.)
320
+ * @param {Object} baseline - The baseline JSON (app.json)
321
+ * @returns {string[]} - Array of changed column names
322
+ */
323
+ export function detectBinChanges(binEntry, baseline) {
324
+ if (!baseline || !baseline.children || !Array.isArray(baseline.children.bin)) {
325
+ return BIN_TRACKED_COLUMNS; // no baseline → treat all as changed
326
+ }
327
+
328
+ const baselineBin = baseline.children.bin.find(
329
+ b => b.UID === binEntry.uid || b.BinID === binEntry.binId
330
+ );
331
+
332
+ if (!baselineBin) {
333
+ return BIN_TRACKED_COLUMNS; // new bin, not in baseline
334
+ }
335
+
336
+ const changed = [];
337
+
338
+ // Map structure.json field names → server column names for comparison
339
+ const fieldMap = {
340
+ Name: { current: binEntry.name, baseline: baselineBin.Name },
341
+ Path: { current: binEntry.path, baseline: baselineBin.Path },
342
+ ParentBinID: { current: binEntry.parentBinID, baseline: baselineBin.ParentBinID },
343
+ };
344
+
345
+ for (const [col, { current, baseline: base }] of Object.entries(fieldMap)) {
346
+ const curStr = current !== null && current !== undefined ? String(current) : '';
347
+ const baseStr = base !== null && base !== undefined ? String(base) : '';
348
+ if (curStr !== baseStr) changed.push(col);
349
+ }
350
+
351
+ // Direct column comparisons for Active/Public (may exist on baseline)
352
+ for (const col of ['Active', 'Public']) {
353
+ if (baselineBin[col] !== undefined && binEntry[col] !== undefined) {
354
+ if (String(binEntry[col]) !== String(baselineBin[col])) {
355
+ changed.push(col);
356
+ }
357
+ }
358
+ }
359
+
360
+ return changed;
361
+ }
362
+
363
+ /**
364
+ * Synthesize a virtual metadata object for a bin entry (for use with pushFromMetadata).
365
+ *
366
+ * @param {Object} binEntry - Bin entry from structure.json
367
+ * @param {number} [appId] - AppID from app config
368
+ * @returns {Object} - Virtual metadata object
369
+ */
370
+ export function synthesizeBinMetadata(binEntry, appId) {
371
+ const meta = {
372
+ _entity: 'bin',
373
+ _id: binEntry.binId,
374
+ UID: binEntry.uid,
375
+ Name: binEntry.name,
376
+ Path: binEntry.path,
377
+ ParentBinID: binEntry.parentBinID,
378
+ };
379
+ if (appId) meta.AppID = appId;
380
+ return meta;
381
+ }
382
+
291
383
  async function _walkChildrenForChanges(childrenObj, baseline, metaDir, result) {
292
384
  for (const docKey of _OUTPUT_DOC_KEYS) {
293
385
  const entityArray = childrenObj[docKey];
package/src/lib/diff.js CHANGED
@@ -3,9 +3,120 @@ import { readFile, writeFile, readdir, access, stat } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { loadIgnore } from './ignore.js';
5
5
  import { parseServerDate, setFileTimestamps } from './timestamps.js';
6
- import { loadConfig, loadUserInfo } from './config.js';
6
+ import { loadConfig, loadUserInfo, loadAppJsonBaseline } from './config.js';
7
+ import { findBaselineEntry } from './delta.js';
7
8
  import { log } from './logger.js';
8
9
 
10
+ // ─── Baseline Cache ─────────────────────────────────────────────────────────
11
+ // Cached baseline used by isServerNewer to compare raw _LastUpdated strings
12
+ // and avoid false-positive conflicts from timezone conversion drift.
13
+ let _cachedBaseline;
14
+
15
+ /**
16
+ * Pre-load the previous baseline into memory so that isServerNewer can
17
+ * do a fast string comparison. Call once at the start of clone/pull.
18
+ */
19
+ export async function loadBaselineForComparison() {
20
+ _cachedBaseline = await loadAppJsonBaseline();
21
+ return _cachedBaseline;
22
+ }
23
+
24
+ /**
25
+ * Clear the cached baseline.
26
+ * Call after saving a new baseline (e.g. after clone completes).
27
+ */
28
+ export function resetBaselineCache() {
29
+ _cachedBaseline = undefined;
30
+ }
31
+
32
+ /**
33
+ * Look up the baseline _LastUpdated for a given entity/uid pair.
34
+ * Requires loadBaselineForComparison() to have been called first.
35
+ */
36
+ function getBaselineLastUpdated(entity, uid) {
37
+ if (!_cachedBaseline) return undefined;
38
+ const entry = findBaselineEntry(_cachedBaseline, entity, uid);
39
+ return entry?._LastUpdated;
40
+ }
41
+
42
+ // ─── File Finding ───────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Recursively search the project directory for a file matching a bare filename.
46
+ * Respects .dboignore and skips known non-project dirs.
47
+ *
48
+ * @param {string} filename - Bare filename (e.g., "myfile.md", "myfile.metadata.json")
49
+ * @param {string} [dir=process.cwd()] - Starting directory
50
+ * @param {import('ignore').Ignore} [ig] - Ignore instance
51
+ * @returns {Promise<string[]>} - Array of matching absolute paths
52
+ */
53
+ export async function findFileInProject(filename, dir, ig) {
54
+ if (!dir) dir = process.cwd();
55
+ if (!ig) ig = await loadIgnore();
56
+
57
+ const results = [];
58
+ const entries = await readdir(dir, { withFileTypes: true });
59
+
60
+ for (const entry of entries) {
61
+ const fullPath = join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
64
+ if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
65
+ results.push(...await findFileInProject(filename, fullPath, ig));
66
+ } else if (entry.name === filename) {
67
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
68
+ if (!ig.ignores(relPath)) results.push(fullPath);
69
+ }
70
+ }
71
+
72
+ return results;
73
+ }
74
+
75
+ // ─── UID Lookup ─────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Find records by UID across all metadata files and structure.json bins.
79
+ *
80
+ * @param {string[]} uids - Array of UIDs to search for
81
+ * @returns {Promise<Array<{ uid: string, metaPath?: string, meta?: Object, binEntry?: Object }>>}
82
+ */
83
+ export async function findByUID(uids) {
84
+ const uidSet = new Set(uids);
85
+ const results = [];
86
+ const found = new Set();
87
+
88
+ // Search metadata files
89
+ const ig = await loadIgnore();
90
+ const metaFiles = await findMetadataFiles(process.cwd(), ig);
91
+
92
+ for (const metaPath of metaFiles) {
93
+ try {
94
+ const raw = await readFile(metaPath, 'utf8');
95
+ const meta = JSON.parse(raw);
96
+ if (meta.UID && uidSet.has(meta.UID)) {
97
+ results.push({ uid: meta.UID, metaPath, meta });
98
+ found.add(meta.UID);
99
+ }
100
+ } catch { /* skip unreadable files */ }
101
+ }
102
+
103
+ // Search structure.json bin entries
104
+ if (found.size < uidSet.size) {
105
+ try {
106
+ const { loadStructureFile } = await import('./structure.js');
107
+ const structure = await loadStructureFile();
108
+ for (const [binId, entry] of Object.entries(structure)) {
109
+ if (entry.uid && uidSet.has(entry.uid) && !found.has(entry.uid)) {
110
+ results.push({ uid: entry.uid, binEntry: { binId: Number(binId), ...entry } });
111
+ found.add(entry.uid);
112
+ }
113
+ }
114
+ } catch { /* no structure file */ }
115
+ }
116
+
117
+ return results;
118
+ }
119
+
9
120
  // ─── Content Value Resolution ───────────────────────────────────────────────
10
121
 
11
122
  /**
@@ -30,7 +141,7 @@ async function fileExists(path) {
30
141
 
31
142
  /**
32
143
  * Recursively find all metadata files in a directory.
33
- * Includes .metadata.json files and output hierarchy files (_output~*.json).
144
+ * Includes .metadata.json files and output hierarchy files (Name~UID.json).
34
145
  */
35
146
  export async function findMetadataFiles(dir, ig) {
36
147
  if (!ig) ig = await loadIgnore();
@@ -47,11 +158,10 @@ export async function findMetadataFiles(dir, ig) {
47
158
  } else if (entry.name.endsWith('.metadata.json')) {
48
159
  const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
49
160
  if (!ig.ignores(relPath)) results.push(fullPath);
50
- } else if (entry.name.startsWith('_output~') && entry.name.endsWith('.json') && !entry.name.includes('.CustomSQL.')) {
51
- // Output hierarchy root files only: _output~<name>~<uid>.json
161
+ } else if (entry.name.endsWith('.json') && !entry.name.endsWith('.metadata.json') && !entry.name.includes('.CustomSQL.') && entry.name.includes('~')) {
162
+ // Output hierarchy root files: Name~UID.json (or legacy _output~Name~UID.json)
52
163
  // Exclude old-format child output files — they contain a dot-prefixed
53
164
  // child-type segment (.column~, .join~, .filter~) before .json.
54
- // Root output files never have these segments, even when <name> contains dots.
55
165
  const isChildFile = /\.(column|join|filter)~/.test(entry.name);
56
166
  if (!isChildFile) {
57
167
  const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
@@ -140,11 +250,30 @@ export async function hasLocalModifications(metaPath, config = {}) {
140
250
  /**
141
251
  * Compare local sync time against server's _LastUpdated.
142
252
  * Returns true if the server record is newer than local files.
253
+ *
254
+ * When entity + uid are provided AND a baseline has been pre-loaded via
255
+ * loadBaselineForComparison(), the raw _LastUpdated strings are compared
256
+ * first. If they match exactly, the record hasn't changed on the server
257
+ * and we skip the timezone conversion entirely — preventing false positives
258
+ * caused by DST-related rounding in the mtime ↔ parseServerDate round-trip.
259
+ *
260
+ * @param {Date} localSyncTime - File mtime (from getLocalSyncTime)
261
+ * @param {string} serverLastUpdated - Raw _LastUpdated from the fresh server fetch
262
+ * @param {Object} config - Must include ServerTimezone
263
+ * @param {string} [entity] - Entity name for baseline lookup
264
+ * @param {string} [uid] - Record UID for baseline lookup
143
265
  */
144
- export function isServerNewer(localSyncTime, serverLastUpdated, config) {
266
+ export function isServerNewer(localSyncTime, serverLastUpdated, config, entity, uid) {
145
267
  if (!serverLastUpdated) return false;
146
268
  if (!localSyncTime) return true;
147
269
 
270
+ // Fast path: compare raw server string against baseline.
271
+ // If identical, the record hasn't changed — no timezone math needed.
272
+ if (entity && uid) {
273
+ const baselineLastUpdated = getBaselineLastUpdated(entity, uid);
274
+ if (baselineLastUpdated && serverLastUpdated === baselineLastUpdated) return false;
275
+ }
276
+
148
277
  const serverTz = config.ServerTimezone;
149
278
  const serverDate = parseServerDate(serverLastUpdated, serverTz);
150
279
  if (!serverDate) return false;
@@ -155,17 +284,8 @@ export function isServerNewer(localSyncTime, serverLastUpdated, config) {
155
284
 
156
285
  // ─── Server Fetching ────────────────────────────────────────────────────────
157
286
 
158
- /**
159
- * Fetch a single record from the server by entity and UID.
160
- */
161
- export async function fetchServerRecord(client, entity, uid) {
162
- const result = await client.get(`/api/output/entity/${entity}/${uid}`, {
163
- '_format': 'json_raw',
164
- });
165
- const data = result.payload || result.data;
166
- const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || [data]);
167
- return rows.length > 0 ? rows[0] : null;
168
- }
287
+ // Per-record fetch removed — all server record fetching now goes through
288
+ // fetchServerRecordsBatch() in toe-stepping.js via /api/app/object/.
169
289
 
170
290
  // ─── Diff Algorithm ─────────────────────────────────────────────────────────
171
291
 
@@ -327,8 +447,12 @@ function buildHunk(entries, start, end) {
327
447
  /**
328
448
  * Compare a local record (metadata.json + content files) against the server.
329
449
  * Returns a DiffResult object.
450
+ *
451
+ * @param {string} metaPath - Absolute path to the .metadata.json file
452
+ * @param {Object} config
453
+ * @param {Map<string, Object>} serverRecordsMap - Pre-fetched server records (uid → record)
330
454
  */
331
- export async function compareRecord(metaPath, client, config) {
455
+ export async function compareRecord(metaPath, config, serverRecordsMap) {
332
456
  let localMeta;
333
457
  try {
334
458
  localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
@@ -343,7 +467,7 @@ export async function compareRecord(metaPath, client, config) {
343
467
  return { error: `Missing _entity or UID in ${metaPath}` };
344
468
  }
345
469
 
346
- const serverRecord = await fetchServerRecord(client, entity, uid);
470
+ const serverRecord = serverRecordsMap?.get(uid) || null;
347
471
  if (!serverRecord) {
348
472
  return { error: `Record not found on server: ${entity}/${uid}` };
349
473
  }
package/src/lib/ignore.js CHANGED
@@ -16,7 +16,6 @@ const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
16
16
  .dboignore
17
17
  *.dboio.json
18
18
  app.json
19
- dbo.deploy.json
20
19
 
21
20
  # Editor / IDE / OS
22
21
  .DS_Store
@@ -43,7 +42,7 @@ package-lock.json
43
42
 
44
43
  # Local development (not pushed to DBO server)
45
44
  src/
46
- tests/
45
+ test/
47
46
 
48
47
  # Documentation (repo scaffolding)
49
48
  SETUP.md
@@ -28,20 +28,37 @@ export function resolveDirective(filePath) {
28
28
  const parts = rel.split('/');
29
29
  const topDir = parts[0];
30
30
 
31
- // docs/ prefix → static mapping
31
+ // docs/ prefix at project root → static mapping (docs/ stays at root)
32
32
  if (topDir === 'docs') {
33
33
  return { ...STATIC_DIRECTIVE_MAP.docs };
34
34
  }
35
35
 
36
- // extension/<descriptor>/need at least 3 parts (extension/descriptor/file)
36
+ // lib/<subDir>/...all server-managed dirs are now under lib/
37
+ if (topDir === 'lib') {
38
+ const subDir = parts[1];
39
+ if (!subDir) return null;
40
+
41
+ // lib/extension/<descriptor>/<file> — need at least 4 parts
42
+ if (subDir === 'extension') {
43
+ if (parts.length < 4) return null;
44
+ const descriptorDir = parts[2];
45
+ if (descriptorDir.startsWith('.') || descriptorDir === '_unsupported') return null;
46
+ return { entity: 'extension', descriptor: descriptorDir };
47
+ }
48
+
49
+ // lib/<entityType>/<file> — other entity dirs
50
+ if (ENTITY_DIR_NAMES.has(subDir)) {
51
+ return { entity: subDir, descriptor: null };
52
+ }
53
+ }
54
+
55
+ // Legacy fallback: bare entity-dir paths (pre-migration projects)
37
56
  if (topDir === 'extension') {
38
57
  if (parts.length < 3) return null;
39
58
  const secondDir = parts[1];
40
59
  if (secondDir.startsWith('.')) return null;
41
60
  return { entity: 'extension', descriptor: secondDir };
42
61
  }
43
-
44
- // Other entity-dir types
45
62
  if (ENTITY_DIR_NAMES.has(topDir)) {
46
63
  return { entity: topDir, descriptor: null };
47
64
  }
@@ -0,0 +1,75 @@
1
+ import { readdir } from 'fs/promises';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { isInitialized, loadCompletedMigrations, saveCompletedMigration } from './config.js';
5
+ import { log } from './logger.js';
6
+
7
+ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '../migrations');
8
+ const MIGRATION_FILE_RE = /^(\d{3})-[\w-]+\.js$/;
9
+
10
+ /**
11
+ * Run all pending migrations in sequence.
12
+ * Called at the start of every command handler unless options.migrate === false.
13
+ *
14
+ * @param {object} options - Command options object (checked for migrate flag)
15
+ */
16
+ export async function runPendingMigrations(options = {}) {
17
+ // --no-migrate suppresses for this invocation
18
+ if (options.migrate === false) return;
19
+
20
+ // No .dbo/ project in this directory — nothing to migrate
21
+ if (!(await isInitialized())) return;
22
+
23
+ // Discover migration files
24
+ let files;
25
+ try {
26
+ files = await readdir(MIGRATIONS_DIR);
27
+ } catch {
28
+ return; // migrations/ directory absent — safe no-op
29
+ }
30
+
31
+ const migrationFiles = files
32
+ .filter(f => MIGRATION_FILE_RE.test(f))
33
+ .sort(); // ascending by NNN prefix
34
+
35
+ if (migrationFiles.length === 0) return;
36
+
37
+ const completed = new Set(await loadCompletedMigrations());
38
+ const pending = migrationFiles.filter(f => {
39
+ const id = f.match(MIGRATION_FILE_RE)?.[1];
40
+ return id && !completed.has(id);
41
+ });
42
+
43
+ if (pending.length === 0) return;
44
+
45
+ for (const file of pending) {
46
+ const id = file.match(MIGRATION_FILE_RE)[1];
47
+ try {
48
+ const migrationPath = join(MIGRATIONS_DIR, file);
49
+ const { default: run, description } = await import(migrationPath);
50
+ await run(options);
51
+ await saveCompletedMigration(id);
52
+ log.success(`Migration ${id}: ${description}`);
53
+ } catch (err) {
54
+ log.warn(`Migration ${id} failed: ${err.message} — skipped`);
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Count pending (unrun) migrations without executing them.
61
+ * Used by `dbo status` to report pending count.
62
+ *
63
+ * @returns {Promise<number>}
64
+ */
65
+ export async function countPendingMigrations() {
66
+ if (!(await isInitialized())) return 0;
67
+ let files;
68
+ try { files = await readdir(MIGRATIONS_DIR); } catch { return 0; }
69
+ const migrationFiles = files.filter(f => MIGRATION_FILE_RE.test(f));
70
+ const completed = new Set(await loadCompletedMigrations());
71
+ return migrationFiles.filter(f => {
72
+ const id = f.match(MIGRATION_FILE_RE)?.[1];
73
+ return id && !completed.has(id);
74
+ }).length;
75
+ }
@@ -230,7 +230,7 @@ export async function saveToDisk(rows, columns, options = {}) {
230
230
 
231
231
  if (bulkAction !== 'overwrite_all') {
232
232
  const localSyncTime = await diffModule.getLocalSyncTime(metaPath);
233
- const serverIsNewer = diffModule.isServerNewer(localSyncTime, row._LastUpdated, options.config || {});
233
+ const serverIsNewer = diffModule.isServerNewer(localSyncTime, row._LastUpdated, options.config || {}, options.entity, row.UID);
234
234
 
235
235
  if (serverIsNewer) {
236
236
  const action = await diffModule.promptChangeDetection(finalName, row, options.config || {});
@@ -1,6 +1,6 @@
1
1
  import { mkdir, stat, readFile, writeFile, access } from 'fs/promises';
2
2
  import { join } from 'path';
3
- import { DEFAULT_PROJECT_DIRS } from './structure.js';
3
+ import { SCAFFOLD_DIRS } from './structure.js';
4
4
  import { log } from './logger.js';
5
5
  import { applyTrashIcon } from './folder-icon.js';
6
6
 
@@ -9,20 +9,15 @@ import { applyTrashIcon } from './folder-icon.js';
9
9
  * Creates missing directories, skips existing ones, warns on name conflicts.
10
10
  * Also creates app.json with {} if absent.
11
11
  *
12
- * When appShortName is provided, also creates media sub-directories:
13
- * media/<appShortName>/app/
14
- * media/<appShortName>/user/
15
- *
16
12
  * @param {string} [cwd=process.cwd()]
17
- * @param {{ appShortName?: string }} [options]
18
13
  * @returns {Promise<{ created: string[], skipped: string[], warned: string[] }>}
19
14
  */
20
- export async function scaffoldProjectDirs(cwd = process.cwd(), options = {}) {
15
+ export async function scaffoldProjectDirs(cwd = process.cwd()) {
21
16
  const created = [];
22
17
  const skipped = [];
23
18
  const warned = [];
24
19
 
25
- for (const dir of DEFAULT_PROJECT_DIRS) {
20
+ for (const dir of SCAFFOLD_DIRS) {
26
21
  const target = join(cwd, dir);
27
22
  try {
28
23
  const s = await stat(target);
@@ -39,26 +34,6 @@ export async function scaffoldProjectDirs(cwd = process.cwd(), options = {}) {
39
34
  }
40
35
  }
41
36
 
42
- // Create media sub-directories when app short name is known:
43
- // media/<appShortName>/app/ — app-level media assets
44
- // media/<appShortName>/user/ — user-uploaded media
45
- if (options.appShortName) {
46
- const mediaSubs = [
47
- `media/${options.appShortName}/app`,
48
- `media/${options.appShortName}/user`,
49
- ];
50
- for (const sub of mediaSubs) {
51
- const target = join(cwd, sub);
52
- try {
53
- await stat(target);
54
- skipped.push(sub);
55
- } catch {
56
- await mkdir(target, { recursive: true });
57
- created.push(sub);
58
- }
59
- }
60
- }
61
-
62
37
  // Best-effort: apply trash icon to the trash directory
63
38
  await applyTrashIcon(join(cwd, 'trash'));
64
39