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