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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +120 -52
  2. package/dist/config.d.ts +18 -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 +34 -3
  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 +232 -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/lookup-sql.d.ts +108 -0
  64. package/dist/lib/lookup-sql.d.ts.map +1 -0
  65. package/dist/lib/lookup-sql.js +231 -0
  66. package/dist/lib/lookup-sql.js.map +1 -0
  67. package/dist/lib/natural-key-preflight.d.ts +86 -0
  68. package/dist/lib/natural-key-preflight.d.ts.map +1 -0
  69. package/dist/lib/natural-key-preflight.js +173 -0
  70. package/dist/lib/natural-key-preflight.js.map +1 -0
  71. package/dist/lib/provider-utils.d.ts.map +1 -1
  72. package/dist/lib/provider-utils.js +2 -4
  73. package/dist/lib/provider-utils.js.map +1 -1
  74. package/dist/lib/pull-option-overrides.d.ts +24 -0
  75. package/dist/lib/pull-option-overrides.d.ts.map +1 -0
  76. package/dist/lib/pull-option-overrides.js +39 -0
  77. package/dist/lib/pull-option-overrides.js.map +1 -0
  78. package/dist/lib/push-directories.d.ts +42 -0
  79. package/dist/lib/push-directories.d.ts.map +1 -0
  80. package/dist/lib/push-directories.js +71 -0
  81. package/dist/lib/push-directories.js.map +1 -0
  82. package/dist/lib/push-when.d.ts +38 -0
  83. package/dist/lib/push-when.d.ts.map +1 -0
  84. package/dist/lib/push-when.js +84 -0
  85. package/dist/lib/push-when.js.map +1 -0
  86. package/dist/lib/record-dependency-analyzer.d.ts +11 -2
  87. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  88. package/dist/lib/record-dependency-analyzer.js +48 -71
  89. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  90. package/dist/lib/record-file-glob.d.ts +11 -0
  91. package/dist/lib/record-file-glob.d.ts.map +1 -0
  92. package/dist/lib/record-file-glob.js +10 -0
  93. package/dist/lib/record-file-glob.js.map +1 -0
  94. package/dist/lib/reference-parser.d.ts +3 -2
  95. package/dist/lib/reference-parser.d.ts.map +1 -1
  96. package/dist/lib/reference-parser.js +27 -27
  97. package/dist/lib/reference-parser.js.map +1 -1
  98. package/dist/lib/schema-placeholders.d.ts +29 -0
  99. package/dist/lib/schema-placeholders.d.ts.map +1 -0
  100. package/dist/lib/schema-placeholders.js +72 -0
  101. package/dist/lib/schema-placeholders.js.map +1 -0
  102. package/dist/lib/sql-logger.d.ts +9 -1
  103. package/dist/lib/sql-logger.d.ts.map +1 -1
  104. package/dist/lib/sql-logger.js +12 -7
  105. package/dist/lib/sql-logger.js.map +1 -1
  106. package/dist/lib/sql-set-line.d.ts +26 -0
  107. package/dist/lib/sql-set-line.d.ts.map +1 -0
  108. package/dist/lib/sql-set-line.js +84 -0
  109. package/dist/lib/sql-set-line.js.map +1 -0
  110. package/dist/lib/sync-engine.d.ts +58 -4
  111. package/dist/lib/sync-engine.d.ts.map +1 -1
  112. package/dist/lib/sync-engine.js +243 -59
  113. package/dist/lib/sync-engine.js.map +1 -1
  114. package/dist/lib/sync-state-manager.d.ts +7 -3
  115. package/dist/lib/sync-state-manager.d.ts.map +1 -1
  116. package/dist/lib/sync-state-manager.js +19 -4
  117. package/dist/lib/sync-state-manager.js.map +1 -1
  118. package/dist/lib/watch-file-filter.d.ts +31 -0
  119. package/dist/lib/watch-file-filter.d.ts.map +1 -0
  120. package/dist/lib/watch-file-filter.js +64 -0
  121. package/dist/lib/watch-file-filter.js.map +1 -0
  122. package/dist/plugins/index.d.ts.map +1 -1
  123. package/dist/plugins/index.js +44 -14
  124. package/dist/plugins/index.js.map +1 -1
  125. package/dist/services/PullService.d.ts +41 -1
  126. package/dist/services/PullService.d.ts.map +1 -1
  127. package/dist/services/PullService.js +261 -101
  128. package/dist/services/PullService.js.map +1 -1
  129. package/dist/services/PushService.d.ts +80 -0
  130. package/dist/services/PushService.d.ts.map +1 -1
  131. package/dist/services/PushService.js +531 -189
  132. package/dist/services/PushService.js.map +1 -1
  133. package/dist/services/ValidationService.d.ts +43 -8
  134. package/dist/services/ValidationService.d.ts.map +1 -1
  135. package/dist/services/ValidationService.js +338 -127
  136. package/dist/services/ValidationService.js.map +1 -1
  137. package/dist/services/WatchService.d.ts +28 -0
  138. package/dist/services/WatchService.d.ts.map +1 -1
  139. package/dist/services/WatchService.js +226 -74
  140. package/dist/services/WatchService.js.map +1 -1
  141. package/dist/types/validation.d.ts +6 -0
  142. package/dist/types/validation.d.ts.map +1 -1
  143. 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,12 @@ 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;
103
+ /** Runs right before a `@lookup:…?create` lookup saves the record it creates, with the lookup that creates it. */
104
+ this.BeforeLookupCreateSaveSource = null;
96
105
  /**
97
106
  * Where non-fatal warnings raised inside the engine go (the BaseEntity-fallback notice on the
98
107
  * lookup auto-create path, for one). PushService points this at its own warnings list so the
@@ -128,6 +137,7 @@ export class SyncEngine {
128
137
  * - `@url:address` - Fetches content from a URL
129
138
  * - `@lookup:Entity.Field=Value` - Looks up an entity ID by field value
130
139
  * - `@env:VARIABLE` - Reads an environment variable
140
+ * - `@env:VARIABLE?optional` - Like `@env:`, but a missing variable throws {@link OptionalEnvMissingError} so the caller can leave the field out
131
141
  *
132
142
  * @param value - The field value to process
133
143
  * @param baseDir - Base directory for resolving relative file paths
@@ -170,7 +180,9 @@ export class SyncEngine {
170
180
  // Check if it's an array or a plain object (not a Date, etc.)
171
181
  if (Array.isArray(value) || value.constructor === Object) {
172
182
  // 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);
183
+ const envCollector = this.envOnlyCollector(resolutionCollector);
184
+ const processedValue = await this.processJsonFieldValues(value, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord, envCollector, fieldName);
185
+ this.mergeEnvFields(resolutionCollector, envCollector);
174
186
  // Then convert to pretty-printed JSON string for inline metadata objects
175
187
  // Objects from @file references will be handled by BaseEntity during save
176
188
  return JSON.stringify(processedValue, null, 2);
@@ -196,13 +208,14 @@ export class SyncEngine {
196
208
  const ownerName = ownerRecord.EntityInfo?.Name || 'unknown';
197
209
  throw new Error(`@owner:${ownerFieldName} resolved to undefined on owner ${ownerName}. Field does not exist or has undefined value.`);
198
210
  }
211
+ const sourceVariables = this.recordCopiedEnvValue(resolvedValue, resolutionCollector, fieldName);
199
212
  // Track the resolution if collector is provided
200
213
  if (resolutionCollector && fieldName) {
201
214
  resolutionCollector.notes.push({
202
215
  type: 'parent',
203
216
  field: `${resolutionCollector.fieldPrefix}.${fieldName}`,
204
217
  expression: value,
205
- resolved: String(resolvedValue)
218
+ resolved: sourceVariables.length > 0 ? EnvSourceLabel(sourceVariables) : String(resolvedValue)
206
219
  });
207
220
  }
208
221
  return resolvedValue;
@@ -214,13 +227,14 @@ export class SyncEngine {
214
227
  }
215
228
  const parentFieldName = ExtractKeywordValue(value) || '';
216
229
  const resolvedValue = parentRecord.Get(parentFieldName);
230
+ const sourceVariables = this.recordCopiedEnvValue(resolvedValue, resolutionCollector, fieldName);
217
231
  // Track the resolution if collector is provided
218
232
  if (resolutionCollector && fieldName) {
219
233
  resolutionCollector.notes.push({
220
234
  type: 'parent',
221
235
  field: `${resolutionCollector.fieldPrefix}.${fieldName}`,
222
236
  expression: value,
223
- resolved: String(resolvedValue)
237
+ resolved: sourceVariables.length > 0 ? EnvSourceLabel(sourceVariables) : String(resolvedValue)
224
238
  });
225
239
  }
226
240
  return resolvedValue;
@@ -257,7 +271,9 @@ export class SyncEngine {
257
271
  }
258
272
  // Now recursively process any @file references within the JSON
259
273
  const fileDir = path.dirname(fullPath);
260
- processedJson = await this.processJsonFieldValues(processedJson, fileDir, parentRecord, rootRecord, depth + 1, batchContext, recordProvider);
274
+ const envCollector = this.envOnlyCollector(resolutionCollector);
275
+ processedJson = await this.processJsonFieldValues(processedJson, fileDir, parentRecord, rootRecord, depth + 1, batchContext, recordProvider, undefined, envCollector, fieldName);
276
+ this.mergeEnvFields(resolutionCollector, envCollector);
261
277
  // Return the processed JSON object directly without stringifying
262
278
  // Let BaseEntity handle serialization when saving to database
263
279
  // This ensures proper escaping of embedded code/scripts
@@ -299,25 +315,28 @@ export class SyncEngine {
299
315
  const lookupStr = ExtractKeywordValue(value);
300
316
  // Parse lookup with optional flags: ?create, ?allowDefer
301
317
  // Format: EntityName.Field1=Value1&Field2=Value2?create&allowDefer&OtherField=Value
302
- const entityMatch = lookupStr.match(/^([^.]+)\./);
303
- if (!entityMatch) {
318
+ const split = SplitLookupReference(lookupStr);
319
+ if (!split) {
304
320
  throw new Error(`Invalid lookup format: ${value}`);
305
321
  }
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');
322
+ if (split.UnknownBareFlags.includes('optional')) {
323
+ throw new Error(`Invalid @lookup reference: ${value}. ${LOOKUP_OPTIONAL_MESSAGE}`);
324
+ }
325
+ const entityName = split.EntityName;
326
+ const lookupPart = split.CriteriaText;
327
+ const flagsPart = split.FlagsText;
328
+ const hasCreate = split.FlagNames.has('create');
329
+ const allowDefer = split.FlagNames.has('allowdefer');
316
330
  // Parse all lookup fields (can be multiple with &)
317
331
  const lookupFields = [];
318
332
  const lookupPairs = lookupPart.split('&');
319
333
  // Collector for nested resolutions within this lookup
320
334
  const nestedResolutions = [];
335
+ // What the lookup matched on, so the SQL log can match on it too
336
+ const sqlCriteria = [];
337
+ let criteriaFromEnv = false;
338
+ // Criteria and create fields whose values came from @env:, for the lookup messages
339
+ const lookupEnvFields = new Map();
321
340
  for (const pair of lookupPairs) {
322
341
  const fieldMatch = pair.match(/^(.+?)=(.+)$/);
323
342
  if (!fieldMatch) {
@@ -326,15 +345,19 @@ export class SyncEngine {
326
345
  const [, lookupFieldName, fieldValue] = fieldMatch;
327
346
  const rawFieldValue = fieldValue.trim();
328
347
  // Recursively process the field value to resolve any nested @ commands
329
- // Create a temporary collector to capture nested resolutions
330
- const nestedCollector = { notes: [], fieldPrefix: '' };
348
+ // Create a temporary collector to capture nested resolutions and env-sourced criteria
349
+ const nestedCollector = { notes: [], fieldPrefix: '', EnvFields: new Map(), LookupSources: new Map() };
331
350
  const processedValue = await this.ProcessFieldValue(rawFieldValue, baseDir, parentRecord, rootRecord, depth + 1, batchContext, nestedCollector, lookupFieldName, // Pass field name for tracking
332
351
  recordProvider);
352
+ const criteriaVariables = this.addLookupEnvField(lookupEnvFields, lookupFieldName.trim(), nestedCollector);
353
+ const nestedSource = nestedCollector.LookupSources?.get(lookupFieldName);
354
+ criteriaFromEnv = criteriaFromEnv || Boolean(criteriaVariables) || nestedSource?.EnvSourced === true;
355
+ sqlCriteria.push({ FieldName: lookupFieldName.trim(), Value: nestedSource ?? String(processedValue) });
333
356
  // If the raw value was a lookup expression that got resolved, track it as nested
334
357
  if (rawFieldValue.startsWith(METADATA_KEYWORDS.LOOKUP) && rawFieldValue !== processedValue) {
335
358
  nestedResolutions.push({
336
359
  expression: rawFieldValue,
337
- resolved: String(processedValue)
360
+ resolved: criteriaVariables ? EnvSourceLabel(criteriaVariables) : String(processedValue)
338
361
  });
339
362
  }
340
363
  lookupFields.push({ fieldName: lookupFieldName.trim(), fieldValue: processedValue });
@@ -346,28 +369,26 @@ export class SyncEngine {
346
369
  // These are key=value pairs after the flags (e.g., ?create&Description=Some%20Value)
347
370
  let createFields = {};
348
371
  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
- }
372
+ for (const createField of ParseLookupCreateFields(flagsPart)) {
373
+ // Recursively process the field value to resolve any nested @ commands
374
+ const createCollector = { notes: [], fieldPrefix: '', EnvFields: new Map() };
375
+ createFields[createField.Name] = await this.ProcessFieldValue(createField.Value, baseDir, parentRecord, rootRecord, depth + 1, batchContext, createCollector, createField.Name, recordProvider);
376
+ this.addLookupEnvField(lookupEnvFields, createField.Name, createCollector);
361
377
  }
362
378
  }
363
- const resolvedValue = await this.ResolveLookup(entityName, lookupFields, hasCreate, createFields, batchContext, allowDefer, value, recordProvider);
379
+ const lookupSource = { Expression: value, EntityName: entityName, Criteria: sqlCriteria, EnvSourced: criteriaFromEnv };
380
+ const resolvedValue = await this.ResolveLookup(entityName, lookupFields, hasCreate, createFields, batchContext, allowDefer, value, recordProvider, lookupEnvFields, lookupSource);
381
+ if (fieldName) {
382
+ resolutionCollector?.LookupSources?.set(fieldName, lookupSource);
383
+ }
384
+ const sourceVariables = this.recordCopiedEnvValue(resolvedValue, resolutionCollector, fieldName);
364
385
  // Track the resolution if collector is provided
365
386
  if (resolutionCollector && fieldName) {
366
387
  const note = {
367
388
  type: 'lookup',
368
389
  field: `${resolutionCollector.fieldPrefix}.${fieldName}`,
369
390
  expression: value,
370
- resolved: String(resolvedValue)
391
+ resolved: sourceVariables.length > 0 ? EnvSourceLabel(sourceVariables) : String(resolvedValue)
371
392
  };
372
393
  // Include nested resolutions if any were captured
373
394
  if (nestedResolutions.length > 0) {
@@ -379,10 +400,23 @@ export class SyncEngine {
379
400
  }
380
401
  // Check for @env: reference
381
402
  if (value.startsWith(METADATA_KEYWORDS.ENV)) {
382
- const envVar = ExtractKeywordValue(value);
383
- const envValue = process.env[envVar];
403
+ const reference = ParseEnvReference(value);
404
+ if (!reference) {
405
+ throw new Error(`Invalid @env reference: ${value}`);
406
+ }
407
+ const envValue = process.env[reference.VariableName];
384
408
  if (envValue === undefined) {
385
- throw new Error(`Environment variable not found: ${envVar}`);
409
+ if (reference.Optional) {
410
+ throw new OptionalEnvMissingError(reference.VariableName);
411
+ }
412
+ const field = resolutionCollector?.fieldPrefix && fieldName ? `${resolutionCollector.fieldPrefix}.${fieldName}` : fieldName;
413
+ throw new EnvVariableNotSetError(reference.VariableName, field);
414
+ }
415
+ this.ResolvedEnvVariables.add(reference.VariableName);
416
+ if (resolutionCollector?.EnvFields && fieldName) {
417
+ const variables = resolutionCollector.EnvFields.get(fieldName) ?? new Set();
418
+ variables.add(reference.VariableName);
419
+ resolutionCollector.EnvFields.set(fieldName, variables);
386
420
  }
387
421
  return envValue;
388
422
  }
@@ -443,7 +477,9 @@ export class SyncEngine {
443
477
  });
444
478
  return `${encodeURIComponent(entityName.toLowerCase())}|${parts.join('&')}`;
445
479
  }
446
- async ResolveLookup(entityName, lookupFields, autoCreate = false, createFields = {}, batchContext, allowDefer = false, originalValue, recordProvider) {
480
+ async ResolveLookup(entityName, lookupFields, autoCreate = false, createFields = {}, batchContext, allowDefer = false, originalValue, recordProvider, envFields, lookupSource) {
481
+ // Criteria and create fields whose values came from @env: print as labels
482
+ const envMask = new EnvDisplayMask(envFields);
447
483
  const lookupCacheKey = this.buildLookupCacheKey(entityName, lookupFields);
448
484
  if (this.syncMetadataEngine) {
449
485
  const cachedId = this.syncMetadataEngine.getCachedLookup(lookupCacheKey);
@@ -624,16 +660,21 @@ export class SyncEngine {
624
660
  }
625
661
  }
626
662
  // 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}`);
663
+ console.log(`📝 Auto-creating ${entityName} record where ${this.describeLookupFilter(lookupFields, envMask)}`);
664
+ if (envFields) {
665
+ this.BeforeLookupCreateSave?.(newEntity, envFields);
666
+ }
667
+ if (lookupSource) {
668
+ this.BeforeLookupCreateSaveSource?.(newEntity, lookupSource);
669
+ }
629
670
  const saved = await newEntity.Save();
630
671
  if (!saved) {
631
672
  const message = newEntity.LatestResult?.Message;
632
673
  if (message) {
633
- throw new Error(`Failed to auto-create ${entityName}: ${message}`);
674
+ throw new Error(`Failed to auto-create ${entityName}: ${envMask.MaskText(message)}`);
634
675
  }
635
676
  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}`);
677
+ throw new Error(`Failed to auto-create ${entityName}: ${envMask.MaskText(errors)}`);
637
678
  }
638
679
  // Return the new ID
639
680
  if (entityInfo.PrimaryKeys.length > 0) {
@@ -647,16 +688,38 @@ export class SyncEngine {
647
688
  }
648
689
  }
649
690
  const filterDesc = lookupFields.map(({ fieldName, fieldValue }) => `${fieldName}='${fieldValue}'`).join(' AND ');
650
- const errorMessage = `Lookup failed: No record found in '${entityName}' where ${filterDesc}`;
691
+ const errorMessage = `Lookup failed: No record found in '${entityName}' where ${this.describeLookupFilter(lookupFields, envMask)}`;
651
692
  // If allowDefer is true, throw DeferrableLookupError so caller can queue for retry
652
693
  if (allowDefer) {
653
694
  throw new DeferrableLookupError(errorMessage, entityName, lookupFields, originalValue || `@lookup:${entityName}.${filterDesc}`);
654
695
  }
655
696
  throw new Error(errorMessage);
656
697
  }
698
+ /** `Field='value' AND …` for messages; env-sourced criteria print as `Field=<from @env:VAR>`. */
699
+ describeLookupFilter(lookupFields, envMask) {
700
+ return lookupFields
701
+ .map(({ fieldName, fieldValue }) => `${fieldName}=${envMask.NewValue(fieldName, `'${fieldValue}'`)}`)
702
+ .join(' AND ');
703
+ }
704
+ /**
705
+ * Adds the variable names a lookup criterion or create field recorded in its scratch collector to the
706
+ * lookup's map. `@env:` values and `@parent:`/`@lookup:` results equal to an env value are recorded.
707
+ * @returns The field's variable names in the lookup's map, or undefined when it is not env-sourced
708
+ */
709
+ addLookupEnvField(lookupEnvFields, lookupFieldName, scratch) {
710
+ const recorded = new Set();
711
+ scratch.EnvFields?.forEach(names => names.forEach(name => recorded.add(name)));
712
+ if (recorded.size === 0) {
713
+ return undefined;
714
+ }
715
+ const merged = lookupEnvFields.get(lookupFieldName) ?? new Set();
716
+ recorded.forEach(name => merged.add(name));
717
+ lookupEnvFields.set(lookupFieldName, merged);
718
+ return merged;
719
+ }
657
720
  /** @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);
721
+ async resolveLookup(entityName, lookupFields, autoCreate = false, createFields = {}, batchContext, allowDefer = false, originalValue, recordProvider, envFields, lookupSource) {
722
+ return this.ResolveLookup(entityName, lookupFields, autoCreate, createFields, batchContext, allowDefer, originalValue, recordProvider, envFields, lookupSource);
660
723
  }
661
724
  /**
662
725
  * Build cascading defaults for a file path and process field values
@@ -667,17 +730,16 @@ export class SyncEngine {
667
730
  *
668
731
  * @param filePath - Path to the file being processed
669
732
  * @param entityConfig - Entity configuration containing base defaults
733
+ * @param resolutionCollector - Optional collector; when it has an `EnvFields` map, env-sourced defaults are recorded in it (no notes are added)
734
+ * @param skippedOptional - Optional map; receives field → variable for each default left out because its `?optional` variable is not set
670
735
  * @returns Processed defaults with all references resolved
671
736
  * @throws Error if any default value processing fails
672
737
  */
673
- async BuildDefaults(filePath, entityConfig) {
674
- const parts = path.dirname(filePath).split(path.sep);
738
+ async BuildDefaults(filePath, entityConfig, resolutionCollector, skippedOptional) {
675
739
  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);
740
+ // Folder defaults from the sync root down to the file's folder; deeper folders win
741
+ for (const dir of await this.folderChainFor(path.dirname(path.resolve(filePath)))) {
742
+ const folderConfig = await this.loadFolderConfig(dir);
681
743
  if (folderConfig?.defaults) {
682
744
  defaults = { ...defaults, ...folderConfig.defaults };
683
745
  }
@@ -687,17 +749,126 @@ export class SyncEngine {
687
749
  const baseDir = path.dirname(filePath);
688
750
  for (const [field, value] of Object.entries(defaults)) {
689
751
  try {
690
- processedDefaults[field] = await this.ProcessFieldValue(value, baseDir, null, null, 0);
752
+ const envCollector = this.envOnlyCollector(resolutionCollector);
753
+ processedDefaults[field] = await this.ProcessFieldValue(value, baseDir, null, null, 0, undefined, envCollector, field);
754
+ this.mergeEnvFields(resolutionCollector, envCollector);
691
755
  }
692
756
  catch (error) {
757
+ // A default whose optional variable is not set is left out
758
+ if (error instanceof OptionalEnvMissingError) {
759
+ skippedOptional?.set(field, error.VariableName);
760
+ continue;
761
+ }
693
762
  throw new Error(`Failed to process default for field '${field}': ${error}`);
694
763
  }
695
764
  }
696
765
  return processedDefaults;
697
766
  }
698
767
  /** @deprecated Use {@link BuildDefaults}. */
699
- async buildDefaults(filePath, entityConfig) {
700
- return this.BuildDefaults(filePath, entityConfig);
768
+ async buildDefaults(filePath, entityConfig, resolutionCollector, skippedOptional) {
769
+ return this.BuildDefaults(filePath, entityConfig, resolutionCollector, skippedOptional);
770
+ }
771
+ /**
772
+ * The folders from the sync root (the nearest folder at or above `startDir` whose
773
+ * `.mj-sync.json` has no `entity`) down to `startDir`. Without a sync root, the chain
774
+ * starts at the file system root.
775
+ * The walk passes through folders without a `.mj-sync.json`, because record sub-folders have none (unlike `FindSyncRootDir`, which finds the settings root).
776
+ */
777
+ async folderChainFor(startDir) {
778
+ const chain = [];
779
+ let current = startDir;
780
+ for (;;) {
781
+ chain.push(current);
782
+ if (await this.isSyncRoot(current)) {
783
+ break;
784
+ }
785
+ const parent = path.dirname(current);
786
+ if (parent === current) {
787
+ break;
788
+ }
789
+ current = parent;
790
+ }
791
+ return chain.reverse();
792
+ }
793
+ /** True when `dir` has a readable `.mj-sync.json` with no (or an empty) `entity`. */
794
+ async isSyncRoot(dir) {
795
+ const configPath = path.join(dir, '.mj-sync.json');
796
+ if (!(await fs.pathExists(configPath))) {
797
+ return false;
798
+ }
799
+ try {
800
+ const config = await fs.readJson(configPath);
801
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
802
+ return false;
803
+ }
804
+ return !('entity' in config) || !config.entity;
805
+ }
806
+ catch {
807
+ return false;
808
+ }
809
+ }
810
+ /**
811
+ * A scratch collector that records `@env:` fields into its own map and drops notes, or undefined when the
812
+ * caller's collector has no map. {@link mergeEnvFields} copies its map into the caller's map on success.
813
+ */
814
+ envOnlyCollector(resolutionCollector) {
815
+ if (!resolutionCollector?.EnvFields) {
816
+ return undefined;
817
+ }
818
+ return { notes: [], fieldPrefix: resolutionCollector.fieldPrefix, EnvFields: new Map() };
819
+ }
820
+ /** Adds the variable names of a scratch collector to the caller's `EnvFields` map. */
821
+ mergeEnvFields(target, scratch) {
822
+ if (!target?.EnvFields || !scratch?.EnvFields) {
823
+ return;
824
+ }
825
+ for (const [field, variables] of scratch.EnvFields) {
826
+ const merged = target.EnvFields.get(field) ?? new Set();
827
+ variables.forEach(variable => merged.add(variable));
828
+ target.EnvFields.set(field, merged);
829
+ }
830
+ }
831
+ /**
832
+ * Finds the resolved variables whose current value is in a value copied by `@parent:` or `@owner:`, or returned by `@lookup:`
833
+ * (trimmed, case-insensitive): the whole value, or for values of 6 or more characters any part of it,
834
+ * plain or JSON-escaped. Records them under `fieldName` when the collector has an `EnvFields` map.
835
+ * @returns The matching variable names, sorted
836
+ */
837
+ recordCopiedEnvValue(copiedValue, resolutionCollector, fieldName) {
838
+ if (copiedValue === null || copiedValue === undefined) {
839
+ return [];
840
+ }
841
+ const copied = (typeof copiedValue === 'object' ? JSON.stringify(copiedValue) : String(copiedValue)).trim().toLowerCase();
842
+ if (copied === '') {
843
+ return [];
844
+ }
845
+ const names = [];
846
+ for (const name of this.ResolvedEnvVariables) {
847
+ const envValue = process.env[name];
848
+ if (envValue === undefined || envValue.trim() === '') {
849
+ continue;
850
+ }
851
+ if (this.holdsCopiedEnvValue(copied, envValue.trim().toLowerCase())) {
852
+ names.push(name);
853
+ }
854
+ }
855
+ names.sort();
856
+ if (names.length > 0 && resolutionCollector?.EnvFields && fieldName) {
857
+ const variables = resolutionCollector.EnvFields.get(fieldName) ?? new Set();
858
+ names.forEach(name => variables.add(name));
859
+ resolutionCollector.EnvFields.set(fieldName, variables);
860
+ }
861
+ return names;
862
+ }
863
+ /** True when the copied text is the variable value, or holds a value of at least `MIN_MASKED_VALUE_LENGTH` characters. */
864
+ holdsCopiedEnvValue(copied, envValue) {
865
+ if (copied === envValue) {
866
+ return true;
867
+ }
868
+ if (envValue.length < MIN_MASKED_VALUE_LENGTH) {
869
+ return false;
870
+ }
871
+ return copied.includes(envValue) || copied.includes(JSON.stringify(envValue).slice(1, -1));
701
872
  }
702
873
  /**
703
874
  * Load folder configuration from .mj-folder.json file
@@ -1047,6 +1218,19 @@ export class SyncEngine {
1047
1218
  }
1048
1219
  return loadedEntity;
1049
1220
  }
1221
+ /**
1222
+ * Loads a record by primary key straight from the database, never from the preload cache, on
1223
+ * `recordProvider` when one is passed (so the read joins that provider's transaction). Returns
1224
+ * `null` when no row exists. A hit is not cached. Used where the cache can be stale inside the
1225
+ * push transaction, such as a row an earlier delete's cascade removed (#4935).
1226
+ */
1227
+ async LoadEntityFromDatabase(entityName, primaryKey, recordProvider) {
1228
+ const entityInfo = this.GetEntityInfo(entityName);
1229
+ if (!entityInfo) {
1230
+ throw new Error(`Entity not found: ${entityName}`);
1231
+ }
1232
+ return this.loadFromDatabase(entityName, entityInfo, primaryKey, recordProvider, false);
1233
+ }
1050
1234
  /** @deprecated Use {@link LoadEntity}. */
1051
1235
  async loadEntity(entityName, primaryKey, recordProvider) {
1052
1236
  return this.LoadEntity(entityName, primaryKey, recordProvider);
@@ -1136,7 +1320,7 @@ export class SyncEngine {
1136
1320
  * @returns Promise resolving to processed JSON object
1137
1321
  * @private
1138
1322
  */
1139
- async processJsonFieldValues(obj, baseDir, parentRecord, rootRecord, depth = 0, batchContext, recordProvider, ownerRecord) {
1323
+ async processJsonFieldValues(obj, baseDir, parentRecord, rootRecord, depth = 0, batchContext, recordProvider, ownerRecord, envCollector, envFieldName) {
1140
1324
  // Handle null and undefined
1141
1325
  if (obj === null || obj === undefined) {
1142
1326
  return obj;
@@ -1144,13 +1328,13 @@ export class SyncEngine {
1144
1328
  // Handle top-level strings (important for array elements that are strings with @ syntax)
1145
1329
  if (typeof obj === 'string') {
1146
1330
  if (IsMetadataKeyword(obj)) {
1147
- return this.ProcessFieldValue(obj, baseDir, parentRecord, rootRecord, depth, batchContext, undefined, undefined, recordProvider, ownerRecord);
1331
+ return this.ProcessFieldValue(obj, baseDir, parentRecord, rootRecord, depth, batchContext, envCollector, envFieldName, recordProvider, ownerRecord);
1148
1332
  }
1149
1333
  return obj;
1150
1334
  }
1151
1335
  // Handle arrays
1152
1336
  if (Array.isArray(obj)) {
1153
- return Promise.all(obj.map(item => this.processJsonFieldValues(item, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord)));
1337
+ return Promise.all(obj.map(item => this.processJsonFieldValues(item, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord, envCollector, envFieldName)));
1154
1338
  }
1155
1339
  // Handle objects
1156
1340
  if (typeof obj === 'object') {
@@ -1161,7 +1345,7 @@ export class SyncEngine {
1161
1345
  // Check if this looks like a reference that needs processing
1162
1346
  // Only process known reference types, ignore other @ strings (like npm packages)
1163
1347
  if (IsMetadataKeyword(value)) {
1164
- result[key] = await this.ProcessFieldValue(value, baseDir, parentRecord, rootRecord, depth, batchContext, undefined, undefined, recordProvider, ownerRecord);
1348
+ result[key] = await this.ProcessFieldValue(value, baseDir, parentRecord, rootRecord, depth, batchContext, envCollector, envFieldName, recordProvider, ownerRecord);
1165
1349
  }
1166
1350
  else {
1167
1351
  result[key] = value;
@@ -1169,7 +1353,7 @@ export class SyncEngine {
1169
1353
  }
1170
1354
  else if (typeof value === 'object') {
1171
1355
  // Recursively process nested objects
1172
- result[key] = await this.processJsonFieldValues(value, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord);
1356
+ result[key] = await this.processJsonFieldValues(value, baseDir, parentRecord, rootRecord, depth, batchContext, recordProvider, ownerRecord, envCollector, envFieldName);
1173
1357
  }
1174
1358
  else {
1175
1359
  // Keep primitive values as-is