@memberjunction/metadata-sync 6.1.0-edge.5 → 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.
- package/README.md +48 -3
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/constants/metadata-keywords.d.ts +13 -2
- package/dist/constants/metadata-keywords.d.ts.map +1 -1
- package/dist/constants/metadata-keywords.js +12 -0
- package/dist/constants/metadata-keywords.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/RecordProcessor.d.ts +12 -0
- package/dist/lib/RecordProcessor.d.ts.map +1 -1
- package/dist/lib/RecordProcessor.js +163 -10
- package/dist/lib/RecordProcessor.js.map +1 -1
- package/dist/lib/batch-context-index.js +1 -1
- package/dist/lib/batch-context-index.js.map +1 -1
- package/dist/lib/collection-resolver.d.ts +24 -0
- package/dist/lib/collection-resolver.d.ts.map +1 -0
- package/dist/lib/collection-resolver.js +117 -0
- package/dist/lib/collection-resolver.js.map +1 -0
- package/dist/lib/database-reference-scanner.js +5 -5
- package/dist/lib/database-reference-scanner.js.map +1 -1
- package/dist/lib/deletion-auditor.d.ts.map +1 -1
- package/dist/lib/deletion-auditor.js +1 -0
- package/dist/lib/deletion-auditor.js.map +1 -1
- package/dist/lib/entity-subclass-guard.d.ts +10 -0
- package/dist/lib/entity-subclass-guard.d.ts.map +1 -0
- package/dist/lib/entity-subclass-guard.js +38 -0
- package/dist/lib/entity-subclass-guard.js.map +1 -0
- package/dist/lib/graph-provider-pool.d.ts +67 -0
- package/dist/lib/graph-provider-pool.d.ts.map +1 -0
- package/dist/lib/graph-provider-pool.js +149 -0
- package/dist/lib/graph-provider-pool.js.map +1 -0
- package/dist/lib/json-write-helper.d.ts +12 -8
- package/dist/lib/json-write-helper.d.ts.map +1 -1
- package/dist/lib/json-write-helper.js +54 -24
- package/dist/lib/json-write-helper.js.map +1 -1
- package/dist/lib/record-dependency-analyzer.d.ts +8 -0
- package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
- package/dist/lib/record-dependency-analyzer.js +20 -5
- package/dist/lib/record-dependency-analyzer.js.map +1 -1
- package/dist/lib/reference-parser.d.ts.map +1 -1
- package/dist/lib/reference-parser.js +1 -0
- package/dist/lib/reference-parser.js.map +1 -1
- package/dist/lib/sync-engine.d.ts +27 -4
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +94 -45
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/lib/sync-metadata-engine.d.ts +44 -1
- package/dist/lib/sync-metadata-engine.d.ts.map +1 -1
- package/dist/lib/sync-metadata-engine.js +152 -4
- package/dist/lib/sync-metadata-engine.js.map +1 -1
- package/dist/lib/transaction-manager.d.ts +2 -1
- package/dist/lib/transaction-manager.d.ts.map +1 -1
- package/dist/lib/transaction-manager.js +4 -1
- package/dist/lib/transaction-manager.js.map +1 -1
- package/dist/plugins/index.js +3 -3
- package/dist/plugins/index.js.map +1 -1
- package/dist/services/FormattingService.d.ts +8 -0
- package/dist/services/FormattingService.d.ts.map +1 -1
- package/dist/services/FormattingService.js +18 -0
- package/dist/services/FormattingService.js.map +1 -1
- package/dist/services/PullService.js +1 -1
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +53 -3
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +455 -143
- package/dist/services/PushService.js.map +1 -1
- package/dist/services/ValidationService.d.ts +26 -0
- package/dist/services/ValidationService.d.ts.map +1 -1
- package/dist/services/ValidationService.js +454 -14
- package/dist/services/ValidationService.js.map +1 -1
- 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
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
|
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 =
|
|
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
|
|
1018
|
+
// Also check if file content or composition payload has changed
|
|
1005
1019
|
if (!isDirty && !isNew && record.sync) {
|
|
1006
|
-
const
|
|
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
|
-
|
|
1078
|
-
|
|
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
|
-
//
|
|
1246
|
-
|
|
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
|
|
1249
|
-
checksum
|
|
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
|