ata-validator 1.8.0 → 1.8.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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to ata-validator are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and this project adheres to semantic versioning.
4
4
 
5
+ ## 1.8.1 - 2026-08-27
6
+
7
+ ### Changed
8
+
9
+ - Constructing a validator no longer clones and serializes every schema to find out whether it needed normalizing. It did that on the root and on each registered schema: serialize, deep clone, normalize the clone, serialize again, compare. For a schema with no `nullable` field and no draft-07 keyword the answer is always no, and the work was thrown away. Every question the normalizers ask is whether some key appears anywhere in the tree, so one walk answers all of them, and the clone now happens only for schemas that need it. No registry and ten fields goes from 18.5 to 4.6 µs; fifty registered schemas of fifty fields, which is a small server's worth, from 2570 to 260 µs.
10
+
11
+ The walk descends through every object-valued key rather than the subschema keywords the normalizers recurse through, so it is a superset of what they visit: it can report work where there is none, costing one clone, and cannot miss work there is. Across the 2344 schemas in the official suite it reports work on 335 where normalization changes 334. `tests/test_schema_scan.js` asserts that direction over the whole suite, and asserts the check is load-bearing by breaking the scan deliberately and confirming the broken one is caught.
12
+
13
+ The answer is remembered against the schema object, the way whole compiled validators already are, and so is the schema map built from a registry. Both were being redone once per validator, so a server building one validator per route over a shared registry paid for that registry once per route. Fifty routes over twenty shared schemas boots in 0.046 ms rather than 20.66 ms. Validators share the map, and anything that writes to one, which is `addSchema()` and the meta-schema registration during compilation, takes a private copy first.
14
+
15
+ Nothing about what any schema validates to changes. The comparison that decided the old answer is still there behind the walk, so a schema the walk sends down the slow path gets exactly the result it got before.
16
+
5
17
  ## 1.8.0 - 2026-08-27
6
18
 
7
19
  ### Added
@@ -24,7 +36,7 @@ All notable changes to ata-validator are documented here. The format follows [Ke
24
36
 
25
37
  ### Changed
26
38
 
27
- - Rejecting a document through the buffer APIs no longer costs more than accepting one. The On-Demand plan answers first, and a `false` from it used to be ambiguous: it could mean the document failed a constraint, or that the plan could not decide. The caller had to assume the second, so it re-created the padded view, parsed the whole document again into a DOM, tried the generated plan, and then walked the tree. A rejected document was therefore parsed twice and walked up to three times. The plan now reports whether it stopped because the document failed a constraint or because it could not be read, and only the second falls through. simdjson's `INCORRECT_TYPE` is a constraint failure rather than a read failure, which is the distinction that makes this work: a property holding a string where the schema wants an integer surfaces as a read error from `get<int64>`, not as a type mismatch. On a 62 KB array of a thousand objects with one bad element: 316 µs to 11 µs when the bad element is first, 315 µs to 40 µs when it is last, and rejecting is now never dearer than accepting. `tests/test_buffer_path_parity.js` holds the buffer path to the same answers as `validate()` across all 3359 suite cases, and still reports zero disagreements.
39
+ - Rejecting a document through the buffer APIs no longer costs more than accepting one. The On-Demand plan answers first, and a `false` from it used to be ambiguous: it could mean the document failed a constraint, or that the plan could not decide. The caller had to assume the second, so it re-created the padded view, parsed the whole document again into a DOM, tried the generated plan, and then walked the tree. A rejected document was therefore parsed twice and walked up to three times. The plan now reports whether it stopped because the document failed a constraint or because it could not be read, and only the second falls through. simdjson's `INCORRECT_TYPE` is a constraint failure rather than a read failure, which is the distinction that makes this work: a property holding a string where the schema wants an integer surfaces as a read error from `get<int64>`, not as a type mismatch. On a 62 KB array of a thousand objects with one bad element: 316 µs to 11 µs when the bad element is first, 315 µs to 40 µs when it is last. On that shape rejecting is no longer dearer than accepting, and when the bad element is early it is several times cheaper. It is still dearer on a small document, where there is no bulk of parsing for an early exit to save: on a two-field object, 129 ns to accept and 278 ns to reject. `tests/test_buffer_path_parity.js` holds the buffer path to the same answers as `validate()` across all 3359 suite cases, and still reports zero disagreements.
28
40
 
29
41
  ## 1.7.3 - 2026-08-26
30
42
 
package/index.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  } = require("./lib/js-compiler");
12
12
  const { normalizeDraft7, normalizeNullable, stripFormatAssertions } = require("./lib/draft7");
13
13
  const { enabledKeywords, stripDisabledKeywords } = require("./lib/vocabularies");
14
+ const { needsNormalization } = require("./lib/schema-scan");
14
15
  const { isV1Dialect } = require("./lib/dialect");
15
16
  const { classify } = require("./lib/shape-classifier");
16
17
  const { buildTier0Plan, tier0Validate } = require("./lib/tier0");
@@ -460,11 +461,20 @@ function _normalizeCallerSchema(s, inheritDraft7) {
460
461
  const needsDraft7 = declares
461
462
  ? (s.$schema === 'http://json-schema.org/draft-07/schema#' || s.$schema === 'http://json-schema.org/draft-07/schema')
462
463
  : !!inheritDraft7
464
+ // One walk answers whether there is anything to do. Almost always there is
465
+ // not, and then the serialize, clone, normalize, serialize, compare below is
466
+ // work spent to find that out. The walk over-reports rather than under, so a
467
+ // schema it clears is one no normalizer would have touched;
468
+ // `tests/test_schema_scan.js` holds that direction against the whole suite.
469
+ if (!needsNormalization(s, needsDraft7)) return s
470
+
463
471
  const str = JSON.stringify(s)
464
472
  const copy = _deepCloneWithSymbols(s)
465
473
  if (needsDraft7) normalizeDraft7(copy, true)
466
474
  normalizeNullable(copy)
467
475
  // Return original when normalization produced no change, copy otherwise.
476
+ // Kept even though the walk has already said there is work, so that a walk
477
+ // which over-reports still returns exactly what it returned before.
468
478
  // Change-detection uses JSON content only; symbols do not affect it.
469
479
  return JSON.stringify(copy) === str ? s : copy
470
480
  }
@@ -488,8 +498,33 @@ function declaredId(original, normalized) {
488
498
 
489
499
  // `inheritDraft7` is true when the root schema is draft-07: a retrieved
490
500
  // document that declares no dialect is read under the root's draft.
501
+ // The map is derived entirely from what the caller passed, so the same
502
+ // `schemas` gives the same map. A server building one validator per route over
503
+ // a shared registry rebuilt it once per route, normalizing and re-reading the
504
+ // `$id` of every registered schema each time. Keyed by the registry object,
505
+ // and by the draft it is read under, since that changes what normalization
506
+ // does to a document which declares no dialect of its own.
507
+ //
508
+ // Validators share the returned map, so anything that mutates one calls
509
+ // `_ownSchemaMap()` first. There are two such places: registering the vendored
510
+ // meta-schemas during compilation, and `addSchema()`.
511
+ const _schemaMapCache = new WeakMap()
512
+
491
513
  function buildSchemaMap(schemas, inheritDraft7) {
492
514
  if (!schemas) return null
515
+ const byDraft = _schemaMapCache.get(schemas)
516
+ if (byDraft) {
517
+ const hit = byDraft[inheritDraft7 ? 1 : 0]
518
+ if (hit) return hit
519
+ }
520
+ const map = _buildSchemaMap(schemas, inheritDraft7)
521
+ const slot = byDraft || [null, null]
522
+ slot[inheritDraft7 ? 1 : 0] = map
523
+ if (!byDraft) _schemaMapCache.set(schemas, slot)
524
+ return map
525
+ }
526
+
527
+ function _buildSchemaMap(schemas, inheritDraft7) {
493
528
  const map = new Map()
494
529
  if (Array.isArray(schemas)) {
495
530
  for (const s of schemas) {
@@ -648,7 +683,9 @@ class Validator {
648
683
  // Built here rather than below because `$vocabulary` is resolved against
649
684
  // it, and that resolution waits until compilation so a meta-schema
650
685
  // registered by addSchema() still counts.
651
- const schemaMap = buildSchemaMap(options.schemas, rootIsDraft7) || new Map();
686
+ const shared = buildSchemaMap(options.schemas, rootIsDraft7);
687
+ const schemaMap = shared || new Map();
688
+ this._schemaMapShared = shared !== null;
652
689
  this._schemaIsCallers = schemaObj === schema;
653
690
  this._vocabulariesApplied = false;
654
691
 
@@ -824,6 +861,7 @@ class Validator {
824
861
  // reference pay for the lookup.
825
862
  if (this._schemaStr.includes('json-schema.org/draft')) {
826
863
  const { METASCHEMAS } = require('./lib/metaschemas');
864
+ this._ownSchemaMap();
827
865
  for (const [id, meta] of METASCHEMAS) {
828
866
  const bare = id.replace(/#$/, '');
829
867
  for (const key of [id, bare, bare + '#', bare.replace(/^https:/, 'http:'), bare.replace(/^http:/, 'https:')]) {
@@ -1532,9 +1570,18 @@ class Validator {
1532
1570
  const rootIsDraft7 = !!(root && typeof root === 'object' && typeof root.$schema === 'string' &&
1533
1571
  (root.$schema === 'http://json-schema.org/draft-07/schema#' || root.$schema === 'http://json-schema.org/draft-07/schema'))
1534
1572
  const normalized = _normalizeCallerSchema(schema, rootIsDraft7)
1573
+ this._ownSchemaMap()
1535
1574
  this._schemaMap.set(normalized.$id, normalized)
1536
1575
  }
1537
1576
 
1577
+ // buildSchemaMap hands the same map to every validator built from the same
1578
+ // registry. Take a private copy before writing to it.
1579
+ _ownSchemaMap() {
1580
+ if (!this._schemaMapShared) return
1581
+ this._schemaMap = new Map(this._schemaMap)
1582
+ this._schemaMapShared = false
1583
+ }
1584
+
1538
1585
  _ensureCodegen() {
1539
1586
  if (this._jsFn) return;
1540
1587
  this._ensureVocabularies();
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ // One walk of a schema, answering in a single integer the questions that are
4
+ // currently answered by doing work and looking at the result.
5
+ //
6
+ // `_normalizeCallerSchema` decides whether a schema needs normalizing by
7
+ // serializing it, cloning it, normalizing the clone and serializing again to
8
+ // compare. Two serializations and a deep copy, on every schema, to find out
9
+ // that a modern schema needs nothing. The questions the normalizers actually
10
+ // ask are all of the form "does this key appear anywhere in the tree", and one
11
+ // traversal answers all of them at once. Daniel Lemire's rule from the C++
12
+ // side, a layer up: do not do the work to find out whether the work is needed.
13
+ //
14
+ // The walk deliberately descends through **every** object-valued key, not the
15
+ // list of subschema keywords the normalizers recurse through. That makes it a
16
+ // superset of what they visit, so it can report work where there is none, but
17
+ // never miss work there is. A false positive costs one clone. A false negative
18
+ // would hand an un-normalized schema to the engines, which is the silent
19
+ // acceptance this codebase treats as its worst failure. `tests/test_schema_scan.js`
20
+ // pins the direction of that error against the whole suite.
21
+
22
+ // One bit per question. Kept small and explicit; a schema which trips none of
23
+ // them scans to 0 and can skip normalization entirely.
24
+ const NULLABLE = 1 << 0; // `nullable`, which normalizeNullable folds into `type`
25
+ const REF_SIBLINGS = 1 << 1; // a draft-07 `$ref` carrying keywords which are ignored
26
+ const ANCHOR_ID = 1 << 2; // a fragment-only `$id`, which is an anchor in draft-07
27
+ const DEFINITIONS = 1 << 3; // `definitions`, renamed to `$defs`
28
+ const DEPENDENCIES = 1 << 4; // `dependencies`, split into two keywords
29
+ const TUPLE_ITEMS = 1 << 5; // array-valued `items`, split into `prefixItems`
30
+
31
+ // Everything only draft-07 normalization acts on. A schema of another dialect
32
+ // can carry these without them meaning anything, so they are read together
33
+ // with whether the document is draft-07 at all.
34
+ const DRAFT7_WORK = REF_SIBLINGS | ANCHOR_ID | DEFINITIONS | DEPENDENCIES | TUPLE_ITEMS;
35
+
36
+ // Copied from the normalizer rather than shared, so that a change there which
37
+ // forgets this file shows up as a differential test failure rather than as a
38
+ // silently wider scan. The test asserts the two agree.
39
+ const REF_SIBLINGS_KEPT = new Set([
40
+ '$ref', '$defs', 'definitions', '$schema', '$comment',
41
+ 'title', 'description', 'examples', 'default', 'readOnly', 'writeOnly',
42
+ ]);
43
+ const ANCHOR_ID_RE = /^#[A-Za-z][A-Za-z0-9_.:-]*$/;
44
+
45
+ // The answer depends only on the object, so it is remembered against it. A
46
+ // server building one validator per route over a shared registry hands the
47
+ // same registry objects to every one of them: fifty routes over twenty shared
48
+ // schemas scanned those twenty a thousand times. Identity caching is what
49
+ // `_identityCache` already does for whole compiled validators.
50
+ //
51
+ // This assumes a schema is not mutated after being handed to a Validator,
52
+ // which is already true of everything else here: the compile cache, the
53
+ // identity cache and the schema map would all be stale too.
54
+ const CACHE = new WeakMap();
55
+
56
+ function scan(schema) {
57
+ if (typeof schema !== 'object' || schema === null) return 0;
58
+ const hit = CACHE.get(schema);
59
+ if (hit !== undefined) return hit;
60
+
61
+ let bits = 0;
62
+ const seen = new Set();
63
+
64
+ const walk = (node) => {
65
+ if (typeof node !== 'object' || node === null) return;
66
+ if (Array.isArray(node)) {
67
+ for (let i = 0; i < node.length; i++) walk(node[i]);
68
+ return;
69
+ }
70
+ if (seen.has(node)) return;
71
+ seen.add(node);
72
+
73
+ if ('nullable' in node) bits |= NULLABLE;
74
+ if (node.definitions !== undefined && node.$defs === undefined) bits |= DEFINITIONS;
75
+ if (node.dependencies !== undefined) bits |= DEPENDENCIES;
76
+ if (Array.isArray(node.items)) bits |= TUPLE_ITEMS;
77
+ if (typeof node.$id === 'string' && ANCHOR_ID_RE.test(node.$id)) bits |= ANCHOR_ID;
78
+
79
+ // `Object.keys` rather than `for...in`: the prototype chain has nothing
80
+ // to contribute here, and walking it is the slower path in V8.
81
+ const keys = Object.keys(node);
82
+ if (typeof node.$ref === 'string') {
83
+ for (let i = 0; i < keys.length; i++) {
84
+ if (!REF_SIBLINGS_KEPT.has(keys[i])) { bits |= REF_SIBLINGS; break; }
85
+ }
86
+ }
87
+ // Every key, not just the subschema keywords, so this cannot miss a place
88
+ // the normalizers would reach.
89
+ for (let i = 0; i < keys.length; i++) walk(node[keys[i]]);
90
+ };
91
+
92
+ walk(schema);
93
+ CACHE.set(schema, bits);
94
+ return bits;
95
+ }
96
+
97
+ // Would normalization change this schema? `isDraft7` says whether the draft-07
98
+ // rules apply at all, which the caller already knows.
99
+ function needsNormalization(schema, isDraft7) {
100
+ const bits = scan(schema);
101
+ if (bits & NULLABLE) return true;
102
+ return isDraft7 ? (bits & DRAFT7_WORK) !== 0 : false;
103
+ }
104
+
105
+ module.exports = {
106
+ scan,
107
+ needsNormalization,
108
+ NULLABLE,
109
+ REF_SIBLINGS,
110
+ ANCHOR_ID,
111
+ DEFINITIONS,
112
+ DEPENDENCIES,
113
+ TUPLE_ITEMS,
114
+ DRAFT7_WORK,
115
+ };
package/lib/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  //
8
8
  // Kept in lockstep with package.json by `tests/test_version_sync.js`.
9
9
 
10
- module.exports = '1.8.0';
10
+ module.exports = '1.8.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "JSON Schema validation with first-class TypeScript and zero runtime cost. AOT compile to per-schema ESM modules with zero validator dependency. Generic Validator<T> for TypeBox/Zod/Valibot composition. Optional runtime API. Standard Schema V1 compatible.",
5
5
  "main": "index.js",
6
6
  "module": "index.mjs",
@@ -44,7 +44,7 @@
44
44
  "release:check": "node scripts/regen-safe-regex-source.js && node tests/test_pack_purity.js && node scripts/check-doc-coverage.js && node tests/test_error_codes_lock.js && node tests/test_safe_regex_source_sync.js && node tests/test_version_sync.js",
45
45
  "build": "cmake-js build --target ata",
46
46
  "rebuild": "cmake-js rebuild --target ata",
47
- "test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.js && node tests/test_no_eval.js && node tests/test_property_dependencies.js && node tests/test_v1_dialect.js && node tests/test_buffer_path_parity.js && node tests/test_buffer_gate.js && node tests/test_draft7_semantics.js && node tests/test_metaschema_ref.js && node tests/test_pure_js_unsupported.js && node tests/test_native_load_order.js && node tests/test_pack_purity.js && node tests/test_make_native_package.js && node tests/test_browser_nofs.js && node tests/test_browser_imports_guard.js && node tests/test_version_sync.js && node tests/test_native_loaded.js && node tests/test_safe_regex_source_sync.js && node tests/test_t_builder.js && node tests/test_async_refine.js && node tests/test_safe_regex.js && node tests/test_safe_regex_integration.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_standalone_formats.js && node tests/test_aot_format_mode.js && node tests/test_aot_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.js && node tests/test_engine_diagnostic.js && node tests/test_format_engine_parity.js && node tests/test_defs_pointer_alias.js && node tests/test_cross_doc_root_ref.js && node tests/test_codegen_entrypoint_agreement.js && node tests/test_pattern_properties_errors.js && node tests/test_no_input_mutation.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_error_code_lookup.js && node tests/test_error_order.js && node tests/test_value_equality.js && node tests/test_vocabulary.js && node tests/test_lazy_errors.js && node tests/test_plan_compiler.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_enrich_received.js && node tests/test_rich_errors_optout.js && node tests/test_error_messages.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node tests/test_cli_version.js && node benchmark/bench_aot_size.mjs",
47
+ "test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.js && node tests/test_no_eval.js && node tests/test_property_dependencies.js && node tests/test_v1_dialect.js && node tests/test_buffer_path_parity.js && node tests/test_buffer_gate.js && node tests/test_draft7_semantics.js && node tests/test_metaschema_ref.js && node tests/test_pure_js_unsupported.js && node tests/test_native_load_order.js && node tests/test_pack_purity.js && node tests/test_make_native_package.js && node tests/test_browser_nofs.js && node tests/test_browser_imports_guard.js && node tests/test_version_sync.js && node tests/test_native_loaded.js && node tests/test_safe_regex_source_sync.js && node tests/test_t_builder.js && node tests/test_async_refine.js && node tests/test_safe_regex.js && node tests/test_safe_regex_integration.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_standalone_formats.js && node tests/test_aot_format_mode.js && node tests/test_aot_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.js && node tests/test_engine_diagnostic.js && node tests/test_format_engine_parity.js && node tests/test_defs_pointer_alias.js && node tests/test_cross_doc_root_ref.js && node tests/test_codegen_entrypoint_agreement.js && node tests/test_pattern_properties_errors.js && node tests/test_no_input_mutation.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_error_code_lookup.js && node tests/test_error_order.js && node tests/test_value_equality.js && node tests/test_vocabulary.js && node tests/test_schema_scan.js && node tests/test_lazy_errors.js && node tests/test_plan_compiler.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_enrich_received.js && node tests/test_rich_errors_optout.js && node tests/test_error_messages.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node tests/test_cli_version.js && node benchmark/bench_aot_size.mjs",
48
48
  "bench:size": "node benchmark/bench_aot_size.mjs",
49
49
  "test:suite": "node tests/run_suite.js && node tests/run_suite.js draft7 && node tests/run_suite.js v1",
50
50
  "test:compat": "node tests/test_compat.js",
@@ -112,13 +112,13 @@
112
112
  "LICENSE"
113
113
  ],
114
114
  "optionalDependencies": {
115
- "@ata-validator/native-darwin-arm64": "1.8.0",
116
- "@ata-validator/native-darwin-x64": "1.8.0",
117
- "@ata-validator/native-linux-x64-gnu": "1.8.0",
118
- "@ata-validator/native-linux-arm64-gnu": "1.8.0",
119
- "@ata-validator/native-linux-x64-musl": "1.8.0",
120
- "@ata-validator/native-linux-arm64-musl": "1.8.0",
121
- "@ata-validator/native-win32-x64": "1.8.0"
115
+ "@ata-validator/native-darwin-arm64": "1.8.1",
116
+ "@ata-validator/native-darwin-x64": "1.8.1",
117
+ "@ata-validator/native-linux-x64-gnu": "1.8.1",
118
+ "@ata-validator/native-linux-arm64-gnu": "1.8.1",
119
+ "@ata-validator/native-linux-x64-musl": "1.8.1",
120
+ "@ata-validator/native-linux-arm64-musl": "1.8.1",
121
+ "@ata-validator/native-win32-x64": "1.8.1"
122
122
  },
123
123
  "peerDependencies": {
124
124
  "yaml": "^2.0.0"