@memberjunction/metadata-sync 2.84.0 → 2.86.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,36 +273,22 @@ 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
  }
296
282
  }
297
- // Write back the entire file if it's an array (after processing all records)
298
- if (isArray && !options.dryRun) {
299
- await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(filePath, records);
283
+ // Write back to file (handles both single records and arrays)
284
+ if (!options.dryRun) {
285
+ if (isArray) {
286
+ await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(filePath, records);
287
+ }
288
+ else {
289
+ // For single record files, write back the single record
290
+ await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(filePath, records[0]);
291
+ }
300
292
  }
301
293
  }
302
294
  catch (fileError) {
@@ -307,111 +299,125 @@ class PushService {
307
299
  }
308
300
  return { created, updated, unchanged, errors };
309
301
  }
310
- async processRecord(recordData, entityConfig, entityDir, options, callbacks, filePath, arrayIndex) {
302
+ async processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks) {
311
303
  const metadata = new core_1.Metadata();
304
+ const { record, entityName, parentContext, id: recordId } = flattenedRecord;
305
+ // Use the unique record ID from the flattened record for batch context
306
+ // This ensures we can properly find parent entities even when they're new
307
+ const lookupKey = recordId;
308
+ // Check if already in batch context
309
+ let entity = batchContext.get(lookupKey);
310
+ if (entity) {
311
+ // Already processed
312
+ return { status: 'unchanged', isDuplicate: true };
313
+ }
312
314
  // Get or create entity instance
313
- let entity = await metadata.GetEntityObject(entityConfig.entity, this.contextUser);
315
+ entity = await metadata.GetEntityObject(entityName, this.contextUser);
314
316
  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;
317
+ throw new Error(`Failed to create entity object for ${entityName}`);
332
318
  }
333
319
  // Check if record exists
334
- const primaryKey = recordData.primaryKey;
320
+ const primaryKey = record.primaryKey;
335
321
  let exists = false;
336
322
  let isNew = false;
337
323
  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) {
324
+ // First check if the record exists using the sync engine's loadEntity method
325
+ // This avoids the "Error in BaseEntity.Load" message for missing records
326
+ const existingEntity = await this.syncEngine.loadEntity(entityName, primaryKey);
327
+ if (existingEntity) {
328
+ // Record exists, use the loaded entity
329
+ entity = existingEntity;
330
+ exists = true;
331
+ }
332
+ else {
333
+ // Record doesn't exist in database
344
334
  const autoCreate = this.syncConfig?.push?.autoCreateMissingRecords ?? false;
345
335
  const pkDisplay = Object.entries(primaryKey)
346
336
  .map(([key, value]) => `${key}=${value}`)
347
337
  .join(', ');
348
338
  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`;
339
+ const warning = `Record not found: ${entityName} with primaryKey {${pkDisplay}}. To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`;
350
340
  this.warnings.push(warning);
351
341
  callbacks?.onWarn?.(warning);
352
- return { status: 'error', relatedStats: { created: 0, updated: 0, unchanged: 0 } };
342
+ return { status: 'error' };
353
343
  }
354
- else if (options.verbose) {
355
- callbacks?.onLog?.(`Auto-creating missing ${entityConfig.entity} record with primaryKey {${pkDisplay}}`);
344
+ else {
345
+ // Log that we're creating the missing record
346
+ if (options.verbose) {
347
+ callbacks?.onLog?.(`📝 Creating missing ${entityName} record with primaryKey {${pkDisplay}}`);
348
+ }
356
349
  }
357
350
  }
358
351
  }
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
352
  if (!exists) {
370
- entity.NewRecord(); // make sure our record starts out fresh
353
+ entity.NewRecord();
371
354
  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
355
+ // Set primary key values for new records if provided
374
356
  if (primaryKey) {
375
357
  for (const [pkField, pkValue] of Object.entries(primaryKey)) {
376
358
  entity.Set(pkField, pkValue);
377
359
  }
378
360
  }
379
361
  }
380
- // Set field values
381
- for (const [fieldName, fieldValue] of Object.entries(processedData)) {
382
- entity.Set(fieldName, fieldValue);
362
+ // Store original field values to preserve @ references
363
+ const originalFields = { ...record.fields };
364
+ // Get parent entity from context if available
365
+ let parentEntity = null;
366
+ if (parentContext) {
367
+ // Find the parent's flattened record ID
368
+ // The parent record was flattened before this child, so it should have a lower ID number
369
+ const parentRecordId = flattenedRecord.dependencies.values().next().value;
370
+ if (parentRecordId) {
371
+ parentEntity = batchContext.get(parentRecordId) || null;
372
+ }
373
+ if (!parentEntity) {
374
+ // Parent should have been processed before child due to dependency ordering
375
+ throw new Error(`Parent entity not found in batch context for ${entityName}. Parent dependencies: ${Array.from(flattenedRecord.dependencies).join(', ')}`);
376
+ }
383
377
  }
384
- // Handle related entities
385
- if (recordData.relatedEntities) {
386
- // Store related entities to process after parent save
387
- entity.__pendingRelatedEntities = recordData.relatedEntities;
378
+ // Process field values with parent context and batch context
379
+ for (const [fieldName, fieldValue] of Object.entries(record.fields)) {
380
+ const processedValue = await this.syncEngine.processFieldValue(fieldValue, entityDir, parentEntity, null, // rootRecord
381
+ 0, batchContext // Pass batch context for lookups
382
+ );
383
+ entity.Set(fieldName, processedValue);
388
384
  }
389
385
  // Check if the record is actually dirty before considering it changed
390
386
  let isDirty = entity.Dirty;
391
387
  // Also check if file content has changed (for @file references)
392
- if (!isDirty && !isNew && recordData.sync) {
388
+ if (!isDirty && !isNew && record.sync) {
393
389
  const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(originalFields, entityDir);
394
- if (currentChecksum !== recordData.sync.checksum) {
390
+ if (currentChecksum !== record.sync.checksum) {
395
391
  isDirty = true;
396
392
  if (options.verbose) {
397
- callbacks?.onLog?.(`📄 File content changed for ${entityConfig.entity} record (checksum mismatch)`);
393
+ callbacks?.onLog?.(`📄 File content changed for ${entityName} record (checksum mismatch)`);
398
394
  }
399
395
  }
400
396
  }
397
+ if (options.dryRun) {
398
+ if (exists) {
399
+ callbacks?.onLog?.(`[DRY RUN] Would update ${entityName} record`);
400
+ return { status: 'updated' };
401
+ }
402
+ else {
403
+ callbacks?.onLog?.(`[DRY RUN] Would create ${entityName} record`);
404
+ return { status: 'created' };
405
+ }
406
+ }
401
407
  // If updating an existing record that's dirty, show what changed
402
408
  if (!isNew && isDirty) {
403
409
  const changes = entity.GetChangesSinceLastSave();
404
410
  const changeKeys = Object.keys(changes);
405
411
  if (changeKeys.length > 0) {
406
412
  // Get primary key info for display
407
- const entityInfo = this.syncEngine.getEntityInfo(entityConfig.entity);
413
+ const entityInfo = this.syncEngine.getEntityInfo(entityName);
408
414
  const primaryKeyDisplay = [];
409
415
  if (entityInfo) {
410
416
  for (const pk of entityInfo.PrimaryKeys) {
411
417
  primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
412
418
  }
413
419
  }
414
- callbacks?.onLog?.(`📝 Updating ${entityConfig.entity} record:`);
420
+ callbacks?.onLog?.(`📝 Updating ${entityName} record:`);
415
421
  if (primaryKeyDisplay.length > 0) {
416
422
  callbacks?.onLog?.(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
417
423
  }
@@ -424,32 +430,28 @@ class PushService {
424
430
  }
425
431
  }
426
432
  }
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)
433
+ // Save the record
433
434
  const saveResult = await entity.Save();
434
435
  if (!saveResult) {
435
- throw new Error(`Failed to save ${entityConfig.entity} record: ${entity.LatestResult?.Message || 'Unknown error'}`);
436
+ throw new Error(`Failed to save ${entityName} record: ${entity.LatestResult?.Message || 'Unknown error'}`);
436
437
  }
438
+ // Add to batch context AFTER save so it has an ID for child @parent:ID references
439
+ // Use the recordId (lookupKey) as the key so child records can find this parent
440
+ batchContext.set(lookupKey, entity);
437
441
  // Update primaryKey for new records
438
442
  if (isNew) {
439
- const entityInfo = this.syncEngine.getEntityInfo(entityConfig.entity);
443
+ const entityInfo = this.syncEngine.getEntityInfo(entityName);
440
444
  if (entityInfo) {
441
445
  const newPrimaryKey = {};
442
446
  for (const pk of entityInfo.PrimaryKeys) {
443
447
  newPrimaryKey[pk.Name] = entity.Get(pk.Name);
444
448
  }
445
- recordData.primaryKey = newPrimaryKey;
449
+ record.primaryKey = newPrimaryKey;
446
450
  }
447
- // Track the new record now that we have its primary key
448
- this.checkAndTrackRecord(entityConfig.entity, entity, filePath, arrayIndex);
449
451
  }
450
452
  // Only update sync metadata if the record was actually dirty (changed)
451
453
  if (isNew || isDirty) {
452
- recordData.sync = {
454
+ record.sync = {
453
455
  lastModified: new Date().toISOString(),
454
456
  checksum: await this.syncEngine.calculateChecksumWithFileContent(originalFields, entityDir)
455
457
  };
@@ -461,310 +463,46 @@ class PushService {
461
463
  callbacks?.onLog?.(` - Skipped sync metadata update (no changes detected)`);
462
464
  }
463
465
  // 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
466
+ record.fields = originalFields;
467
+ return {
468
+ status: isNew ? 'created' : (isDirty ? 'updated' : 'unchanged'),
469
+ isDuplicate: false
484
470
  };
485
- // Return enhanced result with related stats
486
- return result;
487
471
  }
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`);
472
+ formatFieldValue(value) {
473
+ if (value === null || value === undefined)
474
+ return 'null';
475
+ if (typeof value === 'string') {
476
+ // Truncate long strings and show quotes
477
+ if (value.length > 50) {
478
+ return `"${value.substring(0, 47)}..."`;
494
479
  }
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}`);
686
- }
687
- }
688
- }
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) + '...';
480
+ return `"${value}"`;
702
481
  }
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
482
+ if (typeof value === 'object') {
483
+ return JSON.stringify(value);
738
484
  }
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
485
+ return String(value);
746
486
  }
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);
487
+ buildBatchContextKey(entityName, record) {
488
+ // Build a unique key for the batch context based on entity name and identifying fields
489
+ const keyParts = [entityName];
490
+ // Use primary key if available
491
+ if (record.primaryKey) {
492
+ for (const [field, value] of Object.entries(record.primaryKey)) {
493
+ keyParts.push(`${field}=${value}`);
494
+ }
760
495
  }
761
- else if (arrayIndex !== undefined) {
762
- // Fallback estimation if we don't have actual line numbers
763
- lineNumber = 2 + (arrayIndex * 15);
496
+ else {
497
+ // Use a combination of important fields as fallback
498
+ const identifyingFields = ['Name', 'ID', 'Code', 'Email'];
499
+ for (const field of identifyingFields) {
500
+ if (record.fields[field]) {
501
+ keyParts.push(`${field}=${record.fields[field]}`);
502
+ }
503
+ }
764
504
  }
765
- // Create clickable file path for VSCode - format: file:line
766
- // VSCode will make this clickable in the terminal
767
- return `${absolutePath}:${lineNumber}`;
505
+ return keyParts.join('|');
768
506
  }
769
507
  findEntityDirectories(baseDir, specificDir, directoryOrder) {
770
508
  const dirs = [];