@memberjunction/metadata-sync 5.40.2 → 5.42.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,8 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import fastGlob from 'fast-glob';
4
- import { Metadata } from '@memberjunction/core';
4
+ import chalk from 'chalk';
5
+ import { Metadata, IsVerboseLoggingEnabled } from '@memberjunction/core';
5
6
  import { UUIDsEqual } from '@memberjunction/global';
6
7
  import { IsStringSQLType } from '@memberjunction/sql-dialect';
7
8
  import { DeferrableLookupError } from '../lib/sync-engine.js';
@@ -29,6 +30,7 @@ const PARALLEL_BATCH_SIZE = 1;
29
30
  export class PushService {
30
31
  constructor(syncEngine, contextUser, stateManager) {
31
32
  this.warnings = [];
33
+ this.changeDetails = [];
32
34
  this.syncConfig = null;
33
35
  this.deferredFileWrites = new Map();
34
36
  this.deferredRecords = [];
@@ -73,8 +75,53 @@ export class PushService {
73
75
  const t = (sqlType ?? '').trim().toLowerCase();
74
76
  return t === 'string' || IsStringSQLType(t);
75
77
  }
78
+ /**
79
+ * Build the compact, single-line per-directory result shown in normal (non-verbose)
80
+ * runs — e.g. "ai-models — 722 records, no changes" or
81
+ * "ai-prompts — 3 created, 1 updated, 8 unchanged". Only non-zero buckets are listed;
82
+ * a directory with no creates/updates/deletes/errors collapses to "N records, no changes".
83
+ *
84
+ * Returns the fully-styled text plus a `changed` flag. No-op directories are dimmed so
85
+ * the (colored) changed directories pop visually; the `changed` flag lets the caller pick
86
+ * the success glyph. The "X of N" prefix is dimmed so it reads as secondary.
87
+ */
88
+ formatDirectorySummary(prefix, dirName, result, total) {
89
+ const parts = [];
90
+ if (result.created > 0)
91
+ parts.push(chalk.green(`${result.created} created`));
92
+ if (result.updated > 0)
93
+ parts.push(chalk.green(`${result.updated} updated`));
94
+ if (result.deleted > 0)
95
+ parts.push(chalk.red(`${result.deleted} deleted`));
96
+ if (result.deferred > 0)
97
+ parts.push(chalk.yellow(`${result.deferred} deferred`));
98
+ if (result.skipped > 0)
99
+ parts.push(chalk.gray(`${result.skipped} skipped`));
100
+ if (result.errors > 0)
101
+ parts.push(chalk.red(`${result.errors} error${result.errors === 1 ? '' : 's'}`));
102
+ const changed = result.created > 0 || result.updated > 0 || result.deleted > 0 || result.deferred > 0 || result.errors > 0;
103
+ if (parts.length === 0) {
104
+ // Pure no-op — dim the whole line so genuine changes stand out against the stream.
105
+ return {
106
+ text: chalk.dim(`${prefix}${dirName} — ${total} record${total === 1 ? '' : 's'}, no changes`),
107
+ changed: false,
108
+ };
109
+ }
110
+ // When something changed, append unchanged as trailing context so the totals reconcile.
111
+ if (result.unchanged > 0)
112
+ parts.push(chalk.gray(`${result.unchanged} unchanged`));
113
+ return {
114
+ text: `${chalk.dim(prefix)}${dirName} — ${parts.join(', ')}`,
115
+ changed,
116
+ };
117
+ }
76
118
  async push(options, callbacks) {
77
119
  this.warnings = [];
120
+ this.changeDetails = [];
121
+ // Respect the global MJ_VERBOSE env/flag in addition to the per-command --verbose
122
+ // flag, so a single dial (MJ_VERBOSE=1) controls diagnostic verbosity across every
123
+ // MJ CLI tool (codegen, sync, …) rather than each command inventing its own.
124
+ options.verbose = options.verbose || IsVerboseLoggingEnabled();
78
125
  // Validate that include and exclude are not used together
79
126
  if (options.include && options.exclude) {
80
127
  throw new Error('Cannot specify both --include and --exclude options. Please use one or the other.');
@@ -162,13 +209,27 @@ export class PushService {
162
209
  // Metadata.Provider — `mj sync` is single-process so they resolve to the
163
210
  // same instance today, but routing through SyncEngine keeps the wiring
164
211
  // self-consistent and makes future provider plumbing trivial.
165
- callbacks?.onLog?.('⚡ Preloading metadata and caching files...');
212
+ // Preload is internal plumbing — emit its progress only in verbose mode so a
213
+ // normal run jumps straight from validation to per-directory results.
214
+ if (options.verbose) {
215
+ callbacks?.onLog?.('⚡ Preloading metadata and caching files...');
216
+ }
166
217
  this.syncMetadataEngine.setEntityDirs(entityDirs);
167
218
  await this.syncMetadataEngine.Config(true, this.contextUser, this.syncEngine.getProvider());
168
219
  for (const warning of this.syncMetadataEngine.drainWarnings()) {
169
220
  callbacks?.onWarn?.(` ⚠️ ${warning}`);
170
221
  }
171
- callbacks?.onLog?.('✓ Preload completed successfully\n');
222
+ if (options.verbose) {
223
+ const delegations = this.syncMetadataEngine.getDelegationSummary();
224
+ if (delegations.length > 0) {
225
+ const donorCount = new Set(delegations.map(d => d.engineClassName)).size;
226
+ callbacks?.onLog?.(` ↪ Reused in-memory caches for ${delegations.length} ${delegations.length === 1 ? 'entity' : 'entities'} already loaded by ${donorCount} ${donorCount === 1 ? 'engine' : 'engines'}`);
227
+ for (const d of delegations.sort((a, b) => a.entityName.localeCompare(b.entityName))) {
228
+ callbacks?.onLog?.(` • ${d.entityName} ← ${d.engineClassName}`);
229
+ }
230
+ }
231
+ callbacks?.onLog?.('✓ Preload completed successfully\n');
232
+ }
172
233
  if (options.verbose) {
173
234
  callbacks?.onLog?.(`Found ${entityDirs.length} entity ${entityDirs.length === 1 ? 'directory' : 'directories'} to process`);
174
235
  }
@@ -226,7 +287,8 @@ export class PushService {
226
287
  skipped: 0,
227
288
  deferred: 0,
228
289
  errors: 0,
229
- warnings: this.warnings
290
+ warnings: this.warnings,
291
+ changeLog: this.changeDetails
230
292
  };
231
293
  }
232
294
  }
@@ -236,8 +298,13 @@ export class PushService {
236
298
  }
237
299
  try {
238
300
  // PHASE 1: Process creates/updates for all entities
239
- callbacks?.onLog?.('📝 Processing creates and updates...\n');
240
- for (const entityDir of entityDirs) {
301
+ if (options.verbose) {
302
+ callbacks?.onLog?.('📝 Processing creates and updates...\n');
303
+ }
304
+ for (const [dirIdx, entityDir] of entityDirs.entries()) {
305
+ // "X of N" position prefix — only when there's more than one directory, so a
306
+ // single-directory push stays uncluttered.
307
+ const progressPrefix = entityDirs.length > 1 ? `[${dirIdx + 1}/${entityDirs.length}] ` : '';
241
308
  const entityConfig = await loadEntityConfig(entityDir);
242
309
  if (!entityConfig) {
243
310
  const warning = `Skipping ${entityDir} - no valid entity configuration`;
@@ -246,12 +313,16 @@ export class PushService {
246
313
  totalSkipped++; // Count skipped directories
247
314
  continue;
248
315
  }
249
- // Show folder with spinner at start
316
+ // Show folder with spinner at start. The folder header is redundant in a
317
+ // normal run (the per-directory result line below names the directory), so
318
+ // it's verbose-only; the live spinner still shows "[X/N] Processing <dir>…".
250
319
  const dirName = path.relative(process.cwd(), entityDir) || '.';
251
- callbacks?.onLog?.(`\n📁 ${dirName}:`);
320
+ if (options.verbose) {
321
+ callbacks?.onLog?.(`\n📁 ${dirName}:`);
322
+ }
252
323
  // Use onProgress for animated spinner if available
253
324
  if (callbacks?.onProgress) {
254
- callbacks.onProgress(`Processing ${dirName}...`);
325
+ callbacks.onProgress(`${progressPrefix}Processing ${dirName}...`);
255
326
  }
256
327
  else {
257
328
  callbacks?.onLog?.(` ⏳ Processing...`);
@@ -260,13 +331,18 @@ export class PushService {
260
331
  callbacks.onLog(`Processing ${entityConfig.entity} in ${entityDir}`);
261
332
  }
262
333
  const result = await this.processEntityDirectory(entityDir, entityConfig, options, fileBackupManager, callbacks, configDir);
263
- // Stop the spinner if we were using onProgress
334
+ // Per-directory result: one compact line (always), naming the directory and
335
+ // its changes — or "no changes" for a clean dir. The detailed per-status
336
+ // breakdown is verbose-only since the final summary box already aggregates it.
337
+ const dirTotal = result.created + result.updated + result.unchanged + result.deleted + result.skipped;
338
+ const { text: dirSummary, changed: dirChanged } = this.formatDirectorySummary(progressPrefix, dirName, result, dirTotal);
264
339
  if (callbacks?.onProgress && callbacks?.onSuccess) {
265
- callbacks.onSuccess(`Processed ${dirName}`);
340
+ callbacks.onSuccess(dirSummary, dirChanged);
266
341
  }
267
- // Show per-directory summary
268
- const dirTotal = result.created + result.updated + result.unchanged + result.deleted + result.skipped;
269
- if (dirTotal > 0 || result.errors > 0) {
342
+ else {
343
+ callbacks?.onLog?.(` ${dirSummary}`);
344
+ }
345
+ if (options.verbose && (dirTotal > 0 || result.errors > 0)) {
270
346
  callbacks?.onLog?.(` Total processed: ${dirTotal} records`);
271
347
  if (result.created > 0) {
272
348
  callbacks?.onLog?.(` ✓ Created: ${result.created}`);
@@ -336,13 +412,20 @@ export class PushService {
336
412
  callbacks?.onLog?.('✅ File backups committed');
337
413
  }
338
414
  }
339
- // Close SQL logging session if it was created
415
+ // Close SQL logging session if it was created. dispose() deletes the file when
416
+ // it's empty (no statements emitted), so only surface a saved path when the log
417
+ // actually has content — otherwise the "SQL log saved to…" line would point at a
418
+ // file dispose() just unlinked.
340
419
  let sqlLogPath;
341
420
  if (sqlLoggingSession) {
342
- sqlLogPath = sqlLoggingSession.filePath;
421
+ const filePath = sqlLoggingSession.filePath;
422
+ const hadStatements = sqlLoggingSession.statementCount > 0;
343
423
  await sqlLoggingSession.dispose();
344
- if (options.verbose) {
345
- callbacks?.onLog?.(`📝 SQL log written to: ${sqlLogPath}`);
424
+ if (hadStatements) {
425
+ sqlLogPath = filePath;
426
+ if (options.verbose) {
427
+ callbacks?.onLog?.(`📝 SQL log written to: ${filePath}`);
428
+ }
346
429
  }
347
430
  }
348
431
  return {
@@ -354,7 +437,8 @@ export class PushService {
354
437
  deferred: totalDeferred,
355
438
  errors: totalErrors,
356
439
  warnings: this.warnings,
357
- sqlLogPath
440
+ sqlLogPath,
441
+ changeLog: this.changeDetails
358
442
  };
359
443
  }
360
444
  catch (error) {
@@ -748,6 +832,14 @@ export class PushService {
748
832
  const warning = `Record not found: ${entityName} with primaryKey {${pkDisplay}}. To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`;
749
833
  localWarnings.push(warning);
750
834
  callbacks?.onWarn?.(warning);
835
+ // Counted as an error below — surface it structurally too (this path
836
+ // returns rather than throws, so it wouldn't otherwise reach onRecordError).
837
+ callbacks?.onRecordError?.({
838
+ entityName,
839
+ path: flattenedRecord.path,
840
+ primaryKey: pkDisplay,
841
+ message: `Record not found: ${entityName} {${pkDisplay}} (set push.autoCreateMissingRecords=true to auto-create)`,
842
+ });
751
843
  return { status: 'error', isDuplicate: false, warnings: localWarnings }; // This will be counted as error, not skipped
752
844
  }
753
845
  else {
@@ -933,7 +1025,9 @@ export class PushService {
933
1025
  return { status: 'created', batchContextEntry };
934
1026
  }
935
1027
  }
936
- // If updating an existing record that's dirty, show what changed
1028
+ // If updating an existing record that's dirty, capture what changed for the
1029
+ // end-of-run recap. The inline diff is verbose-only now: the recap is the default
1030
+ // surface, and emitting the diff mid-spinner previously garbled the spinner line.
937
1031
  if (!isNew && isDirty) {
938
1032
  const changes = entity.GetChangesSinceLastSave();
939
1033
  const changeKeys = Object.keys(changes);
@@ -946,16 +1040,29 @@ export class PushService {
946
1040
  primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
947
1041
  }
948
1042
  }
949
- callbacks?.onLog?.(`📝 Updating ${entityName} record:`);
950
- if (primaryKeyDisplay.length > 0) {
951
- callbacks?.onLog?.(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
952
- }
953
- callbacks?.onLog?.(` Changes:`);
954
- for (const fieldName of changeKeys) {
1043
+ const fieldDiffs = changeKeys.map((fieldName) => {
955
1044
  const field = entity.GetFieldByName(fieldName);
956
- const oldValue = field ? field.OldValue : undefined;
957
- const newValue = changes[fieldName];
958
- callbacks?.onLog?.(` ${fieldName}: ${this.formatFieldValue(oldValue)} → ${this.formatFieldValue(newValue)}`);
1045
+ return {
1046
+ field: fieldName,
1047
+ oldValue: this.formatFieldValue(field ? field.OldValue : undefined),
1048
+ newValue: this.formatFieldValue(changes[fieldName]),
1049
+ };
1050
+ });
1051
+ this.changeDetails.push({
1052
+ entityName,
1053
+ primaryKey: primaryKeyDisplay.join(', '),
1054
+ operation: 'updated',
1055
+ fields: fieldDiffs,
1056
+ });
1057
+ if (options.verbose) {
1058
+ callbacks?.onLog?.(`📝 Updating ${entityName} record:`);
1059
+ if (primaryKeyDisplay.length > 0) {
1060
+ callbacks?.onLog?.(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
1061
+ }
1062
+ callbacks?.onLog?.(` Changes:`);
1063
+ for (const diff of fieldDiffs) {
1064
+ callbacks?.onLog?.(` ${diff.field}: ${diff.oldValue} → ${diff.newValue}`);
1065
+ }
959
1066
  }
960
1067
  }
961
1068
  }
@@ -1084,6 +1191,13 @@ export class PushService {
1084
1191
  if (errorMessage.includes('transaction')) {
1085
1192
  callbacks?.onError?.(` ⚠️ Transaction error detected. The database transaction may be corrupted.`);
1086
1193
  }
1194
+ // Structured, single-shot error for machine-readable consumers.
1195
+ callbacks?.onRecordError?.({
1196
+ entityName,
1197
+ path: flattenedRecord.path,
1198
+ primaryKey: primaryKeyInfo.length > 0 ? primaryKeyInfo.join(', ') : undefined,
1199
+ message: errorMessage,
1200
+ });
1087
1201
  // Throw error to trigger rollback and stop processing
1088
1202
  throw new Error(`Failed to save ${entityName} record at ${flattenedRecord.path}: ${errorMessage}`);
1089
1203
  }
@@ -1113,10 +1227,19 @@ export class PushService {
1113
1227
  const entityInfo = this.syncEngine.getEntityInfo(entityName);
1114
1228
  if (entityInfo) {
1115
1229
  const newPrimaryKey = {};
1230
+ const primaryKeyDisplay = [];
1116
1231
  for (const pk of entityInfo.PrimaryKeys) {
1117
1232
  newPrimaryKey[pk.Name] = entity.Get(pk.Name);
1233
+ primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
1118
1234
  }
1119
1235
  record.primaryKey = newPrimaryKey;
1236
+ // Capture the create for the changes recap (no field diffs — the whole record is new).
1237
+ this.changeDetails.push({
1238
+ entityName,
1239
+ primaryKey: primaryKeyDisplay.join(', '),
1240
+ operation: 'created',
1241
+ fields: [],
1242
+ });
1120
1243
  }
1121
1244
  }
1122
1245
  // Only update sync metadata if the record was actually dirty (changed)
@@ -1239,22 +1362,34 @@ export class PushService {
1239
1362
  primaryKeyDisplay.push(`${pk.Name}: ${existingEntity.Get(pk.Name)}`);
1240
1363
  }
1241
1364
  }
1242
- if (isDbOnly) {
1243
- callbacks?.onLog?.(`🗑️ Deleting database-only ${entityName} record:`);
1244
- }
1245
- else {
1246
- callbacks?.onLog?.(`🗑️ Deleting ${entityName} record:`);
1247
- }
1248
- if (primaryKeyDisplay.length > 0) {
1249
- callbacks?.onLog?.(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
1250
- }
1251
- // Additional info if available
1252
- const recordName = existingEntity.Get('Name');
1253
- if (recordName) {
1254
- callbacks?.onLog?.(` Name: ${recordName}`);
1365
+ // Deletion detail streams inline only in verbose mode — the recap is the default
1366
+ // surface (and inline logging mid-spinner garbles the spinner line).
1367
+ if (options.verbose) {
1368
+ if (isDbOnly) {
1369
+ callbacks?.onLog?.(`🗑️ Deleting database-only ${entityName} record:`);
1370
+ }
1371
+ else {
1372
+ callbacks?.onLog?.(`🗑️ Deleting ${entityName} record:`);
1373
+ }
1374
+ if (primaryKeyDisplay.length > 0) {
1375
+ callbacks?.onLog?.(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
1376
+ }
1377
+ const recordNameVerbose = existingEntity.Get('Name');
1378
+ if (recordNameVerbose) {
1379
+ callbacks?.onLog?.(` Name: ${recordNameVerbose}`);
1380
+ }
1255
1381
  }
1382
+ // Capture the deletion for the changes recap (no field diffs for deletes).
1383
+ this.changeDetails.push({
1384
+ entityName,
1385
+ primaryKey: primaryKeyDisplay.join(', '),
1386
+ operation: 'deleted',
1387
+ fields: [],
1388
+ });
1256
1389
  if (options.dryRun) {
1257
- callbacks?.onLog?.(`[DRY RUN] Would delete ${entityName} record`);
1390
+ if (options.verbose) {
1391
+ callbacks?.onLog?.(`[DRY RUN] Would delete ${entityName} record`);
1392
+ }
1258
1393
  return { status: 'deleted', isDuplicate: false };
1259
1394
  }
1260
1395
  // Delete the record
@@ -1270,6 +1405,12 @@ export class PushService {
1270
1405
  if (errorDetails) {
1271
1406
  callbacks?.onError?.(` Details: ${errorDetails}`);
1272
1407
  }
1408
+ callbacks?.onRecordError?.({
1409
+ entityName,
1410
+ path: flattenedRecord.path,
1411
+ primaryKey: primaryKeyDisplay.length > 0 ? primaryKeyDisplay.join(', ') : undefined,
1412
+ message: errorMessage,
1413
+ });
1273
1414
  throw new Error(`Failed to delete ${entityName} record: ${errorMessage}`);
1274
1415
  }
1275
1416
  // Remove from metadata cache