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 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), 'native' (the C++ engine, only
407
- * for some $dynamicRef schemas) or 'interpreter'. The verdict is the same
408
- * on every engine; the cost is not. A diagnostic, not a configuration.
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: delegate to native C++ (interpretive path unreliable)
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 errFn(data); }
926
- : errFn;
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
- if (_hasDynRef && !_hasUneval && !_hasPropDeps && !this._v1Dynamic) {
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 = (data) => _validate(data).valid;
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 = (data) => run(data).valid;
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
- // Declaration-order errors: whichever engine produced them, multi-error
1192
- // results are sorted by the schema's keyword declaration order before
1193
- // enrichment. Single-error and abortEarly results pass through untouched.
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
- if (result && !result.valid && result.errors && result.errors.length > 1 && result !== ABORT_EARLY_RESULT) {
1200
- return { valid: false, errors: sortErrorsBySchemaOrder(root, result.errors) };
1201
- }
1202
- return result;
1203
- };
1204
- }
1205
-
1206
- // richErrors enrichment: layered on top of whichever validate path was
1207
- // bound above. Verbose's parentSchema flows through because enrich()
1208
- // copies it. Opt-out (`richErrors: false`) leaves the raw v0.14 shape.
1209
- if (this._richErrors && this.validate) {
1210
- const inner = this.validate;
1211
- const enrich = require('./lib/enrich-error').enrich;
1212
- this.validate = (data) => {
1213
- const result = inner(data);
1214
- if (result && !result.valid && result.errors && result.errors.length) {
1215
- // abortEarly returns the shared ATA9000 stub; preserve it as-is so the
1216
- // perf fast path stays allocation-free and the documented code stays stable.
1217
- if (result === ABORT_EARLY_RESULT) return result;
1218
- const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
1219
- const enriched = result.errors.map((e) => enrich(e, {
1220
- data,
1221
- positions,
1222
- schemaPositions: this._schemaPositions,
1223
- schemaFile: this._source ? this._source.path : undefined,
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) >= 0xD800 && s.charCodeAt(i) <= 0xDBFF) {
24
+ if (((s.charCodeAt(i) - 0xD800) >>> 0) < 0x400) {
25
25
  let n = 0; for (const _ of s) n++; return n;
26
26
  }
27
27
  }