ata-validator 1.35.0 → 1.36.1
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/README.md +25 -16
- package/build.d.ts +23 -0
- package/build.mjs +4 -0
- package/index.d.ts +6 -0
- package/index.js +138 -47
- package/index.node.mjs +10 -0
- package/lib/aot-build.js +12 -6
- package/lib/compiled.js +17 -5
- package/lib/defaults.js +88 -0
- package/lib/js-compiler.js +211 -123
- package/lib/rejections.js +26 -6
- package/lib/schema-order.js +53 -3
- package/lib/validator-core.js +99 -118
- package/lib/version.js +1 -1
- package/package.json +16 -11
package/README.md
CHANGED
|
@@ -101,16 +101,15 @@ file.
|
|
|
101
101
|
| Bundle (gzipped) | simple | 1.3 KB | 58.1 KB | 43.9x smaller |
|
|
102
102
|
| Bundle (gzipped) | complex | 7.8 KB | 58.1 KB | 7.4x smaller |
|
|
103
103
|
| Bundle (gzipped) | nested | 4.5 KB | 58.1 KB | 12.9x smaller |
|
|
104
|
-
| Cold start | simple | 22 ms |
|
|
105
|
-
| Throughput (1M ops) | simple |
|
|
106
|
-
| Compile time | simple | 19 µs | 1.
|
|
104
|
+
| Cold start | simple | 22 ms | 40 ms | 1.8x faster |
|
|
105
|
+
| Throughput (1M ops) | simple | 269 Mops/s | 116 Mops/s | 2.3x faster |
|
|
106
|
+
| Compile time | simple | 19 µs | 1.63 ms | 85x faster |
|
|
107
107
|
|
|
108
108
|
The runtime column is the default validator most frameworks ship. Reproduce on your machine
|
|
109
109
|
with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-09-28,
|
|
110
|
-
on ata-validator 1.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
and the compile ratio between 80x and 87x. The throughput row times a single one-million-call
|
|
110
|
+
on ata-validator 1.36.0. Across four runs throughput moved between 213 and 269 Mops/s against
|
|
111
|
+
94 to 116, cold start between 21 and 22 ms against 40 to 42, and the compile ratio between 79x
|
|
112
|
+
and 90x. The throughput row times a single one-million-call
|
|
114
113
|
loop of a few milliseconds, so it moves the most from run to run; treat the last three rows as
|
|
115
114
|
an order of magnitude rather than a constant.
|
|
116
115
|
|
|
@@ -240,21 +239,21 @@ is the most common way to get a misleading number out of this library.
|
|
|
240
239
|
|
|
241
240
|
| | compiled with `ata build` | runtime `new Validator(schema)` |
|
|
242
241
|
|---|---|---|
|
|
243
|
-
| In a bundle, gzipped | **2.2 KB** |
|
|
244
|
-
| Time to a served request | **3.5 ms** | 7.
|
|
242
|
+
| In a bundle, gzipped | **2.2 KB** | 95.6 KB |
|
|
243
|
+
| Time to a served request | **3.5 ms** | 7.8 ms |
|
|
245
244
|
| Schema known when | build time | any time |
|
|
246
245
|
|
|
247
246
|
The bundle row is the ten-field user schema in
|
|
248
247
|
`tests/fixtures/error-dx/user.schema.json`, every export of the compiled module against
|
|
249
|
-
`new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.
|
|
250
|
-
The startup row is a Hono route on Bun 1.4,
|
|
248
|
+
`new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.36.0.
|
|
249
|
+
The startup row is a Hono route on Bun 1.4, median of nine interleaved runs, from
|
|
251
250
|
`benchmark/bundle`, against 3.5 ms for the same app doing no validation at all, so the
|
|
252
251
|
compiled path costs nothing measurable to start. The runtime figure is what it is because
|
|
253
252
|
a schema that arrives at run time can use any keyword, so the whole engine has to be
|
|
254
253
|
there. The compiled module imports nothing and contains only the checks your schema asks
|
|
255
254
|
for.
|
|
256
255
|
|
|
257
|
-
**On a server, use whichever fits your schemas.**
|
|
256
|
+
**On a server, use whichever fits your schemas.** 96 KB of JavaScript on a Node or Bun
|
|
258
257
|
process is not a cost anyone notices, and the runtime API is the simpler thing to reach
|
|
259
258
|
for. Speed is the same either way once warm.
|
|
260
259
|
|
|
@@ -486,7 +485,7 @@ const v = new Validator(schema, {
|
|
|
486
485
|
|
|
487
486
|
### Build-time compile (`ata compile`)
|
|
488
487
|
|
|
489
|
-
The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. For the ten-field user schema in `tests/fixtures/error-dx/user.schema.json` the module is 2.2 KB gzipped, full error detail included, against
|
|
488
|
+
The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. For the ten-field user schema in `tests/fixtures/error-dx/user.schema.json` the module is 2.2 KB gzipped, full error detail included, against 95.6 KB for the runtime bundled for the browser.
|
|
490
489
|
|
|
491
490
|
```bash
|
|
492
491
|
npx ata compile schemas/user.json -o src/generated/user.validator.mjs
|
|
@@ -528,11 +527,11 @@ npx ata build 'schemas/*.json' --out-dir build/validators --check
|
|
|
528
527
|
Run with `--watch` during development for incremental rebuilds.
|
|
529
528
|
|
|
530
529
|
Bundle sizes for the 10-field user schema in `tests/fixtures/error-dx/user.schema.json`,
|
|
531
|
-
minified and gzipped, measured with `bun build --minify --target=browser` on ata 1.
|
|
530
|
+
minified and gzipped, measured with `bun build --minify --target=browser` on ata 1.36.0:
|
|
532
531
|
|
|
533
532
|
| What the app imports | Size | Notes |
|
|
534
533
|
|---|---|---|
|
|
535
|
-
| `Validator` from `ata-validator` |
|
|
534
|
+
| `Validator` from `ata-validator` | 95.6 KB | The compiler ships with it, because a runtime schema can use any keyword |
|
|
536
535
|
| `isValid` from the compiled module | **1.3 KB** | Nothing else is reachable, so the error collector is dropped |
|
|
537
536
|
| `validate` from the compiled module | **2.0 KB** | Adds the detailed error collector |
|
|
538
537
|
|
|
@@ -541,6 +540,16 @@ bundling it makes no difference: importing only `isValid` already leaves the err
|
|
|
541
540
|
collector unreachable, and a bundler drops it. Use the flag to cut the file on disk,
|
|
542
541
|
not to cut what ships.
|
|
543
542
|
|
|
543
|
+
To get the compiled module without changing code written against the runtime API, use
|
|
544
|
+
`compileAway` in [`@ata-project/unplugin`](https://github.com/ata-core/unplugin-ata): a
|
|
545
|
+
`new Validator(schema)` whose schema is known at build time is replaced with the compiled
|
|
546
|
+
module wrapped by `fromCompiled()` from `ata-validator/compiled`, which answers `validate()`,
|
|
547
|
+
`isValidObject()`, `validateJSON()` and `isValidJSON()` as the runtime does, defaults and errors
|
|
548
|
+
included. For the plugin's three-schema test entry a minified Vite build goes from 115.7 KB to
|
|
549
|
+
15.5 KB gzipped. In Node, loading the wrapper and a compiled module and answering the first two
|
|
550
|
+
checks takes 1.12 ms where the runtime takes 6.63 ms (median of 15 fresh processes). Across
|
|
551
|
+
SchemaStore's 977 schemas, 725 can be compiled away; the rest stay on the runtime.
|
|
552
|
+
|
|
544
553
|
Programmatic API if you prefer to script it:
|
|
545
554
|
|
|
546
555
|
```javascript
|
|
@@ -557,7 +566,7 @@ through a `setFormats()` export the module carries. `docs/API.md` has the
|
|
|
557
566
|
details.
|
|
558
567
|
|
|
559
568
|
**Fastify startup, 10 route schemas, from a cold process to the first validated request:
|
|
560
|
-
ajv
|
|
569
|
+
ajv 19.9 ms, ata 2.9 ms, no build step required.** ata registers in 0.5 ms of that and
|
|
561
570
|
compiles on the first request, so counting only registration would overstate the gap.
|
|
562
571
|
Reproduce with `node benchmark/bench_fastify_boot.mjs`.
|
|
563
572
|
|
package/build.d.ts
CHANGED
|
@@ -155,3 +155,26 @@ export function schemaHash(schema: unknown): string;
|
|
|
155
155
|
* schema, plus `parse` when {@link ToStandaloneModuleOptions.parse} is set
|
|
156
156
|
* and `validateJSON` when {@link ToStandaloneModuleOptions.positions} is. */
|
|
157
157
|
export function toStandaloneModule(schema: unknown, options?: ToStandaloneModuleOptions): string | null;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether `new Validator(schema)` with default options can be replaced by
|
|
161
|
+
* `fromCompiled()` from `ata-validator/compiled`. False for a schema with
|
|
162
|
+
* custom `errorMessage`s. A caller also needs {@link compiledModuleFor} to
|
|
163
|
+
* return a module.
|
|
164
|
+
*/
|
|
165
|
+
export function compiledEligible(schema: unknown): boolean;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The schema a default `Validator` reads after normalization. Pass it to
|
|
169
|
+
* `fromCompiled()`, so defaults, error order and diagnostics follow the same
|
|
170
|
+
* document the runtime does.
|
|
171
|
+
*/
|
|
172
|
+
export function compiledSchemaFor(schema: unknown): object;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The module that replaces `new Validator(schema)`, or null where the
|
|
176
|
+
* replacement would not answer as the runtime does: a schema
|
|
177
|
+
* {@link compiledEligible} declines, one the emitter cannot compile, and one
|
|
178
|
+
* whose detailed errors the generator cannot produce.
|
|
179
|
+
*/
|
|
180
|
+
export function compiledModuleFor(schema: unknown, opts?: { format?: 'esm' | 'cjs' }): string | null;
|
package/build.mjs
CHANGED
|
@@ -8,4 +8,8 @@ export const watch = mod.watch;
|
|
|
8
8
|
export const bundleStandalone = mod.bundleStandalone;
|
|
9
9
|
export const bundleCompact = mod.bundleCompact;
|
|
10
10
|
export const toStandaloneModule = mod.toStandaloneModule;
|
|
11
|
+
export const schemaHash = mod.schemaHash;
|
|
12
|
+
export const compiledEligible = mod.compiledEligible;
|
|
13
|
+
export const compiledSchemaFor = mod.compiledSchemaFor;
|
|
14
|
+
export const compiledModuleFor = mod.compiledModuleFor;
|
|
11
15
|
export default mod;
|
package/index.d.ts
CHANGED
|
@@ -407,6 +407,12 @@ export interface ValidatorOptions {
|
|
|
407
407
|
* `'log'` warns through `logger` (or the console) and continues.
|
|
408
408
|
*/
|
|
409
409
|
strictSchema?: boolean | 'log';
|
|
410
|
+
/**
|
|
411
|
+
* The URI the schema was retrieved from. Relative references resolve against
|
|
412
|
+
* it when the schema declares no `$id` of its own, including a draft-07 root
|
|
413
|
+
* whose `$id` sits beside a `$ref` and is therefore ignored.
|
|
414
|
+
*/
|
|
415
|
+
baseURI?: string;
|
|
410
416
|
/** Receives `strictSchema: 'log'` warnings; `false` silences them. */
|
|
411
417
|
logger?: { warn(...args: unknown[]): void } | false;
|
|
412
418
|
/**
|
package/index.js
CHANGED
|
@@ -568,11 +568,117 @@ function emitRemovals(node, access, lines, depth, seen) {
|
|
|
568
568
|
else lines.push(`if(${access}!==null&&typeof ${access}==='object'&&!Array.isArray(${access})){${body.join('\n')}}`);
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
+
// The coercions the closure pass applies, emitted. Values: `number` and
|
|
572
|
+
// `integer` from numeric strings and booleans, `string` from numbers and
|
|
573
|
+
// booleans, `boolean` from "true"/"1" and "false"/"0".
|
|
574
|
+
const COERCIBLE_TYPES = new Set(['number', 'integer', 'string', 'boolean']);
|
|
575
|
+
function scalarCoercion(a, t) {
|
|
576
|
+
if (t === 'integer') return [`if(typeof ${a}==='string'){var _n=Number(${a});if(${a}!==''&&Number.isInteger(_n))${a}=_n}`, `if(typeof ${a}==='boolean')${a}=${a}?1:0`];
|
|
577
|
+
if (t === 'number') return [`if(typeof ${a}==='string'){var _n=Number(${a});if(${a}!==''&&!isNaN(_n))${a}=_n}`, `if(typeof ${a}==='boolean')${a}=${a}?1:0`];
|
|
578
|
+
if (t === 'string') return [`if(typeof ${a}==='number'||typeof ${a}==='boolean')${a}=String(${a})`];
|
|
579
|
+
return [`if(${a}==='true'||${a}==='1')${a}=true`, `if(${a}==='false'||${a}==='0')${a}=false`];
|
|
580
|
+
}
|
|
581
|
+
function isScalarCoercible(node) {
|
|
582
|
+
return !!(node && typeof node === 'object' && typeof node.type === 'string' && COERCIBLE_TYPES.has(node.type));
|
|
583
|
+
}
|
|
584
|
+
// Whether anything below `node` is coerced, as buildNodeCoercer decides it.
|
|
585
|
+
// Remembered per node for the build, since every enclosing node asks again.
|
|
586
|
+
function coercesInside(node, seen, memo) {
|
|
587
|
+
if (!node || typeof node !== 'object' || seen.has(node)) return false;
|
|
588
|
+
if (memo && memo.has(node)) return memo.get(node);
|
|
589
|
+
seen.add(node);
|
|
590
|
+
let found = false;
|
|
591
|
+
if (node.properties) {
|
|
592
|
+
for (const [key, prop] of Object.entries(node.properties)) {
|
|
593
|
+
if (key === '__proto__' || !prop || typeof prop !== 'object') continue;
|
|
594
|
+
if (isScalarCoercible(prop) || coercesInside(prop, seen, memo)) { found = true; break; }
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (!found && node.items && typeof node.items === 'object' && !Array.isArray(node.items)) {
|
|
598
|
+
found = isScalarCoercible(node.items) || coercesInside(node.items, seen, memo);
|
|
599
|
+
}
|
|
600
|
+
seen.delete(node);
|
|
601
|
+
if (memo) memo.set(node, found);
|
|
602
|
+
return found;
|
|
603
|
+
}
|
|
604
|
+
function emitCoercions(node, ov, lines, st, depth) {
|
|
605
|
+
if (st.seen.has(node)) { st.cycle = true; return; }
|
|
606
|
+
st.seen.add(node);
|
|
607
|
+
if (node.properties) {
|
|
608
|
+
for (const [key, prop] of Object.entries(node.properties)) {
|
|
609
|
+
// Coercion writes with plain assignment, which for a key named
|
|
610
|
+
// __proto__ rewrites the prototype instead. The raw value still goes
|
|
611
|
+
// through validation, so skipping is a refusal to coerce, not a hole.
|
|
612
|
+
if (key === '__proto__' || !prop || typeof prop !== 'object') continue;
|
|
613
|
+
const k = JSON.stringify(key);
|
|
614
|
+
const a = `${ov}[${k}]`;
|
|
615
|
+
if (isScalarCoercible(prop)) lines.push(...scalarCoercion(a, prop.type));
|
|
616
|
+
// Wrapping a lone value in an array is a top-level rule only, as it was.
|
|
617
|
+
else if (depth === 0 && prop.type === 'array' && st.arrayMode) lines.push(`if(${k} in ${ov}&&${a}!==undefined&&!Array.isArray(${a}))${a}=[${a}]`);
|
|
618
|
+
// A leaf, the common case, has nothing below it to coerce.
|
|
619
|
+
if ((prop.properties || prop.items) && coercesInside(prop, new Set(), st.memo)) {
|
|
620
|
+
const n = '_c' + st.n++;
|
|
621
|
+
lines.push(`{const ${n}=${a};if(typeof ${n}==='object'&&${n}!==null){`);
|
|
622
|
+
emitCoercions(prop, n, lines, st, depth + 1);
|
|
623
|
+
lines.push('}}');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const it = node.items;
|
|
628
|
+
if (it && typeof it === 'object' && !Array.isArray(it) && (isScalarCoercible(it) || coercesInside(it, new Set(), st.memo))) {
|
|
629
|
+
const i = '_i' + st.n++;
|
|
630
|
+
lines.push(`if(Array.isArray(${ov}))for(let ${i}=0;${i}<${ov}.length;${i}++){`);
|
|
631
|
+
if (isScalarCoercible(it)) lines.push(...scalarCoercion(`${ov}[${i}]`, it.type));
|
|
632
|
+
if (coercesInside(it, new Set(), st.memo)) {
|
|
633
|
+
const n = '_c' + st.n++;
|
|
634
|
+
lines.push(`{const ${n}=${ov}[${i}];if(typeof ${n}==='object'&&${n}!==null){`);
|
|
635
|
+
emitCoercions(it, n, lines, st, depth + 1);
|
|
636
|
+
lines.push('}}');
|
|
637
|
+
}
|
|
638
|
+
lines.push('}');
|
|
639
|
+
}
|
|
640
|
+
st.seen.delete(node);
|
|
641
|
+
}
|
|
642
|
+
function hasDefaultsInside(node, seen) {
|
|
643
|
+
if (!node || typeof node !== 'object' || !node.properties || seen.has(node)) return false;
|
|
644
|
+
seen.add(node);
|
|
645
|
+
let found = false;
|
|
646
|
+
for (const prop of Object.values(node.properties)) {
|
|
647
|
+
if (prop && typeof prop === 'object' && (prop.default !== undefined || hasDefaultsInside(prop, seen))) { found = true; break; }
|
|
648
|
+
}
|
|
649
|
+
seen.delete(node);
|
|
650
|
+
return found;
|
|
651
|
+
}
|
|
652
|
+
function emitDefaults(node, ov, lines, st) {
|
|
653
|
+
if (st.seen.has(node)) { st.cycle = true; return; }
|
|
654
|
+
st.seen.add(node);
|
|
655
|
+
for (const [key, prop] of Object.entries(node.properties || {})) {
|
|
656
|
+
if (!prop || typeof prop !== 'object') continue;
|
|
657
|
+
const k = JSON.stringify(key);
|
|
658
|
+
if (prop.default !== undefined) {
|
|
659
|
+
const def = JSON.stringify(prop.default);
|
|
660
|
+
// Assignment to a key named __proto__ hits the prototype setter
|
|
661
|
+
// instead of creating a property; defineProperty writes an own key.
|
|
662
|
+
lines.push(key === '__proto__'
|
|
663
|
+
? `if(!Object.hasOwn(${ov},${k}))Object.defineProperty(${ov},${k},{value:${def},writable:true,enumerable:true,configurable:true})`
|
|
664
|
+
: `if(!Object.hasOwn(${ov},${k}))${ov}[${k}]=${def}`);
|
|
665
|
+
}
|
|
666
|
+
// Into an own property that holds an object, arrays included, as the
|
|
667
|
+
// closure pass walks it.
|
|
668
|
+
if (prop.properties && hasDefaultsInside(prop, new Set())) {
|
|
669
|
+
const n = '_d' + st.n++;
|
|
670
|
+
lines.push(`if(Object.hasOwn(${ov},${k})){const ${n}=${ov}[${k}];if(typeof ${n}==='object'&&${n}!==null){`);
|
|
671
|
+
emitDefaults(prop, n, lines, st);
|
|
672
|
+
lines.push('}}');
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
st.seen.delete(node);
|
|
676
|
+
}
|
|
677
|
+
|
|
571
678
|
// Generate a fast preprocess function via codegen instead of closure arrays
|
|
572
679
|
function buildPreprocessCodegen(schema, options) {
|
|
573
680
|
if (typeof schema !== 'object' || schema === null || !schema.properties) return null;
|
|
574
681
|
const lines = [];
|
|
575
|
-
const props = schema.properties;
|
|
576
682
|
|
|
577
683
|
// removeAdditional: strip unknown keys at every level the schema describes,
|
|
578
684
|
// not just the top one. The closure path below (collectRemovals) always
|
|
@@ -584,59 +690,44 @@ function buildPreprocessCodegen(schema, options) {
|
|
|
584
690
|
emitRemovals(schema, 'd', lines, 0, new Set());
|
|
585
691
|
}
|
|
586
692
|
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
lines.push(`if(typeof d[${k}]==='string'){var _n=Number(d[${k}]);if(d[${k}]!==''&&Number.isInteger(_n))d[${k}]=_n}`);
|
|
600
|
-
lines.push(`if(typeof d[${k}]==='boolean')d[${k}]=d[${k}]?1:0`);
|
|
601
|
-
} else if (t === 'number') {
|
|
602
|
-
lines.push(`if(typeof d[${k}]==='string'){var _n=Number(d[${k}]);if(d[${k}]!==''&&!isNaN(_n))d[${k}]=_n}`);
|
|
603
|
-
lines.push(`if(typeof d[${k}]==='boolean')d[${k}]=d[${k}]?1:0`);
|
|
604
|
-
} else if (t === 'string') {
|
|
605
|
-
lines.push(`if(typeof d[${k}]==='number'||typeof d[${k}]==='boolean')d[${k}]=String(d[${k}])`);
|
|
606
|
-
} else if (t === 'boolean') {
|
|
607
|
-
lines.push(`if(d[${k}]==='true'||d[${k}]==='1')d[${k}]=true`);
|
|
608
|
-
lines.push(`if(d[${k}]==='false'||d[${k}]==='0')d[${k}]=false`);
|
|
609
|
-
} else if (t === 'array' && options.coerceTypes === 'array') {
|
|
610
|
-
lines.push(`if(${k} in d&&d[${k}]!==undefined&&!Array.isArray(d[${k}]))d[${k}]=[d[${k}]]`);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// defaults: inline per property
|
|
616
|
-
if (options.useDefaults !== false) {
|
|
617
|
-
for (const [key, prop] of Object.entries(props)) {
|
|
618
|
-
if (prop && typeof prop === 'object' && prop.default !== undefined) {
|
|
619
|
-
const k = JSON.stringify(key);
|
|
620
|
-
const def = JSON.stringify(prop.default);
|
|
621
|
-
// Assignment to a key named __proto__ hits the prototype setter
|
|
622
|
-
// instead of creating a property; defineProperty writes an own key.
|
|
623
|
-
lines.push(key === '__proto__'
|
|
624
|
-
? `if(!Object.hasOwn(d,${k}))Object.defineProperty(d,${k},{value:${def},writable:true,enumerable:true,configurable:true})`
|
|
625
|
-
: `if(!Object.hasOwn(d,${k}))d[${k}]=${def}`);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
}
|
|
693
|
+
// Coercion and defaults reach every depth the closure passes in
|
|
694
|
+
// validator-core.js reach, with the same rules in the same order, so the
|
|
695
|
+
// interpreted engine, which uses those passes, gives the same answer. Both
|
|
696
|
+
// used to stop at the top-level properties. A schema object that contains
|
|
697
|
+
// itself declines here, and the closure passes, which carry a guard for it,
|
|
698
|
+
// take the schema.
|
|
699
|
+
const st = { n: 0, seen: new Set(), cycle: false, arrayMode: options.coerceTypes === 'array', memo: new Map() };
|
|
700
|
+
if (options.coerceTypes) emitCoercions(schema, 'd', lines, st, 0);
|
|
701
|
+
// hasDefaultsInside answers without emitting; most schemas have no default,
|
|
702
|
+
// and the emitting walk allocates for every property it visits.
|
|
703
|
+
if (options.useDefaults !== false && hasDefaultsInside(schema, new Set())) emitDefaults(schema, 'd', lines, st);
|
|
704
|
+
if (st.cycle) return null;
|
|
629
705
|
|
|
630
706
|
if (lines.length === 0) return null;
|
|
631
707
|
// Data may legitimately be null or a non-object (e.g. a `['object','null']`
|
|
632
708
|
// schema), so the per-property mutations must not run on it.
|
|
633
709
|
lines.unshift(`if(d===null||typeof d!=='object')return`);
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
710
|
+
// The pass depends on property names, types, defaults and which objects
|
|
711
|
+
// are closed, not on the constraints the verdict checks, so routes that
|
|
712
|
+
// take the same shape with different limits (a page/limit querystring, an
|
|
713
|
+
// id param) emit the same source. Compiling it is most of what building the
|
|
714
|
+
// pass costs, so the function is kept by its source and compiled once. It
|
|
715
|
+
// holds no state: it rewrites the object it is given and nothing else.
|
|
716
|
+
const src = lines.join('\n');
|
|
717
|
+
let fn = _preprocessBySource.get(src);
|
|
718
|
+
if (fn === undefined) {
|
|
719
|
+
try {
|
|
720
|
+
fn = new Function('d', src);
|
|
721
|
+
} catch {
|
|
722
|
+
fn = null;
|
|
723
|
+
}
|
|
724
|
+
if (_preprocessBySource.size >= PREPROCESS_SOURCE_LIMIT) _preprocessBySource.clear();
|
|
725
|
+
_preprocessBySource.set(src, fn);
|
|
638
726
|
}
|
|
727
|
+
return fn;
|
|
639
728
|
}
|
|
729
|
+
const _preprocessBySource = new Map();
|
|
730
|
+
const PREPROCESS_SOURCE_LIMIT = 4096;
|
|
640
731
|
|
|
641
732
|
|
|
642
733
|
// parse() for the full package: a generated function that copies the keys the
|
package/index.node.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The ESM entry Node itself resolves (the `module-sync` condition, Node 22.10
|
|
2
|
+
// and later). Importing index.js with an `import` statement makes Node run
|
|
3
|
+
// its CommonJS lexer over every module the package requires, to find named
|
|
4
|
+
// exports this file lists by hand anyway: 7.7 ms to load against 5.5 through
|
|
5
|
+
// require. Bundlers do not match `module-sync` and keep index.mjs, whose
|
|
6
|
+
// static import they can follow.
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
const mod = createRequire(import.meta.url)('./index.js');
|
|
9
|
+
export const { Validator, compile, validate, validateAsync, parseAsync, version, createPaddedBuffer, SIMDJSON_PADDING, parseJSON, toTypeScript, defineSchema, renderPretty, renderCompact, toOutput, toRetryMessage, describeSchema, renderJSON } = mod;
|
|
10
|
+
export default mod;
|
package/lib/aot-build.js
CHANGED
|
@@ -292,17 +292,22 @@ const { schemaHash } = require('./schema-hash');
|
|
|
292
292
|
// fromCompiled() around the module toStandaloneModule(schema) writes, which a
|
|
293
293
|
// bundler plugin does so the runtime compiler stays out of the bundle. The
|
|
294
294
|
// wrapper reproduces a default Validator's results exactly for these schemas;
|
|
295
|
-
// tests/test_compiled_parity.js holds that. Declined:
|
|
296
|
-
//
|
|
297
|
-
// module applies no defaults, and custom error messages, which the core
|
|
298
|
-
// applies in a layer the wrapper does not carry. A caller must also get a
|
|
295
|
+
// tests/test_compiled_parity.js holds that. Declined: custom error messages,
|
|
296
|
+
// which the core applies in a layer the wrapper does not carry. A caller must also get a
|
|
299
297
|
// module back from toStandaloneModule, which returns null for a schema it
|
|
300
298
|
// cannot compile. The text checks are deliberately coarse: a property named
|
|
301
299
|
// `default` declines too, and declining costs only the saving.
|
|
302
300
|
function compiledEligible(schema) {
|
|
303
301
|
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return false;
|
|
304
|
-
|
|
305
|
-
|
|
302
|
+
return !JSON.stringify(schema).includes('"errorMessage"');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// The schema a default Validator reads, after normalization: what
|
|
306
|
+
// fromCompiled() takes, so its defaults, sorting and diagnostics follow the
|
|
307
|
+
// same document the runtime does.
|
|
308
|
+
function compiledSchemaFor(schema) {
|
|
309
|
+
const { Validator } = require('../index.js');
|
|
310
|
+
return new Validator(schema)._schemaObj;
|
|
306
311
|
}
|
|
307
312
|
|
|
308
313
|
// The module that replaces `new Validator(schema)`, or null where the
|
|
@@ -328,6 +333,7 @@ function compiledModuleFor(schema, opts) {
|
|
|
328
333
|
|
|
329
334
|
module.exports = {
|
|
330
335
|
compiledEligible,
|
|
336
|
+
compiledSchemaFor,
|
|
331
337
|
compiledModuleFor,
|
|
332
338
|
build,
|
|
333
339
|
expandGlobs,
|
package/lib/compiled.js
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
// objects, which come from the same rejection classes the validator core uses.
|
|
9
9
|
// tests/test_compiled_parity.js holds that over the official suite.
|
|
10
10
|
//
|
|
11
|
-
// Only for a schema compiledEligible() accepts
|
|
12
|
-
// (
|
|
13
|
-
// than the defaults stay on the
|
|
11
|
+
// Only for a schema compiledEligible() accepts, and with the schema
|
|
12
|
+
// compiledSchemaFor() returns: the one the runtime reads, after normalization.
|
|
13
|
+
// Custom error messages and every option other than the defaults stay on the
|
|
14
|
+
// runtime. Defaults are applied as a default Validator applies them, before
|
|
15
|
+
// any check, with the same code.
|
|
14
16
|
|
|
15
17
|
const { LazyRejection, RichRejection, LazyJsonRejection, _enrichLazy } = require('./rejections');
|
|
18
|
+
const { buildDefaultsApplier } = require('./defaults');
|
|
16
19
|
|
|
17
20
|
const VALID_RESULT = Object.freeze({ valid: true, errors: Object.freeze([]) });
|
|
18
21
|
const EMPTY_ERRORS = Object.freeze([]);
|
|
@@ -41,9 +44,16 @@ class CompiledState {
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
function fromCompiled(mod, schema) {
|
|
44
|
-
const isValid = mod.isValid;
|
|
45
|
-
const errFn = mod.validate;
|
|
46
47
|
const self = new CompiledState(schema);
|
|
48
|
+
const fill = buildDefaultsApplier(schema);
|
|
49
|
+
if (fill) {
|
|
50
|
+
self._mutatesInput = true;
|
|
51
|
+
self._preprocess = fill;
|
|
52
|
+
}
|
|
53
|
+
// With defaults, the document is filled in before it is checked, on every
|
|
54
|
+
// entry point, as the runtime does.
|
|
55
|
+
const isValid = fill ? (d) => { fill(d); return mod.isValid(d); } : mod.isValid;
|
|
56
|
+
const errFn = mod.validate;
|
|
47
57
|
// The generated function's own result, rejected: if it says valid where the
|
|
48
58
|
// verdict said no, the disagreement is reported rather than turned into an
|
|
49
59
|
// acceptance, as the core does.
|
|
@@ -63,6 +73,8 @@ function fromCompiled(mod, schema) {
|
|
|
63
73
|
return {
|
|
64
74
|
validate(data) {
|
|
65
75
|
if (isValid(data)) return { valid: true, data, errors: EMPTY_ERRORS };
|
|
76
|
+
// The runtime returns the rich rejection directly when it rewrites input.
|
|
77
|
+
if (fill) return rich(data);
|
|
66
78
|
return new LazyRejection(buildErrors, data, buildRawErrors);
|
|
67
79
|
},
|
|
68
80
|
isValidObject(data) {
|
package/lib/defaults.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The closure pass that fills `default` values, shared by the validator core
|
|
4
|
+
// and by the wrapper around a compiled module, so both fill defaults the same
|
|
5
|
+
// way. The generated pass in index.js is held to this one by
|
|
6
|
+
// tests/test_nested_defaults_engines.js.
|
|
7
|
+
|
|
8
|
+
// Extract default values from a schema tree. Returns a function that applies
|
|
9
|
+
// defaults to an object in-place (mutates), or null if no defaults exist.
|
|
10
|
+
function buildDefaultsApplier(schema) {
|
|
11
|
+
if (typeof schema !== "object" || schema === null) return null;
|
|
12
|
+
const actions = [];
|
|
13
|
+
collectDefaults(schema, actions);
|
|
14
|
+
if (actions.length === 0) return null;
|
|
15
|
+
return (data) => {
|
|
16
|
+
for (let i = 0; i < actions.length; i++) actions[i](data);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Write an own property. Plain assignment of a key named `__proto__` does
|
|
21
|
+
// not create a property at all: it hits the Object.prototype setter and
|
|
22
|
+
// rewrites the object's prototype, which is how a schema could reach
|
|
23
|
+
// Object.prototype itself. defineProperty has no such special case.
|
|
24
|
+
function setOwn(obj, key, val) {
|
|
25
|
+
if (key === "__proto__") {
|
|
26
|
+
Object.defineProperty(obj, key, {
|
|
27
|
+
value: val,
|
|
28
|
+
writable: true,
|
|
29
|
+
enumerable: true,
|
|
30
|
+
configurable: true,
|
|
31
|
+
});
|
|
32
|
+
} else {
|
|
33
|
+
obj[key] = val;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectDefaults(schema, actions, path) {
|
|
38
|
+
if (typeof schema !== "object" || schema === null) return;
|
|
39
|
+
const props = schema.properties;
|
|
40
|
+
if (!props) return;
|
|
41
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
42
|
+
if (prop && typeof prop === "object" && prop.default !== undefined) {
|
|
43
|
+
const defaultVal = prop.default;
|
|
44
|
+
if (!path) {
|
|
45
|
+
actions.push((data) => {
|
|
46
|
+
if (typeof data === "object" && data !== null && !Object.hasOwn(data, key)) {
|
|
47
|
+
setOwn(data,
|
|
48
|
+
key,
|
|
49
|
+
typeof defaultVal === "object" && defaultVal !== null
|
|
50
|
+
? JSON.parse(JSON.stringify(defaultVal))
|
|
51
|
+
: defaultVal);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
const parentPath = path;
|
|
56
|
+
actions.push((data) => {
|
|
57
|
+
let target = data;
|
|
58
|
+
for (let j = 0; j < parentPath.length; j++) {
|
|
59
|
+
if (typeof target !== "object" || target === null) return;
|
|
60
|
+
// Own keys only. `target[key]` for an inherited name walks the
|
|
61
|
+
// prototype chain: a parent named `__proto__` that the instance
|
|
62
|
+
// does not carry resolved to Object.prototype, and the child
|
|
63
|
+
// defaults were written onto it, for every object in the realm.
|
|
64
|
+
if (!Object.hasOwn(target, parentPath[j])) return;
|
|
65
|
+
target = target[parentPath[j]];
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
typeof target === "object" &&
|
|
69
|
+
target !== null &&
|
|
70
|
+
!Object.hasOwn(target, key)
|
|
71
|
+
) {
|
|
72
|
+
setOwn(target,
|
|
73
|
+
key,
|
|
74
|
+
typeof defaultVal === "object" && defaultVal !== null
|
|
75
|
+
? JSON.parse(JSON.stringify(defaultVal))
|
|
76
|
+
: defaultVal);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Recurse into nested object schemas
|
|
82
|
+
if (prop && typeof prop === "object" && prop.properties) {
|
|
83
|
+
collectDefaults(prop, actions, (path || []).concat(key));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { buildDefaultsApplier, setOwn };
|