@warp-drive/json-api 5.9.0-alpha.17 → 5.9.0-alpha.19

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.
@@ -45,8 +45,43 @@ export declare class Reporter {
45
45
  capabilities: CacheCapabilitiesManager;
46
46
  contextDocument: StructuredDocument<ResourceDocument>;
47
47
  errors: ErrorReport[];
48
- ast: ReturnType<typeof jsonToAst>;
49
- jsonStr: string;
48
+ /**
49
+ * The maximum number of source lines the reporter will annotate and emit
50
+ * in a single `console.log` call when producing a report. Documents with
51
+ * more lines than this are chunked across multiple calls so that no
52
+ * single call ever has to spread an unbounded number of colorization
53
+ * args (which can exceed engine call-argument/stack limits for very
54
+ * large payloads).
55
+ */
56
+ maxLines: number;
57
+ /**
58
+ * The number of source lines to show before/after each reported line when
59
+ * annotating the document. Stretches of source between annotated lines
60
+ * that are larger than this get collapsed into a single
61
+ * `... N lines skipped ...` marker rather than printed in full.
62
+ */
63
+ contextLines: number;
64
+ /**
65
+ * The number of occurrences of an identical error message to annotate
66
+ * inline before collapsing the rest. When a message recurs beyond this
67
+ * count, the last shown occurrence's annotation is suffixed with
68
+ * `(recurs N more times)` and the remaining occurrences are omitted from
69
+ * the printed document entirely (though they still count toward the
70
+ * totals in the summary line).
71
+ */
72
+ maxOccurrencesPerGroup: number;
73
+ /**
74
+ * The number of distinct error messages to annotate inline before
75
+ * refusing to show any more. Once this many distinct messages have been
76
+ * shown, further distinct messages are omitted entirely and rolled up
77
+ * into a single trailing `... and N more distinct issues ... not shown`
78
+ * line so nothing is dropped silently.
79
+ */
80
+ maxDistinctIssues: number;
81
+ _ast: ReturnType<typeof jsonToAst> | undefined;
82
+ _jsonStr: string | undefined;
83
+ get jsonStr(): string;
84
+ get ast(): ReturnType<typeof jsonToAst>;
50
85
  strict: {
51
86
  linkage: boolean;
52
87
  unknownType: boolean;
package/dist/index.js CHANGED
@@ -60,7 +60,7 @@ function validateResourceFields(schema, resource, options) {
60
60
  case 'hasMany':
61
61
  {
62
62
  if (field.options.linksMode) {
63
- validateHasManyToLinksMode(resourceType, field);
63
+ validateHasManyToLinksMode(resourceType, field, relationshipDoc, options);
64
64
  }
65
65
  break;
66
66
  }
@@ -71,31 +71,105 @@ function validateBelongsToLinksMode(resourceType, field, relationshipDoc, option
71
71
  if (field.options.async) {
72
72
  throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but async is not yet supported`);
73
73
  }
74
- if (!relationshipDoc.links?.related) {
75
- throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the related link is missing`);
74
+ if (!field.options.async) {
75
+ const relationshipData = relationshipDoc.data;
76
+ if (Array.isArray(relationshipData)) {
77
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the relationship data for a belongsTo relationship is unexpectedly an array`);
78
+ }
79
+
80
+ /**
81
+ * If we are sync, we must have a related link when we have no related data field
82
+ *
83
+ * We explicitly allow `null`! Missing key or `undefined` are always invalid.
84
+ */
85
+ if (relationshipData === undefined && !relationshipDoc.links?.related) {
86
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the relationship data is undefined and no link is present`);
87
+ }
88
+
89
+ /**
90
+ * Nothing more to verify since we are empty
91
+ */
92
+ if (!relationshipData) {
93
+ return;
94
+ }
95
+
96
+ /**
97
+ * We are explicitly asked to not verify full-linkage
98
+ */
99
+ if (!options.verifyIncluded) {
100
+ return;
101
+ }
102
+
103
+ /**
104
+ * If we have a link, full-linkage verification is not required.
105
+ */
106
+ if (relationshipDoc.links?.related) {
107
+ return;
108
+ }
109
+
110
+ /**
111
+ * If we are sync and have relationship data, we must have full linkage to an included resource
112
+ */
113
+ const includedDoc = options.included?.find(doc => doc.type === relationshipData.type && doc.id === relationshipData.id);
114
+ if (!includedDoc) {
115
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the related data is not included`);
116
+ }
117
+ } else {
118
+ /**
119
+ * If we are async, we must have a related link.
120
+ */
121
+ if (!relationshipDoc.links?.related) {
122
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the related link is missing`);
123
+ }
124
+ }
125
+ }
126
+ function validateHasManyToLinksMode(resourceType, field, relationshipDoc, options) {
127
+ if (field.options.async) {
128
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but async hasMany is not yet supported`);
76
129
  }
77
130
  const relationshipData = relationshipDoc.data;
78
- if (Array.isArray(relationshipData)) {
79
- throw new Error(`Cannot fetch ${resourceType}.${field.name} because the relationship data for a belongsTo relationship is unexpectedly an array`);
131
+ if (relationshipData !== undefined && !Array.isArray(relationshipData)) {
132
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the relationship data for a hasMany relationship is unexpectedly not an array`);
80
133
  }
81
- // Explicitly allow `null`! Missing key or `undefined` are always invalid.
82
- if (relationshipData === undefined) {
83
- throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the relationship data is undefined`);
134
+
135
+ /**
136
+ * If we are sync, we must have a related link when we have no related data field
137
+ *
138
+ * We explicitly allow an empty array! Missing key or `undefined` are always invalid.
139
+ */
140
+ if (relationshipData === undefined && !relationshipDoc.links?.related) {
141
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the relationship data is undefined and no link is present`);
84
142
  }
85
- if (relationshipData === null) {
143
+
144
+ /**
145
+ * Nothing more to verify since we are empty
146
+ */
147
+ if (!relationshipData || relationshipData.length === 0) {
86
148
  return;
87
149
  }
150
+
151
+ /**
152
+ * We are explicitly asked to not verify full-linkage
153
+ */
88
154
  if (!options.verifyIncluded) {
89
155
  return;
90
156
  }
91
- const includedDoc = options.included?.find(doc => doc.type === relationshipData.type && doc.id === relationshipData.id);
92
- if (!includedDoc) {
93
- throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the related data is not included`);
157
+
158
+ /**
159
+ * If we have a link, full-linkage verification is not required.
160
+ */
161
+ if (relationshipDoc.links?.related) {
162
+ return;
94
163
  }
95
- }
96
- function validateHasManyToLinksMode(resourceType, field, _relationshipDoc, _options) {
97
- if (field.options.async) {
98
- throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but async hasMany is not yet supported`);
164
+
165
+ /**
166
+ * If we are sync and have relationship data, we must have full linkage to included resources
167
+ */
168
+ for (const identifier of relationshipData) {
169
+ const includedDoc = options.included?.find(doc => doc.type === identifier.type && doc.id === identifier.id);
170
+ if (!includedDoc) {
171
+ throw new Error(`Cannot fetch ${resourceType}.${field.name} because the field is in linksMode but the related data is not included`);
172
+ }
99
173
  }
100
174
  }
101
175
 
@@ -163,8 +237,60 @@ class Reporter {
163
237
  capabilities;
164
238
  contextDocument;
165
239
  errors = [];
166
- ast;
167
- jsonStr;
240
+
241
+ /**
242
+ * The maximum number of source lines the reporter will annotate and emit
243
+ * in a single `console.log` call when producing a report. Documents with
244
+ * more lines than this are chunked across multiple calls so that no
245
+ * single call ever has to spread an unbounded number of colorization
246
+ * args (which can exceed engine call-argument/stack limits for very
247
+ * large payloads).
248
+ */
249
+ maxLines = 500;
250
+
251
+ /**
252
+ * The number of source lines to show before/after each reported line when
253
+ * annotating the document. Stretches of source between annotated lines
254
+ * that are larger than this get collapsed into a single
255
+ * `... N lines skipped ...` marker rather than printed in full.
256
+ */
257
+ contextLines = 2;
258
+
259
+ /**
260
+ * The number of occurrences of an identical error message to annotate
261
+ * inline before collapsing the rest. When a message recurs beyond this
262
+ * count, the last shown occurrence's annotation is suffixed with
263
+ * `(recurs N more times)` and the remaining occurrences are omitted from
264
+ * the printed document entirely (though they still count toward the
265
+ * totals in the summary line).
266
+ */
267
+ maxOccurrencesPerGroup = 1;
268
+
269
+ /**
270
+ * The number of distinct error messages to annotate inline before
271
+ * refusing to show any more. Once this many distinct messages have been
272
+ * shown, further distinct messages are omitted entirely and rolled up
273
+ * into a single trailing `... and N more distinct issues ... not shown`
274
+ * line so nothing is dropped silently.
275
+ */
276
+ maxDistinctIssues = 50;
277
+ // lazy: only parse the document into a string/AST if we actually need to
278
+ // locate an error, warning, or info within it. Clean documents (the vast
279
+ // majority) never pay this cost.
280
+ get jsonStr() {
281
+ if (this._jsonStr === undefined) {
282
+ this._jsonStr = JSON.stringify(this.contextDocument.content, null, 2);
283
+ }
284
+ return this._jsonStr;
285
+ }
286
+ get ast() {
287
+ if (!this._ast) {
288
+ this._ast = jsonToAst(this.jsonStr, {
289
+ loc: true
290
+ });
291
+ }
292
+ return this._ast;
293
+ }
168
294
 
169
295
  // TODO @runspired make this configurable to consuming apps before
170
296
  // activating by default
@@ -213,10 +339,6 @@ class Reporter {
213
339
  constructor(capabilities, doc) {
214
340
  this.capabilities = capabilities;
215
341
  this.contextDocument = doc;
216
- this.jsonStr = JSON.stringify(doc.content, null, 2);
217
- this.ast = jsonToAst(this.jsonStr, {
218
- loc: true
219
- });
220
342
  }
221
343
  searchTypes(type) {
222
344
  if (!this._typeFilter) {
@@ -343,8 +465,6 @@ class Reporter {
343
465
  return REGISTERED_EXTENSIONS.get(extensionName);
344
466
  }
345
467
  report(colorize = true) {
346
- const lines = this.jsonStr.split('\n');
347
-
348
468
  // sort the errors by line, then by column, then by type
349
469
  const {
350
470
  errors
@@ -352,59 +472,163 @@ class Reporter {
352
472
  if (!errors.length) {
353
473
  return;
354
474
  }
475
+ const lines = this.jsonStr.split('\n');
355
476
  errors.sort((a, b) => {
356
477
  return a.loc.end.line < b.loc.end.line ? -1 : a.loc.end.column < b.loc.end.column ? -1 : compareType(a.type, b.type);
357
478
  });
358
479
 
359
- // store the errors in a map by line
360
- const errorMap = new Map();
361
- for (const error of errors) {
362
- const line = error.loc.end.line;
363
- if (!errorMap.has(line)) {
364
- errorMap.set(line, []);
365
- }
366
- errorMap.get(line).push(error);
367
- }
368
-
369
- // splice the errors into the lines
370
- const errorLines = [];
371
- const colors = [];
480
+ // counts reflect every error/warning/info regardless of whether it ends
481
+ // up annotated inline below, so the header total is always accurate.
372
482
  const counts = {
373
483
  error: 0,
374
484
  warning: 0,
375
485
  info: 0
376
486
  };
377
- const LINE_SIZE = String(lines.length).length;
378
- for (let i = 0; i < lines.length; i++) {
379
- const line = lines[i];
380
- errorLines.push(colorize ? `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t%c${line}%c` : `${String(i + 1).padEnd(LINE_SIZE, ' ')} \t${line}`);
381
- colors.push(`color: grey; background-color: transparent;`,
382
- // first color sets color
383
- `color: inherit; background-color: transparent;` // second color resets the color profile
384
- );
385
- if (errorMap.has(i + 1)) {
386
- const errorsForLine = errorMap.get(i + 1);
387
- for (const error of errorsForLine) {
388
- counts[error.type]++;
389
- const {
390
- loc,
391
- message
392
- } = error;
393
- const start = loc.end.line === loc.start.line ? loc.start.column - 1 : loc.end.column - 1;
394
- const end = loc.end.column - 1;
395
- const symbol = error.type === 'error' ? '❌' : error.type === 'warning' ? '⚠️' : 'ℹ️';
396
- const errorLine = colorize ? `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}%c^${'~'.repeat(end - start)} %c//%c ${message}%c` : `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}^${'~'.repeat(end - start)} // ${message}`;
397
- errorLines.push(errorLine);
398
- colors.push(error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: grey;', error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: inherit; background-color: transparent;' // reset color
399
- );
487
+ for (const error of errors) {
488
+ counts[error.type]++;
489
+ }
490
+ const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
491
+
492
+ // group identical messages together so repeats can be collapsed down to
493
+ // a representative sample instead of printed in full.
494
+ const groups = new Map();
495
+ for (const error of errors) {
496
+ let group = groups.get(error.message);
497
+ if (!group) {
498
+ group = [];
499
+ groups.set(error.message, group);
500
+ }
501
+ group.push(error);
502
+ }
503
+ const activeErrors = new Set();
504
+ const recurrenceNoteFor = new Map();
505
+ let shownGroupCount = 0;
506
+ let hiddenGroupCount = 0;
507
+ let hiddenOccurrenceCount = 0;
508
+ for (const group of groups.values()) {
509
+ if (shownGroupCount < this.maxDistinctIssues) {
510
+ shownGroupCount++;
511
+ const showCount = Math.min(this.maxOccurrencesPerGroup, group.length);
512
+ for (let i = 0; i < showCount; i++) {
513
+ activeErrors.add(group[i]);
514
+ }
515
+ const extra = group.length - showCount;
516
+ if (extra > 0) {
517
+ recurrenceNoteFor.set(group[showCount - 1], extra);
400
518
  }
519
+ } else {
520
+ hiddenGroupCount++;
521
+ hiddenOccurrenceCount += group.length;
401
522
  }
402
523
  }
403
- const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
404
- const errorString = contextStr + `\n\n` + errorLines.join('\n');
405
524
 
406
- // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
407
- colorize ? console.log(errorString, ...colors) : console.log(errorString);
525
+ // store the active (to-be-annotated) errors in a map by line
526
+ const errorMap = new Map();
527
+ for (const error of activeErrors) {
528
+ const line = error.loc.end.line;
529
+ let errorsForLine = errorMap.get(line);
530
+ if (!errorsForLine) {
531
+ errorsForLine = [];
532
+ errorMap.set(line, errorsForLine);
533
+ }
534
+ errorsForLine.push(error);
535
+ }
536
+
537
+ // determine which stretches of source to display: a window of
538
+ // `contextLines` around every active error line, merging windows that
539
+ // are close enough together that a skip marker wouldn't save anything.
540
+ const MERGE_GAP = 3;
541
+ const activeLines = Array.from(errorMap.keys()).sort((a, b) => a - b);
542
+ const ranges = [];
543
+ for (const line of activeLines) {
544
+ const start = Math.max(1, line - this.contextLines);
545
+ const end = Math.min(lines.length, line + this.contextLines);
546
+ const lastRange = ranges[ranges.length - 1];
547
+ if (lastRange && start <= lastRange[1] + MERGE_GAP + 1) {
548
+ lastRange[1] = Math.max(lastRange[1], end);
549
+ } else {
550
+ ranges.push([start, end]);
551
+ }
552
+ }
553
+
554
+ // extend the first/last range to the document boundary when the
555
+ // leading/trailing stretch is too small for a skip marker to be worth it
556
+ const firstRange = ranges[0];
557
+ if (firstRange && firstRange[0] - 1 <= MERGE_GAP + 1) {
558
+ firstRange[0] = 1;
559
+ }
560
+ const lastRangeOverall = ranges[ranges.length - 1];
561
+ if (lastRangeOverall && lines.length - lastRangeOverall[1] <= MERGE_GAP + 1) {
562
+ lastRangeOverall[1] = lines.length;
563
+ }
564
+
565
+ // render into chunks so that no single `console.log` call has to spread
566
+ // more than `this.maxLines` worth of rendered lines as colorization
567
+ // args.
568
+ const chunks = [{
569
+ text: [],
570
+ colors: []
571
+ }];
572
+ let renderedCount = 0;
573
+ const nextLine = (text, lineColors) => {
574
+ if (renderedCount > 0 && renderedCount % this.maxLines === 0) {
575
+ chunks.push({
576
+ text: [],
577
+ colors: []
578
+ });
579
+ }
580
+ const chunk = chunks[chunks.length - 1];
581
+ chunk.text.push(text);
582
+ chunk.colors.push(...lineColors);
583
+ renderedCount++;
584
+ };
585
+ const pushSkipMarker = gap => {
586
+ nextLine(colorize ? `%c... ${gap} line${gap === 1 ? '' : 's'} skipped (no errors) ...%c` : `... ${gap} line${gap === 1 ? '' : 's'} skipped (no errors) ...`, ['color: grey; font-style: italic;', 'color: inherit; background-color: transparent;']);
587
+ };
588
+ const LINE_SIZE = String(lines.length).length;
589
+ for (let r = 0; r < ranges.length; r++) {
590
+ const [rangeStart, rangeEnd] = ranges[r];
591
+ if (r === 0) {
592
+ if (rangeStart > 1) {
593
+ pushSkipMarker(rangeStart - 1);
594
+ }
595
+ } else {
596
+ const gap = rangeStart - ranges[r - 1][1] - 1;
597
+ if (gap > 0) {
598
+ pushSkipMarker(gap);
599
+ }
600
+ }
601
+ for (let i = rangeStart; i <= rangeEnd; i++) {
602
+ const line = lines[i - 1];
603
+ nextLine(colorize ? `${String(i).padEnd(LINE_SIZE, ' ')} \t%c${line}%c` : `${String(i).padEnd(LINE_SIZE, ' ')} \t${line}`, [`color: grey; background-color: transparent;`, `color: inherit; background-color: transparent;`]);
604
+ if (errorMap.has(i)) {
605
+ for (const error of errorMap.get(i)) {
606
+ const {
607
+ loc
608
+ } = error;
609
+ const start = loc.end.line === loc.start.line ? loc.start.column - 1 : loc.end.column - 1;
610
+ const end = loc.end.column - 1;
611
+ const symbol = error.type === 'error' ? '❌' : error.type === 'warning' ? '⚠️' : 'ℹ️';
612
+ const extra = recurrenceNoteFor.get(error);
613
+ const message = extra ? `${error.message} (recurs ${extra} more time${extra === 1 ? '' : 's'})` : error.message;
614
+ nextLine(colorize ? `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}%c^${'~'.repeat(end - start)} %c//%c ${message}%c` : `${''.padStart(LINE_SIZE, ' ') + symbol}\t${' '.repeat(start)}^${'~'.repeat(end - start)} // ${message}`, [error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: grey;', error.type === 'error' ? 'color: red;' : error.type === 'warning' ? 'color: orange;' : 'color: blue;', 'color: inherit; background-color: transparent;']);
615
+ }
616
+ }
617
+ }
618
+ }
619
+ const lastRenderedRange = ranges[ranges.length - 1];
620
+ if (lastRenderedRange && lastRenderedRange[1] < lines.length) {
621
+ pushSkipMarker(lines.length - lastRenderedRange[1]);
622
+ }
623
+ if (hiddenGroupCount > 0) {
624
+ nextLine(colorize ? `%c... and ${hiddenGroupCount} more distinct issue${hiddenGroupCount === 1 ? '' : 's'} (${hiddenOccurrenceCount} occurrence${hiddenOccurrenceCount === 1 ? '' : 's'}) not shown ...%c` : `... and ${hiddenGroupCount} more distinct issues (${hiddenOccurrenceCount} occurrences) not shown ...`, ['color: grey; font-style: italic;', 'color: inherit; background-color: transparent;']);
625
+ }
626
+ chunks.forEach((chunk, index) => {
627
+ const prefix = index === 0 ? `${contextStr}\n\n` : '';
628
+ const chunkString = prefix + chunk.text.join('\n');
629
+ // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
630
+ colorize ? console.log(chunkString, ...chunk.colors) : console.log(chunkString);
631
+ });
408
632
  if (macroCondition(getGlobalConfig().WarpDrive.features.JSON_API_CACHE_VALIDATION_ERRORS)) {
409
633
  if (counts.error > 0) {
410
634
  throw new Error(contextStr);
@@ -465,6 +689,27 @@ function getRemoteField(fields, key) {
465
689
  }
466
690
  return field;
467
691
  }
692
+
693
+ /**
694
+ * Detects the common mistake of providing a field's `name` in a payload
695
+ * when the field's schema defines a `sourceKey` that should be used instead.
696
+ *
697
+ * @internal
698
+ */
699
+ function getSourceKeyMismatch(fields, key) {
700
+ const field = getRemoteField(fields, key);
701
+ if (!field) {
702
+ return undefined;
703
+ }
704
+ const sourceKey = 'sourceKey' in field ? field.sourceKey : undefined;
705
+ if (sourceKey && sourceKey !== key) {
706
+ return {
707
+ field,
708
+ sourceKey
709
+ };
710
+ }
711
+ return undefined;
712
+ }
468
713
  function addResourceToMap(map, resource, index, location) {
469
714
  if (!map.has(resource.type)) {
470
715
  map.set(resource.type, new Map());
@@ -856,7 +1101,11 @@ function validateResourceAttributes(reporter, type, resource, path) {
856
1101
  if (!field && actualField) {
857
1102
  reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's attributes. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
858
1103
  } else if (!field) {
859
- if (key.includes(':')) {
1104
+ const sourceKeyMismatch = getSourceKeyMismatch(fields, key);
1105
+ if (sourceKeyMismatch) {
1106
+ const method = reporter.strict.unknownAttribute ? 'error' : 'warn';
1107
+ reporter[method]([...path, key], `Expected the "${sourceKeyMismatch.field.kind}" field "${key}" to be provided using its sourceKey "${sourceKeyMismatch.sourceKey}" instead of its field name "${key}". Update the payload to use "${sourceKeyMismatch.sourceKey}" as the key, or remove the sourceKey from the field's definition in the ResourceSchema for "${type}" if the field name should be used instead.`);
1108
+ } else if (key.includes(':')) {
860
1109
  const extensionName = key.split(':')[0];
861
1110
  if (reporter.hasExtension(extensionName)) {
862
1111
  const extension = reporter.getExtension(extensionName);
@@ -881,16 +1130,23 @@ function validateResourceAttributes(reporter, type, resource, path) {
881
1130
  // TODO @runspired we should validate that field values are valid JSON and not instances
882
1131
  }
883
1132
  function validateResourceRelationships(reporter, type, resource, path) {
884
- const schema = reporter.schema.fields({
1133
+ const fields = reporter.schema.fields({
885
1134
  type
886
1135
  });
1136
+ const cacheFields = reporter.schema.cacheFields?.({
1137
+ type
1138
+ }) ?? fields;
887
1139
  for (const [key] of Object.entries(resource)) {
888
- const field = getRemoteField(schema, key);
889
- const actualField = schema.get(key);
1140
+ const field = getRemoteField(cacheFields, key);
1141
+ const actualField = cacheFields.get(key);
890
1142
  if (!field && actualField) {
891
1143
  reporter.warn([...path, key], `Expected the ${actualField.kind} field "${key}" to not have its own data in the ResourceObject's relationships. Likely this field should either not be returned in this payload or the field definition should be updated in the schema.`);
892
1144
  } else if (!field) {
893
- if (key.includes(':')) {
1145
+ const sourceKeyMismatch = getSourceKeyMismatch(fields, key);
1146
+ if (sourceKeyMismatch) {
1147
+ const method = reporter.strict.unknownRelationship ? 'error' : 'warn';
1148
+ reporter[method]([...path, key], `Expected the "${sourceKeyMismatch.field.kind}" field "${key}" to be provided using its sourceKey "${sourceKeyMismatch.sourceKey}" instead of its field name "${key}". Update the payload to use "${sourceKeyMismatch.sourceKey}" as the key, or remove the sourceKey from the field's definition in the ResourceSchema for "${type}" if the field name should be used instead.`);
1149
+ } else if (key.includes(':')) {
894
1150
  const extensionName = key.split(':')[0];
895
1151
  if (reporter.hasExtension(extensionName)) {
896
1152
  const extension = reporter.getExtension(extensionName);