ata-validator 1.35.0 → 1.36.0

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/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 };