@memberjunction/metadata-sync 6.1.0-edge.5 → 6.1.0-edge.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +48 -3
  2. package/dist/config.d.ts +32 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/constants/metadata-keywords.d.ts +13 -2
  6. package/dist/constants/metadata-keywords.d.ts.map +1 -1
  7. package/dist/constants/metadata-keywords.js +12 -0
  8. package/dist/constants/metadata-keywords.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/RecordProcessor.d.ts +12 -0
  14. package/dist/lib/RecordProcessor.d.ts.map +1 -1
  15. package/dist/lib/RecordProcessor.js +163 -10
  16. package/dist/lib/RecordProcessor.js.map +1 -1
  17. package/dist/lib/batch-context-index.js +1 -1
  18. package/dist/lib/batch-context-index.js.map +1 -1
  19. package/dist/lib/collection-resolver.d.ts +24 -0
  20. package/dist/lib/collection-resolver.d.ts.map +1 -0
  21. package/dist/lib/collection-resolver.js +117 -0
  22. package/dist/lib/collection-resolver.js.map +1 -0
  23. package/dist/lib/database-reference-scanner.js +5 -5
  24. package/dist/lib/database-reference-scanner.js.map +1 -1
  25. package/dist/lib/deletion-auditor.d.ts.map +1 -1
  26. package/dist/lib/deletion-auditor.js +1 -0
  27. package/dist/lib/deletion-auditor.js.map +1 -1
  28. package/dist/lib/entity-subclass-guard.d.ts +10 -0
  29. package/dist/lib/entity-subclass-guard.d.ts.map +1 -0
  30. package/dist/lib/entity-subclass-guard.js +38 -0
  31. package/dist/lib/entity-subclass-guard.js.map +1 -0
  32. package/dist/lib/graph-provider-pool.d.ts +67 -0
  33. package/dist/lib/graph-provider-pool.d.ts.map +1 -0
  34. package/dist/lib/graph-provider-pool.js +149 -0
  35. package/dist/lib/graph-provider-pool.js.map +1 -0
  36. package/dist/lib/json-write-helper.d.ts +12 -8
  37. package/dist/lib/json-write-helper.d.ts.map +1 -1
  38. package/dist/lib/json-write-helper.js +54 -24
  39. package/dist/lib/json-write-helper.js.map +1 -1
  40. package/dist/lib/record-dependency-analyzer.d.ts +8 -0
  41. package/dist/lib/record-dependency-analyzer.d.ts.map +1 -1
  42. package/dist/lib/record-dependency-analyzer.js +20 -5
  43. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  44. package/dist/lib/reference-parser.d.ts.map +1 -1
  45. package/dist/lib/reference-parser.js +1 -0
  46. package/dist/lib/reference-parser.js.map +1 -1
  47. package/dist/lib/sync-engine.d.ts +27 -4
  48. package/dist/lib/sync-engine.d.ts.map +1 -1
  49. package/dist/lib/sync-engine.js +94 -45
  50. package/dist/lib/sync-engine.js.map +1 -1
  51. package/dist/lib/sync-metadata-engine.d.ts +44 -1
  52. package/dist/lib/sync-metadata-engine.d.ts.map +1 -1
  53. package/dist/lib/sync-metadata-engine.js +152 -4
  54. package/dist/lib/sync-metadata-engine.js.map +1 -1
  55. package/dist/lib/transaction-manager.d.ts +2 -1
  56. package/dist/lib/transaction-manager.d.ts.map +1 -1
  57. package/dist/lib/transaction-manager.js +4 -1
  58. package/dist/lib/transaction-manager.js.map +1 -1
  59. package/dist/plugins/index.js +3 -3
  60. package/dist/plugins/index.js.map +1 -1
  61. package/dist/services/FormattingService.d.ts +8 -0
  62. package/dist/services/FormattingService.d.ts.map +1 -1
  63. package/dist/services/FormattingService.js +18 -0
  64. package/dist/services/FormattingService.js.map +1 -1
  65. package/dist/services/PullService.js +1 -1
  66. package/dist/services/PullService.js.map +1 -1
  67. package/dist/services/PushService.d.ts +53 -3
  68. package/dist/services/PushService.d.ts.map +1 -1
  69. package/dist/services/PushService.js +455 -143
  70. package/dist/services/PushService.js.map +1 -1
  71. package/dist/services/ValidationService.d.ts +26 -0
  72. package/dist/services/ValidationService.d.ts.map +1 -1
  73. package/dist/services/ValidationService.js +454 -14
  74. package/dist/services/ValidationService.js.map +1 -1
  75. 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 filePath = path.join(dir, file);
132
- const result = await this.validateFile(filePath, entityInfo, config);
133
- totalEntities += result.entityCount;
134
- fileResults.set(filePath, result);
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
  }