@jarenjs/validate 0.8.3 → 0.9.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.
Files changed (48) hide show
  1. package/ARCHITECTURE.md +1067 -0
  2. package/LICENSE +21 -0
  3. package/README.md +339 -2
  4. package/dist/types/array.d.ts +2 -0
  5. package/dist/types/bigint.d.ts +1 -0
  6. package/dist/types/combine.d.ts +1 -0
  7. package/dist/types/condition.d.ts +1 -0
  8. package/dist/types/content.d.ts +3 -0
  9. package/dist/types/data.d.ts +7 -0
  10. package/dist/types/dollar-data.d.ts +20 -0
  11. package/dist/types/dynamic-ref.d.ts +44 -0
  12. package/dist/types/enum.d.ts +1 -0
  13. package/dist/types/format.d.ts +21 -0
  14. package/dist/types/index.d.ts +874 -0
  15. package/dist/types/number.d.ts +1 -0
  16. package/dist/types/object.d.ts +3 -0
  17. package/dist/types/query-keyword.d.ts +19 -0
  18. package/dist/types/query.d.ts +29 -0
  19. package/dist/types/schema.d.ts +1 -0
  20. package/dist/types/string.d.ts +1 -0
  21. package/dist/types/tools.d.ts +51 -0
  22. package/dist/types/traverse.d.ts +32 -0
  23. package/dist/types/unevaluated.d.ts +12 -0
  24. package/package.json +32 -7
  25. package/src/array.js +565 -0
  26. package/src/bigint.js +97 -0
  27. package/src/combine.js +226 -0
  28. package/src/condition.js +109 -0
  29. package/src/content.js +83 -0
  30. package/src/data.js +477 -0
  31. package/src/dollar-data.js +629 -0
  32. package/src/dynamic-ref.js +121 -0
  33. package/src/enum.js +148 -0
  34. package/src/format.js +66 -0
  35. package/src/index.js +1854 -0
  36. package/src/number.js +159 -0
  37. package/src/object.js +755 -0
  38. package/src/query-keyword.js +99 -0
  39. package/src/query.js +59 -0
  40. package/src/schema.js +645 -0
  41. package/src/string.js +152 -0
  42. package/src/tools.js +205 -0
  43. package/src/traverse.js +433 -0
  44. package/src/unevaluated.js +151 -0
  45. package/dist/index.js +0 -1802
  46. package/dist/index.js.map +0 -7
  47. package/dist/index.min.js +0 -2
  48. package/dist/index.min.js.map +0 -7
package/src/index.js ADDED
@@ -0,0 +1,1854 @@
1
+ //@ts-check
2
+
3
+ import {
4
+ isObjectClass,
5
+ isStringType,
6
+ } from '@jarenjs/core';
7
+
8
+ import {
9
+ compileSchemaObject,
10
+ } from './schema.js';
11
+
12
+ import {
13
+ storeSchemaIdsInMap,
14
+ restoreSchemaRefsInMap,
15
+ resolveRefSchemaDeep,
16
+ encodeJsonPointerPath,
17
+ TraverseOptions,
18
+ createJsonPointer,
19
+ } from './traverse.js';
20
+
21
+ import {
22
+ isBoolOrObjectClass,
23
+ hasSchemaRef,
24
+ EvalLog,
25
+ } from './tools.js';
26
+ import { wrapUnevaluated } from './unevaluated.js';
27
+ import { registerFormatCompiler, registerFormatCompilers } from './format.js';
28
+ import { mergeMap } from '@jarenjs/core/object';
29
+ import { hasRecursiveAnchor, getDynamicAnchorName, collectDynamicAnchors, collectDynamicAnchorsDeep } from './dynamic-ref.js';
30
+
31
+ export {
32
+ registerFormatCompilers
33
+ } from './format.js';
34
+
35
+ export { TraverseOptions };
36
+
37
+ /**
38
+ * Ajv-style $data reference object.
39
+ * The value is a Relative JSON Pointer that resolves from the current data location.
40
+ * Format: `<non-negative-integer>("#"|<json-pointer>)`
41
+ * - "0" - The current value itself
42
+ * - "0#" - The property name/index of the current value
43
+ * - "0/foo" - The "foo" property of the current value
44
+ * - "1" - The parent value
45
+ * - "1/foo" - The "foo" property of the parent value
46
+ * @see https://github.com/ajv-validator/ajv/tree/master/spec/extras/%24data
47
+ * @typedef {Object} DollarDataRef
48
+ * @property {string} $data - Relative JSON Pointer resolved from the current data location
49
+ */
50
+
51
+ /**
52
+ * Data keyword schema for referencing instance data (json-everything style).
53
+ * Allows constraints to reference values from other parts of the instance;
54
+ * every property value is a (relative) JSON Pointer to the constraint's value.
55
+ * @see https://docs.json-everything.net/schema/examples/data-ref/
56
+ * @typedef {Object} DataKeywordSchema
57
+ * @property {string} [minimum] - JSON Pointer to the minimum value
58
+ * @property {string} [maximum] - JSON Pointer to the maximum value
59
+ * @property {string} [exclusiveMinimum] - JSON Pointer to the exclusive minimum value
60
+ * @property {string} [exclusiveMaximum] - JSON Pointer to the exclusive maximum value
61
+ * @property {string} [multipleOf] - JSON Pointer to the multipleOf value
62
+ * @property {string} [minLength] - JSON Pointer to the minLength value
63
+ * @property {string} [maxLength] - JSON Pointer to the maxLength value
64
+ * @property {string} [pattern] - JSON Pointer to the pattern string
65
+ * @property {string} [format] - JSON Pointer to the format name
66
+ * @property {string} [enum] - JSON Pointer to an array of valid values
67
+ * @property {string} [const] - JSON Pointer to the constant value
68
+ * @property {string} [minItems] - JSON Pointer to the minItems value
69
+ * @property {string} [maxItems] - JSON Pointer to the maxItems value
70
+ * @property {string} [minProperties] - JSON Pointer to the minProperties value
71
+ * @property {string} [maxProperties] - JSON Pointer to the maxProperties value
72
+ */
73
+
74
+ /**
75
+ * The standard JSON Schema keywords understood by Jaren
76
+ * (draft-06 through draft 2020-12). See {@link JSONSchema} for the full
77
+ * schema object type that also permits custom keywords.
78
+ * @typedef {Object} JSONSchemaKeywords
79
+ * @property {string} [$id] - Schema resource identifier (URI)
80
+ * @property {string} [$schema] - Meta-schema URI declaring the draft dialect
81
+ * @property {string} [$ref] - Reference to another schema (URI reference)
82
+ * @property {string} [$anchor] - Plain-name fragment identifier (2019-09+)
83
+ * @property {string} [$dynamicRef] - Dynamic reference (2020-12)
84
+ * @property {string} [$dynamicAnchor] - Dynamic anchor (2020-12)
85
+ * @property {Record<string, boolean>} [$vocabulary] - Vocabulary declarations of a meta-schema
86
+ * @property {string} [$comment] - Comment for schema maintainers; not used in validation
87
+ * @property {Record<string, JSONSchema>} [$defs] - Reusable subschema definitions (2019-09+)
88
+ * @property {Record<string, JSONSchema>} [definitions] - Reusable subschema definitions (draft-07 and earlier)
89
+ * @property {string | string[]} [type] - Expected JSON type(s): 'null', 'boolean', 'object', 'array', 'number', 'string' or 'integer'
90
+ * @property {unknown[] | DollarDataRef} [enum] - Exhaustive list of valid values
91
+ * @property {unknown | DollarDataRef} [const] - Single valid value
92
+ * @property {number | DollarDataRef} [minLength] - Minimum string length (in graphemes by default)
93
+ * @property {number | DollarDataRef} [maxLength] - Maximum string length (in graphemes by default)
94
+ * @property {string | DollarDataRef} [pattern] - ECMA-262 regular expression the string must match
95
+ * @property {string} [contentEncoding] - Encoding of a string-embedded document (e.g. 'base64')
96
+ * @property {string} [contentMediaType] - Media type of a string-embedded document
97
+ * @property {JSONSchema} [contentSchema] - Schema for the decoded string-embedded document
98
+ * @property {number | DollarDataRef} [multipleOf] - Number must be a multiple of this value
99
+ * @property {number | DollarDataRef} [minimum] - Inclusive lower bound
100
+ * @property {number | DollarDataRef} [maximum] - Inclusive upper bound
101
+ * @property {number | boolean | DollarDataRef} [exclusiveMinimum] - Exclusive lower bound (boolean form in draft-04 style schemas)
102
+ * @property {number | boolean | DollarDataRef} [exclusiveMaximum] - Exclusive upper bound (boolean form in draft-04 style schemas)
103
+ * @property {Record<string, JSONSchema>} [properties] - Schemas for named object members
104
+ * @property {Record<string, JSONSchema>} [patternProperties] - Schemas for members whose name matches a regular expression
105
+ * @property {boolean | JSONSchema} [additionalProperties] - Schema for members not matched by properties/patternProperties
106
+ * @property {boolean | JSONSchema} [unevaluatedProperties] - Schema for members not evaluated by any subschema (2019-09+)
107
+ * @property {string[] | DollarDataRef} [required] - Member names that must be present
108
+ * @property {JSONSchema} [propertyNames] - Schema every member name must validate against
109
+ * @property {number | DollarDataRef} [minProperties] - Minimum number of members
110
+ * @property {number | DollarDataRef} [maxProperties] - Maximum number of members
111
+ * @property {JSONSchema | JSONSchema[]} [items] - Schema for array elements (array form is the draft-07 tuple syntax)
112
+ * @property {JSONSchema[]} [prefixItems] - Tuple element schemas (2020-12)
113
+ * @property {boolean | JSONSchema} [additionalItems] - Schema for elements beyond the tuple prefix (draft-07 and earlier)
114
+ * @property {boolean | JSONSchema} [unevaluatedItems] - Schema for elements not evaluated by any subschema (2019-09+)
115
+ * @property {JSONSchema} [contains] - At least one element must validate against this schema
116
+ * @property {number | DollarDataRef} [minItems] - Minimum number of elements
117
+ * @property {number | DollarDataRef} [maxItems] - Maximum number of elements
118
+ * @property {boolean | DollarDataRef} [uniqueItems] - Whether all elements must be unique
119
+ * @property {number} [minContains] - Minimum number of elements matching 'contains' (2019-09+)
120
+ * @property {number} [maxContains] - Maximum number of elements matching 'contains' (2019-09+)
121
+ * @property {JSONSchema[]} [allOf] - Value must validate against all of these schemas
122
+ * @property {JSONSchema[]} [anyOf] - Value must validate against at least one of these schemas
123
+ * @property {JSONSchema[]} [oneOf] - Value must validate against exactly one of these schemas
124
+ * @property {JSONSchema} [not] - Value must NOT validate against this schema
125
+ * @property {JSONSchema} [if] - Condition schema selecting between 'then' and 'else'
126
+ * @property {JSONSchema} [then] - Applied when 'if' validates
127
+ * @property {JSONSchema} [else] - Applied when 'if' does not validate
128
+ * @property {Record<string, JSONSchema>} [dependentSchemas] - Schemas applied when a member is present (2019-09+)
129
+ * @property {Record<string, string[]>} [dependentRequired] - Members required when a member is present (2019-09+)
130
+ * @property {string} [title] - Short descriptive title
131
+ * @property {string} [description] - Explanation of the schema's purpose
132
+ * @property {unknown} [default] - Default value annotation
133
+ * @property {unknown[]} [examples] - Example values annotation
134
+ * @property {boolean} [readOnly] - Value is managed by the receiving authority
135
+ * @property {boolean} [writeOnly] - Value is never returned by the receiving authority
136
+ * @property {boolean} [deprecated] - Value is deprecated
137
+ * @property {string | DollarDataRef} [format] - Named semantic format (e.g. 'email', 'uri', 'date-time')
138
+ * @property {string} [formatMinimum] - Format-aware inclusive lower bound (non-standard, Ajv-style)
139
+ * @property {string} [formatMaximum] - Format-aware inclusive upper bound (non-standard, Ajv-style)
140
+ * @property {string} [formatExclusiveMinimum] - Format-aware exclusive lower bound (non-standard, Ajv-style)
141
+ * @property {string} [formatExclusiveMaximum] - Format-aware exclusive upper bound (non-standard, Ajv-style)
142
+ * @property {DataKeywordSchema} [data] - Data keyword referencing instance data (json-everything style)
143
+ */
144
+
145
+ /**
146
+ * Represents a JSON Schema object.
147
+ * Covers the standard keywords of drafts 06, 07, 2019-09 and 2020-12
148
+ * (see {@link JSONSchemaKeywords}) while remaining open for custom
149
+ * keywords: any property outside the standard set is permitted.
150
+ * Note that a complete schema is `JSONSchema | boolean` - the boolean
151
+ * forms accept everything (`true`) or nothing (`false`).
152
+ * @typedef {JSONSchemaKeywords & Record<string, unknown>} JSONSchema
153
+ */
154
+
155
+ /**
156
+ * A format compiler function.
157
+ * Called once per schema location at compile time with the compiling
158
+ * ValidationObject and the schema that declares the format; returns the
159
+ * format validator that is invoked for each instance value, or undefined
160
+ * when the format does not apply to the schema location. Compilers are
161
+ * only invoked for schemas whose `format` member is a plain string.
162
+ * @typedef {(schemaObj: ValidationObject, jsonSchema: JSONSchema & {format?: string}) => ((data: unknown, dataPath?: string) => boolean) | undefined} FormatCompiler
163
+ */
164
+
165
+ export const DEFAULT_SCHEMA_DRAFT = 'http://json-schema.org/draft-06/schema#'
166
+
167
+ /**
168
+ * Detects the JSON Schema draft version from the schema's $schema property
169
+ * @param {object} schema - The JSON schema
170
+ * @returns {number} - The draft version (6, 7, 2019, or 2020)
171
+ */
172
+ export function detectSchemaDraft(schema) {
173
+ if (!schema || typeof schema !== 'object') return 7; // default to draft7
174
+ const schemaUrl = schema.$schema || '';
175
+ if (schemaUrl.includes('2020-12')) return 2020;
176
+ if (schemaUrl.includes('2019-09')) return 2019;
177
+ if (schemaUrl.includes('draft-07') || schemaUrl.includes('draft/07')) return 7;
178
+ if (schemaUrl.includes('draft-06') || schemaUrl.includes('draft/06')) return 6;
179
+ return 7; // default to draft7 behavior
180
+ }
181
+
182
+ const isBrowser = typeof window !== 'undefined';
183
+
184
+ const performance = (() => isBrowser
185
+ // eslint-disable-next-line no-undef
186
+ ? window.performance
187
+ : {
188
+ now: function performanceNow(start) {
189
+ // @ts-ignore
190
+ const ps = process;
191
+ if (!start) return ps.hrtime();
192
+ const end = ps.hrtime(start);
193
+ return Math.round((end[0] * 1000) + (end[1] / 1000000));
194
+ },
195
+ })();
196
+
197
+ class InternalValidationError {
198
+ constructor(obj, key, expected, dataKey, value, rest) {
199
+ this.timeStamp = performance.now();
200
+ this.object = obj;
201
+ this.key = key;
202
+ this.expected = expected;
203
+ this.dataKey = dataKey;
204
+ this.value = value;
205
+ this.rest = rest;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * JSON Schema Validation Error
211
+ * Represents a validation error according to the JSON Schema specification.
212
+ * @see https://json-schema.org/draft/2020-12/json-schema-core.html#output
213
+ */
214
+ export class ValidationError {
215
+ /**
216
+ * @param {object} options - Error options
217
+ * @param {string} options.keyword - The keyword that failed validation
218
+ * @param {string} options.instancePath - JSON Pointer to the data location
219
+ * @param {string} options.schemaPath - JSON Pointer to the schema location
220
+ * @param {object} options.params - Keyword-specific parameters
221
+ * @param {string} [options.message] - Human-readable error message
222
+ */
223
+ constructor(options) {
224
+ this.keyword = options.keyword;
225
+ this.instancePath = options.instancePath || '';
226
+ this.schemaPath = options.schemaPath || '';
227
+ this.params = options.params || {};
228
+ this.message = options.message || '';
229
+ }
230
+
231
+ /**
232
+ * Convert error to a plain object
233
+ * @returns {object} Plain object representation
234
+ */
235
+ toJSON() {
236
+ return {
237
+ keyword: this.keyword,
238
+ instancePath: this.instancePath,
239
+ schemaPath: this.schemaPath,
240
+ params: this.params,
241
+ message: this.message,
242
+ };
243
+ }
244
+ }
245
+
246
+ /**
247
+ * ValidationOptions configures the behavior of the validation process.
248
+ * @class
249
+ */
250
+ export class ValidationOptions {
251
+ /**
252
+ * Creates validation options.
253
+ * @param {boolean} [skipErrors=true] - Whether to stop at first error or continue
254
+ * @param {boolean} [useGrapheme=true] - Whether to use grapheme cluster counting for strings
255
+ * @param {boolean} [collectErrors=false] - Whether to collect all errors or just return boolean
256
+ * @param {boolean|null} [contentValidation=null] - Whether to validate contentEncoding/contentMediaType (null = auto based on draft)
257
+ * @param {number} [draftVersion=7] - The JSON Schema draft version (6, 7, 2019, or 2020)
258
+ * @param {boolean} [vocabValidation=true] - Whether the validation vocabulary is enabled (false when the schema's metaschema omits it via $vocabulary)
259
+ * @param {boolean|null} [formatAssertion=null] - Whether format asserts (null = auto: asserts below draft 2020-12, annotation-only from 2020-12 on)
260
+ */
261
+ constructor(
262
+ skipErrors = true,
263
+ useGrapheme = true,
264
+ collectErrors = false,
265
+ contentValidation = null,
266
+ draftVersion = 7,
267
+ vocabValidation = true,
268
+ formatAssertion = null
269
+ ) {
270
+ /** @type {boolean} Whether to stop at first error or continue */
271
+ this.skipErrors = skipErrors;
272
+ /** @type {boolean} Whether to use grapheme cluster counting for string length */
273
+ this.useGrapheme = useGrapheme;
274
+ /** @type {boolean} Whether to collect and return detailed errors */
275
+ this.collectErrors = collectErrors;
276
+ /** @type {boolean|null} Whether to validate contentEncoding/contentMediaType (null = auto based on draft) */
277
+ this.contentValidation = contentValidation;
278
+ /** @type {number} The JSON Schema draft version (6, 7, 2019, or 2020) */
279
+ this.draftVersion = draftVersion;
280
+ /** @type {boolean} Whether validation vocabulary keywords (type, minimum, ...) are asserted */
281
+ this.vocabValidation = vocabValidation;
282
+ /** @type {boolean|null} Whether the format keyword asserts (null = auto by draft) */
283
+ this.formatAssertion = formatAssertion;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * ValidationRoot manages the compilation and validation context for a schema.
289
+ * It holds references to all schemas, formats, options, and compiled ValidationObjects.
290
+ * @class
291
+ */
292
+ export class ValidationRoot {
293
+ /**
294
+ * Creates a ValidationObject and stores it in the root's object map.
295
+ * @param {ValidationRoot} self - The ValidationRoot instance
296
+ * @param {string} path - The URI path for this schema object
297
+ * @param {object|boolean} schema - The JSON schema
298
+ * @param {string} baseUri - The base URI for resolving relative refs
299
+ * @returns {ValidationObject} The created ValidationObject
300
+ */
301
+ static #createObject(self, path, schema, baseUri, parentDeclaredDraft = null) {
302
+ const objects = self.#objects;
303
+ if (objects.has(path)) {
304
+ const p = objects.get(path);
305
+ if (p != null)
306
+ throw new Error(`Object at '${path}' is already created`);
307
+ }
308
+
309
+ const obj = new ValidationObject(self, path, schema, baseUri, parentDeclaredDraft);
310
+ objects.set(path, obj);
311
+ return obj;
312
+ }
313
+
314
+ /** @type {string|null} The root schema origin/URI */
315
+ #rootOrigin = null;
316
+ /** @type {Map|null} Map of schema paths to schema objects */
317
+ #schemas = null;
318
+ /** @type {object|null} Registered format validators */
319
+ #formats = null;
320
+ /** @type {ValidationOptions|null} Validation options */
321
+ #options = null;
322
+ /** @type {TraverseOptions|null} Schema traversal options */
323
+ #traverse = null;
324
+ /** @type {Map|null} Map of paths to ValidationObjects */
325
+ #objects = null;
326
+ /** @type {Array} Array of validation errors */
327
+ #errors = null;
328
+ /** @type {ValidationObject|null} The root schema's ValidationObject */
329
+ #firstSchema = null;
330
+ /** @type {Map<string, Function[]>} Map of anchor names to stacks of validator functions */
331
+ #dynamicAnchors = null;
332
+ /** @type {string|null} Anchor name to register for the root schema on each validation, or null when not needed */
333
+ #rootAnchorName = null;
334
+ /** @type {function|null} Cached compiled validator of the root schema */
335
+ #rootValidator = null;
336
+ /** @type {Array<{name: string, schema: object, validator: (function|null)}>} $dynamicAnchors of the root resource (excluding the root's own), registered on each validation */
337
+ #rootDynamicAnchors = [];
338
+ /** @type {boolean} Whether any schema in this compilation contains a $data reference */
339
+ #usesDollarData = false;
340
+ /** @type {boolean} Whether any schema in this compilation contains unevaluatedProperties/unevaluatedItems */
341
+ #usesUnevaluated = false;
342
+ /** @type {EvalLog} Log of evaluated properties/items for unevaluated* support */
343
+ #evalLog = new EvalLog();
344
+ /** @type {object|null} The JarenValidator instance this compilation belongs to, or null when constructed standalone */
345
+ #owner = null;
346
+
347
+ /** Keywords whose value is a map of arbitrary names to schemas; those
348
+ * names must not be mistaken for keywords (e.g. a metaschema declaring
349
+ * a property named 'unevaluatedProperties'). */
350
+ static #SCAN_MAP_KEYWORDS = new Set([
351
+ 'properties', 'patternProperties', 'dependentSchemas',
352
+ '$defs', 'definitions',
353
+ ]);
354
+
355
+ /**
356
+ * Recursively scans a schema (sub)tree for keys that require special
357
+ * runtime support: '$data' references and 'unevaluatedProperties'/
358
+ * 'unevaluatedItems'. Keys inside name->schema maps (properties, $defs,
359
+ * ...) are property/definition names and are not treated as keywords.
360
+ * @param {any} node - The schema node to scan
361
+ * @param {Set<object>} seen - Cycle guard
362
+ * @param {{dollarData: boolean, unevaluated: boolean}} flags - Output flags
363
+ * @param {boolean} [isSchema=true] - Whether node's keys are schema keywords
364
+ */
365
+ static #scanSchemaFeatures(node, seen, flags, isSchema = true) {
366
+ if (node == null || typeof node !== 'object') return;
367
+ if (flags.dollarData && flags.unevaluated) return;
368
+ if (seen.has(node)) return;
369
+ seen.add(node);
370
+ if (Array.isArray(node)) {
371
+ for (let i = 0; i < node.length; ++i) {
372
+ ValidationRoot.#scanSchemaFeatures(node[i], seen, flags, isSchema);
373
+ }
374
+ return;
375
+ }
376
+ const keys = Object.keys(node);
377
+ for (let i = 0; i < keys.length; ++i) {
378
+ const key = keys[i];
379
+ if (isSchema) {
380
+ // The json-everything 'data' keyword resolves relative pointers
381
+ // against the data path at validation time, just like '$data'.
382
+ if (key === '$data'
383
+ || (key === 'data' && node[key] !== null && typeof node[key] === 'object')) flags.dollarData = true;
384
+ else if (key === '$query') {
385
+ // The '$query' keyword binds the instance path to its 'path'
386
+ // external at validation time, so it consumes data paths like
387
+ // '$data'. Its value is a query document, not a schema - the
388
+ // keys inside (operators, embedded schema literals) must not
389
+ // register as keywords of this compilation.
390
+ flags.dollarData = true;
391
+ continue;
392
+ }
393
+ else if (key === 'unevaluatedProperties' || key === 'unevaluatedItems') flags.unevaluated = true;
394
+ if (ValidationRoot.#SCAN_MAP_KEYWORDS.has(key)) {
395
+ // The value is a name->schema map: its keys are names, its values schemas.
396
+ ValidationRoot.#scanSchemaFeatures(node[key], seen, flags, false);
397
+ continue;
398
+ }
399
+ }
400
+ ValidationRoot.#scanSchemaFeatures(node[key], seen, flags, true);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Creates a new ValidationRoot.
406
+ * @param {string} origin - The root schema origin/URI
407
+ * @param {Map} schemas - Map of schema paths to schema objects
408
+ * @param {Record<string, FormatCompiler>} formats - Registered format validators
409
+ * @param {ValidationOptions} [opts] - Validation options
410
+ * @param {TraverseOptions} [traverse] - Schema traversal options
411
+ * @param {object|null} [owner] - The owning JarenValidator instance; extension
412
+ * keywords ('$query') compile embedded schema literals against it so their
413
+ * `$ref`s resolve to the owner's `addSchema` registrations
414
+ */
415
+ constructor(origin, schemas, formats, opts = new ValidationOptions(), traverse = new TraverseOptions, owner = null) {
416
+ const schema = schemas.get(origin);
417
+ this.#rootOrigin = origin;
418
+ this.#schemas = schemas;
419
+ this.#formats = formats;
420
+
421
+ this.#options = opts;
422
+ this.#traverse = traverse;
423
+ this.#owner = owner;
424
+
425
+ this.#objects = new Map();
426
+ this.#errors = [];
427
+ this.#dynamicAnchors = new Map();
428
+
429
+ // Detect $data references and unevaluated* keywords once, so fast paths
430
+ // can skip path building / annotation logging when nothing consumes them.
431
+ // Must run before validators are compiled below.
432
+ const flags = { dollarData: false, unevaluated: false };
433
+ const seen = new Set();
434
+ for (const value of schemas.values()) {
435
+ ValidationRoot.#scanSchemaFeatures(value, seen, flags);
436
+ if (flags.dollarData && flags.unevaluated) break;
437
+ }
438
+ this.#usesDollarData = flags.dollarData;
439
+ this.#usesUnevaluated = flags.unevaluated;
440
+
441
+ // For the root schema, baseUri is the origin
442
+ this.#firstSchema = ValidationRoot.#createObject(this, origin, schema, origin);
443
+
444
+ // Precompute per-validation constants so validate() stays allocation-free.
445
+ // The root schema never changes after compilation.
446
+ const rootSchema = this.#firstSchema.schema;
447
+ const hasRecAnchor = isObjectClass(rootSchema) && hasRecursiveAnchor(rootSchema);
448
+ const dynAnchorName = isObjectClass(rootSchema) ? getDynamicAnchorName(rootSchema) : null;
449
+ this.#rootAnchorName = (hasRecAnchor || dynAnchorName) ? (dynAnchorName || '') : null;
450
+ this.#rootValidator = this.#firstSchema.validate;
451
+
452
+ // Entering the root resource brings ALL of its $dynamicAnchors into the
453
+ // dynamic scope (the root's own anchor is handled via #rootAnchorName).
454
+ this.#rootDynamicAnchors = collectDynamicAnchorsDeep(rootSchema)
455
+ .filter(anchor => anchor.schema !== rootSchema);
456
+ }
457
+
458
+ /** @returns {string} The root schema origin/URI */
459
+ get rootOrigin() { return this.#rootOrigin; }
460
+
461
+ /** @returns {TraverseOptions} Schema traversal options */
462
+ get traverse() { return this.#traverse; }
463
+
464
+ /** @returns {ValidationOptions} Validation options */
465
+ get options() { return this.#options; }
466
+
467
+ /** @returns {object} Registered format validators */
468
+ get formats() { return this.#formats; }
469
+
470
+ /** @returns {Array} Array of validation errors */
471
+ get errors() { return this.#errors; }
472
+
473
+ /** @returns {ValidationObject} The root schema's ValidationObject */
474
+ get firstSchema() { return this.#firstSchema; }
475
+
476
+ /** @returns {boolean} Whether any schema in this compilation contains a $data reference */
477
+ get usesDollarData() { return this.#usesDollarData; }
478
+
479
+ /** @returns {boolean} Whether any schema in this compilation contains unevaluatedProperties/unevaluatedItems */
480
+ get usesUnevaluated() { return this.#usesUnevaluated; }
481
+
482
+ /** @returns {EvalLog} The evaluation log for unevaluated* annotation tracking */
483
+ get evalLog() { return this.#evalLog; }
484
+
485
+ /** @returns {object|null} The owning JarenValidator instance, or null when constructed standalone */
486
+ get owner() { return this.#owner; }
487
+
488
+ /**
489
+ * Creates a new ValidationObject for the given path and schema.
490
+ * @param {string} path - The URI path for this schema object
491
+ * @param {object|boolean} schema - The JSON schema
492
+ * @param {string} baseUri - The base URI for resolving relative refs
493
+ * @returns {ValidationObject} The created ValidationObject
494
+ */
495
+ createObject(path, schema, baseUri, parentDeclaredDraft = null) {
496
+ return ValidationRoot.#createObject(this, path, schema, baseUri, parentDeclaredDraft);
497
+ }
498
+
499
+ /**
500
+ * Checks if an object exists at the given path without creating it.
501
+ * @param {string} path - The URI path to check
502
+ * @returns {ValidationObject|null|undefined} The existing object, null if marked unresolved, or undefined if not known
503
+ */
504
+ unresolvedObject(path) {
505
+ const objects = this.#objects;
506
+ if (objects.has(path))
507
+ return objects.get(path);
508
+
509
+ objects.set(path, null);
510
+ return null;
511
+ }
512
+
513
+ /**
514
+ * Gets the raw schema object for a given reference without compiling it.
515
+ * Used to check schema properties (like $recursiveAnchor) at compile time.
516
+ * @param {string} ref - The reference URI to resolve
517
+ * @param {string} path - The current path (for error messages)
518
+ * @param {object} schema - The schema containing the $ref
519
+ * @returns {{id: string, schema: object}|null} The resolved schema info or null
520
+ */
521
+ getRawSchema(ref, path, schema) {
522
+ try {
523
+ const schemas = this.#schemas;
524
+ const traverse = this.#traverse;
525
+ return resolveRefSchemaDeep(schemas, path, schema, traverse);
526
+ } catch (e) {
527
+ return null;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Gets the raw schema object by its URI/ID directly from the schemas map.
533
+ * This performs a direct lookup without following references.
534
+ * @param {string} uri - The schema URI to look up
535
+ * @returns {object|undefined} The raw schema object or undefined
536
+ */
537
+ getSchemaByUri(uri) {
538
+ return this.#schemas.get(uri);
539
+ }
540
+
541
+ /**
542
+ * Resolves a $ref to a ValidationObject, creating it if necessary.
543
+ * @param {string} ref - The reference URI to resolve
544
+ * @param {string} path - The current path (for error messages)
545
+ * @param {object} schema - The schema containing the $ref
546
+ * @returns {ValidationObject} The resolved ValidationObject
547
+ */
548
+ resolveObject(ref, path, schema) {
549
+ // Fast path - check if already compiled first
550
+ const objects = this.#objects;
551
+ const cached = objects.get(ref);
552
+ if (cached != null) return cached;
553
+
554
+ // Resolve ref chain and check final ID cache
555
+ const schemas = this.#schemas;
556
+ const traverse = this.#traverse;
557
+ const { id, schema: root } = resolveRefSchemaDeep(schemas, path, schema, traverse);
558
+
559
+ // Check if final ID is already compiled
560
+ const finalCached = objects.get(id);
561
+ if (finalCached != null) return finalCached;
562
+
563
+ // Create and cache the validation object
564
+ return ValidationRoot.#createObject(this, id, root);
565
+ }
566
+
567
+ /**
568
+ * Adds an error to the validation errors list.
569
+ * @param {InternalValidationError} error - The error to add
570
+ * @returns {boolean} Always returns false for convenience in validators
571
+ */
572
+ addError(error /*:InternalValidationError*/) {
573
+ this.#errors.push(error);
574
+ return false;
575
+ }
576
+
577
+ /**
578
+ * Validates data against the root schema.
579
+ * @param {unknown} data - The data to validate
580
+ * @returns {boolean} True if valid, false otherwise
581
+ */
582
+ validate(data /*:unknown*/) {
583
+ // only clear errors array when we need to collect errors
584
+ // When skipErrors=true (default), we don't use the errors array
585
+ if (!this.#options.skipErrors) {
586
+ this.#errors = [];
587
+ }
588
+ // Push/pop pairs are balanced (try/finally), so the anchors map is
589
+ // normally empty here already; clear defensively without reallocating.
590
+ if (this.#dynamicAnchors.size !== 0) {
591
+ this.#dynamicAnchors.clear();
592
+ }
593
+ // Clear evaluation annotations from the previous validation
594
+ if (this.#usesUnevaluated) {
595
+ this.#evalLog.reset();
596
+ }
597
+
598
+ const rootValidator = this.#rootValidator;
599
+ const anchorName = this.#rootAnchorName;
600
+ const rootAnchors = this.#rootDynamicAnchors;
601
+ if (anchorName !== null || rootAnchors.length !== 0) {
602
+ if (anchorName !== null)
603
+ this.pushDynamicAnchorValidator(anchorName, rootValidator);
604
+ for (let i = 0; i < rootAnchors.length; ++i) {
605
+ const anchor = rootAnchors[i];
606
+ if (anchor.validator === null)
607
+ anchor.validator = this.getOrCreateValidator(anchor.schema, this.#rootOrigin, this.#rootOrigin);
608
+ this.pushDynamicAnchorValidator(anchor.name, anchor.validator);
609
+ }
610
+ try {
611
+ // call compiled validator with dataRoot as third argument
612
+ return rootValidator(data, '', data);
613
+ } finally {
614
+ for (let i = rootAnchors.length - 1; i >= 0; --i) {
615
+ this.popDynamicAnchorValidator(rootAnchors[i].name);
616
+ }
617
+ if (anchorName !== null)
618
+ this.popDynamicAnchorValidator(anchorName);
619
+ }
620
+ }
621
+
622
+ // call compiled validator
623
+ // Pass dataRoot as the third argument for data keyword support
624
+ return rootValidator(data, '', data);
625
+ }
626
+
627
+ /**
628
+ * Get the stored validator for a dynamic anchor.
629
+ * Used by $dynamicRef for runtime resolution.
630
+ * Per draft 2020-12, $dynamicRef resolves to the FIRST (outermost)
631
+ * resource in the dynamic scope that defines the anchor.
632
+ * @param {string} anchorName - The anchor name
633
+ * @returns {Function|null} The validator function or null if not set
634
+ */
635
+ getDynamicAnchorValidator(anchorName) {
636
+ const stack = this.#dynamicAnchors.get(anchorName);
637
+ if (!stack || stack.length === 0) return null;
638
+ return stack[0];
639
+ }
640
+
641
+ /**
642
+ * Get the outermost validator for a recursive anchor.
643
+ * Used by $recursiveRef for runtime resolution.
644
+ * Returns the bottom of the stack (first/outermost registered validator).
645
+ * @param {string} anchorName - The anchor name (empty string for $recursiveRef)
646
+ * @returns {Function|null} The validator function or null if not set
647
+ */
648
+ getOutermostDynamicAnchorValidator(anchorName) {
649
+ const stack = this.#dynamicAnchors.get(anchorName);
650
+ if (!stack || stack.length === 0) return null;
651
+ return stack[0];
652
+ }
653
+
654
+ /**
655
+ * Push a validator onto the stack for a dynamic anchor.
656
+ * Called when entering a schema with $recursiveAnchor or $dynamicAnchor.
657
+ * @param {string} anchorName - The anchor name
658
+ * @param {Function} validator - The validator function
659
+ */
660
+ pushDynamicAnchorValidator(anchorName, validator) {
661
+ if (!this.#dynamicAnchors.has(anchorName)) {
662
+ this.#dynamicAnchors.set(anchorName, []);
663
+ }
664
+ this.#dynamicAnchors.get(anchorName).push(validator);
665
+ }
666
+
667
+ /**
668
+ * Pop a validator from the stack for a dynamic anchor.
669
+ * Called when exiting a schema with $recursiveAnchor or $dynamicAnchor.
670
+ * @param {string} anchorName - The anchor name
671
+ */
672
+ popDynamicAnchorValidator(anchorName) {
673
+ const stack = this.#dynamicAnchors.get(anchorName);
674
+ if (stack && stack.length > 0) {
675
+ stack.pop();
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Get or create a validator for a given schema.
681
+ * This is used when we need a validator for a schema at validation time
682
+ * (e.g., for dynamic anchors collected from $defs).
683
+ * @param {object} schema - The schema to create a validator for
684
+ * @param {string} basePath - The base path for the schema
685
+ * @param {string} baseUri - The base URI for the schema
686
+ * @returns {Function} The validator function
687
+ */
688
+ getOrCreateValidator(schema, basePath, baseUri) {
689
+ // Create a unique path for this schema based on its content
690
+ // We use a simple JSON stringify for now, but this could be improved
691
+ const path = basePath + '/$def-anchor/' + JSON.stringify(schema).slice(0, 50);
692
+
693
+ // Check if we already have an object for this path
694
+ let obj = this.#objects.get(path);
695
+ if (obj != null) {
696
+ return obj.validate;
697
+ }
698
+
699
+ // Create a new validation object for this schema
700
+ obj = ValidationRoot.#createObject(this, path, schema, baseUri);
701
+ return obj.validate;
702
+ }
703
+ }
704
+
705
+ /**
706
+ * ValidationObject represents a single schema location with its compiled validator.
707
+ * It handles the compilation of schema validation logic and provides methods for
708
+ * creating child validators and error handlers.
709
+ * @class
710
+ */
711
+ export class ValidationObject {
712
+ /**
713
+ * Compiles a validator function for the given schema.
714
+ * This is the main entry point for schema compilation. It handles:
715
+ * - Simple schemas (type-only, required-only) via fast paths
716
+ * - Schemas with $ref by resolving to target validators
717
+ * - Complex schemas by delegating to compileSchemaObject
718
+ * @param {ValidationObject} self - The validation object that is compiling this validator
719
+ * @param {string} path - The path to this schema object (its URI identifier)
720
+ * @param {any} schema - The schema object to compile
721
+ * @param {string} baseUri - The base URI for resolving $ref (parent's base, before any sibling $id)
722
+ * @returns {function(any, any):boolean} A function that validates data against the compiled schema and returns a boolean.
723
+ */
724
+ static compileValidator(self, path, schema, baseUri) {
725
+ if (!hasSchemaRef(schema))
726
+ return compileSchemaObject(self, schema);
727
+
728
+ const root = self.#root;
729
+
730
+ // In draft 2019-09+, $ref can have sibling keywords that are applied together.
731
+ // In draft 7 and earlier, $ref overrides siblings.
732
+ const draftVersion = root.options.draftVersion || 7;
733
+
734
+ // Base URI for resolving $ref:
735
+ // - Draft 7 and earlier: $ref replaces the schema entirely, so a sibling
736
+ // $id does not change the base URI - resolve against the parent's base.
737
+ // - Draft 2019-09+: $id establishes the base URI for the schema object it
738
+ // appears in, INCLUDING a sibling $ref (self.baseUri accounts for $id).
739
+ const refBase = draftVersion >= 2019
740
+ ? (self.baseUri || baseUri || path)
741
+ : (baseUri || path);
742
+ const { id: ref } = createJsonPointer(schema.$ref, refBase, root.traverse);
743
+ // Only check for sibling validators in draft 2019-09+
744
+ const siblingValidator = draftVersion >= 2019 ? compileSchemaObject(self, schema) : null;
745
+
746
+ // Collect dynamic anchors from the current schema's $defs/definitions.
747
+ // When following a $ref, dynamic anchors defined in the source schema's $defs
748
+ // should be in scope for $dynamicRef in the target schema.
749
+ // This handles cases like: root has $defs.foo with $dynamicAnchor, root.$ref points to list,
750
+ // and list.items has $dynamicRef that should resolve to root.$defs.foo's anchor.
751
+ const sourceDynamicAnchors = draftVersion >= 2020 ? collectDynamicAnchors(schema) : [];
752
+
753
+ // Since refs are now pre-compiled at compile time,
754
+ // we should always find the target immediately
755
+ const resolved = root.unresolvedObject(ref);
756
+ if (resolved != null) {
757
+ const refValidator = resolved.validate;
758
+ const resolvedSchema = resolved.schema;
759
+
760
+ // Check if the target schema has a dynamic anchor ($recursiveAnchor or $dynamicAnchor)
761
+ // If so, we need to wrap the call to register the anchor at validation time
762
+ const hasRecAnchor = isObjectClass(resolvedSchema) && hasRecursiveAnchor(resolvedSchema);
763
+ const dynAnchorName = isObjectClass(resolvedSchema) ? getDynamicAnchorName(resolvedSchema) : null;
764
+
765
+ // Entering the target resource brings ALL of its $dynamicAnchors into
766
+ // the dynamic scope (the target root's own anchor is pushed separately
767
+ // as refValidator below). Source $defs anchors are kept for schemas
768
+ // whose resource entry point is the $ref itself.
769
+ const enterAnchors = sourceDynamicAnchors.slice();
770
+ const targetDeepAnchors = collectDynamicAnchorsDeep(resolvedSchema);
771
+ for (let i = 0; i < targetDeepAnchors.length; i++) {
772
+ const anchor = targetDeepAnchors[i];
773
+ if (anchor.schema === resolvedSchema) continue;
774
+ anchor.base = resolved.baseUri;
775
+ enterAnchors.push(anchor);
776
+ }
777
+
778
+ if (hasRecAnchor || dynAnchorName || enterAnchors.length > 0) {
779
+ const anchorName = dynAnchorName || ''; // empty string for $recursiveAnchor
780
+
781
+ // Wrap the ref validator to register dynamic anchor at call time (validation time)
782
+ if (siblingValidator) {
783
+ // unevaluated* keywords must see annotations produced by the $ref
784
+ // target, so the wrapper goes around the combined validator.
785
+ return wrapUnevaluated(self, schema, function validateRefWithDynamicAnchorAndSiblings(data, dataPath, dataRoot) {
786
+ // Register the entered resource's dynamic anchors first
787
+ for (let i = 0; i < enterAnchors.length; i++) {
788
+ const anchor = enterAnchors[i];
789
+ if (anchor.validator == null)
790
+ anchor.validator = root.getOrCreateValidator(anchor.schema, path, anchor.base || baseUri);
791
+ root.pushDynamicAnchorValidator(anchor.name, anchor.validator);
792
+ }
793
+ // Then register target's dynamic anchor if any
794
+ if (hasRecAnchor || dynAnchorName) {
795
+ root.pushDynamicAnchorValidator(anchorName, refValidator);
796
+ }
797
+ try {
798
+ return refValidator(data, dataPath, dataRoot) && siblingValidator(data, dataPath, dataRoot);
799
+ } finally {
800
+ // Pop in reverse order
801
+ if (hasRecAnchor || dynAnchorName) {
802
+ root.popDynamicAnchorValidator(anchorName);
803
+ }
804
+ for (let i = enterAnchors.length - 1; i >= 0; i--) {
805
+ root.popDynamicAnchorValidator(enterAnchors[i].name);
806
+ }
807
+ }
808
+ });
809
+ } else {
810
+ return function validateRefWithDynamicAnchor(data, dataPath, dataRoot) {
811
+ // Register the entered resource's dynamic anchors first
812
+ for (let i = 0; i < enterAnchors.length; i++) {
813
+ const anchor = enterAnchors[i];
814
+ if (anchor.validator == null)
815
+ anchor.validator = root.getOrCreateValidator(anchor.schema, path, anchor.base || baseUri);
816
+ root.pushDynamicAnchorValidator(anchor.name, anchor.validator);
817
+ }
818
+ // Then register target's dynamic anchor if any
819
+ if (hasRecAnchor || dynAnchorName) {
820
+ root.pushDynamicAnchorValidator(anchorName, refValidator);
821
+ }
822
+ try {
823
+ return refValidator(data, dataPath, dataRoot);
824
+ } finally {
825
+ // Pop in reverse order
826
+ if (hasRecAnchor || dynAnchorName) {
827
+ root.popDynamicAnchorValidator(anchorName);
828
+ }
829
+ for (let i = enterAnchors.length - 1; i >= 0; i--) {
830
+ root.popDynamicAnchorValidator(enterAnchors[i].name);
831
+ }
832
+ }
833
+ };
834
+ }
835
+ }
836
+
837
+ // If there are sibling validators (draft 2019-09+), combine them with the ref validator
838
+ if (siblingValidator) {
839
+ // unevaluated* keywords must see annotations produced by the $ref
840
+ // target, so the wrapper goes around the combined validator.
841
+ return wrapUnevaluated(self, schema, function validateRefWithSiblings(data, dataPath, dataRoot) {
842
+ return refValidator(data, dataPath, dataRoot) && siblingValidator(data, dataPath, dataRoot);
843
+ });
844
+ }
845
+
846
+ self.#validator = refValidator;
847
+ return refValidator;
848
+ }
849
+
850
+ // Fallback for edge cases (e.g., recursive refs that weren't pre-compiled)
851
+ // Pre-bind values to avoid closure overhead in hot path
852
+ const rootRef = root;
853
+ const boundRef = ref;
854
+ const boundRefBase = refBase;
855
+ const boundSchema = schema;
856
+ const boundSiblingValidator = siblingValidator;
857
+ const boundSourceAnchors = sourceDynamicAnchors;
858
+
859
+ // Memoized state of the first resolution - the target never changes.
860
+ let resolvedState = null;
861
+
862
+ return wrapUnevaluated(self, schema, function resolveSchemaCompiler(data, dataPath, dataRoot) {
863
+ if (resolvedState === null) {
864
+ const obj = rootRef.resolveObject(boundRef, boundRefBase, boundSchema);
865
+ const resolvedSchema = obj.schema;
866
+
867
+ // Entering the target resource brings ALL of its $dynamicAnchors into
868
+ // the dynamic scope, exactly like the pre-compiled path above.
869
+ const enterAnchors = boundSourceAnchors.slice();
870
+ const targetDeepAnchors = collectDynamicAnchorsDeep(resolvedSchema);
871
+ for (let i = 0; i < targetDeepAnchors.length; i++) {
872
+ const anchor = targetDeepAnchors[i];
873
+ if (anchor.schema === resolvedSchema) continue;
874
+ anchor.base = obj.baseUri;
875
+ enterAnchors.push(anchor);
876
+ }
877
+
878
+ resolvedState = {
879
+ refValidator: obj.validate,
880
+ enterAnchors,
881
+ hasRecAnchor: isObjectClass(resolvedSchema) && hasRecursiveAnchor(resolvedSchema),
882
+ dynAnchorName: isObjectClass(resolvedSchema) ? getDynamicAnchorName(resolvedSchema) : null,
883
+ };
884
+ }
885
+ const { refValidator, enterAnchors, hasRecAnchor, dynAnchorName } = resolvedState;
886
+
887
+ for (let i = 0; i < enterAnchors.length; i++) {
888
+ const anchor = enterAnchors[i];
889
+ if (anchor.validator == null)
890
+ anchor.validator = rootRef.getOrCreateValidator(anchor.schema, path, anchor.base || baseUri);
891
+ rootRef.pushDynamicAnchorValidator(anchor.name, anchor.validator);
892
+ }
893
+
894
+ try {
895
+ if (hasRecAnchor || dynAnchorName) {
896
+ const anchorName = dynAnchorName || '';
897
+ rootRef.pushDynamicAnchorValidator(anchorName, refValidator);
898
+ try {
899
+ // If there are sibling validators (draft 2019-09+), combine them with the ref validator
900
+ if (boundSiblingValidator) {
901
+ return refValidator(data, dataPath, dataRoot) && boundSiblingValidator(data, dataPath, dataRoot);
902
+ }
903
+ return refValidator(data, dataPath, dataRoot);
904
+ } finally {
905
+ rootRef.popDynamicAnchorValidator(anchorName);
906
+ }
907
+ }
908
+
909
+ // If there are sibling validators (draft 2019-09+), combine them with the ref validator
910
+ if (boundSiblingValidator) {
911
+ return refValidator(data, dataPath, dataRoot) && boundSiblingValidator(data, dataPath, dataRoot);
912
+ }
913
+
914
+ // Cache the validator directly (skipping this resolver) only when
915
+ // this ref registers no dynamic anchors - otherwise later calls
916
+ // would lose the registrations.
917
+ if (enterAnchors.length === 0) {
918
+ self.#validator = refValidator;
919
+ }
920
+ return refValidator(data, dataPath, dataRoot);
921
+ } finally {
922
+ // Pop anchors in reverse order
923
+ for (let i = enterAnchors.length - 1; i >= 0; i--) {
924
+ rootRef.popDynamicAnchorValidator(enterAnchors[i].name);
925
+ }
926
+ }
927
+ });
928
+ }
929
+
930
+ /** @type {ValidationRoot} The root validation context */
931
+ #root = null;
932
+ /** @type {string} The URI path identifying this schema object */
933
+ #path = null;
934
+ /** @type {ValidationObject[]} Child validation objects created by this object */
935
+ #members = null;
936
+ /** @type {any} The schema object being validated */
937
+ #schema = null;
938
+ /** @type {function|null} The compiled validator function */
939
+ #validator = null;
940
+ /** @type {string} The base URI passed during construction */
941
+ #baseUri = null;
942
+ /** @type {string} The effective base URI for child $ref resolution */
943
+ #effectiveBaseUri = null;
944
+ /** @type {number|null} Draft version declared by this schema's document ($schema), inherited by subschemas; null when never declared */
945
+ #declaredDraft = null;
946
+
947
+ /**
948
+ * Creates a new ValidationObject.
949
+ * @param {ValidationRoot} root - The root validation context
950
+ * @param {string} path - The URI path identifying this schema object
951
+ * @param {any} schema - The schema object to compile
952
+ * @param {string} baseUri - The base URI for resolving $ref
953
+ * @param {number|null} [parentDeclaredDraft] - The declared draft inherited from the parent schema object
954
+ */
955
+ constructor(root, path, schema, baseUri, parentDeclaredDraft = null) {
956
+ this.#root = root;
957
+ this.#path = path;
958
+ this.#members = [];
959
+ this.#schema = schema;
960
+ this.#validator = null;
961
+
962
+ // A document that declares its own $schema is processed per that draft
963
+ // (cross-draft references); subschemas inherit the document's draft.
964
+ this.#declaredDraft = (isObjectClass(schema) && isStringType(schema.$schema))
965
+ ? detectSchemaDraft(schema)
966
+ : parentDeclaredDraft;
967
+
968
+ // Calculate the effective base URI for this schema.
969
+ // The effective base is what children should use for resolving relative $refs.
970
+ // If this schema has an $id, it becomes the new base for children.
971
+ if (isObjectClass(schema) && schema.$id) {
972
+ // This schema has its own $id - resolve it against the parent's baseUri
973
+ // to get the absolute base for children
974
+ const { id: resolvedId } = createJsonPointer(schema.$id, baseUri);
975
+ this.#effectiveBaseUri = resolvedId.endsWith('#') ? resolvedId.slice(0, -1) : resolvedId;
976
+ } else {
977
+ // No $id - inherit parent's base
978
+ this.#effectiveBaseUri = baseUri;
979
+ }
980
+ this.#baseUri = baseUri;
981
+
982
+ this.#validator = ValidationObject.compileValidator(this, path, schema, baseUri);
983
+ }
984
+
985
+ /** @returns {string} The URI path identifying this schema object */
986
+ get path() {
987
+ return this.#path;
988
+ }
989
+
990
+ /** @returns {string} The effective base URI for resolving relative $refs */
991
+ get baseUri() {
992
+ return this.#effectiveBaseUri;
993
+ }
994
+
995
+ /** @returns {Array} The current validation errors from the root */
996
+ get errors() {
997
+ return this.#root.errors;
998
+ }
999
+
1000
+ /** @returns {function} The compiled validator function */
1001
+ get validate() {
1002
+ return this.#validator;
1003
+ }
1004
+
1005
+ /** @returns {ValidationOptions} The validation options */
1006
+ get options() {
1007
+ return this.#root.options;
1008
+ }
1009
+
1010
+ /** @returns {object} The registered format validators */
1011
+ get formats() {
1012
+ return this.#root.formats;
1013
+ }
1014
+
1015
+ /** @returns {ValidationRoot} The root validation context */
1016
+ get root() {
1017
+ return this.#root;
1018
+ }
1019
+
1020
+ /** @returns {object} The schema object */
1021
+ get schema() {
1022
+ return this.#schema;
1023
+ }
1024
+
1025
+ /** @returns {number|null} Draft version declared by this schema's document via $schema, or null when never declared */
1026
+ get declaredDraft() {
1027
+ return this.#declaredDraft;
1028
+ }
1029
+
1030
+ /**
1031
+ * Creates an error handler function for validation failures.
1032
+ * @param {any} expected - The expected value that failed validation
1033
+ * @param {string | string[]} key - The keyword or keywords that failed
1034
+ * @returns {(data: unknown, ...meta: any[]) => boolean} A function that adds an error and returns false
1035
+ */
1036
+ createErrorHandler(expected, key) {
1037
+ const self = this;
1038
+
1039
+ // when skipErrors is true, we don't need to create error objects
1040
+ // Just return false immediately to avoid the overhead of error creation
1041
+ if (self.#root.options.skipErrors) {
1042
+ if (!Array.isArray(key)) {
1043
+ return function addNormalErrorFast(data, ...meta) {
1044
+ // Just return false without creating error object
1045
+ return false;
1046
+ };
1047
+ }
1048
+ else {
1049
+ return function addKeyedErrorFast(dataKey, data, ...meta) {
1050
+ // Just return false without creating error object
1051
+ return false;
1052
+ };
1053
+ }
1054
+ }
1055
+
1056
+ if (!Array.isArray(key)) {
1057
+ return function addNormalError(data, ...meta) {
1058
+ const error = new InternalValidationError(self, key, expected, null, data, meta);
1059
+ return self.#root.addError(error);
1060
+ };
1061
+ }
1062
+ else {
1063
+ return function addKeyedError(dataKey, data, ...meta) {
1064
+ const error = new InternalValidationError(self, key, expected, dataKey, data, meta);
1065
+ return self.#root.addError(error);
1066
+ };
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Creates a validator function for a child schema.
1072
+ * This is used when compiling nested schemas (e.g., array items, object properties).
1073
+ * @param {JSONSchema | boolean} schema - The child schema to compile
1074
+ * @param {string} key - The property key where the schema is located
1075
+ * @param {number} [index] - Optional array index for tuple items
1076
+ * @returns {function|undefined} The compiled validator function, or undefined if schema is invalid
1077
+ */
1078
+ createValidator(schema, key, index) {
1079
+ if (!isBoolOrObjectClass(schema))
1080
+ return undefined;
1081
+
1082
+ const root = this.#root;
1083
+ // Use the effective base URI for resolving relative $refs
1084
+ // This is either: (a) the resolved $id of this schema, or (b) the inherited base from parent
1085
+ let basePath = this.#effectiveBaseUri;
1086
+ // Strip trailing '#' for URL resolution - a base URL ending with '#' breaks relative ref resolution
1087
+ if (basePath && basePath.endsWith('#')) {
1088
+ basePath = basePath.slice(0, -1);
1089
+ }
1090
+
1091
+ // Check if schema has $id - this affects how we calculate the path
1092
+ const hasId = isObjectClass(schema) && schema.$id;
1093
+
1094
+ // If schema has $id, resolve it against basePath to get the new base URI
1095
+ // Otherwise, use the current path
1096
+ const id = hasId
1097
+ ? createJsonPointer(schema.$id, basePath || this.#path).id
1098
+ : this.#path;
1099
+
1100
+ // If schema has $id, use the resolved ID as the path (it defines the schema's location)
1101
+ // Otherwise, append key/index to create a JSON pointer path
1102
+ const path = hasId
1103
+ ? id
1104
+ : index == null
1105
+ ? encodeJsonPointerPath(id, key)
1106
+ : encodeJsonPointerPath(id, key, String(index));
1107
+
1108
+
1109
+
1110
+ // Pass basePath as the baseUri for the child object.
1111
+ // This ensures that $ref in the child will be resolved against basePath,
1112
+ // not against any sibling $id that the child might have.
1113
+ // The child inherits this document's declared draft version.
1114
+ const child = root.createObject(path, schema, basePath, this.#declaredDraft);
1115
+ this.#members.push(child);
1116
+
1117
+ return child.#validator;
1118
+ }
1119
+ }
1120
+
1121
+ /**
1122
+ * ValidatorOptions configures the JarenValidator instance.
1123
+ * Can be created with positional arguments or an options object.
1124
+ * @class
1125
+ * @example
1126
+ * // Positional arguments
1127
+ * const options = new ValidatorOptions(formats, schemas, validation, traverse);
1128
+ *
1129
+ * // Options object (recommended)
1130
+ * const options = new ValidatorOptions({
1131
+ * formats: { custom: validator },
1132
+ * collectErrors: true,
1133
+ * useGrapheme: false
1134
+ * });
1135
+ */
1136
+ export class ValidatorOptions {
1137
+ /**
1138
+ * Creates validator options.
1139
+ * @param {object|object[]} [formats={}] - Format validators or options object
1140
+ * @param {object[]} [schemas=[]] - Initial schemas to register
1141
+ * @param {ValidationOptions} [validation] - Validation behavior options
1142
+ * @param {TraverseOptions} [traverse] - Schema traversal options
1143
+ */
1144
+ constructor(
1145
+ formats = {},
1146
+ schemas = [],
1147
+ validation = new ValidationOptions(),
1148
+ traverse = new TraverseOptions(),
1149
+ ) {
1150
+ // Support object destructuring: new ValidatorOptions({ collectErrors: true })
1151
+ if (formats && typeof formats === 'object' && !Array.isArray(formats) &&
1152
+ !(formats instanceof Map)) {
1153
+ const opts = formats;
1154
+ /** @type {object} Registered format validators */
1155
+ this.formats = opts.formats || {};
1156
+ /** @type {object[]} Initial schemas to register */
1157
+ this.schemas = opts.schemas || [];
1158
+ // If collectErrors is passed directly, create ValidationOptions with it
1159
+ if (opts.collectErrors != null || opts.skipErrors != null || opts.useGrapheme != null || opts.contentValidation != null || opts.draftVersion != null || opts.formatAssertion != null) {
1160
+ const collectErrors = opts.collectErrors ?? false;
1161
+ this.validation = new ValidationOptions(
1162
+ // collecting errors implies actually recording them
1163
+ opts.skipErrors ?? !collectErrors,
1164
+ opts.useGrapheme ?? true,
1165
+ collectErrors,
1166
+ opts.contentValidation ?? false,
1167
+ opts.draftVersion ?? 7,
1168
+ true,
1169
+ opts.formatAssertion ?? null
1170
+ );
1171
+ } else {
1172
+ /** @type {ValidationOptions} Validation behavior options */
1173
+ this.validation = opts.validation || new ValidationOptions();
1174
+ }
1175
+ /** @type {TraverseOptions} Schema traversal options */
1176
+ this.traverse = opts.traverse || new TraverseOptions();
1177
+ } else {
1178
+ this.formats = formats;
1179
+ this.schemas = schemas;
1180
+ this.validation = validation;
1181
+ this.traverse = traverse;
1182
+ }
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * JarenValidator is the main entry point for JSON Schema validation.
1188
+ * It manages schema registration, format registration, and compilation.
1189
+ * @class
1190
+ * @example
1191
+ * const validator = new JarenValidator();
1192
+ * validator.addSchema({ $id: 'http://example.com/schema', type: 'object' });
1193
+ * const validate = validator.compile({ $ref: 'http://example.com/schema' });
1194
+ * const valid = validate({ foo: 'bar' }); // true
1195
+ */
1196
+ export class JarenValidator {
1197
+ /** @type {object} Registered format validators */
1198
+ #formats = {}
1199
+ /** @type {Map} Map of schema URIs to schema objects */
1200
+ #schemas = new Map();
1201
+ /** @type {Map} Map of meta-schema URIs to compiled validators */
1202
+ #metaSchemas = new Map();
1203
+ /** @type {ValidatorOptions} Validator options */
1204
+ #options = new ValidatorOptions();
1205
+
1206
+ /**
1207
+ * Creates a new JarenValidator instance.
1208
+ * @param {ValidatorOptions} [options] - Validator options including formats, schemas, validation options, and traverse options
1209
+ */
1210
+ constructor(options = new ValidatorOptions()) {
1211
+ // Accept a plain options object ({ skipErrors, collectErrors, ... })
1212
+ // as well as a ValidatorOptions instance.
1213
+ if (!(options instanceof ValidatorOptions)) {
1214
+ options = new ValidatorOptions(options);
1215
+ }
1216
+ this.#formats = options.formats || {};
1217
+ this.#schemas = new Map();
1218
+ this.#metaSchemas = new Map();
1219
+ this.#options = options;
1220
+ }
1221
+
1222
+ /**
1223
+ * Adds a format validator.
1224
+ * @param {string} name - The format name (e.g., 'email', 'uri', 'date-time')
1225
+ * @param {FormatCompiler} formatCompiler - A function that compiles format validators
1226
+ * @returns {JarenValidator} This validator instance for chaining
1227
+ * @example
1228
+ * validator.addFormat('custom', (schemaObj, schema) => {
1229
+ * return (data) => data.startsWith('custom:');
1230
+ * });
1231
+ */
1232
+ addFormat(name, formatCompiler) {
1233
+ registerFormatCompiler(
1234
+ this.#formats,
1235
+ name,
1236
+ formatCompiler);
1237
+ return this;
1238
+ }
1239
+
1240
+ /**
1241
+ * Adds multiple format validators at once.
1242
+ * @param {Record<string, FormatCompiler>} formatCompilers - Object mapping format names to compiler functions
1243
+ * @returns {JarenValidator} This validator instance for chaining
1244
+ */
1245
+ addFormats(formatCompilers) {
1246
+ registerFormatCompilers(
1247
+ this.#formats,
1248
+ formatCompilers);
1249
+ return this;
1250
+ }
1251
+
1252
+ /**
1253
+ *
1254
+ * @param {boolean | object} schema
1255
+ * @param {object[] | undefined} schemas
1256
+ * @param {TraverseOptions} opts
1257
+ * @returns {{origin:string, map: Map}}
1258
+ */
1259
+ static #traverseSchema(schema, schemas = undefined, fromMap = undefined, opts = new TraverseOptions()) {
1260
+ // initialize schema map for all ids and refs
1261
+ const schemaMap = new Map(fromMap);
1262
+ const origin = storeSchemaIdsInMap(
1263
+ schemaMap,
1264
+ opts.origin,
1265
+ schema,
1266
+ opts);
1267
+
1268
+ // Then add the other reference schemas
1269
+ if (Array.isArray(schemas) && schemas.length > 0) {
1270
+ schemas.forEach(ref => storeSchemaIdsInMap(
1271
+ schemaMap,
1272
+ origin,
1273
+ ref,
1274
+ opts));
1275
+ }
1276
+
1277
+ // make sure all schemas are connected
1278
+ restoreSchemaRefsInMap(schemaMap, opts);
1279
+
1280
+ return { origin: origin, map: schemaMap };
1281
+ }
1282
+
1283
+ /**
1284
+ * Adds schema(s) to the validator instance.
1285
+ * This method does not compile schemas - it only registers them for reference.
1286
+ * Dependencies can be added in any order, and circular dependencies are supported.
1287
+ * @param {JSONSchema | boolean | (JSONSchema | boolean)[]} schema - The schema(s) to add
1288
+ * @param {string} [key] - Optional key/URI to register the schema under
1289
+ * @returns {JarenValidator} This validator instance for chaining
1290
+ * @example
1291
+ * // Add a single schema
1292
+ * validator.addSchema({ $id: 'http://example.com/user', type: 'object' });
1293
+ *
1294
+ * // Add multiple schemas
1295
+ * validator.addSchema([schema1, schema2]);
1296
+ *
1297
+ * // Add with explicit key
1298
+ * validator.addSchema({ type: 'string' }, 'http://example.com/name');
1299
+ */
1300
+ addSchema(schema, key = undefined) {
1301
+ if (Array.isArray(schema)) {
1302
+ schema.forEach((s, index) => this.addSchema(s, key ? `${key}[${index}]` : undefined));
1303
+ }
1304
+ else if (typeof schema === 'object') {
1305
+ const schemaKey = key || schema.$id;
1306
+ if (schemaKey) {
1307
+ this.#schemas.set(schemaKey, schema);
1308
+ // CHECK: Also store with alternate key (with/without #) for absolute URIs
1309
+ if (!schemaKey.startsWith('#')) {
1310
+ const altKey = schemaKey.endsWith('#') ? schemaKey.slice(0, -1) : schemaKey + '#';
1311
+ if (!this.#schemas.has(altKey)) {
1312
+ this.#schemas.set(altKey, schema);
1313
+ }
1314
+ }
1315
+
1316
+ // Also traverse the schema to find and store all internal $id anchors
1317
+ // This is important for remote schemas that may have location-independent identifiers
1318
+ // We use a wrapper that skips already-existing keys instead of throwing
1319
+ this.#traverseAndStoreIds(schemaKey, schema);
1320
+ }
1321
+ }
1322
+ return this;
1323
+ }
1324
+
1325
+ /**
1326
+ * Traverse a schema and store all $id anchors in the schemas map.
1327
+ * This is a wrapper around storeSchemaIdsInMap that skips already-existing keys.
1328
+ * Anchors are scoped to their document context (anchorsGlobal: false) to prevent
1329
+ * conflicts between different schemas that may use the same anchor names.
1330
+ * @param {string} baseUri - The base URI for the schema
1331
+ * @param {object} schema - The schema to traverse
1332
+ */
1333
+ #traverseAndStoreIds(baseUri, schema) {
1334
+ const traverseOpts = this.#options.traverse;
1335
+
1336
+ // Create options with anchorsGlobal: false to scope anchors to their document.
1337
+ // This prevents conflicts when multiple schemas use the same anchor names (e.g., '#foo').
1338
+ // Anchors will be stored as 'baseUri#anchor' instead of just '#anchor'.
1339
+ const scopedOpts = new TraverseOptions(
1340
+ traverseOpts.origin,
1341
+ traverseOpts.mergeSchemas,
1342
+ false, // anchorsGlobal: false - scope anchors to document
1343
+ traverseOpts.anchorsAllowed,
1344
+ traverseOpts.skipErrors
1345
+ );
1346
+
1347
+ // Use a wrapper map to collect new entries, then merge them
1348
+ const newSchemas = new Map();
1349
+ try {
1350
+ storeSchemaIdsInMap(newSchemas, baseUri, schema, scopedOpts);
1351
+ } catch (e) {
1352
+ // Ignore errors for already-existing schemas at the root level
1353
+ }
1354
+
1355
+ // Merge new entries into the main schemas map, skipping existing keys
1356
+ for (const [id, value] of newSchemas.entries()) {
1357
+ if (!this.#schemas.has(id)) {
1358
+ this.#schemas.set(id, value);
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ /**
1364
+ * Convert internal validation errors to public ValidationError format
1365
+ * @param {InternalValidationError[]} internalErrors
1366
+ * @returns {ValidationError[]}
1367
+ */
1368
+ static #convertErrors(internalErrors) {
1369
+ return internalErrors.map(err => {
1370
+ const keyword = Array.isArray(err.key) ? err.key[err.key.length - 1] : err.key;
1371
+
1372
+ // Build params based on error type
1373
+ const params = {};
1374
+ if (keyword === 'required') {
1375
+ params.missingProperty = err.dataKey;
1376
+ } else if (keyword === 'type') {
1377
+ if (Array.isArray(err.expected)) {
1378
+ params.types = err.expected;
1379
+ } else {
1380
+ params.type = err.expected;
1381
+ }
1382
+ } else if (['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'minProperties', 'maxProperties', 'minItems', 'maxItems'].includes(keyword)) {
1383
+ params.limit = err.expected;
1384
+ if (keyword === 'minimum' || keyword === 'maximum') {
1385
+ params.comparison = keyword === 'minimum' ? '>=' : '<=';
1386
+ } else if (keyword === 'exclusiveMinimum' || keyword === 'exclusiveMaximum') {
1387
+ params.comparison = keyword === 'exclusiveMinimum' ? '>' : '<';
1388
+ }
1389
+ } else if (keyword === 'multipleOf') {
1390
+ params.multipleOf = err.expected;
1391
+ } else if (keyword === 'pattern') {
1392
+ params.pattern = err.expected?.source || err.expected;
1393
+ } else if (keyword === 'additionalProperties') {
1394
+ params.additionalProperty = err.dataKey;
1395
+ } else if (keyword === '$query') {
1396
+ // A '$query' runtime failure passes the JQ2xxx code and the query
1397
+ // document pointer as extra meta arguments after the data path;
1398
+ // a plain EBV-false failure passes neither.
1399
+ if (err.rest != null && err.rest.length > 1) {
1400
+ params.code = err.rest[1];
1401
+ params.docPath = err.rest[2];
1402
+ }
1403
+ }
1404
+
1405
+ // Generate message
1406
+ let message = `validation failed for keyword '${keyword}'`;
1407
+ if (keyword === 'required') {
1408
+ message = params.missingProperty
1409
+ ? `must have required property '${params.missingProperty}'`
1410
+ : 'must have required properties';
1411
+ } else if (keyword === 'type') {
1412
+ message = params.types
1413
+ ? `must be one of the following types: ${params.types.join(', ')}`
1414
+ : `must be ${params.type === 'integer' ? 'an' : 'a'} ${params.type}`;
1415
+ } else if (keyword === 'minimum' || keyword === 'maximum') {
1416
+ message = `must be ${params.comparison} ${params.limit}`;
1417
+ } else if (keyword === 'exclusiveMinimum' || keyword === 'exclusiveMaximum') {
1418
+ message = `must be ${params.comparison} ${params.limit}`;
1419
+ } else if (keyword === 'multipleOf') {
1420
+ message = `must be multiple of ${params.multipleOf}`;
1421
+ } else if (keyword === 'minLength') {
1422
+ message = `must NOT have fewer than ${params.limit} characters`;
1423
+ } else if (keyword === 'maxLength') {
1424
+ message = `must NOT have more than ${params.limit} characters`;
1425
+ } else if (keyword === 'pattern') {
1426
+ message = `must match pattern "${params.pattern}"`;
1427
+ } else if (keyword === 'additionalProperties') {
1428
+ message = params.additionalProperty
1429
+ ? `must NOT have additional property '${params.additionalProperty}'`
1430
+ : 'must NOT have additional properties';
1431
+ } else if (keyword === 'minProperties') {
1432
+ message = `must NOT have fewer than ${params.limit} properties`;
1433
+ } else if (keyword === 'maxProperties') {
1434
+ message = `must NOT have more than ${params.limit} properties`;
1435
+ } else if (keyword === 'minItems') {
1436
+ message = `must NOT have fewer than ${params.limit} items`;
1437
+ } else if (keyword === 'maxItems') {
1438
+ message = `must NOT have more than ${params.limit} items`;
1439
+ } else if (keyword === 'uniqueItems') {
1440
+ message = 'must NOT have duplicate items';
1441
+ } else if (keyword === 'contains') {
1442
+ message = 'must contain at least one valid item';
1443
+ } else if (keyword === 'items') {
1444
+ message = 'array items are invalid';
1445
+ } else if (keyword === 'allOf') {
1446
+ message = 'must match all of the subschemas';
1447
+ } else if (keyword === 'anyOf') {
1448
+ message = 'must match a subschema in anyOf';
1449
+ } else if (keyword === 'oneOf') {
1450
+ message = 'must match exactly one subschema in oneOf';
1451
+ } else if (keyword === 'not') {
1452
+ message = 'must NOT match the subschema';
1453
+ } else if (keyword === 'format') {
1454
+ const formatName = err.expected || err.value;
1455
+ params.format = formatName;
1456
+ message = `must match format "${formatName}"`;
1457
+ } else if (keyword === 'if') {
1458
+ message = 'must match "if" schema';
1459
+ } else if (keyword === 'then') {
1460
+ message = 'must match "then" schema';
1461
+ } else if (keyword === 'else') {
1462
+ message = 'must match "else" schema';
1463
+ } else if (keyword === 'false schema') {
1464
+ message = 'boolean schema false is always invalid';
1465
+ } else if (keyword === '$query') {
1466
+ message = params.code
1467
+ ? `'$query' assertion raised ${params.code} at '${params.docPath}'`
1468
+ : "must satisfy the '$query' assertion";
1469
+ }
1470
+
1471
+ // Validators pass the data path as the first meta argument to the
1472
+ // error handler; use it when it looks like a JSON pointer.
1473
+ const meta0 = err.rest?.[0];
1474
+ const instancePath = (typeof meta0 === 'string' && (meta0 === '' || meta0.charCodeAt(0) === 0x2f))
1475
+ ? meta0
1476
+ : '';
1477
+
1478
+ return new ValidationError({
1479
+ keyword,
1480
+ instancePath,
1481
+ schemaPath: err.object?.path || '',
1482
+ params,
1483
+ message,
1484
+ });
1485
+ });
1486
+ }
1487
+
1488
+ /**
1489
+ *
1490
+ * @param {JarenValidator} self
1491
+ * @param {string} origin
1492
+ * @param {Map} schemas
1493
+ * @returns {(data) => boolean | {valid: boolean, errors: ValidationError[]}}
1494
+ */
1495
+ static #compileSchema(self, origin, schemas) {
1496
+ const root = new ValidationRoot(
1497
+ origin,
1498
+ schemas,
1499
+ self.#formats,
1500
+ self.#options.validation,
1501
+ self.#options.traverse,
1502
+ self);
1503
+
1504
+ const collectErrors = self.#options.validation?.collectErrors || false;
1505
+
1506
+ function jarenValidateSchema(data) {
1507
+ const valid = root.validate(data);
1508
+ if (collectErrors) {
1509
+ return {
1510
+ valid,
1511
+ errors: valid ? [] : JarenValidator.#convertErrors(root.errors)
1512
+ };
1513
+ }
1514
+ return valid;
1515
+ }
1516
+
1517
+ Object.defineProperty(jarenValidateSchema, "errors", {
1518
+ get: function () { return root.errors }
1519
+ })
1520
+
1521
+ return jarenValidateSchema;
1522
+ }
1523
+
1524
+ /**
1525
+ * Compile schema using a pre-created ValidationRoot (with pre-compiled refs).
1526
+ * @param {JarenValidator} self
1527
+ * @param {string} origin
1528
+ * @param {Map} schemas
1529
+ * @param {ValidationRoot} root - Pre-created root with pre-compiled refs
1530
+ * @returns {(data) => boolean | {valid: boolean, errors: ValidationError[]}}
1531
+ */
1532
+ static #compileSchemaWithRoot(self, origin, schemas, root) {
1533
+ const collectErrors = self.#options.validation?.collectErrors || false;
1534
+
1535
+ function jarenValidateSchema(data) {
1536
+ const valid = root.validate(data);
1537
+ if (collectErrors) {
1538
+ return {
1539
+ valid,
1540
+ errors: valid ? [] : JarenValidator.#convertErrors(root.errors)
1541
+ };
1542
+ }
1543
+ return valid;
1544
+ }
1545
+
1546
+ Object.defineProperty(jarenValidateSchema, "errors", {
1547
+ get: function () { return root.errors }
1548
+ })
1549
+
1550
+ return jarenValidateSchema;
1551
+ }
1552
+
1553
+ static normalizeUriKey(key) {
1554
+ return key;
1555
+ }
1556
+
1557
+ /**
1558
+ * Adds meta-schema(s) that can be used to validate schemas.
1559
+ * Meta-schemas are schemas that describe the structure of valid JSON schemas.
1560
+ * @param {JSONSchema | boolean | (JSONSchema | boolean)[]} schema - The meta-schema(s) to add
1561
+ * @param {string} [key] - Optional key/URI for the meta-schema
1562
+ * @returns {JarenValidator} This validator instance for chaining
1563
+ * @example
1564
+ * validator.addMetaSchema(draft7MetaSchema, 'http://json-schema.org/draft-07/schema');
1565
+ */
1566
+ addMetaSchema(schema, key = undefined) {
1567
+ key = JarenValidator.normalizeUriKey(key)
1568
+ if (Array.isArray(schema)) {
1569
+ const first = schema.shift();
1570
+ const { origin, map } = JarenValidator.#traverseSchema(first, schema, undefined, new TraverseOptions(key));
1571
+ const compiled = JarenValidator.#compileSchema(this, origin, map);
1572
+ this.#metaSchemas.set(origin, compiled);
1573
+ mergeMap(this.#schemas, map);
1574
+ }
1575
+ else if (isBoolOrObjectClass(schema)) {
1576
+ const { origin, map } = JarenValidator.#traverseSchema(schema, undefined, undefined, new TraverseOptions(key));
1577
+ const compiled = JarenValidator.#compileSchema(this, origin, map);
1578
+ this.#metaSchemas.set(origin, compiled);
1579
+ mergeMap(this.#schemas, map);
1580
+ }
1581
+ return this;
1582
+ }
1583
+
1584
+ /**
1585
+ * Retrieves a registered schema by its key/URI.
1586
+ * @param {string} key - The schema URI/key
1587
+ * @returns {JSONSchema | boolean | null} The registered schema, or null if not found
1588
+ */
1589
+ getSchema(key) {
1590
+ key = JarenValidator.normalizeUriKey(key)
1591
+ return this.#schemas.get(key) || null;
1592
+ }
1593
+
1594
+ /**
1595
+ * Validates a schema against a registered meta-schema.
1596
+ * This is used to ensure schemas are valid according to the JSON Schema specification.
1597
+ * @param {JSONSchema | boolean} schema - The schema to validate
1598
+ * @returns {boolean} True if the schema is valid
1599
+ * @example
1600
+ * validator.addMetaSchema(draft7MetaSchema);
1601
+ * const isValid = validator.validateSchema({ type: 'string' }); // true
1602
+ */
1603
+ validateSchema(schema) {
1604
+ // if no meta schema is present, just return true;
1605
+ if (this.#metaSchemas.size == 0)
1606
+ return true;
1607
+
1608
+ const schemaId = JarenValidator.normalizeUriKey(schema.$schema || DEFAULT_SCHEMA_DRAFT);
1609
+ const metaSchema = this.#metaSchemas.get(schemaId);
1610
+ // if the metaSchema is not present, we fail the validation
1611
+ if (!metaSchema)
1612
+ return false;
1613
+
1614
+ // otherwise, validate the schema
1615
+ return metaSchema(schema);
1616
+ }
1617
+
1618
+ /**
1619
+ * Pre-compile refs to eliminate validation-time overhead.
1620
+ * Creates ValidationObjects for all refs in the schemas map during compile time.
1621
+ * @param {ValidationRoot} root - The validation root
1622
+ * @param {Map} schemas - The schemas map
1623
+ * @param {string} origin - The origin schema ID
1624
+ */
1625
+ static #precompileRefs(root, schemas, origin) {
1626
+ // Pre-create validation objects for all refs in the schemas map
1627
+ // This moves ref resolution from validation time to compile time
1628
+
1629
+ // First pass: Create objects for schemas with $id (canonical paths)
1630
+ // These establish the base URIs for their descendants
1631
+ for (const [id, schema] of schemas.entries()) {
1632
+ // Skip if already compiled
1633
+ if (root.unresolvedObject(id) !== null) continue;
1634
+
1635
+ // Skip null placeholders
1636
+ if (schema == null) continue;
1637
+
1638
+ // Only process schemas with $id that are stored under their $id path
1639
+ // (not JSON pointer paths)
1640
+ if (!isObjectClass(schema) || !schema.$id) continue;
1641
+
1642
+ // Check if this id matches the resolved $id
1643
+ const { id: resolvedId } = createJsonPointer(schema.$id, origin);
1644
+ if (id === resolvedId || id === resolvedId + '#') {
1645
+ // This is a canonical $id path - create with origin as base
1646
+ try {
1647
+ root.createObject(id, schema, origin);
1648
+ } catch (e) {
1649
+ // May fail if dependencies not resolved yet
1650
+ }
1651
+ }
1652
+ }
1653
+
1654
+ // Second pass: Create objects for remaining schemas
1655
+ // This includes:
1656
+ // 1. JSON pointer paths (e.g., #/definitions/x) - calculate baseUri by finding nearest ancestor $id
1657
+ // 2. Canonical $id paths with relative $ids that weren't matched in first pass
1658
+ for (const [id, schema] of schemas.entries()) {
1659
+ // Skip if already compiled
1660
+ if (root.unresolvedObject(id) !== null) continue;
1661
+
1662
+ // Skip null placeholders
1663
+ if (schema == null) continue;
1664
+
1665
+ // Skip schemas without $id that aren't refs - they're subschemas
1666
+ // that will be reached through traversal from a parent
1667
+ const hasId = isObjectClass(schema) && schema.$id;
1668
+ const isJsonPointerPath = id.includes('#/');
1669
+ const isCanonicalPath = !isJsonPointerPath && id.endsWith('#');
1670
+
1671
+ if (!hasId && !isJsonPointerPath) continue;
1672
+
1673
+ // Calculate baseUri for this schema
1674
+ let baseUri = origin;
1675
+
1676
+ if (isJsonPointerPath) {
1677
+ // JSON pointer path - traverse from root to find nearest $id ancestor
1678
+ const hashIndex = id.indexOf('#/');
1679
+ const baseDoc = id.substring(0, hashIndex);
1680
+ const pointer = id.substring(hashIndex + 1);
1681
+ const pointerParts = pointer.split('/').filter(p => p);
1682
+
1683
+ const rootId = baseDoc + '#';
1684
+ const rootSchema = schemas.get(rootId);
1685
+
1686
+ // Check if the root schema has an $id that matches the baseDoc.
1687
+ // If so, the baseDoc is already the resolved $id and we shouldn't
1688
+ // apply $id resolution during traversal (that would double-resolve).
1689
+ let rootIdMatchesBaseDoc = false;
1690
+ if (rootSchema && isObjectClass(rootSchema) && rootSchema.$id) {
1691
+ const { id: resolvedRootId } = createJsonPointer(rootSchema.$id, origin);
1692
+ const resolvedRootBase = resolvedRootId.endsWith('#') ? resolvedRootId.slice(0, -1) : resolvedRootId;
1693
+ if (baseDoc === resolvedRootBase) {
1694
+ rootIdMatchesBaseDoc = true;
1695
+ }
1696
+ }
1697
+
1698
+ let currentSchema = rootSchema;
1699
+ let currentBaseUri = baseDoc;
1700
+
1701
+ // Traverse and find the nearest $id ancestor
1702
+ for (let i = 0; i < pointerParts.length && currentSchema; i++) {
1703
+ const part = pointerParts[i];
1704
+
1705
+ // Check if current schema has $id (before moving to child)
1706
+ if (isObjectClass(currentSchema) && currentSchema.$id) {
1707
+ const isRootSchema = (i === 0);
1708
+ const shouldApplyId = !isRootSchema || !rootIdMatchesBaseDoc;
1709
+
1710
+ if (shouldApplyId) {
1711
+ const { id: resolvedId } = createJsonPointer(currentSchema.$id, currentBaseUri);
1712
+ currentBaseUri = resolvedId.endsWith('#') ? resolvedId.slice(0, -1) : resolvedId;
1713
+ }
1714
+ }
1715
+
1716
+ // Move to next level - handle both direct properties and definitions/$defs
1717
+ const nextSchema = currentSchema[part] ||
1718
+ currentSchema.$defs?.[part] ||
1719
+ currentSchema.definitions?.[part];
1720
+ currentSchema = nextSchema;
1721
+ }
1722
+
1723
+ baseUri = currentBaseUri;
1724
+ } else if (hasId && isCanonicalPath) {
1725
+ // Canonical $id path that wasn't handled in first pass
1726
+ // This happens when the $id is relative and resolves differently
1727
+ // than against the origin. We need to find the correct base URI.
1728
+
1729
+ // Find any schema in the map that has this schema as a descendant
1730
+ // and use its $id as the base
1731
+ for (const [candidateId, candidateSchema] of schemas.entries()) {
1732
+ if (!candidateSchema || candidateSchema === schema) continue;
1733
+
1734
+ // Check if candidate is an ancestor by checking if our id starts with candidate's path
1735
+ if (isJsonPointerPath && id.startsWith(candidateId.replace('#', '#/') + '/')) {
1736
+ // This is a descendant of a JSON pointer path - skip for now
1737
+ continue;
1738
+ }
1739
+
1740
+ // If candidate has an $id, it could be our base
1741
+ if (isObjectClass(candidateSchema) && candidateSchema.$id) {
1742
+ // Try resolving our $id against this candidate's resolved $id
1743
+ const { id: candidateResolvedId } = createJsonPointer(candidateSchema.$id, origin);
1744
+ const candidateBase = candidateResolvedId.endsWith('#') ? candidateResolvedId.slice(0, -1) : candidateResolvedId;
1745
+
1746
+ try {
1747
+ const { id: testResolvedId } = createJsonPointer(schema.$id, candidateBase);
1748
+ const testResolvedIdWithHash = testResolvedId.endsWith('#') ? testResolvedId : testResolvedId + '#';
1749
+
1750
+ if (id === testResolvedIdWithHash || id === testResolvedId) {
1751
+ baseUri = candidateBase;
1752
+ break;
1753
+ }
1754
+ } catch (e) {
1755
+ // Invalid URL, skip this candidate
1756
+ }
1757
+ }
1758
+ }
1759
+ }
1760
+
1761
+ try {
1762
+ root.createObject(id, schema, baseUri);
1763
+ } catch (e) {
1764
+ // Ref may not be resolvable yet, that's ok
1765
+ }
1766
+ }
1767
+ }
1768
+
1769
+ /**
1770
+ * Compiles a schema into a validation function.
1771
+ * This is the main method for creating validators. It resolves all $ref references,
1772
+ * compiles the schema structure, and returns a function that validates data.
1773
+ * @param {JSONSchema | boolean} schema - The schema to compile
1774
+ * @param {(JSONSchema | boolean)[]} [schemas] - Additional schemas to reference during compilation
1775
+ * @returns {(data: any) => boolean | {valid: boolean, errors: ValidationError[]}} A validation function
1776
+ * @example
1777
+ * const validate = validator.compile({
1778
+ * type: 'object',
1779
+ * properties: {
1780
+ * name: { type: 'string' }
1781
+ * }
1782
+ * });
1783
+ *
1784
+ * const valid = validate({ name: 'John' }); // true
1785
+ * const invalid = validate({ name: 123 }); // false
1786
+ *
1787
+ * // With error collection
1788
+ * validator = new JarenValidator({ collectErrors: true });
1789
+ * const result = validate({ name: 123 });
1790
+ * // result = { valid: false, errors: [...] }
1791
+ */
1792
+ compile(schema, schemas = undefined) {
1793
+ const { origin, map } = JarenValidator.#traverseSchema(schema, schemas, this.#schemas, this.#options.traverse);
1794
+
1795
+ // Detect draft version from schema and update validation options
1796
+ const draftVersion = detectSchemaDraft(schema);
1797
+ const existingValidation = this.#options.validation || new ValidationOptions();
1798
+ // For draft7, contentValidation defaults to true; for 2019-09+, defaults to false
1799
+ const contentValidationDefault = draftVersion < 2019;
1800
+
1801
+ // When the schema declares a custom metaschema via $schema, its
1802
+ // $vocabulary decides which keyword vocabularies are asserted. A
1803
+ // metaschema that omits the validation vocabulary turns keywords like
1804
+ // 'type' and 'minimum' into annotations that assert nothing.
1805
+ let vocabValidation = true;
1806
+ // In draft 2020-12 the format keyword is annotation-only unless the
1807
+ // metaschema opts into the format-assertion vocabulary (or the user
1808
+ // sets the formatAssertion option explicitly).
1809
+ let formatAssertion = existingValidation.formatAssertion ?? null;
1810
+ if (isObjectClass(schema) && isStringType(schema.$schema)) {
1811
+ const metaKey = JarenValidator.normalizeUriKey(schema.$schema);
1812
+ const metaSchema = map.get(metaKey)
1813
+ || map.get(metaKey.endsWith('#') ? metaKey.slice(0, -1) : metaKey + '#');
1814
+ if (isObjectClass(metaSchema) && isObjectClass(metaSchema.$vocabulary)) {
1815
+ vocabValidation = Object.keys(metaSchema.$vocabulary)
1816
+ .some(uri => uri.includes('/vocab/validation'));
1817
+ if (formatAssertion == null
1818
+ && Object.keys(metaSchema.$vocabulary).some(uri => uri.includes('/vocab/format-assertion'))) {
1819
+ formatAssertion = true;
1820
+ }
1821
+ }
1822
+ }
1823
+ if (formatAssertion == null) {
1824
+ formatAssertion = draftVersion < 2020;
1825
+ }
1826
+
1827
+ const validationOptions = new ValidationOptions(
1828
+ existingValidation.skipErrors ?? true,
1829
+ existingValidation.useGrapheme ?? true,
1830
+ existingValidation.collectErrors ?? false,
1831
+ existingValidation.contentValidation ?? contentValidationDefault,
1832
+ draftVersion,
1833
+ vocabValidation,
1834
+ formatAssertion
1835
+ );
1836
+
1837
+ // Pre-compile all refs before returning the validator
1838
+ // This ensures all ref chains are resolved at compile time
1839
+ const root = new ValidationRoot(
1840
+ origin,
1841
+ map,
1842
+ this.#formats,
1843
+ validationOptions,
1844
+ this.#options.traverse,
1845
+ this
1846
+ );
1847
+
1848
+ // Pre-create validation objects for all refs
1849
+ JarenValidator.#precompileRefs(root, map, origin);
1850
+
1851
+ // Re-compile with the pre-populated root
1852
+ return JarenValidator.#compileSchemaWithRoot(this, origin, map, root);
1853
+ }
1854
+ }