@ultimat3/schema 29.3.2 → 30.0.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/CLAUDE.md +25 -4
- package/README.md +43 -1
- package/package.json +13 -2
- package/src/array-schema.ts +17 -4
- package/src/builder.ts +57 -3
- package/src/discriminated-union.ts +15 -4
- package/src/error-codes.ts +1 -0
- package/src/errors.ts +17 -0
- package/src/index.ts +5 -1
- package/src/json-schema.ts +4 -1
- package/src/json-value.ts +3 -3
- package/src/money-value.ts +7 -2
- package/src/node.ts +5 -0
- package/src/record-schema.ts +78 -0
- package/src/sample-draw.ts +118 -0
- package/src/sample-seeded.ts +147 -0
- package/src/sample.ts +161 -0
- package/src/schema-rules.ts +65 -0
- package/src/sensitive.ts +137 -0
- package/src/standard.ts +9 -0
- package/src/validators.ts +66 -75
package/src/validators.ts
CHANGED
|
@@ -8,12 +8,13 @@ import {
|
|
|
8
8
|
type AnySchema,
|
|
9
9
|
type Check,
|
|
10
10
|
checkOf,
|
|
11
|
-
fail,
|
|
12
11
|
failWith,
|
|
13
12
|
isPlainObject,
|
|
14
13
|
makeSchema,
|
|
14
|
+
mistyped,
|
|
15
15
|
pass,
|
|
16
16
|
type Refinement,
|
|
17
|
+
rejected,
|
|
17
18
|
type Schema,
|
|
18
19
|
type Shape,
|
|
19
20
|
type ShapeInput,
|
|
@@ -26,7 +27,7 @@ import { isIsoDateTime, isZonelessDateTime } from './iso-date';
|
|
|
26
27
|
import { type JsonValue, jsonSchema } from './json-value';
|
|
27
28
|
import { type MoneyValue, moneySchema } from './money-value';
|
|
28
29
|
import type { SchemaNode } from './node';
|
|
29
|
-
import {
|
|
30
|
+
import { recordSchema } from './record-schema';
|
|
30
31
|
import type { InferInput, InferOutput, StandardIssue } from './standard';
|
|
31
32
|
import { isIanaZoneName } from './time-zone-name';
|
|
32
33
|
import { unstorable } from './unstorable-text';
|
|
@@ -87,22 +88,47 @@ function patternTester(node: SchemaNode): ((value: string) => boolean) | undefin
|
|
|
87
88
|
function stringLike(node: SchemaNode, what: string, test?: (value: string) => boolean) {
|
|
88
89
|
const matchesPattern = patternTester(node);
|
|
89
90
|
const check: Check<string> = (value, path) => {
|
|
90
|
-
if (typeof value !== 'string') return
|
|
91
|
+
if (typeof value !== 'string') return mistyped(path, expected(what, value), value, 'string');
|
|
91
92
|
// A NUL or a lone surrogate passed here reached the row write — or a `where` bind — as
|
|
92
93
|
// `X_DB_STATEMENT_FAILED`, a 500 blaming the server for what the caller sent (`unstorable`).
|
|
93
94
|
// Only those: a tab, a newline or any other C0 control is legal text Postgres stores unchanged.
|
|
94
95
|
const unfit = unstorable(value);
|
|
95
|
-
|
|
96
|
+
// `type`: a string Postgres cannot store is not a string this field can hold, whatever its rule.
|
|
97
|
+
if (unfit !== undefined) {
|
|
98
|
+
return rejected(path, `${expected(what, value)} ${unfit}`, 'type', { type: 'string' });
|
|
99
|
+
}
|
|
96
100
|
if (node.minLength !== undefined && charCount(value) < node.minLength) {
|
|
97
|
-
return
|
|
101
|
+
return rejected(
|
|
102
|
+
path,
|
|
103
|
+
expected(`${what} of at least ${node.minLength} chars`, value),
|
|
104
|
+
'string.min',
|
|
105
|
+
{
|
|
106
|
+
min: node.minLength,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
98
109
|
}
|
|
99
110
|
if (node.maxLength !== undefined && charCount(value) > node.maxLength) {
|
|
100
|
-
return
|
|
111
|
+
return rejected(
|
|
112
|
+
path,
|
|
113
|
+
expected(`${what} of at most ${node.maxLength} chars`, value),
|
|
114
|
+
'string.max',
|
|
115
|
+
{
|
|
116
|
+
max: node.maxLength,
|
|
117
|
+
},
|
|
118
|
+
);
|
|
101
119
|
}
|
|
102
120
|
if (matchesPattern !== undefined && !matchesPattern(value)) {
|
|
103
|
-
|
|
121
|
+
const pattern = describePattern(node);
|
|
122
|
+
return rejected(path, expected(`${what} matching ${pattern}`, value), 'string.pattern', {
|
|
123
|
+
pattern,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (test !== undefined && !test(value)) {
|
|
127
|
+
// Every builtin with a `test` declares the `format` it tests for (`uuid`, `email`, …).
|
|
128
|
+
return rejected(path, expected(what, value), 'string.format', {
|
|
129
|
+
format: node.format ?? 'string',
|
|
130
|
+
});
|
|
104
131
|
}
|
|
105
|
-
if (test !== undefined && !test(value)) return fail(path, expected(what, value));
|
|
106
132
|
return pass(value);
|
|
107
133
|
};
|
|
108
134
|
return check;
|
|
@@ -136,7 +162,7 @@ function makeStringSchema(
|
|
|
136
162
|
function makeNumberSchema(node: SchemaNode): NumberSchema {
|
|
137
163
|
const check: Check<number> = (value, path) => {
|
|
138
164
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
139
|
-
return
|
|
165
|
+
return mistyped(path, expected('a finite number', value), value, 'number');
|
|
140
166
|
}
|
|
141
167
|
// Safe, not merely whole — the same rule `money-value.ts` states one file over, for the same
|
|
142
168
|
// reason: `Number.isInteger` let 2^53 through the boundary as a 200, so the policy gate and
|
|
@@ -145,13 +171,17 @@ function makeNumberSchema(node: SchemaNode): NumberSchema {
|
|
|
145
171
|
// without. Above 2^53 a double cannot name its own successor, so a "whole" number there is
|
|
146
172
|
// already a rounded one.
|
|
147
173
|
if (node.integer === true && !Number.isSafeInteger(value)) {
|
|
148
|
-
return
|
|
174
|
+
return rejected(path, expected('a safe integer', value), 'number.integer');
|
|
149
175
|
}
|
|
150
176
|
if (node.minimum !== undefined && value < node.minimum) {
|
|
151
|
-
return
|
|
177
|
+
return rejected(path, expected(`a number >= ${node.minimum}`, value), 'number.min', {
|
|
178
|
+
min: node.minimum,
|
|
179
|
+
});
|
|
152
180
|
}
|
|
153
181
|
if (node.maximum !== undefined && value > node.maximum) {
|
|
154
|
-
return
|
|
182
|
+
return rejected(path, expected(`a number <= ${node.maximum}`, value), 'number.max', {
|
|
183
|
+
max: node.maximum,
|
|
184
|
+
});
|
|
155
185
|
}
|
|
156
186
|
return pass(value);
|
|
157
187
|
};
|
|
@@ -178,7 +208,7 @@ export function objectSchema<S extends Shape>(shape: S): ObjectSchema<S> {
|
|
|
178
208
|
const node: SchemaNode = { kind: 'object', properties };
|
|
179
209
|
|
|
180
210
|
const check: Check<ShapeOutput<S>> = (value, path) => {
|
|
181
|
-
if (!isPlainObject(value)) return
|
|
211
|
+
if (!isPlainObject(value)) return mistyped(path, expected('an object', value), value, 'object');
|
|
182
212
|
const issues: StandardIssue[] = [];
|
|
183
213
|
// `Object.create(null)`, for `recordSchema`'s reason one screen down: on a `{}` literal
|
|
184
214
|
// `out['__proto__'] = value` hits the `Object.prototype` SETTER, so a DECLARED `__proto__`
|
|
@@ -239,14 +269,19 @@ export function enumSchema<const V extends readonly [string, ...string[]]>(
|
|
|
239
269
|
if (typeof value === 'string' && (values as readonly string[]).includes(value)) {
|
|
240
270
|
return pass(value as V[number]);
|
|
241
271
|
}
|
|
242
|
-
|
|
272
|
+
const listed = values.join(' | ');
|
|
273
|
+
return rejected(path, expected(`one of ${listed}`, value), 'enum', { values: listed });
|
|
243
274
|
});
|
|
244
275
|
}
|
|
245
276
|
|
|
246
277
|
export function literalSchema<const V extends string | number | boolean>(value: V): Schema<V, V> {
|
|
247
278
|
const node: SchemaNode = { kind: 'literal', literal: value };
|
|
248
279
|
return makeSchema<V, V>(node, (candidate, path) =>
|
|
249
|
-
candidate === value
|
|
280
|
+
candidate === value
|
|
281
|
+
? pass(value)
|
|
282
|
+
: rejected(path, expected(JSON.stringify(value), candidate), 'literal', {
|
|
283
|
+
value: JSON.stringify(value),
|
|
284
|
+
}),
|
|
250
285
|
);
|
|
251
286
|
}
|
|
252
287
|
|
|
@@ -262,63 +297,10 @@ export function unionSchema<S extends readonly [AnySchema, ...AnySchema[]]>(
|
|
|
262
297
|
if (result.ok) return pass(result.value as InferOutput<S[number]>);
|
|
263
298
|
reasons.push(result.issues.map((issue) => issue.message).join(', '));
|
|
264
299
|
}
|
|
265
|
-
return
|
|
300
|
+
return rejected(path, `no union member matched (${reasons.join(' | ')})`, 'union');
|
|
266
301
|
});
|
|
267
302
|
}
|
|
268
303
|
|
|
269
|
-
export function recordSchema<S extends AnySchema>(
|
|
270
|
-
values: S,
|
|
271
|
-
): Schema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>> {
|
|
272
|
-
const valueCheck = checkOf(values);
|
|
273
|
-
const node: SchemaNode = { kind: 'record', valueNode: values.node };
|
|
274
|
-
return makeSchema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>>(
|
|
275
|
-
node,
|
|
276
|
-
(value, path) => {
|
|
277
|
-
if (!isPlainObject(value)) return fail(path, expected('an object', value));
|
|
278
|
-
const issues: StandardIssue[] = [];
|
|
279
|
-
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
280
|
-
for (const [index, [key, entry]] of Object.entries(value).entries()) {
|
|
281
|
-
// The entry's POSITION, never its key. `describe-value.ts`'s absolute is stated about the
|
|
282
|
-
// rejected value, and a record's KEY is the caller's data just as much: the path travels
|
|
283
|
-
// `formatIssue` -> `bodyInvalid`'s `cause` -> the problem document AND the log line, and
|
|
284
|
-
// core's logger redacts by key, so a key baked into a string has no key left to redact.
|
|
285
|
-
// `@ultimat3/http`'s `bodyInvalid` states the contract this was breaking in its own doc
|
|
286
|
-
// block — *"`issues` … must name only facts the framework itself chose"*. A record keyed
|
|
287
|
-
// by an email address, a phone number or a pasted credential wrote every one of them into
|
|
288
|
-
// the central log index, in the same shape the password bug did.
|
|
289
|
-
//
|
|
290
|
-
// The index rather than nothing at all: the segment's whole job is to say WHICH entry
|
|
291
|
-
// failed, and dropping it makes three failing entries render three identical lines. It is
|
|
292
|
-
// the same segment `arraySchema` already uses, so `formatPath` renders `meta[3]` with no
|
|
293
|
-
// new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of
|
|
294
|
-
// string ones — so it names an entry that exists rather than a byte offset in the body.
|
|
295
|
-
const at = [...path, index];
|
|
296
|
-
// A record is stored as `jsonb` far more often than not, and jsonb refuses an unstorable
|
|
297
|
-
// KEY exactly as it does a value — so the key is refused here, by position, not quoted.
|
|
298
|
-
const unfitKey = unstorable(key);
|
|
299
|
-
if (unfitKey !== undefined) {
|
|
300
|
-
issues.push({ message: `expected a record key, received one ${unfitKey}`, path: at });
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
if (isPrototypeKey(key)) {
|
|
304
|
-
issues.push({
|
|
305
|
-
// The refused NAMES are a closed set declared right here, so the message may state
|
|
306
|
-
// them: that is the one part of this line the caller did not choose.
|
|
307
|
-
message: expected(`a record key that is not ${PROTOTYPE_KEYS.join(' | ')}`, key),
|
|
308
|
-
path: at,
|
|
309
|
-
});
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
const result = valueCheck(entry, at);
|
|
313
|
-
if (result.ok) out[key] = result.value;
|
|
314
|
-
else issues.push(...result.issues);
|
|
315
|
-
}
|
|
316
|
-
if (issues.length > 0) return failWith(issues);
|
|
317
|
-
return pass(out as Record<string, InferOutput<S>>);
|
|
318
|
-
},
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
304
|
export function nullableSchema<S extends AnySchema>(
|
|
323
305
|
schema: S,
|
|
324
306
|
): Schema<InferInput<S> | null, InferOutput<S> | null> {
|
|
@@ -351,12 +333,14 @@ function isLocale(value: string): boolean {
|
|
|
351
333
|
}
|
|
352
334
|
}
|
|
353
335
|
|
|
336
|
+
const DATE_TIME = Object.freeze({ format: 'date-time' });
|
|
337
|
+
|
|
354
338
|
const dateSchema: Schema<Date | string | number, Date> = makeSchema<Date | string | number, Date>(
|
|
355
339
|
{ kind: 'date', format: 'date-time' },
|
|
356
340
|
(value, path) => {
|
|
357
341
|
if (value instanceof Date) {
|
|
358
342
|
return Number.isNaN(value.getTime())
|
|
359
|
-
?
|
|
343
|
+
? rejected(path, expected('a valid Date', value), 'type', { type: 'date' })
|
|
360
344
|
: pass(value);
|
|
361
345
|
}
|
|
362
346
|
if (typeof value === 'string' || typeof value === 'number') {
|
|
@@ -365,19 +349,24 @@ const dateSchema: Schema<Date | string | number, Date> = makeSchema<Date | strin
|
|
|
365
349
|
// process's zone — reachable from the wire, since `coerceQuery` routes a `t.date` field
|
|
366
350
|
// here and a container's `TZ` then decided which instant a query parameter meant.
|
|
367
351
|
if (typeof value === 'string' && isZonelessDateTime(value)) {
|
|
368
|
-
return
|
|
352
|
+
return rejected(
|
|
353
|
+
path,
|
|
354
|
+
expected('an ISO-8601 date-time with an offset or Z', value),
|
|
355
|
+
'string.format',
|
|
356
|
+
DATE_TIME,
|
|
357
|
+
);
|
|
369
358
|
}
|
|
370
359
|
// `new Date` also parses `'March 14, 2026'` and `'12'` at the host's local midnight, so the
|
|
371
360
|
// shape is checked first: a string that is not ISO-8601 is refused, not guessed at.
|
|
372
361
|
if (typeof value === 'string' && !isIsoDateTime(value)) {
|
|
373
|
-
return
|
|
362
|
+
return rejected(path, expected('an ISO-8601 date-time', value), 'string.format', DATE_TIME);
|
|
374
363
|
}
|
|
375
364
|
const parsed = new Date(value);
|
|
376
365
|
return Number.isNaN(parsed.getTime())
|
|
377
|
-
?
|
|
366
|
+
? rejected(path, expected('an ISO-8601 date-time', value), 'string.format', DATE_TIME)
|
|
378
367
|
: pass(parsed);
|
|
379
368
|
}
|
|
380
|
-
return
|
|
369
|
+
return mistyped(path, expected('an ISO-8601 date-time', value), value, 'date');
|
|
381
370
|
},
|
|
382
371
|
);
|
|
383
372
|
|
|
@@ -433,7 +422,9 @@ export const builtinT: TNamespace = Object.freeze({
|
|
|
433
422
|
string: makeStringSchema({ kind: 'string', minLength: 1 }, 'a non-empty string'),
|
|
434
423
|
number: makeNumberSchema({ kind: 'number' }),
|
|
435
424
|
boolean: makeSchema<boolean, boolean>({ kind: 'boolean' }, (value, path) =>
|
|
436
|
-
typeof value === 'boolean'
|
|
425
|
+
typeof value === 'boolean'
|
|
426
|
+
? pass(value)
|
|
427
|
+
: mistyped(path, expected('a boolean', value), value, 'boolean'),
|
|
437
428
|
),
|
|
438
429
|
uuid: makeStringSchema({ kind: 'string', format: 'uuid' }, 'a uuid', (value) =>
|
|
439
430
|
UUID_RE.test(value),
|