ata-validator 1.6.1 → 1.6.2

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,15 @@
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.6.2 - 2026-08-19
6
+
7
+ ### Fixed
8
+
9
+ - The Standard Schema surface carried no output type. `~standard.validate()` returned `{ value: unknown }` and the `types` carrier the specification defines for inference was missing, so every consumer that reads the validated type off `~standard` (Fastify, tRPC, TanStack Form, Drizzle) saw `unknown` and needed a cast. `~standard` is now typed against the validator's own data type, and `types.output` carries it. Type-only: the runtime object is unchanged, and the specification defines `types` as never present at runtime.
10
+ - Boolean schemas were rejected by the `Validator` constructor's TypeScript signature. `true` and `false` are schemas anywhere JSON Schema allows one, and both have always worked at runtime; only the types disagreed, which made a schema of unknown shape (`object | boolean`) impossible to pass without a cast. Nested boolean subschemas are still typed as objects, so `{ properties: { a: true } }` needs `defineSchema` or a cast.
11
+ - The `t` builder's option bags rejected vendor keywords. `t.object({}, { instanceof: 'Date' })` is what a custom keyword package expects to be given, and the option types only allowed the keywords the builder itself emits, so callers wrote `as never`. Every option bag now accepts unknown keywords alongside the typed ones.
12
+ - `tests/test_interop_types.ts` covers all three under `tsc --noEmit`.
13
+
5
14
  ## 1.6.1 - 2026-08-09
6
15
 
7
16
  ### Fixed
package/README.md CHANGED
@@ -43,15 +43,29 @@ The `.compiled.mjs` modules are self-contained: zero runtime dependency on ata-v
43
43
 
44
44
  | Dimension | Schema | ata-AOT | AJV-runtime | Difference |
45
45
  |---|---|---|---|---|
46
- | Bundle (gzipped) | simple | 955 B | 52.7 KB | 56x smaller |
47
- | Bundle (gzipped) | complex | 1.6 KB | 52.7 KB | 32x smaller |
48
- | Cold start | simple | 21 ms | 38 ms | 1.8x faster |
49
- | Throughput (10M ops) | simple | 345 Mops/s | 116 Mops/s | 3.0x faster |
50
- | Compile time | simple | 6 µs | 1.5 ms | 246x faster |
51
-
52
- Reproduce on your machine with `npm run bench:aot-vs-ajv`. Numbers measured on Apple M4 Pro, Node 25.2.1.
53
-
54
- The wins are largest on bundle size and compile time because AOT moves work from runtime to build time. Throughput and cold start are also faster because the compiled validator is a tight straight-line function with no schema-walk overhead.
46
+ | Bundle (gzipped) | simple | 1.0 KB | 52.7 KB | 50.5x smaller |
47
+ | Bundle (gzipped) | complex | 4.8 KB | 52.7 KB | 11.0x smaller |
48
+ | Bundle (gzipped) | nested | 2.3 KB | 52.7 KB | 23.1x smaller |
49
+ | Cold start | simple | 21 ms | 40 ms | 1.9x faster |
50
+ | Throughput (1M ops) | simple | 258 Mops/s | 102 Mops/s | 2.5x faster |
51
+ | Compile time | simple | 8 µs | 1.61 ms | 191x faster |
52
+
53
+ Reproduce on your machine with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple
54
+ M4 Pro, Node 25.2.1, 2026-08-13. Across three runs throughput moved between 258 and 278
55
+ Mops/s and the compile ratio between 150x and 199x, so treat the last two rows as an order
56
+ of magnitude rather than a constant.
57
+
58
+ The wins are largest on bundle size and compile time because AOT moves work from runtime to
59
+ build time. Throughput and cold start are also faster because the compiled validator is a
60
+ tight straight-line function with no schema-walk overhead.
61
+
62
+ What the table does not cover is data that fails. It measures compiled modules on input that
63
+ passes, and a rejection costs more than a verdict: ata builds an error carrying a code, the
64
+ offending value, a documentation link and a suggestion. On a five-field object schema a
65
+ passing payload costs about 17 ns and a rejected one about 155 ns. `abortEarly: true` or
66
+ `isValidObject()` skips that work when only the verdict matters. Schemas ata declines to
67
+ compile, mostly cross-document `$ref`, `$dynamicRef` and `unevaluated*`, run on the
68
+ interpreted engine and are slower again.
55
69
 
56
70
  ## Error messages
57
71
 
@@ -385,7 +399,10 @@ const { toStandaloneModule } = require('ata-validator/build');
385
399
  fs.writeFileSync('./user.validator.mjs', toStandaloneModule(schema, { format: 'esm' }));
386
400
  ```
387
401
 
388
- **Fastify startup (10 routes cold): ajv 12.6ms → ata 0.5ms (24x faster boot, no build step required)**
402
+ **Fastify startup, 10 route schemas, from a cold process to the first validated request:
403
+ ajv 19.6 ms, ata 3.1 ms, no build step required.** ata registers in 1.1 ms of that and
404
+ compiles on the first request, so counting only registration would overstate the gap.
405
+ Reproduce with `node benchmark/bench_fastify_boot.mjs`.
389
406
 
390
407
  ### Standard Schema V1
391
408
 
package/index.d.ts CHANGED
@@ -364,13 +364,13 @@ export interface BundleStandaloneOptions extends ValidatorOptions {
364
364
  format?: 'esm' | 'cjs';
365
365
  }
366
366
 
367
- export interface StandardSchemaV1Props {
367
+ export interface StandardSchemaV1Props<Output = unknown, Input = unknown> {
368
368
  version: 1;
369
369
  vendor: "ata-validator";
370
370
  validate(
371
371
  value: unknown
372
372
  ):
373
- | { value: unknown }
373
+ | { value: Output }
374
374
  | {
375
375
  issues: Array<{
376
376
  message: string;
@@ -378,6 +378,11 @@ export interface StandardSchemaV1Props {
378
378
  path?: ReadonlyArray<{ key: PropertyKey }>;
379
379
  }>;
380
380
  };
381
+ /**
382
+ * Type-only carrier the Standard Schema spec uses for inference: consumers
383
+ * read the validated type off `types.output`. Never present at runtime.
384
+ */
385
+ readonly types?: { readonly input: Input; readonly output: Output } | undefined;
381
386
  }
382
387
 
383
388
  export interface StandaloneModule {
@@ -425,7 +430,7 @@ export interface Validator<T = unknown> {
425
430
  isValidNDJSON(ndjsonBuffer: Buffer): boolean[];
426
431
 
427
432
  /** Standard Schema V1 interface, compatible with Fastify, tRPC, TanStack, etc. */
428
- readonly "~standard": StandardSchemaV1Props;
433
+ readonly "~standard": StandardSchemaV1Props<T>;
429
434
  }
430
435
 
431
436
  /** Constructor + statics for {@link Validator}. */
@@ -433,7 +438,7 @@ export interface ValidatorConstructor {
433
438
  /** Construct from a JSON Schema literal; the validated data type is inferred. */
434
439
  new <const S extends JSONSchema>(schema: S, options?: ValidatorOptions): Validator<Infer<S>>;
435
440
  /** Construct from a plain object/string schema, or with an explicit data type. */
436
- new <T = unknown>(schema: object | string, options?: ValidatorOptions): Validator<T>;
441
+ new <T = unknown>(schema: object | string | boolean, options?: ValidatorOptions): Validator<T>;
437
442
 
438
443
  /** Load a pre-compiled standalone module. Zero schema compilation at startup. */
439
444
  fromStandalone<T = unknown>(mod: StandaloneModule, schema: object | string, options?: ValidatorOptions): Validator<T>;
@@ -46,14 +46,24 @@ function expectedFor (err) {
46
46
 
47
47
  function pickReceived (err, data) {
48
48
  if (!data && data !== 0 && data !== false) return undefined;
49
- // Walk JSON pointer to extract the actual offending value
49
+ // Walk JSON pointer to extract the actual offending value. This runs for
50
+ // every error on every rejected payload, so the walk reads segments straight
51
+ // out of the pointer: no leading-slash regex, no parts array, and no unescape
52
+ // pass on the segments that carry no `~`.
50
53
  const p = err.instancePath || err.path || '';
51
54
  if (!p) return reprValue(data);
52
- const parts = p.replace(/^\//, '').split('/').map(s => s.replace(/~1/g, '/').replace(/~0/g, '~'));
55
+ const len = p.length;
53
56
  let cur = data;
54
- for (const part of parts) {
57
+ let i = p.charCodeAt(0) === 47 ? 1 : 0; // 47 is '/'
58
+ for (;;) {
59
+ let j = p.indexOf('/', i);
60
+ if (j === -1) j = len;
61
+ let seg = p.slice(i, j);
62
+ if (seg.indexOf('~') !== -1) seg = seg.replace(/~1/g, '/').replace(/~0/g, '~');
55
63
  if (cur == null) return undefined;
56
- cur = cur[part];
64
+ cur = cur[seg];
65
+ if (j === len) break;
66
+ i = j + 1;
57
67
  }
58
68
  return reprValue(cur);
59
69
  }
@@ -77,20 +77,27 @@ function all () {
77
77
  return Object.keys(CODES).sort();
78
78
  }
79
79
 
80
+ // Reverse lookups, built once. codeFor runs per error on every failing
81
+ // validation, so walking the table there showed up as most of the cost of
82
+ // enriching a rejected payload. Insertion follows sorted code order, so the
83
+ // first writer wins and each keyword keeps the lowest code that carries it.
84
+ const BY_KEYWORD = new Map();
85
+ const BY_FORMAT = new Map();
86
+ for (const c of Object.keys(CODES).sort()) {
87
+ const meta = CODES[c];
88
+ if (!BY_KEYWORD.has(meta.keyword)) BY_KEYWORD.set(meta.keyword, c);
89
+ if (meta.keyword === 'format' && meta.format && !BY_FORMAT.has(meta.format)) BY_FORMAT.set(meta.format, c);
90
+ }
91
+
80
92
  // Map a (keyword, optional format) tuple back to a code. Used by the codegen
81
93
  // integration to attach codes to existing error sites without rewriting them all.
82
94
  function codeFor (keyword, format) {
83
95
  if (keyword === 'format' && format) {
84
- for (const c of all()) {
85
- const meta = CODES[c];
86
- if (meta.keyword === 'format' && meta.format === format) return c;
87
- }
88
- return 'ATA3099';
89
- }
90
- for (const c of all()) {
91
- if (CODES[c].keyword === keyword) return c;
96
+ const hit = BY_FORMAT.get(format);
97
+ return hit === undefined ? 'ATA3099' : hit;
92
98
  }
93
- return null;
99
+ const hit = BY_KEYWORD.get(keyword);
100
+ return hit === undefined ? null : hit;
94
101
  }
95
102
 
96
103
  module.exports = { CODES, get, all, codeFor };
@@ -845,6 +845,16 @@ function codegenSafe(schema, schemaMap) {
845
845
  if (typeof schema.unevaluatedProperties === 'object' && schema.unevaluatedProperties !== null) {
846
846
  if (!codegenSafe(schema.unevaluatedProperties, schemaMap)) return false
847
847
  }
848
+ // When evaluation is both dynamic (branch-dependent) and some branch
849
+ // unconditionally evaluates all properties, static tracking cannot
850
+ // correctly determine which properties are unevaluated at runtime.
851
+ // For example, a oneOf whose branches include both patternProperties
852
+ // and unevaluatedProperties:true requires annotation-aware evaluation
853
+ // that only the interpreted engine provides.
854
+ if (schema.unevaluatedProperties === false || (typeof schema.unevaluatedProperties === 'object' && schema.unevaluatedProperties !== null)) {
855
+ const evalResult = collectEvaluated(schema, schemaMap)
856
+ if (evalResult.dynamic && evalResult.allProps) return false
857
+ }
848
858
  }
849
859
  // unevaluatedItems: allow boolean and schema values
850
860
  if (schema.unevaluatedItems !== undefined) {
@@ -861,15 +871,52 @@ function codegenSafe(schema, schemaMap) {
861
871
  const defs = schema.$defs || schema.definitions
862
872
  if (defs) {
863
873
  const defNames = new Set(Object.keys(defs))
864
- // Detect cyclic local refs: if any def's own body references back to a sibling
865
- // def, the schema contains a cycle. The JS codegen returns (() => true) on cycle
866
- // (permissive), so cyclic schemas must route to the interpreted engine instead.
867
- // We check only the def bodies, not the top-level schema, so that a plain
868
- // { $ref: '#/$defs/Name', $defs: { Name: { type: 'string' } } } (no cycle)
869
- // still compiles.
870
- const hasCycle = Object.values(defs).some(
871
- (def) => typeof def === 'object' && def !== null && subtreeRefersToLocalDef(def, defNames)
872
- )
874
+ // Detect cyclic local refs: build a directed graph of def->def references and
875
+ // check for cycles using DFS. A plain cross-reference (A -> B where B doesn't
876
+ // refer back to A) is fine; only true cycles (A -> B -> A) must be rejected.
877
+ // The JS codegen returns (() => true) on cycle (permissive), so cyclic schemas
878
+ // must route to the interpreted engine instead.
879
+ const defRefs = {}
880
+ for (const [name, def] of Object.entries(defs)) {
881
+ defRefs[name] = []
882
+ if (typeof def === 'object' && def !== null) {
883
+ const refs = []
884
+ const seen = new WeakSet()
885
+ const collectRefs = (node) => {
886
+ if (typeof node !== 'object' || node === null) return
887
+ if (seen.has(node)) return
888
+ seen.add(node)
889
+ if (node.$ref) {
890
+ const m = /^#\/(?:\$defs|definitions)\/([^/]+)$/.exec(node.$ref)
891
+ if (m && defNames.has(m[1])) refs.push(m[1])
892
+ }
893
+ for (const val of Object.values(node)) {
894
+ if (typeof val === 'object' && val !== null) {
895
+ if (Array.isArray(val)) { for (const item of val) collectRefs(item) }
896
+ else collectRefs(val)
897
+ }
898
+ }
899
+ }
900
+ collectRefs(def)
901
+ defRefs[name] = refs
902
+ }
903
+ }
904
+ const hasCycle = (() => {
905
+ const visited = new Set()
906
+ const inStack = new Set()
907
+ const dfs = (node) => {
908
+ if (inStack.has(node)) return true
909
+ if (visited.has(node)) return false
910
+ visited.add(node)
911
+ inStack.add(node)
912
+ for (const neighbor of (defRefs[node] || [])) {
913
+ if (dfs(neighbor)) return true
914
+ }
915
+ inStack.delete(node)
916
+ return false
917
+ }
918
+ return Object.keys(defRefs).some(dfs)
919
+ })()
873
920
  if (hasCycle) return false
874
921
  for (const [name, def] of Object.entries(defs)) {
875
922
  if (/[~/"']/.test(name)) return false // special chars in def name
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.6.1';
10
+ module.exports = '1.6.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
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_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_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_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.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_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_order.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_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_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_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_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.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_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_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,12 +112,12 @@
112
112
  "LICENSE"
113
113
  ],
114
114
  "optionalDependencies": {
115
- "@ata-validator/native-darwin-arm64": "1.6.1",
116
- "@ata-validator/native-linux-x64-gnu": "1.6.1",
117
- "@ata-validator/native-linux-arm64-gnu": "1.6.1",
118
- "@ata-validator/native-linux-x64-musl": "1.6.1",
119
- "@ata-validator/native-linux-arm64-musl": "1.6.1",
120
- "@ata-validator/native-win32-x64": "1.6.1"
115
+ "@ata-validator/native-darwin-arm64": "1.6.2",
116
+ "@ata-validator/native-linux-x64-gnu": "1.6.2",
117
+ "@ata-validator/native-linux-arm64-gnu": "1.6.2",
118
+ "@ata-validator/native-linux-x64-musl": "1.6.2",
119
+ "@ata-validator/native-linux-arm64-musl": "1.6.2",
120
+ "@ata-validator/native-win32-x64": "1.6.2"
121
121
  },
122
122
  "peerDependencies": {
123
123
  "yaml": "^2.0.0"
package/t.d.ts CHANGED
@@ -34,7 +34,18 @@ export type TOptional<S> = S & { readonly [OPTIONAL]: true };
34
34
 
35
35
  export type TSchema = JSONSchema;
36
36
 
37
- export interface StringOptions {
37
+ /**
38
+ * Shared by every builder option bag. Annotations and vendor keywords (custom
39
+ * keywords outside the JSON Schema vocabulary) are carried through to the
40
+ * emitted schema, so the option types accept them.
41
+ */
42
+ export interface CommonOptions {
43
+ description?: string;
44
+ title?: string;
45
+ [keyword: string]: unknown;
46
+ }
47
+
48
+ export interface StringOptions extends CommonOptions {
38
49
  minLength?: number;
39
50
  maxLength?: number;
40
51
  pattern?: string;
@@ -45,7 +56,7 @@ export interface StringOptions {
45
56
  examples?: readonly string[];
46
57
  }
47
58
 
48
- export interface NumericOptions {
59
+ export interface NumericOptions extends CommonOptions {
49
60
  minimum?: number;
50
61
  maximum?: number;
51
62
  exclusiveMinimum?: number;
@@ -57,7 +68,7 @@ export interface NumericOptions {
57
68
  examples?: readonly number[];
58
69
  }
59
70
 
60
- export interface ArrayOptions {
71
+ export interface ArrayOptions extends CommonOptions {
61
72
  minItems?: number;
62
73
  maxItems?: number;
63
74
  uniqueItems?: boolean;
@@ -65,13 +76,13 @@ export interface ArrayOptions {
65
76
  title?: string;
66
77
  }
67
78
 
68
- export interface ObjectOptions {
79
+ export interface ObjectOptions extends CommonOptions {
69
80
  additionalProperties?: boolean | JSONSchema;
70
81
  description?: string;
71
82
  title?: string;
72
83
  }
73
84
 
74
- export interface RecordOptions {
85
+ export interface RecordOptions extends CommonOptions {
75
86
  minProperties?: number;
76
87
  maxProperties?: number;
77
88
  description?: string;