@ultimat3/schema 2.0.0 → 4.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 +11 -1
- package/package.json +1 -1
- package/src/coerce.ts +19 -4
- package/src/iso-date.ts +23 -0
- package/src/provider.ts +4 -2
- 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
|
}
|
|
@@ -81,7 +86,10 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
81
86
|
const source = raw as Record<string, unknown>;
|
|
82
87
|
const out: Record<string, unknown> = { ...source };
|
|
83
88
|
for (const [key, child] of Object.entries(node.properties)) {
|
|
84
|
-
|
|
89
|
+
// `Object.hasOwn`, never `key in source`: `{ ...source }` above already dropped what a
|
|
90
|
+
// prototype carries, so an `in` check put it back — a schema field named `toString` or
|
|
91
|
+
// `valueOf` was coerced from the INHERITED member and handed validation a function.
|
|
92
|
+
if (Object.hasOwn(source, key)) out[key] = coerceNode(child, source[key]);
|
|
85
93
|
}
|
|
86
94
|
return out;
|
|
87
95
|
}
|
|
@@ -111,11 +119,17 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
111
119
|
}
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
/**
|
|
123
|
+
* `Object.create(null)`, for the reason `@ultimat3/http`'s `parseQuery` already uses one: this
|
|
124
|
+
* record is built from caller-controlled keys. On a `{}` literal `out['__proto__'] = …` hits the
|
|
125
|
+
* prototype accessor instead of declaring a key, and every member of `Object.prototype` reads
|
|
126
|
+
* back as present when the client sent nothing.
|
|
127
|
+
*/
|
|
114
128
|
function toRecord(source: QuerySource): Record<string, string | readonly string[] | undefined> {
|
|
129
|
+
const out = Object.create(null) as Record<string, string | readonly string[] | undefined>;
|
|
115
130
|
if (!(source instanceof URLSearchParams)) {
|
|
116
|
-
return
|
|
131
|
+
return Object.assign(out, source);
|
|
117
132
|
}
|
|
118
|
-
const out: Record<string, string | readonly string[]> = {};
|
|
119
133
|
for (const key of new Set(source.keys())) {
|
|
120
134
|
const all = source.getAll(key);
|
|
121
135
|
out[key] = all.length > 1 ? all : (all[0] as string);
|
|
@@ -134,7 +148,8 @@ export function coerceQuery(schema: unknown, source: QuerySource): Record<string
|
|
|
134
148
|
|
|
135
149
|
const out: Record<string, unknown> = { ...record };
|
|
136
150
|
for (const [key, child] of Object.entries(node.properties)) {
|
|
137
|
-
|
|
151
|
+
// See `toRecord`: a declared property is coerced only when the caller actually sent it.
|
|
152
|
+
if (!Object.hasOwn(record, key)) continue;
|
|
138
153
|
const raw = record[key];
|
|
139
154
|
out[key] = coerceNode(child, child.kind === 'array' ? (raw ?? []) : normaliseSingle(raw));
|
|
140
155
|
}
|
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/provider.ts
CHANGED
|
@@ -11,8 +11,10 @@ export interface SchemaProvider {
|
|
|
11
11
|
readonly vendor: string;
|
|
12
12
|
readonly t: TNamespace;
|
|
13
13
|
/**
|
|
14
|
-
* Return the IR for one of this provider's schemas. Required for OpenAPI
|
|
15
|
-
*
|
|
14
|
+
* Return the IR for one of this provider's schemas. Required for OpenAPI, MCP tool schemas
|
|
15
|
+
* and the admin form generator: `toJsonSchema()` calls it unconditionally and throws
|
|
16
|
+
* `X_SCHEMA_UNSUPPORTED` without it. There is no second way to describe a schema — the IR is
|
|
17
|
+
* the one projection surface, and every generator reads it.
|
|
16
18
|
*/
|
|
17
19
|
introspect?(schema: unknown): SchemaNode | undefined;
|
|
18
20
|
}
|
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))
|