@memberjunction/metadata-sync 2.50.0 → 2.52.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.
Files changed (39) hide show
  1. package/README.md +423 -2
  2. package/dist/commands/file-reset/index.d.ts +15 -0
  3. package/dist/commands/file-reset/index.js +221 -0
  4. package/dist/commands/file-reset/index.js.map +1 -0
  5. package/dist/commands/pull/index.d.ts +1 -0
  6. package/dist/commands/pull/index.js +82 -10
  7. package/dist/commands/pull/index.js.map +1 -1
  8. package/dist/commands/push/index.d.ts +21 -0
  9. package/dist/commands/push/index.js +589 -45
  10. package/dist/commands/push/index.js.map +1 -1
  11. package/dist/commands/validate/index.d.ts +15 -0
  12. package/dist/commands/validate/index.js +149 -0
  13. package/dist/commands/validate/index.js.map +1 -0
  14. package/dist/commands/watch/index.js +39 -1
  15. package/dist/commands/watch/index.js.map +1 -1
  16. package/dist/config.d.ts +7 -0
  17. package/dist/config.js.map +1 -1
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +5 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/lib/file-backup-manager.d.ts +90 -0
  22. package/dist/lib/file-backup-manager.js +186 -0
  23. package/dist/lib/file-backup-manager.js.map +1 -0
  24. package/dist/lib/provider-utils.d.ts +2 -2
  25. package/dist/lib/provider-utils.js +3 -4
  26. package/dist/lib/provider-utils.js.map +1 -1
  27. package/dist/lib/sync-engine.js +29 -3
  28. package/dist/lib/sync-engine.js.map +1 -1
  29. package/dist/services/FormattingService.d.ts +45 -0
  30. package/dist/services/FormattingService.js +564 -0
  31. package/dist/services/FormattingService.js.map +1 -0
  32. package/dist/services/ValidationService.d.ts +110 -0
  33. package/dist/services/ValidationService.js +737 -0
  34. package/dist/services/ValidationService.js.map +1 -0
  35. package/dist/types/validation.d.ts +98 -0
  36. package/dist/types/validation.js +97 -0
  37. package/dist/types/validation.js.map +1 -0
  38. package/oclif.manifest.json +205 -39
  39. package/package.json +7 -7
@@ -29,16 +29,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  const core_1 = require("@oclif/core");
30
30
  const fs_extra_1 = __importDefault(require("fs-extra"));
31
31
  const path_1 = __importDefault(require("path"));
32
+ const prompts_1 = require("@inquirer/prompts");
32
33
  const ora_classic_1 = __importDefault(require("ora-classic"));
33
34
  const fast_glob_1 = __importDefault(require("fast-glob"));
35
+ const chalk_1 = __importDefault(require("chalk"));
34
36
  const config_1 = require("../../config");
35
37
  const provider_utils_1 = require("../../lib/provider-utils");
36
- const provider_utils_2 = require("../../lib/provider-utils");
38
+ const core_2 = require("@memberjunction/core");
37
39
  const config_manager_1 = require("../../lib/config-manager");
38
40
  const singleton_manager_1 = require("../../lib/singleton-manager");
41
+ const sqlserver_dataprovider_1 = require("@memberjunction/sqlserver-dataprovider");
39
42
  const global_1 = require("@memberjunction/global");
43
+ const file_backup_manager_1 = require("../../lib/file-backup-manager");
40
44
  class Push extends core_1.Command {
41
45
  static description = 'Push local file changes to the database';
46
+ warnings = [];
47
+ errors = [];
48
+ processedRecords = new Map();
42
49
  static examples = [
43
50
  `<%= config.bin %> <%= command.id %>`,
44
51
  `<%= config.bin %> <%= command.id %> --dry-run`,
@@ -50,11 +57,23 @@ class Push extends core_1.Command {
50
57
  'dry-run': core_1.Flags.boolean({ description: 'Show what would be pushed without actually pushing' }),
51
58
  ci: core_1.Flags.boolean({ description: 'CI mode - no prompts, fail on issues' }),
52
59
  verbose: core_1.Flags.boolean({ char: 'v', description: 'Show detailed field-level output' }),
60
+ 'no-validate': core_1.Flags.boolean({ description: 'Skip validation before push' }),
53
61
  };
62
+ // Override warn to collect warnings
63
+ warn(input) {
64
+ const message = typeof input === 'string' ? input : input.message;
65
+ this.warnings.push(message);
66
+ return super.warn(input);
67
+ }
54
68
  async run() {
55
69
  const { flags } = await this.parse(Push);
56
70
  const spinner = (0, ora_classic_1.default)();
57
71
  let sqlLogger = null;
72
+ const fileBackupManager = new file_backup_manager_1.FileBackupManager();
73
+ let hasActiveTransaction = false;
74
+ const startTime = Date.now();
75
+ // Reset the processed records tracking for this push operation
76
+ this.processedRecords.clear();
58
77
  try {
59
78
  // Load configurations
60
79
  spinner.start('Loading configuration');
@@ -72,7 +91,12 @@ class Push extends core_1.Command {
72
91
  // Initialize sync engine using singleton pattern
73
92
  const syncEngine = await (0, singleton_manager_1.getSyncEngine)((0, provider_utils_1.getSystemUser)());
74
93
  // Show success after all initialization is complete
75
- spinner.succeed('Configuration and metadata loaded');
94
+ if (flags.verbose) {
95
+ spinner.succeed('Configuration and metadata loaded');
96
+ }
97
+ else {
98
+ spinner.stop();
99
+ }
76
100
  // Initialize SQL logging AFTER provider setup is complete
77
101
  if (syncConfig?.sqlLogging?.enabled) {
78
102
  const outputDir = syncConfig.sqlLogging.outputDirectory || './sql_logging';
@@ -96,18 +120,20 @@ class Push extends core_1.Command {
96
120
  // Import and access the data provider from the provider utils
97
121
  const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
98
122
  const dataProvider = getDataProvider();
99
- if (dataProvider && typeof dataProvider.createSqlLogger === 'function') {
100
- sqlLogger = await dataProvider.createSqlLogger(logFilePath, {
123
+ if (dataProvider && typeof dataProvider.CreateSqlLogger === 'function') {
124
+ sqlLogger = await dataProvider.CreateSqlLogger(logFilePath, {
101
125
  formatAsMigration,
102
126
  description: 'MetadataSync Push Operation',
103
127
  statementTypes: 'mutations', // Only log mutations (data changes)
104
128
  batchSeparator: 'GO', // Add GO statements for SQL Server batch processing
105
129
  prettyPrint: true // Enable pretty printing for readable output
106
130
  });
107
- this.log(`📝 SQL logging enabled: ${path_1.default.relative(process.cwd(), logFilePath)}`);
131
+ if (flags.verbose) {
132
+ this.log(`📝 SQL logging enabled: ${path_1.default.relative(process.cwd(), logFilePath)}`);
133
+ }
108
134
  }
109
135
  else {
110
- this.warn('SQL logging requested but data provider does not support createSqlLogger');
136
+ this.warn('SQL logging requested but data provider does not support CreateSqlLogger');
111
137
  }
112
138
  }
113
139
  // Find entity directories to process
@@ -115,10 +141,104 @@ class Push extends core_1.Command {
115
141
  if (entityDirs.length === 0) {
116
142
  this.error('No entity directories found');
117
143
  }
118
- this.log(`Found ${entityDirs.length} entity ${entityDirs.length === 1 ? 'directory' : 'directories'} to process`);
144
+ if (flags.verbose) {
145
+ this.log(`Found ${entityDirs.length} entity ${entityDirs.length === 1 ? 'directory' : 'directories'} to process`);
146
+ }
147
+ // Run validation unless disabled
148
+ if (!flags['no-validate']) {
149
+ const { ValidationService } = await Promise.resolve().then(() => __importStar(require('../../services/ValidationService')));
150
+ const { FormattingService } = await Promise.resolve().then(() => __importStar(require('../../services/FormattingService')));
151
+ spinner.start('Validating metadata...');
152
+ const validator = new ValidationService({ verbose: flags.verbose });
153
+ const formatter = new FormattingService();
154
+ const targetDir = flags.dir ? path_1.default.resolve(config_manager_1.configManager.getOriginalCwd(), flags.dir) : config_manager_1.configManager.getOriginalCwd();
155
+ const validationResult = await validator.validateDirectory(targetDir);
156
+ spinner.stop();
157
+ if (!validationResult.isValid || validationResult.warnings.length > 0) {
158
+ // Show validation results
159
+ this.log('\n' + formatter.formatValidationResult(validationResult, flags.verbose));
160
+ if (!validationResult.isValid) {
161
+ // In CI mode, fail immediately
162
+ if (flags.ci) {
163
+ this.error('Validation failed. Cannot proceed with push.');
164
+ }
165
+ // Otherwise, ask for confirmation
166
+ const shouldContinue = await (0, prompts_1.confirm)({
167
+ message: 'Validation failed with errors. Do you want to continue anyway?',
168
+ default: false
169
+ });
170
+ if (!shouldContinue) {
171
+ this.error('Push cancelled due to validation errors.');
172
+ }
173
+ }
174
+ }
175
+ else {
176
+ this.log(chalk_1.default.green('✓ Validation passed'));
177
+ }
178
+ }
179
+ // Initialize file backup manager (unless in dry-run mode)
180
+ if (!flags['dry-run']) {
181
+ await fileBackupManager.initialize();
182
+ if (flags.verbose) {
183
+ this.log('📁 File backup manager initialized');
184
+ }
185
+ }
186
+ // Start a database transaction for the entire push operation (unless in dry-run mode)
187
+ // IMPORTANT: We start the transaction AFTER metadata loading and validation to avoid
188
+ // transaction conflicts with background refresh operations
189
+ if (!flags['dry-run']) {
190
+ const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
191
+ const dataProvider = getDataProvider();
192
+ // Ensure we have SQLServerDataProvider for transaction support
193
+ if (!(dataProvider instanceof sqlserver_dataprovider_1.SQLServerDataProvider)) {
194
+ const errorMsg = 'MetadataSync requires SQLServerDataProvider for transaction support. Current provider does not support transactions.';
195
+ // Rollback file backups since we're not proceeding
196
+ try {
197
+ await fileBackupManager.rollback();
198
+ }
199
+ catch (rollbackError) {
200
+ this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
201
+ }
202
+ this.error(errorMsg);
203
+ }
204
+ if (dataProvider && typeof dataProvider.BeginTransaction === 'function') {
205
+ try {
206
+ await dataProvider.BeginTransaction();
207
+ hasActiveTransaction = true;
208
+ if (flags.verbose) {
209
+ this.log('🔄 Transaction started - all changes will be committed or rolled back as a unit');
210
+ }
211
+ }
212
+ catch (error) {
213
+ // Transaction start failure is critical - we should not proceed without it
214
+ const errorMsg = `Failed to start database transaction: ${error instanceof Error ? error.message : String(error)}`;
215
+ // Rollback file backups since we're not proceeding
216
+ try {
217
+ await fileBackupManager.rollback();
218
+ }
219
+ catch (rollbackError) {
220
+ this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
221
+ }
222
+ this.error(errorMsg);
223
+ }
224
+ }
225
+ else {
226
+ // No transaction support is also critical for data integrity
227
+ const errorMsg = 'Transaction support not available - cannot ensure data integrity';
228
+ // Rollback file backups since we're not proceeding
229
+ try {
230
+ await fileBackupManager.rollback();
231
+ }
232
+ catch (rollbackError) {
233
+ this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
234
+ }
235
+ this.error(errorMsg);
236
+ }
237
+ }
119
238
  // Process each entity directory
120
239
  let totalCreated = 0;
121
240
  let totalUpdated = 0;
241
+ let totalUnchanged = 0;
122
242
  let totalErrors = 0;
123
243
  for (const entityDir of entityDirs) {
124
244
  const entityConfig = await (0, config_1.loadEntityConfig)(entityDir);
@@ -126,23 +246,156 @@ class Push extends core_1.Command {
126
246
  this.warn(`Skipping ${entityDir} - no valid entity configuration`);
127
247
  continue;
128
248
  }
129
- this.log(`\nProcessing ${entityConfig.entity} in ${entityDir}`);
130
- const result = await this.processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig);
249
+ if (flags.verbose) {
250
+ this.log(`\nProcessing ${entityConfig.entity} in ${entityDir}`);
251
+ }
252
+ const result = await this.processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager);
253
+ // Show per-directory summary
254
+ const dirName = path_1.default.relative(process.cwd(), entityDir) || '.';
255
+ const dirTotal = result.created + result.updated + result.unchanged;
256
+ if (dirTotal > 0 || result.errors > 0) {
257
+ this.log(`\n📁 ${dirName}:`);
258
+ this.log(` Total processed: ${dirTotal} unique records`);
259
+ if (result.created > 0) {
260
+ this.log(` ✓ Created: ${result.created}`);
261
+ }
262
+ if (result.updated > 0) {
263
+ this.log(` ✓ Updated: ${result.updated}`);
264
+ }
265
+ if (result.unchanged > 0) {
266
+ this.log(` - Unchanged: ${result.unchanged}`);
267
+ }
268
+ if (result.errors > 0) {
269
+ this.log(` ✗ Errors: ${result.errors}`);
270
+ }
271
+ }
131
272
  totalCreated += result.created;
132
273
  totalUpdated += result.updated;
274
+ totalUnchanged += result.unchanged;
133
275
  totalErrors += result.errors;
134
276
  }
135
- // Summary
136
- this.log('\n=== Push Summary ===');
137
- this.log(`Created: ${totalCreated}`);
138
- this.log(`Updated: ${totalUpdated}`);
139
- this.log(`Errors: ${totalErrors}`);
140
- if (totalErrors > 0 && flags.ci) {
141
- this.error('Push failed with errors in CI mode');
277
+ // Summary using FormattingService
278
+ const endTime = Date.now();
279
+ const { FormattingService } = await Promise.resolve().then(() => __importStar(require('../../services/FormattingService')));
280
+ const formatter = new FormattingService();
281
+ this.log('\n' + formatter.formatSyncSummary('push', {
282
+ created: totalCreated,
283
+ updated: totalUpdated,
284
+ unchanged: totalUnchanged,
285
+ deleted: 0,
286
+ skipped: 0,
287
+ errors: totalErrors,
288
+ duration: endTime - startTime
289
+ }));
290
+ // Handle transaction commit/rollback
291
+ if (!flags['dry-run'] && hasActiveTransaction) {
292
+ const dataProvider = core_2.Metadata.Provider;
293
+ // We know we have an active transaction at this point
294
+ if (dataProvider) {
295
+ let shouldCommit = true;
296
+ // If there are any errors, always rollback
297
+ if (totalErrors > 0 || this.errors.length > 0) {
298
+ shouldCommit = false;
299
+ this.log('\n❌ Errors detected - rolling back all changes');
300
+ }
301
+ // If there are warnings, ask user (unless in CI mode)
302
+ else if (this.warnings.length > 0) {
303
+ // Filter out transaction-related warnings since we're now using transactions
304
+ const nonTransactionWarnings = this.warnings.filter(w => !w.includes('Transaction support not available') &&
305
+ !w.includes('Failed to start transaction'));
306
+ if (nonTransactionWarnings.length > 0) {
307
+ if (flags.ci) {
308
+ // In CI mode, rollback on warnings
309
+ shouldCommit = false;
310
+ this.log('\n⚠️ Warnings detected in CI mode - rolling back all changes');
311
+ }
312
+ else {
313
+ // Show warnings to user
314
+ this.log('\n⚠️ The following warnings were encountered:');
315
+ for (const warning of nonTransactionWarnings) {
316
+ this.log(` - ${warning}`);
317
+ }
318
+ // Ask user whether to commit or rollback
319
+ shouldCommit = await (0, prompts_1.confirm)({
320
+ message: 'Do you want to commit these changes despite the warnings?',
321
+ default: false // Default to rollback
322
+ });
323
+ }
324
+ }
325
+ }
326
+ try {
327
+ if (shouldCommit) {
328
+ await dataProvider.CommitTransaction();
329
+ this.log('\n✅ All changes committed successfully');
330
+ // Clean up file backups after successful commit
331
+ await fileBackupManager.cleanup();
332
+ }
333
+ else {
334
+ // User chose to rollback or errors/warnings in CI mode
335
+ this.log('\n🔙 Rolling back all changes...');
336
+ // Rollback database transaction
337
+ await dataProvider.RollbackTransaction();
338
+ // Rollback file changes
339
+ this.log('🔙 Rolling back file changes...');
340
+ await fileBackupManager.rollback();
341
+ this.log('✅ Rollback completed - no changes were made to the database or files');
342
+ }
343
+ }
344
+ catch (error) {
345
+ // Try to rollback on any error
346
+ this.log('\n❌ Transaction error - attempting to roll back changes');
347
+ try {
348
+ await dataProvider.RollbackTransaction();
349
+ this.log('✅ Database rollback completed');
350
+ }
351
+ catch (rollbackError) {
352
+ this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
353
+ }
354
+ // Also rollback file changes
355
+ try {
356
+ this.log('🔙 Rolling back file changes...');
357
+ await fileBackupManager.rollback();
358
+ this.log('✅ File rollback completed');
359
+ }
360
+ catch (fileRollbackError) {
361
+ this.log('❌ File rollback failed: ' + (fileRollbackError instanceof Error ? fileRollbackError.message : String(fileRollbackError)));
362
+ }
363
+ throw error;
364
+ }
365
+ }
366
+ }
367
+ // Exit with error if there were errors in CI mode
368
+ if ((totalErrors > 0 || this.errors.length > 0 || (this.warnings.length > 0 && flags.ci)) && flags.ci) {
369
+ this.error('Push failed in CI mode');
142
370
  }
143
371
  }
144
372
  catch (error) {
145
373
  spinner.fail('Push failed');
374
+ // Try to rollback the transaction and files if not in dry-run mode
375
+ if (!flags['dry-run']) {
376
+ const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
377
+ const dataProvider = getDataProvider();
378
+ // Rollback database transaction if we have one
379
+ if (hasActiveTransaction && dataProvider && typeof dataProvider.RollbackTransaction === 'function') {
380
+ try {
381
+ this.log('\n🔙 Rolling back database transaction due to error...');
382
+ await dataProvider.RollbackTransaction();
383
+ this.log('✅ Database rollback completed');
384
+ }
385
+ catch (rollbackError) {
386
+ this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
387
+ }
388
+ }
389
+ // Rollback file changes
390
+ try {
391
+ this.log('🔙 Rolling back file changes...');
392
+ await fileBackupManager.rollback();
393
+ this.log('✅ File rollback completed - all files restored to original state');
394
+ }
395
+ catch (fileRollbackError) {
396
+ this.log('❌ File rollback failed: ' + (fileRollbackError instanceof Error ? fileRollbackError.message : String(fileRollbackError)));
397
+ }
398
+ }
146
399
  // Enhanced error logging for debugging
147
400
  this.log('\n=== Push Error Details ===');
148
401
  this.log(`Error type: ${error?.constructor?.name || 'Unknown'}`);
@@ -176,7 +429,9 @@ class Push extends core_1.Command {
176
429
  if (sqlLogger) {
177
430
  try {
178
431
  await sqlLogger.dispose();
179
- this.log('✅ SQL logging session closed');
432
+ if (flags.verbose) {
433
+ this.log('✅ SQL logging session closed');
434
+ }
180
435
  }
181
436
  catch (error) {
182
437
  this.warn(`Failed to close SQL logging session: ${error}`);
@@ -184,14 +439,13 @@ class Push extends core_1.Command {
184
439
  }
185
440
  // Reset sync engine singleton
186
441
  (0, singleton_manager_1.resetSyncEngine)();
187
- // Clean up database connection
188
- await (0, provider_utils_2.cleanupProvider)();
189
442
  // Exit process to prevent background MJ tasks from throwing errors
443
+ // We don't explicitly close the connection - let the process termination handle it
190
444
  process.exit(0);
191
445
  }
192
446
  }
193
- async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig) {
194
- const result = { created: 0, updated: 0, errors: 0 };
447
+ async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager) {
448
+ const result = { created: 0, updated: 0, unchanged: 0, errors: 0 };
195
449
  // Find files matching the configured pattern
196
450
  const pattern = entityConfig.filePattern || '*.json';
197
451
  const jsonFiles = await (0, fast_glob_1.default)(pattern, {
@@ -200,9 +454,11 @@ class Push extends core_1.Command {
200
454
  dot: true, // Include dotfiles (files starting with .)
201
455
  onlyFiles: true
202
456
  });
203
- this.log(`Processing ${jsonFiles.length} records in ${path_1.default.relative(process.cwd(), entityDir) || '.'}`);
457
+ if (flags.verbose) {
458
+ this.log(`Processing ${jsonFiles.length} records in ${path_1.default.relative(process.cwd(), entityDir) || '.'}`);
459
+ }
204
460
  // First, process all JSON files in this directory
205
- await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result);
461
+ await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager);
206
462
  // Then, recursively process subdirectories
207
463
  const entries = await fs_extra_1.default.readdir(entityDir, { withFileTypes: true });
208
464
  for (const entry of entries) {
@@ -228,47 +484,68 @@ class Push extends core_1.Command {
228
484
  };
229
485
  }
230
486
  // Process subdirectory with merged config
231
- const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig);
487
+ const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig, fileBackupManager);
232
488
  result.created += subResult.created;
233
489
  result.updated += subResult.updated;
490
+ result.unchanged += subResult.unchanged;
234
491
  result.errors += subResult.errors;
235
492
  }
236
493
  }
237
494
  return result;
238
495
  }
239
- async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result) {
496
+ async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager) {
240
497
  if (jsonFiles.length === 0) {
241
498
  return;
242
499
  }
243
500
  const spinner = (0, ora_classic_1.default)();
244
501
  spinner.start('Processing records');
245
- let totalRecords = 0;
246
502
  for (const file of jsonFiles) {
247
503
  try {
248
504
  const filePath = path_1.default.join(entityDir, file);
249
- const fileContent = await fs_extra_1.default.readJson(filePath);
505
+ // Backup the file before any modifications (unless dry-run)
506
+ if (!flags['dry-run'] && fileBackupManager) {
507
+ await fileBackupManager.backupFile(filePath);
508
+ }
509
+ // Parse JSON with line number tracking
510
+ const { content: fileContent, lineNumbers } = await this.parseJsonWithLineNumbers(filePath);
250
511
  // Process templates in the loaded content
251
512
  const processedContent = await syncEngine.processTemplates(fileContent, entityDir);
252
513
  // Check if the file contains a single record or an array of records
253
514
  const isArray = Array.isArray(processedContent);
254
515
  const records = isArray ? processedContent : [processedContent];
255
- totalRecords += records.length;
256
516
  // Build and process defaults (including lookups)
257
517
  const defaults = await syncEngine.buildDefaults(filePath, entityConfig);
258
518
  // Process each record in the file
259
519
  for (let i = 0; i < records.length; i++) {
260
520
  const recordData = records[i];
261
521
  // Process the record
262
- const isNew = await this.pushRecord(recordData, entityConfig.entity, path_1.default.dirname(filePath), file, defaults, syncEngine, flags['dry-run'], flags.verbose, isArray ? i : undefined);
522
+ const recordLineNumber = lineNumbers.get(i); // Get line number for this array index
523
+ const pushResult = await this.pushRecord(recordData, entityConfig.entity, path_1.default.dirname(filePath), file, defaults, syncEngine, flags['dry-run'], flags.verbose, isArray ? i : undefined, fileBackupManager, recordLineNumber);
263
524
  if (!flags['dry-run']) {
264
- if (isNew) {
265
- result.created++;
525
+ // Don't count duplicates in stats
526
+ if (!pushResult.isDuplicate) {
527
+ if (pushResult.isNew) {
528
+ result.created++;
529
+ }
530
+ else if (pushResult.wasActuallyUpdated) {
531
+ result.updated++;
532
+ }
533
+ else {
534
+ result.unchanged++;
535
+ }
266
536
  }
267
- else {
268
- result.updated++;
537
+ // Add related entity stats
538
+ if (pushResult.relatedStats) {
539
+ result.created += pushResult.relatedStats.created;
540
+ result.updated += pushResult.relatedStats.updated;
541
+ result.unchanged += pushResult.relatedStats.unchanged;
542
+ // Debug logging for related entities
543
+ if (flags.verbose && pushResult.relatedStats.unchanged > 0) {
544
+ this.log(` Related entities: ${pushResult.relatedStats.unchanged} unchanged`);
545
+ }
269
546
  }
270
547
  }
271
- spinner.text = `Processing records (${result.created + result.updated + result.errors}/${totalRecords})`;
548
+ spinner.text = `Processing records (${result.created + result.updated + result.unchanged + result.errors} processed)`;
272
549
  }
273
550
  // Write back the entire file if it's an array
274
551
  if (isArray && !flags['dry-run']) {
@@ -278,17 +555,45 @@ class Push extends core_1.Command {
278
555
  catch (error) {
279
556
  result.errors++;
280
557
  const errorMessage = error instanceof Error ? error.message : String(error);
281
- this.error(`Failed to process ${file}: ${errorMessage}`, { exit: false });
558
+ const fullErrorMessage = `Failed to process ${file}: ${errorMessage}`;
559
+ this.errors.push(fullErrorMessage);
560
+ this.error(fullErrorMessage, { exit: false });
282
561
  }
283
562
  }
284
- spinner.succeed(`Processed ${totalRecords} records from ${jsonFiles.length} files`);
563
+ if (flags.verbose) {
564
+ spinner.succeed(`Processed ${result.created + result.updated + result.unchanged} records from ${jsonFiles.length} files`);
565
+ }
566
+ else {
567
+ spinner.stop();
568
+ }
285
569
  }
286
- async pushRecord(recordData, entityName, baseDir, fileName, defaults, syncEngine, dryRun, verbose = false, arrayIndex) {
570
+ async pushRecord(recordData, entityName, baseDir, fileName, defaults, syncEngine, dryRun, verbose = false, arrayIndex, fileBackupManager, lineNumber) {
287
571
  // Load or create entity
288
572
  let entity = null;
289
573
  let isNew = false;
290
574
  if (recordData.primaryKey) {
291
575
  entity = await syncEngine.loadEntity(entityName, recordData.primaryKey);
576
+ // Warn if record has primaryKey but wasn't found
577
+ if (!entity) {
578
+ const pkDisplay = Object.entries(recordData.primaryKey)
579
+ .map(([key, value]) => `${key}=${value}`)
580
+ .join(', ');
581
+ // Load sync config to check autoCreateMissingRecords setting
582
+ const syncConfig = await (0, config_1.loadSyncConfig)(config_manager_1.configManager.getOriginalCwd());
583
+ const autoCreate = syncConfig?.push?.autoCreateMissingRecords ?? false;
584
+ if (!autoCreate) {
585
+ const fileRef = lineNumber ? `${fileName}:${lineNumber}` : fileName;
586
+ this.warn(`⚠️ Record not found: ${entityName} with primaryKey {${pkDisplay}} at ${fileRef}`);
587
+ this.warn(` To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`);
588
+ // Skip this record
589
+ return { isNew: false, wasActuallyUpdated: false, isDuplicate: false };
590
+ }
591
+ else {
592
+ if (verbose) {
593
+ this.log(` Auto-creating missing ${entityName} record with primaryKey {${pkDisplay}}`);
594
+ }
595
+ }
596
+ }
292
597
  }
293
598
  if (!entity) {
294
599
  // New record
@@ -349,7 +654,46 @@ class Push extends core_1.Command {
349
654
  }
350
655
  if (dryRun) {
351
656
  this.log(`Would ${isNew ? 'create' : 'update'} ${entityName} record`);
352
- return isNew;
657
+ return { isNew, wasActuallyUpdated: true, isDuplicate: false, relatedStats: undefined };
658
+ }
659
+ // Check for duplicate processing (but only for existing records that were loaded)
660
+ let isDuplicate = false;
661
+ if (!isNew && entity) {
662
+ const fullFilePath = path_1.default.join(baseDir, fileName);
663
+ isDuplicate = this.checkAndTrackRecord(entityName, entity, fullFilePath, arrayIndex, lineNumber);
664
+ }
665
+ // Check if the record is dirty before saving
666
+ let wasActuallyUpdated = false;
667
+ if (!isNew && entity.Dirty) {
668
+ // Record is dirty, get the changes
669
+ const changes = entity.GetChangesSinceLastSave();
670
+ const changeKeys = Object.keys(changes);
671
+ if (changeKeys.length > 0) {
672
+ wasActuallyUpdated = true;
673
+ // Get primary key info for display
674
+ const entityInfo = syncEngine.getEntityInfo(entityName);
675
+ const primaryKeyDisplay = [];
676
+ if (entityInfo) {
677
+ for (const pk of entityInfo.PrimaryKeys) {
678
+ primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
679
+ }
680
+ }
681
+ this.log(''); // Add newline before update output
682
+ this.log(`📝 Updating ${entityName} record:`);
683
+ if (primaryKeyDisplay.length > 0) {
684
+ this.log(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
685
+ }
686
+ this.log(` Changes:`);
687
+ for (const fieldName of changeKeys) {
688
+ const field = entity.GetFieldByName(fieldName);
689
+ const oldValue = field ? field.OldValue : undefined;
690
+ const newValue = changes[fieldName];
691
+ this.log(` ${fieldName}: ${oldValue} → ${newValue}`);
692
+ }
693
+ }
694
+ }
695
+ else if (isNew) {
696
+ wasActuallyUpdated = true;
353
697
  }
354
698
  // Save the record
355
699
  const saved = await entity.Save();
@@ -362,9 +706,12 @@ class Push extends core_1.Command {
362
706
  throw new Error(`Failed to save record: ${errors}`);
363
707
  }
364
708
  // Process related entities after saving parent
709
+ let relatedStats;
365
710
  if (recordData.relatedEntities && !dryRun) {
366
- await this.processRelatedEntities(recordData.relatedEntities, entity, entity, // root is same as parent for top level
367
- baseDir, syncEngine, verbose);
711
+ const fullFilePath = path_1.default.join(baseDir, fileName);
712
+ relatedStats = await this.processRelatedEntities(recordData.relatedEntities, entity, entity, // root is same as parent for top level
713
+ baseDir, syncEngine, verbose, fileBackupManager, 1, // indentLevel
714
+ fullFilePath, arrayIndex);
368
715
  }
369
716
  // Update the local file with new primary key if created
370
717
  if (isNew) {
@@ -376,6 +723,9 @@ class Push extends core_1.Command {
376
723
  }
377
724
  recordData.primaryKey = newPrimaryKey;
378
725
  }
726
+ // Track the new record now that we have its primary key
727
+ const fullFilePath = path_1.default.join(baseDir, fileName);
728
+ this.checkAndTrackRecord(entityName, entity, fullFilePath, arrayIndex, lineNumber);
379
729
  }
380
730
  // Always update sync metadata
381
731
  // This ensures related entities are persisted with their metadata
@@ -389,12 +739,15 @@ class Push extends core_1.Command {
389
739
  const filePath = path_1.default.join(baseDir, fileName);
390
740
  await fs_extra_1.default.writeJson(filePath, recordData, { spaces: 2 });
391
741
  }
392
- return isNew;
742
+ return { isNew, wasActuallyUpdated, isDuplicate, relatedStats };
393
743
  }
394
- async processRelatedEntities(relatedEntities, parentEntity, rootEntity, baseDir, syncEngine, verbose = false, indentLevel = 1) {
744
+ async processRelatedEntities(relatedEntities, parentEntity, rootEntity, baseDir, syncEngine, verbose = false, fileBackupManager, indentLevel = 1, parentFilePath, parentArrayIndex) {
395
745
  const indent = ' '.repeat(indentLevel);
746
+ const stats = { created: 0, updated: 0, unchanged: 0 };
396
747
  for (const [entityName, records] of Object.entries(relatedEntities)) {
397
- this.log(`${indent}↳ Processing ${records.length} related ${entityName} records`);
748
+ if (verbose) {
749
+ this.log(`${indent}↳ Processing ${records.length} related ${entityName} records`);
750
+ }
398
751
  for (const relatedRecord of records) {
399
752
  try {
400
753
  // Load or create entity
@@ -402,6 +755,27 @@ class Push extends core_1.Command {
402
755
  let isNew = false;
403
756
  if (relatedRecord.primaryKey) {
404
757
  entity = await syncEngine.loadEntity(entityName, relatedRecord.primaryKey);
758
+ // Warn if record has primaryKey but wasn't found
759
+ if (!entity) {
760
+ const pkDisplay = Object.entries(relatedRecord.primaryKey)
761
+ .map(([key, value]) => `${key}=${value}`)
762
+ .join(', ');
763
+ // Load sync config to check autoCreateMissingRecords setting
764
+ const syncConfig = await (0, config_1.loadSyncConfig)(config_manager_1.configManager.getOriginalCwd());
765
+ const autoCreate = syncConfig?.push?.autoCreateMissingRecords ?? false;
766
+ if (!autoCreate) {
767
+ const fileRef = parentFilePath ? path_1.default.relative(config_manager_1.configManager.getOriginalCwd(), parentFilePath) : 'unknown';
768
+ this.warn(`${indent}⚠️ Related record not found: ${entityName} with primaryKey {${pkDisplay}} at ${fileRef}`);
769
+ this.warn(`${indent} To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`);
770
+ // Skip this record
771
+ continue;
772
+ }
773
+ else {
774
+ if (verbose) {
775
+ this.log(`${indent} Auto-creating missing related ${entityName} record with primaryKey {${pkDisplay}}`);
776
+ }
777
+ }
778
+ }
405
779
  }
406
780
  if (!entity) {
407
781
  entity = await syncEngine.createEntityObject(entityName);
@@ -452,6 +826,46 @@ class Push extends core_1.Command {
452
826
  this.warn(`${indent} Field '${field}' does not exist on entity '${entityName}'`);
453
827
  }
454
828
  }
829
+ // Check for duplicate processing (but only for existing records that were loaded)
830
+ let isDuplicate = false;
831
+ if (!isNew && entity) {
832
+ // Use parent file path for related entities since they're defined in the parent's file
833
+ const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
834
+ isDuplicate = this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
835
+ }
836
+ // Check if the record is dirty before saving
837
+ let wasActuallyUpdated = false;
838
+ if (!isNew && entity.Dirty) {
839
+ // Record is dirty, get the changes
840
+ const changes = entity.GetChangesSinceLastSave();
841
+ const changeKeys = Object.keys(changes);
842
+ if (changeKeys.length > 0) {
843
+ wasActuallyUpdated = true;
844
+ // Get primary key info for display
845
+ const entityInfo = syncEngine.getEntityInfo(entityName);
846
+ const primaryKeyDisplay = [];
847
+ if (entityInfo) {
848
+ for (const pk of entityInfo.PrimaryKeys) {
849
+ primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
850
+ }
851
+ }
852
+ this.log(''); // Add newline before update output
853
+ this.log(`${indent}📝 Updating related ${entityName} record:`);
854
+ if (primaryKeyDisplay.length > 0) {
855
+ this.log(`${indent} Primary Key: ${primaryKeyDisplay.join(', ')}`);
856
+ }
857
+ this.log(`${indent} Changes:`);
858
+ for (const fieldName of changeKeys) {
859
+ const field = entity.GetFieldByName(fieldName);
860
+ const oldValue = field ? field.OldValue : undefined;
861
+ const newValue = changes[fieldName];
862
+ this.log(`${indent} ${fieldName}: ${oldValue} → ${newValue}`);
863
+ }
864
+ }
865
+ }
866
+ else if (isNew) {
867
+ wasActuallyUpdated = true;
868
+ }
455
869
  // Save the related entity
456
870
  const saved = await entity.Save();
457
871
  if (!saved) {
@@ -462,9 +876,24 @@ class Push extends core_1.Command {
462
876
  const errors = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || 'Unknown error';
463
877
  throw new Error(`Failed to save related ${entityName}: ${errors}`);
464
878
  }
465
- if (verbose) {
879
+ // Update stats - don't count duplicates
880
+ if (!isDuplicate) {
881
+ if (isNew) {
882
+ stats.created++;
883
+ }
884
+ else if (wasActuallyUpdated) {
885
+ stats.updated++;
886
+ }
887
+ else {
888
+ stats.unchanged++;
889
+ }
890
+ }
891
+ if (verbose && wasActuallyUpdated) {
466
892
  this.log(`${indent} ✓ ${isNew ? 'Created' : 'Updated'} ${entityName} record`);
467
893
  }
894
+ else if (verbose && !wasActuallyUpdated) {
895
+ this.log(`${indent} - No changes to ${entityName} record`);
896
+ }
468
897
  // Update the related record with primary key and sync metadata
469
898
  const entityInfo = syncEngine.getEntityInfo(entityName);
470
899
  if (entityInfo) {
@@ -474,6 +903,9 @@ class Push extends core_1.Command {
474
903
  for (const pk of entityInfo.PrimaryKeys) {
475
904
  relatedRecord.primaryKey[pk.Name] = entity.Get(pk.Name);
476
905
  }
906
+ // Track the new related entity now that we have its primary key
907
+ const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
908
+ this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
477
909
  }
478
910
  // Always update sync metadata
479
911
  relatedRecord.sync = {
@@ -483,7 +915,11 @@ class Push extends core_1.Command {
483
915
  }
484
916
  // Process nested related entities if any
485
917
  if (relatedRecord.relatedEntities) {
486
- await this.processRelatedEntities(relatedRecord.relatedEntities, entity, rootEntity, baseDir, syncEngine, verbose, indentLevel + 1);
918
+ const nestedStats = await this.processRelatedEntities(relatedRecord.relatedEntities, entity, rootEntity, baseDir, syncEngine, verbose, fileBackupManager, indentLevel + 1, parentFilePath, parentArrayIndex);
919
+ // Accumulate nested stats
920
+ stats.created += nestedStats.created;
921
+ stats.updated += nestedStats.updated;
922
+ stats.unchanged += nestedStats.unchanged;
487
923
  }
488
924
  }
489
925
  catch (error) {
@@ -491,6 +927,114 @@ class Push extends core_1.Command {
491
927
  }
492
928
  }
493
929
  }
930
+ return stats;
931
+ }
932
+ /**
933
+ * Generate a unique tracking key for a record based on entity name and primary key values
934
+ */
935
+ generateRecordKey(entityName, entity) {
936
+ const entityInfo = entity.EntityInfo;
937
+ const primaryKeyValues = [];
938
+ if (entityInfo && entityInfo.PrimaryKeys.length > 0) {
939
+ for (const pk of entityInfo.PrimaryKeys) {
940
+ const value = entity.Get(pk.Name);
941
+ primaryKeyValues.push(`${pk.Name}:${value}`);
942
+ }
943
+ }
944
+ return `${entityName}|${primaryKeyValues.join('|')}`;
945
+ }
946
+ /**
947
+ * Check if a record has already been processed and warn if duplicate
948
+ */
949
+ checkAndTrackRecord(entityName, entity, filePath, arrayIndex, lineNumber) {
950
+ const recordKey = this.generateRecordKey(entityName, entity);
951
+ const existing = this.processedRecords.get(recordKey);
952
+ if (existing) {
953
+ const primaryKeyDisplay = entity.EntityInfo?.PrimaryKeys
954
+ .map(pk => `${pk.Name}: ${entity.Get(pk.Name)}`)
955
+ .join(', ') || 'unknown';
956
+ // Format file location with clickable link for VSCode
957
+ // Create maps with just the line numbers we have
958
+ const currentLineMap = lineNumber ? new Map([[arrayIndex || 0, lineNumber]]) : undefined;
959
+ const originalLineMap = existing.lineNumber ? new Map([[existing.arrayIndex || 0, existing.lineNumber]]) : undefined;
960
+ const currentLocation = this.formatFileLocation(filePath, arrayIndex, currentLineMap);
961
+ const originalLocation = this.formatFileLocation(existing.filePath, existing.arrayIndex, originalLineMap);
962
+ this.warn(`⚠️ Duplicate record detected for ${entityName} (${primaryKeyDisplay})`);
963
+ this.warn(` Current location: ${currentLocation}`);
964
+ this.warn(` Original location: ${originalLocation}`);
965
+ this.warn(` The duplicate update will proceed, but you should review your data for unintended duplicates.`);
966
+ return true; // is duplicate
967
+ }
968
+ // Track the record with its source location
969
+ this.processedRecords.set(recordKey, {
970
+ filePath: filePath || 'unknown',
971
+ arrayIndex,
972
+ lineNumber
973
+ });
974
+ return false; // not duplicate
975
+ }
976
+ /**
977
+ * Parse JSON file and track line numbers for array elements
978
+ */
979
+ async parseJsonWithLineNumbers(filePath) {
980
+ const fileText = await fs_extra_1.default.readFile(filePath, 'utf-8');
981
+ const lines = fileText.split('\n');
982
+ const lineNumbers = new Map();
983
+ // Parse the JSON
984
+ const content = JSON.parse(fileText);
985
+ // If it's an array, try to find where each element starts
986
+ if (Array.isArray(content)) {
987
+ let inString = false;
988
+ let bracketDepth = 0;
989
+ let currentIndex = -1;
990
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
991
+ const line = lines[lineNum];
992
+ // Simple tracking of string boundaries and bracket depth
993
+ for (let i = 0; i < line.length; i++) {
994
+ const char = line[i];
995
+ const prevChar = i > 0 ? line[i - 1] : '';
996
+ if (char === '"' && prevChar !== '\\') {
997
+ inString = !inString;
998
+ }
999
+ if (!inString) {
1000
+ if (char === '{') {
1001
+ bracketDepth++;
1002
+ // If we're at depth 1 in the main array, this is a new object
1003
+ if (bracketDepth === 1 && line.trim().startsWith('{')) {
1004
+ currentIndex++;
1005
+ lineNumbers.set(currentIndex, lineNum + 1); // 1-based line numbers
1006
+ }
1007
+ }
1008
+ else if (char === '}') {
1009
+ bracketDepth--;
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+ }
1015
+ return { content, lineNumbers };
1016
+ }
1017
+ /**
1018
+ * Format file location with clickable link for VSCode
1019
+ */
1020
+ formatFileLocation(filePath, arrayIndex, lineNumbers) {
1021
+ if (!filePath || filePath === 'unknown') {
1022
+ return 'unknown';
1023
+ }
1024
+ // Get absolute path for better VSCode integration
1025
+ const absolutePath = path_1.default.resolve(filePath);
1026
+ // Try to get actual line number from our tracking
1027
+ let lineNumber = 1;
1028
+ if (arrayIndex !== undefined && lineNumbers && lineNumbers.has(arrayIndex)) {
1029
+ lineNumber = lineNumbers.get(arrayIndex);
1030
+ }
1031
+ else if (arrayIndex !== undefined) {
1032
+ // Fallback estimation if we don't have actual line numbers
1033
+ lineNumber = 2 + (arrayIndex * 15);
1034
+ }
1035
+ // Create clickable file path for VSCode - format: file:line
1036
+ // VSCode will make this clickable in the terminal
1037
+ return `${absolutePath}:${lineNumber}`;
494
1038
  }
495
1039
  }
496
1040
  exports.default = Push;