ata-validator 1.6.2 → 1.7.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 +40 -0
- package/README.md +15 -5
- package/build.d.ts +12 -0
- package/index.d.ts +7 -0
- package/index.js +178 -57
- package/lib/aot-build.js +1 -0
- package/lib/aot.js +70 -20
- package/lib/buffer-gate.js +132 -0
- package/lib/draft7.js +24 -2
- package/lib/interpreter.js +644 -208
- package/lib/js-compiler.js +187 -55
- package/lib/metaschemas.js +21 -0
- package/lib/plan-compiler.js +545 -0
- package/lib/safe-regex-source.js +1 -1
- package/lib/safe-regex.js +209 -29
- package/lib/version.js +1 -1
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
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.7.1 - 2026-08-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- A closure-tree compiler for the interpreted engine. A schema the code generator declines is compiled into a tree of plain closures, one per schema node, with every keyword branch decided at compile time and `$ref` targets resolved once; no source generation and no `new Function`, so it works under a CSP and on Workers. Scope: schemas without `unevaluatedProperties`/`unevaluatedItems`, and `$dynamicRef` only in single-resource schemas; everything else keeps the generic evaluator. `tests/test_plan_compiler.js` holds the compiled tree to byte-identical verdicts and errors against the evaluator over 2,864 suite cases.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Plans that check only value-level keywords (most leaves of any schema) skip the evaluator's prologue entirely; `$ref` resolutions are cached with their planned target on the plan itself; the dynamic scope is pushed and popped in place instead of copied per resource. Rejection results are a small class with the `errors` accessor on the prototype, since defining a getter inside an object literal builds a closure and an accessor property on every rejection, which was the single largest cost on the rejection path.
|
|
14
|
+
- The interpreted engine gained a verdict-only mode: `isValidObject()` and the internal fast checks walk the schema without constructing a single error object, message string or scratch array. On an interpreter-routed schema the boolean check dropped from 894 ns to 70 ns.
|
|
15
|
+
- Object validation for `$dynamicRef` schemas no longer routes to the native engine. The interpreted engine has scored the same on every `$dynamicRef` case of the suite since the dynamic-scope fix in 1.7.0, needs no addon, and carries the verdict-only mode; the suite's `$dynamicRef` rejects dropped from about 1.7 µs to the interpreter's cost.
|
|
16
|
+
- String length bounds decide from the UTF-16 length where possible: a string's code point count always sits between half its length and its length, so `minLength`/`maxLength` only count code points inside the narrow band where the answer is genuinely uncertain. The surrogate test is a single wraparound compare. The code generator already worked this way; the interpreter and the closure path now match it.
|
|
17
|
+
- Errors are paid for when read, not when produced. `validate()` answers the verdict from the fastest engine for the schema and materializes `errors` through a cached getter on first access; declaration-order sorting and enrichment (received value, suggestions, source frames) moved with it into one presentation layer. A caller that only reads `.valid`, which is every gateway check, no longer pays for error construction at all. The output of `.errors` is byte-for-byte what it was. Measured on a suite-shaped benchmark of prebuilt validators over 1,052 mixed valid and invalid cases, `validate().valid` went from 778 ns to about 150 ns per call, ahead of every error-capable validator we measured, and a rejection that never has its errors read now costs less than `abortEarly` mode used to. One observable edge: mutating the data between `validate()` and the first read of `.errors` now reflects the mutated data in the errors, and if the mutation makes the data valid the errors fall back to a single generic entry.
|
|
18
|
+
|
|
19
|
+
## 1.7.0 - 2026-08-23
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- The four code generation entry points disagreed with each other on 157 instances of the official suite. Most of it was the closure compiler, the boolean fallback behind `isValidObject()` when the code generator declines a schema: it ignored `unevaluatedProperties` and `unevaluatedItems` outright, treated a self-referencing `$ref: "#"` as always true, skipped `additionalProperties` whenever no `properties` map sat next to it, and passed strings in `date-time`, `time`, `uri` and `duration` format without checking them. Each of those accepted input it should have rejected. It now declines those schemas, so they reach an engine that handles them. The remaining disagreements were wrong rejections shared by both boolean paths: `required`, `minProperties` and `maxProperties` applied to non-objects, `multipleOf` had no tolerance for fractional divisors, `const` and `enum` compared objects by key order, and `items` started at index 0 when `prefixItems` was present. `isValidObject()` and `validate()` now agree on every suite instance.
|
|
24
|
+
- A `dependentSchemas` branch carrying `additionalProperties: false` had that check hoisted out of its condition to the top level of the compiled function, so the restriction applied whether or not the triggering property was present.
|
|
25
|
+
- The error path counted a property matched by `patternProperties` as additional under `additionalProperties: false`. The same path now reports the real pointer (`#/patternProperties/<pattern>/...`) for a failing pattern subschema instead of the synthetic `#/patternProperties`, so source frames resolve for those errors.
|
|
26
|
+
- The buffer APIs (`isValid`, `isValidJSON` above the simdjson threshold, `countValid`, `batchIsValid`, `isValidNDJSON`, `isValidParallel`, `isValidPrepadded`) disagreed with `validate()` on 245 of 2222 suite cases, and in 195 of those they accepted a document `validate()` rejects. The native walker behind them does not handle `contains`, `unevaluated*`, `dependencies`, `dependentSchemas`, `dependentRequired`, `propertyNames`, `patternProperties`, tuple-form `items` and `prefixItems`, cross-document `$ref`, embedded `$id`, an empty `enum`, a boolean root schema, Unicode property escapes in `pattern`, or the `hostname`, `date-time`, `time`, `uri-reference` and `duration` formats. For a schema using any of those, every buffer API now parses the bytes and answers through `validate()`; `lib/buffer-gate.js` holds the list. Schemas without them keep the zero-copy path. `tests/test_buffer_path_parity.js` now compares all three dialects, 3359 cases, and holds the disagreement count at zero.
|
|
27
|
+
- A value failing a `patternProperties` subschema was reported at runtime as a generic "value invalid for key" error on the parent object with the synthetic pointer `#/patternProperties`. The subschema is now generated in place, so the error comes from its own keyword, at the key's path (`/x-flag`), with the real pointer (`#/patternProperties/^x-/type`), and source frames resolve for it. Patterns containing `/` or `'` produce a correctly escaped pointer.
|
|
28
|
+
- `tests/test_codegen_entrypoint_agreement.js` drives the whole suite through each entry point directly and fails on any split verdict. It runs as part of `npm test`.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- A `$ref` to a dialect's meta-schema (`https://json-schema.org/draft/2020-12/schema`, `http://json-schema.org/draft-07/schema#`, any http/https or trailing-`#` spelling) resolves from copies vendored in `lib/metaschemas.js`, so "validate this schema against its dialect" works with no registry and no network. A copy supplied through `schemas` or `addSchema()` still wins. The 2020-12 meta-schema is eight documents joined by `$dynamicRef`; see the routing change below.
|
|
33
|
+
|
|
34
|
+
- `validator.engine()` reports which engine answers `validate()` for the schema: `'codegen'`, `'closure'`, `'native'` or `'interpreter'`. A diagnostic for startup logs and benchmarks. Measured over fourteen request-shaped schemas (body, params, query, shared `$ref`, `oneOf`, `if`/`then`, local `$defs`), thirteen take the generated path; `patternProperties` with `additionalProperties: false` is the one that goes to the interpreter.
|
|
35
|
+
- `formatMode: 'inject'` for `toStandaloneModule`, `bundleStandalone`, `bundleCompact` and `build()`. The output carries no custom format source; it exports `setFormats(map)` and looks each format up from that registry at validation time, with a named error if one is missing. This is for formats that cannot be serialized: functions that close over variables, bound functions, or code rewritten by coverage and transpile steps. The default `'embed'` is unchanged in behavior, but it now checks each function's source at build time and throws with the format's name when it would not survive embedding, instead of emitting a module that fails on first use.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- Draft-07 is now read by its own rules rather than as 2020-12 with renamed keywords. A schema object carrying `$ref` is that reference and nothing else, so sibling keywords, `$id` included, are ignored, as the draft specifies. A fragment-only `$id` (`"$id": "#name"`) is a plain-name anchor. A document supplied through `schemas` or `addSchema()` that declares no `$schema` of its own is read under the root's draft. JSON Pointers written against array-form `items` still resolve after the keyword is normalized to `prefixItems`. Schemas that declare no `$schema` are unaffected. Draft 7 on the official suite goes from 916 to 927 of 927.
|
|
40
|
+
- The code generator now declines, and the interpreted engine answers, whenever a document reachable through a cross-document `$ref` uses `$dynamicRef`, `$dynamicAnchor`, `unevaluatedProperties`, `unevaluatedItems`, or an embedded `$id`. Before, the generator emitted a vacuous check for such a reference and accepted everything behind it: `{ "$ref": "https://json-schema.org/draft/2020-12/schema" }` accepted `{ "type": 1 }`. These checks now live in one function, `sharedCodegenGate`, that every entry point runs first. Draft 2020-12 goes from 1294 to 1298 of 1299 and the v1 dialect from 1131 to 1133 of 1133; the one remaining 2020-12 miss needs `$vocabulary`.
|
|
41
|
+
- The interpreted engine extends the dynamic scope whenever evaluation enters a schema resource, not only when it lands on the resource's root. A `$dynamicRef` reached through `first#/$defs/stuff` now sees resource `first` in scope, which closes the last `$dynamicRef` case the interpreter missed. With code generation blocked the figures are the same as with it: 1298 of 1299, 927 of 927, 1133 of 1133.
|
|
42
|
+
- The interpreted engine is between 4 and 14 times faster depending on the schema. Each schema node is resolved once into a fixed-shape plan (type bitmask, compiled pattern, looked-up format, one flag per keyword group) and children are linked at plan time, so the walk reads no schema properties and does no map lookups. On a six-field object schema a warm `validate()` went from 3786 ns to 343 ns; a `$ref`-heavy schema from 5354 ns to 366 ns; a recursive `$dynamicRef` tree from 5302 ns to 526 ns. The compiled path is unchanged at about 44 ns on the same schema.
|
|
43
|
+
- The linear-time pattern matcher now runs a lazily built DFA over the Thompson NFA, with cached ASCII transitions and a fallback to the NFA walk past 256 states. On typical anchored patterns it is within 1.2 to 3.5 times of V8's backtracking engine (`^[0-9]{5}$` 14 ns against 12 ns, an email pattern 70 ns against 21 ns) while keeping the linear bound: `^(a+)+$` against 100,000 characters takes 1 ms. This matcher backs `pattern`, `patternProperties` and `propertyNames` in every engine and in standalone output, so all of them gain.
|
|
44
|
+
|
|
5
45
|
## 1.6.2 - 2026-08-19
|
|
6
46
|
|
|
7
47
|
### Fixed
|
package/README.md
CHANGED
|
@@ -20,11 +20,11 @@ The `ata-validator` package itself is pure JavaScript. The native accelerator (s
|
|
|
20
20
|
npm install ata-validator --omit=optional
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
or set `ATA_NO_NATIVE=1` at runtime. Typical schemas compile to specialized JS; shapes the compiler cannot represent (some `$dynamicRef`, cyclic `$ref`, unusual keyword interactions) fall back to an interpreted engine, so every schema validates in every environment. The pure-JS setup scores the same on the official suite as the native one,
|
|
23
|
+
or set `ATA_NO_NATIVE=1` at runtime. Typical schemas compile to specialized JS; shapes the compiler cannot represent (some `$dynamicRef`, cyclic `$ref`, unusual keyword interactions) fall back to an interpreted engine, so every schema validates in every environment. The pure-JS setup scores the same on the official suite as the native one, 1298 of 1299 Draft 2020-12 cases; the one case both miss needs `$vocabulary`, which ata does not implement. Only the buffer and parallel APIs (`isValid` on raw buffers, `countValid`, `batchIsValid`, `validateAndParse`) need the native engine and say so with a clear error.
|
|
24
24
|
|
|
25
|
-
Those four
|
|
25
|
+
Those four now agree with `validate()` on every case of the official suite, 3359 across three dialects. The native walker behind them does not handle every shape (`contains`, `unevaluatedProperties`, `patternProperties`, tuple `items`, cross-document `$ref`, a few formats), so for schemas using one of those the buffer APIs parse the bytes and answer through `validate()`; the list is in `lib/buffer-gate.js`. Typical request schemas stay on the zero-copy path. `npm test` holds the disagreement count at zero.
|
|
26
26
|
|
|
27
|
-
Where `new Function` is refused altogether, on Cloudflare Workers, Deno Deploy or under a strict Content-Security-Policy, ata drops to the interpreted engine and scores
|
|
27
|
+
Where `new Function` is refused altogether, on Cloudflare Workers, Deno Deploy or under a strict Content-Security-Policy, ata drops to the interpreted engine and scores the same 1298 of 1299 with code generation blocked. No flags, and on Workers no `nodejs_compat` either. See [docs/edge-runtimes.md](docs/edge-runtimes.md).
|
|
28
28
|
|
|
29
29
|
In your code:
|
|
30
30
|
|
|
@@ -65,7 +65,8 @@ offending value, a documentation link and a suggestion. On a five-field object s
|
|
|
65
65
|
passing payload costs about 17 ns and a rejected one about 155 ns. `abortEarly: true` or
|
|
66
66
|
`isValidObject()` skips that work when only the verdict matters. Schemas ata declines to
|
|
67
67
|
compile, mostly cross-document `$ref`, `$dynamicRef` and `unevaluated*`, run on the
|
|
68
|
-
interpreted engine
|
|
68
|
+
interpreted engine: about 343 ns for a passing payload on a six-field object schema, eight
|
|
69
|
+
times the compiled path, measured warm on the same machine.
|
|
69
70
|
|
|
70
71
|
## Error messages
|
|
71
72
|
|
|
@@ -168,6 +169,9 @@ v.isValidJSON('{"name": "Mert", "email": "mert@example.com"}'); // true
|
|
|
168
169
|
// Buffer input (zero-copy, raw NAPI)
|
|
169
170
|
v.isValid(Buffer.from('{"name": "Mert", "email": "mert@example.com"}'));
|
|
170
171
|
|
|
172
|
+
// Which engine answers this schema: 'codegen', 'closure', 'native' or 'interpreter'
|
|
173
|
+
v.engine(); // 'codegen'
|
|
174
|
+
|
|
171
175
|
// Parallel batch - multi-core, NDJSON, 13.4M items/sec
|
|
172
176
|
const ndjson = Buffer.from(lines.join('\n'));
|
|
173
177
|
v.isValidParallel(ndjson); // bool[]
|
|
@@ -399,6 +403,12 @@ const { toStandaloneModule } = require('ata-validator/build');
|
|
|
399
403
|
fs.writeFileSync('./user.validator.mjs', toStandaloneModule(schema, { format: 'esm' }));
|
|
400
404
|
```
|
|
401
405
|
|
|
406
|
+
Custom format functions either get their source embedded (the default, refused
|
|
407
|
+
at build time with a named error when the function would not survive
|
|
408
|
+
serialization) or, with `formatMode: 'inject'`, are supplied at load time
|
|
409
|
+
through a `setFormats()` export the module carries. `docs/API.md` has the
|
|
410
|
+
details.
|
|
411
|
+
|
|
402
412
|
**Fastify startup, 10 route schemas, from a cold process to the first validated request:
|
|
403
413
|
ajv 19.6 ms, ata 3.1 ms, no build step required.** ata registers in 1.1 ms of that and
|
|
404
414
|
compiles on the first request, so counting only registration would overstate the gap.
|
|
@@ -496,7 +506,7 @@ Both are implemented in the interpreted engine, so a v1 schema that uses `$dynam
|
|
|
496
506
|
|
|
497
507
|
### Known limitations
|
|
498
508
|
|
|
499
|
-
Running the whole Draft 2020-12 suite with nothing excluded, `format` and `default` under specification semantics (`assertFormat: false`, `useDefaults: false`), gives
|
|
509
|
+
Running the whole Draft 2020-12 suite with nothing excluded, `format` and `default` under specification semantics (`assertFormat: false`, `useDefaults: false`), gives 1298 of 1299 cases, 99.9%. Draft 7 gives 927 of 927. The v1 dialect gives 1133 of 1133. The one 2020-12 case that fails uses a custom meta-schema that drops the validation vocabulary through `$vocabulary`, which ata does not implement, so the validation keywords still apply. `npm run test:suite` reproduces all three and names it.
|
|
500
510
|
|
|
501
511
|
Areas that remain deliberate scope decisions for 1.x:
|
|
502
512
|
|
package/build.d.ts
CHANGED
|
@@ -76,6 +76,15 @@ export function watch(opts: BuildOptions, onReport?: (r: BuildReport) => void):
|
|
|
76
76
|
export interface BundleStandaloneOptions {
|
|
77
77
|
format?: 'cjs' | 'esm';
|
|
78
78
|
formats?: Record<string, (value: string) => boolean>;
|
|
79
|
+
/**
|
|
80
|
+
* How custom format functions reach the output. 'embed' (default) writes
|
|
81
|
+
* each function's source into the module; it must be a plain function
|
|
82
|
+
* with no closed-over variables and no coverage instrumentation, or the
|
|
83
|
+
* build throws. 'inject' writes no function source: the module exports
|
|
84
|
+
* `setFormats(map)` and looks formats up from that registry at validation
|
|
85
|
+
* time, so the caller supplies them at load time.
|
|
86
|
+
*/
|
|
87
|
+
formatMode?: 'embed' | 'inject';
|
|
79
88
|
verbose?: boolean;
|
|
80
89
|
}
|
|
81
90
|
|
|
@@ -85,6 +94,9 @@ export interface ToStandaloneModuleOptions {
|
|
|
85
94
|
source?: boolean;
|
|
86
95
|
sourceMap?: unknown;
|
|
87
96
|
schemaFile?: string;
|
|
97
|
+
formats?: Record<string, (value: string) => boolean>;
|
|
98
|
+
/** See {@link BundleStandaloneOptions.formatMode}. */
|
|
99
|
+
formatMode?: 'embed' | 'inject';
|
|
88
100
|
}
|
|
89
101
|
|
|
90
102
|
/** Bundle multiple schemas into one self-contained module (no ata-validator runtime). */
|
package/index.d.ts
CHANGED
|
@@ -401,6 +401,13 @@ export interface Validator<T = unknown> {
|
|
|
401
401
|
|
|
402
402
|
/** Fast boolean check via JS codegen or tier 0 interpreter. No error collection. */
|
|
403
403
|
isValidObject(data: unknown): data is T;
|
|
404
|
+
/**
|
|
405
|
+
* Which engine answers `validate()` for this schema: 'codegen' (generated
|
|
406
|
+
* JS), 'closure' (the closure compiler) or 'interpreter'; 'native' is
|
|
407
|
+
* reserved. The verdict is the same on every engine; the cost is not.
|
|
408
|
+
* A diagnostic, not a configuration.
|
|
409
|
+
*/
|
|
410
|
+
engine(): 'codegen' | 'closure' | 'native' | 'interpreter';
|
|
404
411
|
|
|
405
412
|
/** Validate a JSON string. Uses simdjson fast path for large documents. */
|
|
406
413
|
validateJSON(jsonString: string): ValidationResult<T>;
|
package/index.js
CHANGED
|
@@ -314,6 +314,34 @@ const ABORT_EARLY_RESULT = Object.freeze({
|
|
|
314
314
|
|
|
315
315
|
// Above this size, simdjson On Demand (selective field access) beats JSON.parse
|
|
316
316
|
// (which must materialize the full JS object tree). Buffer.from + NAPI ~2x faster.
|
|
317
|
+
|
|
318
|
+
// Rejection result with errors materialized on first read. The accessor
|
|
319
|
+
// lives on the prototype so constructing one is a plain allocation; an
|
|
320
|
+
// object-literal getter would create a closure and define an accessor
|
|
321
|
+
// property on every rejection, which showed up as the single largest cost
|
|
322
|
+
// on the rejection path. `toJSON` keeps JSON.stringify output identical to
|
|
323
|
+
// the eager shape. Note for tests: deepStrictEqual against a plain object
|
|
324
|
+
// compares prototypes; read `.errors` and compare that.
|
|
325
|
+
class LazyRejection {
|
|
326
|
+
constructor(build, data) {
|
|
327
|
+
this.valid = false;
|
|
328
|
+
this._build = build;
|
|
329
|
+
this._data = data;
|
|
330
|
+
this._errors = null;
|
|
331
|
+
}
|
|
332
|
+
toJSON() {
|
|
333
|
+
return { valid: false, errors: this.errors };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
Object.defineProperty(LazyRejection.prototype, 'errors', {
|
|
337
|
+
enumerable: true,
|
|
338
|
+
configurable: true,
|
|
339
|
+
get() {
|
|
340
|
+
if (this._errors === null) this._errors = this._build(this._data);
|
|
341
|
+
return this._errors;
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
317
345
|
const SIMDJSON_THRESHOLD = 8192;
|
|
318
346
|
|
|
319
347
|
// Resolve a JSON Schema path like "#/properties/name/type" to the schema object
|
|
@@ -426,33 +454,35 @@ function _deepCloneWithSymbols(v) {
|
|
|
426
454
|
// Normalize a caller-provided schema without mutating the original.
|
|
427
455
|
// Clones only when normalization would change the object (draft-07 keys
|
|
428
456
|
// present or nullable fields present). Internal-only — not exported.
|
|
429
|
-
function _normalizeCallerSchema(s) {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
s.$schema === 'http://json-schema.org/draft-07/schema'
|
|
433
|
-
|
|
457
|
+
function _normalizeCallerSchema(s, inheritDraft7) {
|
|
458
|
+
const declares = s && typeof s === 'object' && s.$schema !== undefined
|
|
459
|
+
const needsDraft7 = declares
|
|
460
|
+
? (s.$schema === 'http://json-schema.org/draft-07/schema#' || s.$schema === 'http://json-schema.org/draft-07/schema')
|
|
461
|
+
: !!inheritDraft7
|
|
434
462
|
const str = JSON.stringify(s)
|
|
435
463
|
const copy = _deepCloneWithSymbols(s)
|
|
436
|
-
if (needsDraft7) normalizeDraft7(copy)
|
|
464
|
+
if (needsDraft7) normalizeDraft7(copy, true)
|
|
437
465
|
normalizeNullable(copy)
|
|
438
466
|
// Return original when normalization produced no change, copy otherwise.
|
|
439
467
|
// Change-detection uses JSON content only; symbols do not affect it.
|
|
440
468
|
return JSON.stringify(copy) === str ? s : copy
|
|
441
469
|
}
|
|
442
470
|
|
|
443
|
-
|
|
471
|
+
// `inheritDraft7` is true when the root schema is draft-07: a retrieved
|
|
472
|
+
// document that declares no dialect is read under the root's draft.
|
|
473
|
+
function buildSchemaMap(schemas, inheritDraft7) {
|
|
444
474
|
if (!schemas) return null
|
|
445
475
|
const map = new Map()
|
|
446
476
|
if (Array.isArray(schemas)) {
|
|
447
477
|
for (const s of schemas) {
|
|
448
|
-
const normalized = _normalizeCallerSchema(s)
|
|
478
|
+
const normalized = _normalizeCallerSchema(s, inheritDraft7)
|
|
449
479
|
const id = normalized.$id
|
|
450
480
|
if (!id) throw new Error('Schema in schemas option must have $id')
|
|
451
481
|
map.set(id, normalized)
|
|
452
482
|
}
|
|
453
483
|
} else {
|
|
454
484
|
for (const [key, s] of Object.entries(schemas)) {
|
|
455
|
-
const normalized = _normalizeCallerSchema(s)
|
|
485
|
+
const normalized = _normalizeCallerSchema(s, inheritDraft7)
|
|
456
486
|
// A retrieved document is addressable both by the URI it was registered
|
|
457
487
|
// under and by the $id it declares. Registering only the $id makes
|
|
458
488
|
// references to the retrieval URI unresolvable.
|
|
@@ -560,8 +590,10 @@ class Validator {
|
|
|
560
590
|
// When schema is an object, normalization runs on a clone so the caller's
|
|
561
591
|
// object is never touched.
|
|
562
592
|
let schemaObj = typeof schema === "string"
|
|
563
|
-
? JSON.parse(schema)
|
|
593
|
+
? _normalizeCallerSchema(JSON.parse(schema))
|
|
564
594
|
: _normalizeCallerSchema(schema);
|
|
595
|
+
const rootIsDraft7 = !!(schemaObj && typeof schemaObj === 'object' && typeof schemaObj.$schema === 'string' &&
|
|
596
|
+
(schemaObj.$schema === 'http://json-schema.org/draft-07/schema#' || schemaObj.$schema === 'http://json-schema.org/draft-07/schema'));
|
|
565
597
|
|
|
566
598
|
// assertFormat: false makes `format` annotation-only. Strip it on a clone
|
|
567
599
|
// so the caller's schema keeps the keyword.
|
|
@@ -579,11 +611,12 @@ class Validator {
|
|
|
579
611
|
this._compiled = null;
|
|
580
612
|
this._fastSlot = -1;
|
|
581
613
|
this._jsFn = null;
|
|
614
|
+
this._engine = undefined;
|
|
582
615
|
this._preprocess = null;
|
|
583
616
|
this._applyDefaults = null;
|
|
584
617
|
|
|
585
618
|
// Schema map for cross-schema $ref resolution
|
|
586
|
-
this._schemaMap = buildSchemaMap(options.schemas) || new Map();
|
|
619
|
+
this._schemaMap = buildSchemaMap(options.schemas, rootIsDraft7) || new Map();
|
|
587
620
|
|
|
588
621
|
// User-supplied format checkers: { formatName: (value) => boolean }.
|
|
589
622
|
// Looked up at runtime when a schema references a format the built-in
|
|
@@ -717,6 +750,20 @@ class Validator {
|
|
|
717
750
|
// Lazy stringify — only computed here, not in constructor
|
|
718
751
|
if (!this._schemaStr) this._schemaStr = JSON.stringify(schemaObj);
|
|
719
752
|
|
|
753
|
+
// A $ref to a meta-schema resolves from the vendored copies, so
|
|
754
|
+
// "validate this schema against its dialect" needs no network and no
|
|
755
|
+
// caller-supplied registry. Only schemas that mention json-schema.org in a
|
|
756
|
+
// reference pay for the lookup.
|
|
757
|
+
if (this._schemaStr.includes('json-schema.org/draft')) {
|
|
758
|
+
const { METASCHEMAS } = require('./lib/metaschemas');
|
|
759
|
+
for (const [id, meta] of METASCHEMAS) {
|
|
760
|
+
const bare = id.replace(/#$/, '');
|
|
761
|
+
for (const key of [id, bare, bare + '#', bare.replace(/^https:/, 'http:'), bare.replace(/^http:/, 'https:')]) {
|
|
762
|
+
if (!this._schemaMap.has(key)) this._schemaMap.set(key, meta);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
720
767
|
// Check cache first -- reuse compiled functions for same schema
|
|
721
768
|
const sm = this._schemaMap.size > 0 ? this._schemaMap : null;
|
|
722
769
|
const mapKey = compileCacheKey(this._schemaStr, this._schemaMap);
|
|
@@ -754,6 +801,7 @@ class Validator {
|
|
|
754
801
|
jsCombinedFn = compileToJSCombined(schemaObj, VALID_RESULT, sm, uf);
|
|
755
802
|
jsErrFn = compileToJSCodegenWithErrors(schemaObj, sm, uf);
|
|
756
803
|
_isCodegen = !!_cgFn;
|
|
804
|
+
this._engine = _cgFn ? 'codegen' : jsFn ? 'closure' : null;
|
|
757
805
|
if (!uf) {
|
|
758
806
|
_compileCache.set(mapKey, { jsFn, combined: jsCombinedFn, errFn: jsErrFn, isCodegen: _isCodegen });
|
|
759
807
|
}
|
|
@@ -761,6 +809,7 @@ class Validator {
|
|
|
761
809
|
jsFn = null; jsCombinedFn = null; jsErrFn = null;
|
|
762
810
|
}
|
|
763
811
|
this._jsFn = jsFn;
|
|
812
|
+
if (this._engine === undefined) this._engine = cached ? (cached.isCodegen ? 'codegen' : jsFn ? 'closure' : null) : null;
|
|
764
813
|
|
|
765
814
|
// Data mutators -- try codegen first (12x faster), fallback to closure arrays.
|
|
766
815
|
// Follow cross-refs so coercion/defaults/removeAdditional see the referenced
|
|
@@ -883,6 +932,10 @@ class Validator {
|
|
|
883
932
|
} catch {}
|
|
884
933
|
}
|
|
885
934
|
|
|
935
|
+
// The boolean engine is the verdict authority for these paths; the
|
|
936
|
+
// final lazy wrapper uses it to skip error construction entirely.
|
|
937
|
+
if (!hasDynRef || _isCodegen) this._fastVerdict = preprocess ? null : jsFn;
|
|
938
|
+
|
|
886
939
|
if (options.abortEarly && jsFn && !hasDynRef) {
|
|
887
940
|
// abortEarly: do NOT enrich. Skip position lookups, suggestions, source maps.
|
|
888
941
|
// This is the perf-critical path for edge gateways. The richErrors wrap
|
|
@@ -899,10 +952,22 @@ class Validator {
|
|
|
899
952
|
? (data) => { preprocess(data); return _fn(data) ? _R : _efn(data); }
|
|
900
953
|
: (data) => _fn(data) ? _R : _efn(data);
|
|
901
954
|
} else if (hasDynRef) {
|
|
902
|
-
// $dynamicRef without codegen:
|
|
955
|
+
// $dynamicRef without codegen: the interpreted engine. It scores the
|
|
956
|
+
// same on the suite's $dynamicRef cases as the native walker since the
|
|
957
|
+
// dynamic-scope fix, needs no addon, and gets the verdict-only mode.
|
|
958
|
+
if (!_interp) {
|
|
959
|
+
const { createInterpreter } = require('./lib/interpreter');
|
|
960
|
+
_interp = createInterpreter(schemaObj, {
|
|
961
|
+
schemaMap: this._schemaMap.size > 0 ? this._schemaMap : null,
|
|
962
|
+
formats: this._userFormats,
|
|
963
|
+
v1: isV1Dialect(schemaObj),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
const interp = _interp;
|
|
967
|
+
this._fastVerdict = preprocess ? null : (d) => interp.isValid(d);
|
|
903
968
|
this.validate = preprocess
|
|
904
|
-
? (data) => { preprocess(data); return
|
|
905
|
-
:
|
|
969
|
+
? (data) => { preprocess(data); return interp.validate(data); }
|
|
970
|
+
: (data) => interp.validate(data);
|
|
906
971
|
} else if (jsFn && jsFn._hybridFactory) {
|
|
907
972
|
// Zero-wrapper: hybridFactory bakes VALID_RESULT + errFn into a single function
|
|
908
973
|
// No arrow function wrapper, no ternary, one function call
|
|
@@ -1077,21 +1142,20 @@ class Validator {
|
|
|
1077
1142
|
// propertyDependencies exists only in the interpreted engine, so a schema
|
|
1078
1143
|
// using it goes there even when it also uses $dynamicRef.
|
|
1079
1144
|
const _hasPropDeps = this._schemaStr.includes('"propertyDependencies"')
|
|
1145
|
+
// $dynamicRef used to delegate to the native validateJSON path here;
|
|
1146
|
+
// the interpreted engine now scores the same on those cases, carries
|
|
1147
|
+
// the verdict-only mode, and works without the addon.
|
|
1080
1148
|
let _validate;
|
|
1081
|
-
|
|
1082
|
-
// validateJSON is the C++ path with full anchor-map support; the NAPI
|
|
1083
|
-
// direct V8 `validate` path has no anchor maps.
|
|
1084
|
-
_validate = (data) => this._compiled.validateJSON(JSON.stringify(data));
|
|
1085
|
-
this.validateJSON = (jsonStr) => this._compiled.validateJSON(jsonStr);
|
|
1086
|
-
this.isValidJSON = (jsonStr) => this._compiled.isValidJSON(jsonStr);
|
|
1087
|
-
} else {
|
|
1149
|
+
{
|
|
1088
1150
|
const { createInterpreter } = require('./lib/interpreter');
|
|
1089
1151
|
const interp = createInterpreter(schemaObj, {
|
|
1090
1152
|
schemaMap: this._schemaMap.size > 0 ? this._schemaMap : null,
|
|
1091
1153
|
formats: this._userFormats,
|
|
1092
1154
|
v1: isV1Dialect(schemaObj),
|
|
1093
1155
|
});
|
|
1156
|
+
this._engine = 'interpreter';
|
|
1094
1157
|
_validate = (data) => interp.validate(data);
|
|
1158
|
+
this._fastVerdict = preprocess ? null : (d) => interp.isValid(d);
|
|
1095
1159
|
this.validateJSON = (jsonStr) => {
|
|
1096
1160
|
try {
|
|
1097
1161
|
return _validate(JSON.parse(jsonStr));
|
|
@@ -1107,7 +1171,9 @@ class Validator {
|
|
|
1107
1171
|
return _validate(data);
|
|
1108
1172
|
}
|
|
1109
1173
|
: _validate;
|
|
1110
|
-
this.isValidObject =
|
|
1174
|
+
this.isValidObject = this._fastVerdict
|
|
1175
|
+
? this._fastVerdict
|
|
1176
|
+
: (data) => _validate(data).valid;
|
|
1111
1177
|
this.validateAndParse = (jsonStr) => this._compiled.validateAndParse(jsonStr);
|
|
1112
1178
|
{
|
|
1113
1179
|
const slot = this._fastSlot;
|
|
@@ -1149,11 +1215,15 @@ class Validator {
|
|
|
1149
1215
|
formats: this._userFormats,
|
|
1150
1216
|
v1: isV1Dialect(schemaObj),
|
|
1151
1217
|
});
|
|
1218
|
+
this._engine = 'interpreter';
|
|
1219
|
+
if (!preprocess) this._fastVerdict = (d) => interp.isValid(d);
|
|
1152
1220
|
const run = preprocess
|
|
1153
1221
|
? (data) => { preprocess(data); return interp.validate(data); }
|
|
1154
1222
|
: (data) => interp.validate(data);
|
|
1155
1223
|
this.validate = run;
|
|
1156
|
-
this.isValidObject =
|
|
1224
|
+
this.isValidObject = this._fastVerdict
|
|
1225
|
+
? this._fastVerdict
|
|
1226
|
+
: (data) => run(data).valid;
|
|
1157
1227
|
this.validateJSON = (jsonStr) => {
|
|
1158
1228
|
try {
|
|
1159
1229
|
return run(JSON.parse(jsonStr));
|
|
@@ -1164,42 +1234,44 @@ class Validator {
|
|
|
1164
1234
|
this.isValidJSON = (jsonStr) => this.validateJSON(jsonStr).valid;
|
|
1165
1235
|
}
|
|
1166
1236
|
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1237
|
+
// Error presentation, one lazy layer: declaration-order sorting, rich
|
|
1238
|
+
// enrichment (received value, suggestions, source frames, docUrl), or the
|
|
1239
|
+
// raw v0.14 shape under `richErrors: false`. All of it is work a caller
|
|
1240
|
+
// that only reads `.valid` never sees, so it runs on first access to
|
|
1241
|
+
// `.errors` and is cached. One wrapper, one allocation per rejection.
|
|
1170
1242
|
if (this.validate) {
|
|
1171
1243
|
const inner = this.validate;
|
|
1244
|
+
const enrich = this._richErrors ? require('./lib/enrich-error').enrich : null;
|
|
1172
1245
|
const root = this._schemaObj;
|
|
1246
|
+
const self = this;
|
|
1173
1247
|
this.validate = (data) => {
|
|
1174
1248
|
const result = inner(data);
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1201
|
-
if (positions) this._posCache.reset();
|
|
1202
|
-
return { valid: false, errors: enriched };
|
|
1249
|
+
// abortEarly returns the shared ATA9000 stub; preserve it as-is so the
|
|
1250
|
+
// perf fast path stays allocation-free and the documented code stays stable.
|
|
1251
|
+
if (result && result.valid === false && result !== ABORT_EARLY_RESULT) {
|
|
1252
|
+
// Positions come from the raw input when validateJSON set one;
|
|
1253
|
+
// resolved eagerly since the cache is reset per call.
|
|
1254
|
+
const positions = (enrich && self._lastRawInput != null) ? self._posCache.get(self._lastRawInput) : null;
|
|
1255
|
+
if (positions) self._posCache.reset();
|
|
1256
|
+
let cached = null;
|
|
1257
|
+
return {
|
|
1258
|
+
valid: false,
|
|
1259
|
+
get errors() {
|
|
1260
|
+
if (cached === null) {
|
|
1261
|
+
let raw = result.errors || [];
|
|
1262
|
+
if (raw.length > 1) raw = sortErrorsBySchemaOrder(root, raw);
|
|
1263
|
+
cached = (enrich && raw.length)
|
|
1264
|
+
? raw.map((e) => enrich(e, {
|
|
1265
|
+
data,
|
|
1266
|
+
positions,
|
|
1267
|
+
schemaPositions: self._schemaPositions,
|
|
1268
|
+
schemaFile: self._source ? self._source.path : undefined,
|
|
1269
|
+
}))
|
|
1270
|
+
: raw;
|
|
1271
|
+
}
|
|
1272
|
+
return cached;
|
|
1273
|
+
},
|
|
1274
|
+
};
|
|
1203
1275
|
}
|
|
1204
1276
|
return result;
|
|
1205
1277
|
};
|
|
@@ -1207,7 +1279,7 @@ class Validator {
|
|
|
1207
1279
|
// validateJSON also enriches: set _lastRawInput so the position cache
|
|
1208
1280
|
// can lazily build a map for dataFrame attachment. Only validateJSON
|
|
1209
1281
|
// wires this — validate(data) takes a pre-parsed object, by design.
|
|
1210
|
-
if (this.validateJSON) {
|
|
1282
|
+
if (this._richErrors && this.validateJSON) {
|
|
1211
1283
|
const innerJson = this.validateJSON;
|
|
1212
1284
|
this.validateJSON = (jsonStr) => {
|
|
1213
1285
|
this._lastRawInput = jsonStr;
|
|
@@ -1311,12 +1383,57 @@ class Validator {
|
|
|
1311
1383
|
}
|
|
1312
1384
|
}
|
|
1313
1385
|
|
|
1386
|
+
// Errors are paid for when read, not when produced. The full pipeline
|
|
1387
|
+
// above (error codegen, enrichment, custom messages, verbose) stays
|
|
1388
|
+
// intact, but validate() now answers the verdict from the boolean
|
|
1389
|
+
// engine and materializes `errors` through a getter on first access.
|
|
1390
|
+
// A caller that only reads `.valid`, which is every gateway check and
|
|
1391
|
+
// every benchmark, skips error construction entirely; a caller that
|
|
1392
|
+
// reads `.errors` pays once and the result is cached. Skipped when the
|
|
1393
|
+
// schema coerces or defaults (preprocess mutates before the verdict),
|
|
1394
|
+
// under abortEarly (already a frozen stub), and for $dynamicRef (the
|
|
1395
|
+
// boolean engine is not the authority there).
|
|
1396
|
+
if (this._fastVerdict && !preprocess && !options.abortEarly && this.validate) {
|
|
1397
|
+
const _full = this.validate;
|
|
1398
|
+
const _fast = this._fastVerdict;
|
|
1399
|
+
const EMPTY_ERRORS = Object.freeze([]);
|
|
1400
|
+
const _buildErrors = (data) => {
|
|
1401
|
+
const r = _full(data);
|
|
1402
|
+
return (r && r.valid === false && r.errors && r.errors.length)
|
|
1403
|
+
? r.errors
|
|
1404
|
+
// The data changed between the verdict and this read; keep the
|
|
1405
|
+
// verdict and say so rather than inventing a specific error.
|
|
1406
|
+
: [{ keyword: 'validation', instancePath: '', schemaPath: '#', params: {}, message: 'schema validation failed' }];
|
|
1407
|
+
};
|
|
1408
|
+
this.validate = (data) => {
|
|
1409
|
+
if (_fast(data)) return { valid: true, data, errors: EMPTY_ERRORS };
|
|
1410
|
+
return new LazyRejection(_buildErrors, data);
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// The buffer APIs answer from the native walker, which disagrees with
|
|
1415
|
+
// validate() on shapes listed in lib/buffer-gate.js. For those schemas
|
|
1416
|
+
// every buffer entry point goes through validate() instead.
|
|
1417
|
+
if (native) {
|
|
1418
|
+
const { bufferNeedsSlowPath, installSlowBufferApis } = require('./lib/buffer-gate');
|
|
1419
|
+
if (bufferNeedsSlowPath(schemaObj, this._schemaMap)) installSlowBufferApis(this);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1314
1422
|
// Save to identity cache for ultra-fast reuse with same schema object
|
|
1315
1423
|
if (this._schemaObj && typeof this._schemaObj === 'object') {
|
|
1316
1424
|
_identityCache.set(this._schemaObj, this);
|
|
1317
1425
|
}
|
|
1318
1426
|
}
|
|
1319
1427
|
|
|
1428
|
+
// Which engine answers validate() for this schema: 'codegen' (generated
|
|
1429
|
+
// JS), 'closure' (the closure compiler, the boolean fallback), 'native'
|
|
1430
|
+
// (the C++ engine, only for some $dynamicRef schemas), or 'interpreter'.
|
|
1431
|
+
// A diagnostic: the answer is the same on every engine, the cost is not.
|
|
1432
|
+
engine() {
|
|
1433
|
+
this._ensureCompiled();
|
|
1434
|
+
return this._engine || 'interpreter';
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1320
1437
|
_ensureNative() {
|
|
1321
1438
|
if (this._nativeReady) return;
|
|
1322
1439
|
this._nativeReady = true;
|
|
@@ -1341,8 +1458,12 @@ class Validator {
|
|
|
1341
1458
|
if (!schema || !schema.$id) {
|
|
1342
1459
|
throw new Error('Schema must have $id')
|
|
1343
1460
|
}
|
|
1344
|
-
// Normalize a copy so the caller's object is never mutated.
|
|
1345
|
-
|
|
1461
|
+
// Normalize a copy so the caller's object is never mutated. A document
|
|
1462
|
+
// without a dialect of its own is read under the root's draft.
|
|
1463
|
+
const root = this._schemaObj
|
|
1464
|
+
const rootIsDraft7 = !!(root && typeof root === 'object' && typeof root.$schema === 'string' &&
|
|
1465
|
+
(root.$schema === 'http://json-schema.org/draft-07/schema#' || root.$schema === 'http://json-schema.org/draft-07/schema'))
|
|
1466
|
+
const normalized = _normalizeCallerSchema(schema, rootIsDraft7)
|
|
1346
1467
|
this._schemaMap.set(normalized.$id, normalized)
|
|
1347
1468
|
}
|
|
1348
1469
|
|