@scalar/validation 0.6.4 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @scalar/validation
2
2
 
3
+ ## 0.6.6
4
+
5
+ ### Patch Changes
6
+
7
+ - [#10417](https://github.com/scalar/scalar/pull/10417): Reduce union scoring overhead for literals, arrays, records, and missing optional values while preserving branch selection.
8
+
9
+ ## 0.6.5
10
+
11
+ ### Patch Changes
12
+
13
+ - [#10315](https://github.com/scalar/scalar/pull/10315): Bound how deep union scoring looks into a value, so `coerce` no longer takes exponential time on recursive unions. `coerce` now also stops at a nesting depth of 1,000 calls instead of overflowing the stack. It leaves deeper values unchanged and logs a warning.
14
+
3
15
  ## 0.6.4
4
16
 
5
17
  ### Patch Changes
@@ -1 +1 @@
1
- {"version":3,"file":"coerce.d.ts","sourceRoot":"","sources":["../src/coerce.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAGrC;;;;;;;;;GASG;AACH,KAAK,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AA+SxC;;;;;;;;;;;;;GAaG;AACH,KAAK,UAAU,CAAC,CAAC,SAAS,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;AAE1E;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,MAAM,GAAI,CAAC,SAAS,MAAM,EACrC,QAAQ,CAAC,EACT,OAAO,OAAO,EACd,QAAO,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAiB,EAC5D,YAAW,SAAyB,KACnC,UAAU,CAAC,CAAC,CAAkE,CAAA"}
1
+ {"version":3,"file":"coerce.d.ts","sourceRoot":"","sources":["../src/coerce.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AA6CrC;;;;;;;;;GASG;AACH,KAAK,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AA+WxC;;;;;;;;;;;;;GAaG;AACH,KAAK,UAAU,CAAC,CAAC,SAAS,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;AAE1E;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,MAAM,GAAI,CAAC,SAAS,MAAM,EACrC,QAAQ,CAAC,EACT,OAAO,OAAO,EACd,QAAO,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAiB,EAC5D,YAAW,SAAyB,KACnC,UAAU,CAAC,CAAC,CAAyF,CAAA"}
package/dist/coerce.js CHANGED
@@ -1,5 +1,44 @@
1
1
  import { isObject } from './helpers/is-object.js';
2
2
  import { validate } from './validate.js';
3
+ /**
4
+ * How many object levels below a union node `scoreUnion` descends before it
5
+ * stops scoring child values. Picking a union branch is a local decision, so
6
+ * the shape near the union node is what matters. Scoring the whole subtree
7
+ * made the cost grow as 2^depth on recursive unions (a few hundred bytes of
8
+ * JSON could block the main thread for seconds). It also let a branch win
9
+ * just because the value under it happened to be deep.
10
+ *
11
+ * Three is a safety margin, not a derived minimum. The existing
12
+ * branch-selection tests only need the discriminator to be scored, which
13
+ * happens at any depth.
14
+ */
15
+ const MAX_VALUE_DEPTH = 3;
16
+ /**
17
+ * How many `lazy` nodes deep one scoring path may go before it returns a
18
+ * neutral score. `lazy` is the only way to build an infinite schema, so this
19
+ * stops schema cycles that never descend into a value, such as
20
+ * `T = lazy(() => union([T, string()]))` scored against `'s'`.
21
+ *
22
+ * It limits depth, not fan-out. The in-progress guard only tracks objects, so
23
+ * a schema cycle that branches over a primitive can still do a lot of work.
24
+ * Only a schema author can build one of those, and the OpenAPI and AsyncAPI
25
+ * schemas do not contain one.
26
+ */
27
+ const MAX_LAZY_DEPTH = 64;
28
+ /**
29
+ * How many nested `coerceInner` calls one `coerce` call makes before it
30
+ * stops and returns the remaining value unchanged. This has to fire before
31
+ * the JavaScript stack overflows, or it does nothing. Without it, a deeply
32
+ * nested document, or a schema cycle over a primitive such as
33
+ * `lazy(() => union([T, string()]))`, throws a `RangeError`.
34
+ *
35
+ * Measured against the real OpenAPI Schema Object in Node, the stack
36
+ * overflows at about 3,200 nested calls. That comes from 6 to 11 calls per
37
+ * document level, depending on shape. We stop at roughly a third of that,
38
+ * because browsers, web workers and the caller's own frames can leave less
39
+ * stack. Schema Objects nested 90 to 165 levels deep still coerce fully.
40
+ */
41
+ const MAX_COERCE_DEPTH = 1000;
3
42
  const resolveLazy = (schema, lazyCache) => {
4
43
  const cached = lazyCache.get(schema);
5
44
  if (cached) {
@@ -45,15 +84,44 @@ const isDiscriminatorProperty = (schema) => {
45
84
  * Markers are removed in `finally` so sibling union branches that share a
46
85
  * schema reference are scored independently rather than inheriting a stale
47
86
  * "in cycle" marker.
87
+ *
88
+ * The marker only stops *nested* re-entry. It does not stop the same pair
89
+ * being scored again through a sibling path, so it cannot bound the work on
90
+ * its own. Two budgets do that, each checked in the branch that uses it:
91
+ * - `valueDepth` counts descents into object properties below the union node.
92
+ * Past {@link MAX_VALUE_DEPTH}, non-discriminator properties score a flat `1`
93
+ * and are not descended into.
94
+ * - `lazyDepth` counts `lazy` nodes on the current path, capped at {@link MAX_LAZY_DEPTH}.
95
+ *
96
+ * Every schema node still runs its own type check at the cap. So an `object`
97
+ * schema against a string still scores `0`, and the budgets only cut off
98
+ * descent into child values.
48
99
  */
49
- const scoreUnion = (schema, value, lazyCache, scoringCache = new WeakMap()) => {
100
+ const scoreUnion = (schema, value, lazyCache, scoringCache = new WeakMap(), valueDepth = 0, lazyDepth = 0) => {
101
+ // These scores do not descend into the value, so they cannot encounter a cycle.
102
+ // In particular, literals are common in discriminator and enum unions and do
103
+ // not need a separate validation traversal or its cache allocation.
104
+ if (schema.type === 'literal') {
105
+ // Keep strict equality in sync with the literal branch of validateInner in validate.ts.
106
+ return value === schema.value ? 1 : 0;
107
+ }
108
+ if (schema.type === 'array') {
109
+ return Array.isArray(value) ? 1 : 0;
110
+ }
111
+ if (schema.type === 'record') {
112
+ // TODO: implement smarter scoring for records
113
+ return isObject(value) ? 1 : 0;
114
+ }
115
+ if (schema.type === 'optional' && value === undefined) {
116
+ return 1;
117
+ }
118
+ const trackable = isObject(value);
50
119
  // Short-circuit on cycles: this exact (value, schema) pair is already being
51
120
  // scored higher up the call stack. The enclosing call's score subsumes any
52
121
  // contribution we could compute here, so return a neutral positive score.
53
- if (isObject(value) && scoringCache.get(value)?.has(schema)) {
122
+ if (trackable && scoringCache.get(value)?.has(schema)) {
54
123
  return 1;
55
124
  }
56
- const trackable = isObject(value);
57
125
  if (trackable) {
58
126
  const schemas = scoringCache.get(value) ?? new Set();
59
127
  schemas.add(schema);
@@ -61,7 +129,7 @@ const scoreUnion = (schema, value, lazyCache, scoringCache = new WeakMap()) => {
61
129
  }
62
130
  try {
63
131
  if (schema.type === 'object') {
64
- if (!isObject(value)) {
132
+ if (!trackable) {
65
133
  return 0;
66
134
  }
67
135
  const keys = Object.keys(schema.properties);
@@ -81,41 +149,49 @@ const scoreUnion = (schema, value, lazyCache, scoringCache = new WeakMap()) => {
81
149
  }
82
150
  const propSchema = schema.properties[key];
83
151
  const raw = value[key];
84
- const base = scoreUnion(propSchema, raw, lazyCache, scoringCache);
85
- if (isDiscriminatorProperty(propSchema)) {
152
+ const isDiscriminator = isDiscriminatorProperty(propSchema);
153
+ // Past the depth budget we stop descending into child values. Discriminators are still
154
+ // scored in full: they are finite trees of literals, optionals and unions (never `lazy`)
155
+ // that do not descend into the value, so this stays cheap. It also keeps a tag like
156
+ // `kind: literal('b')` deciding the branch when it sits below the budget.
157
+ const base = valueDepth >= MAX_VALUE_DEPTH
158
+ ? isDiscriminator
159
+ ? scoreUnion(propSchema, raw, lazyCache, scoringCache, valueDepth, lazyDepth)
160
+ : 1
161
+ : scoreUnion(propSchema, raw, lazyCache, scoringCache, valueDepth + 1, lazyDepth);
162
+ if (isDiscriminator) {
86
163
  return acc + (base > 0 ? base * 10 : 0);
87
164
  }
88
165
  return acc + (base > 0 ? base : 1);
89
166
  }, 0);
90
167
  }
91
- if (schema.type === 'array') {
92
- // Score 1 if value is an array, otherwise 0
93
- return Array.isArray(value) ? 1 : 0;
94
- }
95
- if (schema.type === 'record') {
96
- // TODO: implement smarter scoring for records (just a placeholder for now)
97
- return isObject(value) ? 1 : 0;
98
- }
99
168
  if (schema.type === 'optional') {
100
- return value === undefined ? 1 : scoreUnion(schema.schema, value, lazyCache, scoringCache);
169
+ return scoreUnion(schema.schema, value, lazyCache, scoringCache, valueDepth, lazyDepth);
101
170
  }
102
171
  if (schema.type === 'union') {
103
172
  // For a union, use the highest score among all sub-schemas
104
- return Math.max(...schema.schemas.map((branch) => scoreUnion(branch, value, lazyCache, scoringCache)));
173
+ return Math.max(...schema.schemas.map((branch) => scoreUnion(branch, value, lazyCache, scoringCache, valueDepth, lazyDepth)));
105
174
  }
106
175
  if (schema.type === 'intersection') {
107
176
  if (schema.schemas.length === 0) {
108
177
  return 1;
109
178
  }
110
- return schema.schemas.reduce((acc, sub) => acc + scoreUnion(sub, value, lazyCache, scoringCache), 0);
179
+ return schema.schemas.reduce((acc, sub) => acc + scoreUnion(sub, value, lazyCache, scoringCache, valueDepth, lazyDepth), 0);
111
180
  }
112
181
  if (schema.type === 'lazy') {
182
+ // We cannot know the type without resolving, so a neutral score is the only option here.
183
+ if (lazyDepth >= MAX_LAZY_DEPTH) {
184
+ return 1;
185
+ }
113
186
  // For a lazy schema, evaluate the inner schema and recurse
114
- return scoreUnion(resolveLazy(schema, lazyCache), value, lazyCache, scoringCache);
187
+ return scoreUnion(resolveLazy(schema, lazyCache), value, lazyCache, scoringCache, valueDepth, lazyDepth + 1);
115
188
  }
116
189
  if (schema.type === 'evaluate') {
117
- // For an evaluate schema, evaluate the expression and recurse
118
- return scoreUnion(schema.schema, schema.expression(value), lazyCache, scoringCache);
190
+ // For an evaluate schema, evaluate the expression and recurse. This spends no budget: the
191
+ // inner `object` branch still stops descending at the cap, and `lazy` still bounds cycles.
192
+ // Only a schema that points `evaluate` back at itself without a `lazy` in between could
193
+ // loop, and the builders in `schema.ts` cannot construct one.
194
+ return scoreUnion(schema.schema, schema.expression(value), lazyCache, scoringCache, valueDepth, lazyDepth);
119
195
  }
120
196
  // For primitives and any other type, return 1 if valid, otherwise 0
121
197
  return validate(schema, value) ? 1 : 0;
@@ -148,7 +224,17 @@ const trackCycle = (value, schema, result, cache) => {
148
224
  * can overflow the type checker now that `LazyStatic` resolves recursive schemas without a depth
149
225
  * cap. The public `coerce` wrapper preserves the typed surface.
150
226
  */
151
- const coerceInner = (schema, value, cache, lazyCache) => {
227
+ const coerceInner = (schema, value, cache, lazyCache, depth, warningState) => {
228
+ // Stop before the stack overflows. Return the value unchanged, not a schema default: callers
229
+ // merge the result back into the document, so a default would overwrite real content. Leaving
230
+ // the subtree un-normalized loses nothing.
231
+ if (depth >= MAX_COERCE_DEPTH) {
232
+ if (!warningState.emitted) {
233
+ warningState.emitted = true;
234
+ console.warn(`[@scalar/validation] coerce stopped at nesting depth ${MAX_COERCE_DEPTH}; deeper values are left as-is.`);
235
+ }
236
+ return value;
237
+ }
152
238
  // Prevent infinite recursion by returning the in-progress result that was
153
239
  // staged by an enclosing call via trackCycle.
154
240
  if ((isObject(value) || Array.isArray(value)) && cache.get(value)?.has(schema)) {
@@ -195,7 +281,7 @@ const coerceInner = (schema, value, cache, lazyCache) => {
195
281
  if (value === undefined) {
196
282
  return undefined;
197
283
  }
198
- return coerceInner(schema.schema, value, cache, lazyCache);
284
+ return coerceInner(schema.schema, value, cache, lazyCache, depth + 1, warningState);
199
285
  }
200
286
  if (schema.type === 'array') {
201
287
  if (!Array.isArray(value)) {
@@ -206,7 +292,7 @@ const coerceInner = (schema, value, cache, lazyCache) => {
206
292
  const result = new Array(value.length);
207
293
  trackCycle(value, schema, result, cache);
208
294
  for (let i = 0; i < value.length; i++) {
209
- result[i] = coerceInner(schema.items, value[i], cache, lazyCache);
295
+ result[i] = coerceInner(schema.items, value[i], cache, lazyCache, depth + 1, warningState);
210
296
  }
211
297
  return result;
212
298
  }
@@ -219,7 +305,7 @@ const coerceInner = (schema, value, cache, lazyCache) => {
219
305
  const result = {};
220
306
  trackCycle(value, schema, result, cache);
221
307
  for (const key of Object.keys(value)) {
222
- result[key] = coerceInner(schema.value, value[key], cache, lazyCache);
308
+ result[key] = coerceInner(schema.value, value[key], cache, lazyCache, depth + 1, warningState);
223
309
  }
224
310
  return result;
225
311
  }
@@ -236,7 +322,7 @@ const coerceInner = (schema, value, cache, lazyCache) => {
236
322
  if (propSchema.type === 'optional' && raw === undefined) {
237
323
  continue;
238
324
  }
239
- result[key] = coerceInner(propSchema, raw, cache, lazyCache);
325
+ result[key] = coerceInner(propSchema, raw, cache, lazyCache, depth + 1, warningState);
240
326
  }
241
327
  return result;
242
328
  }
@@ -246,19 +332,19 @@ const coerceInner = (schema, value, cache, lazyCache) => {
246
332
  return score > acc.score ? { schema: branchSchema, score } : acc;
247
333
  }, { schema: schema.schemas[0], score: 0 });
248
334
  // We need some way to pick one of the union values
249
- return coerceInner(branch.schema, value, cache, lazyCache);
335
+ return coerceInner(branch.schema, value, cache, lazyCache, depth + 1, warningState);
250
336
  }
251
337
  if (schema.type === 'intersection') {
252
- return schema.schemas.reduce((acc, subSchema) => Object.assign(acc, coerceInner(subSchema, value, cache, lazyCache)), {});
338
+ return schema.schemas.reduce((acc, subSchema) => Object.assign(acc, coerceInner(subSchema, value, cache, lazyCache, depth + 1, warningState)), {});
253
339
  }
254
340
  if (schema.type === 'literal') {
255
341
  return schema.value;
256
342
  }
257
343
  if (schema.type === 'lazy') {
258
- return coerceInner(resolveLazy(schema, lazyCache), value, cache, lazyCache);
344
+ return coerceInner(resolveLazy(schema, lazyCache), value, cache, lazyCache, depth + 1, warningState);
259
345
  }
260
346
  if (schema.type === 'evaluate') {
261
- return coerceInner(schema.schema, schema.expression(value), cache, lazyCache);
347
+ return coerceInner(schema.schema, schema.expression(value), cache, lazyCache, depth + 1, warningState);
262
348
  }
263
349
  // We need to assert here that schema has the type never so we know we handle all cases
264
350
  const _exhaustive = schema;
@@ -283,4 +369,4 @@ const coerceInner = (schema, value, cache, lazyCache) => {
283
369
  * The optional `cache` argument tracks visited object–schema pairs to stop infinite recursion
284
370
  * on cyclic graphs; callers normally omit it.
285
371
  */
286
- export const coerce = (schema, value, cache = new WeakMap(), lazyCache = new WeakMap()) => coerceInner(schema, value, cache, lazyCache);
372
+ export const coerce = (schema, value, cache = new WeakMap(), lazyCache = new WeakMap()) => coerceInner(schema, value, cache, lazyCache, 0, { emitted: false });
package/package.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "coerce",
16
16
  "scalar"
17
17
  ],
18
- "version": "0.6.4",
18
+ "version": "0.6.6",
19
19
  "engines": {
20
20
  "node": ">=20"
21
21
  },