@ultimat3/schema 3.0.0 → 4.1.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 +11 -1
- package/package.json +1 -1
- package/src/coerce.ts +5 -0
- package/src/errors.ts +30 -3
- package/src/iso-date.ts +23 -0
- package/src/validators.ts +20 -2
package/CLAUDE.md
CHANGED
|
@@ -15,7 +15,9 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
|
|
|
15
15
|
|
|
16
16
|
Module order (no cycles):
|
|
17
17
|
`describe-value → node → builder → money-value → validators → discriminated-union → provider → t`.
|
|
18
|
-
`standard.ts` and `errors.ts` depend on nothing but each other.
|
|
18
|
+
`standard.ts` and `errors.ts` depend on nothing but each other. `iso-date.ts` imports nothing and
|
|
19
|
+
is imported by `validators.ts` and `coerce.ts` — the two doors a `t.date` string comes through, so
|
|
20
|
+
the rule that a clock time must carry an offset or `Z` has one copy, not one per door.
|
|
19
21
|
|
|
20
22
|
**An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`,
|
|
21
23
|
which is returned to the caller AND interpolated into the log line — and core's logger redacts by
|
|
@@ -75,6 +77,14 @@ Gotchas:
|
|
|
75
77
|
present on output. Object key optionality is derived from that — don't hand-roll it.
|
|
76
78
|
- `AnySchema = Schema<unknown, unknown>` is the general constraint. Never `any`.
|
|
77
79
|
- Unknown object keys are dropped by design; JSON Schema says `additionalProperties: false`.
|
|
80
|
+
- **An object parse reads every declared field with `Object.hasOwn` and answers a null-prototype
|
|
81
|
+
object** — the same two halves `recordSchema` has. A raw `value[key]` read `toString` off the
|
|
82
|
+
PROTOTYPE, so a field named after one was unsatisfiable for every input and its `.default()`
|
|
83
|
+
never fired; a `{}` output let a declared `__proto__` field write through the setter. Never
|
|
84
|
+
reintroduce either half, and never assume a parsed object has `Object.prototype` on it.
|
|
85
|
+
- `t.date` refuses a clock time with no offset and no `Z` (`iso-date.ts`): a zone-less string is a
|
|
86
|
+
different instant per host `TZ`, and `coerceQuery` puts it one query parameter from the wire.
|
|
87
|
+
A date-only string carries no clock time and is UTC by spec, so it still parses.
|
|
78
88
|
- Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit.
|
|
79
89
|
- **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind`
|
|
80
90
|
has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw
|
package/package.json
CHANGED
package/src/coerce.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// HTTP layer has strings that "mean" numbers. Actions, jobs and MCP calls receive real JSON and
|
|
3
3
|
// must never get this leniency.
|
|
4
4
|
|
|
5
|
+
import { isZonelessDateTime } from './iso-date';
|
|
5
6
|
import type { SchemaNode } from './node';
|
|
6
7
|
import { tryIntrospect } from './provider';
|
|
7
8
|
|
|
@@ -56,6 +57,10 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
56
57
|
}
|
|
57
58
|
case 'date': {
|
|
58
59
|
if (typeof raw !== 'string') return raw;
|
|
60
|
+
// Returned untouched rather than converted, this function's standing contract: converting
|
|
61
|
+
// it resolves a zone-less clock time through the CONTAINER's `TZ`, so `?at=2026-08-19T10:00`
|
|
62
|
+
// meant a different instant per pod. Validation states the refusal.
|
|
63
|
+
if (isZonelessDateTime(raw)) return raw;
|
|
59
64
|
const parsed = new Date(raw);
|
|
60
65
|
return Number.isNaN(parsed.getTime()) ? raw : parsed;
|
|
61
66
|
}
|
package/src/errors.ts
CHANGED
|
@@ -4,6 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
import { formatIssues } from './standard';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The same escape `@ultimat3/core`'s `singleLine` performs. **Keep in sync**, and read the reason
|
|
9
|
+
* there — briefly: the 3-line format is line-oriented, and a `cause` may hold a value the caller
|
|
10
|
+
* chose, so a newline in one writes a line an operator reads as a genuine framework message.
|
|
11
|
+
*
|
|
12
|
+
* Copied rather than imported for the same reason the brand symbol above is: `schema` and `core`
|
|
13
|
+
* are both tier 0, so `schema` may not import `core` (imports go DOWN, never sideways). A schema
|
|
14
|
+
* cause is the one most likely to carry a hostile string — it describes the value that failed
|
|
15
|
+
* validation, which is the request body.
|
|
16
|
+
*/
|
|
17
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: escaping them is the point.
|
|
18
|
+
const CONTROL = /[\u0000-\u001f\u007f\u2028\u2029]/g;
|
|
19
|
+
const ESCAPES: Readonly<Record<string, string>> = {
|
|
20
|
+
'\n': String.raw`\n`,
|
|
21
|
+
'\r': String.raw`\r`,
|
|
22
|
+
'\t': String.raw`\t`,
|
|
23
|
+
'\b': String.raw`\b`,
|
|
24
|
+
'\f': String.raw`\f`,
|
|
25
|
+
};
|
|
26
|
+
const singleLine = (text: string): string =>
|
|
27
|
+
text.replace(
|
|
28
|
+
CONTROL,
|
|
29
|
+
(char) => ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
30
|
+
);
|
|
31
|
+
|
|
7
32
|
/** Same well-known symbol `@ultimat3/core` brands with. Keep in sync, never rename. */
|
|
8
33
|
export const ULTIMATE_ERROR_BRAND: unique symbol = Symbol.for('ultimate.error');
|
|
9
34
|
|
|
@@ -82,9 +107,11 @@ export class SchemaError extends Error {
|
|
|
82
107
|
|
|
83
108
|
/** The same 3-line rendering as `UltimateError.format()`. */
|
|
84
109
|
format(): string {
|
|
85
|
-
return [
|
|
86
|
-
|
|
87
|
-
|
|
110
|
+
return [
|
|
111
|
+
`${singleLine(this.code)}: ${singleLine(this.title)}`,
|
|
112
|
+
` cause: ${singleLine(this.cause)}`,
|
|
113
|
+
` fix: ${singleLine(this.fix)}`,
|
|
114
|
+
].join('\n');
|
|
88
115
|
}
|
|
89
116
|
|
|
90
117
|
toJSON(): SchemaErrorJSON {
|
package/src/iso-date.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Single responsibility: the one rule deciding whether a date-time STRING names an instant on its
|
|
2
|
+
// own. Its own file because both the validator and the HTTP coercion have to answer identically —
|
|
3
|
+
// two copies of this pair is how the wire path and the validation path came to disagree.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A clock time, and the zone it is stated in. `2026-08-19T10:00` carries the first and not the
|
|
7
|
+
* second, so `new Date` resolves it through the HOST process's zone: the same wire value is
|
|
8
|
+
* `14:00Z` on a `TZ=America/New_York` pod and `10:00Z` on a `TZ=UTC` one, from one request.
|
|
9
|
+
* That is the framework's "no date without an explicit zone, no ambient default anywhere" rule
|
|
10
|
+
* failing at the parse end rather than the format end.
|
|
11
|
+
*
|
|
12
|
+
* A date-only form carries no clock time and is UTC by specification, so it is not this.
|
|
13
|
+
*
|
|
14
|
+
* The same pair `@ultimat3/time`'s `fromIso` refuses on, character for character: one wire value,
|
|
15
|
+
* one rule, whichever door it comes through.
|
|
16
|
+
*/
|
|
17
|
+
const CLOCK_TIME = /[t ]\d{1,2}:\d{2}/i;
|
|
18
|
+
const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
|
|
19
|
+
|
|
20
|
+
/** What a `t.date` string must not be: a clock time with no offset and no `Z`. */
|
|
21
|
+
export function isZonelessDateTime(value: string): boolean {
|
|
22
|
+
return CLOCK_TIME.test(value) && !UTC_OFFSET.test(value);
|
|
23
|
+
}
|
package/src/validators.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './builder';
|
|
20
20
|
import { expected } from './describe-value';
|
|
21
21
|
import { discriminatedUnionSchema } from './discriminated-union';
|
|
22
|
+
import { isZonelessDateTime } from './iso-date';
|
|
22
23
|
import { type MoneyValue, moneySchema } from './money-value';
|
|
23
24
|
import type { SchemaNode } from './node';
|
|
24
25
|
import type { InferInput, InferOutput, StandardIssue } from './standard';
|
|
@@ -172,9 +173,19 @@ export function objectSchema<S extends Shape>(shape: S): ObjectSchema<S> {
|
|
|
172
173
|
const check: Check<ShapeOutput<S>> = (value, path) => {
|
|
173
174
|
if (!isPlainObject(value)) return fail(path, expected('an object', value));
|
|
174
175
|
const issues: StandardIssue[] = [];
|
|
175
|
-
|
|
176
|
+
// `Object.create(null)`, for `recordSchema`'s reason one screen down: on a `{}` literal
|
|
177
|
+
// `out['__proto__'] = value` hits the `Object.prototype` SETTER, so a DECLARED `__proto__`
|
|
178
|
+
// field parsed to `{}` whose prototype was the caller's object — every key of it then read
|
|
179
|
+
// back off a value nobody sent.
|
|
180
|
+
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
176
181
|
for (const [key, memberCheck] of checks) {
|
|
177
|
-
|
|
182
|
+
// `Object.hasOwn`, never a raw index — the guard `coerce.ts` already applies to the same
|
|
183
|
+
// read. `value['toString']` answered the INHERITED function for an input that carried no
|
|
184
|
+
// such key, so a field named after a prototype member was unsatisfiable for every input,
|
|
185
|
+
// its `.default()` could never fire, and the HTTP path and this one disagreed about what
|
|
186
|
+
// the caller sent.
|
|
187
|
+
const raw = Object.hasOwn(value, key) ? value[key] : undefined;
|
|
188
|
+
const result = memberCheck(raw, [...path, key]);
|
|
178
189
|
if (result.ok) {
|
|
179
190
|
// Unknown keys are dropped, never forwarded — no mass assignment through an action.
|
|
180
191
|
if (result.value !== undefined) out[key] = result.value;
|
|
@@ -349,6 +360,13 @@ const dateSchema: Schema<Date | string | number, Date> = makeSchema<Date | strin
|
|
|
349
360
|
: pass(value);
|
|
350
361
|
}
|
|
351
362
|
if (typeof value === 'string' || typeof value === 'number') {
|
|
363
|
+
// Enforced, not documented: the node publishes `format: 'date-time'` and this refusal has
|
|
364
|
+
// always said ISO-8601, while `new Date` resolved a zone-less clock time through the host
|
|
365
|
+
// process's zone — reachable from the wire, since `coerceQuery` routes a `t.date` field
|
|
366
|
+
// here and a container's `TZ` then decided which instant a query parameter meant.
|
|
367
|
+
if (typeof value === 'string' && isZonelessDateTime(value)) {
|
|
368
|
+
return fail(path, expected('an ISO-8601 date-time with an offset or Z', value));
|
|
369
|
+
}
|
|
352
370
|
const parsed = new Date(value);
|
|
353
371
|
return Number.isNaN(parsed.getTime())
|
|
354
372
|
? fail(path, expected('an ISO-8601 date-time', value))
|