ata-validator 0.15.0 → 0.15.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,13 @@
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
+ ## 0.15.1 - 2026-05-23
6
+
7
+ ### Fixed
8
+
9
+ - Coercion, defaults, and `removeAdditional` now follow a cross-schema `$ref` to the referenced shape. A whole-schema reference like `{ $ref: 'shared#' }` (used for shared route schemas) or a property reference like `{ id: { $ref: 'shared#/properties/id' } }` is preprocessed instead of skipped.
10
+ - The compile cache now keys on referenced schema content, not just the `$id`. Two validators that share a root schema string and an `$id` pointing at different schemas no longer reuse the wrong compiled function.
11
+
5
12
  ## 0.15.0 - 2026-05-18
6
13
 
7
14
  ### Added
package/index.js CHANGED
@@ -363,6 +363,19 @@ function buildSchemaMap(schemas) {
363
363
  return map
364
364
  }
365
365
 
366
+ // Compile-cache key for a root schema plus its external schemas. Must include
367
+ // the external schema CONTENT, not just their $ids: two validators can share a
368
+ // root schema string and the same $id while pointing that $id at different
369
+ // schemas (separate app instances, test suites, multi-tenant). Keying on $id
370
+ // alone reuses the wrong compiled validator and silently mis-validates.
371
+ function compileCacheKey(schemaStr, schemaMap) {
372
+ if (!schemaMap || schemaMap.size === 0) return schemaStr
373
+ const parts = []
374
+ for (const [id, s] of schemaMap) parts.push(id + '=' + JSON.stringify(s))
375
+ parts.sort()
376
+ return schemaStr + '\0' + parts.join('\0')
377
+ }
378
+
366
379
  // Resolve a relative URI ref against a base URI
367
380
  function resolveRelativeRef(ref, baseId) {
368
381
  if (!baseId || ref.includes('://') || ref.startsWith('#')) return ref
@@ -371,6 +384,66 @@ function resolveRelativeRef(ref, baseId) {
371
384
  return baseId.substring(0, lastSlash + 1) + ref
372
385
  }
373
386
 
387
+ // Resolve a cross-schema $ref to its target schema for preprocessing purposes.
388
+ // Handles whole-schema refs (`shared#`), relative-id matching, and JSON pointer
389
+ // fragments (`shared#/properties/id`). Returns null for local-only refs or when
390
+ // the target cannot be found. Used only to read `type`/`properties` for
391
+ // coercion/defaults/removeAdditional, never for validation.
392
+ function resolveRefForPreprocess(ref, schemaMap) {
393
+ if (!schemaMap || schemaMap.size === 0 || typeof ref !== 'string') return null
394
+ const hashIdx = ref.indexOf('#')
395
+ const baseId = hashIdx >= 0 ? ref.slice(0, hashIdx) : ref
396
+ const fragment = hashIdx >= 0 ? ref.slice(hashIdx + 1) : ''
397
+ if (!baseId) return null
398
+ let base = null
399
+ if (schemaMap.has(baseId)) base = schemaMap.get(baseId)
400
+ else if (!ref.includes('://')) {
401
+ for (const [id, s] of schemaMap) {
402
+ if (id.endsWith('/' + baseId)) { base = s; break }
403
+ }
404
+ }
405
+ if (!base) return null
406
+ if (!fragment) return base
407
+ let target = base
408
+ for (const part of fragment.split('/')) {
409
+ if (part === '') continue
410
+ if (target == null || typeof target !== 'object') return null
411
+ target = target[part.replace(/~1/g, '/').replace(/~0/g, '~')]
412
+ }
413
+ return target == null ? null : target
414
+ }
415
+
416
+ // Preprocessing (coerce/defaults/removeAdditional) reads `schema.properties` and
417
+ // each property's `type`. When the data shape lives behind a cross-schema $ref
418
+ // (a whole-schema ref like Fastify's `params: { $ref: 'shared#' }`, or a
419
+ // property ref like `{ id: { $ref: 'shared#/properties/id' } }`), follow the
420
+ // ref so the preprocessor can see the referenced shape. Returns the schema with
421
+ // such refs resolved, cloning only when a substitution is made.
422
+ function resolveSchemaForPreprocess(schema, schemaMap) {
423
+ if (!schema || typeof schema !== 'object' || !schemaMap || schemaMap.size === 0) return schema
424
+ let s = schema
425
+ // Whole-schema ref (only when it has no own properties, to avoid dropping
426
+ // sibling keywords on schemas that mix $ref with properties).
427
+ if (s.$ref && !s.properties) {
428
+ const t = resolveRefForPreprocess(s.$ref, schemaMap)
429
+ if (t && typeof t === 'object') s = t
430
+ }
431
+ if (!s.properties) return s
432
+ // Property-level refs: substitute the resolved target so coercion sees `type`.
433
+ let cloned = null
434
+ for (const key of Object.keys(s.properties)) {
435
+ const p = s.properties[key]
436
+ if (p && typeof p === 'object' && p.$ref && !p.type) {
437
+ const t = resolveRefForPreprocess(p.$ref, schemaMap)
438
+ if (t && typeof t === 'object') {
439
+ if (!cloned) { cloned = Object.assign({}, s); cloned.properties = Object.assign({}, s.properties) }
440
+ cloned.properties[key] = t
441
+ }
442
+ }
443
+ }
444
+ return cloned || s
445
+ }
446
+
374
447
  class Validator {
375
448
  constructor(schema, opts) {
376
449
  const options = opts || {};
@@ -531,9 +604,7 @@ class Validator {
531
604
 
532
605
  // Check cache first -- reuse compiled functions for same schema
533
606
  const sm = this._schemaMap.size > 0 ? this._schemaMap : null;
534
- const mapKey = this._schemaMap.size > 0
535
- ? this._schemaStr + '\0' + [...this._schemaMap.keys()].sort().join('\0')
536
- : this._schemaStr;
607
+ const mapKey = compileCacheKey(this._schemaStr, this._schemaMap);
537
608
  // Custom formats are JS functions: bypass the compile cache since they can
538
609
  // differ between validators that share the same schema string.
539
610
  const cached = this._userFormats ? null : _compileCache.get(mapKey);
@@ -559,13 +630,17 @@ class Validator {
559
630
  }
560
631
  this._jsFn = jsFn;
561
632
 
562
- // Data mutators -- try codegen first (12x faster), fallback to closure arrays
563
- let preprocess = buildPreprocessCodegen(schemaObj, options);
633
+ // Data mutators -- try codegen first (12x faster), fallback to closure arrays.
634
+ // Follow cross-refs so coercion/defaults/removeAdditional see the referenced
635
+ // shape (e.g. Fastify `params: { $ref: 'shared#' }` or property refs like
636
+ // `{ id: { $ref: 'shared#/properties/id' } }`).
637
+ const preprocessSchema = resolveSchemaForPreprocess(schemaObj, this._schemaMap);
638
+ let preprocess = buildPreprocessCodegen(preprocessSchema, options);
564
639
  if (!preprocess) {
565
- const applyDefaults = buildDefaultsApplier(schemaObj);
566
- const applyCoerce = options.coerceTypes ? buildCoercer(schemaObj) : null;
640
+ const applyDefaults = buildDefaultsApplier(preprocessSchema);
641
+ const applyCoerce = options.coerceTypes ? buildCoercer(preprocessSchema) : null;
567
642
  const applyRemove = options.removeAdditional
568
- ? buildRemover(schemaObj)
643
+ ? buildRemover(preprocessSchema)
569
644
  : null;
570
645
  const mutators = [applyRemove, applyCoerce, applyDefaults].filter(Boolean);
571
646
  preprocess =
@@ -1007,9 +1082,7 @@ class Validator {
1007
1082
  if (typeof process !== 'undefined' && process.env && process.env.ATA_FORCE_NAPI) return;
1008
1083
  if (!this._schemaStr) this._schemaStr = JSON.stringify(this._schemaObj);
1009
1084
  const sm = this._schemaMap.size > 0 ? this._schemaMap : null;
1010
- const mapKey = this._schemaMap.size > 0
1011
- ? this._schemaStr + '\0' + [...this._schemaMap.keys()].sort().join('\0')
1012
- : this._schemaStr;
1085
+ const mapKey = compileCacheKey(this._schemaStr, this._schemaMap);
1013
1086
  // Custom formats are JS functions: skip the shared cache so different
1014
1087
  // validators with the same schema string but different formats don't collide.
1015
1088
  const cached = this._userFormats ? null : _compileCache.get(mapKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
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",
@@ -10,7 +10,7 @@ const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'error-codes.md')
10
10
  const missing = [];
11
11
  const placeholder = [];
12
12
  for (const code of all()) {
13
- const headingRe = new RegExp(`^### ${code} `, 'm');
13
+ const headingRe = new RegExp(`^### ${code}\\b`, 'm');
14
14
  if (!headingRe.test(doc)) {
15
15
  missing.push(code);
16
16
  continue;