@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.
- package/README.md +117 -53
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/RecordProcessor.d.ts +98 -0
- package/dist/lib/RecordProcessor.d.ts.map +1 -1
- package/dist/lib/RecordProcessor.js +424 -22
- package/dist/lib/RecordProcessor.js.map +1 -1
- package/dist/lib/RelatedEntityHandler.d.ts +4 -1
- package/dist/lib/RelatedEntityHandler.d.ts.map +1 -1
- package/dist/lib/RelatedEntityHandler.js +49 -11
- package/dist/lib/RelatedEntityHandler.js.map +1 -1
- package/dist/lib/created-record-key.d.ts +34 -0
- package/dist/lib/created-record-key.d.ts.map +1 -0
- package/dist/lib/created-record-key.js +46 -0
- package/dist/lib/created-record-key.js.map +1 -0
- package/dist/lib/deletion-auditor.d.ts.map +1 -1
- package/dist/lib/deletion-auditor.js +33 -2
- package/dist/lib/deletion-auditor.js.map +1 -1
- package/dist/lib/entity-field-names.d.ts +11 -0
- package/dist/lib/entity-field-names.d.ts.map +1 -0
- package/dist/lib/entity-field-names.js +15 -0
- package/dist/lib/entity-field-names.js.map +1 -0
- package/dist/lib/entity-subclass-guard.d.ts +8 -7
- package/dist/lib/entity-subclass-guard.d.ts.map +1 -1
- package/dist/lib/entity-subclass-guard.js +20 -8
- package/dist/lib/entity-subclass-guard.js.map +1 -1
- package/dist/lib/env-derived-fields.d.ts +23 -0
- package/dist/lib/env-derived-fields.d.ts.map +1 -0
- package/dist/lib/env-derived-fields.js +53 -0
- package/dist/lib/env-derived-fields.js.map +1 -0
- package/dist/lib/env-display.d.ts +54 -0
- package/dist/lib/env-display.d.ts.map +1 -0
- package/dist/lib/env-display.js +156 -0
- package/dist/lib/env-display.js.map +1 -0
- package/dist/lib/env-reference.d.ts +104 -0
- package/dist/lib/env-reference.d.ts.map +1 -0
- package/dist/lib/env-reference.js +241 -0
- package/dist/lib/env-reference.js.map +1 -0
- package/dist/lib/env-sql-redactor.d.ts +74 -0
- package/dist/lib/env-sql-redactor.d.ts.map +1 -0
- package/dist/lib/env-sql-redactor.js +297 -0
- package/dist/lib/env-sql-redactor.js.map +1 -0
- package/dist/lib/existing-record-index.d.ts +70 -0
- package/dist/lib/existing-record-index.d.ts.map +1 -0
- package/dist/lib/existing-record-index.js +158 -0
- package/dist/lib/existing-record-index.js.map +1 -0
- package/dist/lib/ignore-directories.d.ts +10 -0
- package/dist/lib/ignore-directories.d.ts.map +1 -0
- package/dist/lib/ignore-directories.js +12 -0
- package/dist/lib/ignore-directories.js.map +1 -0
- package/dist/lib/json-record-lines.d.ts +17 -0
- package/dist/lib/json-record-lines.d.ts.map +1 -0
- package/dist/lib/json-record-lines.js +170 -0
- package/dist/lib/json-record-lines.js.map +1 -0
- package/dist/lib/json-subproperty-externalization.d.ts.map +1 -1
- package/dist/lib/json-subproperty-externalization.js +3 -1
- package/dist/lib/json-subproperty-externalization.js.map +1 -1
- package/dist/lib/lookup-reference.d.ts +38 -0
- package/dist/lib/lookup-reference.d.ts.map +1 -0
- package/dist/lib/lookup-reference.js +62 -0
- package/dist/lib/lookup-reference.js.map +1 -0
- package/dist/lib/provider-utils.d.ts.map +1 -1
- package/dist/lib/provider-utils.js +2 -4
- package/dist/lib/provider-utils.js.map +1 -1
- package/dist/lib/pull-option-overrides.d.ts +24 -0
- package/dist/lib/pull-option-overrides.d.ts.map +1 -0
- package/dist/lib/pull-option-overrides.js +39 -0
- package/dist/lib/pull-option-overrides.js.map +1 -0
- package/dist/lib/push-directories.d.ts +42 -0
- package/dist/lib/push-directories.d.ts.map +1 -0
- package/dist/lib/push-directories.js +71 -0
- package/dist/lib/push-directories.js.map +1 -0
- package/dist/lib/push-when.d.ts +38 -0
- package/dist/lib/push-when.d.ts.map +1 -0
- package/dist/lib/push-when.js +84 -0
- package/dist/lib/push-when.js.map +1 -0
- package/dist/lib/record-dependency-analyzer.d.ts +2 -0
- package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
- package/dist/lib/record-dependency-analyzer.js.map +1 -1
- package/dist/lib/record-file-glob.d.ts +11 -0
- package/dist/lib/record-file-glob.d.ts.map +1 -0
- package/dist/lib/record-file-glob.js +10 -0
- package/dist/lib/record-file-glob.js.map +1 -0
- package/dist/lib/reference-parser.d.ts +3 -2
- package/dist/lib/reference-parser.d.ts.map +1 -1
- package/dist/lib/reference-parser.js +27 -27
- package/dist/lib/reference-parser.js.map +1 -1
- package/dist/lib/sync-engine.d.ts +52 -4
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +233 -61
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/lib/sync-state-manager.d.ts +7 -3
- package/dist/lib/sync-state-manager.d.ts.map +1 -1
- package/dist/lib/sync-state-manager.js +19 -4
- package/dist/lib/sync-state-manager.js.map +1 -1
- package/dist/lib/watch-file-filter.d.ts +31 -0
- package/dist/lib/watch-file-filter.d.ts.map +1 -0
- package/dist/lib/watch-file-filter.js +64 -0
- package/dist/lib/watch-file-filter.js.map +1 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +55 -23
- package/dist/plugins/index.js.map +1 -1
- package/dist/services/FormattingService.d.ts +14 -7
- package/dist/services/FormattingService.d.ts.map +1 -1
- package/dist/services/FormattingService.js +22 -9
- package/dist/services/FormattingService.js.map +1 -1
- package/dist/services/PullService.d.ts +53 -1
- package/dist/services/PullService.d.ts.map +1 -1
- package/dist/services/PullService.js +323 -120
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +59 -0
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +448 -173
- package/dist/services/PushService.js.map +1 -1
- package/dist/services/ValidationService.d.ts +43 -8
- package/dist/services/ValidationService.d.ts.map +1 -1
- package/dist/services/ValidationService.js +338 -127
- package/dist/services/ValidationService.js.map +1 -1
- package/dist/services/WatchService.d.ts +26 -0
- package/dist/services/WatchService.d.ts.map +1 -1
- package/dist/services/WatchService.js +210 -75
- package/dist/services/WatchService.js.map +1 -1
- package/dist/types/validation.d.ts +6 -0
- package/dist/types/validation.d.ts.map +1 -1
- package/package.json +17 -16
package/dist/lib/sync-engine.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
303
|
-
if (!
|
|
316
|
+
const split = SplitLookupReference(lookupStr);
|
|
317
|
+
if (!split) {
|
|
304
318
|
throw new Error(`Invalid lookup format: ${value}`);
|
|
305
319
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
const lookupPart =
|
|
311
|
-
const flagsPart =
|
|
312
|
-
|
|
313
|
-
const
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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 ${
|
|
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
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|