@memberjunction/metadata-sync 2.51.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.
@@ -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');
@@ -169,31 +176,69 @@ class Push extends core_1.Command {
169
176
  this.log(chalk_1.default.green('✓ Validation passed'));
170
177
  }
171
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
+ }
172
186
  // Start a database transaction for the entire push operation (unless in dry-run mode)
173
187
  // IMPORTANT: We start the transaction AFTER metadata loading and validation to avoid
174
188
  // transaction conflicts with background refresh operations
175
189
  if (!flags['dry-run']) {
176
190
  const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
177
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
+ }
178
204
  if (dataProvider && typeof dataProvider.BeginTransaction === 'function') {
179
205
  try {
180
206
  await dataProvider.BeginTransaction();
207
+ hasActiveTransaction = true;
181
208
  if (flags.verbose) {
182
209
  this.log('🔄 Transaction started - all changes will be committed or rolled back as a unit');
183
210
  }
184
211
  }
185
212
  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)}`);
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);
188
223
  }
189
224
  }
190
225
  else {
191
- this.warn('Transaction support not available - changes will be committed individually');
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);
192
236
  }
193
237
  }
194
238
  // Process each entity directory
195
239
  let totalCreated = 0;
196
240
  let totalUpdated = 0;
241
+ let totalUnchanged = 0;
197
242
  let totalErrors = 0;
198
243
  for (const entityDir of entityDirs) {
199
244
  const entityConfig = await (0, config_1.loadEntityConfig)(entityDir);
@@ -204,9 +249,29 @@ class Push extends core_1.Command {
204
249
  if (flags.verbose) {
205
250
  this.log(`\nProcessing ${entityConfig.entity} in ${entityDir}`);
206
251
  }
207
- const result = await this.processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig);
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
+ }
208
272
  totalCreated += result.created;
209
273
  totalUpdated += result.updated;
274
+ totalUnchanged += result.unchanged;
210
275
  totalErrors += result.errors;
211
276
  }
212
277
  // Summary using FormattingService
@@ -216,15 +281,16 @@ class Push extends core_1.Command {
216
281
  this.log('\n' + formatter.formatSyncSummary('push', {
217
282
  created: totalCreated,
218
283
  updated: totalUpdated,
284
+ unchanged: totalUnchanged,
219
285
  deleted: 0,
220
286
  skipped: 0,
221
287
  errors: totalErrors,
222
288
  duration: endTime - startTime
223
289
  }));
224
290
  // Handle transaction commit/rollback
225
- if (!flags['dry-run']) {
291
+ if (!flags['dry-run'] && hasActiveTransaction) {
226
292
  const dataProvider = core_2.Metadata.Provider;
227
- // Check if we have an active transaction
293
+ // We know we have an active transaction at this point
228
294
  if (dataProvider) {
229
295
  let shouldCommit = true;
230
296
  // If there are any errors, always rollback
@@ -261,12 +327,18 @@ class Push extends core_1.Command {
261
327
  if (shouldCommit) {
262
328
  await dataProvider.CommitTransaction();
263
329
  this.log('\n✅ All changes committed successfully');
330
+ // Clean up file backups after successful commit
331
+ await fileBackupManager.cleanup();
264
332
  }
265
333
  else {
266
334
  // User chose to rollback or errors/warnings in CI mode
267
335
  this.log('\n🔙 Rolling back all changes...');
336
+ // Rollback database transaction
268
337
  await dataProvider.RollbackTransaction();
269
- this.log('✅ Rollback completed - no changes were made to the database');
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');
270
342
  }
271
343
  }
272
344
  catch (error) {
@@ -274,10 +346,19 @@ class Push extends core_1.Command {
274
346
  this.log('\n❌ Transaction error - attempting to roll back changes');
275
347
  try {
276
348
  await dataProvider.RollbackTransaction();
277
- this.log('✅ Rollback completed');
349
+ this.log('✅ Database rollback completed');
278
350
  }
279
351
  catch (rollbackError) {
280
- this.log('❌ Rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(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)));
281
362
  }
282
363
  throw error;
283
364
  }
@@ -290,20 +371,30 @@ class Push extends core_1.Command {
290
371
  }
291
372
  catch (error) {
292
373
  spinner.fail('Push failed');
293
- // Try to rollback the transaction if one is active
374
+ // Try to rollback the transaction and files if not in dry-run mode
294
375
  if (!flags['dry-run']) {
295
376
  const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
296
377
  const dataProvider = getDataProvider();
297
- if (dataProvider && typeof dataProvider.RollbackTransaction === 'function') {
378
+ // Rollback database transaction if we have one
379
+ if (hasActiveTransaction && dataProvider && typeof dataProvider.RollbackTransaction === 'function') {
298
380
  try {
299
- this.log('\n🔙 Rolling back transaction due to error...');
381
+ this.log('\n🔙 Rolling back database transaction due to error...');
300
382
  await dataProvider.RollbackTransaction();
301
- this.log('✅ Rollback completed - no changes were made to the database');
383
+ this.log('✅ Database rollback completed');
302
384
  }
303
385
  catch (rollbackError) {
304
- this.log('❌ Rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
386
+ this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
305
387
  }
306
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
+ }
307
398
  }
308
399
  // Enhanced error logging for debugging
309
400
  this.log('\n=== Push Error Details ===');
@@ -353,8 +444,8 @@ class Push extends core_1.Command {
353
444
  process.exit(0);
354
445
  }
355
446
  }
356
- async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig) {
357
- 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 };
358
449
  // Find files matching the configured pattern
359
450
  const pattern = entityConfig.filePattern || '*.json';
360
451
  const jsonFiles = await (0, fast_glob_1.default)(pattern, {
@@ -367,7 +458,7 @@ class Push extends core_1.Command {
367
458
  this.log(`Processing ${jsonFiles.length} records in ${path_1.default.relative(process.cwd(), entityDir) || '.'}`);
368
459
  }
369
460
  // First, process all JSON files in this directory
370
- await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result);
461
+ await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager);
371
462
  // Then, recursively process subdirectories
372
463
  const entries = await fs_extra_1.default.readdir(entityDir, { withFileTypes: true });
373
464
  for (const entry of entries) {
@@ -393,47 +484,68 @@ class Push extends core_1.Command {
393
484
  };
394
485
  }
395
486
  // Process subdirectory with merged config
396
- const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig);
487
+ const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig, fileBackupManager);
397
488
  result.created += subResult.created;
398
489
  result.updated += subResult.updated;
490
+ result.unchanged += subResult.unchanged;
399
491
  result.errors += subResult.errors;
400
492
  }
401
493
  }
402
494
  return result;
403
495
  }
404
- async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result) {
496
+ async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager) {
405
497
  if (jsonFiles.length === 0) {
406
498
  return;
407
499
  }
408
500
  const spinner = (0, ora_classic_1.default)();
409
501
  spinner.start('Processing records');
410
- let totalRecords = 0;
411
502
  for (const file of jsonFiles) {
412
503
  try {
413
504
  const filePath = path_1.default.join(entityDir, file);
414
- 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);
415
511
  // Process templates in the loaded content
416
512
  const processedContent = await syncEngine.processTemplates(fileContent, entityDir);
417
513
  // Check if the file contains a single record or an array of records
418
514
  const isArray = Array.isArray(processedContent);
419
515
  const records = isArray ? processedContent : [processedContent];
420
- totalRecords += records.length;
421
516
  // Build and process defaults (including lookups)
422
517
  const defaults = await syncEngine.buildDefaults(filePath, entityConfig);
423
518
  // Process each record in the file
424
519
  for (let i = 0; i < records.length; i++) {
425
520
  const recordData = records[i];
426
521
  // 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);
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);
428
524
  if (!flags['dry-run']) {
429
- if (isNew) {
430
- 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
+ }
431
536
  }
432
- else {
433
- 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
+ }
434
546
  }
435
547
  }
436
- 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)`;
437
549
  }
438
550
  // Write back the entire file if it's an array
439
551
  if (isArray && !flags['dry-run']) {
@@ -449,18 +561,39 @@ class Push extends core_1.Command {
449
561
  }
450
562
  }
451
563
  if (flags.verbose) {
452
- spinner.succeed(`Processed ${totalRecords} records from ${jsonFiles.length} files`);
564
+ spinner.succeed(`Processed ${result.created + result.updated + result.unchanged} records from ${jsonFiles.length} files`);
453
565
  }
454
566
  else {
455
567
  spinner.stop();
456
568
  }
457
569
  }
458
- 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) {
459
571
  // Load or create entity
460
572
  let entity = null;
461
573
  let isNew = false;
462
574
  if (recordData.primaryKey) {
463
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
+ }
464
597
  }
465
598
  if (!entity) {
466
599
  // New record
@@ -521,7 +654,46 @@ class Push extends core_1.Command {
521
654
  }
522
655
  if (dryRun) {
523
656
  this.log(`Would ${isNew ? 'create' : 'update'} ${entityName} record`);
524
- 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;
525
697
  }
526
698
  // Save the record
527
699
  const saved = await entity.Save();
@@ -534,9 +706,12 @@ class Push extends core_1.Command {
534
706
  throw new Error(`Failed to save record: ${errors}`);
535
707
  }
536
708
  // Process related entities after saving parent
709
+ let relatedStats;
537
710
  if (recordData.relatedEntities && !dryRun) {
538
- await this.processRelatedEntities(recordData.relatedEntities, entity, entity, // root is same as parent for top level
539
- 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);
540
715
  }
541
716
  // Update the local file with new primary key if created
542
717
  if (isNew) {
@@ -548,6 +723,9 @@ class Push extends core_1.Command {
548
723
  }
549
724
  recordData.primaryKey = newPrimaryKey;
550
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);
551
729
  }
552
730
  // Always update sync metadata
553
731
  // This ensures related entities are persisted with their metadata
@@ -561,10 +739,11 @@ class Push extends core_1.Command {
561
739
  const filePath = path_1.default.join(baseDir, fileName);
562
740
  await fs_extra_1.default.writeJson(filePath, recordData, { spaces: 2 });
563
741
  }
564
- return isNew;
742
+ return { isNew, wasActuallyUpdated, isDuplicate, relatedStats };
565
743
  }
566
- 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) {
567
745
  const indent = ' '.repeat(indentLevel);
746
+ const stats = { created: 0, updated: 0, unchanged: 0 };
568
747
  for (const [entityName, records] of Object.entries(relatedEntities)) {
569
748
  if (verbose) {
570
749
  this.log(`${indent}↳ Processing ${records.length} related ${entityName} records`);
@@ -576,6 +755,27 @@ class Push extends core_1.Command {
576
755
  let isNew = false;
577
756
  if (relatedRecord.primaryKey) {
578
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
+ }
579
779
  }
580
780
  if (!entity) {
581
781
  entity = await syncEngine.createEntityObject(entityName);
@@ -626,6 +826,46 @@ class Push extends core_1.Command {
626
826
  this.warn(`${indent} Field '${field}' does not exist on entity '${entityName}'`);
627
827
  }
628
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
+ }
629
869
  // Save the related entity
630
870
  const saved = await entity.Save();
631
871
  if (!saved) {
@@ -636,9 +876,24 @@ class Push extends core_1.Command {
636
876
  const errors = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || 'Unknown error';
637
877
  throw new Error(`Failed to save related ${entityName}: ${errors}`);
638
878
  }
639
- 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) {
640
892
  this.log(`${indent} ✓ ${isNew ? 'Created' : 'Updated'} ${entityName} record`);
641
893
  }
894
+ else if (verbose && !wasActuallyUpdated) {
895
+ this.log(`${indent} - No changes to ${entityName} record`);
896
+ }
642
897
  // Update the related record with primary key and sync metadata
643
898
  const entityInfo = syncEngine.getEntityInfo(entityName);
644
899
  if (entityInfo) {
@@ -648,6 +903,9 @@ class Push extends core_1.Command {
648
903
  for (const pk of entityInfo.PrimaryKeys) {
649
904
  relatedRecord.primaryKey[pk.Name] = entity.Get(pk.Name);
650
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);
651
909
  }
652
910
  // Always update sync metadata
653
911
  relatedRecord.sync = {
@@ -657,7 +915,11 @@ class Push extends core_1.Command {
657
915
  }
658
916
  // Process nested related entities if any
659
917
  if (relatedRecord.relatedEntities) {
660
- 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;
661
923
  }
662
924
  }
663
925
  catch (error) {
@@ -665,6 +927,114 @@ class Push extends core_1.Command {
665
927
  }
666
928
  }
667
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}`;
668
1038
  }
669
1039
  }
670
1040
  exports.default = Push;