ata-validator 1.43.0 → 1.45.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/README.md CHANGED
@@ -244,21 +244,21 @@ is the most common way to get a misleading number out of this library.
244
244
 
245
245
  | | compiled with `ata build` | runtime `new Validator(schema)` |
246
246
  |---|---|---|
247
- | In a bundle, gzipped | **2.2 KB** | 95.6 KB |
248
- | Time to a served request | **3.5 ms** | 7.8 ms |
247
+ | In a bundle, gzipped | **2.1 KB** | 112.8 KB |
248
+ | Time to a served request | **3.5 ms** | 8.6 ms |
249
249
  | Schema known when | build time | any time |
250
250
 
251
251
  The bundle row is the ten-field user schema in
252
252
  `tests/fixtures/error-dx/user.schema.json`, every export of the compiled module against
253
- `new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.36.0.
254
- The startup row is a Hono route on Bun 1.4, median of nine interleaved runs, from
253
+ `new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.44.0.
254
+ The startup row is a Hono route on Bun 1.4, the median of three rounds of best-of-seven, from
255
255
  `benchmark/bundle`, against 3.5 ms for the same app doing no validation at all, so the
256
256
  compiled path costs nothing measurable to start. The runtime figure is what it is because
257
257
  a schema that arrives at run time can use any keyword, so the whole engine has to be
258
258
  there. The compiled module imports nothing and contains only the checks your schema asks
259
259
  for.
260
260
 
261
- **On a server, use whichever fits your schemas.** 96 KB of JavaScript on a Node or Bun
261
+ **On a server, use whichever fits your schemas.** 113 KB of JavaScript on a Node or Bun
262
262
  process is not a cost anyone notices, and the runtime API is the simpler thing to reach
263
263
  for. Speed is the same either way once warm.
264
264
 
@@ -491,7 +491,7 @@ const v = new Validator(schema, {
491
491
 
492
492
  ### Build-time compile (`ata compile`)
493
493
 
494
- 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.
494
+ 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.1 KB gzipped, full error detail included, against 112.8 KB for the runtime bundled for the browser (ata 1.44.0).
495
495
 
496
496
  ```bash
497
497
  npx ata compile schemas/user.json -o src/generated/user.validator.mjs
@@ -533,13 +533,13 @@ npx ata build 'schemas/*.json' --out-dir build/validators --check
533
533
  Run with `--watch` during development for incremental rebuilds.
534
534
 
535
535
  Bundle sizes for the 10-field user schema in `tests/fixtures/error-dx/user.schema.json`,
536
- minified and gzipped, measured with `bun build --minify --target=browser` on ata 1.36.0:
536
+ minified and gzipped, measured with `bun build --minify --target=browser` on ata 1.44.0:
537
537
 
538
538
  | What the app imports | Size | Notes |
539
539
  |---|---|---|
540
- | `Validator` from `ata-validator` | 95.6 KB | The compiler ships with it, because a runtime schema can use any keyword |
540
+ | `Validator` from `ata-validator` | 112.8 KB | The compiler ships with it, because a runtime schema can use any keyword |
541
541
  | `isValid` from the compiled module | **1.3 KB** | Nothing else is reachable, so the error collector is dropped |
542
- | `validate` from the compiled module | **2.0 KB** | Adds the detailed error collector |
542
+ | `validate` from the compiled module | **1.9 KB** | Adds the detailed error collector |
543
543
 
544
544
  `--abort-early` makes the generated source about three times smaller, and after
545
545
  bundling it makes no difference: importing only `isValid` already leaves the error
@@ -575,7 +575,7 @@ through a `setFormats()` export the module carries. `docs/API.md` has the
575
575
  details.
576
576
 
577
577
  **Fastify startup, 10 route schemas, from a cold process to the first validated request:
578
- ajv 19.9 ms, ata 2.9 ms, no build step required.** ata registers in 0.5 ms of that and
578
+ ajv 19.4 ms, ata 2.7 ms, no build step required.** ata registers in 0.2 ms of that and
579
579
  compiles on the first request, so counting only registration would overstate the gap.
580
580
  Reproduce with `node benchmark/bench_fastify_boot.mjs`.
581
581
 
package/index.js CHANGED
@@ -434,6 +434,9 @@ function installCodegenPaths (ctx) {
434
434
  }
435
435
  return nativeText ? getNative().rawFastValidate(this._fastSlot, Buffer.from(jsonStr)) : undefined;
436
436
  };
437
+ // The parsed document is left on `_lastParsed` for the rich layer around
438
+ // validateJSON, which hands it to the rejection so reading the errors does
439
+ // not parse the text again (see LazyJsonRejection).
437
440
  const validateText = (jsonStr) => {
438
441
  let obj;
439
442
  try {
@@ -442,6 +445,7 @@ function installCodegenPaths (ctx) {
442
445
  if (!(e instanceof SyntaxError)) throw e;
443
446
  return _jsonSyntaxRejection(e);
444
447
  }
448
+ this._lastParsed = obj;
445
449
  return jsonValidateFn(obj);
446
450
  };
447
451
  this.validateJSON = useSimdjsonForLarge && !preprocess
@@ -878,8 +882,58 @@ function buildParse (self, decline, extended) {
878
882
  // preprocess pass, the JSON-text scanner, the parse() copy and the
879
883
  // ahead-of-time bundle methods. parse() and the bundle methods load on first
880
884
  // use.
885
+ // Cold start. A validator's first COLD_CALLS calls are answered by an
886
+ // interpreted twin, built from the same schema and options with
887
+ // `engine: 'interpreter'`, which the test suite holds to the same answers and
888
+ // errors as the generated code. Generating code and having V8 compile it is
889
+ // the largest part of a first call on a large schema: from process start to
890
+ // one rejection with its error read, SchemaStore's SARIF schema took 87 ms
891
+ // with code generated at the first call and 31 ms with the twin answering.
892
+ // A validator still in use after that compiles as before, so a server reaches
893
+ // the generated code's speed within its first requests, and a command line
894
+ // tool or a short-lived function never pays to compile. Only where compiling
895
+ // costs enough to matter: a schema whose size with its references followed
896
+ // passes COLD_MIN characters (expandedChars in lib/js-compiler.js). A small
897
+ // schema compiles in about a millisecond and is generated at its first call
898
+ // as before, which also keeps every test that compares the generated code
899
+ // with the interpreter comparing the generated code. Not for a validator with
900
+ // an extension (_extendValidate, _extendChecks), which the twin would not
901
+ // carry. Returns the twin to answer this call, or null to compile.
902
+ const COLD_CALLS = 64;
903
+ const COLD_MIN = 16 * 1024;
904
+ // Whether the schema may expand past `limit`, answered from its text where
905
+ // that settles it. Without a `$ref` nothing expands, and expandedChars counts
906
+ // at most 8 for each character of the text, so a short schema is decided
907
+ // without walking it. The text is the one _ensureCompiled needs anyway. Walking
908
+ // every small route schema here cost a Fastify boot of ten routes 0.15 ms.
909
+ function mayExpandPast (self, limit) {
910
+ const str = self._schemaStr || (self._schemaStr = JSON.stringify(self._schemaObj));
911
+ if (str === undefined) return false;
912
+ if (str.length * 8 <= limit && !str.includes('"$ref"')) return false;
913
+ return jsCompiler.expandedChars(self._schemaObj, limit) > limit;
914
+ }
915
+
916
+ function coldTwin (self, Validator) {
917
+ const n = self._coldCalls === undefined ? 0 : self._coldCalls;
918
+ if (n >= COLD_CALLS || self._validateTail !== null || self._verdictTail !== null) return null;
919
+ let t = self._twin;
920
+ if (t === undefined) {
921
+ t = null;
922
+ if (!self._interpretOnly && !self._initialized && mayExpandPast(self, COLD_MIN)) {
923
+ try { t = new Validator(self._rawSchema, Object.assign({}, self._options, { engine: 'interpreter' })); } catch { t = null; }
924
+ }
925
+ self._twin = t;
926
+ }
927
+ if (t === null) return null;
928
+ self._coldCalls = n + 1;
929
+ return t;
930
+ }
931
+
881
932
  core._registerCodegen({
882
933
  jsCompiler,
934
+ coldTwin,
935
+ // The native locator for error frames, loaded with the first frame.
936
+ nativePositions: () => require('./lib/native-positions').nativeTargeted,
883
937
  installPaths: installCodegenPaths,
884
938
  compileVerdict: installCodegenPaths.compileVerdict,
885
939
  installScanner: installCodegenPaths.installScanner,
package/lib/clone-emit.js CHANGED
@@ -78,8 +78,11 @@ function inlineRefsForClone(root) {
78
78
  for (const k of Object.keys(cur)) {
79
79
  if (strip && k === 'default') continue;
80
80
  const v = cur[k];
81
- if (_INLINE_DATA_KEYS.has(k) || k === '$defs' || k === 'definitions') { out[k] = v; continue; }
82
- out[k] = walk(v, active, strip);
81
+ // A property named "__proto__" is an own key of the copy: assigned, it
82
+ // set the copy's prototype and the property was gone from parse().
83
+ const w = (_INLINE_DATA_KEYS.has(k) || k === '$defs' || k === 'definitions') ? v : walk(v, active, strip);
84
+ if (k === '__proto__') Object.defineProperty(out, k, { value: w, writable: true, enumerable: true, configurable: true });
85
+ else out[k] = w;
83
86
  }
84
87
  if (siblingDefault !== undefined) out.default = siblingDefault;
85
88
  return out;
@@ -2,13 +2,17 @@
2
2
 
3
3
  const { buildDataPositionMap, buildTargetedPositionMap } = require('./data-positions');
4
4
 
5
+
5
6
  /**
6
7
  * Memoize the position map for the duration of a single validate() call.
7
8
  * Caller passes the original buffer/string. Identity-keyed: same reference
8
9
  * == same map. No global state, caller holds the cache instance.
9
10
  */
10
11
 
11
- function createCache () {
12
+ // `nativeTargeted`, when given, is a faster source of the same entries: the
13
+ // full entry point passes lib/native-positions.js, lite passes nothing.
14
+ function createCache (nativeTargeted) {
15
+ const _nativeTargeted = typeof nativeTargeted === 'function' ? nativeTargeted : null;
12
16
  const wm = new WeakMap();
13
17
  const sm = new Map(); // strings can't go in WeakMap; clear after each validate
14
18
  let lastInput = null;
@@ -32,11 +36,13 @@ function createCache () {
32
36
  ? sm.get(input)
33
37
  : (Buffer.isBuffer(input) ? wm.get(input) : undefined);
34
38
  if (already) return already;
35
- let filtered;
36
- try {
37
- filtered = buildTargetedPositionMap(input, wanted);
38
- } catch {
39
- return null;
39
+ let filtered = _nativeTargeted === null ? null : _nativeTargeted(input, wanted);
40
+ if (filtered === null) {
41
+ try {
42
+ filtered = buildTargetedPositionMap(input, wanted);
43
+ } catch {
44
+ return null;
45
+ }
40
46
  }
41
47
  let full;
42
48
  const fallback = (key) => {
package/lib/draft7.js CHANGED
@@ -49,13 +49,11 @@ function _normalize(schema) {
49
49
  // dependencies → dependentSchemas + dependentRequired
50
50
  if (schema.dependencies) {
51
51
  for (const [key, value] of Object.entries(schema.dependencies)) {
52
- if (Array.isArray(value)) {
53
- if (!schema.dependentRequired) schema.dependentRequired = {}
54
- schema.dependentRequired[key] = value
55
- } else {
56
- if (!schema.dependentSchemas) schema.dependentSchemas = {}
57
- schema.dependentSchemas[key] = value
58
- }
52
+ // defineProperty: a dependency on a property named "__proto__" is an
53
+ // own key here, where assignment would set the map's prototype.
54
+ const target = Array.isArray(value) ? 'dependentRequired' : 'dependentSchemas'
55
+ if (!schema[target]) schema[target] = {}
56
+ Object.defineProperty(schema[target], key, { value, writable: true, enumerable: true, configurable: true })
59
57
  }
60
58
  delete schema.dependencies
61
59
  }
package/lib/formats.js CHANGED
@@ -382,12 +382,18 @@ function uriChar (c) {
382
382
  function isHex (c) {
383
383
  return (c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70);
384
384
  }
385
+ // The same set through the URI_CHAR and HEX_CHAR tables: one load per
386
+ // character, where uriChar() was a call and up to eleven comparisons, and on
387
+ // SchemaStore's SARIF samples, full of URIs, that loop was a sixth of what the
388
+ // interpreted engine spent. A "%" at the end reads past the string, NaN,
389
+ // which fails the bound as it failed isHex().
385
390
  function uriChars (s, from) {
386
391
  for (let i = from; i < s.length; i++) {
387
392
  const c = s.charCodeAt(i);
388
- if (!uriChar(c)) return false;
393
+ if (c > 127 || URI_CHAR[c] === 0) return false;
389
394
  if (c === 37) { // '%'
390
- if (!isHex(s.charCodeAt(i + 1)) || !isHex(s.charCodeAt(i + 2))) return false;
395
+ const h1 = s.charCodeAt(i + 1), h2 = s.charCodeAt(i + 2);
396
+ if (!(h1 <= 127) || !(h2 <= 127) || HEX_CHAR[h1] === 0 || HEX_CHAR[h2] === 0) return false;
391
397
  i += 2;
392
398
  }
393
399
  }
@@ -183,26 +183,22 @@ function resolveRef(ref, fromBase, state) {
183
183
 
184
184
  // ---------- value helpers ----------
185
185
 
186
- function deepEqual(a, b) {
187
- if (a === b) return true;
188
- if (typeof a === 'number' && typeof b === 'number') return a === b;
189
- if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
190
- const aArr = Array.isArray(a);
191
- if (aArr !== Array.isArray(b)) return false;
192
- if (aArr) {
193
- if (a.length !== b.length) return false;
194
- for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
195
- return true;
196
- }
197
- const ak = Object.keys(a);
198
- const bk = Object.keys(b);
199
- if (ak.length !== bk.length) return false;
200
- for (const k of ak) {
201
- if (!(k in b) || !deepEqual(a[k], b[k])) return false;
202
- }
203
- return true;
186
+ // uniqueItems: the functions the code generator emits as _uq and _uqp
187
+ // (lib/unique-items.js), so both engines run the same code.
188
+ const { allDistinct, duplicatePair, _deq } = require('./unique-items');
189
+
190
+ // Whether `data` deep-equals one of the node's enum values (see enumPrims).
191
+ function enumMember(P, data) {
192
+ if (data === null || typeof data !== 'object') return P.enumPrims.has(data);
193
+ const objs = P.enumObjs;
194
+ for (let i = 0; i < objs.length; i++) if (deepEqual(objs[i], data)) return true;
195
+ return false;
204
196
  }
205
197
 
198
+ // JSON equality: the same function the code generator emits as `_deq`, so the
199
+ // engines agree on what equal means (an object's own keys, in any order).
200
+ const deepEqual = _deq;
201
+
206
202
  function codePointLength(s) {
207
203
  let n = 0;
208
204
  for (let i = 0; i < s.length; i++) {
@@ -375,6 +371,21 @@ class Plan {
375
371
  }
376
372
  this.hasType = schema.type !== undefined;
377
373
  this.enum = schema.enum !== undefined ? schema.enum : null;
374
+ // Membership for enumMember(): the primitive values in a Set, so a long
375
+ // enum answers in one lookup, and the object and array values, which only
376
+ // deepEqual can compare, in a list. Scanning every value with deepEqual
377
+ // was a fifth to a third of what validating SchemaStore's samples cost.
378
+ // NaN stays out of the Set: deepEqual never matches it, and a Set would.
379
+ this.enumPrims = null;
380
+ this.enumObjs = null;
381
+ if (Array.isArray(this.enum)) {
382
+ this.enumPrims = new Set();
383
+ this.enumObjs = [];
384
+ for (const v of this.enum) {
385
+ if (v !== null && typeof v === 'object') this.enumObjs.push(v);
386
+ else if (!(typeof v === 'number' && v !== v)) this.enumPrims.add(v);
387
+ }
388
+ }
378
389
  this.hasConst = schema.const !== undefined;
379
390
  this.const = schema.const;
380
391
 
@@ -524,9 +535,7 @@ function evalLeafV(P, data) {
524
535
  const bits = dataBits(data);
525
536
  if (P.hasType && (bits & P.typeMask) === 0) return false;
526
537
  if (P.enum !== null) {
527
- let found = false;
528
- for (let i = 0; i < P.enum.length; i++) { if (deepEqual(P.enum[i], data)) { found = true; break; } }
529
- if (!found) return false;
538
+ if (!enumMember(P, data)) return false;
530
539
  }
531
540
  if (P.hasConst && !deepEqual(P.const, data)) return false;
532
541
  if (P.hasNumber && Number.isFinite(data)) {
@@ -545,13 +554,7 @@ function evalLeafV(P, data) {
545
554
  if (P.hasArray && bits === T_ARRAY) {
546
555
  if (P.minItems !== undefined && data.length < P.minItems) return false;
547
556
  if (P.maxItems !== undefined && data.length > P.maxItems) return false;
548
- if (P.uniqueItems) {
549
- for (let i = 0; i < data.length; i++) {
550
- for (let j = i + 1; j < data.length; j++) {
551
- if (deepEqual(data[i], data[j])) return false;
552
- }
553
- }
554
- }
557
+ if (P.uniqueItems && !allDistinct(data)) return false;
555
558
  }
556
559
  if (P.hasObject && bits === T_OBJECT) {
557
560
  if (P.required !== null) {
@@ -635,9 +638,7 @@ function evalLeaf(P, data, errors, instancePath, schemaPath) {
635
638
  valid = false;
636
639
  }
637
640
  if (P.enum !== null) {
638
- let found = false;
639
- for (let i = 0; i < P.enum.length; i++) { if (deepEqual(P.enum[i], data)) { found = true; break; } }
640
- if (!found) {
641
+ if (!enumMember(P, data)) {
641
642
  if (errors !== NOERRORS) errors.push(err('enum', 'enum', instancePath, schemaPath + '/enum', { allowedValues: P.enum }, 'must be equal to one of the allowed values'));
642
643
  valid = false;
643
644
  }
@@ -663,14 +664,11 @@ function evalLeaf(P, data, errors, instancePath, schemaPath) {
663
664
  if (P.minItems !== undefined && data.length < P.minItems) { errors === NOERRORS || errors.push(err('minItems', 'minItems', instancePath, schemaPath + '/minItems', { limit: P.minItems }, `must NOT have fewer than ${P.minItems} items`)); valid = false; }
664
665
  if (P.maxItems !== undefined && data.length > P.maxItems) { errors === NOERRORS || errors.push(err('maxItems', 'maxItems', instancePath, schemaPath + '/maxItems', { limit: P.maxItems }, `must NOT have more than ${P.maxItems} items`)); valid = false; }
665
666
  if (P.uniqueItems) {
666
- outer: for (let i = 0; i < data.length; i++) {
667
- for (let j = i + 1; j < data.length; j++) {
668
- if (deepEqual(data[i], data[j])) {
669
- if (errors !== NOERRORS) errors.push(err('uniqueItems', 'uniqueItems', instancePath, schemaPath + '/uniqueItems', { i, j }, `must NOT have duplicate items (items ## ${j} and ${i} are identical)`));
670
- valid = false;
671
- break outer;
672
- }
673
- }
667
+ const pair = duplicatePair(data);
668
+ if (pair !== null) {
669
+ const i = pair[0], j = pair[1];
670
+ if (errors !== NOERRORS) errors.push(err('uniqueItems', 'uniqueItems', instancePath, schemaPath + '/uniqueItems', { i, j }, `must NOT have duplicate items (items ## ${j} and ${i} are identical)`));
671
+ valid = false;
674
672
  }
675
673
  }
676
674
  }
@@ -924,9 +922,7 @@ class Interpreter {
924
922
  valid = false;
925
923
  }
926
924
  if (P.enum !== null) {
927
- let found = false;
928
- for (let i = 0; i < P.enum.length; i++) { if (deepEqual(P.enum[i], data)) { found = true; break; } }
929
- if (!found) {
925
+ if (!enumMember(P, data)) {
930
926
  if (errors !== NOERRORS) errors.push(err('enum', 'enum', instancePath, schemaPath + '/enum', { allowedValues: P.enum }, 'must be equal to one of the allowed values'));
931
927
  valid = false;
932
928
  }
@@ -958,14 +954,11 @@ class Interpreter {
958
954
  if (P.minItems !== undefined && data.length < P.minItems) { errors === NOERRORS || errors.push(err('minItems', 'minItems', instancePath, schemaPath + '/minItems', { limit: P.minItems }, `must NOT have fewer than ${P.minItems} items`)); valid = false; }
959
955
  if (P.maxItems !== undefined && data.length > P.maxItems) { errors === NOERRORS || errors.push(err('maxItems', 'maxItems', instancePath, schemaPath + '/maxItems', { limit: P.maxItems }, `must NOT have more than ${P.maxItems} items`)); valid = false; }
960
956
  if (P.uniqueItems) {
961
- outer: for (let i = 0; i < data.length; i++) {
962
- for (let j = i + 1; j < data.length; j++) {
963
- if (deepEqual(data[i], data[j])) {
964
- if (errors !== NOERRORS) errors.push(err('uniqueItems', 'uniqueItems', instancePath, schemaPath + '/uniqueItems', { i, j }, `must NOT have duplicate items (items ## ${j} and ${i} are identical)`));
965
- valid = false;
966
- break outer;
967
- }
968
- }
957
+ const pair = duplicatePair(data);
958
+ if (pair !== null) {
959
+ const i = pair[0], j = pair[1];
960
+ if (errors !== NOERRORS) errors.push(err('uniqueItems', 'uniqueItems', instancePath, schemaPath + '/uniqueItems', { i, j }, `must NOT have duplicate items (items ## ${j} and ${i} are identical)`));
961
+ valid = false;
969
962
  }
970
963
  }
971
964
  const prefix = P.prefixItems;
@@ -1246,7 +1239,7 @@ function err(code, keyword, instancePath, schemaPath, params, message) {
1246
1239
 
1247
1240
  const { compileInterpreter } = require('./plan-compiler').install({
1248
1241
  Plan, NOERRORS, err, evalLeaf, evalLeafV, runCustomV, dataBits, escapePointer,
1249
- deepEqual, multipleOfOk, cpAtLeast, cpAtMost,
1242
+ deepEqual, enumMember, allDistinct, multipleOfOk, cpAtLeast, cpAtMost,
1250
1243
  T_STRING, T_ARRAY, T_OBJECT, resolveRef, splitFragment, resolveUri,
1251
1244
  });
1252
1245