@memberjunction/metadata-sync 2.51.0 → 2.53.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.
@@ -38,11 +38,14 @@ const provider_utils_1 = require("../../lib/provider-utils");
38
38
  const core_2 = require("@memberjunction/core");
39
39
  const config_manager_1 = require("../../lib/config-manager");
40
40
  const singleton_manager_1 = require("../../lib/singleton-manager");
41
+ const sqlserver_dataprovider_1 = require("@memberjunction/sqlserver-dataprovider");
41
42
  const global_1 = require("@memberjunction/global");
43
+ const file_backup_manager_1 = require("../../lib/file-backup-manager");
42
44
  class Push extends core_1.Command {
43
45
  static description = 'Push local file changes to the database';
44
46
  warnings = [];
45
47
  errors = [];
48
+ processedRecords = new Map();
46
49
  static examples = [
47
50
  `<%= config.bin %> <%= command.id %>`,
48
51
  `<%= config.bin %> <%= command.id %> --dry-run`,
@@ -66,7 +69,11 @@ class Push extends core_1.Command {
66
69
  const { flags } = await this.parse(Push);
67
70
  const spinner = (0, ora_classic_1.default)();
68
71
  let sqlLogger = null;
72
+ const fileBackupManager = new file_backup_manager_1.FileBackupManager();
73
+ let hasActiveTransaction = false;
69
74
  const startTime = Date.now();
75
+ // Reset the processed records tracking for this push operation
76
+ this.processedRecords.clear();
70
77
  try {
71
78
  // Load configurations
72
79
  spinner.start('Loading configuration');
@@ -130,7 +137,7 @@ class Push extends core_1.Command {
130
137
  }
131
138
  }
132
139
  // Find entity directories to process
133
- const entityDirs = (0, provider_utils_1.findEntityDirectories)(config_manager_1.configManager.getOriginalCwd(), flags.dir, syncConfig?.directoryOrder);
140
+ const entityDirs = (0, provider_utils_1.findEntityDirectories)(config_manager_1.configManager.getOriginalCwd(), flags.dir, syncConfig?.directoryOrder, syncConfig?.ignoreDirectories);
134
141
  if (entityDirs.length === 0) {
135
142
  this.error('No entity directories found');
136
143
  }
@@ -161,7 +168,9 @@ class Push extends core_1.Command {
161
168
  default: false
162
169
  });
163
170
  if (!shouldContinue) {
164
- this.error('Push cancelled due to validation errors.');
171
+ this.log(chalk_1.default.yellow('\n⚠️ Push cancelled due to validation errors.'));
172
+ // Exit cleanly without throwing an error
173
+ return;
165
174
  }
166
175
  }
167
176
  }
@@ -169,31 +178,69 @@ class Push extends core_1.Command {
169
178
  this.log(chalk_1.default.green('✓ Validation passed'));
170
179
  }
171
180
  }
181
+ // Initialize file backup manager (unless in dry-run mode)
182
+ if (!flags['dry-run']) {
183
+ await fileBackupManager.initialize();
184
+ if (flags.verbose) {
185
+ this.log('📁 File backup manager initialized');
186
+ }
187
+ }
172
188
  // Start a database transaction for the entire push operation (unless in dry-run mode)
173
189
  // IMPORTANT: We start the transaction AFTER metadata loading and validation to avoid
174
190
  // transaction conflicts with background refresh operations
175
191
  if (!flags['dry-run']) {
176
192
  const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
177
193
  const dataProvider = getDataProvider();
194
+ // Ensure we have SQLServerDataProvider for transaction support
195
+ if (!(dataProvider instanceof sqlserver_dataprovider_1.SQLServerDataProvider)) {
196
+ const errorMsg = 'MetadataSync requires SQLServerDataProvider for transaction support. Current provider does not support transactions.';
197
+ // Rollback file backups since we're not proceeding
198
+ try {
199
+ await fileBackupManager.rollback();
200
+ }
201
+ catch (rollbackError) {
202
+ this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
203
+ }
204
+ this.error(errorMsg);
205
+ }
178
206
  if (dataProvider && typeof dataProvider.BeginTransaction === 'function') {
179
207
  try {
180
208
  await dataProvider.BeginTransaction();
209
+ hasActiveTransaction = true;
181
210
  if (flags.verbose) {
182
211
  this.log('🔄 Transaction started - all changes will be committed or rolled back as a unit');
183
212
  }
184
213
  }
185
214
  catch (error) {
186
- this.warn('Failed to start transaction - changes will be committed individually');
187
- this.warn(`Transaction error: ${error instanceof Error ? error.message : String(error)}`);
215
+ // Transaction start failure is critical - we should not proceed without it
216
+ const errorMsg = `Failed to start database transaction: ${error instanceof Error ? error.message : String(error)}`;
217
+ // Rollback file backups since we're not proceeding
218
+ try {
219
+ await fileBackupManager.rollback();
220
+ }
221
+ catch (rollbackError) {
222
+ this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
223
+ }
224
+ this.error(errorMsg);
188
225
  }
189
226
  }
190
227
  else {
191
- this.warn('Transaction support not available - changes will be committed individually');
228
+ // No transaction support is also critical for data integrity
229
+ const errorMsg = 'Transaction support not available - cannot ensure data integrity';
230
+ // Rollback file backups since we're not proceeding
231
+ try {
232
+ await fileBackupManager.rollback();
233
+ }
234
+ catch (rollbackError) {
235
+ this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
236
+ }
237
+ this.error(errorMsg);
192
238
  }
193
239
  }
194
240
  // Process each entity directory
195
241
  let totalCreated = 0;
196
242
  let totalUpdated = 0;
243
+ let totalUnchanged = 0;
197
244
  let totalErrors = 0;
198
245
  for (const entityDir of entityDirs) {
199
246
  const entityConfig = await (0, config_1.loadEntityConfig)(entityDir);
@@ -204,9 +251,34 @@ class Push extends core_1.Command {
204
251
  if (flags.verbose) {
205
252
  this.log(`\nProcessing ${entityConfig.entity} in ${entityDir}`);
206
253
  }
207
- const result = await this.processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig);
254
+ // Combine root ignoreDirectories with entity-level ignoreDirectories
255
+ const initialIgnoreDirectories = [
256
+ ...(syncConfig?.ignoreDirectories || []),
257
+ ...(entityConfig?.ignoreDirectories || [])
258
+ ];
259
+ const result = await this.processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager, initialIgnoreDirectories);
260
+ // Show per-directory summary
261
+ const dirName = path_1.default.relative(process.cwd(), entityDir) || '.';
262
+ const dirTotal = result.created + result.updated + result.unchanged;
263
+ if (dirTotal > 0 || result.errors > 0) {
264
+ this.log(`\n📁 ${dirName}:`);
265
+ this.log(` Total processed: ${dirTotal} unique records`);
266
+ if (result.created > 0) {
267
+ this.log(` ✓ Created: ${result.created}`);
268
+ }
269
+ if (result.updated > 0) {
270
+ this.log(` ✓ Updated: ${result.updated}`);
271
+ }
272
+ if (result.unchanged > 0) {
273
+ this.log(` - Unchanged: ${result.unchanged}`);
274
+ }
275
+ if (result.errors > 0) {
276
+ this.log(` ✗ Errors: ${result.errors}`);
277
+ }
278
+ }
208
279
  totalCreated += result.created;
209
280
  totalUpdated += result.updated;
281
+ totalUnchanged += result.unchanged;
210
282
  totalErrors += result.errors;
211
283
  }
212
284
  // Summary using FormattingService
@@ -216,15 +288,16 @@ class Push extends core_1.Command {
216
288
  this.log('\n' + formatter.formatSyncSummary('push', {
217
289
  created: totalCreated,
218
290
  updated: totalUpdated,
291
+ unchanged: totalUnchanged,
219
292
  deleted: 0,
220
293
  skipped: 0,
221
294
  errors: totalErrors,
222
295
  duration: endTime - startTime
223
296
  }));
224
297
  // Handle transaction commit/rollback
225
- if (!flags['dry-run']) {
298
+ if (!flags['dry-run'] && hasActiveTransaction) {
226
299
  const dataProvider = core_2.Metadata.Provider;
227
- // Check if we have an active transaction
300
+ // We know we have an active transaction at this point
228
301
  if (dataProvider) {
229
302
  let shouldCommit = true;
230
303
  // If there are any errors, always rollback
@@ -261,12 +334,18 @@ class Push extends core_1.Command {
261
334
  if (shouldCommit) {
262
335
  await dataProvider.CommitTransaction();
263
336
  this.log('\n✅ All changes committed successfully');
337
+ // Clean up file backups after successful commit
338
+ await fileBackupManager.cleanup();
264
339
  }
265
340
  else {
266
341
  // User chose to rollback or errors/warnings in CI mode
267
342
  this.log('\n🔙 Rolling back all changes...');
343
+ // Rollback database transaction
268
344
  await dataProvider.RollbackTransaction();
269
- this.log('✅ Rollback completed - no changes were made to the database');
345
+ // Rollback file changes
346
+ this.log('🔙 Rolling back file changes...');
347
+ await fileBackupManager.rollback();
348
+ this.log('✅ Rollback completed - no changes were made to the database or files');
270
349
  }
271
350
  }
272
351
  catch (error) {
@@ -274,10 +353,19 @@ class Push extends core_1.Command {
274
353
  this.log('\n❌ Transaction error - attempting to roll back changes');
275
354
  try {
276
355
  await dataProvider.RollbackTransaction();
277
- this.log('✅ Rollback completed');
356
+ this.log('✅ Database rollback completed');
278
357
  }
279
358
  catch (rollbackError) {
280
- this.log('❌ Rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
359
+ this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
360
+ }
361
+ // Also rollback file changes
362
+ try {
363
+ this.log('🔙 Rolling back file changes...');
364
+ await fileBackupManager.rollback();
365
+ this.log('✅ File rollback completed');
366
+ }
367
+ catch (fileRollbackError) {
368
+ this.log('❌ File rollback failed: ' + (fileRollbackError instanceof Error ? fileRollbackError.message : String(fileRollbackError)));
281
369
  }
282
370
  throw error;
283
371
  }
@@ -290,20 +378,30 @@ class Push extends core_1.Command {
290
378
  }
291
379
  catch (error) {
292
380
  spinner.fail('Push failed');
293
- // Try to rollback the transaction if one is active
381
+ // Try to rollback the transaction and files if not in dry-run mode
294
382
  if (!flags['dry-run']) {
295
383
  const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
296
384
  const dataProvider = getDataProvider();
297
- if (dataProvider && typeof dataProvider.RollbackTransaction === 'function') {
385
+ // Rollback database transaction if we have one
386
+ if (hasActiveTransaction && dataProvider && typeof dataProvider.RollbackTransaction === 'function') {
298
387
  try {
299
- this.log('\n🔙 Rolling back transaction due to error...');
388
+ this.log('\n🔙 Rolling back database transaction due to error...');
300
389
  await dataProvider.RollbackTransaction();
301
- this.log('✅ Rollback completed - no changes were made to the database');
390
+ this.log('✅ Database rollback completed');
302
391
  }
303
392
  catch (rollbackError) {
304
- this.log('❌ Rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
393
+ this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
305
394
  }
306
395
  }
396
+ // Rollback file changes
397
+ try {
398
+ this.log('🔙 Rolling back file changes...');
399
+ await fileBackupManager.rollback();
400
+ this.log('✅ File rollback completed - all files restored to original state');
401
+ }
402
+ catch (fileRollbackError) {
403
+ this.log('❌ File rollback failed: ' + (fileRollbackError instanceof Error ? fileRollbackError.message : String(fileRollbackError)));
404
+ }
307
405
  }
308
406
  // Enhanced error logging for debugging
309
407
  this.log('\n=== Push Error Details ===');
@@ -353,8 +451,8 @@ class Push extends core_1.Command {
353
451
  process.exit(0);
354
452
  }
355
453
  }
356
- async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig) {
357
- const result = { created: 0, updated: 0, errors: 0 };
454
+ async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager, parentIgnoreDirectories) {
455
+ const result = { created: 0, updated: 0, unchanged: 0, errors: 0 };
358
456
  // Find files matching the configured pattern
359
457
  const pattern = entityConfig.filePattern || '*.json';
360
458
  const jsonFiles = await (0, fast_glob_1.default)(pattern, {
@@ -363,15 +461,83 @@ class Push extends core_1.Command {
363
461
  dot: true, // Include dotfiles (files starting with .)
364
462
  onlyFiles: true
365
463
  });
464
+ // Check if no JSON files were found
465
+ if (jsonFiles.length === 0) {
466
+ const relativePath = path_1.default.relative(process.cwd(), entityDir) || '.';
467
+ const parentPath = path_1.default.dirname(entityDir);
468
+ const dirName = path_1.default.basename(entityDir);
469
+ // Check if this is a subdirectory (not a top-level entity directory)
470
+ const isSubdirectory = parentPath !== path_1.default.resolve(config_manager_1.configManager.getOriginalCwd(), flags.dir || '.');
471
+ if (isSubdirectory) {
472
+ // For subdirectories, make it a warning instead of an error
473
+ let warningMessage = `No JSON files found in ${relativePath} matching pattern: ${pattern}`;
474
+ // Try to be more helpful by checking what files do exist
475
+ const allFiles = await (0, fast_glob_1.default)('*', {
476
+ cwd: entityDir,
477
+ onlyFiles: true,
478
+ dot: true
479
+ });
480
+ if (allFiles.length > 0) {
481
+ warningMessage += `\n Files found: ${allFiles.slice(0, 3).join(', ')}`;
482
+ if (allFiles.length > 3) {
483
+ warningMessage += ` (and ${allFiles.length - 3} more)`;
484
+ }
485
+ }
486
+ const rootConfigPath = path_1.default.join(config_manager_1.configManager.getOriginalCwd(), flags.dir || '.', '.mj-sync.json');
487
+ warningMessage += `\n 💡 If this directory should be ignored, add "${dirName}" to the "ignoreDirectories" array in:\n ${rootConfigPath}`;
488
+ this.warn(warningMessage);
489
+ return result; // Return early without processing further
490
+ }
491
+ else {
492
+ // For top-level entity directories, this is still an error
493
+ const configFile = path_1.default.join(entityDir, '.mj-sync.json');
494
+ let errorMessage = `No JSON files found in ${relativePath} matching pattern: ${pattern}\n`;
495
+ errorMessage += `\nPlease check:\n`;
496
+ errorMessage += ` 1. Files exist with the expected extension (.json)\n`;
497
+ errorMessage += ` 2. The filePattern in ${configFile} matches your files\n`;
498
+ errorMessage += ` 3. Files are not in ignored patterns: .mj-sync.json, .mj-folder.json, *.backup\n`;
499
+ // Try to be more helpful by checking what files do exist
500
+ const allFiles = await (0, fast_glob_1.default)('*', {
501
+ cwd: entityDir,
502
+ onlyFiles: true,
503
+ dot: true
504
+ });
505
+ if (allFiles.length > 0) {
506
+ errorMessage += `\nFiles found in directory: ${allFiles.slice(0, 5).join(', ')}`;
507
+ if (allFiles.length > 5) {
508
+ errorMessage += ` (and ${allFiles.length - 5} more)`;
509
+ }
510
+ }
511
+ throw new Error(errorMessage);
512
+ }
513
+ }
366
514
  if (flags.verbose) {
367
515
  this.log(`Processing ${jsonFiles.length} records in ${path_1.default.relative(process.cwd(), entityDir) || '.'}`);
368
516
  }
369
517
  // First, process all JSON files in this directory
370
- await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result);
518
+ await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager);
371
519
  // Then, recursively process subdirectories
372
520
  const entries = await fs_extra_1.default.readdir(entityDir, { withFileTypes: true });
373
521
  for (const entry of entries) {
374
522
  if (entry.isDirectory() && !entry.name.startsWith('.')) {
523
+ // Build cumulative ignore list: parent + current directory's ignores
524
+ const currentDirConfig = await (0, config_1.loadSyncConfig)(entityDir);
525
+ const currentEntityConfig = await (0, config_1.loadEntityConfig)(entityDir);
526
+ const cumulativeIgnoreDirectories = [
527
+ ...(parentIgnoreDirectories || []),
528
+ ...(currentDirConfig?.ignoreDirectories || []),
529
+ ...(currentEntityConfig?.ignoreDirectories || [])
530
+ ];
531
+ // Check if this directory should be ignored
532
+ if (cumulativeIgnoreDirectories.some((pattern) => {
533
+ // Simple pattern matching: exact name or ends with pattern
534
+ return entry.name === pattern || entry.name.endsWith(pattern);
535
+ })) {
536
+ if (flags.verbose) {
537
+ this.log(` Ignoring directory: ${entry.name} (matched ignore pattern)`);
538
+ }
539
+ continue;
540
+ }
375
541
  const subDir = path_1.default.join(entityDir, entry.name);
376
542
  // Load subdirectory config and merge with parent config
377
543
  let subEntityConfig = { ...entityConfig };
@@ -392,48 +558,69 @@ class Push extends core_1.Command {
392
558
  }
393
559
  };
394
560
  }
395
- // Process subdirectory with merged config
396
- const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig);
561
+ // Process subdirectory with merged config and cumulative ignore directories
562
+ const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig, fileBackupManager, cumulativeIgnoreDirectories);
397
563
  result.created += subResult.created;
398
564
  result.updated += subResult.updated;
565
+ result.unchanged += subResult.unchanged;
399
566
  result.errors += subResult.errors;
400
567
  }
401
568
  }
402
569
  return result;
403
570
  }
404
- async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result) {
571
+ async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager) {
405
572
  if (jsonFiles.length === 0) {
406
573
  return;
407
574
  }
408
575
  const spinner = (0, ora_classic_1.default)();
409
576
  spinner.start('Processing records');
410
- let totalRecords = 0;
411
577
  for (const file of jsonFiles) {
412
578
  try {
413
579
  const filePath = path_1.default.join(entityDir, file);
414
- const fileContent = await fs_extra_1.default.readJson(filePath);
580
+ // Backup the file before any modifications (unless dry-run)
581
+ if (!flags['dry-run'] && fileBackupManager) {
582
+ await fileBackupManager.backupFile(filePath);
583
+ }
584
+ // Parse JSON with line number tracking
585
+ const { content: fileContent, lineNumbers } = await this.parseJsonWithLineNumbers(filePath);
415
586
  // Process templates in the loaded content
416
587
  const processedContent = await syncEngine.processTemplates(fileContent, entityDir);
417
588
  // Check if the file contains a single record or an array of records
418
589
  const isArray = Array.isArray(processedContent);
419
590
  const records = isArray ? processedContent : [processedContent];
420
- totalRecords += records.length;
421
591
  // Build and process defaults (including lookups)
422
592
  const defaults = await syncEngine.buildDefaults(filePath, entityConfig);
423
593
  // Process each record in the file
424
594
  for (let i = 0; i < records.length; i++) {
425
595
  const recordData = records[i];
426
596
  // Process the record
427
- const isNew = await this.pushRecord(recordData, entityConfig.entity, path_1.default.dirname(filePath), file, defaults, syncEngine, flags['dry-run'], flags.verbose, isArray ? i : undefined);
597
+ const recordLineNumber = lineNumbers.get(i); // Get line number for this array index
598
+ 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);
428
599
  if (!flags['dry-run']) {
429
- if (isNew) {
430
- result.created++;
600
+ // Don't count duplicates in stats
601
+ if (!pushResult.isDuplicate) {
602
+ if (pushResult.isNew) {
603
+ result.created++;
604
+ }
605
+ else if (pushResult.wasActuallyUpdated) {
606
+ result.updated++;
607
+ }
608
+ else {
609
+ result.unchanged++;
610
+ }
431
611
  }
432
- else {
433
- result.updated++;
612
+ // Add related entity stats
613
+ if (pushResult.relatedStats) {
614
+ result.created += pushResult.relatedStats.created;
615
+ result.updated += pushResult.relatedStats.updated;
616
+ result.unchanged += pushResult.relatedStats.unchanged;
617
+ // Debug logging for related entities
618
+ if (flags.verbose && pushResult.relatedStats.unchanged > 0) {
619
+ this.log(` Related entities: ${pushResult.relatedStats.unchanged} unchanged`);
620
+ }
434
621
  }
435
622
  }
436
- spinner.text = `Processing records (${result.created + result.updated + result.errors}/${totalRecords})`;
623
+ spinner.text = `Processing records (${result.created + result.updated + result.unchanged + result.errors} processed)`;
437
624
  }
438
625
  // Write back the entire file if it's an array
439
626
  if (isArray && !flags['dry-run']) {
@@ -446,21 +633,43 @@ class Push extends core_1.Command {
446
633
  const fullErrorMessage = `Failed to process ${file}: ${errorMessage}`;
447
634
  this.errors.push(fullErrorMessage);
448
635
  this.error(fullErrorMessage, { exit: false });
636
+ this.log(' ⚠️ This error will cause all changes to be rolled back at the end of processing');
449
637
  }
450
638
  }
451
639
  if (flags.verbose) {
452
- spinner.succeed(`Processed ${totalRecords} records from ${jsonFiles.length} files`);
640
+ spinner.succeed(`Processed ${result.created + result.updated + result.unchanged} records from ${jsonFiles.length} files`);
453
641
  }
454
642
  else {
455
643
  spinner.stop();
456
644
  }
457
645
  }
458
- async pushRecord(recordData, entityName, baseDir, fileName, defaults, syncEngine, dryRun, verbose = false, arrayIndex) {
646
+ async pushRecord(recordData, entityName, baseDir, fileName, defaults, syncEngine, dryRun, verbose = false, arrayIndex, fileBackupManager, lineNumber) {
459
647
  // Load or create entity
460
648
  let entity = null;
461
649
  let isNew = false;
462
650
  if (recordData.primaryKey) {
463
651
  entity = await syncEngine.loadEntity(entityName, recordData.primaryKey);
652
+ // Warn if record has primaryKey but wasn't found
653
+ if (!entity) {
654
+ const pkDisplay = Object.entries(recordData.primaryKey)
655
+ .map(([key, value]) => `${key}=${value}`)
656
+ .join(', ');
657
+ // Load sync config to check autoCreateMissingRecords setting
658
+ const syncConfig = await (0, config_1.loadSyncConfig)(config_manager_1.configManager.getOriginalCwd());
659
+ const autoCreate = syncConfig?.push?.autoCreateMissingRecords ?? false;
660
+ if (!autoCreate) {
661
+ const fileRef = lineNumber ? `${fileName}:${lineNumber}` : fileName;
662
+ this.warn(`⚠️ Record not found: ${entityName} with primaryKey {${pkDisplay}} at ${fileRef}`);
663
+ this.warn(` To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`);
664
+ // Skip this record
665
+ return { isNew: false, wasActuallyUpdated: false, isDuplicate: false };
666
+ }
667
+ else {
668
+ if (verbose) {
669
+ this.log(` Auto-creating missing ${entityName} record with primaryKey {${pkDisplay}}`);
670
+ }
671
+ }
672
+ }
464
673
  }
465
674
  if (!entity) {
466
675
  // New record
@@ -507,7 +716,7 @@ class Push extends core_1.Command {
507
716
  try {
508
717
  const processedValue = await syncEngine.processFieldValue(value, baseDir, null, null);
509
718
  if (verbose) {
510
- this.log(` Setting ${field}: ${JSON.stringify(value)} -> ${JSON.stringify(processedValue)}`);
719
+ this.log(` Setting ${field}: ${this.formatFieldValue(value)} -> ${this.formatFieldValue(processedValue)}`);
511
720
  }
512
721
  entity[field] = processedValue;
513
722
  }
@@ -521,7 +730,46 @@ class Push extends core_1.Command {
521
730
  }
522
731
  if (dryRun) {
523
732
  this.log(`Would ${isNew ? 'create' : 'update'} ${entityName} record`);
524
- return isNew;
733
+ return { isNew, wasActuallyUpdated: true, isDuplicate: false, relatedStats: undefined };
734
+ }
735
+ // Check for duplicate processing (but only for existing records that were loaded)
736
+ let isDuplicate = false;
737
+ if (!isNew && entity) {
738
+ const fullFilePath = path_1.default.join(baseDir, fileName);
739
+ isDuplicate = this.checkAndTrackRecord(entityName, entity, fullFilePath, arrayIndex, lineNumber);
740
+ }
741
+ // Check if the record is dirty before saving
742
+ let wasActuallyUpdated = false;
743
+ if (!isNew && entity.Dirty) {
744
+ // Record is dirty, get the changes
745
+ const changes = entity.GetChangesSinceLastSave();
746
+ const changeKeys = Object.keys(changes);
747
+ if (changeKeys.length > 0) {
748
+ wasActuallyUpdated = true;
749
+ // Get primary key info for display
750
+ const entityInfo = syncEngine.getEntityInfo(entityName);
751
+ const primaryKeyDisplay = [];
752
+ if (entityInfo) {
753
+ for (const pk of entityInfo.PrimaryKeys) {
754
+ primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
755
+ }
756
+ }
757
+ this.log(''); // Add newline before update output
758
+ this.log(`📝 Updating ${entityName} record:`);
759
+ if (primaryKeyDisplay.length > 0) {
760
+ this.log(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
761
+ }
762
+ this.log(` Changes:`);
763
+ for (const fieldName of changeKeys) {
764
+ const field = entity.GetFieldByName(fieldName);
765
+ const oldValue = field ? field.OldValue : undefined;
766
+ const newValue = changes[fieldName];
767
+ this.log(` ${fieldName}: ${this.formatFieldValue(oldValue)} → ${this.formatFieldValue(newValue)}`);
768
+ }
769
+ }
770
+ }
771
+ else if (isNew) {
772
+ wasActuallyUpdated = true;
525
773
  }
526
774
  // Save the record
527
775
  const saved = await entity.Save();
@@ -534,9 +782,12 @@ class Push extends core_1.Command {
534
782
  throw new Error(`Failed to save record: ${errors}`);
535
783
  }
536
784
  // Process related entities after saving parent
785
+ let relatedStats;
537
786
  if (recordData.relatedEntities && !dryRun) {
538
- await this.processRelatedEntities(recordData.relatedEntities, entity, entity, // root is same as parent for top level
539
- baseDir, syncEngine, verbose);
787
+ const fullFilePath = path_1.default.join(baseDir, fileName);
788
+ relatedStats = await this.processRelatedEntities(recordData.relatedEntities, entity, entity, // root is same as parent for top level
789
+ baseDir, syncEngine, verbose, fileBackupManager, 1, // indentLevel
790
+ fullFilePath, arrayIndex);
540
791
  }
541
792
  // Update the local file with new primary key if created
542
793
  if (isNew) {
@@ -548,6 +799,9 @@ class Push extends core_1.Command {
548
799
  }
549
800
  recordData.primaryKey = newPrimaryKey;
550
801
  }
802
+ // Track the new record now that we have its primary key
803
+ const fullFilePath = path_1.default.join(baseDir, fileName);
804
+ this.checkAndTrackRecord(entityName, entity, fullFilePath, arrayIndex, lineNumber);
551
805
  }
552
806
  // Always update sync metadata
553
807
  // This ensures related entities are persisted with their metadata
@@ -561,10 +815,11 @@ class Push extends core_1.Command {
561
815
  const filePath = path_1.default.join(baseDir, fileName);
562
816
  await fs_extra_1.default.writeJson(filePath, recordData, { spaces: 2 });
563
817
  }
564
- return isNew;
818
+ return { isNew, wasActuallyUpdated, isDuplicate, relatedStats };
565
819
  }
566
- async processRelatedEntities(relatedEntities, parentEntity, rootEntity, baseDir, syncEngine, verbose = false, indentLevel = 1) {
820
+ async processRelatedEntities(relatedEntities, parentEntity, rootEntity, baseDir, syncEngine, verbose = false, fileBackupManager, indentLevel = 1, parentFilePath, parentArrayIndex) {
567
821
  const indent = ' '.repeat(indentLevel);
822
+ const stats = { created: 0, updated: 0, unchanged: 0 };
568
823
  for (const [entityName, records] of Object.entries(relatedEntities)) {
569
824
  if (verbose) {
570
825
  this.log(`${indent}↳ Processing ${records.length} related ${entityName} records`);
@@ -576,6 +831,27 @@ class Push extends core_1.Command {
576
831
  let isNew = false;
577
832
  if (relatedRecord.primaryKey) {
578
833
  entity = await syncEngine.loadEntity(entityName, relatedRecord.primaryKey);
834
+ // Warn if record has primaryKey but wasn't found
835
+ if (!entity) {
836
+ const pkDisplay = Object.entries(relatedRecord.primaryKey)
837
+ .map(([key, value]) => `${key}=${value}`)
838
+ .join(', ');
839
+ // Load sync config to check autoCreateMissingRecords setting
840
+ const syncConfig = await (0, config_1.loadSyncConfig)(config_manager_1.configManager.getOriginalCwd());
841
+ const autoCreate = syncConfig?.push?.autoCreateMissingRecords ?? false;
842
+ if (!autoCreate) {
843
+ const fileRef = parentFilePath ? path_1.default.relative(config_manager_1.configManager.getOriginalCwd(), parentFilePath) : 'unknown';
844
+ this.warn(`${indent}⚠️ Related record not found: ${entityName} with primaryKey {${pkDisplay}} at ${fileRef}`);
845
+ this.warn(`${indent} To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`);
846
+ // Skip this record
847
+ continue;
848
+ }
849
+ else {
850
+ if (verbose) {
851
+ this.log(`${indent} Auto-creating missing related ${entityName} record with primaryKey {${pkDisplay}}`);
852
+ }
853
+ }
854
+ }
579
855
  }
580
856
  if (!entity) {
581
857
  entity = await syncEngine.createEntityObject(entityName);
@@ -614,7 +890,7 @@ class Push extends core_1.Command {
614
890
  try {
615
891
  const processedValue = await syncEngine.processFieldValue(value, baseDir, parentEntity, rootEntity);
616
892
  if (verbose) {
617
- this.log(`${indent} Setting ${field}: ${JSON.stringify(value)} -> ${JSON.stringify(processedValue)}`);
893
+ this.log(`${indent} Setting ${field}: ${this.formatFieldValue(value)} -> ${this.formatFieldValue(processedValue)}`);
618
894
  }
619
895
  entity[field] = processedValue;
620
896
  }
@@ -626,6 +902,46 @@ class Push extends core_1.Command {
626
902
  this.warn(`${indent} Field '${field}' does not exist on entity '${entityName}'`);
627
903
  }
628
904
  }
905
+ // Check for duplicate processing (but only for existing records that were loaded)
906
+ let isDuplicate = false;
907
+ if (!isNew && entity) {
908
+ // Use parent file path for related entities since they're defined in the parent's file
909
+ const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
910
+ isDuplicate = this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
911
+ }
912
+ // Check if the record is dirty before saving
913
+ let wasActuallyUpdated = false;
914
+ if (!isNew && entity.Dirty) {
915
+ // Record is dirty, get the changes
916
+ const changes = entity.GetChangesSinceLastSave();
917
+ const changeKeys = Object.keys(changes);
918
+ if (changeKeys.length > 0) {
919
+ wasActuallyUpdated = true;
920
+ // Get primary key info for display
921
+ const entityInfo = syncEngine.getEntityInfo(entityName);
922
+ const primaryKeyDisplay = [];
923
+ if (entityInfo) {
924
+ for (const pk of entityInfo.PrimaryKeys) {
925
+ primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
926
+ }
927
+ }
928
+ this.log(''); // Add newline before update output
929
+ this.log(`${indent}📝 Updating related ${entityName} record:`);
930
+ if (primaryKeyDisplay.length > 0) {
931
+ this.log(`${indent} Primary Key: ${primaryKeyDisplay.join(', ')}`);
932
+ }
933
+ this.log(`${indent} Changes:`);
934
+ for (const fieldName of changeKeys) {
935
+ const field = entity.GetFieldByName(fieldName);
936
+ const oldValue = field ? field.OldValue : undefined;
937
+ const newValue = changes[fieldName];
938
+ this.log(`${indent} ${fieldName}: ${this.formatFieldValue(oldValue)} → ${this.formatFieldValue(newValue)}`);
939
+ }
940
+ }
941
+ }
942
+ else if (isNew) {
943
+ wasActuallyUpdated = true;
944
+ }
629
945
  // Save the related entity
630
946
  const saved = await entity.Save();
631
947
  if (!saved) {
@@ -636,9 +952,24 @@ class Push extends core_1.Command {
636
952
  const errors = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || 'Unknown error';
637
953
  throw new Error(`Failed to save related ${entityName}: ${errors}`);
638
954
  }
639
- if (verbose) {
955
+ // Update stats - don't count duplicates
956
+ if (!isDuplicate) {
957
+ if (isNew) {
958
+ stats.created++;
959
+ }
960
+ else if (wasActuallyUpdated) {
961
+ stats.updated++;
962
+ }
963
+ else {
964
+ stats.unchanged++;
965
+ }
966
+ }
967
+ if (verbose && wasActuallyUpdated) {
640
968
  this.log(`${indent} ✓ ${isNew ? 'Created' : 'Updated'} ${entityName} record`);
641
969
  }
970
+ else if (verbose && !wasActuallyUpdated) {
971
+ this.log(`${indent} - No changes to ${entityName} record`);
972
+ }
642
973
  // Update the related record with primary key and sync metadata
643
974
  const entityInfo = syncEngine.getEntityInfo(entityName);
644
975
  if (entityInfo) {
@@ -648,6 +979,9 @@ class Push extends core_1.Command {
648
979
  for (const pk of entityInfo.PrimaryKeys) {
649
980
  relatedRecord.primaryKey[pk.Name] = entity.Get(pk.Name);
650
981
  }
982
+ // Track the new related entity now that we have its primary key
983
+ const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
984
+ this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
651
985
  }
652
986
  // Always update sync metadata
653
987
  relatedRecord.sync = {
@@ -657,7 +991,11 @@ class Push extends core_1.Command {
657
991
  }
658
992
  // Process nested related entities if any
659
993
  if (relatedRecord.relatedEntities) {
660
- await this.processRelatedEntities(relatedRecord.relatedEntities, entity, rootEntity, baseDir, syncEngine, verbose, indentLevel + 1);
994
+ const nestedStats = await this.processRelatedEntities(relatedRecord.relatedEntities, entity, rootEntity, baseDir, syncEngine, verbose, fileBackupManager, indentLevel + 1, parentFilePath, parentArrayIndex);
995
+ // Accumulate nested stats
996
+ stats.created += nestedStats.created;
997
+ stats.updated += nestedStats.updated;
998
+ stats.unchanged += nestedStats.unchanged;
661
999
  }
662
1000
  }
663
1001
  catch (error) {
@@ -665,6 +1003,128 @@ class Push extends core_1.Command {
665
1003
  }
666
1004
  }
667
1005
  }
1006
+ return stats;
1007
+ }
1008
+ /**
1009
+ * Generate a unique tracking key for a record based on entity name and primary key values
1010
+ */
1011
+ generateRecordKey(entityName, entity) {
1012
+ const entityInfo = entity.EntityInfo;
1013
+ const primaryKeyValues = [];
1014
+ if (entityInfo && entityInfo.PrimaryKeys.length > 0) {
1015
+ for (const pk of entityInfo.PrimaryKeys) {
1016
+ const value = entity.Get(pk.Name);
1017
+ primaryKeyValues.push(`${pk.Name}:${value}`);
1018
+ }
1019
+ }
1020
+ return `${entityName}|${primaryKeyValues.join('|')}`;
1021
+ }
1022
+ /**
1023
+ * Check if a record has already been processed and warn if duplicate
1024
+ */
1025
+ checkAndTrackRecord(entityName, entity, filePath, arrayIndex, lineNumber) {
1026
+ const recordKey = this.generateRecordKey(entityName, entity);
1027
+ const existing = this.processedRecords.get(recordKey);
1028
+ if (existing) {
1029
+ const primaryKeyDisplay = entity.EntityInfo?.PrimaryKeys
1030
+ .map(pk => `${pk.Name}: ${entity.Get(pk.Name)}`)
1031
+ .join(', ') || 'unknown';
1032
+ // Format file location with clickable link for VSCode
1033
+ // Create maps with just the line numbers we have
1034
+ const currentLineMap = lineNumber ? new Map([[arrayIndex || 0, lineNumber]]) : undefined;
1035
+ const originalLineMap = existing.lineNumber ? new Map([[existing.arrayIndex || 0, existing.lineNumber]]) : undefined;
1036
+ const currentLocation = this.formatFileLocation(filePath, arrayIndex, currentLineMap);
1037
+ const originalLocation = this.formatFileLocation(existing.filePath, existing.arrayIndex, originalLineMap);
1038
+ this.warn(`⚠️ Duplicate record detected for ${entityName} (${primaryKeyDisplay})`);
1039
+ this.warn(` Current location: ${currentLocation}`);
1040
+ this.warn(` Original location: ${originalLocation}`);
1041
+ this.warn(` The duplicate update will proceed, but you should review your data for unintended duplicates.`);
1042
+ return true; // is duplicate
1043
+ }
1044
+ // Track the record with its source location
1045
+ this.processedRecords.set(recordKey, {
1046
+ filePath: filePath || 'unknown',
1047
+ arrayIndex,
1048
+ lineNumber
1049
+ });
1050
+ return false; // not duplicate
1051
+ }
1052
+ /**
1053
+ * Format field value for console display
1054
+ */
1055
+ formatFieldValue(value, maxLength = 50) {
1056
+ // Convert value to string representation
1057
+ let strValue = JSON.stringify(value);
1058
+ // Trim the string
1059
+ strValue = strValue.trim();
1060
+ // If it's longer than maxLength, truncate and add ellipsis
1061
+ if (strValue.length > maxLength) {
1062
+ return strValue.substring(0, maxLength) + '...';
1063
+ }
1064
+ return strValue;
1065
+ }
1066
+ /**
1067
+ * Parse JSON file and track line numbers for array elements
1068
+ */
1069
+ async parseJsonWithLineNumbers(filePath) {
1070
+ const fileText = await fs_extra_1.default.readFile(filePath, 'utf-8');
1071
+ const lines = fileText.split('\n');
1072
+ const lineNumbers = new Map();
1073
+ // Parse the JSON
1074
+ const content = JSON.parse(fileText);
1075
+ // If it's an array, try to find where each element starts
1076
+ if (Array.isArray(content)) {
1077
+ let inString = false;
1078
+ let bracketDepth = 0;
1079
+ let currentIndex = -1;
1080
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
1081
+ const line = lines[lineNum];
1082
+ // Simple tracking of string boundaries and bracket depth
1083
+ for (let i = 0; i < line.length; i++) {
1084
+ const char = line[i];
1085
+ const prevChar = i > 0 ? line[i - 1] : '';
1086
+ if (char === '"' && prevChar !== '\\') {
1087
+ inString = !inString;
1088
+ }
1089
+ if (!inString) {
1090
+ if (char === '{') {
1091
+ bracketDepth++;
1092
+ // If we're at depth 1 in the main array, this is a new object
1093
+ if (bracketDepth === 1 && line.trim().startsWith('{')) {
1094
+ currentIndex++;
1095
+ lineNumbers.set(currentIndex, lineNum + 1); // 1-based line numbers
1096
+ }
1097
+ }
1098
+ else if (char === '}') {
1099
+ bracketDepth--;
1100
+ }
1101
+ }
1102
+ }
1103
+ }
1104
+ }
1105
+ return { content, lineNumbers };
1106
+ }
1107
+ /**
1108
+ * Format file location with clickable link for VSCode
1109
+ */
1110
+ formatFileLocation(filePath, arrayIndex, lineNumbers) {
1111
+ if (!filePath || filePath === 'unknown') {
1112
+ return 'unknown';
1113
+ }
1114
+ // Get absolute path for better VSCode integration
1115
+ const absolutePath = path_1.default.resolve(filePath);
1116
+ // Try to get actual line number from our tracking
1117
+ let lineNumber = 1;
1118
+ if (arrayIndex !== undefined && lineNumbers && lineNumbers.has(arrayIndex)) {
1119
+ lineNumber = lineNumbers.get(arrayIndex);
1120
+ }
1121
+ else if (arrayIndex !== undefined) {
1122
+ // Fallback estimation if we don't have actual line numbers
1123
+ lineNumber = 2 + (arrayIndex * 15);
1124
+ }
1125
+ // Create clickable file path for VSCode - format: file:line
1126
+ // VSCode will make this clickable in the terminal
1127
+ return `${absolutePath}:${lineNumber}`;
668
1128
  }
669
1129
  }
670
1130
  exports.default = Push;