ata-validator 1.7.0 → 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 +14 -0
- package/README.md +9 -0
- package/index.d.ts +3 -3
- package/index.js +121 -45
- package/lib/aot.js +1 -1
- package/lib/interpreter.js +249 -45
- package/lib/js-compiler.js +3 -2
- package/lib/plan-compiler.js +545 -0
- package/lib/version.js +1 -1
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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
|
+
|
|
5
19
|
## 1.7.0 - 2026-08-23
|
|
6
20
|
|
|
7
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -169,6 +169,9 @@ v.isValidJSON('{"name": "Mert", "email": "mert@example.com"}'); // true
|
|
|
169
169
|
// Buffer input (zero-copy, raw NAPI)
|
|
170
170
|
v.isValid(Buffer.from('{"name": "Mert", "email": "mert@example.com"}'));
|
|
171
171
|
|
|
172
|
+
// Which engine answers this schema: 'codegen', 'closure', 'native' or 'interpreter'
|
|
173
|
+
v.engine(); // 'codegen'
|
|
174
|
+
|
|
172
175
|
// Parallel batch - multi-core, NDJSON, 13.4M items/sec
|
|
173
176
|
const ndjson = Buffer.from(lines.join('\n'));
|
|
174
177
|
v.isValidParallel(ndjson); // bool[]
|
|
@@ -400,6 +403,12 @@ const { toStandaloneModule } = require('ata-validator/build');
|
|
|
400
403
|
fs.writeFileSync('./user.validator.mjs', toStandaloneModule(schema, { format: 'esm' }));
|
|
401
404
|
```
|
|
402
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
|
+
|
|
403
412
|
**Fastify startup, 10 route schemas, from a cold process to the first validated request:
|
|
404
413
|
ajv 19.6 ms, ata 3.1 ms, no build step required.** ata registers in 1.1 ms of that and
|
|
405
414
|
compiles on the first request, so counting only registration would overstate the gap.
|
package/index.d.ts
CHANGED
|
@@ -403,9 +403,9 @@ export interface Validator<T = unknown> {
|
|
|
403
403
|
isValidObject(data: unknown): data is T;
|
|
404
404
|
/**
|
|
405
405
|
* Which engine answers `validate()` for this schema: 'codegen' (generated
|
|
406
|
-
* JS), 'closure' (the closure compiler)
|
|
407
|
-
*
|
|
408
|
-
*
|
|
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
409
|
*/
|
|
410
410
|
engine(): 'codegen' | 'closure' | 'native' | 'interpreter';
|
|
411
411
|
|
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
|
|
@@ -904,6 +932,10 @@ class Validator {
|
|
|
904
932
|
} catch {}
|
|
905
933
|
}
|
|
906
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
|
+
|
|
907
939
|
if (options.abortEarly && jsFn && !hasDynRef) {
|
|
908
940
|
// abortEarly: do NOT enrich. Skip position lookups, suggestions, source maps.
|
|
909
941
|
// This is the perf-critical path for edge gateways. The richErrors wrap
|
|
@@ -920,10 +952,22 @@ class Validator {
|
|
|
920
952
|
? (data) => { preprocess(data); return _fn(data) ? _R : _efn(data); }
|
|
921
953
|
: (data) => _fn(data) ? _R : _efn(data);
|
|
922
954
|
} else if (hasDynRef) {
|
|
923
|
-
// $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);
|
|
924
968
|
this.validate = preprocess
|
|
925
|
-
? (data) => { preprocess(data); return
|
|
926
|
-
:
|
|
969
|
+
? (data) => { preprocess(data); return interp.validate(data); }
|
|
970
|
+
: (data) => interp.validate(data);
|
|
927
971
|
} else if (jsFn && jsFn._hybridFactory) {
|
|
928
972
|
// Zero-wrapper: hybridFactory bakes VALID_RESULT + errFn into a single function
|
|
929
973
|
// No arrow function wrapper, no ternary, one function call
|
|
@@ -1098,15 +1142,11 @@ class Validator {
|
|
|
1098
1142
|
// propertyDependencies exists only in the interpreted engine, so a schema
|
|
1099
1143
|
// using it goes there even when it also uses $dynamicRef.
|
|
1100
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.
|
|
1101
1148
|
let _validate;
|
|
1102
|
-
|
|
1103
|
-
// validateJSON is the C++ path with full anchor-map support; the NAPI
|
|
1104
|
-
// direct V8 `validate` path has no anchor maps.
|
|
1105
|
-
this._engine = 'native';
|
|
1106
|
-
_validate = (data) => this._compiled.validateJSON(JSON.stringify(data));
|
|
1107
|
-
this.validateJSON = (jsonStr) => this._compiled.validateJSON(jsonStr);
|
|
1108
|
-
this.isValidJSON = (jsonStr) => this._compiled.isValidJSON(jsonStr);
|
|
1109
|
-
} else {
|
|
1149
|
+
{
|
|
1110
1150
|
const { createInterpreter } = require('./lib/interpreter');
|
|
1111
1151
|
const interp = createInterpreter(schemaObj, {
|
|
1112
1152
|
schemaMap: this._schemaMap.size > 0 ? this._schemaMap : null,
|
|
@@ -1115,6 +1155,7 @@ class Validator {
|
|
|
1115
1155
|
});
|
|
1116
1156
|
this._engine = 'interpreter';
|
|
1117
1157
|
_validate = (data) => interp.validate(data);
|
|
1158
|
+
this._fastVerdict = preprocess ? null : (d) => interp.isValid(d);
|
|
1118
1159
|
this.validateJSON = (jsonStr) => {
|
|
1119
1160
|
try {
|
|
1120
1161
|
return _validate(JSON.parse(jsonStr));
|
|
@@ -1130,7 +1171,9 @@ class Validator {
|
|
|
1130
1171
|
return _validate(data);
|
|
1131
1172
|
}
|
|
1132
1173
|
: _validate;
|
|
1133
|
-
this.isValidObject =
|
|
1174
|
+
this.isValidObject = this._fastVerdict
|
|
1175
|
+
? this._fastVerdict
|
|
1176
|
+
: (data) => _validate(data).valid;
|
|
1134
1177
|
this.validateAndParse = (jsonStr) => this._compiled.validateAndParse(jsonStr);
|
|
1135
1178
|
{
|
|
1136
1179
|
const slot = this._fastSlot;
|
|
@@ -1173,11 +1216,14 @@ class Validator {
|
|
|
1173
1216
|
v1: isV1Dialect(schemaObj),
|
|
1174
1217
|
});
|
|
1175
1218
|
this._engine = 'interpreter';
|
|
1219
|
+
if (!preprocess) this._fastVerdict = (d) => interp.isValid(d);
|
|
1176
1220
|
const run = preprocess
|
|
1177
1221
|
? (data) => { preprocess(data); return interp.validate(data); }
|
|
1178
1222
|
: (data) => interp.validate(data);
|
|
1179
1223
|
this.validate = run;
|
|
1180
|
-
this.isValidObject =
|
|
1224
|
+
this.isValidObject = this._fastVerdict
|
|
1225
|
+
? this._fastVerdict
|
|
1226
|
+
: (data) => run(data).valid;
|
|
1181
1227
|
this.validateJSON = (jsonStr) => {
|
|
1182
1228
|
try {
|
|
1183
1229
|
return run(JSON.parse(jsonStr));
|
|
@@ -1188,42 +1234,44 @@ class Validator {
|
|
|
1188
1234
|
this.isValidJSON = (jsonStr) => this.validateJSON(jsonStr).valid;
|
|
1189
1235
|
}
|
|
1190
1236
|
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
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.
|
|
1194
1242
|
if (this.validate) {
|
|
1195
1243
|
const inner = this.validate;
|
|
1244
|
+
const enrich = this._richErrors ? require('./lib/enrich-error').enrich : null;
|
|
1196
1245
|
const root = this._schemaObj;
|
|
1246
|
+
const self = this;
|
|
1197
1247
|
this.validate = (data) => {
|
|
1198
1248
|
const result = inner(data);
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
}
|
|
1225
|
-
if (positions) this._posCache.reset();
|
|
1226
|
-
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
|
+
};
|
|
1227
1275
|
}
|
|
1228
1276
|
return result;
|
|
1229
1277
|
};
|
|
@@ -1231,7 +1279,7 @@ class Validator {
|
|
|
1231
1279
|
// validateJSON also enriches: set _lastRawInput so the position cache
|
|
1232
1280
|
// can lazily build a map for dataFrame attachment. Only validateJSON
|
|
1233
1281
|
// wires this — validate(data) takes a pre-parsed object, by design.
|
|
1234
|
-
if (this.validateJSON) {
|
|
1282
|
+
if (this._richErrors && this.validateJSON) {
|
|
1235
1283
|
const innerJson = this.validateJSON;
|
|
1236
1284
|
this.validateJSON = (jsonStr) => {
|
|
1237
1285
|
this._lastRawInput = jsonStr;
|
|
@@ -1335,6 +1383,34 @@ class Validator {
|
|
|
1335
1383
|
}
|
|
1336
1384
|
}
|
|
1337
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
|
+
|
|
1338
1414
|
// The buffer APIs answer from the native walker, which disagrees with
|
|
1339
1415
|
// validate() on shapes listed in lib/buffer-gate.js. For those schemas
|
|
1340
1416
|
// every buffer entry point goes through validate() instead.
|
package/lib/aot.js
CHANGED
|
@@ -21,7 +21,7 @@ const SAFE_REGEX_SOURCE = require('./safe-regex-source');
|
|
|
21
21
|
const _CP_LEN_SOURCE = `function _cpLen(s) {
|
|
22
22
|
const len = s.length;
|
|
23
23
|
for (let i = 0; i < len; i++) {
|
|
24
|
-
if (s.charCodeAt(i)
|
|
24
|
+
if (((s.charCodeAt(i) - 0xD800) >>> 0) < 0x400) {
|
|
25
25
|
let n = 0; for (const _ of s) n++; return n;
|
|
26
26
|
}
|
|
27
27
|
}
|