@kindgi/schema 0.1.7-rc.0 → 0.1.8-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bounded.ts ADDED
@@ -0,0 +1,376 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The one place `@kindgi/schema` makes an Ajv, under the process's bounds
6
+ * (`@kindgi/bounds`): `compileJsonSchema`, `compileInlineSchema` and
7
+ * `createSpecRegistry` all compile here.
8
+ *
9
+ * With `SELF_HOSTED_BOUNDS`, Ajv is as it always was: patterns on V8,
10
+ * ajv-formats' full formats, Ajv's own `uniqueItems`. On RE2
11
+ * (`HOSTED_BOUNDS`), every check a tenant's schema makes runs in time
12
+ * linear in the data (`uniqueItems`: in its size times its depth):
13
+ * - `pattern`, `patternProperties` and `propertyNames`' patterns run on
14
+ * RE2, through `compileTenantPattern` (Ajv's `code.regExp`); Kindgi's own
15
+ * schemas' patterns stay on V8 (`schemasFrom`);
16
+ * - formats are ajv-formats' `fast` ones; `regex` is a pattern whose
17
+ * syntax `compileTenantPattern` accepts (`tenantPatternSyntaxProblem`,
18
+ * which builds no RE2 program), and `url` is matched without the
19
+ * backtracking its regular expression has (see `linearUrl`);
20
+ * - `uniqueItems` finds a repeat by each item's key, not pair by pair
21
+ * (`./unique-items.ts`).
22
+ *
23
+ * A tenant's schema (`compileTenantSchema`) is also measured before it's
24
+ * compiled, its depth and its size as JSON, and compiled once: the
25
+ * validators are kept by the schema's JSON, the least recently used
26
+ * dropped first (`schemaCacheEntries`, `schemaCacheMaxBytes`). The time
27
+ * its compiles take is charged to the tenant whose work it is
28
+ * (`./compile-budget.ts`).
29
+ */
30
+
31
+ import { createHash } from 'node:crypto';
32
+ import { createRequire } from 'node:module';
33
+
34
+ import {
35
+ compileTenantPattern,
36
+ currentTenant,
37
+ tenantInputBounds,
38
+ tenantPatternSyntaxProblem,
39
+ } from '@kindgi/bounds';
40
+ import type { TenantInputBounds } from '@kindgi/bounds';
41
+ import { Ajv } from 'ajv';
42
+ import type { Options, ValidateFunction } from 'ajv';
43
+ import * as addFormatsModule from 'ajv-formats';
44
+ import { Ajv2019 } from 'ajv/dist/2019.js';
45
+ import { Ajv2020 } from 'ajv/dist/2020.js';
46
+ import type { RegExpEngine, RegExpLike } from 'ajv/dist/types/index.js';
47
+
48
+ import { CompileBudget, SchemaCompileBudgetError } from './compile-budget.js';
49
+ import type { JsonSchemaDialect } from './dialect.js';
50
+ import { linearUniqueItems } from './unique-items.js';
51
+
52
+ type AnyAjv = Ajv | Ajv2019 | Ajv2020;
53
+
54
+ // ajv-formats is CommonJS (`export = fn`): the namespace holds the
55
+ // function directly or under `.default`, depending on the resolver.
56
+ type AddFormatsFn = (ajv: AnyAjv, opts?: { readonly mode?: 'fast' | 'full' }) => unknown;
57
+ const addFormatsRaw = addFormatsModule as unknown;
58
+ const addFormats: AddFormatsFn =
59
+ typeof addFormatsRaw === 'function'
60
+ ? (addFormatsRaw as AddFormatsFn)
61
+ : (addFormatsRaw as { default: AddFormatsFn }).default;
62
+
63
+ const require = createRequire(import.meta.url);
64
+
65
+ /** Whose schemas an Ajv compiles, and the keywords they may carry. */
66
+ export interface BoundedAjvOptions {
67
+ /**
68
+ * `tenant` (the default): anyone's, its patterns run as a tenant's do.
69
+ * `kindgi`: Kindgi's own (`@kindgi/specs`), whose patterns are Kindgi's
70
+ * code and run on V8 as the rest of it does; the checks they make of the
71
+ * data (formats, `uniqueItems`) are bounded all the same.
72
+ */
73
+ readonly schemasFrom?: 'tenant' | 'kindgi';
74
+ /** Keywords Kindgi reads off a schema, which validate nothing. */
75
+ readonly annotations?: readonly string[];
76
+ }
77
+
78
+ /**
79
+ * An Ajv for `dialect` under the process's bounds, with ajv-formats'
80
+ * formats and the keywords in `annotations`.
81
+ */
82
+ export function boundedAjv(
83
+ dialect: JsonSchemaDialect,
84
+ options: Options,
85
+ how: BoundedAjvOptions = {},
86
+ ): AnyAjv {
87
+ const onRe2 = tenantInputBounds().regex === 're2';
88
+ const tenants = how.schemasFrom !== 'kindgi';
89
+ const ajvOptions: Options = tenants
90
+ ? {
91
+ ...options,
92
+ // Ajv's default inlines a `$ref` target at every site that uses
93
+ // it, so the code it writes grows as the target's size times its
94
+ // uses while the JSON stays small: 27 KiB of one `$defs` used 400
95
+ // times took 18 s and 2.8 GiB to compile.
96
+ inlineRefs: false,
97
+ ...(onRe2 && { code: { ...options.code, regExp: tenantRegExp } }),
98
+ }
99
+ : options;
100
+ let ajv: AnyAjv;
101
+ if (dialect === '2020-12') ajv = new Ajv2020(ajvOptions);
102
+ else if (dialect === '2019-09') ajv = new Ajv2019(ajvOptions);
103
+ else {
104
+ ajv = new Ajv(ajvOptions);
105
+ if (dialect === 'draft-06') {
106
+ ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-06.json'));
107
+ }
108
+ }
109
+ if (onRe2) {
110
+ addFormats(ajv, { mode: 'fast' });
111
+ // A pattern `compileTenantPattern` would accept for its syntax, checked
112
+ // without building RE2's program: data can carry thousands of values.
113
+ ajv.addFormat('regex', (input: string) => tenantPatternSyntaxProblem(input) === undefined);
114
+ ajv.addFormat('url', linearUrl());
115
+ ajv.removeKeyword('uniqueItems');
116
+ ajv.addKeyword(linearUniqueItems());
117
+ } else {
118
+ addFormats(ajv);
119
+ }
120
+ for (const keyword of how.annotations ?? []) ajv.addKeyword(keyword);
121
+ return ajv;
122
+ }
123
+
124
+ /**
125
+ * Ajv's `code.regExp` on RE2: a schema's pattern compiled by
126
+ * `compileTenantPattern`, and refused, so the schema doesn't compile, when
127
+ * it can't run there. Ajv keeps one compiled pattern per `toString()`.
128
+ */
129
+ const tenantRegExp: RegExpEngine = Object.assign(
130
+ (source: string, flags: string): RegExpLike => {
131
+ const compiled = compileTenantPattern(source, flags);
132
+ if (compiled.kind === 'refused') {
133
+ throw new Error(
134
+ `The schema's pattern ${JSON.stringify(source)} isn't a regular expression that compiles: ${compiled.reason}.`,
135
+ );
136
+ }
137
+ const { pattern } = compiled;
138
+ const key = JSON.stringify([source, flags]);
139
+ const like = { test: (input: string) => pattern.test(input), toString: () => key };
140
+ return like;
141
+ },
142
+ // Only Ajv's standalone code reads `code`, and Kindgi never generates it.
143
+ { code: 'tenantRegExp' },
144
+ );
145
+
146
+ /**
147
+ * ajv-formats' `url`, with its user-info part `\S+(?::\S*)?@` written
148
+ * `\S+@`: the same strings (`\S` takes the `:` already), without the
149
+ * backtracking that makes it quadratic in a long string with no `@`.
150
+ * Built on first use, so only a process on RE2 depends on its source.
151
+ */
152
+ function linearUrl(): RegExp {
153
+ if (rewrittenUrl !== undefined) return rewrittenUrl;
154
+ const full = (require('ajv-formats/dist/formats.js') as { fullFormats: { url: RegExp } })
155
+ .fullFormats.url;
156
+ const userInfo = '(?:\\S+(?::\\S*)?@)?';
157
+ if (full.source.split(userInfo).length !== 2) {
158
+ throw new Error("ajv-formats' url format isn't the one @kindgi/schema rewrites.");
159
+ }
160
+ rewrittenUrl = new RegExp(full.source.replace(userInfo, '(?:\\S+@)?'), full.flags);
161
+ return rewrittenUrl;
162
+ }
163
+
164
+ let rewrittenUrl: RegExp | undefined;
165
+
166
+ /** What `compileTenantSchema` compiles: the dialect, Ajv's options, and the annotations. */
167
+ export interface TenantSchemaCompile {
168
+ readonly dialect: JsonSchemaDialect;
169
+ readonly options: Options;
170
+ readonly annotations?: readonly string[];
171
+ }
172
+
173
+ /**
174
+ * A validator for a tenant's `schema`, compiled once: measured against the
175
+ * bounds first (its depth, then its size as JSON), then taken from the
176
+ * cache or compiled and kept. Throws, as `ajv.compile` does, when it's over
177
+ * a bound or doesn't compile; a schema that didn't compile is kept too, and
178
+ * throws the same error again. Throws `SchemaCompileBudgetError`, and
179
+ * keeps nothing, when the compile counts against a tenant that has spent
180
+ * its budget.
181
+ *
182
+ * The cache key is the schema's JSON as written, keys in their order: the
183
+ * order of `properties` is the order Ajv fills in defaults and reports
184
+ * errors in. A schema holding a value JSON can't carry (a function,
185
+ * `undefined`) is compiled each time.
186
+ */
187
+ export function compileTenantSchema(schema: unknown, how: TenantSchemaCompile): ValidateFunction {
188
+ return compileKeyed(schema, how).validate;
189
+ }
190
+
191
+ /**
192
+ * A tenant's `schema` compiled now, as `compileTenantSchema` does, for a
193
+ * caller that keeps it: what this keeps is the cache key, not the
194
+ * validator. Each call returns the validator the cache has under the key
195
+ * or, once the cache has dropped it (or the bounds changed), compiles it
196
+ * again as `compileTenantSchema` does, a recompile the tenant's budget
197
+ * counts, and which can throw as it does. So however long a caller keeps
198
+ * it, the cache is all that holds the compiled schema. A schema holding a
199
+ * value JSON can't carry, never cached, is held as compiled.
200
+ */
201
+ export function holdTenantSchema(
202
+ schema: unknown,
203
+ how: TenantSchemaCompile,
204
+ ): () => ValidateFunction {
205
+ const first = compileKeyed(schema, how);
206
+ if (first.key === undefined) return () => first.validate;
207
+ let key = first.key;
208
+ return () => {
209
+ const kept = stateFor(tenantInputBounds()).cache.get(key);
210
+ if (kept?.kind === 'compiled') return kept.validate;
211
+ const again = compileKeyed(schema, how);
212
+ if (again.key !== undefined) key = again.key;
213
+ return again.validate;
214
+ };
215
+ }
216
+
217
+ /** `compileTenantSchema`'s validator, and the key the cache keeps it under (none when it isn't JSON). */
218
+ function compileKeyed(
219
+ schema: unknown,
220
+ how: TenantSchemaCompile,
221
+ ): { readonly validate: ValidateFunction; readonly key?: string } {
222
+ const bounds = tenantInputBounds();
223
+ const measured = measure(schema, bounds);
224
+ if (measured.kind === 'over') throw new Error(measured.reason);
225
+ const text = jsonText(schema);
226
+ const bytes = Buffer.byteLength(text);
227
+ if (bytes > bounds.schemaMaxBytes) {
228
+ throw new Error(
229
+ `The schema is ${bytes} bytes as JSON, over the limit of ${bounds.schemaMaxBytes}.`,
230
+ );
231
+ }
232
+ const compile = () =>
233
+ boundedAjv(how.dialect, how.options, { annotations: how.annotations ?? [] }).compile(
234
+ schema as object,
235
+ );
236
+ const { cache, budget } = stateFor(bounds);
237
+ const scope = currentTenant();
238
+ if (!measured.json) return { validate: budget.run(scope, undefined, compile) };
239
+ const key = createHash('sha256')
240
+ .update(JSON.stringify([how.dialect, how.options, how.annotations ?? []]))
241
+ .update(text)
242
+ .digest('base64');
243
+ const kept = cache.get(key);
244
+ if (kept !== undefined) {
245
+ if (kept.kind === 'refused') throw kept.error;
246
+ return { validate: kept.validate, key };
247
+ }
248
+ let validate: ValidateFunction;
249
+ try {
250
+ validate = budget.run(scope, key, compile);
251
+ } catch (error) {
252
+ // A schema that doesn't compile keeps its error, which is small.
253
+ if (!(error instanceof SchemaCompileBudgetError)) cache.set(key, { kind: 'refused', error }, 0);
254
+ throw error;
255
+ }
256
+ cache.set(key, { kind: 'compiled', validate }, bytes);
257
+ return { validate, key };
258
+ }
259
+
260
+ /** `JSON.stringify(schema)`, or '' when it can't write it (a `BigInt`). */
261
+ function jsonText(schema: unknown): string {
262
+ try {
263
+ return JSON.stringify(schema) ?? '';
264
+ } catch {
265
+ return '';
266
+ }
267
+ }
268
+
269
+ type Measured =
270
+ | { readonly kind: 'over'; readonly reason: string }
271
+ | { readonly kind: 'within'; readonly json: boolean };
272
+
273
+ /**
274
+ * Whether `schema` nests deeper than the bounds allow, and whether it's
275
+ * JSON: only plain objects, arrays, strings, finite numbers, booleans and
276
+ * `null`. Walked without recursion, and stopped once it has seen more
277
+ * values than fit in `schemaMaxBytes` of JSON, so an object graph that
278
+ * shares its parts (or loops) can't make the walk long.
279
+ */
280
+ function measure(schema: unknown, bounds: TenantInputBounds): Measured {
281
+ const max = bounds.schemaMaxDepth;
282
+ const stack: [unknown, number][] = [[schema, 0]];
283
+ let json = true;
284
+ let seen = 0;
285
+ while (stack.length > 0) {
286
+ const [value, depth] = stack.pop() as [unknown, number];
287
+ if (typeof value === 'number') json &&= Number.isFinite(value);
288
+ else if (typeof value !== 'object')
289
+ json &&= typeof value === 'string' || typeof value === 'boolean';
290
+ if (typeof value !== 'object' || value === null) continue;
291
+ if (depth === max) {
292
+ return {
293
+ kind: 'over',
294
+ reason: `The schema nests objects and arrays more than ${max} deep, the limit.`,
295
+ };
296
+ }
297
+ const proto = Object.getPrototypeOf(value);
298
+ json &&= proto === Object.prototype || proto === Array.prototype;
299
+ for (const inner of Object.values(value)) {
300
+ seen += 1;
301
+ // Each value takes at least two bytes of JSON, with its comma.
302
+ if (seen * 2 > bounds.schemaMaxBytes) {
303
+ return {
304
+ kind: 'over',
305
+ reason: `The schema is more than ${bounds.schemaMaxBytes} bytes as JSON, the limit.`,
306
+ };
307
+ }
308
+ stack.push([inner, depth + 1]);
309
+ }
310
+ }
311
+ return { kind: 'within', json };
312
+ }
313
+
314
+ type Kept =
315
+ | { readonly kind: 'compiled'; readonly validate: ValidateFunction }
316
+ | { readonly kind: 'refused'; readonly error: unknown };
317
+
318
+ /**
319
+ * A cache that drops the least recently used entries while it holds more
320
+ * than `maxEntries`, or more than `maxBytes` by the weights it was given.
321
+ */
322
+ class LeastRecentlyUsed<V> {
323
+ private readonly entries = new Map<string, { readonly value: V; readonly bytes: number }>();
324
+ private bytes = 0;
325
+
326
+ constructor(
327
+ private readonly maxEntries: number,
328
+ private readonly maxBytes: number,
329
+ ) {}
330
+
331
+ get(key: string): V | undefined {
332
+ const entry = this.entries.get(key);
333
+ if (entry === undefined) return undefined;
334
+ this.entries.delete(key);
335
+ this.entries.set(key, entry);
336
+ return entry.value;
337
+ }
338
+
339
+ set(key: string, value: V, bytes: number): void {
340
+ this.delete(key);
341
+ this.entries.set(key, { value, bytes });
342
+ this.bytes += bytes;
343
+ while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
344
+ const oldest = this.entries.keys().next();
345
+ if (oldest.done === true) break;
346
+ this.delete(oldest.value);
347
+ }
348
+ }
349
+
350
+ private delete(key: string): void {
351
+ const entry = this.entries.get(key);
352
+ if (entry === undefined) return;
353
+ this.entries.delete(key);
354
+ this.bytes -= entry.bytes;
355
+ }
356
+ }
357
+
358
+ interface State {
359
+ readonly bounds: TenantInputBounds;
360
+ readonly cache: LeastRecentlyUsed<Kept>;
361
+ readonly budget: CompileBudget;
362
+ }
363
+
364
+ let state: State | undefined;
365
+
366
+ /** The cache and the budget for `bounds`: new ones when the bounds change (a test's override). */
367
+ function stateFor(bounds: TenantInputBounds): State {
368
+ if (state?.bounds !== bounds) {
369
+ state = {
370
+ bounds,
371
+ cache: new LeastRecentlyUsed(bounds.schemaCacheEntries, bounds.schemaCacheMaxBytes),
372
+ budget: new CompileBudget(bounds.schemaCompileBudgetMs),
373
+ };
374
+ }
375
+ return state;
376
+ }
@@ -0,0 +1,112 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The time each tenant's schemas spend compiling, in any 60 seconds,
6
+ * against the bounds' `schemaCompileBudgetMs`. Compiling a large schema
7
+ * holds the event loop (a 256 KiB one up to 0.75 s), so a tenant past its
8
+ * budget waits instead of the others.
9
+ *
10
+ * Which compiles count is the tenant's scope's (`runAsTenant`): every one
11
+ * in a request that publishes schemas (`all`), and otherwise only a
12
+ * schema compiled again (`recompiles`), which is what a tenant whose
13
+ * schemas don't fit in the cache makes each call do. A tenant's first
14
+ * compile of a schema in the process is free, up to `FIRST_COMPILES`
15
+ * schemas; past them, every compile counts. A tenant that compiled
16
+ * nothing for an hour is forgotten, as a restart would forget it: its next
17
+ * compiles are first ones again. The window and the refusal are the
18
+ * bounds' budget kept per tenant (`TenantTimeBudget`).
19
+ */
20
+
21
+ import {
22
+ BUDGET_WINDOW_MS,
23
+ type BudgetClock,
24
+ SYSTEM_CLOCK,
25
+ TenantBudgetError,
26
+ type TenantScope,
27
+ TenantTimeBudget,
28
+ } from '@kindgi/bounds';
29
+
30
+ /** The schemas a tenant may compile once for free, in a process. */
31
+ export const FIRST_COMPILES = 4_096;
32
+
33
+ /** How long a tenant compiles nothing before it's forgotten. */
34
+ export const FORGET_AFTER_MS = 60 * 60_000;
35
+
36
+ /** A tenant's compile refused: it spent `budgetMs` in the last 60 seconds. Retry after `retryAfterMs`. */
37
+ export class SchemaCompileBudgetError extends TenantBudgetError {
38
+ readonly code = 'schema-compile-budget-exceeded';
39
+
40
+ constructor(budgetMs: number, retryAfterMs: number) {
41
+ super(
42
+ `Schemas compiled for this tenant took its budget of ${budgetMs} ms in the last 60 seconds: try again in ${Math.max(1, Math.ceil(retryAfterMs / 1000))} s.`,
43
+ 'schema-compile-time',
44
+ budgetMs,
45
+ retryAfterMs,
46
+ );
47
+ this.name = 'SchemaCompileBudgetError';
48
+ }
49
+ }
50
+
51
+ export class CompileBudget {
52
+ private readonly time: TenantTimeBudget;
53
+ /** Each tenant's schemas compiled once, by key, up to `FIRST_COMPILES`, and when it last compiled. */
54
+ private readonly compiled = new Map<string, { readonly seen: Set<string>; at: number }>();
55
+ private sweptAt = 0;
56
+
57
+ constructor(
58
+ private readonly budgetMs: number,
59
+ private readonly clock: BudgetClock = SYSTEM_CLOCK,
60
+ ) {
61
+ this.time = new TenantTimeBudget(
62
+ budgetMs,
63
+ (budget, retryAfterMs) => new SchemaCompileBudgetError(budget, retryAfterMs),
64
+ clock,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Runs `compile`, the compile of the schema `key` (undefined: one that
70
+ * isn't JSON, which always counts), as `scope`'s work. When it counts,
71
+ * it's refused with `SchemaCompileBudgetError` once the tenant has spent
72
+ * its budget, and its time is charged.
73
+ */
74
+ run<T>(scope: TenantScope | undefined, key: string | undefined, compile: () => T): T {
75
+ if (scope === undefined || this.budgetMs === Number.POSITIVE_INFINITY) return compile();
76
+ const now = this.clock.now();
77
+ this.forget(now);
78
+ const seen = this.compiledBy(scope.tenantId, now);
79
+ const first = key !== undefined && !seen.has(key) && seen.size < FIRST_COMPILES;
80
+ if (first && scope.compiles === 'recompiles') {
81
+ seen.add(key);
82
+ return compile();
83
+ }
84
+ // Noted once it runs: a refused compile leaves the schema a first one.
85
+ const noted = first ? key : undefined;
86
+ return this.time.charge(scope.tenantId, () => {
87
+ if (noted !== undefined) seen.add(noted);
88
+ return compile();
89
+ });
90
+ }
91
+
92
+ /** The schemas `tenant` has compiled once, by key; it compiles `now`. */
93
+ private compiledBy(tenant: string, now: number): Set<string> {
94
+ const kept = this.compiled.get(tenant);
95
+ if (kept !== undefined) {
96
+ kept.at = now;
97
+ return kept.seen;
98
+ }
99
+ const seen = new Set<string>();
100
+ this.compiled.set(tenant, { seen, at: now });
101
+ return seen;
102
+ }
103
+
104
+ /** Once a window: forgets the tenants idle past `FORGET_AFTER_MS`. */
105
+ private forget(now: number): void {
106
+ if (now - this.sweptAt <= BUDGET_WINDOW_MS) return;
107
+ this.sweptAt = now;
108
+ for (const [tenant, kept] of [...this.compiled]) {
109
+ if (now - kept.at >= FORGET_AFTER_MS) this.compiled.delete(tenant);
110
+ }
111
+ }
112
+ }
package/src/dialect.ts CHANGED
@@ -18,23 +18,9 @@
18
18
  * (`ALLOW_UNION_TYPES`).
19
19
  */
20
20
 
21
- import { createRequire } from 'node:module';
22
-
23
- import { Ajv } from 'ajv';
24
21
  import type { ValidateFunction } from 'ajv';
25
- import * as addFormatsModule from 'ajv-formats';
26
- import { Ajv2019 } from 'ajv/dist/2019.js';
27
- import { Ajv2020 } from 'ajv/dist/2020.js';
28
-
29
- type AnyAjv = Ajv | Ajv2019 | Ajv2020;
30
- type AddFormatsFn = (ajv: AnyAjv, opts?: unknown) => unknown;
31
- const addFormatsRaw = addFormatsModule as unknown;
32
- const addFormats: AddFormatsFn =
33
- typeof addFormatsRaw === 'function'
34
- ? (addFormatsRaw as AddFormatsFn)
35
- : (addFormatsRaw as { default: AddFormatsFn }).default;
36
22
 
37
- const require = createRequire(import.meta.url);
23
+ import { type TenantSchemaCompile, compileTenantSchema, holdTenantSchema } from './bounded.js';
38
24
 
39
25
  /**
40
26
  * Every Kindgi schema compiler takes a union of types (`type: ['string', 'number']`): standard
@@ -96,30 +82,43 @@ export interface CompileJsonSchemaOptions {
96
82
  }
97
83
 
98
84
  /**
99
- * A validator for `schema`, compiled in its declared dialect. Throws when
100
- * the dialect isn't supported (or allowed) or the schema doesn't compile,
101
- * as `ajv.compile` does.
85
+ * A validator for `schema`, compiled in its declared dialect under the
86
+ * process's bounds (`compileTenantSchema`). Throws when the dialect isn't
87
+ * supported (or allowed), the schema is over a bound, or it doesn't
88
+ * compile, as `ajv.compile` does.
102
89
  */
103
90
  export function compileJsonSchema(
104
91
  schema: Readonly<Record<string, unknown>>,
105
92
  options: CompileJsonSchemaOptions = {},
106
93
  ): ValidateFunction {
107
- const dialect = jsonSchemaDialect(schema, options.dialects);
108
- const ajvOptions = {
109
- strict: options.strict ?? true,
110
- allErrors: true,
111
- allowUnionTypes: ALLOW_UNION_TYPES,
112
- ...(options.useDefaults === true && { useDefaults: true }),
94
+ return compileTenantSchema(schema, compileFor(schema, options));
95
+ }
96
+
97
+ /**
98
+ * `compileJsonSchema` for a caller that keeps the schema: compiled now,
99
+ * and what to call for its validator each time (`holdTenantSchema`). The
100
+ * cache stays all that holds a JSON schema's validator. A schema JSON
101
+ * can't carry (a key whose value is `undefined`, say) is never cached, so
102
+ * it's held as compiled rather than compiled on every call.
103
+ */
104
+ export function holdJsonSchema(
105
+ schema: Readonly<Record<string, unknown>>,
106
+ options: CompileJsonSchemaOptions = {},
107
+ ): () => ValidateFunction {
108
+ return holdTenantSchema(schema, compileFor(schema, options));
109
+ }
110
+
111
+ function compileFor(
112
+ schema: Readonly<Record<string, unknown>>,
113
+ options: CompileJsonSchemaOptions,
114
+ ): TenantSchemaCompile {
115
+ return {
116
+ dialect: jsonSchemaDialect(schema, options.dialects),
117
+ options: {
118
+ strict: options.strict ?? true,
119
+ allErrors: true,
120
+ allowUnionTypes: ALLOW_UNION_TYPES,
121
+ ...(options.useDefaults === true && { useDefaults: true }),
122
+ },
113
123
  };
114
- let ajv: AnyAjv;
115
- if (dialect === '2020-12') ajv = new Ajv2020(ajvOptions);
116
- else if (dialect === '2019-09') ajv = new Ajv2019(ajvOptions);
117
- else {
118
- ajv = new Ajv(ajvOptions);
119
- if (dialect === 'draft-06') {
120
- ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-06.json'));
121
- }
122
- }
123
- addFormats(ajv);
124
- return ajv.compile(schema as object);
125
124
  }
package/src/index.ts CHANGED
@@ -12,7 +12,13 @@ export type {
12
12
  ValidationError,
13
13
  } from './errors.js';
14
14
  export { compileInlineSchema, createSpecRegistry, loadSpecRegistry } from './registry.js';
15
- export { ALLOW_UNION_TYPES, compileJsonSchema, jsonSchemaDialect } from './dialect.js';
15
+ export { SchemaCompileBudgetError } from './compile-budget.js';
16
+ export {
17
+ ALLOW_UNION_TYPES,
18
+ compileJsonSchema,
19
+ holdJsonSchema,
20
+ jsonSchemaDialect,
21
+ } from './dialect.js';
16
22
  export type { CompileJsonSchemaOptions, JsonSchemaDialect } from './dialect.js';
17
23
  export type { CompiledInlineSchema, SpecRegistry, ValidationErrorLike } from './registry.js';
18
24
  export { versionOf } from './version.js';