@zmdb/validator 1.0.0-beta.1
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/LICENSE +674 -0
- package/README.md +35 -0
- package/dist/advanced/index.d.ts +53 -0
- package/dist/advanced/index.d.ts.map +1 -0
- package/dist/advanced/index.js +132 -0
- package/dist/advanced/index.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +102 -0
- package/dist/index.js.map +1 -0
- package/dist/regex-complexity.d.ts +6 -0
- package/dist/regex-complexity.d.ts.map +1 -0
- package/dist/regex-complexity.js +42 -0
- package/dist/regex-complexity.js.map +1 -0
- package/dist/serialization/index.d.ts +20 -0
- package/dist/serialization/index.d.ts.map +1 -0
- package/dist/serialization/index.js +64 -0
- package/dist/serialization/index.js.map +1 -0
- package/dist/utilities/index.d.ts +29 -0
- package/dist/utilities/index.d.ts.map +1 -0
- package/dist/utilities/index.js +694 -0
- package/dist/utilities/index.js.map +1 -0
- package/dist/validation-error.d.ts +31 -0
- package/dist/validation-error.d.ts.map +1 -0
- package/dist/validation-error.js +43 -0
- package/dist/validation-error.js.map +1 -0
- package/package.json +61 -0
- package/src/advanced/index.ts +188 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +115 -0
- package/src/regex-complexity.ts +45 -0
- package/src/serialization/index.ts +70 -0
- package/src/utilities/index.ts +748 -0
- package/src/validation-error.ts +53 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
// The runtime half of the validator: one walk over `TypeIR`.
|
|
2
|
+
//
|
|
3
|
+
// This is what runs before the build has transformed anything — `vitest`, `tsx`, a REPL
|
|
4
|
+
// — and what a call site falls back to when the emitter refused its type. So it is not a
|
|
5
|
+
// toy: REQ-AV-4 requires it to accept and reject **exactly** what the emitted code
|
|
6
|
+
// accepts and rejects, and to report the same issue at the same path. A fallback that
|
|
7
|
+
// disagrees with the compiled form is worse than no fallback, because the disagreement
|
|
8
|
+
// only shows up in production.
|
|
9
|
+
//
|
|
10
|
+
// Two things make that achievable rather than aspirational:
|
|
11
|
+
//
|
|
12
|
+
// 1. **One vocabulary.** Both walks read `TypeIR`, and it is the only shape either of
|
|
13
|
+
// them accepts. This file used to walk its own `TypeDescriptor` — a hand-written
|
|
14
|
+
// mirror of a type, in a form nothing checked against the type it claimed to describe
|
|
15
|
+
// — which is why the two paths had drifted into three divergences by the time anyone
|
|
16
|
+
// measured: the emitted object check accepted an array, the emitted number check
|
|
17
|
+
// accepted `NaN`, and the runtime pattern check threw above 10 000 characters. The
|
|
18
|
+
// descriptor and the `toIR` bridge that normalised it are both gone.
|
|
19
|
+
// 2. **One set of decisions.** Every `expected` string, and the question of whether a
|
|
20
|
+
// union has a discriminant, comes from `@zmdb/schema/ir` — imported by the
|
|
21
|
+
// compiler emitter too. Those are the parts that would otherwise drift.
|
|
22
|
+
//
|
|
23
|
+
// The differential suite (`differential.spec.ts`) feeds both paths the same corpora and
|
|
24
|
+
// asserts the two answers are identical, so the claim above is measured.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
discriminantOf,
|
|
28
|
+
expectedForConstraint,
|
|
29
|
+
expectedForDiscriminant,
|
|
30
|
+
expectedOf,
|
|
31
|
+
hasExcessCheck,
|
|
32
|
+
messageFor,
|
|
33
|
+
type Constraints,
|
|
34
|
+
type ConstraintKeyword,
|
|
35
|
+
type ObjectIR,
|
|
36
|
+
type ScalarIR,
|
|
37
|
+
type TypeIR,
|
|
38
|
+
type UnionIR,
|
|
39
|
+
} from '@zmdb/schema/ir';
|
|
40
|
+
|
|
41
|
+
import { failWith } from '../errors.js';
|
|
42
|
+
import { type ValidationIssue } from '../index.js';
|
|
43
|
+
import { getCachedRegExp } from '../regex-complexity.js';
|
|
44
|
+
|
|
45
|
+
export { AssertError, failWith } from '../errors.js';
|
|
46
|
+
|
|
47
|
+
/** True for a non-null, non-array object — proves a keyed read is safe. */
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ValidateResult<T> {
|
|
53
|
+
readonly success: boolean;
|
|
54
|
+
readonly data?: T;
|
|
55
|
+
readonly errors?: readonly ValidationIssue[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Resolving `ref`
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A recursive type reaches its own definition through `{ kind: 'ref', name }`, so the
|
|
64
|
+
* walk needs a name → node table. It is built once per root and cached, because the
|
|
65
|
+
* alternative is rebuilding it on every call for a shape that never changes.
|
|
66
|
+
*/
|
|
67
|
+
type RefTable = ReadonlyMap<string, ObjectIR>;
|
|
68
|
+
type CheckDepth = number | undefined;
|
|
69
|
+
|
|
70
|
+
const REF_TABLES = new WeakMap<TypeIR & object, RefTable>();
|
|
71
|
+
const NO_REFS: RefTable = new Map();
|
|
72
|
+
|
|
73
|
+
function childDepth(depth: CheckDepth): CheckDepth {
|
|
74
|
+
return depth === undefined ? undefined : Math.max(0, depth - 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function collectRefs(node: TypeIR, into: Map<string, ObjectIR>): void {
|
|
78
|
+
switch (node.kind) {
|
|
79
|
+
case 'object':
|
|
80
|
+
if (node.name !== undefined) {
|
|
81
|
+
if (into.has(node.name)) return;
|
|
82
|
+
into.set(node.name, node);
|
|
83
|
+
}
|
|
84
|
+
for (const property of node.properties) collectRefs(property.type, into);
|
|
85
|
+
return;
|
|
86
|
+
case 'array':
|
|
87
|
+
collectRefs(node.element, into);
|
|
88
|
+
return;
|
|
89
|
+
case 'tuple':
|
|
90
|
+
for (const element of node.elements) collectRefs(element, into);
|
|
91
|
+
return;
|
|
92
|
+
case 'union':
|
|
93
|
+
for (const member of node.members) collectRefs(member, into);
|
|
94
|
+
return;
|
|
95
|
+
default:
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function refsOf(root: TypeIR): RefTable {
|
|
101
|
+
const cached = REF_TABLES.get(root);
|
|
102
|
+
if (cached) return cached;
|
|
103
|
+
const table = new Map<string, ObjectIR>();
|
|
104
|
+
collectRefs(root, table);
|
|
105
|
+
const result: RefTable = table.size === 0 ? NO_REFS : table;
|
|
106
|
+
REF_TABLES.set(root, result);
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// check
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The shape half of a scalar test, without its bounds. Mirrors `scalarBase` in the
|
|
116
|
+
* emitter line for line; both reject `NaN` and an invalid `Date`.
|
|
117
|
+
*/
|
|
118
|
+
function scalarMatches(scalar: ScalarIR['scalar'], value: unknown): boolean {
|
|
119
|
+
switch (scalar) {
|
|
120
|
+
case 'string':
|
|
121
|
+
return typeof value === 'string';
|
|
122
|
+
case 'number':
|
|
123
|
+
return typeof value === 'number' && !Number.isNaN(value);
|
|
124
|
+
case 'integer':
|
|
125
|
+
return Number.isInteger(value);
|
|
126
|
+
case 'bigint':
|
|
127
|
+
return typeof value === 'bigint';
|
|
128
|
+
case 'boolean':
|
|
129
|
+
return typeof value === 'boolean';
|
|
130
|
+
case 'date':
|
|
131
|
+
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Bounds, in the order the emitter emits them.
|
|
137
|
+
*
|
|
138
|
+
* A pattern is tested with a plain cached `RegExp` and **no input-length cap**. The old
|
|
139
|
+
* `safeTestPattern` threw above 10 000 characters, which the emitted form — a literal
|
|
140
|
+
* `/re/.test(v)` — has no way to reproduce, so the cap was a divergence disguised as a
|
|
141
|
+
* safety feature. It also guarded the wrong boundary: a pattern comes from the author's
|
|
142
|
+
* own `Pattern<…>` tag and is complexity-checked at build time, so the untrusted side is
|
|
143
|
+
* the input, and refusing to answer about a long input is not a safe answer.
|
|
144
|
+
*
|
|
145
|
+
* boundary: every cast here is a comparison against a value whose kind the scalar check has
|
|
146
|
+
* already established — a constraint only exists on a node that carries one — and each is
|
|
147
|
+
* written as the negation of the passing comparison rather than as the failing one. That is
|
|
148
|
+
* what makes the casts sound *and* unnecessary to defend: `!(x >= min)` is `true` for a
|
|
149
|
+
* value that is not a number at all, because the comparison is `false`, so a wrong kind
|
|
150
|
+
* reaching here fails the constraint instead of passing it. Writing `x < min` instead would
|
|
151
|
+
* be the same expression with the opposite answer for `NaN`.
|
|
152
|
+
*/
|
|
153
|
+
function constraintsMatch(constraints: Constraints | undefined, value: unknown): boolean {
|
|
154
|
+
if (!constraints) return true;
|
|
155
|
+
if (constraints.minimum !== undefined && !((value as number) >= constraints.minimum)) return false;
|
|
156
|
+
if (constraints.maximum !== undefined && !((value as number) <= constraints.maximum)) return false;
|
|
157
|
+
const length = (value as { length?: number }).length;
|
|
158
|
+
if (constraints.minLength !== undefined && !((length as number) >= constraints.minLength)) return false;
|
|
159
|
+
if (constraints.maxLength !== undefined && !((length as number) <= constraints.maxLength)) return false;
|
|
160
|
+
if (constraints.pattern !== undefined && !getCachedRegExp(constraints.pattern).test(value as string)) return false;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function matches(value: unknown, node: TypeIR, refs: RefTable, depth?: number): boolean {
|
|
165
|
+
switch (node.kind) {
|
|
166
|
+
case 'unknown':
|
|
167
|
+
return true;
|
|
168
|
+
case 'null':
|
|
169
|
+
return value === null;
|
|
170
|
+
case 'undefined':
|
|
171
|
+
return value === undefined;
|
|
172
|
+
case 'literal':
|
|
173
|
+
return value === node.value;
|
|
174
|
+
case 'scalar':
|
|
175
|
+
return scalarMatches(node.scalar, value) && constraintsMatch(node.constraints, value);
|
|
176
|
+
case 'array': {
|
|
177
|
+
if (!Array.isArray(value)) return false;
|
|
178
|
+
if (!constraintsMatch(node.constraints, value)) return false;
|
|
179
|
+
if (depth !== undefined && depth <= 1) return true;
|
|
180
|
+
const nestedDepth = childDepth(depth);
|
|
181
|
+
for (const item of value) if (!matches(item, node.element, refs, nestedDepth)) return false;
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
case 'tuple': {
|
|
185
|
+
if (!Array.isArray(value) || value.length !== node.elements.length) return false;
|
|
186
|
+
if (depth !== undefined && depth <= 1) return true;
|
|
187
|
+
const nestedDepth = childDepth(depth);
|
|
188
|
+
for (const [index, element] of node.elements.entries()) {
|
|
189
|
+
if (!matches(value[index], element, refs, nestedDepth)) return false;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
case 'object':
|
|
194
|
+
return objectMatches(value, node, refs, undefined, depth);
|
|
195
|
+
case 'union':
|
|
196
|
+
return unionMatches(value, node, refs, depth);
|
|
197
|
+
case 'ref': {
|
|
198
|
+
const target = refs.get(node.name);
|
|
199
|
+
return target ? objectMatches(value, target, refs, undefined, depth) : false;
|
|
200
|
+
}
|
|
201
|
+
case 'unsupported':
|
|
202
|
+
// The emitter refuses to compile one of these, so the only way to be here is a
|
|
203
|
+
// reflection refused it. Nothing satisfies a type we cannot describe.
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `skip` is a discriminant the union has already established. */
|
|
209
|
+
function objectMatches(
|
|
210
|
+
value: unknown,
|
|
211
|
+
node: ObjectIR,
|
|
212
|
+
refs: RefTable,
|
|
213
|
+
skip: string | undefined,
|
|
214
|
+
depth?: number,
|
|
215
|
+
): boolean {
|
|
216
|
+
if (!isRecord(value)) return false;
|
|
217
|
+
if (depth === 0) return true;
|
|
218
|
+
const nestedDepth = childDepth(depth);
|
|
219
|
+
for (const property of node.properties) {
|
|
220
|
+
if (property.name === skip) continue;
|
|
221
|
+
const member = value[property.name];
|
|
222
|
+
if (property.optional && member === undefined) continue;
|
|
223
|
+
if (!matches(member, property.type, refs, nestedDepth)) return false;
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function unionMatches(value: unknown, node: UnionIR, refs: RefTable, depth?: number): boolean {
|
|
229
|
+
if (node.members.length === 0) return false;
|
|
230
|
+
const discriminant = discriminantOf(node.members);
|
|
231
|
+
if (discriminant) {
|
|
232
|
+
if (!isRecord(value)) return false;
|
|
233
|
+
const tag = value[discriminant.key];
|
|
234
|
+
for (const arm of discriminant.arms) {
|
|
235
|
+
if (tag === arm.value) return objectMatches(value, arm.node, refs, discriminant.key, depth);
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
for (const member of node.members) if (matches(value, member, refs, depth)) return true;
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// issues
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
function report(out: ValidationIssue[], path: string, expected: string, value: unknown): void {
|
|
248
|
+
out.push({ path, expected, value, message: messageFor(expected) });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The same bounds as `constraintsMatch`, reported instead of summed.
|
|
253
|
+
*
|
|
254
|
+
* boundary: the casts are the ones `constraintsMatch` carries, and sound for the same
|
|
255
|
+
* reason — the scalar check has run, and `ok` is the passing comparison, so a value of the
|
|
256
|
+
* wrong kind makes it `false` and produces an issue rather than silently passing. The two
|
|
257
|
+
* functions stay separate because this one allocates and that one must not.
|
|
258
|
+
*/
|
|
259
|
+
function constraintIssues(
|
|
260
|
+
constraints: Constraints | undefined,
|
|
261
|
+
value: unknown,
|
|
262
|
+
path: string,
|
|
263
|
+
out: ValidationIssue[],
|
|
264
|
+
): void {
|
|
265
|
+
if (!constraints) return;
|
|
266
|
+
const check = (keyword: ConstraintKeyword, ok: boolean, bound: number | string): void => {
|
|
267
|
+
if (!ok) report(out, path, expectedForConstraint(keyword, bound), value);
|
|
268
|
+
};
|
|
269
|
+
const length = (value as { length?: number }).length;
|
|
270
|
+
if (constraints.minimum !== undefined) {
|
|
271
|
+
check('minimum', (value as number) >= constraints.minimum, constraints.minimum);
|
|
272
|
+
}
|
|
273
|
+
if (constraints.maximum !== undefined) {
|
|
274
|
+
check('maximum', (value as number) <= constraints.maximum, constraints.maximum);
|
|
275
|
+
}
|
|
276
|
+
if (constraints.minLength !== undefined) {
|
|
277
|
+
check('minLength', (length as number) >= constraints.minLength, constraints.minLength);
|
|
278
|
+
}
|
|
279
|
+
if (constraints.maxLength !== undefined) {
|
|
280
|
+
check('maxLength', (length as number) <= constraints.maxLength, constraints.maxLength);
|
|
281
|
+
}
|
|
282
|
+
if (constraints.pattern !== undefined) {
|
|
283
|
+
check('pattern', getCachedRegExp(constraints.pattern).test(value as string), constraints.pattern);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function collectIssues(
|
|
288
|
+
value: unknown,
|
|
289
|
+
node: TypeIR,
|
|
290
|
+
path: string,
|
|
291
|
+
out: ValidationIssue[],
|
|
292
|
+
refs: RefTable,
|
|
293
|
+
depth?: number,
|
|
294
|
+
): void {
|
|
295
|
+
switch (node.kind) {
|
|
296
|
+
case 'unknown':
|
|
297
|
+
return;
|
|
298
|
+
case 'null':
|
|
299
|
+
case 'undefined':
|
|
300
|
+
case 'literal':
|
|
301
|
+
if (!matches(value, node, refs, depth)) report(out, path, expectedOf(node), value);
|
|
302
|
+
return;
|
|
303
|
+
case 'scalar':
|
|
304
|
+
// The shape is reported first and stops the walk: `minLength 3` about a number
|
|
305
|
+
// would be two issues where one is the truth.
|
|
306
|
+
if (!scalarMatches(node.scalar, value)) report(out, path, expectedOf(node), value);
|
|
307
|
+
else constraintIssues(node.constraints, value, path, out);
|
|
308
|
+
return;
|
|
309
|
+
case 'array': {
|
|
310
|
+
if (!Array.isArray(value)) {
|
|
311
|
+
report(out, path, 'array', value);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
constraintIssues(node.constraints, value, path, out);
|
|
315
|
+
if (depth !== undefined && depth <= 1) return;
|
|
316
|
+
const nestedDepth = childDepth(depth);
|
|
317
|
+
for (const [index, item] of value.entries()) {
|
|
318
|
+
collectIssues(item, node.element, `${path}[${index}]`, out, refs, nestedDepth);
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
case 'tuple': {
|
|
323
|
+
if (!Array.isArray(value) || value.length !== node.elements.length) {
|
|
324
|
+
report(out, path, expectedOf(node), value);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (depth !== undefined && depth <= 1) return;
|
|
328
|
+
const nestedDepth = childDepth(depth);
|
|
329
|
+
for (const [index, element] of node.elements.entries()) {
|
|
330
|
+
collectIssues(value[index], element, `${path}[${index}]`, out, refs, nestedDepth);
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
case 'object':
|
|
335
|
+
objectIssues(value, node, path, out, refs, depth);
|
|
336
|
+
return;
|
|
337
|
+
case 'union':
|
|
338
|
+
unionIssues(value, node, path, out, refs, depth);
|
|
339
|
+
return;
|
|
340
|
+
case 'ref': {
|
|
341
|
+
const target = refs.get(node.name);
|
|
342
|
+
if (target) objectIssues(value, target, path, out, refs, depth);
|
|
343
|
+
else report(out, path, node.name, value);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
case 'unsupported':
|
|
347
|
+
report(out, path, expectedOf(node), value);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function objectIssues(
|
|
353
|
+
value: unknown,
|
|
354
|
+
node: ObjectIR,
|
|
355
|
+
path: string,
|
|
356
|
+
out: ValidationIssue[],
|
|
357
|
+
refs: RefTable,
|
|
358
|
+
depth?: number,
|
|
359
|
+
): void {
|
|
360
|
+
if (!isRecord(value)) {
|
|
361
|
+
report(out, path, expectedOf(node), value);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (depth === 0) return;
|
|
365
|
+
const nestedDepth = childDepth(depth);
|
|
366
|
+
for (const property of node.properties) {
|
|
367
|
+
const member = value[property.name];
|
|
368
|
+
if (property.optional && member === undefined) continue;
|
|
369
|
+
collectIssues(member, property.type, `${path}${accessorPath(property.name)}`, out, refs, nestedDepth);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function unionIssues(
|
|
374
|
+
value: unknown,
|
|
375
|
+
node: UnionIR,
|
|
376
|
+
path: string,
|
|
377
|
+
out: ValidationIssue[],
|
|
378
|
+
refs: RefTable,
|
|
379
|
+
depth?: number,
|
|
380
|
+
): void {
|
|
381
|
+
const discriminant = discriminantOf(node.members);
|
|
382
|
+
if (!discriminant) {
|
|
383
|
+
// No arm to blame: one issue naming the whole union, at the union's own path.
|
|
384
|
+
if (!matches(value, node, refs, depth)) report(out, path, expectedOf(node), value);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (!isRecord(value)) {
|
|
388
|
+
report(out, path, expectedOf(node), value);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const tag = value[discriminant.key];
|
|
392
|
+
for (const arm of discriminant.arms) {
|
|
393
|
+
if (tag === arm.value) {
|
|
394
|
+
objectIssues(value, arm.node, path, out, refs, depth);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// With a discriminant the failure is precise: the tag itself is wrong.
|
|
399
|
+
report(out, `${path}${accessorPath(discriminant.key)}`, expectedForDiscriminant(discriminant), tag);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
403
|
+
|
|
404
|
+
/** `.email`, or `["odd name"]` — the same spelling the emitter's `join` produces. */
|
|
405
|
+
function accessorPath(name: string): string {
|
|
406
|
+
return IDENTIFIER.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// excess
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Whether the value carries a property its type does not declare.
|
|
415
|
+
*
|
|
416
|
+
* Only ever called after `matches` has passed, which is what lets the all-required case
|
|
417
|
+
* reduce to a key count: every declared property is known to be present, so "no excess"
|
|
418
|
+
* is "the counts agree". Same reduction as the emitted form.
|
|
419
|
+
*/
|
|
420
|
+
function hasNoExcessKeys(value: unknown, node: TypeIR, refs: RefTable): boolean {
|
|
421
|
+
switch (node.kind) {
|
|
422
|
+
case 'object':
|
|
423
|
+
return objectHasNoExcessKeys(value, node, refs);
|
|
424
|
+
case 'array': {
|
|
425
|
+
if (!Array.isArray(value) || !hasExcessCheck(node.element)) return true;
|
|
426
|
+
for (const item of value) if (!hasNoExcessKeys(item, node.element, refs)) return false;
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
case 'tuple': {
|
|
430
|
+
if (!Array.isArray(value)) return true;
|
|
431
|
+
for (const [index, element] of node.elements.entries()) {
|
|
432
|
+
if (!hasExcessCheck(element)) continue;
|
|
433
|
+
if (!hasNoExcessKeys(value[index], element, refs)) return false;
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
case 'union': {
|
|
438
|
+
// A value can satisfy several arms of an undiscriminated union, so "which arm's
|
|
439
|
+
// property list is the declared one" has no answer and neither walk asks it.
|
|
440
|
+
const discriminant = discriminantOf(node.members);
|
|
441
|
+
if (!discriminant || !isRecord(value)) return true;
|
|
442
|
+
const tag = value[discriminant.key];
|
|
443
|
+
for (const arm of discriminant.arms) {
|
|
444
|
+
if (tag === arm.value) return objectHasNoExcessKeys(value, arm.node, refs);
|
|
445
|
+
}
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
case 'ref': {
|
|
449
|
+
const target = refs.get(node.name);
|
|
450
|
+
return target ? objectHasNoExcessKeys(value, target, refs) : true;
|
|
451
|
+
}
|
|
452
|
+
default:
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function objectHasNoExcessKeys(value: unknown, node: ObjectIR, refs: RefTable): boolean {
|
|
458
|
+
if (!isRecord(value)) return true;
|
|
459
|
+
|
|
460
|
+
let allRequired = true;
|
|
461
|
+
for (const property of node.properties) {
|
|
462
|
+
if (property.optional) {
|
|
463
|
+
allRequired = false;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (allRequired && node.properties.length > 0) {
|
|
469
|
+
// No Set and no allocation: count own enumerable keys and compare.
|
|
470
|
+
let actual = 0;
|
|
471
|
+
for (const _ in value) {
|
|
472
|
+
if (++actual > node.properties.length) return false;
|
|
473
|
+
}
|
|
474
|
+
if (actual !== node.properties.length) return false;
|
|
475
|
+
} else {
|
|
476
|
+
for (const key in value) {
|
|
477
|
+
if (!node.properties.some(property => property.name === key)) return false;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (const property of node.properties) {
|
|
482
|
+
if (!hasExcessCheck(property.type)) continue;
|
|
483
|
+
const member = value[property.name];
|
|
484
|
+
// `for…in undefined` throws, and an optional or nullable member may be neither an
|
|
485
|
+
// object nor present. The emitted form guards the same way.
|
|
486
|
+
if (typeof member !== 'object' || member === null) continue;
|
|
487
|
+
if (!hasNoExcessKeys(member, property.type, refs)) return false;
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// sample
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Where `sample` draws from — `Math.random` unless a caller passed something else.
|
|
498
|
+
*
|
|
499
|
+
* A parameter on `sample` would be threaded through eight recursive cases to reach two leaf
|
|
500
|
+
* functions, and every one of them would carry an argument it does not use. A module-level
|
|
501
|
+
* source is the shape that costs nothing on the default path, and it is safe here for one
|
|
502
|
+
* specific reason rather than by luck: `sample` is synchronous and `random` restores the
|
|
503
|
+
* previous value in a `finally`, so there is no interleaving to get wrong.
|
|
504
|
+
*
|
|
505
|
+
* It exists so a seeded generator can be *the same* generator. `@zmdb/orm/seeding`
|
|
506
|
+
* needs "same seed ⇒ same rows" and needs the values to satisfy the column's constraints;
|
|
507
|
+
* those are one requirement, and answering it with a second value generator is how the
|
|
508
|
+
* repo ended up with five walkers over one schema.
|
|
509
|
+
*/
|
|
510
|
+
let entropy: () => number = Math.random;
|
|
511
|
+
|
|
512
|
+
function randomInt(min: number, max: number): number {
|
|
513
|
+
return min + Math.floor(entropy() * (max - min + 1));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function randomString(min: number, max: number): string {
|
|
517
|
+
let s = '';
|
|
518
|
+
while (s.length < Math.max(min, 1)) s += entropy().toString(36).slice(2);
|
|
519
|
+
return s.slice(0, max);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* A value that satisfies `node` by construction, or a thrown refusal.
|
|
524
|
+
*
|
|
525
|
+
* Refusing is the point. The generator this replaced returned `'x'` for any pattern it
|
|
526
|
+
* did not recognise, so `is(random(d), d)` — the single property it claimed — was false
|
|
527
|
+
* for most patterns. Nothing here inverts a regular expression, so it says so.
|
|
528
|
+
*
|
|
529
|
+
* No `RefTable`, unlike its siblings: a `ref` is where sampling stops, either dropped by
|
|
530
|
+
* the union above it or refused outright, so there is never a name to resolve.
|
|
531
|
+
*/
|
|
532
|
+
function sample(node: TypeIR, path: string): unknown {
|
|
533
|
+
switch (node.kind) {
|
|
534
|
+
case 'unknown':
|
|
535
|
+
case 'null':
|
|
536
|
+
return null;
|
|
537
|
+
case 'undefined':
|
|
538
|
+
return undefined;
|
|
539
|
+
case 'literal':
|
|
540
|
+
return node.value;
|
|
541
|
+
case 'scalar':
|
|
542
|
+
return scalarSample(node, path);
|
|
543
|
+
case 'array': {
|
|
544
|
+
const min = node.constraints?.minLength ?? 1;
|
|
545
|
+
const max = node.constraints?.maxLength ?? Math.max(min, 3);
|
|
546
|
+
if (min > max) throw refusal(path, `an array with minLength ${min} above maxLength ${max}`);
|
|
547
|
+
return Array.from({ length: randomInt(min, max) }, () => sample(node.element, `${path}[]`));
|
|
548
|
+
}
|
|
549
|
+
case 'tuple':
|
|
550
|
+
return node.elements.map((element, index) => sample(element, `${path}[${index}]`));
|
|
551
|
+
case 'object': {
|
|
552
|
+
const out: Record<string, unknown> = {};
|
|
553
|
+
for (const property of node.properties) {
|
|
554
|
+
out[property.name] = sample(property.type, `${path}.${property.name}`);
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
case 'union': {
|
|
559
|
+
// A `ref` member is dropped rather than sampled, so `Node { next: Node | null }`
|
|
560
|
+
// terminates on `null`. A union of nothing but refs cannot terminate at all.
|
|
561
|
+
const usable = node.members.filter(member => member.kind !== 'ref');
|
|
562
|
+
if (usable.length === 0) throw refusal(path, 'a union of nothing but back-references cannot be sampled');
|
|
563
|
+
// boundary: `usable` is non-empty — the line above throws otherwise — and the index is
|
|
564
|
+
// drawn from `0 … length - 1`, so both reads are in bounds. The `??` is there for the
|
|
565
|
+
// same reason the cast is: `noUncheckedIndexedAccess` types an in-bounds read as
|
|
566
|
+
// possibly `undefined`, and there is no run of this branch that produces one.
|
|
567
|
+
const chosen = usable[randomInt(0, usable.length - 1)] ?? usable[0];
|
|
568
|
+
return sample(chosen as TypeIR, path);
|
|
569
|
+
}
|
|
570
|
+
case 'ref':
|
|
571
|
+
throw refusal(path, `\`${node.name}\` recurs with no terminating arm, so no finite value satisfies it`);
|
|
572
|
+
case 'unsupported':
|
|
573
|
+
throw refusal(path, node.reason);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function scalarSample(node: ScalarIR, path: string): unknown {
|
|
578
|
+
const constraints = node.constraints;
|
|
579
|
+
switch (node.scalar) {
|
|
580
|
+
case 'boolean':
|
|
581
|
+
return entropy() < 0.5;
|
|
582
|
+
case 'date':
|
|
583
|
+
// An instant drawn from the same source, not `new Date()`: a sample that ignores the
|
|
584
|
+
// generator is a sample a seed cannot reproduce, and "same seed ⇒ same rows" is a
|
|
585
|
+
// property, not a nicety. The range is the epoch to roughly 2024.
|
|
586
|
+
return new Date(Math.floor(entropy() * 1_700_000_000_000));
|
|
587
|
+
case 'number':
|
|
588
|
+
case 'integer':
|
|
589
|
+
case 'bigint': {
|
|
590
|
+
const min = constraints?.minimum ?? 0;
|
|
591
|
+
const max = constraints?.maximum ?? min + 1000;
|
|
592
|
+
if (min > max) throw refusal(path, `a bound with minimum ${min} above maximum ${max}`);
|
|
593
|
+
const value = randomInt(min, max);
|
|
594
|
+
return node.scalar === 'bigint' ? BigInt(value) : value;
|
|
595
|
+
}
|
|
596
|
+
case 'string': {
|
|
597
|
+
if (constraints?.pattern !== undefined) {
|
|
598
|
+
throw refusal(path, 'a sample cannot be built from a `pattern`; nothing here inverts a regular expression');
|
|
599
|
+
}
|
|
600
|
+
const min = constraints?.minLength ?? 1;
|
|
601
|
+
const max = constraints?.maxLength ?? Math.max(min, 12);
|
|
602
|
+
if (min > max) throw refusal(path, `a string with minLength ${min} above maxLength ${max}`);
|
|
603
|
+
return randomString(min, max);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function refusal(path: string, reason: string): Error {
|
|
609
|
+
return new Error(path === '' ? `cannot sample: ${reason}` : `cannot sample \`${path}\`: ${reason}`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
// Public surface
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
const MISSING = 'runtime type witness required in test/fallback mode';
|
|
617
|
+
const INVALID_SHALLOW_DEPTH = 'shallow validation fallback depth must be a positive integer';
|
|
618
|
+
|
|
619
|
+
function required(schema: TypeIR | undefined): TypeIR {
|
|
620
|
+
if (!schema) throw new Error(MISSING);
|
|
621
|
+
return schema;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function shallowDepth(depth: number | undefined): number {
|
|
625
|
+
const resolved = depth ?? 1;
|
|
626
|
+
if (!Number.isInteger(resolved) || resolved <= 0) throw new Error(INVALID_SHALLOW_DEPTH);
|
|
627
|
+
return resolved;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function certified<T>(input: unknown): T {
|
|
631
|
+
// boundary: the caller reaches this only after the runtime witness walk has
|
|
632
|
+
// accepted `input`; this single certification point serves every returning
|
|
633
|
+
// assertion form instead of adding one type assertion per public function.
|
|
634
|
+
return input as T;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function is<T = unknown>(input: unknown, schema?: TypeIR): input is T {
|
|
638
|
+
const node = required(schema);
|
|
639
|
+
return matches(input, node, refsOf(node));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function assert<T = unknown>(input: unknown, schema?: TypeIR): T {
|
|
643
|
+
const node = required(schema);
|
|
644
|
+
const refs = refsOf(node);
|
|
645
|
+
// Two passes, as the emitted form does it: the allocation-free check first, and the
|
|
646
|
+
// issue walk only once we already know a throw is coming (REQ-AV-7).
|
|
647
|
+
if (matches(input, node, refs)) {
|
|
648
|
+
return certified<T>(input);
|
|
649
|
+
}
|
|
650
|
+
const issues: ValidationIssue[] = [];
|
|
651
|
+
collectIssues(input, node, 'input', issues, refs);
|
|
652
|
+
failWith(issues);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function validate<T = unknown>(input: unknown, schema?: TypeIR): ValidateResult<T> {
|
|
656
|
+
const node = required(schema);
|
|
657
|
+
const refs = refsOf(node);
|
|
658
|
+
if (matches(input, node, refs)) return { success: true, data: certified<T>(input) };
|
|
659
|
+
const issues: ValidationIssue[] = [];
|
|
660
|
+
collectIssues(input, node, 'input', issues, refs);
|
|
661
|
+
return { success: false, errors: issues };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Validate only through depth `D`; an ordinary call is replaced at build time.
|
|
666
|
+
*
|
|
667
|
+
* The optional witness and runtime depth exist for tests and generated fallback
|
|
668
|
+
* modules. A real untransformed call has neither and throws `MISSING`, exactly as
|
|
669
|
+
* the full-depth utility family does.
|
|
670
|
+
*/
|
|
671
|
+
export function isShallow<T = unknown, D extends number = 1>(input: unknown, schema?: TypeIR, depth?: D): input is T {
|
|
672
|
+
const node = required(schema);
|
|
673
|
+
return matches(input, node, refsOf(node), shallowDepth(depth));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export function assertShallow<T = unknown, D extends number = 1>(input: unknown, schema?: TypeIR, depth?: D): T {
|
|
677
|
+
const node = required(schema);
|
|
678
|
+
const refs = refsOf(node);
|
|
679
|
+
const limit = shallowDepth(depth);
|
|
680
|
+
if (matches(input, node, refs, limit)) return certified<T>(input);
|
|
681
|
+
const issues: ValidationIssue[] = [];
|
|
682
|
+
collectIssues(input, node, 'input', issues, refs, limit);
|
|
683
|
+
failWith(issues);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function validateShallow<T = unknown, D extends number = 1>(
|
|
687
|
+
input: unknown,
|
|
688
|
+
schema?: TypeIR,
|
|
689
|
+
depth?: D,
|
|
690
|
+
): ValidateResult<T> {
|
|
691
|
+
const node = required(schema);
|
|
692
|
+
const refs = refsOf(node);
|
|
693
|
+
const limit = shallowDepth(depth);
|
|
694
|
+
if (matches(input, node, refs, limit)) return { success: true, data: certified<T>(input) };
|
|
695
|
+
const issues: ValidationIssue[] = [];
|
|
696
|
+
collectIssues(input, node, 'input', issues, refs, limit);
|
|
697
|
+
return { success: false, errors: issues };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function equals<T = unknown>(input: unknown, schema?: TypeIR): input is T {
|
|
701
|
+
const node = required(schema);
|
|
702
|
+
const refs = refsOf(node);
|
|
703
|
+
return matches(input, node, refs) && hasNoExcessKeys(input, node, refs);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function assertEquals<T = unknown>(input: unknown, schema?: TypeIR): T {
|
|
707
|
+
const node = required(schema);
|
|
708
|
+
const refs = refsOf(node);
|
|
709
|
+
if (matches(input, node, refs) && hasNoExcessKeys(input, node, refs)) {
|
|
710
|
+
return certified<T>(input);
|
|
711
|
+
}
|
|
712
|
+
const issues: ValidationIssue[] = [];
|
|
713
|
+
collectIssues(input, node, 'input', issues, refs);
|
|
714
|
+
// Excess properties are one issue about the value as a whole, and only worth
|
|
715
|
+
// reporting when nothing else was wrong: "you also passed `extra`" is noise next to
|
|
716
|
+
// "`email` is not a string".
|
|
717
|
+
if (issues.length === 0 && !hasNoExcessKeys(input, node, refs)) {
|
|
718
|
+
report(issues, 'input', 'no excess properties', input);
|
|
719
|
+
}
|
|
720
|
+
failWith(issues);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export function random<T = unknown>(schema?: TypeIR, rng?: () => number): T {
|
|
724
|
+
const node = required(schema);
|
|
725
|
+
const previous = entropy;
|
|
726
|
+
entropy = rng ?? Math.random;
|
|
727
|
+
try {
|
|
728
|
+
// boundary: `sample` builds the value FROM the IR, so it satisfies it by
|
|
729
|
+
// construction — the `is(random(d), d)` property test guards this.
|
|
730
|
+
return sample(node, '') as T;
|
|
731
|
+
} finally {
|
|
732
|
+
entropy = previous;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Every issue, for a caller that wants them without a `ValidateResult` wrapper. */
|
|
737
|
+
export function issuesFor(input: unknown, schema: TypeIR, path = 'input'): readonly ValidationIssue[] {
|
|
738
|
+
const node = schema;
|
|
739
|
+
const issues: ValidationIssue[] = [];
|
|
740
|
+
collectIssues(input, node, path, issues, refsOf(node));
|
|
741
|
+
return issues;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Re-exported so a caller holding a generated witness can name its type without reaching past
|
|
745
|
+
// this entry point into `@zmdb/schema/ir`. It is the only shape these functions accept,
|
|
746
|
+
// which is the point: there is nothing else left to name.
|
|
747
|
+
export type { TypeIR };
|
|
748
|
+
export type { ValidationIssue };
|