@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/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 =
|
|
141
|
-
|
|
142
|
-
allErrors: true,
|
|
143
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|