@memberjunction/metadata-sync 6.2.0-edge.3 → 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 (130) hide show
  1. package/README.md +117 -53
  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/entity-subclass-guard.d.ts +8 -7
  29. package/dist/lib/entity-subclass-guard.d.ts.map +1 -1
  30. package/dist/lib/entity-subclass-guard.js +20 -8
  31. package/dist/lib/entity-subclass-guard.js.map +1 -1
  32. package/dist/lib/env-derived-fields.d.ts +23 -0
  33. package/dist/lib/env-derived-fields.d.ts.map +1 -0
  34. package/dist/lib/env-derived-fields.js +53 -0
  35. package/dist/lib/env-derived-fields.js.map +1 -0
  36. package/dist/lib/env-display.d.ts +54 -0
  37. package/dist/lib/env-display.d.ts.map +1 -0
  38. package/dist/lib/env-display.js +156 -0
  39. package/dist/lib/env-display.js.map +1 -0
  40. package/dist/lib/env-reference.d.ts +104 -0
  41. package/dist/lib/env-reference.d.ts.map +1 -0
  42. package/dist/lib/env-reference.js +241 -0
  43. package/dist/lib/env-reference.js.map +1 -0
  44. package/dist/lib/env-sql-redactor.d.ts +74 -0
  45. package/dist/lib/env-sql-redactor.d.ts.map +1 -0
  46. package/dist/lib/env-sql-redactor.js +297 -0
  47. package/dist/lib/env-sql-redactor.js.map +1 -0
  48. package/dist/lib/existing-record-index.d.ts +70 -0
  49. package/dist/lib/existing-record-index.d.ts.map +1 -0
  50. package/dist/lib/existing-record-index.js +158 -0
  51. package/dist/lib/existing-record-index.js.map +1 -0
  52. package/dist/lib/ignore-directories.d.ts +10 -0
  53. package/dist/lib/ignore-directories.d.ts.map +1 -0
  54. package/dist/lib/ignore-directories.js +12 -0
  55. package/dist/lib/ignore-directories.js.map +1 -0
  56. package/dist/lib/json-record-lines.d.ts +17 -0
  57. package/dist/lib/json-record-lines.d.ts.map +1 -0
  58. package/dist/lib/json-record-lines.js +170 -0
  59. package/dist/lib/json-record-lines.js.map +1 -0
  60. package/dist/lib/json-subproperty-externalization.d.ts.map +1 -1
  61. package/dist/lib/json-subproperty-externalization.js +3 -1
  62. package/dist/lib/json-subproperty-externalization.js.map +1 -1
  63. package/dist/lib/lookup-reference.d.ts +38 -0
  64. package/dist/lib/lookup-reference.d.ts.map +1 -0
  65. package/dist/lib/lookup-reference.js +62 -0
  66. package/dist/lib/lookup-reference.js.map +1 -0
  67. package/dist/lib/provider-utils.d.ts.map +1 -1
  68. package/dist/lib/provider-utils.js +2 -4
  69. package/dist/lib/provider-utils.js.map +1 -1
  70. package/dist/lib/pull-option-overrides.d.ts +24 -0
  71. package/dist/lib/pull-option-overrides.d.ts.map +1 -0
  72. package/dist/lib/pull-option-overrides.js +39 -0
  73. package/dist/lib/pull-option-overrides.js.map +1 -0
  74. package/dist/lib/push-directories.d.ts +42 -0
  75. package/dist/lib/push-directories.d.ts.map +1 -0
  76. package/dist/lib/push-directories.js +71 -0
  77. package/dist/lib/push-directories.js.map +1 -0
  78. package/dist/lib/push-when.d.ts +38 -0
  79. package/dist/lib/push-when.d.ts.map +1 -0
  80. package/dist/lib/push-when.js +84 -0
  81. package/dist/lib/push-when.js.map +1 -0
  82. package/dist/lib/record-dependency-analyzer.d.ts +2 -0
  83. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  84. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  85. package/dist/lib/record-file-glob.d.ts +11 -0
  86. package/dist/lib/record-file-glob.d.ts.map +1 -0
  87. package/dist/lib/record-file-glob.js +10 -0
  88. package/dist/lib/record-file-glob.js.map +1 -0
  89. package/dist/lib/reference-parser.d.ts +3 -2
  90. package/dist/lib/reference-parser.d.ts.map +1 -1
  91. package/dist/lib/reference-parser.js +27 -27
  92. package/dist/lib/reference-parser.js.map +1 -1
  93. package/dist/lib/sync-engine.d.ts +52 -4
  94. package/dist/lib/sync-engine.d.ts.map +1 -1
  95. package/dist/lib/sync-engine.js +233 -61
  96. package/dist/lib/sync-engine.js.map +1 -1
  97. package/dist/lib/sync-state-manager.d.ts +7 -3
  98. package/dist/lib/sync-state-manager.d.ts.map +1 -1
  99. package/dist/lib/sync-state-manager.js +19 -4
  100. package/dist/lib/sync-state-manager.js.map +1 -1
  101. package/dist/lib/watch-file-filter.d.ts +31 -0
  102. package/dist/lib/watch-file-filter.d.ts.map +1 -0
  103. package/dist/lib/watch-file-filter.js +64 -0
  104. package/dist/lib/watch-file-filter.js.map +1 -0
  105. package/dist/plugins/index.d.ts.map +1 -1
  106. package/dist/plugins/index.js +55 -23
  107. package/dist/plugins/index.js.map +1 -1
  108. package/dist/services/FormattingService.d.ts +14 -7
  109. package/dist/services/FormattingService.d.ts.map +1 -1
  110. package/dist/services/FormattingService.js +22 -9
  111. package/dist/services/FormattingService.js.map +1 -1
  112. package/dist/services/PullService.d.ts +53 -1
  113. package/dist/services/PullService.d.ts.map +1 -1
  114. package/dist/services/PullService.js +323 -120
  115. package/dist/services/PullService.js.map +1 -1
  116. package/dist/services/PushService.d.ts +59 -0
  117. package/dist/services/PushService.d.ts.map +1 -1
  118. package/dist/services/PushService.js +448 -173
  119. package/dist/services/PushService.js.map +1 -1
  120. package/dist/services/ValidationService.d.ts +43 -8
  121. package/dist/services/ValidationService.d.ts.map +1 -1
  122. package/dist/services/ValidationService.js +338 -127
  123. package/dist/services/ValidationService.js.map +1 -1
  124. package/dist/services/WatchService.d.ts +26 -0
  125. package/dist/services/WatchService.d.ts.map +1 -1
  126. package/dist/services/WatchService.js +210 -75
  127. package/dist/services/WatchService.js.map +1 -1
  128. package/dist/types/validation.d.ts +6 -0
  129. package/dist/types/validation.d.ts.map +1 -1
  130. package/package.json +17 -16
@@ -17,6 +17,9 @@ import { GetDialect, IsDateSQLType, IsUuidSQLType } from '@memberjunction/sql-di
17
17
  import { JsonPreprocessor } from './json-preprocessor.js';
18
18
  import { DescribeMissingEntitySubclass } from './entity-subclass-guard.js';
19
19
  import { BatchContextIndex } from './batch-context-index.js';
20
+ import { ParseEnvReference, OptionalEnvMissingError, EnvVariableNotSetError } from './env-reference.js';
21
+ import { EnvDisplayMask, EnvSourceLabel, MIN_MASKED_VALUE_LENGTH } from './env-display.js';
22
+ import { LOOKUP_OPTIONAL_MESSAGE, ParseLookupCreateFields, SplitLookupReference } from './lookup-reference.js';
20
23
  import { METADATA_KEYWORDS, IsMetadataKeyword, IsNonKeywordAtSymbol, ExtractKeywordValue } from '../constants/metadata-keywords.js';
21
24
  /**
22
25
  * Custom error class for lookup failures that can be deferred.
@@ -93,6 +96,10 @@ export class SyncEngine {
93
96
  */
94
97
  constructor(contextUser) {
95
98
  this.syncMetadataEngine = null;
99
+ /** Names of every environment variable this engine resolved, kept for the engine's lifetime and never cleared. Never holds values. */
100
+ this.ResolvedEnvVariables = new Set();
101
+ /** Runs right before a `@lookup:…?create` lookup saves the record it creates, with the env-sourced fields of that record. */
102
+ this.BeforeLookupCreateSave = null;
96
103
  /**
97
104
  * Where non-fatal warnings raised inside the engine go (the BaseEntity-fallback notice on the
98
105
  * lookup auto-create path, for one). PushService points this at its own warnings list so the
@@ -128,6 +135,7 @@ export class SyncEngine {
128
135
  * - `@url:address` - Fetches content from a URL
129
136
  * - `@lookup:Entity.Field=Value` - Looks up an entity ID by field value
130
137
  * - `@env:VARIABLE` - Reads an environment variable
138
+ * - `@env:VARIABLE?optional` - Like `@env:`, but a missing variable throws {@link OptionalEnvMissingError} so the caller can leave the field out
131
139
  *
132
140
  * @param value - The field value to process
133
141
  * @param baseDir - Base directory for resolving relative file paths
@@ -170,7 +178,9 @@ export class SyncEngine {
170
178
  // Check if it's an array or a plain object (not a Date, etc.)
171
179
  if (Array.isArray(value) || value.constructor === Object) {
172
180
  // First recursively process any @lookup, @file, @parent references inside the object
173
- const processedValue = await this.processJsonFieldValues(value, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord);
181
+ const envCollector = this.envOnlyCollector(resolutionCollector);
182
+ const processedValue = await this.processJsonFieldValues(value, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord, envCollector, fieldName);
183
+ this.mergeEnvFields(resolutionCollector, envCollector);
174
184
  // Then convert to pretty-printed JSON string for inline metadata objects
175
185
  // Objects from @file references will be handled by BaseEntity during save
176
186
  return JSON.stringify(processedValue, null, 2);
@@ -196,13 +206,14 @@ export class SyncEngine {
196
206
  const ownerName = ownerRecord.EntityInfo?.Name || 'unknown';
197
207
  throw new Error(`@owner:${ownerFieldName} resolved to undefined on owner ${ownerName}. Field does not exist or has undefined value.`);
198
208
  }
209
+ const sourceVariables = this.recordCopiedEnvValue(resolvedValue, resolutionCollector, fieldName);
199
210
  // Track the resolution if collector is provided
200
211
  if (resolutionCollector && fieldName) {
201
212
  resolutionCollector.notes.push({
202
213
  type: 'parent',
203
214
  field: `${resolutionCollector.fieldPrefix}.${fieldName}`,
204
215
  expression: value,
205
- resolved: String(resolvedValue)
216
+ resolved: sourceVariables.length > 0 ? EnvSourceLabel(sourceVariables) : String(resolvedValue)
206
217
  });
207
218
  }
208
219
  return resolvedValue;
@@ -214,13 +225,14 @@ export class SyncEngine {
214
225
  }
215
226
  const parentFieldName = ExtractKeywordValue(value) || '';
216
227
  const resolvedValue = parentRecord.Get(parentFieldName);
228
+ const sourceVariables = this.recordCopiedEnvValue(resolvedValue, resolutionCollector, fieldName);
217
229
  // Track the resolution if collector is provided
218
230
  if (resolutionCollector && fieldName) {
219
231
  resolutionCollector.notes.push({
220
232
  type: 'parent',
221
233
  field: `${resolutionCollector.fieldPrefix}.${fieldName}`,
222
234
  expression: value,
223
- resolved: String(resolvedValue)
235
+ resolved: sourceVariables.length > 0 ? EnvSourceLabel(sourceVariables) : String(resolvedValue)
224
236
  });
225
237
  }
226
238
  return resolvedValue;
@@ -257,7 +269,9 @@ export class SyncEngine {
257
269
  }
258
270
  // Now recursively process any @file references within the JSON
259
271
  const fileDir = path.dirname(fullPath);
260
- processedJson = await this.processJsonFieldValues(processedJson, fileDir, parentRecord, rootRecord, depth + 1, batchContext, recordProvider);
272
+ const envCollector = this.envOnlyCollector(resolutionCollector);
273
+ processedJson = await this.processJsonFieldValues(processedJson, fileDir, parentRecord, rootRecord, depth + 1, batchContext, recordProvider, undefined, envCollector, fieldName);
274
+ this.mergeEnvFields(resolutionCollector, envCollector);
261
275
  // Return the processed JSON object directly without stringifying
262
276
  // Let BaseEntity handle serialization when saving to database
263
277
  // This ensures proper escaping of embedded code/scripts
@@ -299,25 +313,25 @@ export class SyncEngine {
299
313
  const lookupStr = ExtractKeywordValue(value);
300
314
  // Parse lookup with optional flags: ?create, ?allowDefer
301
315
  // Format: EntityName.Field1=Value1&Field2=Value2?create&allowDefer&OtherField=Value
302
- const entityMatch = lookupStr.match(/^([^.]+)\./);
303
- if (!entityMatch) {
316
+ const split = SplitLookupReference(lookupStr);
317
+ if (!split) {
304
318
  throw new Error(`Invalid lookup format: ${value}`);
305
319
  }
306
- const entityName = entityMatch[1];
307
- const remaining = lookupStr.substring(entityName.length + 1);
308
- // Split into lookup part and flags part
309
- const questionMarkIndex = remaining.indexOf('?');
310
- const lookupPart = questionMarkIndex >= 0 ? remaining.substring(0, questionMarkIndex) : remaining;
311
- const flagsPart = questionMarkIndex >= 0 ? remaining.substring(questionMarkIndex + 1) : '';
312
- // Parse flags from the query string portion
313
- const flags = new Set(flagsPart.split('&').map(f => f.split('=')[0].toLowerCase()));
314
- const hasCreate = flags.has('create');
315
- const allowDefer = flags.has('allowdefer');
320
+ if (split.UnknownBareFlags.includes('optional')) {
321
+ throw new Error(`Invalid @lookup reference: ${value}. ${LOOKUP_OPTIONAL_MESSAGE}`);
322
+ }
323
+ const entityName = split.EntityName;
324
+ const lookupPart = split.CriteriaText;
325
+ const flagsPart = split.FlagsText;
326
+ const hasCreate = split.FlagNames.has('create');
327
+ const allowDefer = split.FlagNames.has('allowdefer');
316
328
  // Parse all lookup fields (can be multiple with &)
317
329
  const lookupFields = [];
318
330
  const lookupPairs = lookupPart.split('&');
319
331
  // Collector for nested resolutions within this lookup
320
332
  const nestedResolutions = [];
333
+ // Criteria and create fields whose values came from @env:, for the lookup messages
334
+ const lookupEnvFields = new Map();
321
335
  for (const pair of lookupPairs) {
322
336
  const fieldMatch = pair.match(/^(.+?)=(.+)$/);
323
337
  if (!fieldMatch) {
@@ -326,15 +340,16 @@ export class SyncEngine {
326
340
  const [, lookupFieldName, fieldValue] = fieldMatch;
327
341
  const rawFieldValue = fieldValue.trim();
328
342
  // Recursively process the field value to resolve any nested @ commands
329
- // Create a temporary collector to capture nested resolutions
330
- const nestedCollector = { notes: [], fieldPrefix: '' };
343
+ // Create a temporary collector to capture nested resolutions and env-sourced criteria
344
+ const nestedCollector = { notes: [], fieldPrefix: '', EnvFields: new Map() };
331
345
  const processedValue = await this.ProcessFieldValue(rawFieldValue, baseDir, parentRecord, rootRecord, depth + 1, batchContext, nestedCollector, lookupFieldName, // Pass field name for tracking
332
346
  recordProvider);
347
+ const criteriaVariables = this.addLookupEnvField(lookupEnvFields, lookupFieldName.trim(), nestedCollector);
333
348
  // If the raw value was a lookup expression that got resolved, track it as nested
334
349
  if (rawFieldValue.startsWith(METADATA_KEYWORDS.LOOKUP) && rawFieldValue !== processedValue) {
335
350
  nestedResolutions.push({
336
351
  expression: rawFieldValue,
337
- resolved: String(processedValue)
352
+ resolved: criteriaVariables ? EnvSourceLabel(criteriaVariables) : String(processedValue)
338
353
  });
339
354
  }
340
355
  lookupFields.push({ fieldName: lookupFieldName.trim(), fieldValue: processedValue });
@@ -346,28 +361,22 @@ export class SyncEngine {
346
361
  // These are key=value pairs after the flags (e.g., ?create&Description=Some%20Value)
347
362
  let createFields = {};
348
363
  if (hasCreate && flagsPart) {
349
- const flagPairs = flagsPart.split('&');
350
- for (const pair of flagPairs) {
351
- // Skip known flags
352
- if (pair.toLowerCase() === 'create' || pair.toLowerCase() === 'allowdefer') {
353
- continue;
354
- }
355
- const [key, val] = pair.split('=');
356
- if (key && val) {
357
- const decodedVal = decodeURIComponent(val);
358
- // Recursively process the field value to resolve any nested @ commands
359
- createFields[key] = await this.ProcessFieldValue(decodedVal, baseDir, parentRecord, rootRecord, depth + 1, batchContext, undefined, undefined, recordProvider);
360
- }
364
+ for (const createField of ParseLookupCreateFields(flagsPart)) {
365
+ // Recursively process the field value to resolve any nested @ commands
366
+ const createCollector = { notes: [], fieldPrefix: '', EnvFields: new Map() };
367
+ createFields[createField.Name] = await this.ProcessFieldValue(createField.Value, baseDir, parentRecord, rootRecord, depth + 1, batchContext, createCollector, createField.Name, recordProvider);
368
+ this.addLookupEnvField(lookupEnvFields, createField.Name, createCollector);
361
369
  }
362
370
  }
363
- const resolvedValue = await this.ResolveLookup(entityName, lookupFields, hasCreate, createFields, batchContext, allowDefer, value, recordProvider);
371
+ const resolvedValue = await this.ResolveLookup(entityName, lookupFields, hasCreate, createFields, batchContext, allowDefer, value, recordProvider, lookupEnvFields);
372
+ const sourceVariables = this.recordCopiedEnvValue(resolvedValue, resolutionCollector, fieldName);
364
373
  // Track the resolution if collector is provided
365
374
  if (resolutionCollector && fieldName) {
366
375
  const note = {
367
376
  type: 'lookup',
368
377
  field: `${resolutionCollector.fieldPrefix}.${fieldName}`,
369
378
  expression: value,
370
- resolved: String(resolvedValue)
379
+ resolved: sourceVariables.length > 0 ? EnvSourceLabel(sourceVariables) : String(resolvedValue)
371
380
  };
372
381
  // Include nested resolutions if any were captured
373
382
  if (nestedResolutions.length > 0) {
@@ -379,10 +388,23 @@ export class SyncEngine {
379
388
  }
380
389
  // Check for @env: reference
381
390
  if (value.startsWith(METADATA_KEYWORDS.ENV)) {
382
- const envVar = ExtractKeywordValue(value);
383
- const envValue = process.env[envVar];
391
+ const reference = ParseEnvReference(value);
392
+ if (!reference) {
393
+ throw new Error(`Invalid @env reference: ${value}`);
394
+ }
395
+ const envValue = process.env[reference.VariableName];
384
396
  if (envValue === undefined) {
385
- throw new Error(`Environment variable not found: ${envVar}`);
397
+ if (reference.Optional) {
398
+ throw new OptionalEnvMissingError(reference.VariableName);
399
+ }
400
+ const field = resolutionCollector?.fieldPrefix && fieldName ? `${resolutionCollector.fieldPrefix}.${fieldName}` : fieldName;
401
+ throw new EnvVariableNotSetError(reference.VariableName, field);
402
+ }
403
+ this.ResolvedEnvVariables.add(reference.VariableName);
404
+ if (resolutionCollector?.EnvFields && fieldName) {
405
+ const variables = resolutionCollector.EnvFields.get(fieldName) ?? new Set();
406
+ variables.add(reference.VariableName);
407
+ resolutionCollector.EnvFields.set(fieldName, variables);
386
408
  }
387
409
  return envValue;
388
410
  }
@@ -443,7 +465,9 @@ export class SyncEngine {
443
465
  });
444
466
  return `${encodeURIComponent(entityName.toLowerCase())}|${parts.join('&')}`;
445
467
  }
446
- async ResolveLookup(entityName, lookupFields, autoCreate = false, createFields = {}, batchContext, allowDefer = false, originalValue, recordProvider) {
468
+ async ResolveLookup(entityName, lookupFields, autoCreate = false, createFields = {}, batchContext, allowDefer = false, originalValue, recordProvider, envFields) {
469
+ // Criteria and create fields whose values came from @env: print as labels
470
+ const envMask = new EnvDisplayMask(envFields);
447
471
  const lookupCacheKey = this.buildLookupCacheKey(entityName, lookupFields);
448
472
  if (this.syncMetadataEngine) {
449
473
  const cachedId = this.syncMetadataEngine.getCachedLookup(lookupCacheKey);
@@ -624,16 +648,18 @@ export class SyncEngine {
624
648
  }
625
649
  }
626
650
  // Save the new record (new records are always dirty)
627
- const filterDesc = lookupFields.map(({ fieldName, fieldValue }) => `${fieldName}='${fieldValue}'`).join(' AND ');
628
- console.log(`📝 Auto-creating ${entityName} record where ${filterDesc}`);
651
+ console.log(`📝 Auto-creating ${entityName} record where ${this.describeLookupFilter(lookupFields, envMask)}`);
652
+ if (envFields) {
653
+ this.BeforeLookupCreateSave?.(newEntity, envFields);
654
+ }
629
655
  const saved = await newEntity.Save();
630
656
  if (!saved) {
631
657
  const message = newEntity.LatestResult?.Message;
632
658
  if (message) {
633
- throw new Error(`Failed to auto-create ${entityName}: ${message}`);
659
+ throw new Error(`Failed to auto-create ${entityName}: ${envMask.MaskText(message)}`);
634
660
  }
635
661
  const errors = newEntity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || 'Unknown error';
636
- throw new Error(`Failed to auto-create ${entityName}: ${errors}`);
662
+ throw new Error(`Failed to auto-create ${entityName}: ${envMask.MaskText(errors)}`);
637
663
  }
638
664
  // Return the new ID
639
665
  if (entityInfo.PrimaryKeys.length > 0) {
@@ -647,16 +673,38 @@ export class SyncEngine {
647
673
  }
648
674
  }
649
675
  const filterDesc = lookupFields.map(({ fieldName, fieldValue }) => `${fieldName}='${fieldValue}'`).join(' AND ');
650
- const errorMessage = `Lookup failed: No record found in '${entityName}' where ${filterDesc}`;
676
+ const errorMessage = `Lookup failed: No record found in '${entityName}' where ${this.describeLookupFilter(lookupFields, envMask)}`;
651
677
  // If allowDefer is true, throw DeferrableLookupError so caller can queue for retry
652
678
  if (allowDefer) {
653
679
  throw new DeferrableLookupError(errorMessage, entityName, lookupFields, originalValue || `@lookup:${entityName}.${filterDesc}`);
654
680
  }
655
681
  throw new Error(errorMessage);
656
682
  }
683
+ /** `Field='value' AND …` for messages; env-sourced criteria print as `Field=<from @env:VAR>`. */
684
+ describeLookupFilter(lookupFields, envMask) {
685
+ return lookupFields
686
+ .map(({ fieldName, fieldValue }) => `${fieldName}=${envMask.NewValue(fieldName, `'${fieldValue}'`)}`)
687
+ .join(' AND ');
688
+ }
689
+ /**
690
+ * Adds the variable names a lookup criterion or create field recorded in its scratch collector to the
691
+ * lookup's map. `@env:` values and `@parent:`/`@lookup:` results equal to an env value are recorded.
692
+ * @returns The field's variable names in the lookup's map, or undefined when it is not env-sourced
693
+ */
694
+ addLookupEnvField(lookupEnvFields, lookupFieldName, scratch) {
695
+ const recorded = new Set();
696
+ scratch.EnvFields?.forEach(names => names.forEach(name => recorded.add(name)));
697
+ if (recorded.size === 0) {
698
+ return undefined;
699
+ }
700
+ const merged = lookupEnvFields.get(lookupFieldName) ?? new Set();
701
+ recorded.forEach(name => merged.add(name));
702
+ lookupEnvFields.set(lookupFieldName, merged);
703
+ return merged;
704
+ }
657
705
  /** @deprecated Use {@link ResolveLookup}. */
658
- async resolveLookup(entityName, lookupFields, autoCreate = false, createFields = {}, batchContext, allowDefer = false, originalValue, recordProvider) {
659
- return this.ResolveLookup(entityName, lookupFields, autoCreate, createFields, batchContext, allowDefer, originalValue, recordProvider);
706
+ async resolveLookup(entityName, lookupFields, autoCreate = false, createFields = {}, batchContext, allowDefer = false, originalValue, recordProvider, envFields) {
707
+ return this.ResolveLookup(entityName, lookupFields, autoCreate, createFields, batchContext, allowDefer, originalValue, recordProvider, envFields);
660
708
  }
661
709
  /**
662
710
  * Build cascading defaults for a file path and process field values
@@ -667,17 +715,16 @@ export class SyncEngine {
667
715
  *
668
716
  * @param filePath - Path to the file being processed
669
717
  * @param entityConfig - Entity configuration containing base defaults
718
+ * @param resolutionCollector - Optional collector; when it has an `EnvFields` map, env-sourced defaults are recorded in it (no notes are added)
719
+ * @param skippedOptional - Optional map; receives field → variable for each default left out because its `?optional` variable is not set
670
720
  * @returns Processed defaults with all references resolved
671
721
  * @throws Error if any default value processing fails
672
722
  */
673
- async BuildDefaults(filePath, entityConfig) {
674
- const parts = path.dirname(filePath).split(path.sep);
723
+ async BuildDefaults(filePath, entityConfig, resolutionCollector, skippedOptional) {
675
724
  let defaults = { ...entityConfig.defaults };
676
- // Walk up the directory tree building defaults
677
- let currentPath = '';
678
- for (const part of parts) {
679
- currentPath = path.join(currentPath, part);
680
- const folderConfig = await this.loadFolderConfig(currentPath);
725
+ // Folder defaults from the sync root down to the file's folder; deeper folders win
726
+ for (const dir of await this.folderChainFor(path.dirname(path.resolve(filePath)))) {
727
+ const folderConfig = await this.loadFolderConfig(dir);
681
728
  if (folderConfig?.defaults) {
682
729
  defaults = { ...defaults, ...folderConfig.defaults };
683
730
  }
@@ -687,17 +734,126 @@ export class SyncEngine {
687
734
  const baseDir = path.dirname(filePath);
688
735
  for (const [field, value] of Object.entries(defaults)) {
689
736
  try {
690
- processedDefaults[field] = await this.ProcessFieldValue(value, baseDir, null, null, 0);
737
+ const envCollector = this.envOnlyCollector(resolutionCollector);
738
+ processedDefaults[field] = await this.ProcessFieldValue(value, baseDir, null, null, 0, undefined, envCollector, field);
739
+ this.mergeEnvFields(resolutionCollector, envCollector);
691
740
  }
692
741
  catch (error) {
742
+ // A default whose optional variable is not set is left out
743
+ if (error instanceof OptionalEnvMissingError) {
744
+ skippedOptional?.set(field, error.VariableName);
745
+ continue;
746
+ }
693
747
  throw new Error(`Failed to process default for field '${field}': ${error}`);
694
748
  }
695
749
  }
696
750
  return processedDefaults;
697
751
  }
698
752
  /** @deprecated Use {@link BuildDefaults}. */
699
- async buildDefaults(filePath, entityConfig) {
700
- return this.BuildDefaults(filePath, entityConfig);
753
+ async buildDefaults(filePath, entityConfig, resolutionCollector, skippedOptional) {
754
+ return this.BuildDefaults(filePath, entityConfig, resolutionCollector, skippedOptional);
755
+ }
756
+ /**
757
+ * The folders from the sync root (the nearest folder at or above `startDir` whose
758
+ * `.mj-sync.json` has no `entity`) down to `startDir`. Without a sync root, the chain
759
+ * starts at the file system root.
760
+ * The walk passes through folders without a `.mj-sync.json`, because record sub-folders have none (unlike `FindSyncRootDir`, which finds the settings root).
761
+ */
762
+ async folderChainFor(startDir) {
763
+ const chain = [];
764
+ let current = startDir;
765
+ for (;;) {
766
+ chain.push(current);
767
+ if (await this.isSyncRoot(current)) {
768
+ break;
769
+ }
770
+ const parent = path.dirname(current);
771
+ if (parent === current) {
772
+ break;
773
+ }
774
+ current = parent;
775
+ }
776
+ return chain.reverse();
777
+ }
778
+ /** True when `dir` has a readable `.mj-sync.json` with no (or an empty) `entity`. */
779
+ async isSyncRoot(dir) {
780
+ const configPath = path.join(dir, '.mj-sync.json');
781
+ if (!(await fs.pathExists(configPath))) {
782
+ return false;
783
+ }
784
+ try {
785
+ const config = await fs.readJson(configPath);
786
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
787
+ return false;
788
+ }
789
+ return !('entity' in config) || !config.entity;
790
+ }
791
+ catch {
792
+ return false;
793
+ }
794
+ }
795
+ /**
796
+ * A scratch collector that records `@env:` fields into its own map and drops notes, or undefined when the
797
+ * caller's collector has no map. {@link mergeEnvFields} copies its map into the caller's map on success.
798
+ */
799
+ envOnlyCollector(resolutionCollector) {
800
+ if (!resolutionCollector?.EnvFields) {
801
+ return undefined;
802
+ }
803
+ return { notes: [], fieldPrefix: resolutionCollector.fieldPrefix, EnvFields: new Map() };
804
+ }
805
+ /** Adds the variable names of a scratch collector to the caller's `EnvFields` map. */
806
+ mergeEnvFields(target, scratch) {
807
+ if (!target?.EnvFields || !scratch?.EnvFields) {
808
+ return;
809
+ }
810
+ for (const [field, variables] of scratch.EnvFields) {
811
+ const merged = target.EnvFields.get(field) ?? new Set();
812
+ variables.forEach(variable => merged.add(variable));
813
+ target.EnvFields.set(field, merged);
814
+ }
815
+ }
816
+ /**
817
+ * Finds the resolved variables whose current value is in a value copied by `@parent:` or `@owner:`, or returned by `@lookup:`
818
+ * (trimmed, case-insensitive): the whole value, or for values of 6 or more characters any part of it,
819
+ * plain or JSON-escaped. Records them under `fieldName` when the collector has an `EnvFields` map.
820
+ * @returns The matching variable names, sorted
821
+ */
822
+ recordCopiedEnvValue(copiedValue, resolutionCollector, fieldName) {
823
+ if (copiedValue === null || copiedValue === undefined) {
824
+ return [];
825
+ }
826
+ const copied = (typeof copiedValue === 'object' ? JSON.stringify(copiedValue) : String(copiedValue)).trim().toLowerCase();
827
+ if (copied === '') {
828
+ return [];
829
+ }
830
+ const names = [];
831
+ for (const name of this.ResolvedEnvVariables) {
832
+ const envValue = process.env[name];
833
+ if (envValue === undefined || envValue.trim() === '') {
834
+ continue;
835
+ }
836
+ if (this.holdsCopiedEnvValue(copied, envValue.trim().toLowerCase())) {
837
+ names.push(name);
838
+ }
839
+ }
840
+ names.sort();
841
+ if (names.length > 0 && resolutionCollector?.EnvFields && fieldName) {
842
+ const variables = resolutionCollector.EnvFields.get(fieldName) ?? new Set();
843
+ names.forEach(name => variables.add(name));
844
+ resolutionCollector.EnvFields.set(fieldName, variables);
845
+ }
846
+ return names;
847
+ }
848
+ /** True when the copied text is the variable value, or holds a value of at least `MIN_MASKED_VALUE_LENGTH` characters. */
849
+ holdsCopiedEnvValue(copied, envValue) {
850
+ if (copied === envValue) {
851
+ return true;
852
+ }
853
+ if (envValue.length < MIN_MASKED_VALUE_LENGTH) {
854
+ return false;
855
+ }
856
+ return copied.includes(envValue) || copied.includes(JSON.stringify(envValue).slice(1, -1));
701
857
  }
702
858
  /**
703
859
  * Load folder configuration from .mj-folder.json file
@@ -989,8 +1145,24 @@ export class SyncEngine {
989
1145
  }
990
1146
  return cached;
991
1147
  }
992
- return null;
1148
+ // A cache miss is NOT proof the row is absent. The preload cache learns about rows saved
1149
+ // during this push only through BaseEngine's save-event bus, and inside an open transaction
1150
+ // that mutation is deferred to commit (rollback-safe engine caches) — and the default push is
1151
+ // one transaction. So a record created earlier in this same push (e.g. by another directory)
1152
+ // is missing here until commit. Confirm against the database on the push's own provider,
1153
+ // which shares the transaction and sees its uncommitted rows. Don't cache the hit: a delegated
1154
+ // slot is a donor engine's live array, and an uncommitted row placed there would outlive a
1155
+ // rollback. The deferred event mutation populates the cache once the transaction commits.
1156
+ return this.loadFromDatabase(entityName, entityInfo, primaryKey, recordProvider, false);
993
1157
  }
1158
+ return this.loadFromDatabase(entityName, entityInfo, primaryKey, recordProvider, true);
1159
+ }
1160
+ /**
1161
+ * Loads a record by primary key straight from the database, using the record's provider when one
1162
+ * is passed so the read joins that provider's transaction. Returns `null` when no row exists.
1163
+ * `cacheResult` controls whether a hit is added to the preload cache.
1164
+ */
1165
+ async loadFromDatabase(entityName, entityInfo, primaryKey, recordProvider, cacheResult) {
994
1166
  // First, check if the record exists using RunView to avoid "Error in BaseEntity.Load" messages
995
1167
  // when records don't exist (which is a normal scenario during sync operations).
996
1168
  // Use the graph provider when one is passed so an update does not replace a
@@ -1026,7 +1198,7 @@ export class SyncEngine {
1026
1198
  compositeKey.LoadFromSimpleObject(primaryKey);
1027
1199
  const loaded = await entity.InnerLoad(compositeKey);
1028
1200
  const loadedEntity = loaded ? entity : null;
1029
- if (loadedEntity && this.syncMetadataEngine) {
1201
+ if (loadedEntity && cacheResult && this.syncMetadataEngine) {
1030
1202
  this.syncMetadataEngine.addEntityToCache(entityName, loadedEntity);
1031
1203
  }
1032
1204
  return loadedEntity;
@@ -1120,7 +1292,7 @@ export class SyncEngine {
1120
1292
  * @returns Promise resolving to processed JSON object
1121
1293
  * @private
1122
1294
  */
1123
- async processJsonFieldValues(obj, baseDir, parentRecord, rootRecord, depth = 0, batchContext, recordProvider, ownerRecord) {
1295
+ async processJsonFieldValues(obj, baseDir, parentRecord, rootRecord, depth = 0, batchContext, recordProvider, ownerRecord, envCollector, envFieldName) {
1124
1296
  // Handle null and undefined
1125
1297
  if (obj === null || obj === undefined) {
1126
1298
  return obj;
@@ -1128,13 +1300,13 @@ export class SyncEngine {
1128
1300
  // Handle top-level strings (important for array elements that are strings with @ syntax)
1129
1301
  if (typeof obj === 'string') {
1130
1302
  if (IsMetadataKeyword(obj)) {
1131
- return this.ProcessFieldValue(obj, baseDir, parentRecord, rootRecord, depth, batchContext, undefined, undefined, recordProvider, ownerRecord);
1303
+ return this.ProcessFieldValue(obj, baseDir, parentRecord, rootRecord, depth, batchContext, envCollector, envFieldName, recordProvider, ownerRecord);
1132
1304
  }
1133
1305
  return obj;
1134
1306
  }
1135
1307
  // Handle arrays
1136
1308
  if (Array.isArray(obj)) {
1137
- return Promise.all(obj.map(item => this.processJsonFieldValues(item, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord)));
1309
+ return Promise.all(obj.map(item => this.processJsonFieldValues(item, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord, envCollector, envFieldName)));
1138
1310
  }
1139
1311
  // Handle objects
1140
1312
  if (typeof obj === 'object') {
@@ -1145,7 +1317,7 @@ export class SyncEngine {
1145
1317
  // Check if this looks like a reference that needs processing
1146
1318
  // Only process known reference types, ignore other @ strings (like npm packages)
1147
1319
  if (IsMetadataKeyword(value)) {
1148
- result[key] = await this.ProcessFieldValue(value, baseDir, parentRecord, rootRecord, depth, batchContext, undefined, undefined, recordProvider, ownerRecord);
1320
+ result[key] = await this.ProcessFieldValue(value, baseDir, parentRecord, rootRecord, depth, batchContext, envCollector, envFieldName, recordProvider, ownerRecord);
1149
1321
  }
1150
1322
  else {
1151
1323
  result[key] = value;
@@ -1153,7 +1325,7 @@ export class SyncEngine {
1153
1325
  }
1154
1326
  else if (typeof value === 'object') {
1155
1327
  // Recursively process nested objects
1156
- result[key] = await this.processJsonFieldValues(value, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord);
1328
+ result[key] = await this.processJsonFieldValues(value, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord, envCollector, envFieldName);
1157
1329
  }
1158
1330
  else {
1159
1331
  // Keep primitive values as-is