@memberjunction/metadata-sync 6.1.0-edge.4 → 6.1.0-edge.6

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 (75) hide show
  1. package/README.md +48 -3
  2. package/dist/config.d.ts +32 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/constants/metadata-keywords.d.ts +13 -2
  6. package/dist/constants/metadata-keywords.d.ts.map +1 -1
  7. package/dist/constants/metadata-keywords.js +12 -0
  8. package/dist/constants/metadata-keywords.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/RecordProcessor.d.ts +12 -0
  14. package/dist/lib/RecordProcessor.d.ts.map +1 -1
  15. package/dist/lib/RecordProcessor.js +163 -10
  16. package/dist/lib/RecordProcessor.js.map +1 -1
  17. package/dist/lib/batch-context-index.js +1 -1
  18. package/dist/lib/batch-context-index.js.map +1 -1
  19. package/dist/lib/collection-resolver.d.ts +24 -0
  20. package/dist/lib/collection-resolver.d.ts.map +1 -0
  21. package/dist/lib/collection-resolver.js +117 -0
  22. package/dist/lib/collection-resolver.js.map +1 -0
  23. package/dist/lib/database-reference-scanner.js +5 -5
  24. package/dist/lib/database-reference-scanner.js.map +1 -1
  25. package/dist/lib/deletion-auditor.d.ts.map +1 -1
  26. package/dist/lib/deletion-auditor.js +1 -0
  27. package/dist/lib/deletion-auditor.js.map +1 -1
  28. package/dist/lib/entity-subclass-guard.d.ts +10 -0
  29. package/dist/lib/entity-subclass-guard.d.ts.map +1 -0
  30. package/dist/lib/entity-subclass-guard.js +38 -0
  31. package/dist/lib/entity-subclass-guard.js.map +1 -0
  32. package/dist/lib/graph-provider-pool.d.ts +67 -0
  33. package/dist/lib/graph-provider-pool.d.ts.map +1 -0
  34. package/dist/lib/graph-provider-pool.js +149 -0
  35. package/dist/lib/graph-provider-pool.js.map +1 -0
  36. package/dist/lib/json-write-helper.d.ts +12 -8
  37. package/dist/lib/json-write-helper.d.ts.map +1 -1
  38. package/dist/lib/json-write-helper.js +54 -24
  39. package/dist/lib/json-write-helper.js.map +1 -1
  40. package/dist/lib/record-dependency-analyzer.d.ts +8 -0
  41. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  42. package/dist/lib/record-dependency-analyzer.js +20 -5
  43. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  44. package/dist/lib/reference-parser.d.ts.map +1 -1
  45. package/dist/lib/reference-parser.js +1 -0
  46. package/dist/lib/reference-parser.js.map +1 -1
  47. package/dist/lib/sync-engine.d.ts +27 -4
  48. package/dist/lib/sync-engine.d.ts.map +1 -1
  49. package/dist/lib/sync-engine.js +94 -45
  50. package/dist/lib/sync-engine.js.map +1 -1
  51. package/dist/lib/sync-metadata-engine.d.ts +44 -1
  52. package/dist/lib/sync-metadata-engine.d.ts.map +1 -1
  53. package/dist/lib/sync-metadata-engine.js +152 -4
  54. package/dist/lib/sync-metadata-engine.js.map +1 -1
  55. package/dist/lib/transaction-manager.d.ts +2 -1
  56. package/dist/lib/transaction-manager.d.ts.map +1 -1
  57. package/dist/lib/transaction-manager.js +4 -1
  58. package/dist/lib/transaction-manager.js.map +1 -1
  59. package/dist/plugins/index.js +3 -3
  60. package/dist/plugins/index.js.map +1 -1
  61. package/dist/services/FormattingService.d.ts +8 -0
  62. package/dist/services/FormattingService.d.ts.map +1 -1
  63. package/dist/services/FormattingService.js +18 -0
  64. package/dist/services/FormattingService.js.map +1 -1
  65. package/dist/services/PullService.js +1 -1
  66. package/dist/services/PullService.js.map +1 -1
  67. package/dist/services/PushService.d.ts +53 -3
  68. package/dist/services/PushService.d.ts.map +1 -1
  69. package/dist/services/PushService.js +455 -143
  70. package/dist/services/PushService.js.map +1 -1
  71. package/dist/services/ValidationService.d.ts +26 -0
  72. package/dist/services/ValidationService.d.ts.map +1 -1
  73. package/dist/services/ValidationService.js +454 -14
  74. package/dist/services/ValidationService.js.map +1 -1
  75. package/package.json +14 -14
@@ -2,7 +2,7 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import fastGlob from 'fast-glob';
4
4
  import chalk from 'chalk';
5
- import { Metadata, IsVerboseLoggingEnabled } from '@memberjunction/core';
5
+ import { Metadata, EntitySaveOptions, IsVerboseLoggingEnabled } from '@memberjunction/core';
6
6
  import { UUIDsEqual } from '@memberjunction/global';
7
7
  import { IsStringSQLType } from '@memberjunction/sql-dialect';
8
8
  import { DeferrableLookupError } from '../lib/sync-engine.js';
@@ -14,19 +14,19 @@ import { configManager } from '../lib/config-manager.js';
14
14
  import { SQLLogger } from '../lib/sql-logger.js';
15
15
  import { TransactionManager } from '../lib/transaction-manager.js';
16
16
  import { JsonWriteHelper } from '../lib/json-write-helper.js';
17
- import { RecordDependencyAnalyzer } from '../lib/record-dependency-analyzer.js';
17
+ import { RecordDependencyAnalyzer, groupRecordsByGraphId } from '../lib/record-dependency-analyzer.js';
18
+ import { GraphProviderPool } from '../lib/graph-provider-pool.js';
18
19
  import { JsonPreprocessor } from '../lib/json-preprocessor.js';
19
20
  import { findEntityDirectories } from '../lib/provider-utils.js';
20
21
  import { DeletionAuditor } from '../lib/deletion-auditor.js';
22
+ import { describeMissingEntitySubclass } from '../lib/entity-subclass-guard.js';
21
23
  import { DeletionReportGenerator } from '../lib/deletion-report-generator.js';
22
- // Configuration for parallel processing.
23
- // The side-effect-as-data pattern (processFlattenedRecord returns mutations instead of
24
- // mutating shared state) makes parallel execution safe from a sync-engine perspective.
25
- // However, entity Save() overrides (e.g., MJActionEntityServer, MJAIPromptEntityServer)
26
- // may start transactions, do check-then-create patterns, or interact with shared singletons
27
- // that assume sequential execution. Default stays at 1 for safety; users can opt in to
28
- // higher values via --parallel-batch-size after verifying their entity subclasses are safe.
29
- const PARALLEL_BATCH_SIZE = 1;
24
+ import { resolveCollectionRelationship } from '../lib/collection-resolver.js';
25
+ // Parallelism is across JSON-root graphs (independent Actions), not flattened rows.
26
+ // Nested relatedEntities share the root's provider so parent+child stay on one TX.
27
+ // Default 10 — never 1. 1 was a wrong workaround for mixed-provider hangs;
28
+ // callers can pass --parallel-batch-size 1 for debugging.
29
+ const PARALLEL_BATCH_SIZE = 10;
30
30
  export class PushService {
31
31
  constructor(syncEngine, contextUser, stateManager) {
32
32
  this.warnings = [];
@@ -34,6 +34,7 @@ export class PushService {
34
34
  this.syncConfig = null;
35
35
  this.deferredFileWrites = new Map();
36
36
  this.deferredRecords = [];
37
+ this.confirmedCollections = new Set();
37
38
  this.syncEngine = syncEngine;
38
39
  this.contextUser = contextUser;
39
40
  this.stateManager = stateManager;
@@ -118,6 +119,12 @@ export class PushService {
118
119
  async push(options, callbacks) {
119
120
  this.warnings = [];
120
121
  this.changeDetails = [];
122
+ // Warnings the engine raises while resolving lookups belong in this push's result envelope,
123
+ // not on the console — same channel as the warnings this service raises itself.
124
+ this.syncEngine.WarningSink = (message) => {
125
+ this.warnings.push(message);
126
+ callbacks?.onWarn?.(`⚠️ ${message}`);
127
+ };
121
128
  // Respect the global MJ_VERBOSE env/flag in addition to the per-command --verbose
122
129
  // flag, so a single dial (MJ_VERBOSE=1) controls diagnostic verbosity across every
123
130
  // MJ CLI tool (codegen, sync, …) rather than each command inventing its own.
@@ -292,7 +299,9 @@ export class PushService {
292
299
  };
293
300
  }
294
301
  }
295
- // Begin transaction if not in dry-run mode
302
+ // Host TX wraps Phase 2 deletions and Phase 2.5 deferred records.
303
+ // Phase 1 graph writes go to independent instances (or, if those are
304
+ // unavailable, ALL graphs share this host TX — never a mix).
296
305
  if (!options.dryRun) {
297
306
  await transactionManager.beginTransaction();
298
307
  }
@@ -400,8 +409,13 @@ export class PushService {
400
409
  // Rollback transaction on error.
401
410
  if (!options.dryRun) {
402
411
  callbacks?.onLog?.('\n⚠️ Rolling back database transaction due to error...');
403
- await transactionManager.rollbackTransaction();
404
- callbacks?.onLog?.('✓ Database transaction rolled back successfully\n');
412
+ const rolledBack = await transactionManager.rollbackTransaction();
413
+ if (rolledBack) {
414
+ callbacks?.onLog?.('✓ Database transaction rolled back successfully\n');
415
+ }
416
+ else {
417
+ callbacks?.onLog?.('❌ Database transaction rollback failed\n');
418
+ }
405
419
  }
406
420
  throw error;
407
421
  }
@@ -472,6 +486,13 @@ export class PushService {
472
486
  let skipped = 0;
473
487
  let deferred = 0;
474
488
  let errors = 0;
489
+ // Issue #4199: a push against an entity whose subclass is not loaded in this process
490
+ // "succeeds" with a generic BaseEntity and silently skips the entity's custom logic. Say so.
491
+ const subclassWarning = describeMissingEntitySubclass(String(entityConfig.entity ?? ''), { dryRun: options.dryRun });
492
+ if (subclassWarning) {
493
+ this.warnings.push(subclassWarning);
494
+ callbacks?.onWarn?.(`⚠️ ${subclassWarning}`);
495
+ }
475
496
  // Find all JSON files in the directory
476
497
  const pattern = entityConfig.filePattern || '*.json';
477
498
  const files = await fastGlob(pattern, {
@@ -547,131 +568,116 @@ export class PushService {
547
568
  // Map operations themselves are atomic, but we ensure records are added to
548
569
  // the context AFTER successful save to maintain consistency.
549
570
  const batchContext = new BatchContextIndex();
550
- // Process records using dependency levels for parallel processing
551
- if (analysisResult.dependencyLevels && analysisResult.dependencyLevels.length > 0) {
552
- // Use parallel processing with dependency levels
553
- for (let levelIndex = 0; levelIndex < analysisResult.dependencyLevels.length; levelIndex++) {
554
- const level = analysisResult.dependencyLevels[levelIndex];
555
- if (options.verbose && level.length > 1) {
556
- callbacks?.onLog?.(` Processing dependency level ${levelIndex} with ${level.length} records in parallel...`);
557
- }
558
- // Process records in this level in parallel batches
571
+ // One provider per JSON-root graph (Action + nested Action Params share a
572
+ // connection). Parallelize sibling roots only. Drain a graph when its last
573
+ // level finishes, or when TransactionDepth is already 0 (Save settled).
574
+ // Peak live independent instances is then the current batch plus any
575
+ // leftover-depth graphs still spanning later levels.
576
+ const hostProvider = Metadata.Provider; // global-provider-ok: host provider template for GraphProviderPool cloning
577
+ const graphPool = new GraphProviderPool(hostProvider, (msg) => callbacks?.onLog?.(msg));
578
+ const applyProcessResult = (result) => {
579
+ if (result.batchContextEntry) {
580
+ batchContext.set(result.batchContextEntry.key, result.batchContextEntry.entity);
581
+ }
582
+ if (result.deferredRecord) {
583
+ this.deferredRecords.push(result.deferredRecord);
584
+ }
585
+ if (result.warnings) {
586
+ this.warnings.push(...result.warnings);
587
+ }
588
+ if (result.isDeletedRecord)
589
+ return;
590
+ if (result.isDuplicate) {
591
+ skipped++;
592
+ return;
593
+ }
594
+ if (result.status === 'created')
595
+ created++;
596
+ else if (result.status === 'updated')
597
+ updated++;
598
+ else if (result.status === 'unchanged')
599
+ unchanged++;
600
+ else if (result.status === 'deleted')
601
+ deleted++;
602
+ else if (result.status === 'skipped')
603
+ skipped++;
604
+ else if (result.status === 'error') {
605
+ // A non-throwing record error must not commit leftover graph depth —
606
+ // the previous per-record release rolled that work back.
607
+ errors++;
608
+ graphPool.markFailed();
609
+ }
610
+ else if (result.status === 'deferred') {
611
+ created++;
612
+ deferred++;
613
+ }
614
+ };
615
+ // Fail-fast: the first thrown record error aborts the file. That is
616
+ // intentional and matches the parallel path; it is a change from the
617
+ // old sequential fallback, which continued after onError.
618
+ let runError;
619
+ try {
620
+ const levels = analysisResult.dependencyLevels && analysisResult.dependencyLevels.length > 0
621
+ ? analysisResult.dependencyLevels
622
+ : [analysisResult.sortedRecords];
623
+ graphPool.noteLevels(levels);
624
+ for (let levelIndex = 0; levelIndex < levels.length; levelIndex++) {
625
+ const level = levels[levelIndex];
626
+ const byGraph = groupRecordsByGraphId(level);
627
+ const graphIds = Array.from(byGraph.keys());
559
628
  const batchSize = options.parallelBatchSize || PARALLEL_BATCH_SIZE;
560
- for (let i = 0; i < level.length; i += batchSize) {
561
- const batch = level.slice(i, Math.min(i + batchSize, level.length));
562
- // Process batch in parallel
563
- const batchResults = await Promise.all(batch.map(async (flattenedRecord) => {
564
- try {
565
- const result = await this.processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig);
566
- return { success: true, result };
567
- }
568
- catch (error) {
569
- // Return error instead of throwing to handle after Promise.all
570
- return { success: false, error, record: flattenedRecord };
629
+ if (options.verbose && graphIds.length > 1) {
630
+ callbacks?.onLog?.(` Level ${levelIndex}: ${level.length} records in ${graphIds.length} graphs (parallel batch ${batchSize})`);
631
+ }
632
+ for (let i = 0; i < graphIds.length; i += batchSize) {
633
+ const batchIds = graphIds.slice(i, i + batchSize);
634
+ const batchResults = await Promise.all(batchIds.map(async (graphId) => {
635
+ const recs = byGraph.get(graphId);
636
+ const provider = await graphPool.obtain(graphId);
637
+ const results = [];
638
+ for (const flattenedRecord of recs) {
639
+ try {
640
+ const result = await this.processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig, true, provider);
641
+ results.push({ success: true, result, record: flattenedRecord, graphId });
642
+ }
643
+ catch (error) {
644
+ graphPool.markFailed();
645
+ results.push({ success: false, error, record: flattenedRecord, graphId });
646
+ break;
647
+ }
571
648
  }
649
+ return results;
572
650
  }));
573
- // Apply side effects sequentially after Promise.all() resolves.
574
- // This eliminates race conditions from concurrent writes to shared state.
575
- for (const batchResult of batchResults) {
576
- if (!batchResult.success) {
577
- // Fail fast on first error with detailed logging
578
- const err = batchResult.error;
579
- const rec = batchResult.record;
580
- // Log concise summary - detailed error was already logged by processFlattenedRecord
581
- callbacks?.onLog?.(`\n❌ Processing failed for ${rec.entityName} at ${rec.path}`);
582
- callbacks?.onLog?.(` ${err.message}\n`);
583
- // Throw concise error to trigger rollback
584
- throw err;
585
- }
586
- const result = batchResult.result;
587
- // Apply side effects from the result
588
- if (result.batchContextEntry) {
589
- batchContext.set(result.batchContextEntry.key, result.batchContextEntry.entity);
590
- }
591
- if (result.deferredRecord) {
592
- this.deferredRecords.push(result.deferredRecord);
593
- }
594
- if (result.warnings) {
595
- this.warnings.push(...result.warnings);
596
- }
597
- // Update stats for successful results
598
- // Don't count deletion records - they're counted in Phase 2
599
- if (result.isDeletedRecord) {
600
- continue; // Skip entirely
601
- }
602
- else if (result.isDuplicate) {
603
- skipped++; // Count duplicates as skipped
604
- }
605
- else {
606
- if (result.status === 'created')
607
- created++;
608
- else if (result.status === 'updated')
609
- updated++;
610
- else if (result.status === 'unchanged')
611
- unchanged++;
612
- else if (result.status === 'deleted')
613
- deleted++;
614
- else if (result.status === 'skipped')
615
- skipped++;
616
- else if (result.status === 'error')
617
- errors++;
618
- else if (result.status === 'deferred') {
619
- created++; // Deferred records were saved (count as created)
620
- deferred++; // Also track separately for reporting
651
+ for (const graphResults of batchResults) {
652
+ for (const batchResult of graphResults) {
653
+ if (batchResult.success === false) {
654
+ const err = batchResult.error;
655
+ const rec = batchResult.record;
656
+ callbacks?.onLog?.(`\n❌ Processing failed for ${rec.entityName} at ${rec.path}`);
657
+ callbacks?.onLog?.(` ${err.message}\n`);
658
+ if (err.stack) {
659
+ callbacks?.onLog?.(` Stack: ${err.stack}\n`);
660
+ }
661
+ throw err;
621
662
  }
663
+ applyProcessResult(batchResult.result);
622
664
  }
623
665
  }
666
+ const drainError = await graphPool.drainBatch(batchIds, levelIndex);
667
+ if (drainError)
668
+ throw drainError;
624
669
  }
625
670
  }
626
671
  }
627
- else {
628
- // Fallback to sequential processing if no dependency levels available
629
- for (const flattenedRecord of analysisResult.sortedRecords) {
630
- try {
631
- const result = await this.processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig);
632
- // Apply side effects (already sequential, but consistent with parallel path)
633
- if (result.batchContextEntry) {
634
- batchContext.set(result.batchContextEntry.key, result.batchContextEntry.entity);
635
- }
636
- if (result.deferredRecord) {
637
- this.deferredRecords.push(result.deferredRecord);
638
- }
639
- if (result.warnings) {
640
- this.warnings.push(...result.warnings);
641
- }
642
- // Update stats
643
- // Don't count deletion records - they're counted in Phase 2
644
- if (!result.isDeletedRecord) {
645
- if (result.isDuplicate) {
646
- skipped++; // Count duplicates as skipped
647
- }
648
- else {
649
- if (result.status === 'created')
650
- created++;
651
- else if (result.status === 'updated')
652
- updated++;
653
- else if (result.status === 'unchanged')
654
- unchanged++;
655
- else if (result.status === 'deleted')
656
- deleted++;
657
- else if (result.status === 'skipped')
658
- skipped++;
659
- else if (result.status === 'error')
660
- errors++;
661
- else if (result.status === 'deferred') {
662
- created++; // Deferred records were saved (count as created)
663
- deferred++; // Also track separately for reporting
664
- }
665
- }
666
- }
667
- }
668
- catch (recordError) {
669
- const errorMsg = `Error processing ${flattenedRecord.entityName} record at ${flattenedRecord.path}: ${recordError}`;
670
- callbacks?.onError?.(errorMsg);
671
- errors++;
672
- }
673
- }
672
+ catch (e) {
673
+ graphPool.markFailed();
674
+ runError = e;
674
675
  }
676
+ const settleError = await graphPool.releaseAll();
677
+ if (runError)
678
+ throw runError;
679
+ if (settleError)
680
+ throw settleError;
675
681
  // Check if this file has any deletion records (including nested relatedEntities)
676
682
  const hasDeletions = this.hasAnyDeletions(records);
677
683
  // Write back to file (handles both single records and arrays)
@@ -721,7 +727,7 @@ export class PushService {
721
727
  }
722
728
  return { created, updated, unchanged, deleted, skipped, deferred, errors };
723
729
  }
724
- async processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig, allowDefer = true) {
730
+ async processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig, allowDefer = true, recordProvider) {
725
731
  const metadata = new Metadata(); // global-provider-ok: metadata sync operates on the configured provider only
726
732
  const { record, entityName, parentContext, id: recordId } = flattenedRecord;
727
733
  // Accumulate warnings locally instead of mutating this.warnings directly.
@@ -759,8 +765,9 @@ export class PushService {
759
765
  if (options.incremental && record.sync?.checksum && record.fields && record.primaryKey && !record.deleteRecord) {
760
766
  // Use calculateChecksumWithFileContent so @file: reference changes are detected.
761
767
  // This reads local files (fast) rather than hitting the DB (slow). The stored
762
- // checksum was also computed with file content, so the comparison is apples-to-apples.
763
- const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(record.fields, entityDir);
768
+ // checksum was also computed with file content and composition payloads, so the comparison is apples-to-apples.
769
+ const checksumPayload = this.buildRecordChecksumPayload(record);
770
+ const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(checksumPayload, entityDir);
764
771
  if (currentChecksum === record.sync.checksum) {
765
772
  // Lightweight stub — just enough for @parent:ID and @lookup resolution.
766
773
  // No DB call, no entity framework overhead.
@@ -775,7 +782,9 @@ export class PushService {
775
782
  }
776
783
  }
777
784
  // Get or create entity instance
778
- entity = await metadata.GetEntityObject(entityName, this.contextUser);
785
+ entity = recordProvider
786
+ ? await recordProvider.GetEntityObject(entityName, this.contextUser)
787
+ : await metadata.GetEntityObject(entityName, this.contextUser);
779
788
  if (!entity) {
780
789
  throw new Error(`Failed to create entity object for ${entityName}`);
781
790
  }
@@ -800,7 +809,7 @@ export class PushService {
800
809
  for (const [pkField, pkValue] of Object.entries(record.primaryKey)) {
801
810
  try {
802
811
  resolvedPrimaryKey[pkField] = await this.syncEngine.processFieldValue(pkValue, entityDir, parentEntity, null, // rootRecord
803
- 0, batchContext, resolutionCollector, pkField);
812
+ 0, batchContext, resolutionCollector, pkField, recordProvider);
804
813
  }
805
814
  catch (pkError) {
806
815
  // Check if this is a deferrable lookup error
@@ -816,10 +825,13 @@ export class PushService {
816
825
  if (resolvedPrimaryKey && Object.keys(resolvedPrimaryKey).length > 0) {
817
826
  // First check if the record exists using the sync engine's loadEntity method
818
827
  // This avoids the "Error in BaseEntity.Load" message for missing records
819
- const existingEntity = await this.syncEngine.loadEntity(entityName, resolvedPrimaryKey);
828
+ const existingEntity = await this.syncEngine.loadEntity(entityName, resolvedPrimaryKey, recordProvider);
820
829
  if (existingEntity) {
821
830
  // Record exists, use the loaded entity
822
831
  entity = existingEntity;
832
+ if (recordProvider) {
833
+ entity.BindProvider(recordProvider);
834
+ }
823
835
  exists = true;
824
836
  }
825
837
  else {
@@ -883,7 +895,7 @@ export class PushService {
883
895
  try {
884
896
  const processedValue = await this.syncEngine.processFieldValue(fieldValue, entityDir, parentEntity, null, // rootRecord
885
897
  0, batchContext, // Pass batch context for lookups
886
- resolutionCollector, fieldName);
898
+ resolutionCollector, fieldName, recordProvider);
887
899
  const fieldInfo = entity.GetFieldByName(fieldName);
888
900
  const fieldType = (fieldInfo?.EntityFieldInfo?.Type || '').trim().toLowerCase();
889
901
  const isUuidField = fieldType === 'uniqueidentifier' || fieldType === 'uuid';
@@ -994,6 +1006,8 @@ export class PushService {
994
1006
  // Note: If we had deferred fields, we still continue to save the record
995
1007
  // The deferred fields are not set, but other fields are. We'll queue for
996
1008
  // re-processing after save succeeds.
1009
+ // Apply first-class composition axes (embeds, collections, extension) onto entity before Save (§5)
1010
+ await this.applyCompositionAxes(entity, record, entityName, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider);
997
1011
  // Check if the record is actually dirty before considering it changed
998
1012
  let isDirty = entity.Dirty;
999
1013
  // Force dirty state if alwaysPush is enabled
@@ -1001,9 +1015,10 @@ export class PushService {
1001
1015
  if (alwaysPush && !isNew) {
1002
1016
  isDirty = true;
1003
1017
  }
1004
- // Also check if file content has changed (for @file references)
1018
+ // Also check if file content or composition payload has changed
1005
1019
  if (!isDirty && !isNew && record.sync) {
1006
- const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(originalFields, entityDir);
1020
+ const checksumPayload = this.buildRecordChecksumPayload(record, originalFields);
1021
+ const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(checksumPayload, entityDir);
1007
1022
  if (currentChecksum !== record.sync.checksum) {
1008
1023
  isDirty = true;
1009
1024
  if (options.verbose) {
@@ -1066,6 +1081,16 @@ export class PushService {
1066
1081
  }
1067
1082
  }
1068
1083
  }
1084
+ if (!isNew && !isDirty && !hasDeferrableLookupError) {
1085
+ // Record already exists and nothing changed — skip save to avoid unnecessary DB writes and side-effects
1086
+ const batchContextEntry = { key: lookupKey, entity };
1087
+ return {
1088
+ isDuplicate: false,
1089
+ batchContextEntry,
1090
+ warnings: localWarnings.length > 0 ? localWarnings : undefined,
1091
+ status: 'unchanged',
1092
+ };
1093
+ }
1069
1094
  // Save the record with detailed error logging
1070
1095
  const recordName = entity.Get('Name');
1071
1096
  const entityRecordId = entity.Get('ID');
@@ -1074,8 +1099,11 @@ export class PushService {
1074
1099
  // Skip embedding generation during sync — vectors can be computed later by the
1075
1100
  // API server. This avoids loading the ~50MB Xenova model in short-lived CLI processes.
1076
1101
  entity.SkipEmbeddings = true;
1077
- // Pass IgnoreDirtyState option when alwaysPush is enabled
1078
- const saveOptions = alwaysPush ? { IgnoreDirtyState: true } : undefined;
1102
+ const saveOptions = new EntitySaveOptions();
1103
+ if (alwaysPush)
1104
+ saveOptions.IgnoreDirtyState = true;
1105
+ if (entityConfig?.push?.skipGeoCoding)
1106
+ saveOptions.SkipGeoCoding = true;
1079
1107
  saveResult = await entity.Save(saveOptions);
1080
1108
  }
1081
1109
  catch (saveError) {
@@ -1242,11 +1270,24 @@ export class PushService {
1242
1270
  });
1243
1271
  }
1244
1272
  }
1245
- // Only update sync metadata if the record was actually dirty (changed)
1246
- if (isNew || isDirty) {
1273
+ // Sync metadata handling:
1274
+ // When push.writeSyncMetadata is explicitly false, do not create or update sync metadata blocks.
1275
+ const shouldWriteSync = entityConfig?.push?.writeSyncMetadata !== false;
1276
+ if (!shouldWriteSync) {
1277
+ delete record.sync;
1278
+ }
1279
+ else if (isNew || isDirty) {
1280
+ const checksumPayload = this.buildRecordChecksumPayload(record, originalFields);
1281
+ const checksum = await this.syncEngine.calculateChecksumWithFileContent(checksumPayload, entityDir);
1282
+ const existingChecksum = record.sync?.checksum;
1283
+ const existingTimestamp = record.sync?.lastModified;
1284
+ // Preserve existing lastModified if checksum has not changed (prevents hydration churn)
1285
+ const lastModified = (existingChecksum && existingChecksum === checksum && existingTimestamp)
1286
+ ? existingTimestamp
1287
+ : new Date().toISOString();
1247
1288
  record.sync = {
1248
- lastModified: new Date().toISOString(),
1249
- checksum: await this.syncEngine.calculateChecksumWithFileContent(originalFields, entityDir)
1289
+ lastModified,
1290
+ checksum
1250
1291
  };
1251
1292
  if (options.verbose) {
1252
1293
  callbacks?.onLog?.(` ✓ Updated sync metadata (record was ${isNew ? 'new' : 'changed'})`);
@@ -1516,6 +1557,15 @@ export class PushService {
1516
1557
  if (!entityConfig) {
1517
1558
  continue;
1518
1559
  }
1560
+ // Check if any collection has authoritative mode (§8.1(a))
1561
+ if (entityConfig.collections) {
1562
+ for (const col of Object.values(entityConfig.collections)) {
1563
+ if (col.mode === 'authoritative') {
1564
+ hasAnyDeletions = true;
1565
+ break;
1566
+ }
1567
+ }
1568
+ }
1519
1569
  const pattern = entityConfig.filePattern || '*.json';
1520
1570
  const files = await fastGlob(pattern, {
1521
1571
  cwd: entityDir,
@@ -1920,5 +1970,267 @@ export class PushService {
1920
1970
  }
1921
1971
  return keyParts.join('|');
1922
1972
  }
1973
+ /**
1974
+ * Builds the payload used for calculating record checksums, including composition axes (§5, §6)
1975
+ */
1976
+ buildRecordChecksumPayload(record, fieldsOverride) {
1977
+ const fields = fieldsOverride ?? record.fields;
1978
+ if (!record.collections && !record.embeds && !record.extension) {
1979
+ return fields;
1980
+ }
1981
+ const payload = { fields };
1982
+ if (record.collections)
1983
+ payload.collections = record.collections;
1984
+ if (record.embeds)
1985
+ payload.embeds = record.embeds;
1986
+ if (record.extension)
1987
+ payload.extension = record.extension;
1988
+ return payload;
1989
+ }
1990
+ /**
1991
+ * Applies first-class composition axes onto the entity before Save (§5):
1992
+ * 1. embeds (peer first via {fkField}_EnsureObject() or companion.Ensure())
1993
+ * 2. collections (owner.Collection.Create() or match by PK, apply fields, recursive composition)
1994
+ * 3. extension (owner.EnsureISAChild(name?), set leaf fields, recursive composition)
1995
+ */
1996
+ async applyCompositionAxes(entity, record, entityName, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth = 0) {
1997
+ // Static JSON parsed from disk is an acyclic finite tree, but a defensive depth guard
1998
+ // (MAX_COMPOSITION_DEPTH = 10) prevents runaway recursion from accidental deep nesting or malformed fixtures.
1999
+ const MAX_COMPOSITION_DEPTH = 10;
2000
+ if (depth > MAX_COMPOSITION_DEPTH) {
2001
+ throw new Error(`Composition nesting depth exceeded maximum of ${MAX_COMPOSITION_DEPTH} on '${entityName}'. Check for accidental deep nesting or recursive composition structures.`);
2002
+ }
2003
+ // 1. Embeds (peer first): for each embeds[fkField], {fkField}_EnsureObject(), recurse apply, do not Save the peer yet
2004
+ if (record.embeds && typeof record.embeds === 'object') {
2005
+ for (const [fkField, embedRecord] of Object.entries(record.embeds)) {
2006
+ if (!embedRecord || typeof embedRecord !== 'object')
2007
+ continue;
2008
+ let embeddedEntity = null;
2009
+ const ensureMethodName = `${fkField}_EnsureObject`;
2010
+ const entityRecord = entity;
2011
+ if (typeof entityRecord[ensureMethodName] === 'function') {
2012
+ embeddedEntity = await entityRecord[ensureMethodName]();
2013
+ }
2014
+ else if (typeof entity.GetCompanion === 'function') {
2015
+ const companion = entity.GetCompanion(fkField);
2016
+ if (companion && typeof companion.Ensure === 'function') {
2017
+ embeddedEntity = await companion.Ensure();
2018
+ }
2019
+ else {
2020
+ const altKey = fkField.endsWith('ID') ? fkField.substring(0, fkField.length - 2) : `${fkField}ID`;
2021
+ const altEnsureName = `${altKey}_EnsureObject`;
2022
+ if (typeof entityRecord[altEnsureName] === 'function') {
2023
+ embeddedEntity = await entityRecord[altEnsureName]();
2024
+ }
2025
+ else {
2026
+ const altCompanion = entity.GetCompanion(altKey);
2027
+ if (altCompanion && typeof altCompanion.Ensure === 'function') {
2028
+ embeddedEntity = altCompanion.Ensure();
2029
+ }
2030
+ }
2031
+ }
2032
+ }
2033
+ if (!embeddedEntity) {
2034
+ throw new Error(`Failed to resolve embedded companion for '${fkField}' on entity '${entityName}'. Ensure that DeclareEmbeddedRecord is called for '${fkField}'.`);
2035
+ }
2036
+ // Apply embed fields, passing ownerRecord = entity for @owner: resolution
2037
+ if (embedRecord.fields && typeof embedRecord.fields === 'object') {
2038
+ for (const [fName, fVal] of Object.entries(embedRecord.fields)) {
2039
+ const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
2040
+ );
2041
+ embeddedEntity.Set(fName, processedValue);
2042
+ }
2043
+ }
2044
+ // Recursively apply nested composition on the embedded record
2045
+ await this.applyCompositionAxes(embeddedEntity, embedRecord, embeddedEntity.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2046
+ }
2047
+ }
2048
+ // 2. Collections: owner.Lines.Create() (or load existing by PK into the collection), recurse apply on child entity
2049
+ if (record.collections && typeof record.collections === 'object') {
2050
+ for (const [colName, colItems] of Object.entries(record.collections)) {
2051
+ if (!Array.isArray(colItems)) {
2052
+ throw new Error(`Collection "${colName}" in ${entityName} must be an array of records. ` +
2053
+ `Per-record mode wrappers (e.g. {"mode": "authoritative", "items": [...]}) are forbidden; mode is directory-level only.`);
2054
+ }
2055
+ // Get collection companion
2056
+ let collectionCompanion = typeof entity.GetCompanion === 'function' ? entity.GetCompanion(colName) : undefined;
2057
+ if (!collectionCompanion && typeof entity.GetCompanion === 'function') {
2058
+ // Try case-insensitive lookup across companions
2059
+ const companions = entity.Companions;
2060
+ if (companions) {
2061
+ const found = companions.find((c) => c.Name.toLowerCase() === colName.toLowerCase());
2062
+ if (found) {
2063
+ collectionCompanion = entity.GetCompanion(found.Name);
2064
+ }
2065
+ }
2066
+ }
2067
+ // Also check if property exists on entity directly
2068
+ if (!collectionCompanion) {
2069
+ const propVal = entity[colName];
2070
+ if (propVal && typeof propVal.Create === 'function') {
2071
+ collectionCompanion = propVal;
2072
+ }
2073
+ }
2074
+ // Dynamically register collection companion if entity supports DeclareRelatedRecords
2075
+ if (!collectionCompanion && typeof entity.DeclareRelatedRecords === 'function') {
2076
+ const entityInfo = entity.EntityInfo ?? new Metadata().EntityByName(entityName);
2077
+ const resolved = resolveCollectionRelationship(entityInfo, colName);
2078
+ if (resolved) {
2079
+ const colOpts = {
2080
+ Name: resolved.collectionName,
2081
+ RelatedEntity: resolved.relatedEntity,
2082
+ RelatedEntityJoinField: resolved.joinField,
2083
+ Load: resolved.load,
2084
+ OnRemove: resolved.onRemove,
2085
+ ...(resolved.orderBy ? { OrderBy: resolved.orderBy } : {}),
2086
+ };
2087
+ const declareFn = entity.DeclareRelatedRecords.bind(entity);
2088
+ collectionCompanion = declareFn(colOpts);
2089
+ }
2090
+ }
2091
+ if (!collectionCompanion) {
2092
+ throw new Error(`Collection '${colName}' not found on entity '${entityName}'. Ensure DeclareRelatedRecords is registered for '${colName}'.`);
2093
+ }
2094
+ const col = collectionCompanion;
2095
+ // Rider 4: Load: 'never' fails loud under both modes
2096
+ if (col.LoadMode === 'never') {
2097
+ throw new Error(`RelatedRecordCollection '${colName}' on '${entityName}' is declared Load: 'never'. Collections synced via metadata sync cannot have Load: 'never' because both upsert and authoritative modes require loading existing records.`);
2098
+ }
2099
+ // Load collection if not loaded and entity is not new
2100
+ if (!entity.IsSaved && !col.IsLoaded) {
2101
+ // new record, nothing in DB yet
2102
+ }
2103
+ else if (!col.IsLoaded) {
2104
+ await col.Load();
2105
+ }
2106
+ const loadedItems = col.Items ?? [];
2107
+ const matchedItemSet = new Set();
2108
+ const colConfig = entityConfig?.collections?.[colName];
2109
+ const mode = colConfig?.mode ?? 'upsert';
2110
+ for (const itemData of colItems) {
2111
+ if (!itemData || typeof itemData !== 'object')
2112
+ continue;
2113
+ let targetChild = null;
2114
+ // Match by primaryKey if available
2115
+ if (itemData.primaryKey && Object.keys(itemData.primaryKey).length > 0 && loadedItems.length > 0) {
2116
+ targetChild = loadedItems.find((child) => {
2117
+ for (const [pkK, pkV] of Object.entries(itemData.primaryKey)) {
2118
+ if (String(child.Get(pkK)) !== String(pkV)) {
2119
+ return false;
2120
+ }
2121
+ }
2122
+ return true;
2123
+ }) ?? null;
2124
+ }
2125
+ if (itemData.deleteRecord?.delete === true) {
2126
+ // Explicit delete in both modes (§8.1(a) rider 1)
2127
+ if (targetChild) {
2128
+ col.Remove(targetChild);
2129
+ matchedItemSet.add(targetChild);
2130
+ }
2131
+ continue;
2132
+ }
2133
+ if (!targetChild) {
2134
+ targetChild = await col.Create();
2135
+ if (itemData.primaryKey) {
2136
+ for (const [pkK, pkV] of Object.entries(itemData.primaryKey)) {
2137
+ targetChild.Set(pkK, pkV);
2138
+ }
2139
+ }
2140
+ }
2141
+ else {
2142
+ matchedItemSet.add(targetChild);
2143
+ }
2144
+ // Apply fields with ownerRecord = entity for @owner:Field resolution
2145
+ if (itemData.fields && typeof itemData.fields === 'object') {
2146
+ for (const [fName, fVal] of Object.entries(itemData.fields)) {
2147
+ const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
2148
+ );
2149
+ targetChild.Set(fName, processedValue);
2150
+ }
2151
+ }
2152
+ // Recursively apply nested composition on the collection item
2153
+ await this.applyCompositionAxes(targetChild, itemData, targetChild.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2154
+ }
2155
+ // Handle authoritative mode deletions (§8.1(a))
2156
+ if (mode === 'authoritative' && loadedItems.length > 0) {
2157
+ const unmentionedItems = loadedItems.filter((item) => !matchedItemSet.has(item));
2158
+ if (unmentionedItems.length > 0) {
2159
+ const deletePercent = (unmentionedItems.length / loadedItems.length) * 100;
2160
+ const maxAllowed = colConfig?.maxImpliedDeletePercent ?? 20;
2161
+ // Bulk rail (§8.1(a) rider 3)
2162
+ if (deletePercent > maxAllowed && !options.allowBulkDelete) {
2163
+ throw new Error(`Authoritative sync for collection '${colName}' on '${entityName}' would delete ${unmentionedItems.length}/${loadedItems.length} (${deletePercent.toFixed(1)}%) records, exceeding the threshold of ${maxAllowed}%. Pass --allow-bulk-delete to override.`);
2164
+ }
2165
+ // Rider 2: Authoritative-implied deletes route through confirmation
2166
+ // Confirmation prompt names the collection and the row count
2167
+ if (!options.dryRun && callbacks?.onConfirm) {
2168
+ const confirmKey = `${entityName}.${colName}`;
2169
+ if (!this.confirmedCollections.has(confirmKey)) {
2170
+ const confirmMsg = `Authoritative collection '${colName}' on '${entityName}' will delete unmentioned records (initial batch: ${unmentionedItems.length} record${unmentionedItems.length > 1 ? 's' : ''}). Authorize authoritative deletions for collection '${colName}' on '${entityName}'? (yes/no)`;
2171
+ const confirmed = await callbacks.onConfirm(confirmMsg);
2172
+ if (!confirmed) {
2173
+ throw new Error(`Authoritative delete of ${unmentionedItems.length} record(s) in collection '${colName}' on '${entityName}' cancelled by user.`);
2174
+ }
2175
+ this.confirmedCollections.add(confirmKey);
2176
+ }
2177
+ }
2178
+ if (options.dryRun) {
2179
+ callbacks?.onLog?.(`🗑️ [DRY RUN] Authoritative collection '${colName}' on '${entityName}': would delete ${unmentionedItems.length} unmentioned record${unmentionedItems.length > 1 ? 's' : ''}`);
2180
+ }
2181
+ else {
2182
+ callbacks?.onLog?.(`🗑️ Authoritative collection '${colName}' on '${entityName}': deleting ${unmentionedItems.length} unmentioned record${unmentionedItems.length > 1 ? 's' : ''}`);
2183
+ for (const itemToDelete of unmentionedItems) {
2184
+ col.Remove(itemToDelete);
2185
+ }
2186
+ }
2187
+ }
2188
+ }
2189
+ }
2190
+ }
2191
+ // 3. Extension: const child = await owner.EnsureISAChild(name?), then Set leaf fields on child.
2192
+ if (record.extension && typeof record.extension === 'object') {
2193
+ const extObj = record.extension;
2194
+ if ('fields' in extObj && typeof extObj.fields === 'object' && extObj.fields !== null) {
2195
+ // Shorthand form: { entity?: string, fields: { ... } }
2196
+ const subName = typeof extObj.entity === 'string' ? extObj.entity : undefined;
2197
+ const child = await entity.EnsureISAChild(subName);
2198
+ if (!child) {
2199
+ throw new Error(`EnsureISAChild returned null for extension on '${entityName}'${subName ? ` (${subName})` : ''}. ` +
2200
+ `Ensure that an EntitySubtypeResolver or Entity.SubtypeSelector is configured, or specify 'entity' in extension.`);
2201
+ }
2202
+ for (const [fName, fVal] of Object.entries(extObj.fields)) {
2203
+ const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
2204
+ );
2205
+ child.Set(fName, processedValue);
2206
+ }
2207
+ // Recursively apply nested composition on the child extension
2208
+ await this.applyCompositionAxes(child, extObj, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2209
+ }
2210
+ else {
2211
+ // Map form: { [SubtypeName]: { fields: { ... } } }
2212
+ for (const [subKey, subVal] of Object.entries(extObj)) {
2213
+ if (subKey === '$schema' || subKey === 'sync' || subKey === '__mj_sync_notes')
2214
+ continue;
2215
+ if (!subVal || typeof subVal !== 'object')
2216
+ continue;
2217
+ const child = await entity.EnsureISAChild(subKey);
2218
+ if (!child) {
2219
+ throw new Error(`EnsureISAChild returned null for extension subtype '${subKey}' on '${entityName}'.`);
2220
+ }
2221
+ const subRecord = subVal;
2222
+ if (subRecord.fields && typeof subRecord.fields === 'object') {
2223
+ for (const [fName, fVal] of Object.entries(subRecord.fields)) {
2224
+ const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
2225
+ );
2226
+ child.Set(fName, processedValue);
2227
+ }
2228
+ }
2229
+ // Recursively apply nested composition on the child extension
2230
+ await this.applyCompositionAxes(child, subRecord, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2231
+ }
2232
+ }
2233
+ }
2234
+ }
1923
2235
  }
1924
2236
  //# sourceMappingURL=PushService.js.map