@memberjunction/metadata-sync 2.84.0 → 2.85.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.
@@ -14,11 +14,11 @@ const config_manager_1 = require("../lib/config-manager");
14
14
  const sql_logger_1 = require("../lib/sql-logger");
15
15
  const transaction_manager_1 = require("../lib/transaction-manager");
16
16
  const json_write_helper_1 = require("../lib/json-write-helper");
17
+ const record_dependency_analyzer_1 = require("../lib/record-dependency-analyzer");
17
18
  class PushService {
18
19
  syncEngine;
19
20
  contextUser;
20
21
  warnings = [];
21
- processedRecords = new Map();
22
22
  syncConfig;
23
23
  constructor(syncEngine, contextUser) {
24
24
  this.syncEngine = syncEngine;
@@ -26,7 +26,6 @@ class PushService {
26
26
  }
27
27
  async push(options, callbacks) {
28
28
  this.warnings = [];
29
- this.processedRecords.clear();
30
29
  const fileBackupManager = new file_backup_manager_1.FileBackupManager();
31
30
  // Load sync config for SQL logging settings and autoCreateMissingRecords flag
32
31
  // If dir option is specified, load from that directory, otherwise use original CWD
@@ -133,7 +132,7 @@ class PushService {
133
132
  if (options.verbose && callbacks?.onLog) {
134
133
  callbacks.onLog(`Processing ${entityConfig.entity} in ${entityDir}`);
135
134
  }
136
- const result = await this.processEntityDirectory(entityDir, entityConfig, options, fileBackupManager, callbacks, sqlLogger);
135
+ const result = await this.processEntityDirectory(entityDir, entityConfig, options, fileBackupManager, callbacks);
137
136
  // Stop the spinner if we were using onProgress
138
137
  if (callbacks?.onProgress && callbacks?.onSuccess) {
139
138
  callbacks.onSuccess(`Processed ${dirName}`);
@@ -220,7 +219,7 @@ class PushService {
220
219
  throw error;
221
220
  }
222
221
  }
223
- async processEntityDirectory(entityDir, entityConfig, options, fileBackupManager, callbacks, sqlLogger) {
222
+ async processEntityDirectory(entityDir, entityConfig, options, fileBackupManager, callbacks) {
224
223
  let created = 0;
225
224
  let updated = 0;
226
225
  let unchanged = 0;
@@ -247,18 +246,25 @@ class PushService {
247
246
  const fileData = await fs_extra_1.default.readJson(filePath);
248
247
  const records = Array.isArray(fileData) ? fileData : [fileData];
249
248
  const isArray = Array.isArray(fileData);
250
- for (let i = 0; i < records.length; i++) {
251
- const recordData = records[i];
252
- if (!this.isValidRecordData(recordData)) {
253
- callbacks?.onWarn?.(`Invalid record format in ${filePath}${isArray ? ` at index ${i}` : ''}`);
254
- errors++;
255
- continue;
249
+ // Analyze dependencies and get sorted records
250
+ const analyzer = new record_dependency_analyzer_1.RecordDependencyAnalyzer();
251
+ const analysisResult = await analyzer.analyzeFileRecords(records, entityConfig.entity);
252
+ if (analysisResult.circularDependencies.length > 0) {
253
+ callbacks?.onWarn?.(`⚠️ Circular dependencies detected in ${filePath}`);
254
+ for (const cycle of analysisResult.circularDependencies) {
255
+ callbacks?.onWarn?.(` Cycle: ${cycle.join(' → ')}`);
256
256
  }
257
+ }
258
+ if (options.verbose) {
259
+ callbacks?.onLog?.(` Analyzed ${analysisResult.sortedRecords.length} records (including nested)`);
260
+ }
261
+ // Create batch context for in-memory entity resolution
262
+ const batchContext = new Map();
263
+ // Process all flattened records in dependency order
264
+ for (const flattenedRecord of analysisResult.sortedRecords) {
257
265
  try {
258
- // For arrays, work with a deep copy to avoid modifying the original
259
- const recordToProcess = isArray ? JSON.parse(JSON.stringify(recordData)) : recordData;
260
- const result = await this.processRecord(recordToProcess, entityConfig, entityDir, options, callbacks, filePath, isArray ? i : undefined);
261
- // Don't count duplicates in stats
266
+ const result = await this.processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks);
267
+ // Update stats
262
268
  if (!result.isDuplicate) {
263
269
  if (result.status === 'created')
264
270
  created++;
@@ -267,29 +273,9 @@ class PushService {
267
273
  else if (result.status === 'unchanged')
268
274
  unchanged++;
269
275
  }
270
- // Add related entity stats
271
- created += result.relatedStats.created;
272
- updated += result.relatedStats.updated;
273
- unchanged += result.relatedStats.unchanged;
274
- // For arrays, update the original record's primaryKey, sync, and relatedEntities
275
- if (isArray) {
276
- // Update primaryKey if it exists (for new records)
277
- if (recordToProcess.primaryKey) {
278
- records[i].primaryKey = recordToProcess.primaryKey;
279
- }
280
- // Update sync metadata only if it was updated (dirty records only)
281
- if (recordToProcess.sync) {
282
- records[i].sync = recordToProcess.sync;
283
- }
284
- // Update relatedEntities to capture primaryKey/sync changes in nested entities
285
- if (recordToProcess.relatedEntities) {
286
- records[i].relatedEntities = recordToProcess.relatedEntities;
287
- }
288
- }
289
- // Record tracking is now handled inside processRecord
290
276
  }
291
277
  catch (recordError) {
292
- const errorMsg = `Error processing record in ${filePath}${isArray ? ` at index ${i}` : ''}: ${recordError}`;
278
+ const errorMsg = `Error processing ${flattenedRecord.entityName} record at ${flattenedRecord.path}: ${recordError}`;
293
279
  callbacks?.onError?.(errorMsg);
294
280
  errors++;
295
281
  }
@@ -307,464 +293,135 @@ class PushService {
307
293
  }
308
294
  return { created, updated, unchanged, errors };
309
295
  }
310
- async processRecord(recordData, entityConfig, entityDir, options, callbacks, filePath, arrayIndex) {
296
+ async processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks) {
311
297
  const metadata = new core_1.Metadata();
298
+ const { record, entityName, parentContext } = flattenedRecord;
299
+ // Build lookup key for batch context
300
+ const lookupKey = this.buildBatchContextKey(entityName, record);
301
+ // Check if already in batch context
302
+ let entity = batchContext.get(lookupKey);
303
+ if (entity) {
304
+ // Already processed
305
+ return { status: 'unchanged', isDuplicate: true };
306
+ }
312
307
  // Get or create entity instance
313
- let entity = await metadata.GetEntityObject(entityConfig.entity, this.contextUser);
308
+ entity = await metadata.GetEntityObject(entityName, this.contextUser);
314
309
  if (!entity) {
315
- throw new Error(`Failed to create entity object for ${entityConfig.entity}`);
316
- }
317
- // Apply defaults from configuration
318
- const defaults = { ...entityConfig.defaults };
319
- // Build full record data - keep original values for file writing
320
- const originalFields = { ...recordData.fields };
321
- const fullData = {
322
- ...defaults,
323
- ...recordData.fields
324
- };
325
- // Process field values for database operations
326
- const processedData = {};
327
- for (const [fieldName, fieldValue] of Object.entries(fullData)) {
328
- const processedValue = await this.syncEngine.processFieldValue(fieldValue, entityDir, null, // parentRecord
329
- null // rootRecord
330
- );
331
- processedData[fieldName] = processedValue;
310
+ throw new Error(`Failed to create entity object for ${entityName}`);
332
311
  }
333
312
  // Check if record exists
334
- const primaryKey = recordData.primaryKey;
313
+ const primaryKey = record.primaryKey;
335
314
  let exists = false;
336
315
  let isNew = false;
337
316
  if (primaryKey && Object.keys(primaryKey).length > 0) {
338
- // Try to load existing record
339
- const compositeKey = new core_1.CompositeKey();
340
- compositeKey.LoadFromSimpleObject(primaryKey);
341
- exists = await entity.InnerLoad(compositeKey);
342
- // Check autoCreateMissingRecords flag if record not found
343
- if (!exists) {
317
+ // First check if the record exists using the sync engine's loadEntity method
318
+ // This avoids the "Error in BaseEntity.Load" message for missing records
319
+ const existingEntity = await this.syncEngine.loadEntity(entityName, primaryKey);
320
+ if (existingEntity) {
321
+ // Record exists, use the loaded entity
322
+ entity = existingEntity;
323
+ exists = true;
324
+ }
325
+ else {
326
+ // Record doesn't exist in database
344
327
  const autoCreate = this.syncConfig?.push?.autoCreateMissingRecords ?? false;
345
328
  const pkDisplay = Object.entries(primaryKey)
346
329
  .map(([key, value]) => `${key}=${value}`)
347
330
  .join(', ');
348
331
  if (!autoCreate) {
349
- const warning = `Record not found: ${entityConfig.entity} with primaryKey {${pkDisplay}}. To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`;
332
+ const warning = `Record not found: ${entityName} with primaryKey {${pkDisplay}}. To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`;
350
333
  this.warnings.push(warning);
351
334
  callbacks?.onWarn?.(warning);
352
- return { status: 'error', relatedStats: { created: 0, updated: 0, unchanged: 0 } };
335
+ return { status: 'error' };
353
336
  }
354
- else if (options.verbose) {
355
- callbacks?.onLog?.(`Auto-creating missing ${entityConfig.entity} record with primaryKey {${pkDisplay}}`);
337
+ else {
338
+ // Log that we're creating the missing record
339
+ if (options.verbose) {
340
+ callbacks?.onLog?.(`📝 Creating missing ${entityName} record with primaryKey {${pkDisplay}}`);
341
+ }
356
342
  }
357
343
  }
358
344
  }
359
- if (options.dryRun) {
360
- if (exists) {
361
- callbacks?.onLog?.(`[DRY RUN] Would update ${entityConfig.entity} record`);
362
- return { status: 'updated', relatedStats: { created: 0, updated: 0, unchanged: 0 } };
363
- }
364
- else {
365
- callbacks?.onLog?.(`[DRY RUN] Would create ${entityConfig.entity} record`);
366
- return { status: 'created', relatedStats: { created: 0, updated: 0, unchanged: 0 } };
367
- }
368
- }
369
345
  if (!exists) {
370
- entity.NewRecord(); // make sure our record starts out fresh
346
+ entity.NewRecord();
371
347
  isNew = true;
372
- // UUID generation now happens automatically in BaseEntity.NewRecord()
373
- // Set primary key values for new records if provided, this is important for the auto-create logic
348
+ // Set primary key values for new records if provided
374
349
  if (primaryKey) {
375
350
  for (const [pkField, pkValue] of Object.entries(primaryKey)) {
376
351
  entity.Set(pkField, pkValue);
377
352
  }
378
353
  }
379
354
  }
380
- // Set field values
381
- for (const [fieldName, fieldValue] of Object.entries(processedData)) {
382
- entity.Set(fieldName, fieldValue);
355
+ // Get parent entity from context if available
356
+ let parentEntity = null;
357
+ if (parentContext) {
358
+ const parentKey = this.buildBatchContextKey(parentContext.entityName, parentContext.record);
359
+ parentEntity = batchContext.get(parentKey) || null;
383
360
  }
384
- // Handle related entities
385
- if (recordData.relatedEntities) {
386
- // Store related entities to process after parent save
387
- entity.__pendingRelatedEntities = recordData.relatedEntities;
361
+ // Process field values with parent context and batch context
362
+ for (const [fieldName, fieldValue] of Object.entries(record.fields)) {
363
+ const processedValue = await this.syncEngine.processFieldValue(fieldValue, entityDir, parentEntity, null, // rootRecord
364
+ 0, batchContext // Pass batch context for lookups
365
+ );
366
+ entity.Set(fieldName, processedValue);
388
367
  }
389
- // Check if the record is actually dirty before considering it changed
390
- let isDirty = entity.Dirty;
391
- // Also check if file content has changed (for @file references)
392
- if (!isDirty && !isNew && recordData.sync) {
393
- const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(originalFields, entityDir);
394
- if (currentChecksum !== recordData.sync.checksum) {
395
- isDirty = true;
396
- if (options.verbose) {
397
- callbacks?.onLog?.(`📄 File content changed for ${entityConfig.entity} record (checksum mismatch)`);
398
- }
368
+ // Add to batch context AFTER fields are set
369
+ batchContext.set(lookupKey, entity);
370
+ if (options.dryRun) {
371
+ if (exists) {
372
+ callbacks?.onLog?.(`[DRY RUN] Would update ${entityName} record`);
373
+ return { status: 'updated' };
399
374
  }
400
- }
401
- // If updating an existing record that's dirty, show what changed
402
- if (!isNew && isDirty) {
403
- const changes = entity.GetChangesSinceLastSave();
404
- const changeKeys = Object.keys(changes);
405
- if (changeKeys.length > 0) {
406
- // Get primary key info for display
407
- const entityInfo = this.syncEngine.getEntityInfo(entityConfig.entity);
408
- const primaryKeyDisplay = [];
409
- if (entityInfo) {
410
- for (const pk of entityInfo.PrimaryKeys) {
411
- primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
412
- }
413
- }
414
- callbacks?.onLog?.(`📝 Updating ${entityConfig.entity} record:`);
415
- if (primaryKeyDisplay.length > 0) {
416
- callbacks?.onLog?.(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
417
- }
418
- callbacks?.onLog?.(` Changes:`);
419
- for (const fieldName of changeKeys) {
420
- const field = entity.GetFieldByName(fieldName);
421
- const oldValue = field ? field.OldValue : undefined;
422
- const newValue = changes[fieldName];
423
- callbacks?.onLog?.(` ${fieldName}: ${this.formatFieldValue(oldValue)} → ${this.formatFieldValue(newValue)}`);
424
- }
375
+ else {
376
+ callbacks?.onLog?.(`[DRY RUN] Would create ${entityName} record`);
377
+ return { status: 'created' };
425
378
  }
426
379
  }
427
- // Check for duplicate processing (but only for existing records that were loaded)
428
- let isDuplicate = false;
429
- if (!isNew && entity) {
430
- isDuplicate = this.checkAndTrackRecord(entityConfig.entity, entity, filePath, arrayIndex);
431
- }
432
- // Save the record (always call Save, but track if it was actually dirty)
380
+ // Save the record
433
381
  const saveResult = await entity.Save();
434
382
  if (!saveResult) {
435
- throw new Error(`Failed to save ${entityConfig.entity} record: ${entity.LatestResult?.Message || 'Unknown error'}`);
383
+ throw new Error(`Failed to save ${entityName} record: ${entity.LatestResult?.Message || 'Unknown error'}`);
436
384
  }
437
385
  // Update primaryKey for new records
438
386
  if (isNew) {
439
- const entityInfo = this.syncEngine.getEntityInfo(entityConfig.entity);
387
+ const entityInfo = this.syncEngine.getEntityInfo(entityName);
440
388
  if (entityInfo) {
441
389
  const newPrimaryKey = {};
442
390
  for (const pk of entityInfo.PrimaryKeys) {
443
391
  newPrimaryKey[pk.Name] = entity.Get(pk.Name);
444
392
  }
445
- recordData.primaryKey = newPrimaryKey;
393
+ record.primaryKey = newPrimaryKey;
446
394
  }
447
- // Track the new record now that we have its primary key
448
- this.checkAndTrackRecord(entityConfig.entity, entity, filePath, arrayIndex);
449
395
  }
450
- // Only update sync metadata if the record was actually dirty (changed)
451
- if (isNew || isDirty) {
452
- recordData.sync = {
453
- lastModified: new Date().toISOString(),
454
- checksum: await this.syncEngine.calculateChecksumWithFileContent(originalFields, entityDir)
455
- };
456
- if (options.verbose) {
457
- callbacks?.onLog?.(` ✓ Updated sync metadata (record was ${isNew ? 'new' : 'changed'})`);
458
- }
459
- }
460
- else if (options.verbose) {
461
- callbacks?.onLog?.(` - Skipped sync metadata update (no changes detected)`);
462
- }
463
- // Restore original field values to preserve @ references
464
- recordData.fields = originalFields;
465
- // Write back to file only if it's a single record (not part of an array)
466
- if (filePath && arrayIndex === undefined && !options.dryRun) {
467
- await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(filePath, recordData);
468
- }
469
- // Process related entities after parent save
470
- let relatedStats = { created: 0, updated: 0, unchanged: 0 };
471
- if (recordData.relatedEntities && !options.dryRun) {
472
- relatedStats = await this.processRelatedEntities(recordData.relatedEntities, entity, entity, // root is same as parent for top level
473
- entityDir, options, callbacks, undefined, // fileBackupManager
474
- 1, // indentLevel
475
- filePath, arrayIndex);
476
- }
477
- // Store related stats on the result for propagation
478
- // Don't count duplicates in stats
479
- const status = isDuplicate ? 'unchanged' : (isNew ? 'created' : (isDirty ? 'updated' : 'unchanged'));
480
- const result = {
481
- status,
482
- relatedStats,
483
- isDuplicate
396
+ // Update sync metadata
397
+ record.sync = {
398
+ lastModified: new Date().toISOString(),
399
+ checksum: await this.syncEngine.calculateChecksumWithFileContent(record.fields, entityDir)
400
+ };
401
+ return {
402
+ status: isNew ? 'created' : (entity.Dirty ? 'updated' : 'unchanged'),
403
+ isDuplicate: false
484
404
  };
485
- // Return enhanced result with related stats
486
- return result;
487
405
  }
488
- async processRelatedEntities(relatedEntities, parentEntity, rootEntity, baseDir, options, callbacks, fileBackupManager, indentLevel = 1, parentFilePath, parentArrayIndex) {
489
- const indent = ' '.repeat(indentLevel);
490
- const stats = { created: 0, updated: 0, unchanged: 0 };
491
- for (const [entityName, records] of Object.entries(relatedEntities)) {
492
- if (options.verbose) {
493
- callbacks?.onLog?.(`${indent}↳ Processing ${records.length} related ${entityName} records`);
406
+ buildBatchContextKey(entityName, record) {
407
+ // Build a unique key for the batch context based on entity name and identifying fields
408
+ const keyParts = [entityName];
409
+ // Use primary key if available
410
+ if (record.primaryKey) {
411
+ for (const [field, value] of Object.entries(record.primaryKey)) {
412
+ keyParts.push(`${field}=${value}`);
494
413
  }
495
- for (const relatedRecord of records) {
496
- try {
497
- // Load or create entity
498
- let entity = null;
499
- let isNew = false;
500
- if (relatedRecord.primaryKey) {
501
- entity = await this.syncEngine.loadEntity(entityName, relatedRecord.primaryKey);
502
- // Warn if record has primaryKey but wasn't found
503
- if (!entity) {
504
- const pkDisplay = Object.entries(relatedRecord.primaryKey)
505
- .map(([key, value]) => `${key}=${value}`)
506
- .join(', ');
507
- // Load sync config to check autoCreateMissingRecords setting
508
- const autoCreate = this.syncConfig?.push?.autoCreateMissingRecords ?? false;
509
- if (!autoCreate) {
510
- const fileRef = parentFilePath ? path_1.default.relative(config_manager_1.configManager.getOriginalCwd(), parentFilePath) : 'unknown';
511
- const warning = `${indent}⚠️ Related record not found: ${entityName} with primaryKey {${pkDisplay}} at ${fileRef}`;
512
- this.warnings.push(warning);
513
- callbacks?.onWarn?.(warning);
514
- const warning2 = `${indent} To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`;
515
- this.warnings.push(warning2);
516
- callbacks?.onWarn?.(warning2);
517
- // Skip this record
518
- continue;
519
- }
520
- else {
521
- if (options.verbose) {
522
- callbacks?.onLog?.(`${indent} Auto-creating missing related ${entityName} record with primaryKey {${pkDisplay}}`);
523
- }
524
- }
525
- }
526
- }
527
- if (!entity) {
528
- entity = await this.syncEngine.createEntityObject(entityName);
529
- entity.NewRecord();
530
- isNew = true;
531
- // Handle primary keys for new related entity records
532
- const entityInfo = this.syncEngine.getEntityInfo(entityName);
533
- if (entityInfo) {
534
- for (const pk of entityInfo.PrimaryKeys) {
535
- if (!pk.AutoIncrement) {
536
- // Check if we have a value in primaryKey object
537
- if (relatedRecord.primaryKey?.[pk.Name]) {
538
- // User specified a primary key for new record, set it on entity directly
539
- // Don't add to fields as it will be in primaryKey section
540
- entity[pk.Name] = relatedRecord.primaryKey[pk.Name];
541
- if (options.verbose) {
542
- callbacks?.onLog?.(`${indent} Using specified primary key ${pk.Name}: ${relatedRecord.primaryKey[pk.Name]}`);
543
- }
544
- }
545
- // Note: BaseEntity.NewRecord() automatically generates UUIDs for uniqueidentifier primary keys
546
- }
547
- }
548
- }
549
- }
550
- // Apply fields with parent/root context
551
- for (const [field, value] of Object.entries(relatedRecord.fields)) {
552
- if (field in entity) {
553
- try {
554
- const processedValue = await this.syncEngine.processFieldValue(value, baseDir, parentEntity, rootEntity);
555
- if (options.verbose) {
556
- callbacks?.onLog?.(`${indent} Setting ${field}: ${this.formatFieldValue(value)} -> ${this.formatFieldValue(processedValue)}`);
557
- }
558
- entity[field] = processedValue;
559
- }
560
- catch (error) {
561
- throw new Error(`Failed to process field '${field}' in ${entityName}: ${error}`);
562
- }
563
- }
564
- else {
565
- const warning = `${indent} Field '${field}' does not exist on entity '${entityName}'`;
566
- this.warnings.push(warning);
567
- callbacks?.onWarn?.(warning);
568
- }
569
- }
570
- // Check for duplicate processing (but only for existing records that were loaded)
571
- let isDuplicate = false;
572
- if (!isNew && entity) {
573
- // Use parent file path for related entities since they're defined in the parent's file
574
- const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
575
- isDuplicate = this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
576
- }
577
- // Check if the record is dirty before saving
578
- let wasActuallyUpdated = false;
579
- // Check for file content changes for related entities
580
- if (!isNew && relatedRecord.sync) {
581
- const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(relatedRecord.fields, baseDir);
582
- if (currentChecksum !== relatedRecord.sync.checksum) {
583
- wasActuallyUpdated = true;
584
- if (options.verbose) {
585
- callbacks?.onLog?.(`${indent}📄 File content changed for related ${entityName} record (checksum mismatch)`);
586
- }
587
- }
588
- }
589
- if (!isNew && entity.Dirty) {
590
- // Record is dirty, get the changes
591
- const changes = entity.GetChangesSinceLastSave();
592
- const changeKeys = Object.keys(changes);
593
- if (changeKeys.length > 0) {
594
- wasActuallyUpdated = true;
595
- // Get primary key info for display
596
- const entityInfo = this.syncEngine.getEntityInfo(entityName);
597
- const primaryKeyDisplay = [];
598
- if (entityInfo) {
599
- for (const pk of entityInfo.PrimaryKeys) {
600
- primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
601
- }
602
- }
603
- callbacks?.onLog?.(''); // Add newline before update output
604
- callbacks?.onLog?.(`${indent}📝 Updating related ${entityName} record:`);
605
- if (primaryKeyDisplay.length > 0) {
606
- callbacks?.onLog?.(`${indent} Primary Key: ${primaryKeyDisplay.join(', ')}`);
607
- }
608
- callbacks?.onLog?.(`${indent} Changes:`);
609
- for (const fieldName of changeKeys) {
610
- const field = entity.GetFieldByName(fieldName);
611
- const oldValue = field ? field.OldValue : undefined;
612
- const newValue = changes[fieldName];
613
- callbacks?.onLog?.(`${indent} ${fieldName}: ${this.formatFieldValue(oldValue)} → ${this.formatFieldValue(newValue)}`);
614
- }
615
- }
616
- }
617
- else if (isNew) {
618
- wasActuallyUpdated = true;
619
- }
620
- // Save the related entity
621
- const saved = await entity.Save();
622
- if (!saved) {
623
- const message = entity.LatestResult?.Message;
624
- if (message) {
625
- throw new Error(`Failed to save related ${entityName}: ${message}`);
626
- }
627
- const errors = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || 'Unknown error';
628
- throw new Error(`Failed to save related ${entityName}: ${errors}`);
629
- }
630
- // Update stats - don't count duplicates
631
- if (!isDuplicate) {
632
- if (isNew) {
633
- stats.created++;
634
- }
635
- else if (wasActuallyUpdated) {
636
- stats.updated++;
637
- }
638
- else {
639
- stats.unchanged++;
640
- }
641
- }
642
- if (options.verbose && wasActuallyUpdated) {
643
- callbacks?.onLog?.(`${indent} ✓ ${isNew ? 'Created' : 'Updated'} ${entityName} record`);
644
- }
645
- else if (options.verbose && !wasActuallyUpdated) {
646
- callbacks?.onLog?.(`${indent} - No changes to ${entityName} record`);
647
- }
648
- // Update the related record with primary key and sync metadata
649
- const entityInfo = this.syncEngine.getEntityInfo(entityName);
650
- if (entityInfo) {
651
- // Update primary key if new
652
- if (isNew) {
653
- relatedRecord.primaryKey = {};
654
- for (const pk of entityInfo.PrimaryKeys) {
655
- relatedRecord.primaryKey[pk.Name] = entity.Get(pk.Name);
656
- }
657
- // Track the new related entity now that we have its primary key
658
- const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
659
- this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
660
- }
661
- // Only update sync metadata if the record was actually changed
662
- if (isNew || wasActuallyUpdated) {
663
- relatedRecord.sync = {
664
- lastModified: new Date().toISOString(),
665
- checksum: await this.syncEngine.calculateChecksumWithFileContent(relatedRecord.fields, baseDir)
666
- };
667
- if (options.verbose) {
668
- callbacks?.onLog?.(`${indent} ✓ Updated sync metadata for related ${entityName} (record was ${isNew ? 'new' : 'changed'})`);
669
- }
670
- }
671
- else if (options.verbose) {
672
- callbacks?.onLog?.(`${indent} - Skipped sync metadata update for related ${entityName} (no changes detected)`);
673
- }
674
- }
675
- // Process nested related entities if any
676
- if (relatedRecord.relatedEntities) {
677
- const nestedStats = await this.processRelatedEntities(relatedRecord.relatedEntities, entity, rootEntity, baseDir, options, callbacks, fileBackupManager, indentLevel + 1, parentFilePath, parentArrayIndex);
678
- // Accumulate nested stats
679
- stats.created += nestedStats.created;
680
- stats.updated += nestedStats.updated;
681
- stats.unchanged += nestedStats.unchanged;
682
- }
683
- }
684
- catch (error) {
685
- throw new Error(`Failed to process related ${entityName}: ${error}`);
414
+ }
415
+ else {
416
+ // Use a combination of important fields as fallback
417
+ const identifyingFields = ['Name', 'ID', 'Code', 'Email'];
418
+ for (const field of identifyingFields) {
419
+ if (record.fields[field]) {
420
+ keyParts.push(`${field}=${record.fields[field]}`);
686
421
  }
687
422
  }
688
423
  }
689
- return stats;
690
- }
691
- isValidRecordData(data) {
692
- return data &&
693
- typeof data === 'object' &&
694
- 'fields' in data &&
695
- typeof data.fields === 'object';
696
- }
697
- formatFieldValue(value, maxLength = 50) {
698
- let strValue = JSON.stringify(value);
699
- strValue = strValue.trim();
700
- if (strValue.length > maxLength) {
701
- return strValue.substring(0, maxLength) + '...';
702
- }
703
- return strValue;
704
- }
705
- /**
706
- * Generate a unique tracking key for a record based on entity name and primary key values
707
- */
708
- generateRecordKey(entityName, entity) {
709
- // Use the built-in CompositeKey ToURLSegment method
710
- const keySegment = entity.PrimaryKey.ToURLSegment();
711
- return `${entityName}|${keySegment}`;
712
- }
713
- /**
714
- * Check if a record has already been processed and warn if duplicate
715
- */
716
- checkAndTrackRecord(entityName, entity, filePath, arrayIndex, lineNumber) {
717
- const recordKey = this.generateRecordKey(entityName, entity);
718
- const existing = this.processedRecords.get(recordKey);
719
- if (existing) {
720
- const primaryKeyDisplay = entity.EntityInfo?.PrimaryKeys
721
- .map(pk => `${pk.Name}: ${entity.Get(pk.Name)}`)
722
- .join(', ') || 'unknown';
723
- // Format file location with clickable link for VSCode
724
- // Create maps with just the line numbers we have
725
- const currentLineMap = lineNumber ? new Map([[arrayIndex || 0, lineNumber]]) : undefined;
726
- const originalLineMap = existing.lineNumber ? new Map([[existing.arrayIndex || 0, existing.lineNumber]]) : undefined;
727
- const currentLocation = this.formatFileLocation(filePath, arrayIndex, currentLineMap);
728
- const originalLocation = this.formatFileLocation(existing.filePath, existing.arrayIndex, originalLineMap);
729
- const warning = `⚠️ Duplicate record detected for ${entityName} (${primaryKeyDisplay})`;
730
- this.warnings.push(warning);
731
- const warning2 = ` Current location: ${currentLocation}`;
732
- this.warnings.push(warning2);
733
- const warning3 = ` Original location: ${originalLocation}`;
734
- this.warnings.push(warning3);
735
- const warning4 = ` The duplicate update will proceed, but you should review your data for unintended duplicates.`;
736
- this.warnings.push(warning4);
737
- return true; // is duplicate
738
- }
739
- // Track the record with its source location
740
- this.processedRecords.set(recordKey, {
741
- filePath: filePath || 'unknown',
742
- arrayIndex,
743
- lineNumber
744
- });
745
- return false; // not duplicate
746
- }
747
- /**
748
- * Format file location with clickable link for VSCode
749
- */
750
- formatFileLocation(filePath, arrayIndex, lineNumbers) {
751
- if (!filePath || filePath === 'unknown') {
752
- return 'unknown';
753
- }
754
- // Get absolute path for better VSCode integration
755
- const absolutePath = path_1.default.resolve(filePath);
756
- // Try to get actual line number from our tracking
757
- let lineNumber = 1;
758
- if (arrayIndex !== undefined && lineNumbers && lineNumbers.has(arrayIndex)) {
759
- lineNumber = lineNumbers.get(arrayIndex);
760
- }
761
- else if (arrayIndex !== undefined) {
762
- // Fallback estimation if we don't have actual line numbers
763
- lineNumber = 2 + (arrayIndex * 15);
764
- }
765
- // Create clickable file path for VSCode - format: file:line
766
- // VSCode will make this clickable in the terminal
767
- return `${absolutePath}:${lineNumber}`;
424
+ return keyParts.join('|');
768
425
  }
769
426
  findEntityDirectories(baseDir, specificDir, directoryOrder) {
770
427
  const dirs = [];