@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/registry.ts CHANGED
@@ -5,26 +5,14 @@ import { readFile, readdir } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
 
7
7
  import type { ValidateFunction } from 'ajv';
8
- import * as addFormatsModule from 'ajv-formats';
9
- // Ajv 8's `/dist/2020` entry exports the Ajv2020 class as a named export.
10
- // The named-import form is stable across ESM/CJS/bundler interop; the default form is not.
11
- import { Ajv2020 } from 'ajv/dist/2020.js';
12
8
 
13
9
  import type { Result } from '@kindgi/types';
14
10
 
11
+ import { boundedAjv, holdTenantSchema } from './bounded.js';
12
+ import { SchemaCompileBudgetError } from './compile-budget.js';
15
13
  import { ALLOW_UNION_TYPES } from './dialect.js';
16
14
  import type { SchemaError } from './errors.js';
17
15
 
18
- // ajv-formats uses CJS `export = fn` style. In ESM with esModuleInterop, the
19
- // module namespace holds the function either directly or under `.default`
20
- // depending on the resolver. Runtime-check to unwrap regardless of shape.
21
- type AddFormatsFn = (ajv: InstanceType<typeof Ajv2020>, opts?: unknown) => unknown;
22
- const addFormatsRaw = addFormatsModule as unknown;
23
- const addFormats: AddFormatsFn =
24
- typeof addFormatsRaw === 'function'
25
- ? (addFormatsRaw as AddFormatsFn)
26
- : (addFormatsRaw as { default: AddFormatsFn }).default;
27
-
28
16
  /**
29
17
  * A registry of loaded and compiled JSON Schemas, keyed by `$id`.
30
18
  *
@@ -128,7 +116,10 @@ function extractId(schema: unknown): Result<string, SchemaError> {
128
116
  }
129
117
 
130
118
  /**
131
- * Build a SpecRegistry from an array of already-parsed schema documents.
119
+ * Build a SpecRegistry from an array of already-parsed schema documents:
120
+ * Kindgi's own (`@kindgi/specs`), whose patterns run on V8 as the rest of
121
+ * Kindgi's code does. A tenant's schema goes through `compileJsonSchema`
122
+ * or `compileInlineSchema`, under the bounds.
132
123
  *
133
124
  * All schemas are registered with the Ajv instance FIRST (so cross-schema
134
125
  * `$ref`s can resolve), then validators are compiled and cached.
@@ -137,12 +128,11 @@ function extractId(schema: unknown): Result<string, SchemaError> {
137
128
  * spec is broken, downstream validators are meaningless.
138
129
  */
139
130
  export function createSpecRegistry(schemas: readonly unknown[]): Result<SpecRegistry, SchemaError> {
140
- const ajv = new Ajv2020({
141
- strict: true,
142
- allErrors: true,
143
- allowUnionTypes: ALLOW_UNION_TYPES,
144
- });
145
- addFormats(ajv);
131
+ const ajv = boundedAjv(
132
+ '2020-12',
133
+ { strict: true, allErrors: true, allowUnionTypes: ALLOW_UNION_TYPES },
134
+ { schemasFrom: 'kindgi' },
135
+ );
146
136
 
147
137
  const schemaMap = new Map<string, unknown>();
148
138
 
@@ -215,7 +205,10 @@ export interface CompiledInlineSchema {
215
205
  /**
216
206
  * Validate `data`. Returns the original value on success (typed as `T`
217
207
  * for caller convenience — the runtime does not verify the T assertion
218
- * beyond schema conformance).
208
+ * beyond schema conformance). It keeps the schema's cache key, not its
209
+ * validator: once the cache has dropped the validator, it compiles the
210
+ * schema again, a recompile the tenant's budget counts, so it throws
211
+ * `SchemaCompileBudgetError` when the tenant has spent its budget.
219
212
  */
220
213
  validate<T = unknown>(data: unknown): Result<T, ValidationErrorLike>;
221
214
  /** The raw schema JSON, for round-tripping or debugging. */
@@ -234,36 +227,35 @@ export interface ValidationErrorLike {
234
227
  readonly errors: readonly unknown[];
235
228
  }
236
229
 
230
+ /**
231
+ * Keywords an inline schema may carry for Kindgi to read, with no effect
232
+ * on validation: `x-kindgi-tunable` marks a settings key an improvement
233
+ * pass may search.
234
+ */
235
+ export const INLINE_ANNOTATIONS: readonly string[] = ['x-kindgi-tunable'];
236
+
237
237
  /**
238
238
  * Compile a single anonymous JSON Schema (draft 2020-12) into a reusable
239
239
  * validator. Returns `err` if the schema itself is malformed (Ajv rejects
240
240
  * it), which is how the flow loader surfaces `LoopOutputSchemaError` and
241
- * similar load-time diagnostics.
241
+ * similar load-time diagnostics. Throws `SchemaCompileBudgetError` when the
242
+ * compile counts against a tenant that has spent its budget: that isn't
243
+ * the schema's fault, and the caller answers it as a limit, not an issue.
242
244
  *
243
245
  * Design note: this is intentionally decoupled from `SpecRegistry` — inline
244
246
  * schemas don't have a `$id` and don't need cross-schema `$ref` resolution.
245
247
  */
246
- /**
247
- * Keywords an inline schema may carry for Kindgi to read, with no effect
248
- * on validation: `x-kindgi-tunable` marks a settings key an improvement
249
- * pass may search.
250
- */
251
- export const INLINE_ANNOTATIONS: readonly string[] = ['x-kindgi-tunable'];
252
-
253
248
  export function compileInlineSchema(schema: unknown): Result<CompiledInlineSchema, SchemaError> {
254
- const ajv = new Ajv2020({
255
- strict: true,
256
- allErrors: true,
257
- allowUnionTypes: ALLOW_UNION_TYPES,
258
- });
259
- addFormats(ajv);
260
- // Annotations Kindgi reads off a schema, which validate nothing.
261
- for (const keyword of INLINE_ANNOTATIONS) ajv.addKeyword(keyword);
262
-
263
- let validator: ValidateFunction;
249
+ let validator: () => ValidateFunction;
264
250
  try {
265
- validator = ajv.compile(schema as object);
251
+ validator = holdTenantSchema(schema, {
252
+ dialect: '2020-12',
253
+ options: { strict: true, allErrors: true, allowUnionTypes: ALLOW_UNION_TYPES },
254
+ // Annotations Kindgi reads off a schema, which validate nothing.
255
+ annotations: INLINE_ANNOTATIONS,
256
+ });
266
257
  } catch (cause) {
258
+ if (cause instanceof SchemaCompileBudgetError) throw cause;
267
259
  return {
268
260
  kind: 'err',
269
261
  error: {
@@ -280,7 +272,8 @@ export function compileInlineSchema(schema: unknown): Result<CompiledInlineSchem
280
272
  value: {
281
273
  source: schema,
282
274
  validate<T = unknown>(data: unknown): Result<T, ValidationErrorLike> {
283
- if (validator(data)) {
275
+ const validate = validator();
276
+ if (validate(data)) {
284
277
  return { kind: 'ok', value: data as T };
285
278
  }
286
279
  return {
@@ -288,7 +281,7 @@ export function compileInlineSchema(schema: unknown): Result<CompiledInlineSchem
288
281
  error: {
289
282
  code: 'validation-error',
290
283
  message: 'Data does not validate against inline schema',
291
- errors: validator.errors ?? [],
284
+ errors: validate.errors ?? [],
292
285
  },
293
286
  };
294
287
  },
@@ -0,0 +1,197 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `uniqueItems` in time linear in the data's size times its depth (each
6
+ * level's key is built from its children's), for schemas compiled on RE2
7
+ * (the hosted bounds).
8
+ *
9
+ * Ajv's own keyword compares every pair of items (n² deep-equals) unless
10
+ * the schema declares only scalar types for them: items typed `object` or
11
+ * `array`, or with no `type` at all, as most schemas leave them. 300,000
12
+ * small objects fit in 2 MiB. This one gives each item a key, its JSON with
13
+ * the keys of every object sorted, and looks for a repeat in a `Map`.
14
+ *
15
+ * The verdict is Ajv's, and so is the error: the same pair of items (`i`,
16
+ * `j`), the same `params` and message. Two items are the same as
17
+ * `fast-deep-equal` (what Ajv uses) finds them for JSON values: key order
18
+ * doesn't matter, array order does, and `-0` is `0`. Items declared
19
+ * scalar go through Ajv's own code, which is linear already. Two cases
20
+ * differ, neither of them from JSON:
21
+ * - an item JSON can't carry (a `Date`, a class instance, a `Map`) makes
22
+ * the whole array compared as Ajv does, pair by pair, with its
23
+ * `fast-deep-equal`. Only code in the server's own process passes those;
24
+ * - an object with its own `valueOf` or `toString` key is compared by its
25
+ * keys, where `fast-deep-equal` throws a `TypeError` calling it.
26
+ */
27
+
28
+ import { _ } from 'ajv';
29
+ import type { CodeKeywordDefinition, KeywordCxt } from 'ajv';
30
+ import { getSchemaTypes } from 'ajv/dist/compile/validate/dataType.js';
31
+ import * as equalModule from 'ajv/dist/runtime/equal.js';
32
+ import * as uniqueItemsModule from 'ajv/dist/vocabularies/validation/uniqueItems.js';
33
+
34
+ // Ajv's dist modules are CommonJS with `exports.default`. An ESM import
35
+ // wraps that once or twice, depending on the resolver (Node's, Vite's),
36
+ // and a wrapper can answer for the export's own keys without holding them,
37
+ // so take the innermost `default`. Read on first use, so a process that
38
+ // never compiles on RE2 never depends on their shape.
39
+ let ajvUniqueItems: CodeKeywordDefinition | undefined;
40
+ let ajvEqual: ((a: unknown, b: unknown) => boolean) | undefined;
41
+
42
+ function ajvKeyword(): CodeKeywordDefinition {
43
+ ajvUniqueItems ??= unwrapDefault(
44
+ uniqueItemsModule,
45
+ (value): value is CodeKeywordDefinition =>
46
+ typeof value === 'object' &&
47
+ value !== null &&
48
+ Object.hasOwn(value, 'keyword') &&
49
+ typeof (value as { code?: unknown }).code === 'function',
50
+ );
51
+ return ajvUniqueItems;
52
+ }
53
+
54
+ function equal(a: unknown, b: unknown): boolean {
55
+ ajvEqual ??= unwrapDefault(
56
+ equalModule,
57
+ (value): value is (a: unknown, b: unknown) => boolean => typeof value === 'function',
58
+ );
59
+ return ajvEqual(a, b);
60
+ }
61
+
62
+ function unwrapDefault<T>(module: unknown, is: (value: unknown) => value is T): T {
63
+ let value = module;
64
+ for (let step = 0; step < 3; step += 1) {
65
+ const inner = (value as { default?: unknown } | null)?.default;
66
+ if (inner === undefined || inner === null) break;
67
+ value = inner;
68
+ }
69
+ if (!is(value)) throw new Error("Ajv's module isn't the shape @kindgi/schema expects.");
70
+ return value;
71
+ }
72
+
73
+ /** The keyword: `ajv.removeKeyword('uniqueItems')`, then `ajv.addKeyword(linearUniqueItems())`. */
74
+ export function linearUniqueItems(): CodeKeywordDefinition {
75
+ return { ...ajvKeyword(), code: linearCode };
76
+ }
77
+
78
+ function linearCode(cxt: KeywordCxt): void {
79
+ const { gen, data, $data, schema, parentSchema, schemaCode } = cxt;
80
+ const itemTypes = parentSchema.items ? getSchemaTypes(parentSchema.items) : [];
81
+ // Ajv's own test: items declared scalar are compared by a key already.
82
+ if (itemTypes.length > 0 && !itemTypes.some((t) => t === 'object' || t === 'array')) {
83
+ ajvKeyword().code(cxt);
84
+ return;
85
+ }
86
+ if (!$data && !schema) return;
87
+ const valid = gen.let('valid');
88
+ cxt.block$data(valid, validateUniqueItems, _`${schemaCode} === false`);
89
+ cxt.ok(valid);
90
+
91
+ function validateUniqueItems(): void {
92
+ const i = gen.let('i', _`${data}.length`);
93
+ const j = gen.let('j');
94
+ cxt.setParams({ i, j });
95
+ gen.assign(valid, true);
96
+ gen.if(_`${i} > 1`, () => {
97
+ const find = gen.scopeValue('func', { ref: lastRepeat });
98
+ const pair = gen.const('pair', _`${find}(${data})`);
99
+ gen.if(pair, () => {
100
+ gen.assign(i, _`${pair}[0]`);
101
+ gen.assign(j, _`${pair}[1]`);
102
+ cxt.error();
103
+ gen.assign(valid, false);
104
+ });
105
+ });
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The pair Ajv's own keyword reports for items it compares pair by pair:
111
+ * `i`, the last item equal to an earlier one, and `j`, the last earlier
112
+ * item it equals. Undefined when every item is unique.
113
+ */
114
+ export function lastRepeat(items: readonly unknown[]): readonly [number, number] | undefined {
115
+ const seen = new Map<string, number>();
116
+ let found: readonly [number, number] | undefined;
117
+ for (let k = 0; k < items.length; k += 1) {
118
+ const key = itemKey(items[k]);
119
+ if (key === undefined) return pairwise(items);
120
+ const before = seen.get(key);
121
+ if (before !== undefined) found = [k, before];
122
+ seen.set(key, k);
123
+ }
124
+ return found;
125
+ }
126
+
127
+ /** Ajv's own loop: from the last item back, each against every item before it. */
128
+ function pairwise(items: readonly unknown[]): readonly [number, number] | undefined {
129
+ for (let i = items.length - 1; i > 0; i -= 1) {
130
+ for (let j = i - 1; j >= 0; j -= 1) {
131
+ if (equal(items[i], items[j])) return [i, j];
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ /**
138
+ * A key two items share exactly when `fast-deep-equal` finds them equal:
139
+ * JSON with each object's keys sorted, numbers as `String` writes them
140
+ * (`-0` is `0`, every `NaN` the same), and `undefined` kept. Undefined for
141
+ * a value JSON can't carry.
142
+ */
143
+ function itemKey(value: unknown): string | undefined {
144
+ switch (typeof value) {
145
+ case 'string':
146
+ return JSON.stringify(value);
147
+ case 'number':
148
+ return String(value);
149
+ case 'boolean':
150
+ return value ? 'true' : 'false';
151
+ case 'undefined':
152
+ return 'undefined';
153
+ case 'object': {
154
+ if (value === null) return 'null';
155
+ const proto = Object.getPrototypeOf(value);
156
+ if (proto === Array.prototype) {
157
+ const parts: string[] = [];
158
+ for (const item of value as readonly unknown[]) {
159
+ const key = itemKey(item);
160
+ if (key === undefined) return undefined;
161
+ parts.push(key);
162
+ }
163
+ return `[${parts.join(',')}]`;
164
+ }
165
+ if (proto !== Object.prototype) return undefined;
166
+ const record = value as Readonly<Record<string, unknown>>;
167
+ const parts: string[] = [];
168
+ for (const name of Object.keys(record).sort()) {
169
+ const key = itemKey(record[name]);
170
+ if (key === undefined) return undefined;
171
+ parts.push(`${JSON.stringify(name)}:${key}`);
172
+ }
173
+ // `fast-deep-equal` first compares `a.constructor` with `===`: an
174
+ // own `constructor` key holding an object or array matches only the
175
+ // same object, never an equal one.
176
+ const ctor = Object.hasOwn(record, 'constructor') ? record.constructor : undefined;
177
+ const same = typeof ctor === 'object' && ctor !== null ? `@${identity(ctor)}` : '';
178
+ return `{${parts.join(',')}}${same}`;
179
+ }
180
+ default:
181
+ return undefined;
182
+ }
183
+ }
184
+
185
+ const identities = new WeakMap<object, number>();
186
+ let lastIdentity = 0;
187
+
188
+ /** A number for `value` itself: the same object, the same number. */
189
+ function identity(value: object): number {
190
+ let id = identities.get(value);
191
+ if (id === undefined) {
192
+ lastIdentity += 1;
193
+ id = lastIdentity;
194
+ identities.set(value, id);
195
+ }
196
+ return id;
197
+ }