@memberjunction/metadata-sync 5.30.1 → 5.32.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.
Files changed (51) hide show
  1. package/README.md +47 -0
  2. package/dist/config.d.ts +3 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +6 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/RecordProcessor.d.ts +7 -1
  10. package/dist/lib/RecordProcessor.d.ts.map +1 -1
  11. package/dist/lib/RecordProcessor.js +21 -7
  12. package/dist/lib/RecordProcessor.js.map +1 -1
  13. package/dist/lib/RelatedEntityHandler.d.ts +10 -0
  14. package/dist/lib/RelatedEntityHandler.d.ts.map +1 -1
  15. package/dist/lib/RelatedEntityHandler.js +69 -0
  16. package/dist/lib/RelatedEntityHandler.js.map +1 -1
  17. package/dist/lib/batch-context-index.d.ts +107 -0
  18. package/dist/lib/batch-context-index.d.ts.map +1 -0
  19. package/dist/lib/batch-context-index.js +203 -0
  20. package/dist/lib/batch-context-index.js.map +1 -0
  21. package/dist/lib/provider-utils.d.ts +1 -2
  22. package/dist/lib/provider-utils.d.ts.map +1 -1
  23. package/dist/lib/provider-utils.js +96 -29
  24. package/dist/lib/provider-utils.js.map +1 -1
  25. package/dist/lib/record-dependency-analyzer.js +1 -1
  26. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  27. package/dist/lib/sync-engine.d.ts +5 -2
  28. package/dist/lib/sync-engine.d.ts.map +1 -1
  29. package/dist/lib/sync-engine.js +31 -21
  30. package/dist/lib/sync-engine.js.map +1 -1
  31. package/dist/lib/sync-state-manager.d.ts +37 -0
  32. package/dist/lib/sync-state-manager.d.ts.map +1 -0
  33. package/dist/lib/sync-state-manager.js +83 -0
  34. package/dist/lib/sync-state-manager.js.map +1 -0
  35. package/dist/lib/transaction-manager.d.ts +18 -4
  36. package/dist/lib/transaction-manager.d.ts.map +1 -1
  37. package/dist/lib/transaction-manager.js +18 -5
  38. package/dist/lib/transaction-manager.js.map +1 -1
  39. package/dist/services/PullService.d.ts +19 -1
  40. package/dist/services/PullService.d.ts.map +1 -1
  41. package/dist/services/PullService.js +158 -9
  42. package/dist/services/PullService.js.map +1 -1
  43. package/dist/services/PushService.d.ts +6 -1
  44. package/dist/services/PushService.d.ts.map +1 -1
  45. package/dist/services/PushService.js +151 -30
  46. package/dist/services/PushService.js.map +1 -1
  47. package/dist/services/ValidationService.js +1 -1
  48. package/dist/services/ValidationService.js.map +1 -1
  49. package/dist/services/WatchService.js +1 -1
  50. package/dist/services/WatchService.js.map +1 -1
  51. package/package.json +12 -10
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import fastGlob from 'fast-glob';
4
4
  import { Metadata } from '@memberjunction/core';
5
5
  import { DeferrableLookupError } from '../lib/sync-engine.js';
6
+ import { BatchContextIndex } from '../lib/batch-context-index.js';
6
7
  import { loadEntityConfig, loadSyncConfig } from '../config.js';
7
8
  import { FileBackupManager } from '../lib/file-backup-manager.js';
8
9
  import { configManager } from '../lib/config-manager.js';
@@ -14,16 +15,27 @@ import { JsonPreprocessor } from '../lib/json-preprocessor.js';
14
15
  import { findEntityDirectories } from '../lib/provider-utils.js';
15
16
  import { DeletionAuditor } from '../lib/deletion-auditor.js';
16
17
  import { DeletionReportGenerator } from '../lib/deletion-report-generator.js';
17
- // Configuration for parallel processing
18
- const PARALLEL_BATCH_SIZE = 1; // Number of records to process in parallel at each dependency level
18
+ // Configuration for parallel processing.
19
+ // The side-effect-as-data pattern (processFlattenedRecord returns mutations instead of
20
+ // mutating shared state) makes parallel execution safe from a sync-engine perspective.
21
+ // However, entity Save() overrides (e.g., MJActionEntityServer, MJAIPromptEntityServer)
22
+ // may start transactions, do check-then-create patterns, or interact with shared singletons
23
+ // that assume sequential execution. Default stays at 1 for safety; users can opt in to
24
+ // higher values via --parallel-batch-size after verifying their entity subclasses are safe.
25
+ const PARALLEL_BATCH_SIZE = 1;
19
26
  export class PushService {
20
- constructor(syncEngine, contextUser) {
27
+ constructor(syncEngine, contextUser, stateManager) {
21
28
  this.warnings = [];
22
29
  this.syncConfig = null;
23
30
  this.deferredFileWrites = new Map();
24
31
  this.deferredRecords = [];
25
32
  this.syncEngine = syncEngine;
26
33
  this.contextUser = contextUser;
34
+ this.stateManager = stateManager;
35
+ }
36
+ /** Set or replace the state manager after construction. */
37
+ setStateManager(stateManager) {
38
+ this.stateManager = stateManager;
27
39
  }
28
40
  /**
29
41
  * Determines whether to emit __mj_sync_notes based on config hierarchy.
@@ -77,7 +89,7 @@ export class PushService {
77
89
  try {
78
90
  // Initialize SQL logger if enabled and not dry-run
79
91
  if (sqlLogger.enabled && !options.dryRun) {
80
- const provider = Metadata.Provider;
92
+ const provider = Metadata.Provider; // global-provider-ok: metadata sync operates on the configured provider only
81
93
  if (options.verbose) {
82
94
  callbacks?.onLog?.(`SQL logging enabled: ${sqlLogger.enabled}`);
83
95
  callbacks?.onLog?.(`Provider type: ${provider?.constructor?.name || 'Unknown'}`);
@@ -213,7 +225,7 @@ export class PushService {
213
225
  if (options.verbose && callbacks?.onLog) {
214
226
  callbacks.onLog(`Processing ${entityConfig.entity} in ${entityDir}`);
215
227
  }
216
- const result = await this.processEntityDirectory(entityDir, entityConfig, options, fileBackupManager, callbacks);
228
+ const result = await this.processEntityDirectory(entityDir, entityConfig, options, fileBackupManager, callbacks, configDir);
217
229
  // Stop the spinner if we were using onProgress
218
230
  if (callbacks?.onProgress && callbacks?.onSuccess) {
219
231
  callbacks.onSuccess(`Processed ${dirName}`);
@@ -275,7 +287,7 @@ export class PushService {
275
287
  }
276
288
  }
277
289
  catch (error) {
278
- // Rollback transaction on error
290
+ // Rollback transaction on error.
279
291
  if (!options.dryRun) {
280
292
  callbacks?.onLog?.('\n⚠️ Rolling back database transaction due to error...');
281
293
  await transactionManager.rollbackTransaction();
@@ -334,7 +346,7 @@ export class PushService {
334
346
  throw error;
335
347
  }
336
348
  }
337
- async processEntityDirectory(entityDir, entityConfig, options, fileBackupManager, callbacks) {
349
+ async processEntityDirectory(entityDir, entityConfig, options, fileBackupManager, callbacks, syncRootDir) {
338
350
  let created = 0;
339
351
  let updated = 0;
340
352
  let unchanged = 0;
@@ -366,16 +378,30 @@ export class PushService {
366
378
  // Keep unprocessed data to write back (preserves @file: references)
367
379
  const unprocessedRecords = Array.isArray(rawFileData) ? rawFileData : [rawFileData];
368
380
  const isArray = Array.isArray(rawFileData);
369
- // Only preprocess if there are @include directives
381
+ // Preprocess @include directives before checksumming, so changes to
382
+ // included files are detected by the incremental skip check.
370
383
  let fileData = rawFileData;
371
384
  const jsonString = JSON.stringify(rawFileData);
372
385
  const hasIncludes = jsonString.includes('"@include"') || jsonString.includes('"@include.');
373
386
  if (hasIncludes) {
374
- // Preprocess the JSON file to handle @include directives
375
- // Create a new preprocessor instance for each file to ensure clean state
376
387
  const jsonPreprocessor = new JsonPreprocessor();
377
388
  fileData = await jsonPreprocessor.processFile(filePath);
378
389
  }
390
+ // Compute checksum on the RESOLVED content (after @include preprocessing)
391
+ // so that changes to included files are properly detected.
392
+ let cachedChecksum;
393
+ if (options.incremental && this.stateManager && syncRootDir) {
394
+ const relativePath = path.relative(syncRootDir, filePath);
395
+ cachedChecksum = this.syncEngine.calculateChecksum(fileData);
396
+ if (!this.stateManager.hasFileChanged(relativePath, cachedChecksum)) {
397
+ const recordCount = Array.isArray(rawFileData) ? rawFileData.length : 1;
398
+ if (options.verbose) {
399
+ callbacks?.onLog?.(` Skipping unchanged file: ${path.basename(filePath)}`);
400
+ }
401
+ unchanged += recordCount;
402
+ continue;
403
+ }
404
+ }
379
405
  const records = Array.isArray(fileData) ? fileData : [fileData];
380
406
  // Analyze dependencies and get sorted records
381
407
  const analyzer = new RecordDependencyAnalyzer();
@@ -393,7 +419,7 @@ export class PushService {
393
419
  // Note: While JavaScript is single-threaded, async operations can interleave.
394
420
  // Map operations themselves are atomic, but we ensure records are added to
395
421
  // the context AFTER successful save to maintain consistency.
396
- const batchContext = new Map();
422
+ const batchContext = new BatchContextIndex();
397
423
  // Process records using dependency levels for parallel processing
398
424
  if (analysisResult.dependencyLevels && analysisResult.dependencyLevels.length > 0) {
399
425
  // Use parallel processing with dependency levels
@@ -417,7 +443,8 @@ export class PushService {
417
443
  return { success: false, error, record: flattenedRecord };
418
444
  }
419
445
  }));
420
- // Process results and check for errors
446
+ // Apply side effects sequentially after Promise.all() resolves.
447
+ // This eliminates race conditions from concurrent writes to shared state.
421
448
  for (const batchResult of batchResults) {
422
449
  if (!batchResult.success) {
423
450
  // Fail fast on first error with detailed logging
@@ -429,8 +456,18 @@ export class PushService {
429
456
  // Throw concise error to trigger rollback
430
457
  throw err;
431
458
  }
432
- // Update stats for successful results
433
459
  const result = batchResult.result;
460
+ // Apply side effects from the result
461
+ if (result.batchContextEntry) {
462
+ batchContext.set(result.batchContextEntry.key, result.batchContextEntry.entity);
463
+ }
464
+ if (result.deferredRecord) {
465
+ this.deferredRecords.push(result.deferredRecord);
466
+ }
467
+ if (result.warnings) {
468
+ this.warnings.push(...result.warnings);
469
+ }
470
+ // Update stats for successful results
434
471
  // Don't count deletion records - they're counted in Phase 2
435
472
  if (result.isDeletedRecord) {
436
473
  continue; // Skip entirely
@@ -465,6 +502,16 @@ export class PushService {
465
502
  for (const flattenedRecord of analysisResult.sortedRecords) {
466
503
  try {
467
504
  const result = await this.processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig);
505
+ // Apply side effects (already sequential, but consistent with parallel path)
506
+ if (result.batchContextEntry) {
507
+ batchContext.set(result.batchContextEntry.key, result.batchContextEntry.entity);
508
+ }
509
+ if (result.deferredRecord) {
510
+ this.deferredRecords.push(result.deferredRecord);
511
+ }
512
+ if (result.warnings) {
513
+ this.warnings.push(...result.warnings);
514
+ }
468
515
  // Update stats
469
516
  // Don't count deletion records - they're counted in Phase 2
470
517
  if (!result.isDeletedRecord) {
@@ -522,17 +569,34 @@ export class PushService {
522
569
  }
523
570
  }
524
571
  }
572
+ // Update stored checksum after successful processing (reuse cached value if available)
573
+ // Uses resolved content (after @include) so included-file changes are tracked.
574
+ if (this.stateManager && syncRootDir) {
575
+ const relativePath = path.relative(syncRootDir, filePath);
576
+ const checksum = cachedChecksum ?? this.syncEngine.calculateChecksum(fileData);
577
+ this.stateManager.setFileChecksum(relativePath, checksum);
578
+ }
525
579
  }
526
580
  catch (fileError) {
527
581
  // Error details already logged by lower-level handlers, just re-throw
528
582
  throw fileError;
529
583
  }
530
584
  }
585
+ // Persist push timestamp for this entity directory after all files processed
586
+ if (this.stateManager && syncRootDir) {
587
+ const relativeEntityDir = path.relative(syncRootDir, entityDir);
588
+ this.stateManager.setLastPushTimestamp(relativeEntityDir, new Date().toISOString());
589
+ await this.stateManager.pruneStaleChecksums(syncRootDir);
590
+ await this.stateManager.save();
591
+ }
531
592
  return { created, updated, unchanged, deleted, skipped, deferred, errors };
532
593
  }
533
594
  async processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig, allowDefer = true) {
534
- const metadata = new Metadata();
595
+ const metadata = new Metadata(); // global-provider-ok: metadata sync operates on the configured provider only
535
596
  const { record, entityName, parentContext, id: recordId } = flattenedRecord;
597
+ // Accumulate warnings locally instead of mutating this.warnings directly.
598
+ // These are returned in the result and applied sequentially after Promise.all().
599
+ const localWarnings = [];
536
600
  // Skip deletion records - they're handled in Phase 2
537
601
  // File writing is deferred for files containing deletions
538
602
  // Mark with special flag so they don't count in Phase 1 stats at all
@@ -543,11 +607,43 @@ export class PushService {
543
607
  // This ensures we can properly find parent entities even when they're new
544
608
  const lookupKey = recordId;
545
609
  // Check if already in batch context
546
- let entity = batchContext.get(lookupKey);
547
- if (entity) {
610
+ const existingEntry = batchContext.get(lookupKey);
611
+ if (existingEntry) {
548
612
  // Already processed
549
613
  return { status: 'unchanged', isDuplicate: true };
550
614
  }
615
+ let entity;
616
+ // Record-level skip (--incremental only): if the record has a stored
617
+ // checksum and it matches the current fields, nothing changed — skip
618
+ // without touching the DB. The checksum is embedded in the JSON
619
+ // (record.sync.checksum) and was stored after the last successful save,
620
+ // so a match means fields are identical. We still add a lightweight stub
621
+ // to the batch context so child records at later dependency levels can
622
+ // resolve @parent:ID references.
623
+ //
624
+ // Gated on options.incremental so non-incremental pushes still load the
625
+ // entity and reassert JSON values over the DB. This preserves the implicit
626
+ // drift-correction behavior single-env workflows rely on, and prevents
627
+ // silent no-ops when seeding a fresh DB from JSON files that already
628
+ // carry stored checksums from another environment.
629
+ if (options.incremental && record.sync?.checksum && record.fields && record.primaryKey && !record.deleteRecord) {
630
+ // Use calculateChecksumWithFileContent so @file: reference changes are detected.
631
+ // This reads local files (fast) rather than hitting the DB (slow). The stored
632
+ // checksum was also computed with file content, so the comparison is apples-to-apples.
633
+ const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(record.fields, entityDir);
634
+ if (currentChecksum === record.sync.checksum) {
635
+ // Lightweight stub — just enough for @parent:ID and @lookup resolution.
636
+ // No DB call, no entity framework overhead.
637
+ const allFields = { ...record.primaryKey, ...record.fields };
638
+ const stub = {
639
+ EntityInfo: { Name: entityName, PrimaryKeys: Object.keys(record.primaryKey).map(k => ({ Name: k })), Fields: Object.keys(allFields).map(k => ({ Name: k })) },
640
+ Get: (field) => allFields[field],
641
+ GetAll: () => allFields,
642
+ };
643
+ const batchContextEntry = { key: lookupKey, entity: stub };
644
+ return { status: 'unchanged', isDuplicate: false, batchContextEntry };
645
+ }
646
+ }
551
647
  // Get or create entity instance
552
648
  entity = await metadata.GetEntityObject(entityName, this.contextUser);
553
649
  if (!entity) {
@@ -604,9 +700,9 @@ export class PushService {
604
700
  .join(', ');
605
701
  if (!autoCreate) {
606
702
  const warning = `Record not found: ${entityName} with primaryKey {${pkDisplay}}. To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`;
607
- this.warnings.push(warning);
703
+ localWarnings.push(warning);
608
704
  callbacks?.onWarn?.(warning);
609
- return { status: 'error', isDuplicate: false }; // This will be counted as error, not skipped
705
+ return { status: 'error', isDuplicate: false, warnings: localWarnings }; // This will be counted as error, not skipped
610
706
  }
611
707
  else {
612
708
  // Log that we're creating the missing record
@@ -738,13 +834,17 @@ export class PushService {
738
834
  }
739
835
  }
740
836
  if (options.dryRun) {
837
+ // Still add to batch context so child records at later dependency levels
838
+ // can resolve @parent references. Without this, dry-run fails on any
839
+ // metadata with parent-child nesting (e.g. Actions → Action Params).
840
+ const batchContextEntry = { key: lookupKey, entity };
741
841
  if (exists) {
742
842
  callbacks?.onLog?.(`[DRY RUN] Would update ${entityName} record`);
743
- return { status: 'updated' };
843
+ return { status: 'updated', batchContextEntry };
744
844
  }
745
845
  else {
746
846
  callbacks?.onLog?.(`[DRY RUN] Would create ${entityName} record`);
747
- return { status: 'created' };
847
+ return { status: 'created', batchContextEntry };
748
848
  }
749
849
  }
750
850
  // If updating an existing record that's dirty, show what changed
@@ -778,6 +878,9 @@ export class PushService {
778
878
  const entityRecordId = entity.Get('ID');
779
879
  let saveResult;
780
880
  try {
881
+ // Skip embedding generation during sync — vectors can be computed later by the
882
+ // API server. This avoids loading the ~50MB Xenova model in short-lived CLI processes.
883
+ entity.SkipEmbeddings = true;
781
884
  // Pass IgnoreDirtyState option when alwaysPush is enabled
782
885
  const saveOptions = alwaysPush ? { IgnoreDirtyState: true } : undefined;
783
886
  saveResult = await entity.Save(saveOptions);
@@ -898,18 +1001,21 @@ export class PushService {
898
1001
  // Throw error to trigger rollback and stop processing
899
1002
  throw new Error(`Failed to save ${entityName} record at ${flattenedRecord.path}: ${errorMessage}`);
900
1003
  }
901
- // Add to batch context AFTER save so it has an ID for child @parent:ID references
902
- // Use the recordId (lookupKey) as the key so child records can find this parent
903
- batchContext.set(lookupKey, entity);
904
- // If we had deferred lookup errors, queue the entire record for re-processing
1004
+ // Return batch context entry as a side effect instead of mutating directly.
1005
+ // The caller applies this sequentially after Promise.all() resolves, avoiding
1006
+ // race conditions where concurrent tasks read stale batchContext state.
1007
+ const batchContextEntry = { key: lookupKey, entity };
1008
+ // If we had deferred lookup errors, return the deferred record as a side effect
1009
+ // instead of pushing to this.deferredRecords directly (avoids concurrent pushes).
905
1010
  // The record has been saved (without the deferred fields), so it exists in the DB.
906
1011
  // In Phase 2.5, we'll re-run processFlattenedRecord with allowDefer=false to fill in the gaps.
1012
+ let deferredRecord;
907
1013
  if (hasDeferrableLookupError && allowDefer && entityConfig) {
908
- this.deferredRecords.push({
1014
+ deferredRecord = {
909
1015
  flattenedRecord,
910
1016
  entityDir,
911
1017
  entityConfig
912
- });
1018
+ };
913
1019
  if (options.verbose) {
914
1020
  callbacks?.onLog?.(` 📋 Queued ${entityName} for deferred processing (record saved, some fields pending)`);
915
1021
  }
@@ -963,18 +1069,25 @@ export class PushService {
963
1069
  // emitSyncNotes is disabled - always remove existing notes
964
1070
  delete recordWithNotes.__mj_sync_notes;
965
1071
  }
1072
+ // Build result with side effects as data
1073
+ const resultBase = {
1074
+ isDuplicate: false,
1075
+ batchContextEntry,
1076
+ deferredRecord,
1077
+ warnings: localWarnings.length > 0 ? localWarnings : undefined,
1078
+ };
966
1079
  // Return appropriate status
967
1080
  // If we had deferred lookups, return 'deferred' to indicate partial save
968
1081
  // The record is saved but will be re-processed in Phase 2.5
969
1082
  if (hasDeferrableLookupError && allowDefer) {
970
1083
  return {
1084
+ ...resultBase,
971
1085
  status: 'deferred',
972
- isDuplicate: false
973
1086
  };
974
1087
  }
975
1088
  return {
1089
+ ...resultBase,
976
1090
  status: isNew ? 'created' : (isDirty ? 'updated' : 'unchanged'),
977
- isDuplicate: false
978
1091
  };
979
1092
  }
980
1093
  formatFieldValue(value) {
@@ -1266,7 +1379,7 @@ export class PushService {
1266
1379
  callbacks?.onWarn?.(`⚠️ Detected ${analysisResult.circularDependencies.length} circular dependencies across metadata files`);
1267
1380
  }
1268
1381
  // Perform comprehensive deletion audit
1269
- const md = new Metadata();
1382
+ const md = new Metadata(); // global-provider-ok: metadata sync operates on the configured provider only
1270
1383
  const auditor = new DeletionAuditor(md, this.contextUser);
1271
1384
  const audit = await auditor.auditDeletions(allRecords, options.deleteDbOnly ?? false);
1272
1385
  // Check if any records actually need deletion
@@ -1413,7 +1526,7 @@ export class PushService {
1413
1526
  let errors = 0;
1414
1527
  // Create a fresh batch context for deferred processing
1415
1528
  // Records are in DB now, so this is mainly for tracking within this phase
1416
- const batchContext = new Map();
1529
+ const batchContext = new BatchContextIndex();
1417
1530
  for (const deferred of this.deferredRecords) {
1418
1531
  const { flattenedRecord, entityDir, entityConfig } = deferred;
1419
1532
  const entityName = flattenedRecord.entityName;
@@ -1425,6 +1538,14 @@ export class PushService {
1425
1538
  // This ensures we use the exact same processing logic
1426
1539
  const result = await this.processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig, false // allowDefer=false - must succeed or fail, no re-deferring
1427
1540
  );
1541
+ // Apply side effects (sequential here, but consistent with parallel path)
1542
+ if (result.batchContextEntry) {
1543
+ batchContext.set(result.batchContextEntry.key, result.batchContextEntry.entity);
1544
+ }
1545
+ if (result.warnings) {
1546
+ this.warnings.push(...result.warnings);
1547
+ }
1548
+ // Note: result.deferredRecord should never be set here since allowDefer=false
1428
1549
  if (result.status === 'created') {
1429
1550
  created++;
1430
1551
  callbacks?.onLog?.(` ✓ ${entityName} (${recordId}) - created`);