@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 +12 -0
- package/dist/coerce.d.ts.map +1 -1
- package/dist/coerce.js +116 -30
- package/package.json +1 -1
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
|
package/dist/coerce.d.ts.map
CHANGED
|
@@ -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;
|
|
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 (
|
|
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 (!
|
|
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
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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 });
|