@memberjunction/metadata-sync 6.2.0-edge.4 → 6.2.0-edge.5

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 (122) hide show
  1. package/README.md +115 -51
  2. package/dist/config.d.ts +5 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/RecordProcessor.d.ts +98 -0
  10. package/dist/lib/RecordProcessor.d.ts.map +1 -1
  11. package/dist/lib/RecordProcessor.js +424 -22
  12. package/dist/lib/RecordProcessor.js.map +1 -1
  13. package/dist/lib/RelatedEntityHandler.d.ts +4 -1
  14. package/dist/lib/RelatedEntityHandler.d.ts.map +1 -1
  15. package/dist/lib/RelatedEntityHandler.js +49 -11
  16. package/dist/lib/RelatedEntityHandler.js.map +1 -1
  17. package/dist/lib/created-record-key.d.ts +34 -0
  18. package/dist/lib/created-record-key.d.ts.map +1 -0
  19. package/dist/lib/created-record-key.js +46 -0
  20. package/dist/lib/created-record-key.js.map +1 -0
  21. package/dist/lib/deletion-auditor.d.ts.map +1 -1
  22. package/dist/lib/deletion-auditor.js +33 -2
  23. package/dist/lib/deletion-auditor.js.map +1 -1
  24. package/dist/lib/entity-field-names.d.ts +11 -0
  25. package/dist/lib/entity-field-names.d.ts.map +1 -0
  26. package/dist/lib/entity-field-names.js +15 -0
  27. package/dist/lib/entity-field-names.js.map +1 -0
  28. package/dist/lib/env-derived-fields.d.ts +23 -0
  29. package/dist/lib/env-derived-fields.d.ts.map +1 -0
  30. package/dist/lib/env-derived-fields.js +53 -0
  31. package/dist/lib/env-derived-fields.js.map +1 -0
  32. package/dist/lib/env-display.d.ts +54 -0
  33. package/dist/lib/env-display.d.ts.map +1 -0
  34. package/dist/lib/env-display.js +156 -0
  35. package/dist/lib/env-display.js.map +1 -0
  36. package/dist/lib/env-reference.d.ts +104 -0
  37. package/dist/lib/env-reference.d.ts.map +1 -0
  38. package/dist/lib/env-reference.js +241 -0
  39. package/dist/lib/env-reference.js.map +1 -0
  40. package/dist/lib/env-sql-redactor.d.ts +74 -0
  41. package/dist/lib/env-sql-redactor.d.ts.map +1 -0
  42. package/dist/lib/env-sql-redactor.js +297 -0
  43. package/dist/lib/env-sql-redactor.js.map +1 -0
  44. package/dist/lib/existing-record-index.d.ts +70 -0
  45. package/dist/lib/existing-record-index.d.ts.map +1 -0
  46. package/dist/lib/existing-record-index.js +158 -0
  47. package/dist/lib/existing-record-index.js.map +1 -0
  48. package/dist/lib/ignore-directories.d.ts +10 -0
  49. package/dist/lib/ignore-directories.d.ts.map +1 -0
  50. package/dist/lib/ignore-directories.js +12 -0
  51. package/dist/lib/ignore-directories.js.map +1 -0
  52. package/dist/lib/json-record-lines.d.ts +17 -0
  53. package/dist/lib/json-record-lines.d.ts.map +1 -0
  54. package/dist/lib/json-record-lines.js +170 -0
  55. package/dist/lib/json-record-lines.js.map +1 -0
  56. package/dist/lib/json-subproperty-externalization.d.ts.map +1 -1
  57. package/dist/lib/json-subproperty-externalization.js +3 -1
  58. package/dist/lib/json-subproperty-externalization.js.map +1 -1
  59. package/dist/lib/lookup-reference.d.ts +38 -0
  60. package/dist/lib/lookup-reference.d.ts.map +1 -0
  61. package/dist/lib/lookup-reference.js +62 -0
  62. package/dist/lib/lookup-reference.js.map +1 -0
  63. package/dist/lib/provider-utils.d.ts.map +1 -1
  64. package/dist/lib/provider-utils.js +2 -4
  65. package/dist/lib/provider-utils.js.map +1 -1
  66. package/dist/lib/pull-option-overrides.d.ts +24 -0
  67. package/dist/lib/pull-option-overrides.d.ts.map +1 -0
  68. package/dist/lib/pull-option-overrides.js +39 -0
  69. package/dist/lib/pull-option-overrides.js.map +1 -0
  70. package/dist/lib/push-directories.d.ts +42 -0
  71. package/dist/lib/push-directories.d.ts.map +1 -0
  72. package/dist/lib/push-directories.js +71 -0
  73. package/dist/lib/push-directories.js.map +1 -0
  74. package/dist/lib/push-when.d.ts +38 -0
  75. package/dist/lib/push-when.d.ts.map +1 -0
  76. package/dist/lib/push-when.js +84 -0
  77. package/dist/lib/push-when.js.map +1 -0
  78. package/dist/lib/record-dependency-analyzer.d.ts +2 -0
  79. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  80. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  81. package/dist/lib/record-file-glob.d.ts +11 -0
  82. package/dist/lib/record-file-glob.d.ts.map +1 -0
  83. package/dist/lib/record-file-glob.js +10 -0
  84. package/dist/lib/record-file-glob.js.map +1 -0
  85. package/dist/lib/reference-parser.d.ts +3 -2
  86. package/dist/lib/reference-parser.d.ts.map +1 -1
  87. package/dist/lib/reference-parser.js +27 -27
  88. package/dist/lib/reference-parser.js.map +1 -1
  89. package/dist/lib/sync-engine.d.ts +46 -4
  90. package/dist/lib/sync-engine.d.ts.map +1 -1
  91. package/dist/lib/sync-engine.js +215 -59
  92. package/dist/lib/sync-engine.js.map +1 -1
  93. package/dist/lib/sync-state-manager.d.ts +7 -3
  94. package/dist/lib/sync-state-manager.d.ts.map +1 -1
  95. package/dist/lib/sync-state-manager.js +19 -4
  96. package/dist/lib/sync-state-manager.js.map +1 -1
  97. package/dist/lib/watch-file-filter.d.ts +31 -0
  98. package/dist/lib/watch-file-filter.d.ts.map +1 -0
  99. package/dist/lib/watch-file-filter.js +64 -0
  100. package/dist/lib/watch-file-filter.js.map +1 -0
  101. package/dist/plugins/index.d.ts.map +1 -1
  102. package/dist/plugins/index.js +44 -14
  103. package/dist/plugins/index.js.map +1 -1
  104. package/dist/services/PullService.d.ts +41 -1
  105. package/dist/services/PullService.d.ts.map +1 -1
  106. package/dist/services/PullService.js +261 -101
  107. package/dist/services/PullService.js.map +1 -1
  108. package/dist/services/PushService.d.ts +59 -0
  109. package/dist/services/PushService.d.ts.map +1 -1
  110. package/dist/services/PushService.js +435 -173
  111. package/dist/services/PushService.js.map +1 -1
  112. package/dist/services/ValidationService.d.ts +43 -8
  113. package/dist/services/ValidationService.d.ts.map +1 -1
  114. package/dist/services/ValidationService.js +338 -127
  115. package/dist/services/ValidationService.js.map +1 -1
  116. package/dist/services/WatchService.d.ts +26 -0
  117. package/dist/services/WatchService.d.ts.map +1 -1
  118. package/dist/services/WatchService.js +210 -75
  119. package/dist/services/WatchService.js.map +1 -1
  120. package/dist/types/validation.d.ts +6 -0
  121. package/dist/types/validation.d.ts.map +1 -1
  122. package/package.json +17 -16
@@ -8,7 +8,7 @@ import { IsStringSQLType } from '@memberjunction/sql-dialect';
8
8
  import { SyncEngine, DeferrableLookupError } from '../lib/sync-engine.js';
9
9
  import { SyncMetadataEngine } from '../lib/sync-metadata-engine.js';
10
10
  import { BatchContextIndex } from '../lib/batch-context-index.js';
11
- import { loadEntityConfig, loadSyncConfig } from '../config.js';
11
+ import { loadEntityConfig } from '../config.js';
12
12
  import { FileBackupManager } from '../lib/file-backup-manager.js';
13
13
  import { configManager } from '../lib/config-manager.js';
14
14
  import { SQLLogger } from '../lib/sql-logger.js';
@@ -19,10 +19,18 @@ import { GraphProviderPool, ProbeIndependentInstances } from '../lib/graph-provi
19
19
  import { ResolveDirectoryMode, GraphBatchSizeFor, IsolatedModeWarning, UnusedBatchSizeWarning, } from '../lib/push-write-mode.js';
20
20
  import { PushAbortedError, DescribeCommitFailure, DescribeRollbackOutcome } from '../lib/push-outcome.js';
21
21
  import { JsonPreprocessor } from '../lib/json-preprocessor.js';
22
- import { findEntityDirectories } from '../lib/provider-utils.js';
22
+ import { ResolvePushFolders, SkippedDirectoryMessage } from '../lib/push-directories.js';
23
23
  import { DeletionAuditor } from '../lib/deletion-auditor.js';
24
24
  import { DescribeMissingEntitySubclass } from '../lib/entity-subclass-guard.js';
25
25
  import { DeletionReportGenerator } from '../lib/deletion-report-generator.js';
26
+ import { EnvVariableNotSetError, OptionalEnvMissingError, OptionalEnvSkipMessage, ParseEnvReference } from '../lib/env-reference.js';
27
+ import { RecordStartLine } from '../lib/json-record-lines.js';
28
+ import { EnvDisplayMask, EnvSourceLabel, ENV_HIDDEN_VALUE, MaskedError } from '../lib/env-display.js';
29
+ import { EnvSqlLog, AddSkippedOptionalEnvField } from '../lib/env-sql-redactor.js';
30
+ import { CreatedRecordKeyFields, IsKeyReference, KeyFieldValue } from '../lib/created-record-key.js';
31
+ import { EntityFieldName, RecordFieldNames } from '../lib/entity-field-names.js';
32
+ import { METADATA_KEYWORDS } from '../constants/metadata-keywords.js';
33
+ import { DEFAULT_RECORD_FILE_PATTERN, RECORD_FILE_GLOB_OPTIONS } from '../lib/record-file-glob.js';
26
34
  import { resolveCollectionRelationship } from '@memberjunction/record-graph';
27
35
  function emptyPushTotals() {
28
36
  return { created: 0, updated: 0, unchanged: 0, deleted: 0, skipped: 0, deferred: 0, errors: 0 };
@@ -64,11 +72,16 @@ export class PushService {
64
72
  this.graphBatchSize = 1;
65
73
  /** Counts so far, so a failed push can still report what it did. */
66
74
  this.runningTotals = emptyPushTotals();
75
+ /** The open SQL log's redactor and lookup-create hook; null when no log is open. */
76
+ this.envSqlLog = null;
77
+ /** Composition child fields waiting for their owner's save, by the owner record's resolution collector. */
78
+ this.pendingEnvSqlChildFields = new WeakMap();
67
79
  /** Non-atomic mode: creates and updates that were committed outside the push transaction. */
68
80
  this.committedWrites = [];
69
81
  /** Incremental state, applied only after the push commits. Keyed by path relative to the sync root. */
70
82
  this.pendingChecksums = new Map();
71
83
  this.pushedEntityDirs = [];
84
+ this.skippedDirectories = [];
72
85
  /** Errors whose record was already sent to onRecordError, so a caller does not report it twice. */
73
86
  this.reportedRecordErrors = new WeakSet();
74
87
  this.syncEngine = syncEngine;
@@ -179,14 +192,18 @@ export class PushService {
179
192
  this.committedWrites = [];
180
193
  this.pendingChecksums.clear();
181
194
  this.pushedEntityDirs = [];
195
+ this.skippedDirectories = [];
182
196
  this.directoryModes.clear();
183
197
  this.runningTotals = emptyPushTotals();
184
198
  this.sqlLogFilePath = undefined;
199
+ this.closeEnvSqlRedactor();
185
200
  const fileBackupManager = new FileBackupManager();
186
201
  // Load sync config for SQL logging settings and autoCreateMissingRecords flag
187
- // If dir option is specified, load from that directory, otherwise use original CWD
188
- const configDir = options.dir ? path.resolve(configManager.getOriginalCwd(), options.dir) : configManager.getOriginalCwd();
189
- this.syncConfig = await loadSyncConfig(configDir);
202
+ // With the dir option, load from the sync root at or above that directory, otherwise from the original CWD
203
+ const folders = await ResolvePushFolders(configManager.getOriginalCwd(), options.dir, options.include, options.exclude);
204
+ const configDir = folders.ConfigDir;
205
+ const syncRootDir = folders.SyncRootDir;
206
+ this.syncConfig = folders.SyncConfig;
190
207
  // Display warnings for special flags that are enabled
191
208
  if (this.syncConfig?.push?.alwaysPush && !options.dryRun) {
192
209
  callbacks?.onWarn?.('\n⚡ WARNING: alwaysPush is enabled - ALL records will be saved to database regardless of changes\n');
@@ -197,7 +214,7 @@ export class PushService {
197
214
  if (options.verbose) {
198
215
  callbacks?.onLog?.(`Original working directory: ${configManager.getOriginalCwd()}`);
199
216
  callbacks?.onLog?.(`Config directory (with dir option): ${configDir}`);
200
- callbacks?.onLog?.(`Config file path: ${path.join(configDir, '.mj-sync.json')}`);
217
+ callbacks?.onLog?.(`Config file path: ${path.join(syncRootDir, '.mj-sync.json')}`);
201
218
  callbacks?.onLog?.(`Full sync config loaded: ${JSON.stringify(this.syncConfig, null, 2)}`);
202
219
  callbacks?.onLog?.(`SQL logging config: ${JSON.stringify(this.syncConfig?.sqlLogging)}`);
203
220
  }
@@ -210,13 +227,12 @@ export class PushService {
210
227
  // Setup SQL logging session with the provider if enabled
211
228
  let sqlLoggingSession = null;
212
229
  try {
213
- sqlLoggingSession = await this.startSqlLogging(sqlLogger, options, configDir, callbacks);
214
- // Find entity directories to process
215
- // Note: If options.dir is specified, configDir already points to that directory
216
- // So we don't need to pass it as specificDir
217
- const entityDirs = findEntityDirectories(configDir, undefined, this.syncConfig?.directoryOrder, this.syncConfig?.ignoreDirectories, options.include, options.exclude);
218
- if (entityDirs.length === 0) {
219
- throw new Error('No entity directories found');
230
+ sqlLoggingSession = await this.startSqlLogging(sqlLogger, options, syncRootDir, callbacks);
231
+ const selection = folders.SelectDirectories();
232
+ const entityDirs = selection.Included;
233
+ this.skippedDirectories = selection.Skipped;
234
+ for (const skipped of selection.Skipped) {
235
+ callbacks?.onLog?.(SkippedDirectoryMessage(configDir, skipped));
220
236
  }
221
237
  await this.applyWritePlan(entityDirs, options, callbacks);
222
238
  await this.preloadMetadata(entityDirs, options, callbacks);
@@ -253,7 +269,8 @@ export class PushService {
253
269
  deferred: 0,
254
270
  errors: 0,
255
271
  warnings: this.warnings,
256
- changeLog: this.changeDetails
272
+ changeLog: this.changeDetails,
273
+ SkippedDirectories: this.skippedDirectories
257
274
  };
258
275
  }
259
276
  }
@@ -263,7 +280,7 @@ export class PushService {
263
280
  if (!options.dryRun) {
264
281
  await transactionManager.beginTransaction();
265
282
  }
266
- const totals = await this.runPushInTransaction({ entityDirs, deletionAudit, options, fileBackupManager, callbacks, configDir }, transactionManager);
283
+ const totals = await this.runPushInTransaction({ entityDirs, deletionAudit, options, fileBackupManager, callbacks, syncRootDir, belowSyncRoot: syncRootDir !== configDir }, transactionManager);
267
284
  if (!options.dryRun) {
268
285
  await fileBackupManager.cleanup();
269
286
  if (options.verbose) {
@@ -281,16 +298,19 @@ export class PushService {
281
298
  await sqlLoggingSession.dispose();
282
299
  if (hadStatements) {
283
300
  sqlLogPath = filePath;
301
+ this.addEnvSqlWarnings(filePath, callbacks);
284
302
  if (options.verbose) {
285
303
  callbacks?.onLog?.(`📝 SQL log written to: ${filePath}`);
286
304
  }
287
305
  }
306
+ this.closeEnvSqlRedactor();
288
307
  }
289
308
  return {
290
309
  ...totals,
291
310
  warnings: this.warnings,
292
311
  sqlLogPath,
293
- changeLog: this.changeDetails
312
+ changeLog: this.changeDetails,
313
+ SkippedDirectories: this.skippedDirectories
294
314
  };
295
315
  }
296
316
  catch (error) {
@@ -308,10 +328,16 @@ export class PushService {
308
328
  if (sqlLoggingSession) {
309
329
  try {
310
330
  await sqlLoggingSession.dispose();
331
+ if (await fs.pathExists(sqlLoggingSession.filePath)) {
332
+ this.addEnvSqlWarnings(sqlLoggingSession.filePath, callbacks);
333
+ }
311
334
  }
312
335
  catch (disposeError) {
313
336
  callbacks?.onWarn?.(`Failed to close SQL logging session: ${disposeError}`);
314
337
  }
338
+ finally {
339
+ this.closeEnvSqlRedactor();
340
+ }
315
341
  }
316
342
  throw error;
317
343
  }
@@ -321,7 +347,7 @@ export class PushService {
321
347
  return this.Push(options, callbacks);
322
348
  }
323
349
  /** Open the SQL logging session when the config asks for one and this is not a dry run. */
324
- async startSqlLogging(sqlLogger, options, configDir, callbacks) {
350
+ async startSqlLogging(sqlLogger, options, syncRootDir, callbacks) {
325
351
  let sqlLoggingSession = null;
326
352
  if (sqlLogger.enabled && !options.dryRun) {
327
353
  const provider = Metadata.Provider; // global-provider-ok: metadata sync operates on the configured provider only
@@ -336,14 +362,16 @@ export class PushService {
336
362
  const filename = this.syncConfig?.sqlLogging?.formatAsMigration
337
363
  ? `MetadataSync_Push_${timestamp}.sql`
338
364
  : `push_${timestamp}.sql`;
339
- // Use .sql-log-push directory in the config directory (where sync was initiated)
340
- const outputDir = path.join(configDir, this.syncConfig?.sqlLogging?.outputDirectory || './sql-log-push');
365
+ // The SQL log folder is relative to the sync root
366
+ const outputDir = path.join(syncRootDir, this.syncConfig?.sqlLogging?.outputDirectory || './sql-log-push');
341
367
  const filepath = path.join(outputDir, filename);
342
368
  // Ensure the directory exists
343
369
  await fs.ensureDir(path.dirname(filepath));
344
370
  // Create the SQL logging session
345
371
  this.sqlLogFilePath = filepath;
372
+ this.envSqlLog = new EnvSqlLog(this.syncEngine);
346
373
  sqlLoggingSession = await provider.CreateSqlLogger(filepath, {
374
+ TransformStatement: this.envSqlLog.Redactor.Transform,
347
375
  formatAsMigration: this.syncConfig?.sqlLogging?.formatAsMigration || false,
348
376
  description: 'MetadataSync push operation',
349
377
  statementTypes: "mutations",
@@ -419,8 +447,16 @@ export class PushService {
419
447
  catch (cleanupError) {
420
448
  callbacks?.onWarn?.(`Failed to clean up SQL logging session: ${cleanupError}`);
421
449
  }
450
+ finally {
451
+ this.closeEnvSqlRedactor();
452
+ }
422
453
  }
423
454
  }
455
+ /** Drops the SQL log redactor and this push's lookup-create hook, after the log is closed. */
456
+ closeEnvSqlRedactor() {
457
+ this.envSqlLog?.Close();
458
+ this.envSqlLog = null;
459
+ }
424
460
  /**
425
461
  * Decide, once and up front, how each entity directory writes: shared by default, isolated where
426
462
  * the entity (or the root, or the CLI flag) asks for it. Deciding it here means a push can never
@@ -526,7 +562,7 @@ export class PushService {
526
562
  committed = true;
527
563
  // PHASE 3: files that contained deletions, then the incremental state
528
564
  await this.writeDeferredFiles(run.options, run.callbacks);
529
- await this.persistIncrementalState(run.configDir);
565
+ await this.persistIncrementalState(run.syncRootDir, run.belowSyncRoot);
530
566
  }
531
567
  return totals;
532
568
  }
@@ -653,7 +689,7 @@ export class PushService {
653
689
  : '❌ The push transaction was still open when the push ended, and rolling it back failed.');
654
690
  }
655
691
  /** Apply incremental checksums and push timestamps. Called only after the push commits. */
656
- async persistIncrementalState(syncRootDir) {
692
+ async persistIncrementalState(syncRootDir, belowSyncRoot) {
657
693
  if (!this.stateManager) {
658
694
  return;
659
695
  }
@@ -664,7 +700,7 @@ export class PushService {
664
700
  for (const relativeEntityDir of this.pushedEntityDirs) {
665
701
  this.stateManager.setLastPushTimestamp(relativeEntityDir, pushedAt);
666
702
  }
667
- await this.stateManager.pruneStaleChecksums(syncRootDir);
703
+ await this.stateManager.pruneStaleChecksums(syncRootDir, belowSyncRoot ? this.pushedEntityDirs : undefined);
668
704
  await this.stateManager.save();
669
705
  }
670
706
  /** PHASE 1 over every entity directory, in order. */
@@ -686,7 +722,7 @@ export class PushService {
686
722
  this.useDirectoryMode(entityDir, options);
687
723
  this.announceDirectory(dirName, progressPrefix, entityConfig.entity, entityDir, options, callbacks);
688
724
  const firstChange = this.changeDetails.length;
689
- const result = await this.processEntityDirectory(entityDir, entityConfig, options, run.fileBackupManager, callbacks, run.configDir);
725
+ const result = await this.processEntityDirectory(entityDir, entityConfig, options, run.fileBackupManager, callbacks, run.syncRootDir);
690
726
  // Nested relatedEntities land in changeDetails under their own entity name, so the
691
727
  // directory's configured entity is not what decides the reload (MJ#4836).
692
728
  await this.reloadMetadataIfTouched(firstChange, options.dryRun);
@@ -758,14 +794,8 @@ export class PushService {
758
794
  callbacks?.onWarn?.(`⚠️ ${subclassWarning}`);
759
795
  }
760
796
  // Find all JSON files in the directory
761
- const pattern = entityConfig.filePattern || '*.json';
762
- const files = await fastGlob(pattern, {
763
- cwd: entityDir,
764
- absolute: true,
765
- onlyFiles: true,
766
- dot: true,
767
- ignore: ['**/node_modules/**', '**/.mj-*.json']
768
- });
797
+ const pattern = entityConfig.filePattern || DEFAULT_RECORD_FILE_PATTERN;
798
+ const files = await fastGlob(pattern, { ...RECORD_FILE_GLOB_OPTIONS, cwd: entityDir });
769
799
  if (options.verbose) {
770
800
  callbacks?.onLog?.(`Found ${files.length} files to process`);
771
801
  }
@@ -818,6 +848,9 @@ export class PushService {
818
848
  // Analyze dependencies and get sorted records
819
849
  const analyzer = new RecordDependencyAnalyzer();
820
850
  const analysisResult = await analyzer.analyzeFileRecords(records, entityConfig.entity);
851
+ for (const flattened of analysisResult.sortedRecords) {
852
+ flattened.FilePath = filePath;
853
+ }
821
854
  if (analysisResult.circularDependencies.length > 0) {
822
855
  callbacks?.onWarn?.(`⚠️ Circular dependencies detected in ${filePath}`);
823
856
  for (const cycle of analysisResult.circularDependencies) {
@@ -1056,8 +1089,8 @@ export class PushService {
1056
1089
  pendingWrites.set(outcome.graphId, writes);
1057
1090
  }
1058
1091
  throwRecordFailure(failure, callbacks) {
1059
- const err = failure.error instanceof Error ? failure.error : new Error(String(failure.error));
1060
1092
  const rec = failure.record;
1093
+ const err = this.locateRecordError(failure.error instanceof Error ? failure.error : new Error(String(failure.error)), rec);
1061
1094
  callbacks?.onLog?.(`\n❌ Processing failed for ${rec.entityName} at ${rec.path}`);
1062
1095
  callbacks?.onLog?.(` ${err.message}\n`);
1063
1096
  if (err.stack) {
@@ -1065,6 +1098,33 @@ export class PushService {
1065
1098
  }
1066
1099
  throw err;
1067
1100
  }
1101
+ /**
1102
+ * For an unset `@env:` variable, an error that names the record file with the record's start line,
1103
+ * the entity and the field: `/abs/file.json:12: Entity fields.Name uses @env:VAR, which is not set`.
1104
+ * Other errors are returned as they are.
1105
+ */
1106
+ locateRecordError(error, record) {
1107
+ if (!(error instanceof EnvVariableNotSetError)) {
1108
+ return error;
1109
+ }
1110
+ const field = error.Field ?? 'a field';
1111
+ const message = `${this.recordLocation(record)}: ${error.EntityName ?? record.entityName} ${field} uses @env:${error.VariableName}, which is not set`;
1112
+ return new Error(message, { cause: error });
1113
+ }
1114
+ /** `/abs/file.json:12` for a record from a file (the line where the record starts, when found), else the record path. */
1115
+ recordLocation(record) {
1116
+ if (!record.FilePath) {
1117
+ return record.path;
1118
+ }
1119
+ const filePath = path.resolve(record.FilePath);
1120
+ try {
1121
+ const line = RecordStartLine(fs.readFileSync(filePath, 'utf8'), record.path);
1122
+ return line === undefined ? filePath : `${filePath}:${line}`;
1123
+ }
1124
+ catch {
1125
+ return filePath;
1126
+ }
1127
+ }
1068
1128
  async processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig, allowDefer = true, recordProvider) {
1069
1129
  const metadata = new Metadata(); // global-provider-ok: metadata sync operates on the configured provider only
1070
1130
  const { record, entityName, parentContext, id: recordId } = flattenedRecord;
@@ -1080,13 +1140,21 @@ export class PushService {
1080
1140
  // Use the unique record ID from the flattened record for batch context
1081
1141
  // This ensures we can properly find parent entities even when they're new
1082
1142
  const lookupKey = recordId;
1083
- // Check if already in batch context
1143
+ // Check if already in batch context; the deferred retry runs again for a record the main pass added
1084
1144
  const existingEntry = batchContext.get(lookupKey);
1085
- if (existingEntry) {
1145
+ if (existingEntry && allowDefer) {
1086
1146
  // Already processed
1087
1147
  return { status: 'unchanged', isDuplicate: true };
1088
1148
  }
1089
1149
  let entity;
1150
+ // Parent entity from the batch context, for @parent refs in primaryKey
1151
+ let parentEntity = null;
1152
+ if (parentContext) {
1153
+ const parentRecordId = flattenedRecord.dependencies.values().next().value;
1154
+ if (parentRecordId) {
1155
+ parentEntity = batchContext.get(parentRecordId) || null;
1156
+ }
1157
+ }
1090
1158
  // Record-level skip (--incremental only): if the record has a stored
1091
1159
  // checksum and it matches the current fields, nothing changed — skip
1092
1160
  // without touching the DB. The checksum is embedded in the JSON
@@ -1100,16 +1168,20 @@ export class PushService {
1100
1168
  // drift-correction behavior single-env workflows rely on, and prevents
1101
1169
  // silent no-ops when seeding a fresh DB from JSON files that already
1102
1170
  // carry stored checksums from another environment.
1103
- if (options.incremental && record.sync?.checksum && record.fields && record.primaryKey && !record.deleteRecord) {
1171
+ // The deferred retry (allowDefer false) never takes this shortcut: the first pass left fields unsaved
1172
+ if (allowDefer && options.incremental && record.sync?.checksum && record.fields && record.primaryKey && !record.deleteRecord) {
1104
1173
  // Use calculateChecksumWithFileContent so @file: reference changes are detected.
1105
1174
  // This reads local files (fast) rather than hitting the DB (slow). The stored
1106
1175
  // checksum was also computed with file content and composition payloads, so the comparison is apples-to-apples.
1107
1176
  const checksumPayload = this.buildRecordChecksumPayload(record);
1108
1177
  const currentChecksum = await this.syncEngine.calculateChecksumWithFileContent(checksumPayload, entityDir);
1109
1178
  if (currentChecksum === record.sync.checksum) {
1110
- // Lightweight stub — just enough for @parent:ID and @lookup resolution.
1111
- // No DB call, no entity framework overhead.
1112
- const allFields = { ...record.primaryKey, ...record.fields };
1179
+ // Lightweight stub — just enough for @parent:ID and @lookup resolution. No entity load.
1180
+ // @env:, @parent: and @owner: key values are resolved without a DB call, so a child's @parent:
1181
+ // reference copies the value; other key values (such as @lookup:) stay raw.
1182
+ const keyCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
1183
+ const shortcutKey = await this.resolvePrimaryKey(record.primaryKey, entityDir, parentEntity, batchContext, keyCollector, recordProvider, true);
1184
+ const allFields = { ...record.fields, ...shortcutKey };
1113
1185
  const stub = {
1114
1186
  EntityInfo: { Name: entityName, PrimaryKeys: Object.keys(record.primaryKey).map(k => ({ Name: k })), Fields: Object.keys(allFields).map(k => ({ Name: k })) },
1115
1187
  Get: (field) => allFields[field],
@@ -1126,37 +1198,18 @@ export class PushService {
1126
1198
  if (!entity) {
1127
1199
  throw new Error(`Failed to create entity object for ${entityName}`);
1128
1200
  }
1129
- // Get parent entity from context if available (needed for @parent refs in primaryKey)
1130
- let parentEntity = null;
1131
- if (parentContext) {
1132
- const parentRecordId = flattenedRecord.dependencies.values().next().value;
1133
- if (parentRecordId) {
1134
- parentEntity = batchContext.get(parentRecordId) || null;
1135
- }
1136
- if (!parentEntity) {
1137
- throw new Error(`Parent entity not found in batch context for ${entityName}. Parent dependencies: ${Array.from(flattenedRecord.dependencies).join(', ')}`);
1138
- }
1201
+ if (parentContext && !parentEntity) {
1202
+ throw new Error(`Parent entity not found in batch context for ${entityName}. Parent dependencies: ${Array.from(flattenedRecord.dependencies).join(', ')}`);
1139
1203
  }
1140
1204
  // Check if record exists
1141
1205
  // Process primaryKey values through processFieldValue to resolve @lookup, @parent, etc.
1142
1206
  // Create a resolution collector to track @lookup and @parent resolutions
1143
- const resolutionCollector = { notes: [], fieldPrefix: 'primaryKey' };
1207
+ const resolutionCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
1208
+ // Reads the same EnvFields map, so fields recorded later are masked too
1209
+ const envMask = new EnvDisplayMask(resolutionCollector.EnvFields);
1144
1210
  let resolvedPrimaryKey;
1145
1211
  if (record.primaryKey && Object.keys(record.primaryKey).length > 0) {
1146
- resolvedPrimaryKey = {};
1147
- for (const [pkField, pkValue] of Object.entries(record.primaryKey)) {
1148
- try {
1149
- resolvedPrimaryKey[pkField] = await this.syncEngine.processFieldValue(pkValue, entityDir, parentEntity, null, // rootRecord
1150
- 0, batchContext, resolutionCollector, pkField, recordProvider);
1151
- }
1152
- catch (pkError) {
1153
- // Check if this is a deferrable lookup error
1154
- if (pkError instanceof DeferrableLookupError) {
1155
- throw new Error(`Cannot defer lookup in primaryKey field '${pkField}': ${pkError.message}. Primary key lookups must resolve immediately.`);
1156
- }
1157
- throw pkError;
1158
- }
1159
- }
1212
+ resolvedPrimaryKey = await this.resolvePrimaryKey(record.primaryKey, entityDir, parentEntity, batchContext, resolutionCollector, recordProvider);
1160
1213
  }
1161
1214
  let exists = false;
1162
1215
  let isNew = false;
@@ -1176,7 +1229,7 @@ export class PushService {
1176
1229
  // Record doesn't exist in database
1177
1230
  const autoCreate = this.syncConfig?.push?.autoCreateMissingRecords ?? false;
1178
1231
  const pkDisplay = Object.entries(resolvedPrimaryKey)
1179
- .map(([key, value]) => `${key}=${value}`)
1232
+ .map(([key, value]) => `${key}=${envMask.NewValue(key, String(value))}`)
1180
1233
  .join(', ');
1181
1234
  if (!autoCreate) {
1182
1235
  const warning = `Record not found: ${entityName} with primaryKey {${pkDisplay}}. To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`;
@@ -1212,11 +1265,32 @@ export class PushService {
1212
1265
  }
1213
1266
  // Apply defaults if entityConfig is provided
1214
1267
  if (entityConfig) {
1215
- const defaults = await this.syncEngine.buildDefaults(flattenedRecord.path, entityConfig);
1268
+ const defaultsCollector = { notes: [], fieldPrefix: 'fields', EnvFields: new Map() };
1269
+ // Folder defaults come from the record file's folders
1270
+ const recordFile = flattenedRecord.FilePath ?? path.join(entityDir, '.record.json');
1271
+ const skippedDefaults = new Map();
1272
+ const defaults = await this.syncEngine.buildDefaults(recordFile, entityConfig, defaultsCollector, skippedDefaults);
1273
+ // Field names match the entity's fields without case; a default for a virtual property applies when the entity has that property
1274
+ const recordFields = new RecordFieldNames(record.fields);
1275
+ // A default left out for an unset optional variable warns when the record would have used it
1276
+ for (const [field, variableName] of skippedDefaults) {
1277
+ const name = EntityFieldName(entity, field) ?? (field in entity ? field : undefined);
1278
+ if (name && !recordFields.Has(name)) {
1279
+ const warning = OptionalEnvSkipMessage(entityName, name, variableName);
1280
+ localWarnings.push(warning);
1281
+ callbacks?.onWarn?.(`⚠️ ${warning}`);
1282
+ AddSkippedOptionalEnvField(resolutionCollector.EnvFields, name, variableName);
1283
+ }
1284
+ }
1216
1285
  // Apply defaults only to fields not explicitly set in record.fields
1217
1286
  for (const [field, value] of Object.entries(defaults)) {
1218
- if (!(field in record.fields) && field in entity) {
1219
- entity.Set(field, value);
1287
+ const name = EntityFieldName(entity, field) ?? (field in entity ? field : undefined);
1288
+ if (name && !recordFields.Has(name)) {
1289
+ entity.Set(name, value);
1290
+ const variables = defaultsCollector.EnvFields?.get(field);
1291
+ if (variables) {
1292
+ resolutionCollector.EnvFields?.set(name, variables);
1293
+ }
1220
1294
  }
1221
1295
  }
1222
1296
  }
@@ -1277,6 +1351,16 @@ export class PushService {
1277
1351
  }
1278
1352
  }
1279
1353
  catch (fieldError) {
1354
+ if (fieldError instanceof EnvVariableNotSetError) {
1355
+ throw fieldError.ForField(`fields.${fieldName}`);
1356
+ }
1357
+ if (fieldError instanceof OptionalEnvMissingError) {
1358
+ const warning = OptionalEnvSkipMessage(entityName, fieldName, fieldError.VariableName);
1359
+ localWarnings.push(warning);
1360
+ callbacks?.onWarn?.(`⚠️ ${warning}`);
1361
+ AddSkippedOptionalEnvField(resolutionCollector.EnvFields, EntityFieldName(entity, fieldName) ?? fieldName, fieldError.VariableName);
1362
+ continue;
1363
+ }
1280
1364
  // Check if this is a deferrable lookup error first
1281
1365
  if (fieldError instanceof DeferrableLookupError) {
1282
1366
  // If allowDefer is false, we're in deferred processing mode - can't defer again
@@ -1376,15 +1460,16 @@ export class PushService {
1376
1460
  const primaryKeyDisplay = [];
1377
1461
  if (entityInfo) {
1378
1462
  for (const pk of entityInfo.PrimaryKeys) {
1379
- primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
1463
+ primaryKeyDisplay.push(`${pk.Name}: ${envMask.NewValue(pk.Name, String(entity.Get(pk.Name)))}`);
1380
1464
  }
1381
1465
  }
1382
1466
  const fieldDiffs = changeKeys.map((fieldName) => {
1383
1467
  const field = entity.GetFieldByName(fieldName);
1468
+ const envName = field?.Name ?? fieldName;
1384
1469
  return {
1385
1470
  field: fieldName,
1386
- oldValue: this.formatFieldValue(field ? field.OldValue : undefined),
1387
- newValue: this.formatFieldValue(changes[fieldName]),
1471
+ oldValue: envMask.OldValue(envName, this.formatFieldValue(field ? field.OldValue : undefined)),
1472
+ newValue: envMask.NewValue(envName, this.formatFieldValue(changes[fieldName])),
1388
1473
  };
1389
1474
  });
1390
1475
  this.changeDetails.push({
@@ -1424,7 +1509,7 @@ export class PushService {
1424
1509
  const batchContextEntry = { key: lookupKey, entity };
1425
1510
  if (exists) {
1426
1511
  callbacks?.onLog?.(`[DRY RUN] Would update ${entityName} record`);
1427
- return { status: 'updated', batchContextEntry };
1512
+ return { status: 'updated', batchContextEntry, warnings: localWarnings.length > 0 ? localWarnings : undefined };
1428
1513
  }
1429
1514
  else {
1430
1515
  callbacks?.onLog?.(`[DRY RUN] Would create ${entityName} record`);
@@ -1433,20 +1518,25 @@ export class PushService {
1433
1518
  // a throwaway UUID that changes every run and is never the one a push writes.
1434
1519
  const entityInfo = this.syncEngine.getEntityInfo(entityName);
1435
1520
  const primaryKeyDisplay = (entityInfo?.PrimaryKeys ?? [])
1436
- .filter((pk) => resolvedPrimaryKey?.[pk.Name] != null || record.fields?.[pk.Name] != null)
1437
- .map((pk) => `${pk.Name}: ${resolvedPrimaryKey?.[pk.Name] ?? entity.Get(pk.Name)}`);
1521
+ .filter((pk) => KeyFieldValue(resolvedPrimaryKey, pk.Name) != null || KeyFieldValue(record.fields, pk.Name) != null)
1522
+ .map((pk) => `${pk.Name}: ${envMask.NewValue(pk.Name, String(KeyFieldValue(resolvedPrimaryKey, pk.Name) ?? entity.Get(pk.Name)))}`);
1438
1523
  this.changeDetails.push({
1439
1524
  entityName,
1440
1525
  primaryKey: primaryKeyDisplay.length > 0 ? primaryKeyDisplay.join(', ') : 'new',
1441
1526
  Operation: 'created',
1442
1527
  fields: [],
1443
1528
  });
1444
- return { status: 'created', batchContextEntry };
1529
+ return { status: 'created', batchContextEntry, warnings: localWarnings.length > 0 ? localWarnings : undefined };
1445
1530
  }
1446
1531
  }
1532
+ this.registerEnvSqlFields(entity, resolutionCollector.EnvFields, isNew || alwaysPush);
1533
+ this.registerPendingEnvSqlChildFields(resolutionCollector);
1447
1534
  // Save the record with detailed error logging
1535
+ const keyBeforeSave = new Map(entity.PrimaryKeys.map((pk) => [pk.Name, entity.Get(pk.Name)]));
1448
1536
  const recordName = entity.Get('Name');
1449
- const entityRecordId = entity.Get('ID');
1537
+ const recordKeyText = this.recordIdLabel(entity, envMask);
1538
+ // Free error text can hold a value of any variable push resolved, composition children included
1539
+ const errorTextMask = EnvDisplayMask.ForVariables(this.syncEngine.ResolvedEnvVariables);
1450
1540
  let saveResult;
1451
1541
  try {
1452
1542
  // Skip embedding generation during sync — vectors can be computed later by the
@@ -1466,10 +1556,10 @@ export class PushService {
1466
1556
  callbacks?.onLog?.(msg);
1467
1557
  };
1468
1558
  logError(`\n❌ SAVE EXCEPTION for ${entityName}`);
1469
- logError(` Record ID: ${entityRecordId || 'NEW'}`);
1470
- logError(` Record Name: ${recordName || 'N/A'}`);
1559
+ logError(` Record ID: ${recordKeyText}`);
1560
+ logError(` Record Name: ${recordName ? envMask.NewValue('Name', String(recordName)) : 'N/A'}`);
1471
1561
  logError(` File Path: ${flattenedRecord.path}`);
1472
- logError(` Error: ${saveError.message || saveError}`);
1562
+ logError(` Error: ${errorTextMask.MaskText(String(saveError.message || saveError))}`);
1473
1563
  // Check for specific error patterns
1474
1564
  if (saveError.message?.includes('Cannot insert the value NULL')) {
1475
1565
  logError(` Tip: A required field is NULL. Check the entity's required fields.`);
@@ -1491,11 +1581,11 @@ export class PushService {
1491
1581
  const displayValue = typeof value === 'string' && value.length > 100
1492
1582
  ? value.substring(0, 100) + '...'
1493
1583
  : value;
1494
- logError(` ${field.Name}: ${displayValue}`);
1584
+ logError(` ${field.Name}: ${envMask.NewValue(field.Name, String(displayValue))}`);
1495
1585
  }
1496
1586
  }
1497
1587
  logError(''); // Empty line for readability
1498
- throw saveError;
1588
+ throw MaskedError(saveError, errorTextMask);
1499
1589
  }
1500
1590
  if (!saveResult) {
1501
1591
  // Helper to log to both console and callbacks
@@ -1504,14 +1594,14 @@ export class PushService {
1504
1594
  callbacks?.onLog?.(msg);
1505
1595
  };
1506
1596
  logError(`\n❌ SAVE RETURNED FALSE for ${entityName}`);
1507
- logError(` Record ID: ${entityRecordId || 'NEW'}`);
1508
- logError(` Record Name: ${recordName || 'N/A'}`);
1597
+ logError(` Record ID: ${recordKeyText}`);
1598
+ logError(` Record Name: ${recordName ? envMask.NewValue('Name', String(recordName)) : 'N/A'}`);
1509
1599
  logError(` File Path: ${flattenedRecord.path}`);
1510
1600
  // Log the LatestResult for debugging
1511
1601
  if (entity.LatestResult) {
1512
1602
  const completeMessage = entity.LatestResult.CompleteMessage;
1513
1603
  if (completeMessage) {
1514
- logError(` Database Error: ${completeMessage}`);
1604
+ logError(` Database Error: ${errorTextMask.MaskText(completeMessage)}`);
1515
1605
  }
1516
1606
  if (entity.LatestResult.SQL) {
1517
1607
  // Don't log the full SQL as it might be huge, just indicate it's available
@@ -1526,7 +1616,7 @@ export class PushService {
1526
1616
  const displayValue = typeof value === 'string' && value.length > 100
1527
1617
  ? value.substring(0, 100) + '...'
1528
1618
  : value;
1529
- logError(` ${field.Name}: ${displayValue}`);
1619
+ logError(` ${field.Name}: ${envMask.NewValue(field.Name, String(displayValue))}`);
1530
1620
  }
1531
1621
  }
1532
1622
  logError(''); // Empty line for readability
@@ -1538,17 +1628,18 @@ export class PushService {
1538
1628
  if (entityInfo) {
1539
1629
  for (const pk of entityInfo.PrimaryKeys) {
1540
1630
  const pkValue = entity.Get(pk.Name);
1541
- primaryKeyInfo.push(`${pk.Name}=${this.formatFieldValue(pkValue)}`);
1631
+ primaryKeyInfo.push(`${pk.Name}=${envMask.NewValue(pk.Name, this.formatFieldValue(pkValue))}`);
1542
1632
  }
1543
1633
  }
1544
1634
  // Collect field values that were being saved
1545
1635
  for (const [fieldName, fieldValue] of Object.entries(record.fields)) {
1546
1636
  const processedValue = entity.Get(fieldName);
1547
- fieldInfo.push(`${fieldName}=${this.formatFieldValue(processedValue)}`);
1637
+ const envName = entity.GetFieldByName(fieldName)?.Name ?? fieldName;
1638
+ fieldInfo.push(`${fieldName}=${envMask.NewValue(envName, this.formatFieldValue(processedValue))}`);
1548
1639
  }
1549
1640
  // Get the actual error details from the entity
1550
- const errorMessage = entity.LatestResult?.CompleteMessage || 'Unknown error';
1551
- const errorDetails = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '';
1641
+ const errorMessage = errorTextMask.MaskText(entity.LatestResult?.CompleteMessage || 'Unknown error');
1642
+ const errorDetails = errorTextMask.MaskText(entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '');
1552
1643
  // Log detailed error information
1553
1644
  callbacks?.onError?.(`\n❌ FATAL ERROR: Failed to save ${entityName} record`);
1554
1645
  callbacks?.onError?.(` Entity: ${entityName}`);
@@ -1597,7 +1688,8 @@ export class PushService {
1597
1688
  deferredRecord = {
1598
1689
  flattenedRecord,
1599
1690
  entityDir,
1600
- entityConfig
1691
+ entityConfig,
1692
+ batchContext
1601
1693
  };
1602
1694
  if (options.verbose) {
1603
1695
  callbacks?.onLog?.(` 📋 Queued ${entityName} for deferred processing (record saved, some fields pending)`);
@@ -1611,9 +1703,10 @@ export class PushService {
1611
1703
  if (entityInfo) {
1612
1704
  const newPrimaryKey = {};
1613
1705
  const primaryKeyDisplay = [];
1614
- for (const pk of entityInfo.PrimaryKeys) {
1615
- newPrimaryKey[pk.Name] = entity.Get(pk.Name);
1616
- primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
1706
+ // An env-sourced key keeps the file's reference unless the database assigned another value
1707
+ for (const key of CreatedRecordKeyFields(entityInfo.PrimaryKeys, entity, keyBeforeSave, record.primaryKey, originalFields, envMask)) {
1708
+ newPrimaryKey[key.Name] = key.FileValue;
1709
+ primaryKeyDisplay.push(`${key.Name}: ${key.KeptBySave ? envMask.NewValue(key.Name, String(key.SavedValue)) : String(key.SavedValue)}`);
1617
1710
  }
1618
1711
  record.primaryKey = newPrimaryKey;
1619
1712
  // Capture the create for the changes recap (no field diffs — the whole record is new).
@@ -1631,6 +1724,12 @@ export class PushService {
1631
1724
  if (!shouldWriteSync) {
1632
1725
  delete record.sync;
1633
1726
  }
1727
+ else if (deferredRecord) {
1728
+ // A deferred record keeps its earlier checksum in the file, so each later --incremental push checks it again until the record changes
1729
+ if (options.verbose) {
1730
+ callbacks?.onLog?.(` - Sync metadata waits for the deferred fields`);
1731
+ }
1732
+ }
1634
1733
  else if (isNew || isDirty) {
1635
1734
  const checksumPayload = this.buildRecordChecksumPayload(record, originalFields);
1636
1735
  const checksum = await this.syncEngine.calculateChecksumWithFileContent(checksumPayload, entityDir);
@@ -1695,6 +1794,77 @@ export class PushService {
1695
1794
  status: isNew ? 'created' : (isDirty ? 'updated' : 'unchanged'),
1696
1795
  };
1697
1796
  }
1797
+ /**
1798
+ * Resolves each primary-key value (`@lookup`, `@parent`, `@env`, …). A key lookup cannot be deferred.
1799
+ * With `referencesOnly`, only `@env:`, `@parent:` and `@owner:` values are resolved; others are copied as is.
1800
+ */
1801
+ async resolvePrimaryKey(primaryKey, entityDir, parentEntity, batchContext, resolutionCollector, recordProvider, referencesOnly = false) {
1802
+ const resolved = {};
1803
+ for (const [pkField, pkValue] of Object.entries(primaryKey)) {
1804
+ if (referencesOnly && !IsKeyReference(pkValue)) {
1805
+ resolved[pkField] = pkValue;
1806
+ continue;
1807
+ }
1808
+ try {
1809
+ resolved[pkField] = await this.syncEngine.processFieldValue(pkValue, entityDir, parentEntity, null, // rootRecord
1810
+ 0, batchContext, resolutionCollector, pkField, recordProvider);
1811
+ }
1812
+ catch (pkError) {
1813
+ if (pkError instanceof EnvVariableNotSetError) {
1814
+ throw pkError.ForField(`primaryKey.${pkField}`);
1815
+ }
1816
+ if (pkError instanceof DeferrableLookupError) {
1817
+ throw new Error(`Cannot defer lookup in primaryKey field '${pkField}': ${pkError.message}. Primary key lookups must resolve immediately.`);
1818
+ }
1819
+ throw pkError;
1820
+ }
1821
+ }
1822
+ return resolved;
1823
+ }
1824
+ /** A file key value for printing: `<from @env:VAR>` for an `@env:` reference, else the value. */
1825
+ fileKeyText(value) {
1826
+ const reference = ParseEnvReference(value);
1827
+ return reference ? EnvSourceLabel([reference.VariableName]) : String(value);
1828
+ }
1829
+ /**
1830
+ * The key with each `@env:` value resolved through `collector`, which records the env-sourced fields.
1831
+ * Other values are copied as written. A malformed or unset reference throws a names-only error.
1832
+ */
1833
+ async resolveEnvKeyValues(primaryKey, entityDir, collector) {
1834
+ const resolved = {};
1835
+ for (const [field, value] of Object.entries(primaryKey)) {
1836
+ resolved[field] = typeof value === 'string' && value.startsWith(METADATA_KEYWORDS.ENV)
1837
+ ? await this.syncEngine.processFieldValue(value, entityDir, null, null, 0, undefined, collector, field)
1838
+ : value;
1839
+ }
1840
+ return resolved;
1841
+ }
1842
+ /** True when the child holds every key value; UUID key fields match without regard to case. */
1843
+ childHasKey(child, key) {
1844
+ return Object.entries(key).every(([field, value]) => {
1845
+ const current = child.Get(field);
1846
+ const isUuidField = child.GetFieldByName(field)?.EntityFieldInfo?.IsUniqueIdentifier === true;
1847
+ if (isUuidField && typeof current === 'string' && typeof value === 'string') {
1848
+ return UUIDsEqual(current, value);
1849
+ }
1850
+ return String(current) === String(value);
1851
+ });
1852
+ }
1853
+ /**
1854
+ * The record's key for save-failure output: the value of a single key field, or `Field=value` pairs
1855
+ * for a composite key, with env-sourced key fields shown as labels. `NEW` when no key field has a value.
1856
+ */
1857
+ recordIdLabel(entity, envMask) {
1858
+ const keyFields = entity.PrimaryKeys.filter((pk) => {
1859
+ const value = entity.Get(pk.Name);
1860
+ return value !== null && value !== undefined && value !== '';
1861
+ });
1862
+ if (keyFields.length === 0) {
1863
+ return 'NEW';
1864
+ }
1865
+ const labels = keyFields.map((pk) => envMask.NewValue(pk.Name, String(entity.Get(pk.Name))));
1866
+ return keyFields.length === 1 ? labels[0] : keyFields.map((pk, i) => `${pk.Name}=${labels[i]}`).join(', ');
1867
+ }
1698
1868
  formatFieldValue(value) {
1699
1869
  if (value === null || value === undefined)
1700
1870
  return 'null';
@@ -1711,14 +1881,17 @@ export class PushService {
1711
1881
  }
1712
1882
  return String(value);
1713
1883
  }
1714
- async processDeleteRecord(flattenedRecord, _entityDir, options, callbacks, isDbOnly = false) {
1884
+ async processDeleteRecord(flattenedRecord, entityDir, options, callbacks, isDbOnly = false) {
1715
1885
  const { record, entityName } = flattenedRecord;
1716
1886
  // Validate that we have a primary key for deletion
1717
1887
  if (!record.primaryKey || Object.keys(record.primaryKey).length === 0) {
1718
1888
  throw new Error(`Cannot delete ${entityName} record without primaryKey. Please specify primaryKey fields.`);
1719
1889
  }
1720
- // Load the entity to check if it exists in the target database
1721
- const existingEntity = await this.syncEngine.loadEntity(entityName, record.primaryKey);
1890
+ // Load the entity to check if it exists in the target database; an `@env:` key value is resolved first
1891
+ const keyCollector = { notes: [], fieldPrefix: 'primaryKey', EnvFields: new Map() };
1892
+ const resolvedKey = await this.resolveEnvKeyValues(record.primaryKey, entityDir, keyCollector);
1893
+ const keyMask = new EnvDisplayMask(keyCollector.EnvFields);
1894
+ const existingEntity = await this.syncEngine.loadEntity(entityName, resolvedKey);
1722
1895
  // Check if the deletion has already been processed
1723
1896
  if (record.deleteRecord?.deletedAt) {
1724
1897
  // Verify if record still exists in THIS database
@@ -1737,7 +1910,7 @@ export class PushService {
1737
1910
  }
1738
1911
  if (!existingEntity) {
1739
1912
  const pkDisplay = Object.entries(record.primaryKey)
1740
- .map(([key, value]) => `${key}=${value}`)
1913
+ .map(([key, value]) => `${key}=${this.fileKeyText(value)}`)
1741
1914
  .join(', ');
1742
1915
  const warning = `Record not found for deletion: ${entityName} with primaryKey {${pkDisplay}}`;
1743
1916
  this.warnings.push(warning);
@@ -1750,12 +1923,14 @@ export class PushService {
1750
1923
  record.deleteRecord.notFound = true;
1751
1924
  return { status: 'skipped', isDuplicate: false };
1752
1925
  }
1926
+ // Key values and free error text can hold a value of any variable push resolved
1927
+ const errorTextMask = EnvDisplayMask.ForVariables(this.syncEngine.ResolvedEnvVariables);
1753
1928
  // Log the deletion
1754
1929
  const entityInfo = this.syncEngine.getEntityInfo(entityName);
1755
1930
  const primaryKeyDisplay = [];
1756
1931
  if (entityInfo) {
1757
1932
  for (const pk of entityInfo.PrimaryKeys) {
1758
- primaryKeyDisplay.push(`${pk.Name}: ${existingEntity.Get(pk.Name)}`);
1933
+ primaryKeyDisplay.push(`${pk.Name}: ${keyMask.NewValue(pk.Name, errorTextMask.MaskText(String(existingEntity.Get(pk.Name))))}`);
1759
1934
  }
1760
1935
  }
1761
1936
  // Deletion detail streams inline only in verbose mode — the recap is the default
@@ -1772,7 +1947,9 @@ export class PushService {
1772
1947
  }
1773
1948
  const recordNameVerbose = existingEntity.Get('Name');
1774
1949
  if (recordNameVerbose) {
1775
- callbacks?.onLog?.(` Name: ${recordNameVerbose}`);
1950
+ const fileNameKey = Object.keys(record.fields ?? {}).find((key) => key.toLowerCase() === 'name');
1951
+ const nameFromEnv = fileNameKey !== undefined && ParseEnvReference(record.fields?.[fileNameKey]) !== null;
1952
+ callbacks?.onLog?.(` Name: ${nameFromEnv ? ENV_HIDDEN_VALUE : recordNameVerbose}`);
1776
1953
  }
1777
1954
  }
1778
1955
  // Capture the deletion for the changes recap (no field diffs for deletes).
@@ -1788,13 +1965,15 @@ export class PushService {
1788
1965
  }
1789
1966
  return { status: 'deleted', isDuplicate: false };
1790
1967
  }
1968
+ // An env-sourced delete key gets the names-only SQL log warning
1969
+ this.registerEnvSqlFields(existingEntity, keyCollector.EnvFields, false);
1791
1970
  // Delete the record
1792
1971
  try {
1793
1972
  const deleteResult = await existingEntity.Delete();
1794
1973
  if (!deleteResult) {
1795
1974
  // Check the LatestResult for error details
1796
- const errorMessage = existingEntity.LatestResult?.CompleteMessage || 'Unknown error';
1797
- const errorDetails = existingEntity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '';
1975
+ const errorMessage = errorTextMask.MaskText(existingEntity.LatestResult?.CompleteMessage || 'Unknown error');
1976
+ const errorDetails = errorTextMask.MaskText(existingEntity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || '');
1798
1977
  callbacks?.onError?.(`\n❌ Failed to delete ${entityName} record`);
1799
1978
  callbacks?.onError?.(` Primary Key: {${primaryKeyDisplay.join(', ')}}`);
1800
1979
  callbacks?.onError?.(` Error: ${errorMessage}`);
@@ -1810,7 +1989,7 @@ export class PushService {
1810
1989
  throw new Error(`Failed to delete ${entityName} record: ${errorMessage}`);
1811
1990
  }
1812
1991
  // Remove from metadata cache
1813
- this.syncMetadataEngine.removeEntityFromCache(entityName, record.primaryKey);
1992
+ this.syncMetadataEngine.removeEntityFromCache(entityName, resolvedKey);
1814
1993
  // Set deletedAt timestamp after successful deletion (only for metadata records)
1815
1994
  if (!isDbOnly) {
1816
1995
  if (!record.deleteRecord) {
@@ -1833,7 +2012,7 @@ export class PushService {
1833
2012
  catch (deleteError) {
1834
2013
  console.error(`\n❌ DELETE EXCEPTION for ${entityName}`);
1835
2014
  console.error(` Primary Key: {${primaryKeyDisplay.join(', ')}}`);
1836
- console.error(` Error: ${deleteError.message || deleteError}`);
2015
+ console.error(` Error: ${errorTextMask.MaskText(String(deleteError.message || deleteError))}`);
1837
2016
  // Check for specific error patterns
1838
2017
  if (deleteError.message?.includes('FOREIGN KEY constraint')) {
1839
2018
  console.error(` Tip: This record is referenced by other records and cannot be deleted.`);
@@ -1842,7 +2021,7 @@ export class PushService {
1842
2021
  else if (deleteError.message?.includes('permission')) {
1843
2022
  console.error(` Tip: You may not have permission to delete this record.`);
1844
2023
  }
1845
- throw deleteError;
2024
+ throw MaskedError(deleteError, errorTextMask);
1846
2025
  }
1847
2026
  }
1848
2027
  /**
@@ -1936,7 +2115,7 @@ export class PushService {
1936
2115
  }
1937
2116
  }
1938
2117
  }
1939
- const pattern = entityConfig.filePattern || '*.json';
2118
+ const pattern = entityConfig.filePattern || DEFAULT_RECORD_FILE_PATTERN;
1940
2119
  const files = await fastGlob(pattern, {
1941
2120
  cwd: entityDir,
1942
2121
  absolute: true,
@@ -1987,7 +2166,7 @@ export class PushService {
1987
2166
  continue;
1988
2167
  }
1989
2168
  // Find all JSON files
1990
- const pattern = entityConfig.filePattern || '*.json';
2169
+ const pattern = entityConfig.filePattern || DEFAULT_RECORD_FILE_PATTERN;
1991
2170
  const files = await fastGlob(pattern, {
1992
2171
  cwd: entityDir,
1993
2172
  absolute: true,
@@ -2010,6 +2189,9 @@ export class PushService {
2010
2189
  const records = Array.isArray(fileData) ? fileData : [fileData];
2011
2190
  // Phase 1: Flatten only - accumulates into analyzer's internal state
2012
2191
  const flattenedRecords = analyzer.flattenFileRecords(records, entityConfig.entity);
2192
+ for (const flattened of flattenedRecords) {
2193
+ flattened.FilePath = filePath;
2194
+ }
2013
2195
  allFlattenedRecords.push(...flattenedRecords);
2014
2196
  }
2015
2197
  catch (error) {
@@ -2119,9 +2301,10 @@ export class PushService {
2119
2301
  }
2120
2302
  }
2121
2303
  catch (error) {
2122
- callbacks?.onError?.(` Failed to delete ${record.entityName}: ${error}`);
2304
+ const located = error instanceof Error ? this.locateRecordError(error, record) : error;
2305
+ callbacks?.onError?.(` Failed to delete ${record.entityName}: ${located}`);
2123
2306
  errors++;
2124
- throw error; // Fail fast on first deletion error
2307
+ throw located; // Fail fast on first deletion error
2125
2308
  }
2126
2309
  }
2127
2310
  }
@@ -2173,14 +2356,14 @@ export class PushService {
2173
2356
  let created = 0;
2174
2357
  let updated = 0;
2175
2358
  let errors = 0;
2176
- // Create a fresh batch context for deferred processing
2177
- // Records are in DB now, so this is mainly for tracking within this phase
2178
- const batchContext = new BatchContextIndex();
2359
+ // A deferred record without the batch context of its main pass gets one for this phase
2360
+ const phaseBatchContext = new BatchContextIndex();
2179
2361
  for (const deferred of this.deferredRecords) {
2180
2362
  const { flattenedRecord, entityDir, entityConfig } = deferred;
2363
+ const batchContext = deferred.batchContext ?? phaseBatchContext;
2181
2364
  const entityName = flattenedRecord.entityName;
2182
2365
  const recordId = flattenedRecord.record.primaryKey
2183
- ? Object.entries(flattenedRecord.record.primaryKey).map(([k, v]) => `${k}=${v}`).join(', ')
2366
+ ? Object.entries(flattenedRecord.record.primaryKey).map(([k, v]) => `${k}=${this.fileKeyText(v)}`).join(', ')
2184
2367
  : (flattenedRecord.record.fields.Name || 'NEW');
2185
2368
  try {
2186
2369
  // Re-run processFlattenedRecord with allowDefer=false
@@ -2208,7 +2391,7 @@ export class PushService {
2208
2391
  }
2209
2392
  }
2210
2393
  catch (error) {
2211
- const err = error instanceof Error ? error : new Error(String(error));
2394
+ const err = this.locateRecordError(error instanceof Error ? error : new Error(String(error)), flattenedRecord);
2212
2395
  callbacks?.onError?.(` ✗ Failed to process deferred record: ${entityName} (${recordId})`);
2213
2396
  callbacks?.onError?.(` Error: ${err.message}`);
2214
2397
  callbacks?.onError?.(` Tip: Ensure all referenced records exist or remove the ?allowDefer flag`);
@@ -2359,6 +2542,86 @@ export class PushService {
2359
2542
  buildRecordChecksumPayload(record, fieldsOverride) {
2360
2543
  return SyncEngine.BuildRecordChecksumPayload(fieldsOverride ?? record.fields, record.collections, record.embeds, record.extension);
2361
2544
  }
2545
+ /**
2546
+ * Resolves one field of an embed, collection item or extension and sets it on the target.
2547
+ * A missing `@env:VAR?optional` variable leaves the field unset and raises a warning.
2548
+ * `sharesOwnerFields` (IS-A extension children) records env-sourced fields in the owner's collector.
2549
+ */
2550
+ async setCompositionField(target, fieldName, rawValue, entityDir, batchContext, resolutionCollector, recordProvider, ownerRecord, callbacks, sharesOwnerFields = false) {
2551
+ // An IS-A extension child writes owner fields, so it records into the owner's collector. Other
2552
+ // children get their own EnvFields map, so a child field never masks an owner field of the same name.
2553
+ const childCollector = sharesOwnerFields
2554
+ ? resolutionCollector
2555
+ : { notes: resolutionCollector.notes, fieldPrefix: resolutionCollector.fieldPrefix, EnvFields: new Map() };
2556
+ try {
2557
+ const value = await this.syncEngine.processFieldValue(rawValue, entityDir, null, null, 0, batchContext, childCollector, fieldName, recordProvider, ownerRecord);
2558
+ target.Set(fieldName, value);
2559
+ const variables = childCollector.EnvFields?.get(fieldName);
2560
+ if (variables) {
2561
+ this.queueEnvSqlChildField(resolutionCollector, { Target: target, FieldName: fieldName, Variables: variables });
2562
+ }
2563
+ }
2564
+ catch (error) {
2565
+ if (error instanceof EnvVariableNotSetError) {
2566
+ throw error.ForField(`fields.${fieldName}`, target.EntityInfo.Name);
2567
+ }
2568
+ if (!(error instanceof OptionalEnvMissingError)) {
2569
+ throw error;
2570
+ }
2571
+ this.addWarning(OptionalEnvSkipMessage(target.EntityInfo.Name, fieldName, error.VariableName), callbacks);
2572
+ this.queueEnvSqlChildField(resolutionCollector, { Target: target, FieldName: fieldName, Variables: new Set([error.VariableName]) });
2573
+ }
2574
+ }
2575
+ /** Runs composition work and names its path (and entity) in an unset-variable error. */
2576
+ async withCompositionPath(compositionPath, entityName, work) {
2577
+ try {
2578
+ await work();
2579
+ }
2580
+ catch (error) {
2581
+ if (error instanceof EnvVariableNotSetError) {
2582
+ throw error.Within(compositionPath, entityName());
2583
+ }
2584
+ throw error;
2585
+ }
2586
+ }
2587
+ /** Registers the env-sourced fields of this save with the SQL log redactor, when a log is open. */
2588
+ registerEnvSqlFields(entity, envFields, fullRow) {
2589
+ this.envSqlLog?.Redactor.RegisterEntityFields(entity, envFields, fullRow);
2590
+ }
2591
+ /** Keeps an env-sourced composition child field until its owner's save, when a SQL log is open. */
2592
+ queueEnvSqlChildField(ownerCollector, field) {
2593
+ if (!this.envSqlLog) {
2594
+ return;
2595
+ }
2596
+ const pending = this.pendingEnvSqlChildFields.get(ownerCollector) ?? [];
2597
+ pending.push(field);
2598
+ this.pendingEnvSqlChildFields.set(ownerCollector, pending);
2599
+ }
2600
+ /** Queues each env-sourced key field of a composition child for the names-only SQL log warning. */
2601
+ queueEnvSqlChildKey(ownerCollector, child, keyCollector) {
2602
+ for (const [fieldName, variables] of keyCollector.EnvFields ?? []) {
2603
+ this.queueEnvSqlChildField(ownerCollector, { Target: child, FieldName: fieldName, Variables: variables });
2604
+ }
2605
+ }
2606
+ /** Registers the queued composition child fields of the record that is about to be saved. */
2607
+ registerPendingEnvSqlChildFields(ownerCollector) {
2608
+ const pending = this.pendingEnvSqlChildFields.get(ownerCollector) ?? [];
2609
+ this.pendingEnvSqlChildFields.delete(ownerCollector);
2610
+ for (const field of pending) {
2611
+ this.registerEnvSqlCompositionField(field.Target, field.FieldName, field.Variables);
2612
+ }
2613
+ }
2614
+ /** Registers one env-sourced composition child field when its save statement holds it. */
2615
+ registerEnvSqlCompositionField(target, fieldName, variables) {
2616
+ const fullRow = !target.IsSaved || this.syncConfig?.push?.alwaysPush === true;
2617
+ this.registerEnvSqlFields(target, new Map([[fieldName, variables]]), fullRow);
2618
+ }
2619
+ /** Adds the names-only SQL log warnings of this run. */
2620
+ addEnvSqlWarnings(sqlLogPath, callbacks) {
2621
+ for (const warning of this.envSqlLog?.Redactor.Warnings(sqlLogPath) ?? []) {
2622
+ this.addWarning(warning, callbacks);
2623
+ }
2624
+ }
2362
2625
  /**
2363
2626
  * Applies first-class composition axes onto the entity before Save (§5):
2364
2627
  * 1. embeds (peer first via {fkField}_EnsureObject() or companion.Ensure())
@@ -2405,16 +2668,17 @@ export class PushService {
2405
2668
  if (!embeddedEntity) {
2406
2669
  throw new Error(`Failed to resolve embedded companion for '${fkField}' on entity '${entityName}'. Ensure that DeclareEmbeddedRecord is called for '${fkField}'.`);
2407
2670
  }
2408
- // Apply embed fields, passing ownerRecord = entity for @owner: resolution
2409
- if (embedRecord.fields && typeof embedRecord.fields === 'object') {
2410
- for (const [fName, fVal] of Object.entries(embedRecord.fields)) {
2411
- const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
2412
- );
2413
- embeddedEntity.Set(fName, processedValue);
2671
+ const embedded = embeddedEntity;
2672
+ await this.withCompositionPath(`embeds.${fkField}`, () => embedded.EntityInfo.Name, async () => {
2673
+ // Apply embed fields, passing ownerRecord = entity for @owner: resolution
2674
+ if (embedRecord.fields && typeof embedRecord.fields === 'object') {
2675
+ for (const [fName, fVal] of Object.entries(embedRecord.fields)) {
2676
+ await this.setCompositionField(embedded, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks);
2677
+ }
2414
2678
  }
2415
- }
2416
- // Recursively apply nested composition on the embedded record
2417
- await this.applyCompositionAxes(embeddedEntity, embedRecord, embeddedEntity.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2679
+ // Recursively apply nested composition on the embedded record
2680
+ await this.applyCompositionAxes(embedded, embedRecord, embedded.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2681
+ });
2418
2682
  }
2419
2683
  }
2420
2684
  // 2. Collections: owner.Lines.Create() (or load existing by PK into the collection), recurse apply on child entity
@@ -2478,50 +2742,48 @@ export class PushService {
2478
2742
  const matchedItemSet = new Set();
2479
2743
  const colConfig = entityConfig?.collections?.[colName];
2480
2744
  const mode = colConfig?.mode ?? 'upsert';
2481
- for (const itemData of colItems) {
2745
+ for (const [itemIndex, itemData] of colItems.entries()) {
2482
2746
  if (!itemData || typeof itemData !== 'object')
2483
2747
  continue;
2484
- let targetChild = null;
2485
- // Match by primaryKey if available
2486
- if (itemData.primaryKey && Object.keys(itemData.primaryKey).length > 0 && loadedItems.length > 0) {
2487
- targetChild = loadedItems.find((child) => {
2488
- for (const [pkK, pkV] of Object.entries(itemData.primaryKey)) {
2489
- if (String(child.Get(pkK)) !== String(pkV)) {
2490
- return false;
2491
- }
2748
+ await this.withCompositionPath(`collections.${colName}[${itemIndex}]`, () => col.RelatedEntityName, async () => {
2749
+ let targetChild = null;
2750
+ // The child's key with `@env:` values resolved; env-sourced key fields are kept for the SQL log
2751
+ const childKeyCollector = { notes: resolutionCollector.notes, fieldPrefix: 'primaryKey', EnvFields: new Map() };
2752
+ const childKey = itemData.primaryKey
2753
+ ? await this.resolveEnvKeyValues(itemData.primaryKey, entityDir, childKeyCollector)
2754
+ : undefined;
2755
+ // Match by primaryKey if available
2756
+ if (childKey && Object.keys(childKey).length > 0 && loadedItems.length > 0) {
2757
+ targetChild = loadedItems.find((child) => this.childHasKey(child, childKey)) ?? null;
2758
+ }
2759
+ if (itemData.deleteRecord?.delete === true) {
2760
+ // Explicit delete in both modes (§8.1(a) rider 1)
2761
+ if (targetChild) {
2762
+ this.queueEnvSqlChildKey(resolutionCollector, targetChild, childKeyCollector);
2763
+ col.Remove(targetChild);
2764
+ matchedItemSet.add(targetChild);
2492
2765
  }
2493
- return true;
2494
- }) ?? null;
2495
- }
2496
- if (itemData.deleteRecord?.delete === true) {
2497
- // Explicit delete in both modes (§8.1(a) rider 1)
2498
- if (targetChild) {
2499
- col.Remove(targetChild);
2500
- matchedItemSet.add(targetChild);
2766
+ return;
2501
2767
  }
2502
- continue;
2503
- }
2504
- if (!targetChild) {
2505
- targetChild = await col.Create();
2506
- if (itemData.primaryKey) {
2507
- for (const [pkK, pkV] of Object.entries(itemData.primaryKey)) {
2768
+ if (!targetChild) {
2769
+ targetChild = await col.Create();
2770
+ for (const [pkK, pkV] of Object.entries(childKey ?? {})) {
2508
2771
  targetChild.Set(pkK, pkV);
2509
2772
  }
2510
2773
  }
2511
- }
2512
- else {
2513
- matchedItemSet.add(targetChild);
2514
- }
2515
- // Apply fields with ownerRecord = entity for @owner:Field resolution
2516
- if (itemData.fields && typeof itemData.fields === 'object') {
2517
- for (const [fName, fVal] of Object.entries(itemData.fields)) {
2518
- const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
2519
- );
2520
- targetChild.Set(fName, processedValue);
2774
+ else {
2775
+ matchedItemSet.add(targetChild);
2521
2776
  }
2522
- }
2523
- // Recursively apply nested composition on the collection item
2524
- await this.applyCompositionAxes(targetChild, itemData, targetChild.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2777
+ this.queueEnvSqlChildKey(resolutionCollector, targetChild, childKeyCollector);
2778
+ // Apply fields with ownerRecord = entity for @owner:Field resolution
2779
+ if (itemData.fields && typeof itemData.fields === 'object') {
2780
+ for (const [fName, fVal] of Object.entries(itemData.fields)) {
2781
+ await this.setCompositionField(targetChild, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks);
2782
+ }
2783
+ }
2784
+ // Recursively apply nested composition on the collection item
2785
+ await this.applyCompositionAxes(targetChild, itemData, targetChild.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2786
+ });
2525
2787
  }
2526
2788
  // Handle authoritative mode deletions (§8.1(a))
2527
2789
  if (mode === 'authoritative' && loadedItems.length > 0) {
@@ -2570,13 +2832,13 @@ export class PushService {
2570
2832
  throw new Error(`EnsureISAChild returned null for extension on '${entityName}'${subName ? ` (${subName})` : ''}. ` +
2571
2833
  `Ensure that an EntitySubtypeResolver or Entity.SubtypeSelector is configured, or specify 'entity' in extension.`);
2572
2834
  }
2573
- for (const [fName, fVal] of Object.entries(extObj.fields)) {
2574
- const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
2575
- );
2576
- child.Set(fName, processedValue);
2577
- }
2578
- // Recursively apply nested composition on the child extension
2579
- await this.applyCompositionAxes(child, extObj, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2835
+ await this.withCompositionPath('extension', () => child.EntityInfo.Name, async () => {
2836
+ for (const [fName, fVal] of Object.entries(extObj.fields)) {
2837
+ await this.setCompositionField(child, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks, true);
2838
+ }
2839
+ // Recursively apply nested composition on the child extension
2840
+ await this.applyCompositionAxes(child, extObj, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2841
+ });
2580
2842
  }
2581
2843
  else {
2582
2844
  // Map form: { [SubtypeName]: { fields: { ... } } }
@@ -2589,16 +2851,16 @@ export class PushService {
2589
2851
  if (!child) {
2590
2852
  throw new Error(`EnsureISAChild returned null for extension subtype '${subKey}' on '${entityName}'.`);
2591
2853
  }
2592
- const subRecord = subVal;
2593
- if (subRecord.fields && typeof subRecord.fields === 'object') {
2594
- for (const [fName, fVal] of Object.entries(subRecord.fields)) {
2595
- const processedValue = await this.syncEngine.processFieldValue(fVal, entityDir, null, null, 0, batchContext, resolutionCollector, fName, recordProvider, entity // ownerRecord
2596
- );
2597
- child.Set(fName, processedValue);
2854
+ await this.withCompositionPath(`extension.${subKey}`, () => child.EntityInfo.Name, async () => {
2855
+ const subRecord = subVal;
2856
+ if (subRecord.fields && typeof subRecord.fields === 'object') {
2857
+ for (const [fName, fVal] of Object.entries(subRecord.fields)) {
2858
+ await this.setCompositionField(child, fName, fVal, entityDir, batchContext, resolutionCollector, recordProvider, entity, callbacks, true);
2859
+ }
2598
2860
  }
2599
- }
2600
- // Recursively apply nested composition on the child extension
2601
- await this.applyCompositionAxes(child, subRecord, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2861
+ // Recursively apply nested composition on the child extension
2862
+ await this.applyCompositionAxes(child, subRecord, child.EntityInfo.Name, entityDir, batchContext, resolutionCollector, options, callbacks, entityConfig, recordProvider, depth + 1);
2863
+ });
2602
2864
  }
2603
2865
  }
2604
2866
  }