ata-validator 1.50.0 → 1.52.0

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/README.md CHANGED
@@ -138,18 +138,19 @@ errors (7.6M with `richErrors: false`).
138
138
 
139
139
  | Dimension | Schema | ata-AOT | runtime validator | Difference |
140
140
  |---|---|---|---|---|
141
- | Bundle (gzipped) | simple | 1.4 KB | 52.7 KB | 38.4x smaller |
142
- | Bundle (gzipped) | complex | 3.0 KB | 52.7 KB | 17.5x smaller |
143
- | Bundle (gzipped) | nested | 4.5 KB | 52.7 KB | 11.6x smaller |
141
+ | Bundle (gzipped) | simple | 1.3 KB | 52.7 KB | 39.0x smaller |
142
+ | Bundle (gzipped) | complex | 3.0 KB | 52.7 KB | 17.7x smaller |
143
+ | Bundle (gzipped) | nested | 4.5 KB | 52.7 KB | 11.8x smaller |
144
144
  | Cold start | simple | 20 ms | 37 ms | 1.9x faster |
145
- | Throughput (1M ops) | simple | 289 Mops/s | 96 Mops/s | 3.0x faster |
146
- | Compile time | simple | 18 µs | 1.56 ms | 85x faster |
145
+ | Throughput (1M ops) | simple | 315 Mops/s | 99 Mops/s | 3.2x faster |
146
+ | Compile time | simple | 24 µs | 1.57 ms | 65x faster |
147
147
 
148
148
  The runtime column is the default validator most frameworks ship. Reproduce on your machine
149
- with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-10-06,
150
- on ata-validator 1.46.0. Across four runs throughput moved between 288 and 329 Mops/s against
151
- 96 to 116, cold start between 19 and 20 ms against 37 to 39, and the compile ratio between 77x
152
- and 85x. The throughput row times a single one-million-call
149
+ with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-10-11,
150
+ on ata-validator 1.51.0. Across five runs throughput moved between 263 and 325 Mops/s against
151
+ 97 to 110, cold start between 20 and 22 ms against 37 to 40, and the compile ratio between 61x
152
+ and 65x. The compile row is the time to write a module, a build-time cost; it was 18 µs on
153
+ 1.46.0, and modules have carried the one-pass error function since 1.47.0. The throughput row times a single one-million-call
153
154
  loop of a few milliseconds, so it moves the most from run to run; treat the last three rows as
154
155
  an order of magnitude rather than a constant.
155
156
 
@@ -279,13 +280,13 @@ is the most common way to get a misleading number out of this library.
279
280
 
280
281
  | | compiled with `ata build` | runtime `new Validator(schema)` |
281
282
  |---|---|---|
282
- | In a bundle, gzipped | **2.1 KB** | 126.1 KB |
283
+ | In a bundle, gzipped | **2.1 KB** | 123.9 KB |
283
284
  | Time to a served request | **3.5 ms** | 7.6 ms |
284
285
  | Schema known when | build time | any time |
285
286
 
286
287
  The bundle row is the ten-field user schema in
287
288
  `tests/fixtures/error-dx/user.schema.json`, every export of the compiled module against
288
- `new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.48.0.
289
+ `new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.51.0.
289
290
  The startup row is a Hono route on Bun 1.4, the median of three rounds of best-of-seven, from
290
291
  `benchmark/bundle`, against 3.5 ms for the same app doing no validation at all, so the
291
292
  compiled path costs nothing measurable to start. The runtime figure is what it is because
@@ -293,7 +294,7 @@ a schema that arrives at run time can use any keyword, so the whole engine has t
293
294
  there. The compiled module imports nothing and contains only the checks your schema asks
294
295
  for.
295
296
 
296
- **On a server, use whichever fits your schemas.** 113 KB of JavaScript on a Node or Bun
297
+ **On a server, use whichever fits your schemas.** 124 KB of JavaScript on a Node or Bun
297
298
  process is not a cost anyone notices, and the runtime API is the simpler thing to reach
298
299
  for. Speed is the same either way once warm.
299
300
 
@@ -535,7 +536,7 @@ const v = new Validator(schema, {
535
536
 
536
537
  ### Build-time compile (`ata compile`)
537
538
 
538
- The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. For the ten-field user schema in `tests/fixtures/error-dx/user.schema.json` the module is 2.1 KB gzipped, full error detail included, against 113.3 KB for the runtime bundled for the browser (ata 1.45.0).
539
+ The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. For the ten-field user schema in `tests/fixtures/error-dx/user.schema.json` the module is 2.1 KB gzipped, full error detail included, against 123.9 KB for the runtime bundled for the browser (ata 1.51.0).
539
540
 
540
541
  ```bash
541
542
  npx ata compile schemas/user.json -o src/generated/user.validator.mjs
@@ -581,20 +582,25 @@ error collector out of the modules: each one carries its verdict and its schema,
581
582
  its errors from one `ata-errors.mjs` the build writes beside them. The build does this only
582
583
  where it makes the output smaller, so a single small schema keeps its self-contained module.
583
584
  The errors are the ones `new Validator(schema, { richErrors: false })` reports, in the same
584
- order. Seven SchemaStore schemas imported into one app, minified with esbuild, go from 148.9 KB
585
- to 63.3 KB gzipped, and from 35.8 ms to 13.8 ms to import with code generation blocked. A
585
+ order. Forty SchemaStore schemas imported into one app, minified with esbuild, go from 513.8 KB
586
+ to 213.3 KB gzipped, and from 60.4 ms to 28.4 ms to import with code generation blocked. A
586
587
  rejected document costs more this way, since its errors come from an interpreted walk:
587
588
  `--errors inline` keeps every module self-contained. `--mode schema` writes each module as
588
589
  its schema and takes the verdict from the runtime too: forty SchemaStore schemas in one app
589
- come to 101.6 KB gzipped against 212.6 KB as compact modules, and load and check each once in
590
- 23.0 ms against 35.5, while a check once warm costs about five times what generated code costs.
590
+ come to 102.4 KB gzipped against 213.3 KB as compact modules, and load and check each once in
591
+ 23.9 ms against 34.5, while a check once warm costs about five times what generated code costs.
592
+ Schemas that reference each other by `$id` are built against each other, and in schema mode
593
+ they are written once to a shared registry that keeps only the definitions the modules reach;
594
+ `--base-uri <uri>` names files that declare no `$id`. Eight Kubernetes kinds, whose schemas
595
+ share one 1.1 MB definitions file, come to 32.6 KB gzipped as schema modules against 68.6 KB
596
+ self-contained, on ata 1.51.0.
591
597
 
592
598
  Bundle sizes for the 10-field user schema in `tests/fixtures/error-dx/user.schema.json`,
593
- minified and gzipped, measured with `bun build --minify --target=browser` on ata 1.45.0:
599
+ minified and gzipped, measured with `bun build --minify --target=browser` on ata 1.51.0:
594
600
 
595
601
  | What the app imports | Size | Notes |
596
602
  |---|---|---|
597
- | `Validator` from `ata-validator` | 113.3 KB | The compiler ships with it, because a runtime schema can use any keyword |
603
+ | `Validator` from `ata-validator` | 123.9 KB | The compiler ships with it, because a runtime schema can use any keyword |
598
604
  | `isValid` from the compiled module | **1.3 KB** | Nothing else is reachable, so the error collector is dropped |
599
605
  | `validate` from the compiled module | **1.9 KB** | Adds the detailed error collector |
600
606
 
@@ -632,7 +638,7 @@ through a `setFormats()` export the module carries. `docs/API.md` has the
632
638
  details.
633
639
 
634
640
  **Fastify startup, 10 route schemas, from a cold process to the first validated request:
635
- ajv 19.7 ms, ata 2.75 ms, no build step required.** ata registers in 0.25 ms of that and
641
+ ajv 19.0 ms, ata 2.81 ms, no build step required.** ata registers in 0.20 ms of that and
636
642
  compiles on the first request, so counting only registration would overstate the gap.
637
643
  Reproduce with `node benchmark/bench_fastify_boot.mjs`.
638
644
 
package/aot.d.ts CHANGED
@@ -56,7 +56,17 @@ export function compactDecline(validator: Validator<unknown>): string | null;
56
56
  * runtime Validator does with `richErrors: false`. Null where
57
57
  * toCompactModule would be null. `ata build --mode schema` writes these.
58
58
  */
59
- export function toSchemaModule(validator: Validator<unknown>, opts: { runtime: string; format?: 'esm' | 'cjs' }): string | null;
59
+ export function toSchemaModule(validator: Validator<unknown>, opts: { runtime: string; registry?: string; format?: 'esm' | 'cjs' }): string | null;
60
+
61
+ /**
62
+ * Source of the registry module schema modules import when their schemas
63
+ * reference each other by `$id`: the schemas, without descriptive keywords,
64
+ * as one array. Pass its import specifier as `registry` to toSchemaModule,
65
+ * for a validator built with those schemas in its `schemas` option; the module
66
+ * then names its schema by `$id` and carries no copy of it. `ata build --mode
67
+ * schema` does this for inputs that reference each other.
68
+ */
69
+ export function registrySource(schemas: ReadonlyArray<object | Validator<unknown>>, format?: 'esm' | 'cjs'): string;
60
70
 
61
71
  /** Source of the runtime compact modules (and, with `schemaMode`, schema
62
72
  * modules) import. With `schemas`, the validators or schemas whose modules
package/aot.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import mod from './lib/aot-impl.js';
2
- export const { toStandalone, toStandaloneModule, toCompactModule, toSchemaModule, compactDecline, errorRuntimeSource, bundle, bundleStandalone, bundleCompact, loadBundle } = mod;
2
+ export const { toStandalone, toStandaloneModule, toCompactModule, toSchemaModule, registrySource, compactDecline, errorRuntimeSource, bundle, bundleStandalone, bundleCompact, loadBundle } = mod;
3
3
  export default mod;
package/bin/ata.js CHANGED
@@ -51,6 +51,9 @@ Build options:
51
51
  --mode <mode> code (default) | schema. schema writes each module as its
52
52
  schema and takes the verdict and errors from one runtime
53
53
  file: far smaller and quicker to load, slower per check
54
+ --base-uri <uri> The URI an input without $id is known by: <uri> plus its
55
+ file name, for schemas that reference a shared file by URL
56
+ or relative path
54
57
  --check Check (don't write); exit 1 if any output is stale
55
58
  --cache-file <path> Cache file for incremental builds (default: cache disabled)
56
59
  --max-size <bytes> Fail build if any compiled module exceeds this gzipped size
@@ -102,6 +105,7 @@ function parseArgs(argv) {
102
105
  if (a === '--one-pass') { out.opts.onePass = true; continue; }
103
106
  if (a === '--errors') { out.opts.errors = argv[++i]; continue; }
104
107
  if (a === '--mode') { out.opts.mode = argv[++i]; continue; }
108
+ if (a === '--base-uri') { out.opts.baseUri = argv[++i]; continue; }
105
109
  if (a === '--check') { out.opts.check = true; continue; }
106
110
  if (a === '--strict') { out.opts.strict = true; continue; }
107
111
  if (a === '--strict-schema') { out.opts.strictSchema = true; continue; }
@@ -306,6 +310,7 @@ function cmdBuild(args) {
306
310
  onePass: args.opts.onePass === true ? true : undefined,
307
311
  errors: args.opts.errors,
308
312
  mode: args.opts.mode,
313
+ baseUri: args.opts.baseUri,
309
314
  check: !!args.opts.check,
310
315
  maxSize: args.opts.maxSize,
311
316
  strict: !!args.opts.strict,
@@ -326,6 +331,22 @@ function cmdBuild(args) {
326
331
  }
327
332
  if (report.runtime) {
328
333
  process.stdout.write(`ata: error runtime -> ${report.runtime.output} (${report.runtime.bytes.toLocaleString()} bytes, shared by the modules above)\n`);
334
+ if (report.runtime.registry) process.stdout.write(`ata: schema registry -> ${report.runtime.registry.output} (${report.runtime.registry.bytes.toLocaleString()} bytes, the schemas the modules above reference)\n`);
335
+ }
336
+ // A module the error generator declined still has an exact verdict, but
337
+ // every failure is the one ATA9000 error. Said once per module, short,
338
+ // and once with what to do about it.
339
+ let noDetail = 0;
340
+ for (const w of report.warnings || []) {
341
+ if (w.kind === 'error-detail') {
342
+ noDetail++;
343
+ process.stderr.write(`ata: warning ${w.input}: no error detail, failures report the single ATA9000 error (the verdict is exact)\n`);
344
+ } else {
345
+ process.stderr.write(`ata: warning ${w.input}: ${w.warning}\n`);
346
+ }
347
+ }
348
+ if (noDetail > 0 && args.opts.mode !== 'schema') {
349
+ process.stderr.write(`ata: ${noDetail} module${noDetail === 1 ? '' : 's'} without error detail; with --mode schema a module takes its errors from the interpreted engine instead\n`);
329
350
  }
330
351
  for (const c of report.cached) {
331
352
  process.stdout.write(`ata: cached ${c.input}\n`);
@@ -0,0 +1,15 @@
1
+ // ESM entry for bundlers that target a server (Bun, esbuild or Rollup for
2
+ // Node) and for Node before 22.10, which does not match `module-sync`: the
3
+ // validator core with the code generator registered, as index.js, but with
4
+ // each tool imported on its own (lib/tools.mjs), so the tools an application
5
+ // does not import stay out of its bundle. Through index.js a bundler sees one
6
+ // CommonJS module and keeps all of them.
7
+ import core from './lib/validator-core.js';
8
+ import engine from './lib/codegen-engine.js';
9
+ import { toTypeScript, renderPretty, renderCompact, toOutput, toRetryMessage, describeSchema, renderJSON } from './lib/tools.mjs';
10
+
11
+ core._registerCodegen(engine);
12
+
13
+ export const { Validator, compile, validate, validateAsync, parseAsync, version, createPaddedBuffer, SIMDJSON_PADDING, parseJSON, defineSchema } = core;
14
+ export { toTypeScript, renderPretty, renderCompact, toOutput, toRetryMessage, describeSchema, renderJSON };
15
+ export default { Validator, compile, validate, validateAsync, parseAsync, version, createPaddedBuffer, SIMDJSON_PADDING, parseJSON, toTypeScript, defineSchema, renderPretty, renderCompact, toOutput, toRetryMessage, describeSchema, renderJSON };
package/lib/aot-build.js CHANGED
@@ -109,6 +109,99 @@ function importSpecifier(fromFile, toFile) {
109
109
  const RUNTIME_PLACEHOLDER = '__ATA_ERROR_RUNTIME__';
110
110
  const ERRORS_MODES = new Set(['auto', 'inline', 'shared']);
111
111
 
112
+ // The registry without the definitions no module reaches. A shared
113
+ // definitions file (Kubernetes ships one with hundreds) is mostly what a
114
+ // given set of schemas never uses. Only `definitions` and `$defs` entries are
115
+ // dropped, and only from a document every reference into which is a plain
116
+ // pointer into them; a document reached as a whole, through an anchor, or
117
+ // holding an embedded $id is kept as it is. The result is checked: every
118
+ // root's references must still resolve against it, or the whole registry is
119
+ // kept.
120
+ function pruneRegistry(roots, byId) {
121
+ const used = new Map();
122
+ const keepWhole = new Set();
123
+ const seen = new Set();
124
+ const queue = [];
125
+ const hasInnerId = new Map();
126
+ const innerId = (doc) => {
127
+ let hit = hasInnerId.get(doc);
128
+ if (hit !== undefined) return hit;
129
+ hit = false;
130
+ const stack = [doc];
131
+ const visited = new Set();
132
+ while (stack.length && !hit) {
133
+ const n = stack.pop();
134
+ if (n === null || typeof n !== 'object' || visited.has(n)) continue;
135
+ visited.add(n);
136
+ if (n !== doc && !Array.isArray(n) && typeof n.$id === 'string') hit = true;
137
+ for (const k of Object.keys(n)) stack.push(n[k]);
138
+ }
139
+ hasInnerId.set(doc, hit);
140
+ return hit;
141
+ };
142
+ const docOf = new Map();
143
+ for (const [id, doc] of byId) docOf.set(id.replace(/#$/, ''), doc);
144
+ const reach = (doc, base, node) => {
145
+ if (node === null || typeof node !== 'object' || seen.has(node)) return;
146
+ seen.add(node);
147
+ queue.push([doc, base, node]);
148
+ };
149
+ const defsOf = (doc) => (doc.$defs && typeof doc.$defs === 'object' ? '$defs' : doc.definitions && typeof doc.definitions === 'object' ? 'definitions' : null);
150
+ for (const root of roots) reach(root, typeof root.$id === 'string' ? root.$id : null, root);
151
+ while (queue.length) {
152
+ const [doc, base, node] = queue.pop();
153
+ if (Array.isArray(node)) { for (const x of node) reach(doc, base, x); continue; }
154
+ for (const k of Object.keys(node)) {
155
+ const v = node[k];
156
+ if ((k === '$ref' || k === '$dynamicRef' || k === '$recursiveRef') && typeof v === 'string') {
157
+ let abs;
158
+ try { abs = base ? new URL(v, base).href : v.charCodeAt(0) === 35 ? null : new URL(v).href; } catch { abs = null; }
159
+ const local = v.charCodeAt(0) === 35;
160
+ const hash = abs === null ? -1 : abs.indexOf('#');
161
+ const uri = abs === null ? null : hash < 0 ? abs : abs.slice(0, hash);
162
+ const target = local ? doc : uri !== null ? docOf.get(uri) : undefined;
163
+ if (target === undefined) continue;
164
+ const frag = local ? v.slice(1) : hash < 0 ? '' : abs.slice(hash + 1);
165
+ const tbase = local ? base : uri;
166
+ const m = /^\/(\$defs|definitions)\/([^/]+)/.exec(frag);
167
+ const which = defsOf(target);
168
+ if (k !== '$ref' || m === null || m[1] !== which) {
169
+ keepWhole.add(target);
170
+ reach(target, tbase, target);
171
+ continue;
172
+ }
173
+ let name = m[2];
174
+ try { name = decodeURIComponent(name); } catch {}
175
+ name = name.replace(/~1/g, '/').replace(/~0/g, '~');
176
+ let set = used.get(target);
177
+ if (set === undefined) { set = new Set(); used.set(target, set); }
178
+ set.add(name);
179
+ // The rest of the document around its definitions is used too.
180
+ for (const key of Object.keys(target)) if (key !== which) reach(target, tbase, target[key]);
181
+ reach(target, tbase, target[which][name]);
182
+ } else if (v !== null && typeof v === 'object') {
183
+ reach(doc, base, v);
184
+ }
185
+ }
186
+ }
187
+ const out = new Map();
188
+ for (const [id, doc] of byId) {
189
+ const which = defsOf(doc);
190
+ if (which === null || keepWhole.has(doc) || innerId(doc) || roots.includes(doc)) { out.set(id, doc); continue; }
191
+ const names = used.get(doc) || new Set();
192
+ const defs = {};
193
+ for (const name of Object.keys(doc[which])) if (names.has(name)) Object.defineProperty(defs, name, { value: doc[which][name], enumerable: true, writable: true, configurable: true });
194
+ out.set(id, { ...doc, [which]: defs });
195
+ }
196
+ for (const root of roots) {
197
+ if (!aot._refsResolveWithin(root, out)) return byId;
198
+ }
199
+ // The registry documents' own references too, as the runtime may walk any
200
+ // part of a definition it keeps.
201
+ for (const doc of out.values()) if (!aot._refsResolveWithin(doc, out)) return byId;
202
+ return out;
203
+ }
204
+
112
205
  async function build(opts) {
113
206
  const globs = opts.globs || [];
114
207
  if (globs.length === 0) throw new Error('build: at least one glob required');
@@ -138,6 +231,28 @@ async function build(opts) {
138
231
  const warnings = [];
139
232
  const entries = [];
140
233
 
234
+ // Schemas that reference each other: a schema whose references do not all
235
+ // land inside it gets the other inputs registered beside it, each under its
236
+ // $id, or, with `baseUri`, an input without one under the base URI and its
237
+ // file name. In schema mode its module then resolves against one registry
238
+ // module the build writes (registrySource); otherwise it is a
239
+ // self-contained module carrying what it references. A schema that
240
+ // resolves within itself is built exactly as it would be alone.
241
+ if (opts.baseUri !== undefined) {
242
+ try { new URL(opts.baseUri); } catch { throw new Error(`build: baseUri must be an absolute URI (got "${opts.baseUri}")`); }
243
+ }
244
+ let siblings = [];
245
+ for (const input of inputs) {
246
+ try {
247
+ const sch = parseSchemaFile(input);
248
+ if (sch === null || typeof sch !== 'object' || Array.isArray(sch)) continue;
249
+ if (typeof sch.$id === 'string' && sch.$id !== '') siblings.push({ input, schema: sch });
250
+ else if (opts.baseUri !== undefined) siblings.push({ input, schema: { $id: new URL(path.basename(input), opts.baseUri).href, ...sch } });
251
+ } catch {}
252
+ }
253
+ if (siblings.length === 0) siblings = null;
254
+ const REGISTRY_PLACEHOLDER = '__ATA_SCHEMA_REGISTRY__';
255
+
141
256
  const compileOne = (input, raw) => {
142
257
  const schema = parseSchemaFile(input);
143
258
  // The build is where an authoring mistake is cheapest to stop: a typo
@@ -151,7 +266,13 @@ async function build(opts) {
151
266
  return { error: problems.map((x) => `strict mode: ${x.message} at ${x.path}`).join('; ') };
152
267
  }
153
268
  }
154
- const v = new Validator(schema);
269
+ const needs = siblings !== null && schema !== null && typeof schema === 'object' && !Array.isArray(schema) && !aot._refsResolveWithin(schema);
270
+ const self = needs ? siblings.find((x) => x.input === input) : undefined;
271
+ const others = needs ? siblings.filter((x) => x.input !== input && (self === undefined || x.schema.$id !== self.schema.$id)).map((x) => x.schema) : null;
272
+ // Known under the base URI too, so its own relative references resolve
273
+ // against it.
274
+ const own = self !== undefined ? self.schema : schema;
275
+ const v = others !== null && others.length > 0 ? new Validator(own, { schemas: others }) : new Validator(schema);
155
276
  let sourceMap = null;
156
277
  if (source) {
157
278
  // Only attempt position map for JSON (the scanner is JSON-only).
@@ -174,14 +295,37 @@ async function build(opts) {
174
295
  onWarning: (w, meta) => inputWarnings.push({ message: w, kind: (meta && meta.kind) || 'general' }),
175
296
  });
176
297
  const compact = schemaMode && src
177
- ? aot.toSchemaModule(v, { format, runtime: RUNTIME_PLACEHOLDER })
298
+ ? aot.toSchemaModule(v, others !== null && others.length > 0 ? { format, runtime: RUNTIME_PLACEHOLDER, registry: REGISTRY_PLACEHOLDER } : { format, runtime: RUNTIME_PLACEHOLDER })
178
299
  : sharable && src ? aot.toCompactModule(v, { format, formatMode: opts.formatMode, errorRuntime: RUNTIME_PLACEHOLDER }) : null;
179
300
  // The runtime parts this module's schema needs (see runtimeFeatures).
180
- const features = compact ? require('./error-runtime').runtimeFeatures([require('./schema-strip').stripAnnotations(v._schemaObj)]) : null;
301
+ // A registry module's schema reaches the others: what it needs from the
302
+ // registry, by $id, is every schema registered beside it. A key that is
303
+ // not its schema's $id cannot be found by the runtime, which keys the
304
+ // registry by $id: such a module is written self-contained.
305
+ let registryEntries = null;
306
+ let compactOut = compact;
307
+ if (compactOut && others !== null && others.length > 0) {
308
+ // A schema without an $id is not in the registry: its module carries it.
309
+ registryEntries = typeof v._schemaObj.$id === 'string' ? [v._schemaObj] : [];
310
+ for (const [id, sub] of v._schemaMap) {
311
+ if (!sub || typeof sub !== 'object' || sub.$id !== id) { compactOut = null; registryEntries = null; break; }
312
+ registryEntries.push(sub);
313
+ }
314
+ }
315
+ const { stripAnnotations: _strip } = require('./schema-strip');
316
+ const features = compactOut ? require('./error-runtime').runtimeFeatures((registryEntries || [v._schemaObj]).map(_strip)) : null;
181
317
  // A module that imports the pattern engine needs it in the runtime,
182
318
  // whatever the schema scan said.
183
- if (features && compact.includes('_ataCompileSafe')) features['safe-regex'] = true;
184
- return { schema, src, compact, features, inputWarnings };
319
+ if (features && compactOut.includes('_ataCompileSafe')) features['safe-regex'] = true;
320
+ // Why there is no module, when the reason is a reference: which one, and
321
+ // what would resolve it.
322
+ let unresolved = null;
323
+ if (!src) {
324
+ const miss = {};
325
+ const registry = v._schemaMap && v._schemaMap.size > 0 ? v._schemaMap : null;
326
+ if (!aot._refsResolveWithin(v._schemaObj, registry, miss) && typeof miss.ref === 'string') unresolved = miss.ref;
327
+ }
328
+ return { schema, src, compact: compactOut, features, registryEntries, root: v._schemaObj, usesSiblings: others !== null, unresolved, inputWarnings };
185
329
  };
186
330
 
187
331
  for (const input of inputs) {
@@ -196,6 +340,9 @@ async function build(opts) {
196
340
  cacheEntry.output === output &&
197
341
  !!cacheEntry.sharable === sharable &&
198
342
  !!cacheEntry.schemaMode === schemaMode &&
343
+ // A module built against the other inputs depends on them too, and
344
+ // the registry is put together from such modules: always rebuilt.
345
+ !cacheEntry.usesSiblings &&
199
346
  fs.existsSync(output) &&
200
347
  cacheEntry.outputHash === hashContent(fs.readFileSync(output))
201
348
  );
@@ -219,13 +366,15 @@ async function build(opts) {
219
366
  for (const x of r.inputWarnings) warnings.push({ input, warning: x.message, kind: x.kind });
220
367
  }
221
368
  if (!r.src) {
222
- const reason = 'schema is not AOT-compatible (toStandaloneModule returned null)';
369
+ const reason = r.unresolved !== null
370
+ ? `$ref "${r.unresolved}" does not resolve: no input has that $id. Add the schema it names as an input${opts.baseUri === undefined ? ', and if that file has no $id, give it one with --base-uri' : ''}`
371
+ : 'schema is not AOT-compatible (toStandaloneModule returned null)';
223
372
  if (opts.strict) failed.push({ input, error: reason });
224
373
  else skipped.push({ input, reason });
225
374
  continue;
226
375
  }
227
376
  entries.push({
228
- input, output, raw, inputHash, schema: r.schema, src: r.src, compact: r.compact, features: r.features,
377
+ input, output, raw, inputHash, schema: r.schema, src: r.src, compact: r.compact, features: r.features, registryEntries: r.registryEntries, root: r.root, usesSiblings: r.usesSiblings,
229
378
  fullGz: zlib.gzipSync(r.src).length,
230
379
  compactGz: r.compact ? zlib.gzipSync(r.compact).length : null,
231
380
  });
@@ -265,6 +414,19 @@ async function build(opts) {
265
414
  for (const e of entries) {
266
415
  e.mode = runtime !== null && candidates.includes(e) ? (schemaMode ? 'schema' : 'compact') : 'inline';
267
416
  }
417
+ // The registry the schema modules resolve against: every schema any of them
418
+ // needs, once, by $id.
419
+ let registryPath = null;
420
+ let registrySrc = null;
421
+ if (runtime !== null) {
422
+ const byId = new Map();
423
+ for (const e of entries) if (e.mode !== 'inline' && e.registryEntries) for (const sub of e.registryEntries) if (!byId.has(sub.$id)) byId.set(sub.$id, sub);
424
+ if (byId.size > 0) {
425
+ registryPath = path.join(path.dirname(runtime.path), 'ata-registry' + (format === 'cjs' ? '.cjs' : '.mjs'));
426
+ const roots = entries.filter((e) => e.mode !== 'inline' && e.registryEntries).map((e) => e.root);
427
+ registrySrc = aot.registrySource([...pruneRegistry(roots, byId).values()], format);
428
+ }
429
+ }
268
430
 
269
431
  for (const e of entries) {
270
432
  const { input, output } = e;
@@ -282,9 +444,10 @@ async function build(opts) {
282
444
  e.schema = r.schema; e.src = r.src; e.compact = r.compact; e.features = r.features;
283
445
  if (e.mode !== 'inline' && !r.compact) e.mode = 'inline';
284
446
  }
285
- const src = e.mode !== 'inline'
447
+ let src = e.mode !== 'inline'
286
448
  ? e.compact.split(JSON.stringify(RUNTIME_PLACEHOLDER)).join(JSON.stringify(importSpecifier(output, runtime.path)))
287
449
  : e.src;
450
+ if (e.mode !== 'inline' && e.registryEntries) src = src.split(JSON.stringify(REGISTRY_PLACEHOLDER)).join(JSON.stringify(importSpecifier(output, registryPath)));
288
451
  fs.mkdirSync(path.dirname(output), { recursive: true });
289
452
  fs.writeFileSync(output, src);
290
453
  const outBytes = Buffer.byteLength(src, 'utf8');
@@ -321,6 +484,7 @@ async function build(opts) {
321
484
  fullGz: e.fullGz,
322
485
  compactGz: e.compactGz,
323
486
  features: e.features || undefined,
487
+ usesSiblings: e.usesSiblings || undefined,
324
488
  };
325
489
  } catch (err) {
326
490
  failed.push({ input, error: err.message });
@@ -335,11 +499,21 @@ async function build(opts) {
335
499
  fs.writeFileSync(runtime.path, runtime.src);
336
500
  }
337
501
  runtimeReport = { output: runtime.path, bytes: Buffer.byteLength(runtime.src, 'utf8'), gzipBytes: runtime.gz };
502
+ if (registrySrc !== null) {
503
+ const cur = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null;
504
+ if (cur !== registrySrc) fs.writeFileSync(registryPath, registrySrc);
505
+ runtimeReport.registry = { output: registryPath, bytes: Buffer.byteLength(registrySrc, 'utf8'), gzipBytes: zlib.gzipSync(registrySrc).length };
506
+ }
338
507
  }
339
508
 
340
509
  writeCache(opts.cacheFile, newCache);
341
510
 
342
- return { compiled, cached, skipped, failed, warnings, runtime: runtimeReport };
511
+ // The self-contained source is built for every input, so its error-detail
512
+ // decline is recorded even where the module written is a compact or schema
513
+ // module, whose errors come from the runtime. Only the written one counts.
514
+ const fromRuntime = new Set(entries.filter((e) => e.mode !== 'inline').map((e) => e.input));
515
+ const kept = warnings.filter((w) => !(w.kind === 'error-detail' && fromRuntime.has(w.input)));
516
+ return { compiled, cached, skipped, failed, warnings: kept, runtime: runtimeReport };
343
517
  }
344
518
 
345
519
  async function watch(opts, onReport) {
package/lib/aot-impl.js CHANGED
@@ -251,7 +251,7 @@ function onePassCore(validator, opts) {
251
251
  const decls = [];
252
252
  const args = [];
253
253
  const own = new Set();
254
- const addOwn = (n) => { if (own.has(n)) return; own.add(n); if (n === '_h' || n === '_hall') addOwn('_hop'); if (n === '_ok') { addOwn('_PN'); addOwn('_hop'); } decls.push(OWN_HELPER_CODE[n]); };
254
+ const addOwn = (n) => { if (own.has(n)) return; own.add(n); if (n === '_h' || n === '_hall') addOwn('_hop'); if (n === '_ok') { addOwn('_PN'); addOwn('_hop'); } if (n === '_nk') addOwn('_PN'); decls.push(OWN_HELPER_CODE[n]); };
255
255
  let usesSafeRe = emit.usesSafeRe, usesCollapse = false;
256
256
  for (let i = 0; i < emit.names.length; i++) {
257
257
  const name = emit.names[i], val = emit.values[i];
@@ -272,7 +272,7 @@ function onePassCore(validator, opts) {
272
272
  if (name === '_VF') { args.push('null'); continue; }
273
273
  if (name === '_PN') { addOwn('_PN'); args.push('_PN'); continue; }
274
274
  if (name === '_OP' || name === '_gp') { addOwn('_PN'); args.push(name); continue; }
275
- if (name === '_hop' || name === '_h' || name === '_ok' || name === '_hall') { addOwn(name); args.push(name); continue; }
275
+ if (name === '_hop' || name === '_h' || name === '_ok' || name === '_hall' || name === '_nk') { addOwn(name); args.push(name); continue; }
276
276
  if (name === '_pe') { decls.push(PTR_ESC_HELPER); args.push('_pe'); continue; }
277
277
  if (name === '_deq') { decls.push(DEQ_HELPER); args.push('_deq'); continue; }
278
278
  if (name === '_anT') { decls.push('function _anT(){return true}'); args.push('_anT'); continue; }
@@ -687,13 +687,14 @@ function hasRefKey(n, seen) {
687
687
  return false;
688
688
  }
689
689
 
690
- function refsResolveWithin(schema) {
690
+ // miss: an object that gets `ref`, the first reference that does not resolve.
691
+ function refsResolveWithin(schema, registry, miss) {
691
692
  // No reference anywhere (a key of that name inside a value counts too):
692
693
  // nothing to resolve, and no index to build.
693
- if (!hasRefKey(schema, new Set())) return true;
694
+ if (!registry && !hasRefKey(schema, new Set())) return true;
694
695
  const { indexSchemas, resolveRef } = require('./interpreter-core')._refInternals;
695
696
  let state;
696
- try { state = indexSchemas(schema, null); } catch { return false; }
697
+ try { state = indexSchemas(schema, registry || null); } catch { return false; }
697
698
  for (const [node, base] of state.nodeBase) {
698
699
  if (node === null || typeof node !== 'object') continue;
699
700
  for (const k of ['$ref', '$dynamicRef']) {
@@ -701,15 +702,19 @@ function refsResolveWithin(schema) {
701
702
  if (r === undefined) continue;
702
703
  if (typeof r !== 'string') return false;
703
704
  let hit;
704
- try { hit = resolveRef(r, base, state); } catch { return false; }
705
- if (hit.node === undefined) return false;
705
+ try { hit = resolveRef(r, base, state); } catch { if (miss) miss.ref = r; return false; }
706
+ if (hit.node === undefined) { if (miss) miss.ref = r; return false; }
706
707
  }
707
708
  }
708
709
  return true;
709
710
  }
710
711
 
711
712
  const COMPACT_OPTIONS = new Set(['engine', 'strictSchema', 'logger', 'source']);
712
- function compactDecline(validator) {
713
+ // registryMode: for a schema module that resolves its references against the
714
+ // build's registry (toSchemaModule with `registry`): schemas registered beside
715
+ // the root are allowed, and every reference has to land in the schema or in
716
+ // one of them.
717
+ function compactDecline(validator, registryMode) {
713
718
  // Options are allowed by name, not refused by name: one this list does not
714
719
  // know (assertFormat: false, baseURI, a future one) may change what the
715
720
  // verdict means, and the runtime would not know to follow it.
@@ -718,19 +723,24 @@ function compactDecline(validator) {
718
723
  validator._ensureCompiled();
719
724
  const o = validator._options || {};
720
725
  for (const k of Object.keys(o)) {
721
- if (o[k] === undefined || COMPACT_OPTIONS.has(k)) continue;
726
+ if (o[k] === undefined || COMPACT_OPTIONS.has(k) || (registryMode === true && k === 'schemas')) continue;
722
727
  return 'option ' + k;
723
728
  }
724
729
  if (validator._usesKeywords || validator._externalChecks) return 'custom keywords';
725
730
  if (validator._userFormats && Object.keys(validator._userFormats).length) return 'custom formats';
726
- if (validator._schemaMap && validator._schemaMap.size > 0) return 'schemas registered beside the root';
731
+ if (registryMode !== true && validator._schemaMap && validator._schemaMap.size > 0) return 'schemas registered beside the root';
727
732
  if (validator._schemaObj === null || typeof validator._schemaObj !== 'object') return 'boolean schema';
728
733
  // The runtime has the embedded schema and nothing else: a reference that
729
734
  // lands outside it (a meta-schema the runtime Validator resolves from its
730
735
  // vendored copies, a schema registered later) would come back unresolved,
731
736
  // and the errors with it. The check runs on the schema as embedded.
732
737
  const { stripAnnotations } = require('./schema-strip');
733
- if (!refsResolveWithin(stripAnnotations(validator._schemaObj))) return 'a reference outside the schema';
738
+ let registry = null;
739
+ if (registryMode === true && validator._schemaMap && validator._schemaMap.size > 0) {
740
+ registry = new Map();
741
+ for (const [id, sub] of validator._schemaMap) registry.set(id, stripAnnotations(sub));
742
+ }
743
+ if (!refsResolveWithin(stripAnnotations(validator._schemaObj), registry)) return registry ? 'a reference outside the schema and the registry' : 'a reference outside the schema';
734
744
  return null;
735
745
  }
736
746
 
@@ -755,7 +765,12 @@ function toCompactModule(validator, opts) {
755
765
  function toSchemaModule(validator, opts) {
756
766
  assertEmittable(validator, 'toSchemaModule');
757
767
  if (!opts || typeof opts.runtime !== 'string') throw new TypeError('toSchemaModule: opts.runtime (the import specifier of the schema runtime) is required');
758
- if (compactDecline(validator) !== null) return null;
768
+ // opts.registry: the import specifier of the build's registry module (the
769
+ // array of schemas, written by errorRegistrySource), for schemas that
770
+ // reference each other by $id. The module then names its schema by $id and
771
+ // carries no copy of it.
772
+ const registry = typeof opts.registry === 'string' ? opts.registry : null;
773
+ if (compactDecline(validator, registry !== null) !== null) return null;
759
774
  const format = opts.format || 'esm';
760
775
  const { stripAnnotations } = require('./schema-strip');
761
776
  const { isV1Dialect } = require('./dialect');
@@ -770,12 +785,15 @@ function toSchemaModule(validator, opts) {
770
785
  if ('$anchor' in n || '$dynamicAnchor' in n || (!root && '$id' in n)) return false;
771
786
  return Object.keys(n).every((k) => flatWalk(n[k], false));
772
787
  };
773
- const rtOpts = [isV1Dialect(sch) ? 'v1: true' : '', flatWalk(stripped, true) ? 'flat: true' : ''].filter(Boolean).join(', ');
788
+ const ownId = registry !== null && typeof sch.$id === 'string' ? sch.$id : null;
789
+ const rtOpts = [isV1Dialect(sch) ? 'v1: true' : '', registry === null && flatWalk(stripped, true) ? 'flat: true' : '', registry !== null ? 'registry: _ataRegistry' : '', ownId !== null ? 'id: ' + JSON.stringify(ownId) : ''].filter(Boolean).join(', ');
774
790
  const hashSrc = hashOf(validator);
775
791
  const spec = JSON.stringify(opts.runtime);
776
- const imp = format === 'esm'
792
+ const regSpec = registry !== null ? JSON.stringify(registry) : null;
793
+ const imp = (format === 'esm'
777
794
  ? `import { createValidator as _ataCreateValidator } from ${spec};\n`
778
- : `const { createValidator: _ataCreateValidator } = require(${spec});\n`;
795
+ : `const { createValidator: _ataCreateValidator } = require(${spec});\n`)
796
+ + (regSpec === null ? '' : format === 'esm' ? `import _ataRegistry from ${regSpec};\n` : `const _ataRegistry = require(${regSpec});\n`);
779
797
  const exp = format === 'esm'
780
798
  ? 'export { validate, isValid, schemaHash, ataVersion };\nexport default { validate, isValid, schemaHash, ataVersion };\n'
781
799
  : 'module.exports = { validate, isValid, schemaHash, ataVersion };\nmodule.exports.default = module.exports;\n';
@@ -786,7 +804,7 @@ function toSchemaModule(validator, opts) {
786
804
  ${imp}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
787
805
  const ABORT = Object.freeze({ valid: false, errors: Object.freeze([Object.freeze({ code: 'ATA9000', message: 'validation failed', keyword: '__abort_early__', path: '' })]) });
788
806
  let _ataV = null;
789
- function _ataGet() { return _ataV !== null ? _ataV : (_ataV = _ataCreateValidator(JSON.parse(${text})${rtOpts ? ', { ' + rtOpts + ' }' : ''})); }
807
+ function _ataGet() { return _ataV !== null ? _ataV : (_ataV = _ataCreateValidator(${ownId !== null ? 'null' : 'JSON.parse(' + text + ')'}${rtOpts ? ', { ' + rtOpts + ' }' : ''})); }
790
808
  function isValid(data) { return _ataGet().isValid(data); }
791
809
  function validate(data) { const v = _ataGet(); if (v.isValid(data)) return VALID; const e = v.errors(data); return e === null ? ABORT : { valid: false, errors: e }; }
792
810
  const schemaHash = ${JSON.stringify(hashSrc)};
@@ -794,6 +812,18 @@ const ataVersion = ${JSON.stringify(ATA_VERSION)};
794
812
  ${exp}`;
795
813
  }
796
814
 
815
+ // The registry module schema modules import when their schemas reference each
816
+ // other: every schema of the build, without its descriptive keywords, as one
817
+ // array the runtime turns into its id-to-schema table.
818
+ function registrySource(schemas, format) {
819
+ const { stripAnnotations } = require('./schema-strip');
820
+ const text = JSON.stringify(JSON.stringify(schemas.map((x) => stripAnnotations(x && x._schemaObj !== undefined ? x._schemaObj : x)))).replace(/</g, '\\u003c');
821
+ const head = `// Auto-generated by ata-validator ${ATA_VERSION}, do not edit.\n// The schemas of one build, which its schema modules resolve references against.\n'use strict';\n`;
822
+ return format === 'cjs'
823
+ ? head + `module.exports = JSON.parse(${text});\n`
824
+ : head + `export default JSON.parse(${text});\n`;
825
+ }
826
+
797
827
  // Bundle multiple validators into a single JS file for fast startup.
798
828
  // Usage:
799
829
  // const bundle = Validator.bundle([schema1, schema2, ...]);
@@ -1110,6 +1140,7 @@ module.exports = {
1110
1140
  toStandaloneModule: htmlSafeResult(toStandaloneModule),
1111
1141
  toCompactModule: htmlSafeResult(toCompactModule),
1112
1142
  toSchemaModule: htmlSafeResult(toSchemaModule),
1143
+ registrySource: htmlSafeResult(registrySource),
1113
1144
  compactDecline,
1114
1145
  // `schemas`: the validators (or schemas) whose compact modules will import
1115
1146
  // this runtime; parts none of them needs are left out. Omitted, the
@@ -1130,3 +1161,5 @@ module.exports = {
1130
1161
  bundleCompact: htmlSafeResult(bundleCompact),
1131
1162
  loadBundle,
1132
1163
  };
1164
+ // For the build only (lib/aot-build.js), not part of ata-validator/aot.
1165
+ Object.defineProperty(module.exports, '_refsResolveWithin', { value: refsResolveWithin, enumerable: false });