@memberjunction/metadata-sync 6.2.0-edge.4 → 6.2.0-edge.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -51
- 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/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 +46 -4
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +215 -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 +59 -0
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +435 -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
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { Metadata, RunView } from '@memberjunction/core';
|
|
2
2
|
import { UUIDsEqual } from '@memberjunction/global';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
|
+
import fse from 'fs-extra';
|
|
4
5
|
import * as path from 'path';
|
|
5
6
|
import { minimatch } from 'minimatch';
|
|
7
|
+
import fastGlob from 'fast-glob';
|
|
8
|
+
import { RecordFieldNames } from '../lib/entity-field-names.js';
|
|
9
|
+
import { RECORD_FILE_GLOB_OPTIONS } from '../lib/record-file-glob.js';
|
|
6
10
|
import { GetSystemUser } from '../lib/provider-utils.js';
|
|
11
|
+
import { IsIgnoredDirectoryName } from '../lib/ignore-directories.js';
|
|
12
|
+
import { MapJsonStartLines } from '../lib/json-record-lines.js';
|
|
7
13
|
import { ParseMetadataReference } from '../lib/reference-parser.js';
|
|
14
|
+
import { ParseEnvReference } from '../lib/env-reference.js';
|
|
15
|
+
import { LOOKUP_OPTIONAL_MESSAGE } from '../lib/lookup-reference.js';
|
|
16
|
+
import { EvaluatePushWhen, PushWhenError } from '../lib/push-when.js';
|
|
8
17
|
import { METADATA_KEYWORDS, IsMetadataKeyword, ExtractKeywordValue } from '../constants/metadata-keywords.js';
|
|
9
18
|
import { ResolveCollectionRelationship } from '@memberjunction/record-graph';
|
|
10
19
|
export class ValidationService {
|
|
@@ -14,6 +23,10 @@ export class ValidationService {
|
|
|
14
23
|
this.entityDependencies = new Map();
|
|
15
24
|
this.processedEntities = new Set();
|
|
16
25
|
this.claimedPrimaryKeys = new Map();
|
|
26
|
+
/** 1-based start line of each record object read from a record file. */
|
|
27
|
+
this.recordStartLines = new WeakMap();
|
|
28
|
+
/** The entity folder being validated. Push resolves record `@file:` references from it. */
|
|
29
|
+
this.entityDir = '';
|
|
17
30
|
this.userRoleCache = new Map();
|
|
18
31
|
this.metadata = new Metadata(); // global-provider-ok: metadata sync operates on the configured provider only
|
|
19
32
|
this.options = {
|
|
@@ -48,13 +61,26 @@ export class ValidationService {
|
|
|
48
61
|
if (config.userRoleValidation?.enabled) {
|
|
49
62
|
await this.loadUserRoles();
|
|
50
63
|
}
|
|
51
|
-
|
|
64
|
+
if (!config.entity && config.pushWhen !== undefined) {
|
|
65
|
+
this.addError({
|
|
66
|
+
type: 'entity',
|
|
67
|
+
severity: 'error',
|
|
68
|
+
file: configPath,
|
|
69
|
+
message: 'pushWhen is only allowed in an entity folder .mj-sync.json',
|
|
70
|
+
suggestion: 'Move pushWhen into the .mj-sync.json of each folder it should gate',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// A config with an entity means `dir` is one entity folder, unless the caller treats it as a root
|
|
74
|
+
const isEntityFolder = !!config.entity && !this.options.ValidateChildFoldersOnly;
|
|
75
|
+
const candidates = isEntityFolder ? [''] : await this.getDirectoriesInOrder(dir, config);
|
|
76
|
+
const directories = candidates.filter((subDir) => this.pushWhenAllows(path.join(dir, subDir)));
|
|
52
77
|
let totalFiles = 0;
|
|
53
78
|
let totalEntities = 0;
|
|
54
79
|
const fileResults = new Map();
|
|
55
80
|
for (const subDir of directories) {
|
|
56
81
|
const subDirPath = path.join(dir, subDir);
|
|
57
82
|
const result = await this.validateEntityDirectory(subDirPath);
|
|
83
|
+
this.warnNestedPushWhen(subDirPath);
|
|
58
84
|
if (result) {
|
|
59
85
|
totalFiles += result.files;
|
|
60
86
|
totalEntities += result.entities;
|
|
@@ -63,7 +89,6 @@ export class ValidationService {
|
|
|
63
89
|
}
|
|
64
90
|
}
|
|
65
91
|
}
|
|
66
|
-
// Validate dependency order
|
|
67
92
|
await this.validateDependencyOrder(directories);
|
|
68
93
|
return {
|
|
69
94
|
isValid: this.errors.length === 0,
|
|
@@ -78,6 +103,35 @@ export class ValidationService {
|
|
|
78
103
|
},
|
|
79
104
|
};
|
|
80
105
|
}
|
|
106
|
+
/** Warns about each `.mj-sync.json` below `dir` that has `pushWhen`; push over a parent folder ignores it. */
|
|
107
|
+
warnNestedPushWhen(dir) {
|
|
108
|
+
const configPaths = fastGlob.sync('*/**/.mj-sync.json', {
|
|
109
|
+
cwd: dir,
|
|
110
|
+
absolute: true,
|
|
111
|
+
dot: true,
|
|
112
|
+
ignore: ['**/node_modules/**'],
|
|
113
|
+
suppressErrors: true,
|
|
114
|
+
});
|
|
115
|
+
for (const configPath of configPaths) {
|
|
116
|
+
let config;
|
|
117
|
+
try {
|
|
118
|
+
config = fse.readJsonSync(configPath);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config) || !('pushWhen' in config)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
this.addWarning({
|
|
127
|
+
type: 'validation',
|
|
128
|
+
severity: 'warning',
|
|
129
|
+
file: configPath,
|
|
130
|
+
message: 'pushWhen in a nested folder has no effect when push runs over a parent folder',
|
|
131
|
+
suggestion: 'Move the folder directly under the metadata root, or push it with --dir',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
81
135
|
/** @deprecated Use {@link ValidateDirectory}. */
|
|
82
136
|
async validateDirectory(dir) {
|
|
83
137
|
return this.ValidateDirectory(dir);
|
|
@@ -86,31 +140,12 @@ export class ValidationService {
|
|
|
86
140
|
* Validates a single entity directory
|
|
87
141
|
*/
|
|
88
142
|
async validateEntityDirectory(dir) {
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
94
|
-
// .mj-folder.json uses entityName field
|
|
95
|
-
if (!config.entityName) {
|
|
96
|
-
this.addError({
|
|
97
|
-
type: 'validation',
|
|
98
|
-
severity: 'error',
|
|
99
|
-
file: configPath,
|
|
100
|
-
message: 'Missing entityName field in .mj-folder.json',
|
|
101
|
-
});
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
config.entity = config.entityName; // Normalize to entity field
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
// Fall back to .mj-sync.json (legacy format)
|
|
108
|
-
configPath = path.join(dir, '.mj-sync.json');
|
|
109
|
-
if (!fs.existsSync(configPath)) {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
143
|
+
// The folder's entity comes from .mj-sync.json; .mj-folder.json holds only defaults
|
|
144
|
+
const configPath = path.join(dir, '.mj-sync.json');
|
|
145
|
+
if (!fs.existsSync(configPath)) {
|
|
146
|
+
return null;
|
|
113
147
|
}
|
|
148
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
114
149
|
// Validate entity name exists
|
|
115
150
|
if (!config.entity || config.entity.trim() === '') {
|
|
116
151
|
this.addError({
|
|
@@ -121,7 +156,8 @@ export class ValidationService {
|
|
|
121
156
|
});
|
|
122
157
|
return null;
|
|
123
158
|
}
|
|
124
|
-
const
|
|
159
|
+
const entityConfig = { ...config, entity: config.entity };
|
|
160
|
+
const entityInfo = this.metadata.EntityByName(entityConfig.entity);
|
|
125
161
|
if (!entityInfo) {
|
|
126
162
|
this.addError({
|
|
127
163
|
type: 'entity',
|
|
@@ -145,11 +181,12 @@ export class ValidationService {
|
|
|
145
181
|
}
|
|
146
182
|
}
|
|
147
183
|
}
|
|
184
|
+
this.entityDir = path.resolve(dir);
|
|
148
185
|
const files = await this.getMatchingFiles(dir, config.filePattern);
|
|
149
186
|
let totalEntities = 0;
|
|
150
187
|
const fileResults = new Map();
|
|
151
188
|
for (const file of files) {
|
|
152
|
-
const result = await this.validateFile(file, entityInfo,
|
|
189
|
+
const result = await this.validateFile(file, entityInfo, entityConfig);
|
|
153
190
|
if (result) {
|
|
154
191
|
totalEntities += result.entityCount;
|
|
155
192
|
fileResults.set(file, result);
|
|
@@ -161,11 +198,11 @@ export class ValidationService {
|
|
|
161
198
|
* Validates a single metadata file
|
|
162
199
|
*/
|
|
163
200
|
async validateFile(filePath, entityInfo, config) {
|
|
164
|
-
const fileErrors = [];
|
|
165
201
|
let entityCount = 0;
|
|
166
202
|
try {
|
|
167
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
203
|
+
const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
|
|
168
204
|
const data = JSON.parse(content);
|
|
205
|
+
this.recordStartLines = MapJsonStartLines(content, data);
|
|
169
206
|
const entities = Array.isArray(data) ? data : [data];
|
|
170
207
|
entityCount = entities.length;
|
|
171
208
|
for (const entityData of entities) {
|
|
@@ -173,7 +210,7 @@ export class ValidationService {
|
|
|
173
210
|
}
|
|
174
211
|
}
|
|
175
212
|
catch (error) {
|
|
176
|
-
|
|
213
|
+
this.addError({
|
|
177
214
|
type: 'entity',
|
|
178
215
|
severity: 'error',
|
|
179
216
|
file: filePath,
|
|
@@ -222,24 +259,8 @@ export class ValidationService {
|
|
|
222
259
|
});
|
|
223
260
|
}
|
|
224
261
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const claimKey = `${entityInfo.Name}:${JSON.stringify(entityData.primaryKey)}`;
|
|
228
|
-
const existingFile = this.claimedPrimaryKeys.get(claimKey);
|
|
229
|
-
if (existingFile && existingFile !== filePath) {
|
|
230
|
-
this.addError({
|
|
231
|
-
type: 'field',
|
|
232
|
-
severity: 'error',
|
|
233
|
-
entity: entityInfo.Name,
|
|
234
|
-
file: filePath,
|
|
235
|
-
message: `Record with primaryKey ${JSON.stringify(entityData.primaryKey)} on entity "${entityInfo.Name}" is already claimed by "${existingFile}". Double ownership is not allowed.`,
|
|
236
|
-
suggestion: 'Ensure records are owned by only one sync file or collection',
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
else {
|
|
240
|
-
this.claimedPrimaryKeys.set(claimKey, filePath);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
262
|
+
this.validatePrimaryKeyEnvReferences(entityData.primaryKey, entityInfo.Name, filePath);
|
|
263
|
+
this.checkRecordClaim(entityData, entityInfo.Name, filePath);
|
|
243
264
|
// Skip validation for deletion records - they don't need field validation or reference checks
|
|
244
265
|
if (entityData.deleteRecord?.delete === true) {
|
|
245
266
|
// Only validate that primaryKey exists for deletion records
|
|
@@ -285,6 +306,7 @@ export class ValidationService {
|
|
|
285
306
|
// block) so the required-field best-practice check can be limited to NEW
|
|
286
307
|
// records — an existing record already holds the value in the DB, so a
|
|
287
308
|
// missing NOT-NULL field in the file is not a problem and would only be noise.
|
|
309
|
+
this.reportTemplateValues(entityData.fields, entityData, entityInfo.Name, filePath);
|
|
288
310
|
await this.validateFields(entityData.fields, entityInfo, filePath, parentContext, !!entityData.primaryKey, ownerContext);
|
|
289
311
|
// Track dependencies
|
|
290
312
|
this.trackEntityDependencies(entityData, entityInfo.Name, filePath);
|
|
@@ -360,9 +382,10 @@ export class ValidationService {
|
|
|
360
382
|
*/
|
|
361
383
|
async validateFields(fields, entityInfo, filePath, parentContext, isExistingRecord = false, ownerContext) {
|
|
362
384
|
const entityFields = entityInfo.Fields;
|
|
363
|
-
|
|
385
|
+
// Field names match the entity's fields without regard to case, as push matches them
|
|
386
|
+
const fieldMap = new Map(entityFields.map((f) => [f.Name.toLowerCase(), f]));
|
|
364
387
|
for (const [fieldName, fieldValue] of Object.entries(fields)) {
|
|
365
|
-
const fieldInfo = fieldMap.get(fieldName);
|
|
388
|
+
const fieldInfo = fieldMap.get(fieldName.toLowerCase());
|
|
366
389
|
if (!fieldInfo) {
|
|
367
390
|
// Check if this might be a virtual property (getter/setter)
|
|
368
391
|
try {
|
|
@@ -399,8 +422,24 @@ export class ValidationService {
|
|
|
399
422
|
continue;
|
|
400
423
|
}
|
|
401
424
|
}
|
|
402
|
-
|
|
403
|
-
|
|
425
|
+
const isSystemField = fieldInfo.Name.startsWith('__mj_');
|
|
426
|
+
// A read-only field that push ignores (such as a joined display field that pull writes) is a warning
|
|
427
|
+
if (fieldInfo.ReadOnly && !fieldInfo.IsPrimaryKey && !isSystemField) {
|
|
428
|
+
const pullAdvice = fieldInfo.IsVirtual
|
|
429
|
+
? 'set pull.ignoreVirtualFields: true to stop pull writing it'
|
|
430
|
+
: 'add it to pull.excludeFields to stop pull writing it';
|
|
431
|
+
this.addWarning({
|
|
432
|
+
type: 'validation',
|
|
433
|
+
severity: 'warning',
|
|
434
|
+
entity: entityInfo.Name,
|
|
435
|
+
field: fieldName,
|
|
436
|
+
file: filePath,
|
|
437
|
+
message: `Field "${fieldName}" is read-only: push ignores it; ${pullAdvice}`,
|
|
438
|
+
});
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// A system field or the primary key cannot be set in fields
|
|
442
|
+
if (fieldInfo.ReadOnly || isSystemField) {
|
|
404
443
|
this.addError({
|
|
405
444
|
type: 'field',
|
|
406
445
|
severity: 'error',
|
|
@@ -422,9 +461,10 @@ export class ValidationService {
|
|
|
422
461
|
// pure noise that masks genuine warnings. New records (no primaryKey) still
|
|
423
462
|
// get the full check since their required fields must be supplied to insert.
|
|
424
463
|
if (this.options.checkBestPractices && !isExistingRecord) {
|
|
464
|
+
const recordFields = new RecordFieldNames(fields);
|
|
425
465
|
for (const field of entityFields) {
|
|
426
466
|
// Skip if field allows null or has a value already (use 'in' to handle falsy values like 0, false, "")
|
|
427
|
-
if (field.AllowsNull || field.Name
|
|
467
|
+
if (field.AllowsNull || recordFields.Has(field.Name)) {
|
|
428
468
|
continue;
|
|
429
469
|
}
|
|
430
470
|
// Skip if field has a default value
|
|
@@ -577,6 +617,107 @@ export class ValidationService {
|
|
|
577
617
|
}
|
|
578
618
|
}
|
|
579
619
|
}
|
|
620
|
+
/** Reports each `@template:` value in a record's fields. Push saves that text unchanged instead of loading a file. */
|
|
621
|
+
reportTemplateValues(fields, record, entityName, filePath) {
|
|
622
|
+
const line = this.recordStartLines.get(record);
|
|
623
|
+
for (const [fieldName, value] of Object.entries(fields)) {
|
|
624
|
+
if (typeof value === 'string' && value.startsWith(METADATA_KEYWORDS.TEMPLATE)) {
|
|
625
|
+
this.addTemplateError(value, entityName, fieldName, filePath, line);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
addTemplateError(value, entityName, fieldName, filePath, line) {
|
|
630
|
+
const location = line === undefined ? filePath : `${filePath}:${line}`;
|
|
631
|
+
this.addError({
|
|
632
|
+
type: 'reference',
|
|
633
|
+
severity: 'error',
|
|
634
|
+
entity: entityName,
|
|
635
|
+
field: fieldName,
|
|
636
|
+
file: filePath,
|
|
637
|
+
message: `@template: is not supported by mj sync push; push would save the literal text "${value}" to ${entityName}.${fieldName} ` +
|
|
638
|
+
`at ${location}. Use @file: instead.`,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Records an entry for its entity and primary key. Several entries may describe the same record,
|
|
643
|
+
* but two entries that set the same field to different raw values give an error.
|
|
644
|
+
* A key that holds an `@parent:`, `@owner:` or `@root:` reference anywhere, or an object value, is not
|
|
645
|
+
* recorded.
|
|
646
|
+
*/
|
|
647
|
+
checkRecordClaim(entityData, entityName, filePath) {
|
|
648
|
+
if (!entityData.primaryKey || Object.keys(entityData.primaryKey).length === 0 || this.hasRelativeKeyValue(entityData.primaryKey)) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const fields = entityData.fields;
|
|
652
|
+
const claim = {
|
|
653
|
+
Path: filePath,
|
|
654
|
+
Line: this.recordStartLines.get(entityData),
|
|
655
|
+
Fields: fields && typeof fields === 'object' && !Array.isArray(fields) ? fields : {},
|
|
656
|
+
};
|
|
657
|
+
const claimKey = `${entityName}:${JSON.stringify(entityData.primaryKey)}`;
|
|
658
|
+
const claims = this.claimedPrimaryKeys.get(claimKey) ?? [];
|
|
659
|
+
for (const earlier of claims) {
|
|
660
|
+
const conflictingFields = Object.keys(claim.Fields).filter((field) => Object.prototype.hasOwnProperty.call(earlier.Fields, field) && !this.jsonValuesEqual(earlier.Fields[field], claim.Fields[field]));
|
|
661
|
+
if (conflictingFields.length > 0) {
|
|
662
|
+
this.addClaimConflictError(entityName, entityData.primaryKey, conflictingFields, earlier, claim);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
claims.push(claim);
|
|
666
|
+
this.claimedPrimaryKeys.set(claimKey, claims);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* True when a key value can depend on the parent, owner or root record, so equal key text can name
|
|
670
|
+
* different records: it holds one of these references (also inside a `@lookup:`), or it is an object.
|
|
671
|
+
*/
|
|
672
|
+
hasRelativeKeyValue(primaryKey) {
|
|
673
|
+
const relativePrefixes = [METADATA_KEYWORDS.PARENT, METADATA_KEYWORDS.OWNER, METADATA_KEYWORDS.ROOT];
|
|
674
|
+
return Object.values(primaryKey).some((value) => (typeof value === 'object' && value !== null) ||
|
|
675
|
+
(typeof value === 'string' && relativePrefixes.some((prefix) => value.includes(prefix))));
|
|
676
|
+
}
|
|
677
|
+
/** Adds the error for two entries of one record that set fields to different values. */
|
|
678
|
+
addClaimConflictError(entityName, primaryKey, conflictingFields, earlier, later) {
|
|
679
|
+
const earlierLocation = this.formatClaimLocation(earlier);
|
|
680
|
+
const laterLocation = this.formatClaimLocation(later);
|
|
681
|
+
const fieldText = conflictingFields
|
|
682
|
+
.map((field) => `field "${field}" is ${this.formatClaimValue(earlier.Fields[field])} at ${earlierLocation} and ${this.formatClaimValue(later.Fields[field])} at ${laterLocation}`)
|
|
683
|
+
.join('; ');
|
|
684
|
+
this.addError({
|
|
685
|
+
type: 'field',
|
|
686
|
+
severity: 'error',
|
|
687
|
+
entity: entityName,
|
|
688
|
+
field: conflictingFields.length === 1 ? conflictingFields[0] : undefined,
|
|
689
|
+
file: later.Path,
|
|
690
|
+
message: `Conflicting values for ${entityName} record ${JSON.stringify(primaryKey)}: ${fieldText}. ` +
|
|
691
|
+
'Make both entries set the same value, or set the field in one entry only.',
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
formatClaimLocation(claim) {
|
|
695
|
+
return claim.Line === undefined ? claim.Path : `${claim.Path}:${claim.Line}`;
|
|
696
|
+
}
|
|
697
|
+
/** A raw field value as JSON text, cut to 80 characters. */
|
|
698
|
+
formatClaimValue(value) {
|
|
699
|
+
const text = JSON.stringify(value) ?? String(value);
|
|
700
|
+
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
|
701
|
+
}
|
|
702
|
+
/** Deep equality of two parsed JSON values. Object key order does not matter. */
|
|
703
|
+
jsonValuesEqual(left, right) {
|
|
704
|
+
if (left === right) {
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
708
|
+
return (Array.isArray(left) &&
|
|
709
|
+
Array.isArray(right) &&
|
|
710
|
+
left.length === right.length &&
|
|
711
|
+
left.every((item, index) => this.jsonValuesEqual(item, right[index])));
|
|
712
|
+
}
|
|
713
|
+
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') {
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
const leftEntries = Object.entries(left);
|
|
717
|
+
const rightValues = new Map(Object.entries(right));
|
|
718
|
+
return (leftEntries.length === rightValues.size &&
|
|
719
|
+
leftEntries.every(([key, value]) => rightValues.has(key) && this.jsonValuesEqual(value, rightValues.get(key))));
|
|
720
|
+
}
|
|
580
721
|
/**
|
|
581
722
|
* Check if a string is actually a MetadataSync reference (not just any @ string)
|
|
582
723
|
*/
|
|
@@ -601,13 +742,13 @@ export class ValidationService {
|
|
|
601
742
|
}
|
|
602
743
|
switch (parsed.type) {
|
|
603
744
|
case METADATA_KEYWORDS.FILE:
|
|
604
|
-
await this.validateFileReference(parsed.value, filePath, entityInfo.Name, fieldInfo.Name);
|
|
745
|
+
await this.validateFileReference(parsed.value, filePath, this.entityDir, entityInfo.Name, fieldInfo.Name);
|
|
605
746
|
break;
|
|
606
747
|
case METADATA_KEYWORDS.LOOKUP:
|
|
607
748
|
await this.validateLookupReference(parsed, filePath, entityInfo.Name, fieldInfo.Name);
|
|
608
749
|
break;
|
|
609
750
|
case METADATA_KEYWORDS.TEMPLATE:
|
|
610
|
-
|
|
751
|
+
// Reported by reportTemplateValues: push does not load @template: files
|
|
611
752
|
break;
|
|
612
753
|
case METADATA_KEYWORDS.OWNER:
|
|
613
754
|
this.validateOwnerReference(parsed.value, ownerContext, filePath, entityInfo.Name, fieldInfo.Name);
|
|
@@ -630,11 +771,11 @@ export class ValidationService {
|
|
|
630
771
|
return ParseMetadataReference(reference);
|
|
631
772
|
}
|
|
632
773
|
/**
|
|
633
|
-
* Validates @file: references
|
|
774
|
+
* Validates @file: references. `baseDir` is the folder push resolves the path from: the entity
|
|
775
|
+
* folder for a record field, or the folder of the JSON file that holds the reference.
|
|
634
776
|
*/
|
|
635
|
-
async validateFileReference(filePath, sourceFile, entityName, fieldName, visitedFiles) {
|
|
636
|
-
const
|
|
637
|
-
const resolvedPath = path.resolve(dir, filePath);
|
|
777
|
+
async validateFileReference(filePath, sourceFile, baseDir, entityName, fieldName, visitedFiles) {
|
|
778
|
+
const resolvedPath = path.resolve(baseDir, filePath);
|
|
638
779
|
// Initialize visited files set if not provided (for circular reference detection)
|
|
639
780
|
const visited = visitedFiles || new Set();
|
|
640
781
|
// Check for circular references
|
|
@@ -707,6 +848,17 @@ export class ValidationService {
|
|
|
707
848
|
* Validates @lookup: references
|
|
708
849
|
*/
|
|
709
850
|
async validateLookupReference(parsed, sourceFile, entityName, fieldName) {
|
|
851
|
+
if (parsed.UnknownFlags?.includes('optional')) {
|
|
852
|
+
this.addError({
|
|
853
|
+
type: 'reference',
|
|
854
|
+
severity: 'error',
|
|
855
|
+
entity: entityName,
|
|
856
|
+
field: fieldName,
|
|
857
|
+
file: sourceFile,
|
|
858
|
+
message: LOOKUP_OPTIONAL_MESSAGE,
|
|
859
|
+
suggestion: 'Remove ?optional. Use @env:VAR?optional only as a whole field value.',
|
|
860
|
+
});
|
|
861
|
+
}
|
|
710
862
|
const lookupEntity = this.metadata.EntityByName(parsed.entity);
|
|
711
863
|
if (!lookupEntity) {
|
|
712
864
|
this.addError({
|
|
@@ -755,40 +907,6 @@ export class ValidationService {
|
|
|
755
907
|
// Track dependency
|
|
756
908
|
this.addEntityDependency(entityName, parsed.entity);
|
|
757
909
|
}
|
|
758
|
-
/**
|
|
759
|
-
* Validates @template: references
|
|
760
|
-
*/
|
|
761
|
-
async validateTemplateReference(templatePath, sourceFile, entityName, fieldName) {
|
|
762
|
-
const dir = path.dirname(sourceFile);
|
|
763
|
-
const resolvedPath = path.resolve(dir, templatePath);
|
|
764
|
-
if (!fs.existsSync(resolvedPath)) {
|
|
765
|
-
this.addError({
|
|
766
|
-
type: 'reference',
|
|
767
|
-
severity: 'error',
|
|
768
|
-
entity: entityName,
|
|
769
|
-
field: fieldName,
|
|
770
|
-
file: sourceFile,
|
|
771
|
-
message: `Template file not found: "${templatePath}"`,
|
|
772
|
-
suggestion: `Create template at: ${resolvedPath}`,
|
|
773
|
-
});
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
// Validate template is valid JSON
|
|
777
|
-
try {
|
|
778
|
-
JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
|
|
779
|
-
}
|
|
780
|
-
catch (error) {
|
|
781
|
-
this.addError({
|
|
782
|
-
type: 'reference',
|
|
783
|
-
severity: 'error',
|
|
784
|
-
entity: entityName,
|
|
785
|
-
field: fieldName,
|
|
786
|
-
file: sourceFile,
|
|
787
|
-
message: `Template file is not valid JSON: "${templatePath}"`,
|
|
788
|
-
details: error instanceof Error ? error.message : String(error),
|
|
789
|
-
});
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
910
|
/**
|
|
793
911
|
* Validates @parent: references
|
|
794
912
|
*/
|
|
@@ -936,10 +1054,12 @@ export class ValidationService {
|
|
|
936
1054
|
* Get directories in order based on config
|
|
937
1055
|
*/
|
|
938
1056
|
async getDirectoriesInOrder(rootDir, config) {
|
|
1057
|
+
// The folders push visits: no dot folders, none that match ignoreDirectories
|
|
939
1058
|
const allDirs = fs
|
|
940
1059
|
.readdirSync(rootDir)
|
|
941
1060
|
.filter((f) => fs.statSync(path.join(rootDir, f)).isDirectory())
|
|
942
|
-
.filter((d) => !d.startsWith('.'))
|
|
1061
|
+
.filter((d) => !d.startsWith('.'))
|
|
1062
|
+
.filter((d) => !IsIgnoredDirectoryName(d, config.ignoreDirectories));
|
|
943
1063
|
let orderedDirs;
|
|
944
1064
|
if (config.directoryOrder && Array.isArray(config.directoryOrder)) {
|
|
945
1065
|
const ordered = config.directoryOrder.filter((d) => allDirs.includes(d));
|
|
@@ -952,6 +1072,61 @@ export class ValidationService {
|
|
|
952
1072
|
// Apply include/exclude filters if specified
|
|
953
1073
|
return this.applyDirectoryFilters(orderedDirs);
|
|
954
1074
|
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Checks a folder's pushWhen and reports a malformed value or an unreadable
|
|
1077
|
+
* .mj-sync.json as an error. Returns false when the config cannot be read, or when
|
|
1078
|
+
* HonorPushWhen is set and push would skip the folder.
|
|
1079
|
+
*/
|
|
1080
|
+
pushWhenAllows(dirPath) {
|
|
1081
|
+
const configPath = path.join(dirPath, '.mj-sync.json');
|
|
1082
|
+
if (!fs.existsSync(configPath)) {
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
let config;
|
|
1086
|
+
try {
|
|
1087
|
+
config = fse.readJsonSync(configPath);
|
|
1088
|
+
}
|
|
1089
|
+
catch (error) {
|
|
1090
|
+
this.addError({
|
|
1091
|
+
type: 'entity',
|
|
1092
|
+
severity: 'error',
|
|
1093
|
+
file: configPath,
|
|
1094
|
+
message: `Cannot read .mj-sync.json: ${this.readErrorReason(error, configPath)}`,
|
|
1095
|
+
});
|
|
1096
|
+
return false;
|
|
1097
|
+
}
|
|
1098
|
+
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
|
1099
|
+
this.addError({
|
|
1100
|
+
type: 'entity',
|
|
1101
|
+
severity: 'error',
|
|
1102
|
+
file: configPath,
|
|
1103
|
+
message: '.mj-sync.json must contain a JSON object',
|
|
1104
|
+
});
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
const pushWhen = 'pushWhen' in config ? config.pushWhen : undefined;
|
|
1108
|
+
if (pushWhen === undefined) {
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
const problem = PushWhenError(pushWhen);
|
|
1112
|
+
if (problem) {
|
|
1113
|
+
this.addError({
|
|
1114
|
+
type: 'entity',
|
|
1115
|
+
severity: 'error',
|
|
1116
|
+
file: configPath,
|
|
1117
|
+
message: problem,
|
|
1118
|
+
suggestion: 'Use "pushWhen": "@env:VARIABLE"',
|
|
1119
|
+
});
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
return !this.options.HonorPushWhen || EvaluatePushWhen(pushWhen).Push;
|
|
1123
|
+
}
|
|
1124
|
+
/** The reason from a read error, without the leading `<path>: ` that fs-extra adds. */
|
|
1125
|
+
readErrorReason(error, filePath) {
|
|
1126
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1127
|
+
const prefix = `${filePath}: `;
|
|
1128
|
+
return message.startsWith(prefix) ? message.slice(prefix.length) : message;
|
|
1129
|
+
}
|
|
955
1130
|
/**
|
|
956
1131
|
* Apply include/exclude filters to directory list
|
|
957
1132
|
*/
|
|
@@ -971,21 +1146,10 @@ export class ValidationService {
|
|
|
971
1146
|
}
|
|
972
1147
|
return filteredDirs;
|
|
973
1148
|
}
|
|
974
|
-
/**
|
|
975
|
-
* Get files matching pattern
|
|
976
|
-
*/
|
|
1149
|
+
/** The record files of an entity folder, found the way push finds them, as sorted absolute paths. */
|
|
977
1150
|
async getMatchingFiles(dir, pattern) {
|
|
978
|
-
const files =
|
|
979
|
-
|
|
980
|
-
const normalizedPattern = pattern.replace(/^\*\*\//, '');
|
|
981
|
-
// Simple glob pattern matching
|
|
982
|
-
if (normalizedPattern === '*.json') {
|
|
983
|
-
return files.filter((f) => f.endsWith('.json') && !f.startsWith('.mj-'));
|
|
984
|
-
}
|
|
985
|
-
else if (normalizedPattern === '.*.json') {
|
|
986
|
-
return files.filter((f) => f.startsWith('.') && f.endsWith('.json') && !f.startsWith('.mj-'));
|
|
987
|
-
}
|
|
988
|
-
return files;
|
|
1151
|
+
const files = await fastGlob(pattern || '*.json', { ...RECORD_FILE_GLOB_OPTIONS, cwd: dir });
|
|
1152
|
+
return files.sort();
|
|
989
1153
|
}
|
|
990
1154
|
/**
|
|
991
1155
|
* Add an error
|
|
@@ -1070,6 +1234,7 @@ export class ValidationService {
|
|
|
1070
1234
|
this.entityDependencies.clear();
|
|
1071
1235
|
this.processedEntities.clear();
|
|
1072
1236
|
this.claimedPrimaryKeys.clear();
|
|
1237
|
+
this.recordStartLines = new WeakMap();
|
|
1073
1238
|
this.userRoleCache.clear();
|
|
1074
1239
|
}
|
|
1075
1240
|
/**
|
|
@@ -1302,6 +1467,63 @@ export class ValidationService {
|
|
|
1302
1467
|
});
|
|
1303
1468
|
}
|
|
1304
1469
|
}
|
|
1470
|
+
/** Checks an `@env:` reference inside a JSON file: its syntax, and whether a required variable is set. */
|
|
1471
|
+
validateNestedEnvReference(value, sourceFile, entityName, key) {
|
|
1472
|
+
const reference = ParseEnvReference(value);
|
|
1473
|
+
if (!reference) {
|
|
1474
|
+
this.addError({
|
|
1475
|
+
type: 'reference',
|
|
1476
|
+
severity: 'error',
|
|
1477
|
+
entity: entityName,
|
|
1478
|
+
field: key,
|
|
1479
|
+
file: sourceFile,
|
|
1480
|
+
message: `Invalid reference format: "${value}"`,
|
|
1481
|
+
});
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (!reference.Optional && !process.env[reference.VariableName]) {
|
|
1485
|
+
this.addWarning({
|
|
1486
|
+
type: 'validation',
|
|
1487
|
+
severity: 'warning',
|
|
1488
|
+
entity: entityName,
|
|
1489
|
+
field: key,
|
|
1490
|
+
file: sourceFile,
|
|
1491
|
+
message: `Environment variable "${reference.VariableName}" is not currently set`,
|
|
1492
|
+
suggestion: `Ensure this variable is set before running push operations`,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
/** Reports a primary key `@env:` reference that is malformed or uses `?optional`; push needs the value to find the record. */
|
|
1497
|
+
validatePrimaryKeyEnvReferences(primaryKey, entityName, filePath) {
|
|
1498
|
+
for (const [field, value] of Object.entries(primaryKey ?? {})) {
|
|
1499
|
+
if (typeof value !== 'string' || !value.startsWith(METADATA_KEYWORDS.ENV)) {
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
const reference = ParseEnvReference(value);
|
|
1503
|
+
if (!reference) {
|
|
1504
|
+
this.addError({
|
|
1505
|
+
type: 'reference',
|
|
1506
|
+
severity: 'error',
|
|
1507
|
+
entity: entityName,
|
|
1508
|
+
field,
|
|
1509
|
+
file: filePath,
|
|
1510
|
+
message: `Invalid reference format: "${value}"`,
|
|
1511
|
+
suggestion: 'Use @env:VARIABLE',
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
else if (reference.Optional) {
|
|
1515
|
+
this.addError({
|
|
1516
|
+
type: 'reference',
|
|
1517
|
+
severity: 'error',
|
|
1518
|
+
entity: entityName,
|
|
1519
|
+
field,
|
|
1520
|
+
file: filePath,
|
|
1521
|
+
message: `Primary key field "${field}" cannot use ?optional: ${value}`,
|
|
1522
|
+
suggestion: 'Remove ?optional from the primary key reference',
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1305
1527
|
/**
|
|
1306
1528
|
* Recursively validates all @ references in a JSON structure
|
|
1307
1529
|
*/
|
|
@@ -1321,7 +1543,7 @@ export class ValidationService {
|
|
|
1321
1543
|
if (value.startsWith(METADATA_KEYWORDS.FILE)) {
|
|
1322
1544
|
const filePath = ExtractKeywordValue(value);
|
|
1323
1545
|
// Recursively validate the file reference (with circular detection)
|
|
1324
|
-
await this.validateFileReference(filePath, sourceFile, entityName, key, visitedFiles);
|
|
1546
|
+
await this.validateFileReference(filePath, sourceFile, path.dirname(sourceFile), entityName, key, visitedFiles);
|
|
1325
1547
|
}
|
|
1326
1548
|
else if (value.startsWith(METADATA_KEYWORDS.LOOKUP)) {
|
|
1327
1549
|
// Parse and validate lookup reference
|
|
@@ -1331,8 +1553,7 @@ export class ValidationService {
|
|
|
1331
1553
|
}
|
|
1332
1554
|
}
|
|
1333
1555
|
else if (value.startsWith(METADATA_KEYWORDS.TEMPLATE)) {
|
|
1334
|
-
|
|
1335
|
-
await this.validateTemplateReference(templatePath, sourceFile, entityName, key);
|
|
1556
|
+
this.addTemplateError(value, entityName, key, sourceFile);
|
|
1336
1557
|
}
|
|
1337
1558
|
else if (value.startsWith(METADATA_KEYWORDS.PARENT)) {
|
|
1338
1559
|
const parsed = this.parseReference(value);
|
|
@@ -1353,18 +1574,7 @@ export class ValidationService {
|
|
|
1353
1574
|
}
|
|
1354
1575
|
}
|
|
1355
1576
|
else if (value.startsWith(METADATA_KEYWORDS.ENV)) {
|
|
1356
|
-
|
|
1357
|
-
if (!process.env[envVar]) {
|
|
1358
|
-
this.addWarning({
|
|
1359
|
-
type: 'validation',
|
|
1360
|
-
severity: 'warning',
|
|
1361
|
-
entity: entityName,
|
|
1362
|
-
field: key,
|
|
1363
|
-
file: sourceFile,
|
|
1364
|
-
message: `Environment variable "${envVar}" is not currently set`,
|
|
1365
|
-
suggestion: `Ensure this variable is set before running push operations`,
|
|
1366
|
-
});
|
|
1367
|
-
}
|
|
1577
|
+
this.validateNestedEnvReference(value, sourceFile, entityName, key);
|
|
1368
1578
|
}
|
|
1369
1579
|
}
|
|
1370
1580
|
else if (value && typeof value === 'object') {
|
|
@@ -1516,7 +1726,7 @@ export class ValidationService {
|
|
|
1516
1726
|
});
|
|
1517
1727
|
return;
|
|
1518
1728
|
}
|
|
1519
|
-
await this.validateExtensionFields(extObj.fields, parentEntityInfo, childEntityInfo, filePath, config, depth);
|
|
1729
|
+
await this.validateExtensionFields(extObj.fields, extObj, parentEntityInfo, childEntityInfo, filePath, config, depth);
|
|
1520
1730
|
return;
|
|
1521
1731
|
}
|
|
1522
1732
|
// Case 2: Map form where keys are subtype entity names (e.g. { "Event Order Lines": { fields: { ... } } })
|
|
@@ -1558,14 +1768,14 @@ export class ValidationService {
|
|
|
1558
1768
|
}
|
|
1559
1769
|
const subRecord = subVal;
|
|
1560
1770
|
if ('fields' in subRecord && typeof subRecord.fields === 'object' && subRecord.fields !== null) {
|
|
1561
|
-
await this.validateExtensionFields(subRecord.fields, parentEntityInfo, childEntityInfo, filePath, config, depth);
|
|
1771
|
+
await this.validateExtensionFields(subRecord.fields, subRecord, parentEntityInfo, childEntityInfo, filePath, config, depth);
|
|
1562
1772
|
}
|
|
1563
1773
|
}
|
|
1564
1774
|
}
|
|
1565
1775
|
/**
|
|
1566
1776
|
* Validates leaf fields within an extension block (§4.1, §4.5, §9)
|
|
1567
1777
|
*/
|
|
1568
|
-
async validateExtensionFields(fields, parentEntityInfo, childEntityInfo, filePath, config, depth) {
|
|
1778
|
+
async validateExtensionFields(fields, extensionRecord, parentEntityInfo, childEntityInfo, filePath, config, depth) {
|
|
1569
1779
|
for (const fieldName of Object.keys(fields)) {
|
|
1570
1780
|
const isParentField = parentEntityInfo.Fields.some((f) => f.Name.toLowerCase() === fieldName.toLowerCase());
|
|
1571
1781
|
if (isParentField) {
|
|
@@ -1580,6 +1790,7 @@ export class ValidationService {
|
|
|
1580
1790
|
});
|
|
1581
1791
|
}
|
|
1582
1792
|
}
|
|
1793
|
+
this.reportTemplateValues(fields, extensionRecord, childEntityInfo.Name, filePath);
|
|
1583
1794
|
await this.validateFields(fields, childEntityInfo, filePath, { entity: parentEntityInfo.Name, field: 'extension' }, false, { entity: parentEntityInfo.Name });
|
|
1584
1795
|
}
|
|
1585
1796
|
/**
|