@kindgi/schema 0.1.7 → 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/README.md +31 -3
- package/dist/bounded.d.ts +57 -0
- package/dist/bounded.d.ts.map +1 -0
- package/dist/bounded.js +308 -0
- package/dist/bounded.js.map +1 -0
- package/dist/compile-budget.d.ts +47 -0
- package/dist/compile-budget.d.ts.map +1 -0
- package/dist/compile-budget.js +91 -0
- package/dist/compile-budget.js.map +1 -0
- package/dist/dialect.d.ts +28 -3
- package/dist/dialect.d.ts.map +1 -1
- package/dist/dialect.js +26 -48
- package/dist/dialect.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +17 -9
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +27 -34
- package/dist/registry.js.map +1 -1
- package/dist/unique-items.d.ts +10 -0
- package/dist/unique-items.d.ts.map +1 -0
- package/dist/unique-items.js +186 -0
- package/dist/unique-items.js.map +1 -0
- package/package.json +4 -3
- package/src/bounded.ts +376 -0
- package/src/compile-budget.ts +112 -0
- package/src/dialect.ts +34 -35
- package/src/index.ts +7 -1
- package/src/registry.ts +36 -43
- package/src/unique-items.ts +197 -0
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
|
-
|
|
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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 {
|
|
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';
|