@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.
- package/dist/lib/record-dependency-analyzer.d.ts +77 -0
- package/dist/lib/record-dependency-analyzer.js +427 -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 -16
- package/dist/services/PushService.js +139 -401
- 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,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
|
|
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
|
|
298
|
-
if (
|
|
299
|
-
|
|
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
|
|
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
|
-
|
|
315
|
+
entity = await metadata.GetEntityObject(entityName, this.contextUser);
|
|
314
316
|
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;
|
|
317
|
+
throw new Error(`Failed to create entity object for ${entityName}`);
|
|
332
318
|
}
|
|
333
319
|
// Check if record exists
|
|
334
|
-
const 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
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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: ${
|
|
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'
|
|
342
|
+
return { status: 'error' };
|
|
353
343
|
}
|
|
354
|
-
else
|
|
355
|
-
|
|
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();
|
|
353
|
+
entity.NewRecord();
|
|
371
354
|
isNew = true;
|
|
372
|
-
//
|
|
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
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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 &&
|
|
388
|
+
if (!isDirty && !isNew && record.sync) {
|
|
393
389
|
const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(originalFields, entityDir);
|
|
394
|
-
if (currentChecksum !==
|
|
390
|
+
if (currentChecksum !== record.sync.checksum) {
|
|
395
391
|
isDirty = true;
|
|
396
392
|
if (options.verbose) {
|
|
397
|
-
callbacks?.onLog?.(`📄 File content changed for ${
|
|
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(
|
|
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 ${
|
|
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
|
-
//
|
|
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 ${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
762
|
-
//
|
|
763
|
-
|
|
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
|
-
|
|
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 = [];
|