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 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 | 44 ms | 2.0x faster |
105
- | Throughput (1M ops) | simple | 266 Mops/s | 109 Mops/s | 2.4x faster |
106
- | Compile time | simple | 19 µs | 1.67 ms | 87x faster |
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.33.1. The runtime column's bundle measured 52.7 KB on the previous run and
111
- 58.1 KB now, with ata's modules unchanged in size. Across three runs throughput moved
112
- between 231 and 280 Mops/s against 97 to 109, cold start between 21 and 22 ms against 40 to 44,
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** | 93.2 KB |
244
- | Time to a served request | **3.5 ms** | 7.7 ms |
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.35.0.
250
- The startup row is a Hono route on Bun 1.4, best of seven, median of three rounds, from
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.** 93 KB of JavaScript on a Node or Bun
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 93.2 KB for the runtime bundled for the browser.
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.35.0:
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` | 93.2 KB | The compiler ships with it, because a runtime schema can use any keyword |
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 20.2 ms, ata 2.7 ms, no build step required.** ata registers in 0.2 ms of that and
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
- // coerceTypes: inline per property
588
- if (options.coerceTypes) {
589
- for (const [key, prop] of Object.entries(props)) {
590
- if (!prop || typeof prop !== 'object' || !prop.type) continue;
591
- // Coercion writes with plain assignment, which for a key named
592
- // __proto__ rewrites the prototype instead. The raw value still goes
593
- // through validation, so skipping is a refusal to coerce, not a hole.
594
- if (key === '__proto__') continue;
595
- const t = Array.isArray(prop.type) ? null : prop.type;
596
- if (!t) continue;
597
- const k = JSON.stringify(key);
598
- if (t === 'integer') {
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
- try {
635
- return new Function('d', lines.join('\n'));
636
- } catch {
637
- return null;
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: anything that rewrites
296
- // its input before the check, since `useDefaults` is on by default and the
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
- const text = JSON.stringify(schema);
305
- return !text.includes('"default"') && !text.includes('"errorMessage"');
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. Anything that rewrites its input
12
- // (defaults, coercion, removal), custom error messages, and every option other
13
- // than the defaults stay on the runtime.
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) {
@@ -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 };