@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/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 { isPrototypeKey, PROTOTYPE_KEYS } from './prototype-keys';
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 fail(path, expected(what, value));
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
- if (unfit !== undefined) return fail(path, `${expected(what, value)} ${unfit}`);
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 fail(path, expected(`${what} of at least ${node.minLength} chars`, value));
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 fail(path, expected(`${what} of at most ${node.maxLength} chars`, value));
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
- return fail(path, expected(`${what} matching ${describePattern(node)}`, value));
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 fail(path, expected('a finite number', value));
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 fail(path, expected('a safe integer', value));
174
+ return rejected(path, expected('a safe integer', value), 'number.integer');
149
175
  }
150
176
  if (node.minimum !== undefined && value < node.minimum) {
151
- return fail(path, expected(`a number >= ${node.minimum}`, value));
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 fail(path, expected(`a number <= ${node.maximum}`, value));
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 fail(path, expected('an object', value));
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
- return fail(path, expected(`one of ${values.join(' | ')}`, value));
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 ? pass(value) : fail(path, expected(JSON.stringify(value), candidate)),
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 fail(path, `no union member matched (${reasons.join(' | ')})`);
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
- ? fail(path, expected('a valid Date', value))
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 fail(path, expected('an ISO-8601 date-time with an offset or Z', value));
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 fail(path, expected('an ISO-8601 date-time', value));
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
- ? fail(path, expected('an ISO-8601 date-time', value))
366
+ ? rejected(path, expected('an ISO-8601 date-time', value), 'string.format', DATE_TIME)
378
367
  : pass(parsed);
379
368
  }
380
- return fail(path, expected('an ISO-8601 date-time', value));
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' ? pass(value) : fail(path, expected('a boolean', value)),
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),