@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.
- package/README.md +47 -0
- package/dist/config.d.ts +3 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/RecordProcessor.d.ts +7 -1
- package/dist/lib/RecordProcessor.d.ts.map +1 -1
- package/dist/lib/RecordProcessor.js +21 -7
- package/dist/lib/RecordProcessor.js.map +1 -1
- package/dist/lib/RelatedEntityHandler.d.ts +10 -0
- package/dist/lib/RelatedEntityHandler.d.ts.map +1 -1
- package/dist/lib/RelatedEntityHandler.js +69 -0
- package/dist/lib/RelatedEntityHandler.js.map +1 -1
- package/dist/lib/batch-context-index.d.ts +107 -0
- package/dist/lib/batch-context-index.d.ts.map +1 -0
- package/dist/lib/batch-context-index.js +203 -0
- package/dist/lib/batch-context-index.js.map +1 -0
- package/dist/lib/provider-utils.d.ts +1 -2
- package/dist/lib/provider-utils.d.ts.map +1 -1
- package/dist/lib/provider-utils.js +96 -29
- package/dist/lib/provider-utils.js.map +1 -1
- package/dist/lib/record-dependency-analyzer.js +1 -1
- package/dist/lib/record-dependency-analyzer.js.map +1 -1
- package/dist/lib/sync-engine.d.ts +5 -2
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +31 -21
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/lib/sync-state-manager.d.ts +37 -0
- package/dist/lib/sync-state-manager.d.ts.map +1 -0
- package/dist/lib/sync-state-manager.js +83 -0
- package/dist/lib/sync-state-manager.js.map +1 -0
- package/dist/lib/transaction-manager.d.ts +18 -4
- package/dist/lib/transaction-manager.d.ts.map +1 -1
- package/dist/lib/transaction-manager.js +18 -5
- package/dist/lib/transaction-manager.js.map +1 -1
- package/dist/services/PullService.d.ts +19 -1
- package/dist/services/PullService.d.ts.map +1 -1
- package/dist/services/PullService.js +158 -9
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +6 -1
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +151 -30
- package/dist/services/PushService.js.map +1 -1
- package/dist/services/ValidationService.js +1 -1
- package/dist/services/ValidationService.js.map +1 -1
- package/dist/services/WatchService.js +1 -1
- package/dist/services/WatchService.js.map +1 -1
- 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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
547
|
-
if (
|
|
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
|
-
|
|
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
|
-
//
|
|
902
|
-
//
|
|
903
|
-
batchContext.
|
|
904
|
-
|
|
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
|
-
|
|
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
|
|
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`);
|