@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.
- package/README.md +120 -52
- package/dist/config.d.ts +18 -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 +34 -3
- 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/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 +232 -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/lookup-sql.d.ts +108 -0
- package/dist/lib/lookup-sql.d.ts.map +1 -0
- package/dist/lib/lookup-sql.js +231 -0
- package/dist/lib/lookup-sql.js.map +1 -0
- package/dist/lib/natural-key-preflight.d.ts +86 -0
- package/dist/lib/natural-key-preflight.d.ts.map +1 -0
- package/dist/lib/natural-key-preflight.js +173 -0
- package/dist/lib/natural-key-preflight.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 +11 -2
- package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
- package/dist/lib/record-dependency-analyzer.js +48 -71
- 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/schema-placeholders.d.ts +29 -0
- package/dist/lib/schema-placeholders.d.ts.map +1 -0
- package/dist/lib/schema-placeholders.js +72 -0
- package/dist/lib/schema-placeholders.js.map +1 -0
- package/dist/lib/sql-logger.d.ts +9 -1
- package/dist/lib/sql-logger.d.ts.map +1 -1
- package/dist/lib/sql-logger.js +12 -7
- package/dist/lib/sql-logger.js.map +1 -1
- package/dist/lib/sql-set-line.d.ts +26 -0
- package/dist/lib/sql-set-line.d.ts.map +1 -0
- package/dist/lib/sql-set-line.js +84 -0
- package/dist/lib/sql-set-line.js.map +1 -0
- package/dist/lib/sync-engine.d.ts +58 -4
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +243 -59
- 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 +44 -14
- package/dist/plugins/index.js.map +1 -1
- package/dist/services/PullService.d.ts +41 -1
- package/dist/services/PullService.d.ts.map +1 -1
- package/dist/services/PullService.js +261 -101
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +80 -0
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +531 -189
- 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 +28 -0
- package/dist/services/WatchService.d.ts.map +1 -1
- package/dist/services/WatchService.js +226 -74
- 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,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
|
|
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
|
-
|
|
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
|
|
303
|
-
if (!
|
|
318
|
+
const split = SplitLookupReference(lookupStr);
|
|
319
|
+
if (!split) {
|
|
304
320
|
throw new Error(`Invalid lookup format: ${value}`);
|
|
305
321
|
}
|
|
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');
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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 ${
|
|
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
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|