@memberjunction/metadata-sync 6.1.0-edge.5 → 6.1.0-edge.7
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 +48 -3
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/constants/metadata-keywords.d.ts +13 -2
- package/dist/constants/metadata-keywords.d.ts.map +1 -1
- package/dist/constants/metadata-keywords.js +12 -0
- package/dist/constants/metadata-keywords.js.map +1 -1
- package/dist/index.d.ts +2 -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 +12 -0
- package/dist/lib/RecordProcessor.d.ts.map +1 -1
- package/dist/lib/RecordProcessor.js +163 -10
- package/dist/lib/RecordProcessor.js.map +1 -1
- package/dist/lib/batch-context-index.js +1 -1
- package/dist/lib/batch-context-index.js.map +1 -1
- package/dist/lib/collection-resolver.d.ts +24 -0
- package/dist/lib/collection-resolver.d.ts.map +1 -0
- package/dist/lib/collection-resolver.js +117 -0
- package/dist/lib/collection-resolver.js.map +1 -0
- package/dist/lib/database-reference-scanner.js +5 -5
- package/dist/lib/database-reference-scanner.js.map +1 -1
- package/dist/lib/deletion-auditor.d.ts.map +1 -1
- package/dist/lib/deletion-auditor.js +1 -0
- package/dist/lib/deletion-auditor.js.map +1 -1
- package/dist/lib/entity-subclass-guard.d.ts +10 -0
- package/dist/lib/entity-subclass-guard.d.ts.map +1 -0
- package/dist/lib/entity-subclass-guard.js +38 -0
- package/dist/lib/entity-subclass-guard.js.map +1 -0
- package/dist/lib/graph-provider-pool.d.ts +67 -0
- package/dist/lib/graph-provider-pool.d.ts.map +1 -0
- package/dist/lib/graph-provider-pool.js +149 -0
- package/dist/lib/graph-provider-pool.js.map +1 -0
- package/dist/lib/json-write-helper.d.ts +12 -8
- package/dist/lib/json-write-helper.d.ts.map +1 -1
- package/dist/lib/json-write-helper.js +54 -24
- package/dist/lib/json-write-helper.js.map +1 -1
- package/dist/lib/record-dependency-analyzer.d.ts +8 -0
- package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
- package/dist/lib/record-dependency-analyzer.js +20 -5
- package/dist/lib/record-dependency-analyzer.js.map +1 -1
- package/dist/lib/reference-parser.d.ts.map +1 -1
- package/dist/lib/reference-parser.js +1 -0
- package/dist/lib/reference-parser.js.map +1 -1
- package/dist/lib/sync-engine.d.ts +27 -4
- package/dist/lib/sync-engine.d.ts.map +1 -1
- package/dist/lib/sync-engine.js +94 -45
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/lib/sync-metadata-engine.d.ts +44 -1
- package/dist/lib/sync-metadata-engine.d.ts.map +1 -1
- package/dist/lib/sync-metadata-engine.js +152 -4
- package/dist/lib/sync-metadata-engine.js.map +1 -1
- package/dist/lib/transaction-manager.d.ts +2 -1
- package/dist/lib/transaction-manager.d.ts.map +1 -1
- package/dist/lib/transaction-manager.js +4 -1
- package/dist/lib/transaction-manager.js.map +1 -1
- package/dist/plugins/index.js +3 -3
- package/dist/plugins/index.js.map +1 -1
- package/dist/services/FormattingService.d.ts +8 -0
- package/dist/services/FormattingService.d.ts.map +1 -1
- package/dist/services/FormattingService.js +18 -0
- package/dist/services/FormattingService.js.map +1 -1
- package/dist/services/PullService.js +1 -1
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +53 -3
- package/dist/services/PushService.d.ts.map +1 -1
- package/dist/services/PushService.js +455 -143
- package/dist/services/PushService.js.map +1 -1
- package/dist/services/ValidationService.d.ts +26 -0
- package/dist/services/ValidationService.d.ts.map +1 -1
- package/dist/services/ValidationService.js +454 -14
- package/dist/services/ValidationService.js.map +1 -1
- package/package.json +14 -14
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { Metadata, RunView } from '@memberjunction/core';
|
|
2
|
+
import { UUIDsEqual } from '@memberjunction/global';
|
|
2
3
|
import * as fs from 'fs';
|
|
3
4
|
import * as path from 'path';
|
|
4
5
|
import { minimatch } from 'minimatch';
|
|
5
6
|
import { getSystemUser } from '../lib/provider-utils.js';
|
|
6
7
|
import { parseMetadataReference } from '../lib/reference-parser.js';
|
|
7
8
|
import { METADATA_KEYWORDS, isMetadataKeyword, extractKeywordValue } from '../constants/metadata-keywords.js';
|
|
9
|
+
import { resolveCollectionRelationship } from '../lib/collection-resolver.js';
|
|
8
10
|
export class ValidationService {
|
|
9
11
|
constructor(options = {}) {
|
|
10
12
|
this.errors = [];
|
|
11
13
|
this.warnings = [];
|
|
12
14
|
this.entityDependencies = new Map();
|
|
13
15
|
this.processedEntities = new Set();
|
|
16
|
+
this.claimedPrimaryKeys = new Map();
|
|
14
17
|
this.userRoleCache = new Map();
|
|
15
18
|
this.metadata = new Metadata(); // global-provider-ok: metadata sync operates on the configured provider only
|
|
16
19
|
this.options = {
|
|
@@ -124,14 +127,29 @@ export class ValidationService {
|
|
|
124
127
|
});
|
|
125
128
|
return null;
|
|
126
129
|
}
|
|
130
|
+
// Validate collection modes if defined (§8.1)
|
|
131
|
+
if (config.collections) {
|
|
132
|
+
for (const [colName, colConfig] of Object.entries(config.collections)) {
|
|
133
|
+
const c = colConfig;
|
|
134
|
+
if (c.mode && c.mode !== 'upsert' && c.mode !== 'authoritative') {
|
|
135
|
+
this.addError({
|
|
136
|
+
type: 'validation',
|
|
137
|
+
severity: 'error',
|
|
138
|
+
file: configPath,
|
|
139
|
+
message: `Invalid collection mode "${c.mode}" for collection "${colName}". Allowed values: "upsert", "authoritative"`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
127
144
|
const files = await this.getMatchingFiles(dir, config.filePattern);
|
|
128
145
|
let totalEntities = 0;
|
|
129
146
|
const fileResults = new Map();
|
|
130
147
|
for (const file of files) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
const result = await this.validateFile(file, entityInfo, config);
|
|
149
|
+
if (result) {
|
|
150
|
+
totalEntities += result.entityCount;
|
|
151
|
+
fileResults.set(file, result);
|
|
152
|
+
}
|
|
135
153
|
}
|
|
136
154
|
return { files: files.length, entities: totalEntities, fileResults };
|
|
137
155
|
}
|
|
@@ -171,7 +189,53 @@ export class ValidationService {
|
|
|
171
189
|
/**
|
|
172
190
|
* Validates a single entity data object
|
|
173
191
|
*/
|
|
174
|
-
async validateEntityData(entityData, entityInfo, filePath, config, parentContext, depth = 0) {
|
|
192
|
+
async validateEntityData(entityData, entityInfo, filePath, config, parentContext, depth = 0, ownerContext) {
|
|
193
|
+
// Validate top-level keys with did-you-mean (§5.1)
|
|
194
|
+
const knownTopLevelKeys = [
|
|
195
|
+
'$schema',
|
|
196
|
+
'primaryKey',
|
|
197
|
+
'fields',
|
|
198
|
+
'collections',
|
|
199
|
+
'embeds',
|
|
200
|
+
'extension',
|
|
201
|
+
'relatedEntities',
|
|
202
|
+
'sync',
|
|
203
|
+
'__mj_sync_notes',
|
|
204
|
+
'deleteRecord',
|
|
205
|
+
'_comments',
|
|
206
|
+
];
|
|
207
|
+
for (const key of Object.keys(entityData)) {
|
|
208
|
+
if (!knownTopLevelKeys.includes(key)) {
|
|
209
|
+
const suggestion = this.findClosestKey(key, knownTopLevelKeys);
|
|
210
|
+
const didYouMean = suggestion ? ` Did you mean "${suggestion}"?` : '';
|
|
211
|
+
this.addError({
|
|
212
|
+
type: 'field',
|
|
213
|
+
severity: 'error',
|
|
214
|
+
entity: entityInfo.Name,
|
|
215
|
+
file: filePath,
|
|
216
|
+
message: `Unknown top-level property "${key}" in record.${didYouMean}`,
|
|
217
|
+
suggestion: `Allowed top-level properties are: ${knownTopLevelKeys.join(', ')}`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Check for double ownership across metadata files (§6)
|
|
222
|
+
if (entityData.primaryKey && Object.keys(entityData.primaryKey).length > 0) {
|
|
223
|
+
const claimKey = `${entityInfo.Name}:${JSON.stringify(entityData.primaryKey)}`;
|
|
224
|
+
const existingFile = this.claimedPrimaryKeys.get(claimKey);
|
|
225
|
+
if (existingFile && existingFile !== filePath) {
|
|
226
|
+
this.addError({
|
|
227
|
+
type: 'field',
|
|
228
|
+
severity: 'error',
|
|
229
|
+
entity: entityInfo.Name,
|
|
230
|
+
file: filePath,
|
|
231
|
+
message: `Record with primaryKey ${JSON.stringify(entityData.primaryKey)} on entity "${entityInfo.Name}" is already claimed by "${existingFile}". Double ownership is not allowed.`,
|
|
232
|
+
suggestion: 'Ensure records are owned by only one sync file or collection',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
this.claimedPrimaryKeys.set(claimKey, filePath);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
175
239
|
// Skip validation for deletion records - they don't need field validation or reference checks
|
|
176
240
|
if (entityData.deleteRecord?.delete === true) {
|
|
177
241
|
// Only validate that primaryKey exists for deletion records
|
|
@@ -217,9 +281,21 @@ export class ValidationService {
|
|
|
217
281
|
// block) so the required-field best-practice check can be limited to NEW
|
|
218
282
|
// records — an existing record already holds the value in the DB, so a
|
|
219
283
|
// missing NOT-NULL field in the file is not a problem and would only be noise.
|
|
220
|
-
await this.validateFields(entityData.fields, entityInfo, filePath, parentContext, !!entityData.primaryKey);
|
|
284
|
+
await this.validateFields(entityData.fields, entityInfo, filePath, parentContext, !!entityData.primaryKey, ownerContext);
|
|
221
285
|
// Track dependencies
|
|
222
286
|
this.trackEntityDependencies(entityData, entityInfo.Name, filePath);
|
|
287
|
+
// Validate extension (§4.1, §4.2)
|
|
288
|
+
if (entityData.extension) {
|
|
289
|
+
await this.validateExtension(entityData.extension, entityInfo, filePath, config, depth);
|
|
290
|
+
}
|
|
291
|
+
// Validate collections (§4.1, §8.1)
|
|
292
|
+
if (entityData.collections) {
|
|
293
|
+
await this.validateCollections(entityData.collections, entityInfo, filePath, config, depth);
|
|
294
|
+
}
|
|
295
|
+
// Validate embeds (§4.1)
|
|
296
|
+
if (entityData.embeds) {
|
|
297
|
+
await this.validateEmbeds(entityData.embeds, entityInfo, filePath, config, depth);
|
|
298
|
+
}
|
|
223
299
|
// Validate related entities
|
|
224
300
|
if (entityData.relatedEntities) {
|
|
225
301
|
for (const [relatedEntityName, relatedData] of Object.entries(entityData.relatedEntities)) {
|
|
@@ -234,6 +310,40 @@ export class ValidationService {
|
|
|
234
310
|
});
|
|
235
311
|
continue;
|
|
236
312
|
}
|
|
313
|
+
// Section 4.5 diagnostics:
|
|
314
|
+
// Check if related entity looks like an IsA child (shared PK / ParentID is this entity)
|
|
315
|
+
if (UUIDsEqual(relatedEntityInfo.ParentID, entityInfo.ID) ||
|
|
316
|
+
(relatedEntityInfo.ParentEntityInfo && relatedEntityInfo.ParentEntityInfo.Name.trim().toLowerCase() === entityInfo.Name.trim().toLowerCase())) {
|
|
317
|
+
this.addWarning({
|
|
318
|
+
type: 'bestpractice',
|
|
319
|
+
severity: 'warning',
|
|
320
|
+
entity: entityInfo.Name,
|
|
321
|
+
file: filePath,
|
|
322
|
+
message: `relatedEntities['${relatedEntityName}'] looks like an IsA child (shared PK). Use extension — see §4.1.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
// Check if a RelatedRecordCollection exists whose related entity is this child
|
|
326
|
+
const matchingCollectionRel = entityInfo.RelatedEntities?.find((rel) => {
|
|
327
|
+
if (!rel.RelatedRecordCollection)
|
|
328
|
+
return false;
|
|
329
|
+
return rel.RelatedEntity?.trim().toLowerCase() === relatedEntityName.trim().toLowerCase();
|
|
330
|
+
});
|
|
331
|
+
if (matchingCollectionRel) {
|
|
332
|
+
let colName = relatedEntityName;
|
|
333
|
+
try {
|
|
334
|
+
const parsed = JSON.parse(matchingCollectionRel.RelatedRecordCollection);
|
|
335
|
+
if (parsed.Name)
|
|
336
|
+
colName = parsed.Name;
|
|
337
|
+
}
|
|
338
|
+
catch { }
|
|
339
|
+
this.addWarning({
|
|
340
|
+
type: 'bestpractice',
|
|
341
|
+
severity: 'warning',
|
|
342
|
+
entity: entityInfo.Name,
|
|
343
|
+
file: filePath,
|
|
344
|
+
message: `relatedEntities['${relatedEntityName}'] could be collections['${colName}'].`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
237
347
|
const relatedEntities = Array.isArray(relatedData) ? relatedData : [relatedData];
|
|
238
348
|
for (const relatedEntity of relatedEntities) {
|
|
239
349
|
await this.validateEntityData(relatedEntity, relatedEntityInfo, filePath, config, { entity: entityInfo.Name, field: relatedEntityName }, depth + 1);
|
|
@@ -244,7 +354,7 @@ export class ValidationService {
|
|
|
244
354
|
/**
|
|
245
355
|
* Validates entity fields
|
|
246
356
|
*/
|
|
247
|
-
async validateFields(fields, entityInfo, filePath, parentContext, isExistingRecord = false) {
|
|
357
|
+
async validateFields(fields, entityInfo, filePath, parentContext, isExistingRecord = false, ownerContext) {
|
|
248
358
|
const entityFields = entityInfo.Fields;
|
|
249
359
|
const fieldMap = new Map(entityFields.map((f) => [f.Name, f]));
|
|
250
360
|
for (const [fieldName, fieldValue] of Object.entries(fields)) {
|
|
@@ -299,7 +409,7 @@ export class ValidationService {
|
|
|
299
409
|
continue;
|
|
300
410
|
}
|
|
301
411
|
// Validate field value and references
|
|
302
|
-
await this.validateFieldValue(fieldValue, fieldInfo, entityInfo, filePath, parentContext);
|
|
412
|
+
await this.validateFieldValue(fieldValue, fieldInfo, entityInfo, filePath, parentContext, ownerContext);
|
|
303
413
|
}
|
|
304
414
|
// Check for required fields — only for NEW records. An existing record
|
|
305
415
|
// (identified by a primaryKey block) already has values persisted in the
|
|
@@ -359,9 +469,9 @@ export class ValidationService {
|
|
|
359
469
|
/**
|
|
360
470
|
* Validates field values and references
|
|
361
471
|
*/
|
|
362
|
-
async validateFieldValue(value, fieldInfo, entityInfo, filePath, parentContext) {
|
|
472
|
+
async validateFieldValue(value, fieldInfo, entityInfo, filePath, parentContext, ownerContext) {
|
|
363
473
|
if (typeof value === 'string' && this.isValidReference(value)) {
|
|
364
|
-
await this.validateReference(value, fieldInfo, entityInfo, filePath, parentContext);
|
|
474
|
+
await this.validateReference(value, fieldInfo, entityInfo, filePath, parentContext, ownerContext);
|
|
365
475
|
// Skip further validation for references as they will be resolved later
|
|
366
476
|
return;
|
|
367
477
|
}
|
|
@@ -472,7 +582,7 @@ export class ValidationService {
|
|
|
472
582
|
/**
|
|
473
583
|
* Validates special references (@file:, @lookup:, etc.)
|
|
474
584
|
*/
|
|
475
|
-
async validateReference(reference, fieldInfo, entityInfo, filePath, parentContext) {
|
|
585
|
+
async validateReference(reference, fieldInfo, entityInfo, filePath, parentContext, ownerContext) {
|
|
476
586
|
const parsed = this.parseReference(reference);
|
|
477
587
|
if (!parsed) {
|
|
478
588
|
this.addError({
|
|
@@ -495,6 +605,9 @@ export class ValidationService {
|
|
|
495
605
|
case METADATA_KEYWORDS.TEMPLATE:
|
|
496
606
|
await this.validateTemplateReference(parsed.value, filePath, entityInfo.Name, fieldInfo.Name);
|
|
497
607
|
break;
|
|
608
|
+
case METADATA_KEYWORDS.OWNER:
|
|
609
|
+
this.validateOwnerReference(parsed.value, ownerContext, filePath, entityInfo.Name, fieldInfo.Name);
|
|
610
|
+
break;
|
|
498
611
|
case METADATA_KEYWORDS.PARENT:
|
|
499
612
|
this.validateParentReference(parsed.value, parentContext, filePath, entityInfo.Name, fieldInfo.Name);
|
|
500
613
|
break;
|
|
@@ -952,6 +1065,7 @@ export class ValidationService {
|
|
|
952
1065
|
this.warnings = [];
|
|
953
1066
|
this.entityDependencies.clear();
|
|
954
1067
|
this.processedEntities.clear();
|
|
1068
|
+
this.claimedPrimaryKeys.clear();
|
|
955
1069
|
this.userRoleCache.clear();
|
|
956
1070
|
}
|
|
957
1071
|
/**
|
|
@@ -1187,13 +1301,13 @@ export class ValidationService {
|
|
|
1187
1301
|
/**
|
|
1188
1302
|
* Recursively validates all @ references in a JSON structure
|
|
1189
1303
|
*/
|
|
1190
|
-
async validateJsonReferences(obj, sourceFile, entityName, visitedFiles, parentContext) {
|
|
1304
|
+
async validateJsonReferences(obj, sourceFile, entityName, visitedFiles, parentContext, ownerContext) {
|
|
1191
1305
|
if (obj === null || obj === undefined) {
|
|
1192
1306
|
return;
|
|
1193
1307
|
}
|
|
1194
1308
|
if (Array.isArray(obj)) {
|
|
1195
1309
|
for (const item of obj) {
|
|
1196
|
-
await this.validateJsonReferences(item, sourceFile, entityName, visitedFiles, parentContext);
|
|
1310
|
+
await this.validateJsonReferences(item, sourceFile, entityName, visitedFiles, parentContext, ownerContext);
|
|
1197
1311
|
}
|
|
1198
1312
|
}
|
|
1199
1313
|
else if (typeof obj === 'object') {
|
|
@@ -1222,6 +1336,12 @@ export class ValidationService {
|
|
|
1222
1336
|
this.validateParentReference(parsed.value, parentContext, sourceFile, entityName, key);
|
|
1223
1337
|
}
|
|
1224
1338
|
}
|
|
1339
|
+
else if (value.startsWith(METADATA_KEYWORDS.OWNER)) {
|
|
1340
|
+
const parsed = this.parseReference(value);
|
|
1341
|
+
if (parsed) {
|
|
1342
|
+
this.validateOwnerReference(parsed.value, ownerContext, sourceFile, entityName, key);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1225
1345
|
else if (value.startsWith(METADATA_KEYWORDS.ROOT)) {
|
|
1226
1346
|
const parsed = this.parseReference(value);
|
|
1227
1347
|
if (parsed) {
|
|
@@ -1245,9 +1365,329 @@ export class ValidationService {
|
|
|
1245
1365
|
}
|
|
1246
1366
|
else if (value && typeof value === 'object') {
|
|
1247
1367
|
// Recursively process nested objects
|
|
1248
|
-
await this.validateJsonReferences(value, sourceFile, entityName, visitedFiles, parentContext);
|
|
1368
|
+
await this.validateJsonReferences(value, sourceFile, entityName, visitedFiles, parentContext, ownerContext);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Validates an @owner: reference
|
|
1375
|
+
*/
|
|
1376
|
+
validateOwnerReference(ownerFieldName, ownerContext, filePath, entityName, currentFieldName) {
|
|
1377
|
+
if (!ownerContext) {
|
|
1378
|
+
this.addError({
|
|
1379
|
+
type: 'reference',
|
|
1380
|
+
severity: 'error',
|
|
1381
|
+
entity: entityName,
|
|
1382
|
+
field: currentFieldName,
|
|
1383
|
+
file: filePath,
|
|
1384
|
+
message: `@owner: cannot be used at root level or outside an embedded/extension/collection context`,
|
|
1385
|
+
suggestion: 'Use @owner: only within extension, embeds, or collections where an enclosing owner entity is present',
|
|
1386
|
+
});
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
const ownerEntityInfo = this.metadata.EntityByName(ownerContext.entity);
|
|
1390
|
+
if (ownerEntityInfo) {
|
|
1391
|
+
const ownerField = ownerEntityInfo.Fields.find((f) => f.Name.toLowerCase() === ownerFieldName.toLowerCase());
|
|
1392
|
+
if (!ownerField) {
|
|
1393
|
+
this.addError({
|
|
1394
|
+
type: 'reference',
|
|
1395
|
+
severity: 'error',
|
|
1396
|
+
entity: entityName,
|
|
1397
|
+
field: currentFieldName,
|
|
1398
|
+
file: filePath,
|
|
1399
|
+
message: `Field "${ownerFieldName}" does not exist on owner entity "${ownerContext.entity}"`,
|
|
1400
|
+
suggestion: `Verify that "${ownerContext.entity}" has a field named "${ownerFieldName}"`,
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Finds closest matching key using Levenshtein distance
|
|
1407
|
+
*/
|
|
1408
|
+
findClosestKey(target, candidates) {
|
|
1409
|
+
let closestKey = null;
|
|
1410
|
+
let minDistance = 3;
|
|
1411
|
+
const lowerTarget = target.toLowerCase();
|
|
1412
|
+
for (const candidate of candidates) {
|
|
1413
|
+
const distance = this.levenshteinDistance(lowerTarget, candidate.toLowerCase());
|
|
1414
|
+
if (distance < minDistance) {
|
|
1415
|
+
minDistance = distance;
|
|
1416
|
+
closestKey = candidate;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return closestKey;
|
|
1420
|
+
}
|
|
1421
|
+
levenshteinDistance(a, b) {
|
|
1422
|
+
const matrix = [];
|
|
1423
|
+
for (let i = 0; i <= b.length; i++) {
|
|
1424
|
+
matrix[i] = [i];
|
|
1425
|
+
}
|
|
1426
|
+
for (let j = 0; j <= a.length; j++) {
|
|
1427
|
+
matrix[0][j] = j;
|
|
1428
|
+
}
|
|
1429
|
+
for (let i = 1; i <= b.length; i++) {
|
|
1430
|
+
for (let j = 1; j <= a.length; j++) {
|
|
1431
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
1432
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
1433
|
+
}
|
|
1434
|
+
else {
|
|
1435
|
+
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
return matrix[b.length][a.length];
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Validates an extension block (§4.1, §4.2, §4.5)
|
|
1443
|
+
*/
|
|
1444
|
+
async validateExtension(extensionData, parentEntityInfo, filePath, config, depth) {
|
|
1445
|
+
if (!extensionData || typeof extensionData !== 'object') {
|
|
1446
|
+
this.addError({
|
|
1447
|
+
type: 'field',
|
|
1448
|
+
severity: 'error',
|
|
1449
|
+
entity: parentEntityInfo.Name,
|
|
1450
|
+
file: filePath,
|
|
1451
|
+
message: '"extension" must be an object',
|
|
1452
|
+
});
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
const extObj = extensionData;
|
|
1456
|
+
// Case 1: Shorthand form with explicit 'fields' object
|
|
1457
|
+
if ('fields' in extObj && typeof extObj.fields === 'object' && extObj.fields !== null) {
|
|
1458
|
+
let childEntityName;
|
|
1459
|
+
if (typeof extObj.entity === 'string' && extObj.entity.trim().length > 0) {
|
|
1460
|
+
childEntityName = extObj.entity.trim();
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
// Look up registered subtypes
|
|
1464
|
+
const childSubtypes = this.metadata.Entities.filter((e) => UUIDsEqual(e.ParentID, parentEntityInfo.ID) || UUIDsEqual(e.ParentEntityInfo?.ID, parentEntityInfo.ID));
|
|
1465
|
+
if (childSubtypes.length === 1) {
|
|
1466
|
+
childEntityName = childSubtypes[0].Name;
|
|
1467
|
+
}
|
|
1468
|
+
else if (childSubtypes.length === 0) {
|
|
1469
|
+
this.addError({
|
|
1470
|
+
type: 'entity',
|
|
1471
|
+
severity: 'error',
|
|
1472
|
+
entity: parentEntityInfo.Name,
|
|
1473
|
+
file: filePath,
|
|
1474
|
+
message: `Entity "${parentEntityInfo.Name}" has no registered IsA child subtypes`,
|
|
1475
|
+
});
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
else if (parentEntityInfo.AllowMultipleSubtypes) {
|
|
1479
|
+
this.addError({
|
|
1480
|
+
type: 'entity',
|
|
1481
|
+
severity: 'error',
|
|
1482
|
+
entity: parentEntityInfo.Name,
|
|
1483
|
+
file: filePath,
|
|
1484
|
+
message: `Entity "${parentEntityInfo.Name}" allows multiple subtypes. Shorthand extension form is ambiguous; use map form keyed by subtype entity name.`,
|
|
1485
|
+
});
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (!childEntityName) {
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const childEntityInfo = this.metadata.EntityByName(childEntityName);
|
|
1493
|
+
if (!childEntityInfo) {
|
|
1494
|
+
this.addError({
|
|
1495
|
+
type: 'entity',
|
|
1496
|
+
severity: 'error',
|
|
1497
|
+
entity: parentEntityInfo.Name,
|
|
1498
|
+
file: filePath,
|
|
1499
|
+
message: `Extension entity "${childEntityName}" not found in metadata`,
|
|
1500
|
+
});
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
const isSubtype = UUIDsEqual(childEntityInfo.ParentID, parentEntityInfo.ID) ||
|
|
1504
|
+
UUIDsEqual(childEntityInfo.ParentEntityInfo?.ID, parentEntityInfo.ID);
|
|
1505
|
+
if (!isSubtype) {
|
|
1506
|
+
this.addError({
|
|
1507
|
+
type: 'entity',
|
|
1508
|
+
severity: 'error',
|
|
1509
|
+
entity: parentEntityInfo.Name,
|
|
1510
|
+
file: filePath,
|
|
1511
|
+
message: `"${childEntityName}" is not an IsA subtype of "${parentEntityInfo.Name}"`,
|
|
1512
|
+
});
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
await this.validateExtensionFields(extObj.fields, parentEntityInfo, childEntityInfo, filePath, config, depth);
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
// Case 2: Map form where keys are subtype entity names (e.g. { "Event Order Lines": { fields: { ... } } })
|
|
1519
|
+
for (const [subKey, subVal] of Object.entries(extObj)) {
|
|
1520
|
+
if (subKey === '$schema' || subKey === 'sync' || subKey === '__mj_sync_notes')
|
|
1521
|
+
continue;
|
|
1522
|
+
const childEntityInfo = this.metadata.EntityByName(subKey);
|
|
1523
|
+
if (!childEntityInfo) {
|
|
1524
|
+
this.addError({
|
|
1525
|
+
type: 'entity',
|
|
1526
|
+
severity: 'error',
|
|
1527
|
+
entity: parentEntityInfo.Name,
|
|
1528
|
+
file: filePath,
|
|
1529
|
+
message: `Extension subtype entity "${subKey}" not found in metadata`,
|
|
1530
|
+
});
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
const isSubtype = UUIDsEqual(childEntityInfo.ParentID, parentEntityInfo.ID) ||
|
|
1534
|
+
UUIDsEqual(childEntityInfo.ParentEntityInfo?.ID, parentEntityInfo.ID);
|
|
1535
|
+
if (!isSubtype) {
|
|
1536
|
+
this.addError({
|
|
1537
|
+
type: 'entity',
|
|
1538
|
+
severity: 'error',
|
|
1539
|
+
entity: parentEntityInfo.Name,
|
|
1540
|
+
file: filePath,
|
|
1541
|
+
message: `"${subKey}" is not an IsA subtype of "${parentEntityInfo.Name}"`,
|
|
1542
|
+
});
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
if (!subVal || typeof subVal !== 'object') {
|
|
1546
|
+
this.addError({
|
|
1547
|
+
type: 'field',
|
|
1548
|
+
severity: 'error',
|
|
1549
|
+
entity: childEntityInfo.Name,
|
|
1550
|
+
file: filePath,
|
|
1551
|
+
message: `Extension for "${subKey}" must be an object with "fields"`,
|
|
1552
|
+
});
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
const subRecord = subVal;
|
|
1556
|
+
if ('fields' in subRecord && typeof subRecord.fields === 'object' && subRecord.fields !== null) {
|
|
1557
|
+
await this.validateExtensionFields(subRecord.fields, parentEntityInfo, childEntityInfo, filePath, config, depth);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Validates leaf fields within an extension block (§4.1, §4.5, §9)
|
|
1563
|
+
*/
|
|
1564
|
+
async validateExtensionFields(fields, parentEntityInfo, childEntityInfo, filePath, config, depth) {
|
|
1565
|
+
for (const fieldName of Object.keys(fields)) {
|
|
1566
|
+
const isParentField = parentEntityInfo.Fields.some((f) => f.Name.toLowerCase() === fieldName.toLowerCase());
|
|
1567
|
+
if (isParentField) {
|
|
1568
|
+
this.addError({
|
|
1569
|
+
type: 'field',
|
|
1570
|
+
severity: 'error',
|
|
1571
|
+
entity: childEntityInfo.Name,
|
|
1572
|
+
field: fieldName,
|
|
1573
|
+
file: filePath,
|
|
1574
|
+
message: `Field "${fieldName}" is owned by parent entity "${parentEntityInfo.Name}". Leaf extension must only contain subtype-specific fields.`,
|
|
1575
|
+
suggestion: `Move "${fieldName}" to the root record fields object`,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
await this.validateFields(fields, childEntityInfo, filePath, { entity: parentEntityInfo.Name, field: 'extension' }, false, { entity: parentEntityInfo.Name });
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Validates a collections block (§4.1, §8.1)
|
|
1583
|
+
*/
|
|
1584
|
+
async validateCollections(collectionsData, entityInfo, filePath, config, depth) {
|
|
1585
|
+
if (!collectionsData || typeof collectionsData !== 'object' || Array.isArray(collectionsData)) {
|
|
1586
|
+
this.addError({
|
|
1587
|
+
type: 'field',
|
|
1588
|
+
severity: 'error',
|
|
1589
|
+
entity: entityInfo.Name,
|
|
1590
|
+
file: filePath,
|
|
1591
|
+
message: '"collections" must be an object map of collection names to record arrays',
|
|
1592
|
+
});
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const colMap = collectionsData;
|
|
1596
|
+
for (const [colName, colItems] of Object.entries(colMap)) {
|
|
1597
|
+
if (!Array.isArray(colItems)) {
|
|
1598
|
+
this.addError({
|
|
1599
|
+
type: 'field',
|
|
1600
|
+
severity: 'error',
|
|
1601
|
+
entity: entityInfo.Name,
|
|
1602
|
+
file: filePath,
|
|
1603
|
+
message: `Collection "${colName}" must be an array of records. Per-record mode wrappers (e.g. {"mode": "authoritative", "items": [...]}) are forbidden; mode is directory-level only.`,
|
|
1604
|
+
});
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
const resolved = resolveCollectionRelationship(entityInfo, colName);
|
|
1608
|
+
if (!resolved) {
|
|
1609
|
+
this.addError({
|
|
1610
|
+
type: 'entity',
|
|
1611
|
+
severity: 'error',
|
|
1612
|
+
entity: entityInfo.Name,
|
|
1613
|
+
file: filePath,
|
|
1614
|
+
message: `Collection "${colName}" is not declared on entity "${entityInfo.Name}" or has no valid relationship join field`,
|
|
1615
|
+
});
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
const childEntityInfo = this.metadata.EntityByName(resolved.relatedEntity);
|
|
1619
|
+
if (!childEntityInfo) {
|
|
1620
|
+
this.addError({
|
|
1621
|
+
type: 'entity',
|
|
1622
|
+
severity: 'error',
|
|
1623
|
+
entity: entityInfo.Name,
|
|
1624
|
+
file: filePath,
|
|
1625
|
+
message: `Collection "${colName}" refers to related entity "${resolved.relatedEntity}" which is not found in metadata`,
|
|
1626
|
+
});
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
for (const item of colItems) {
|
|
1630
|
+
if (!item || typeof item !== 'object') {
|
|
1631
|
+
this.addError({
|
|
1632
|
+
type: 'field',
|
|
1633
|
+
severity: 'error',
|
|
1634
|
+
entity: childEntityInfo.Name,
|
|
1635
|
+
file: filePath,
|
|
1636
|
+
message: `Collection item in "${colName}" must be an object`,
|
|
1637
|
+
});
|
|
1638
|
+
continue;
|
|
1249
1639
|
}
|
|
1640
|
+
await this.validateEntityData(item, childEntityInfo, filePath, config, { entity: entityInfo.Name, field: colName }, depth + 1, { entity: entityInfo.Name });
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Validates an embeds block (§4.1)
|
|
1646
|
+
*/
|
|
1647
|
+
async validateEmbeds(embedsData, entityInfo, filePath, config, depth) {
|
|
1648
|
+
if (!embedsData || typeof embedsData !== 'object' || Array.isArray(embedsData)) {
|
|
1649
|
+
this.addError({
|
|
1650
|
+
type: 'field',
|
|
1651
|
+
severity: 'error',
|
|
1652
|
+
entity: entityInfo.Name,
|
|
1653
|
+
file: filePath,
|
|
1654
|
+
message: '"embeds" must be an object map of foreign key field names to record objects',
|
|
1655
|
+
});
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const embedsMap = embedsData;
|
|
1659
|
+
for (const [key, embedVal] of Object.entries(embedsMap)) {
|
|
1660
|
+
if (!embedVal || typeof embedVal !== 'object') {
|
|
1661
|
+
this.addError({
|
|
1662
|
+
type: 'field',
|
|
1663
|
+
severity: 'error',
|
|
1664
|
+
entity: entityInfo.Name,
|
|
1665
|
+
file: filePath,
|
|
1666
|
+
message: `Embedded record for "${key}" must be an object`,
|
|
1667
|
+
});
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
const field = entityInfo.Fields.find((f) => f.Name.toLowerCase() === key.toLowerCase() ||
|
|
1671
|
+
(f.Name + 'ID').toLowerCase() === key.toLowerCase() ||
|
|
1672
|
+
(key + 'ID').toLowerCase() === f.Name.toLowerCase());
|
|
1673
|
+
let targetEntityName = field?.RelatedEntity;
|
|
1674
|
+
if (!targetEntityName) {
|
|
1675
|
+
const direct = this.metadata.EntityByName(key);
|
|
1676
|
+
if (direct)
|
|
1677
|
+
targetEntityName = direct.Name;
|
|
1678
|
+
}
|
|
1679
|
+
const targetEntityInfo = targetEntityName ? this.metadata.EntityByName(targetEntityName) : null;
|
|
1680
|
+
if (!targetEntityInfo) {
|
|
1681
|
+
this.addError({
|
|
1682
|
+
type: 'field',
|
|
1683
|
+
severity: 'error',
|
|
1684
|
+
entity: entityInfo.Name,
|
|
1685
|
+
file: filePath,
|
|
1686
|
+
message: `Embedded reference "${key}" does not correspond to a foreign key or relationship on entity "${entityInfo.Name}"`,
|
|
1687
|
+
});
|
|
1688
|
+
continue;
|
|
1250
1689
|
}
|
|
1690
|
+
await this.validateEntityData(embedVal, targetEntityInfo, filePath, config, { entity: entityInfo.Name, field: key }, depth + 1, { entity: entityInfo.Name });
|
|
1251
1691
|
}
|
|
1252
1692
|
}
|
|
1253
1693
|
}
|