@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.
- package/dist/lib/record-dependency-analyzer.d.ts +77 -0
- package/dist/lib/record-dependency-analyzer.js +398 -0
- package/dist/lib/record-dependency-analyzer.js.map +1 -0
- package/dist/lib/sync-engine.d.ts +4 -77
- package/dist/lib/sync-engine.js +41 -171
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/services/PushService.d.ts +2 -17
- package/dist/services/PushService.js +100 -443
- package/dist/services/PushService.js.map +1 -1
- package/package.json +7 -7
|
@@ -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
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
308
|
+
entity = await metadata.GetEntityObject(entityName, this.contextUser);
|
|
314
309
|
if (!entity) {
|
|
315
|
-
throw new Error(`Failed to create entity object for ${
|
|
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 =
|
|
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
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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: ${
|
|
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'
|
|
335
|
+
return { status: 'error' };
|
|
353
336
|
}
|
|
354
|
-
else
|
|
355
|
-
|
|
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();
|
|
346
|
+
entity.NewRecord();
|
|
371
347
|
isNew = true;
|
|
372
|
-
//
|
|
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
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
//
|
|
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 ${
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
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 = [];
|