@ultimat3/schema 1.2.0 → 2.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 +90 -0
- package/README.md +54 -3
- package/package.json +2 -1
- package/src/builder.ts +92 -17
- package/src/coerce.ts +41 -7
- package/src/describe-value.ts +71 -0
- package/src/discriminated-union.ts +117 -0
- package/src/errors.ts +20 -0
- package/src/index.ts +15 -12
- package/src/json-schema.ts +91 -9
- package/src/money-value.ts +142 -0
- package/src/node.ts +29 -0
- package/src/t.ts +25 -8
- package/src/validators.ts +102 -49
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @ultimat3/schema — agent notes
|
|
2
|
+
|
|
3
|
+
Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
|
|
4
|
+
|
|
5
|
+
| Rule | |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Deps | none (`bun-types` only) |
|
|
8
|
+
| Errors | `SchemaError` mirrors `UltimateError` field-for-field; keep `Symbol.for('ultimate.error')` |
|
|
9
|
+
| New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` |
|
|
10
|
+
| IR | every schema carries `.node: SchemaNode`; generators read that, never the closure |
|
|
11
|
+
| **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
|
|
12
|
+
| Coercion | HTTP boundary only — never call it from actions, jobs or MCP |
|
|
13
|
+
| Exports | explicit in `src/index.ts`; no `export *`; a namespace member and its free function ship together (`t.nullable`/`nullableSchema`) |
|
|
14
|
+
| Re-exports | `action`, `query`, `jobs`, `entity` re-export `t` verbatim so an authoring file imports one package — never let them wrap or copy it |
|
|
15
|
+
|
|
16
|
+
Module order (no cycles):
|
|
17
|
+
`describe-value → node → builder → money-value → validators → discriminated-union → provider → t`.
|
|
18
|
+
`standard.ts` and `errors.ts` depend on nothing but each other.
|
|
19
|
+
|
|
20
|
+
**An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`,
|
|
21
|
+
which is returned to the caller AND interpolated into the log line — and core's logger redacts by
|
|
22
|
+
KEY, so a value baked into a string has no key left to redact. `'password'` being in `redactedKeys`
|
|
23
|
+
did not help: `expected(…, value)` had already written `received "hunter2"` before the logger saw
|
|
24
|
+
it. Every rejected value goes through `describeValue`, which reports length and type and nothing
|
|
25
|
+
else. No dev flag re-enables the echo — one misconfigured environment is the same breach, and a dev
|
|
26
|
+
overlay already holds the raw body. `describe-value.test.ts` is the enforcement.
|
|
27
|
+
|
|
28
|
+
`X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
|
|
29
|
+
is parsed. A member with no literal at the discriminant, or a second member claiming a tag the
|
|
30
|
+
first already owns, can never be routed to — so it is wrong for every input, and the first import
|
|
31
|
+
of the authoring file is the earliest honest place to say so.
|
|
32
|
+
|
|
33
|
+
`SCHEMA_ERROR_CODES` in `errors.ts` is data, not a `registerErrorCodes()` call — this package is
|
|
34
|
+
tier 0 and cannot import `@ultimat3/core` to reach it. `@ultimat3/core`'s `schema-error-codes.ts`
|
|
35
|
+
carries a duplicate of these titles and registers them unconditionally, so every process gets the
|
|
36
|
+
real titles just by importing core. Add a code here **and** update that duplicate in the same
|
|
37
|
+
change — `schema-error-codes-pin.test.ts` in `@ultimat3/cli` fails the build if they disagree.
|
|
38
|
+
|
|
39
|
+
`MoneyValue` in `money-value.ts` — its own file, because it is the only builtin whose *shape* other
|
|
40
|
+
packages alias — is the framework's **one** declaration of a money value. Tier 0 is
|
|
41
|
+
the only tier every package may import, and `@ultimat3/money`'s `Money` and `@ultimat3/entity`'s
|
|
42
|
+
`MoneyValue` are aliases of it. Never let either restate the shape: it was three structural copies,
|
|
43
|
+
entity's had a `bigint` `minor`, and a row that layer decoded then failed both `t.money` and
|
|
44
|
+
`JSON.stringify`. `minor` stays a `number` for the same reason it is a `number` here — this node is
|
|
45
|
+
the OpenAPI contract, and money crosses every wire the framework projects.
|
|
46
|
+
|
|
47
|
+
`MoneyValue.scale` is the **optional** decimal exponent `minor` counts in, `0…MAX_MONEY_SCALE`
|
|
48
|
+
(15, the last power of ten that is itself a safe integer). Absent means the currency's own minor
|
|
49
|
+
unit, which is every value that predates it — so `{ minor, currency }` parses to exactly
|
|
50
|
+
`{ minor, currency }`, key for key, and the validator adds nothing. What a legal scale is lives in
|
|
51
|
+
`isMoneyScale` here and nowhere else; `@ultimat3/money` imports it rather than restating the
|
|
52
|
+
bound. Adding it to the type means adding it in three more places in the same change — the node's
|
|
53
|
+
`properties`, `json-schema.ts` (optional, never `required`, or a generated client refuses a value
|
|
54
|
+
this validator accepts) and `coerce.ts` (a query string carries it as text like everything else).
|
|
55
|
+
|
|
56
|
+
`MoneyValue.currency` is bounded by `isCurrencyCode`, `isMoneyScale`'s twin, over
|
|
57
|
+
`CURRENCY_CODE_PATTERN` — the pattern **source**, exported because the two projections that cannot
|
|
58
|
+
call a predicate need the string: `json-schema.ts`'s published `pattern` and `@ultimat3/entity`'s
|
|
59
|
+
Postgres `~` CHECK (`currencyCheck`). It was four copies of `^[A-Z]{3}$` across three packages,
|
|
60
|
+
each individually correct, and only a psql session would have seen them diverge. Keep the pattern
|
|
61
|
+
inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically — anchors, a literal
|
|
62
|
+
class, a bounded repetition. `@ultimat3/entity`'s `currency-check.live.test.ts` is what proves a
|
|
63
|
+
real server still reads it the way the predicate does; a `\d` or a lookahead is where that stops.
|
|
64
|
+
|
|
65
|
+
`t` delegates through `schemaProvider()` on every property access — that is what makes
|
|
66
|
+
`configureSchemaProvider()` work for modules that already imported `t`. Do not cache members.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
bun test # from packages/schema
|
|
70
|
+
bun run typecheck
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Gotchas:
|
|
74
|
+
- `Schema<In, Out>` splits input from output: `.default()` makes the key optional on input and
|
|
75
|
+
present on output. Object key optionality is derived from that — don't hand-roll it.
|
|
76
|
+
- `AnySchema = Schema<unknown, unknown>` is the general constraint. Never `any`.
|
|
77
|
+
- Unknown object keys are dropped by design; JSON Schema says `additionalProperties: false`.
|
|
78
|
+
- Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit.
|
|
79
|
+
- **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind`
|
|
80
|
+
has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw
|
|
81
|
+
value through, `@ultimat3/action`'s `sample-input.ts` answers `null` — and they live in packages
|
|
82
|
+
a schema change is not allowed to edit. `refinements` and `discriminant` are both fields for that
|
|
83
|
+
reason: a refined string still reads as a string, and a discriminated union still reads as a
|
|
84
|
+
union, everywhere. `lazy` and `tuple` cannot be — which is why neither has shipped.
|
|
85
|
+
- A refinement is carried as a **declaration** (`name`, `message`, `path`), never a closure: a
|
|
86
|
+
predicate cannot cross into OpenAPI or an MCP tool schema, and `refine`'s `message` is rendered
|
|
87
|
+
verbatim on both — so it states the rule and never interpolates a value.
|
|
88
|
+
- `@ultimat3/action`'s `sampleInput` does not read `refinements`, so a contract test over a refined
|
|
89
|
+
schema reports `X_CONTRACT_DRIFT` exactly as it already does for a bare `pattern`. Documented
|
|
90
|
+
there; fixing it is that package's change, not this one's.
|
package/README.md
CHANGED
|
@@ -9,7 +9,9 @@ type, a JSON Schema, an OpenAPI body, an MCP tool `inputSchema` and HTTP query c
|
|
|
9
9
|
| the blessed `t` namespace | `t.ts` |
|
|
10
10
|
| builtin validators behind `t` | `validators.ts` |
|
|
11
11
|
| the introspectable IR every generator walks | `node.ts` |
|
|
12
|
-
| `Schema` factory, `Infer` machinery | `builder.ts` |
|
|
12
|
+
| `Schema` factory, `Infer` machinery, `.refine()` | `builder.ts` |
|
|
13
|
+
| how a rejected value is described — its shape, never its content | `describe-value.ts` |
|
|
14
|
+
| a union routed by one literal key | `discriminated-union.ts` |
|
|
13
15
|
| `configureSchemaProvider()` — the swap point | `provider.ts` |
|
|
14
16
|
| schema → JSON Schema (OpenAPI + MCP) | `json-schema.ts` |
|
|
15
17
|
| HTTP-boundary coercion, kept out of validation | `coerce.ts` |
|
|
@@ -26,6 +28,7 @@ export const publishPost = t.object({
|
|
|
26
28
|
title: t.string.min(3).max(80),
|
|
27
29
|
tags: t.array(t.slug),
|
|
28
30
|
price: t.money, // { minor: 1999, currency: 'EUR' } — never a float
|
|
31
|
+
// { minor: 2, currency: 'USD', scale: 6 } is $0.000002
|
|
29
32
|
timeZone: t.timezone, // real IANA validation, not an annotation
|
|
30
33
|
cursor: t.optional(t.cursor),
|
|
31
34
|
});
|
|
@@ -35,7 +38,7 @@ type PublishPost = Infer<typeof publishPost>;
|
|
|
35
38
|
|
|
36
39
|
| Value schemas | Factories | Methods |
|
|
37
40
|
|---|---|---|
|
|
38
|
-
| `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `record` `optional` `nullable` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` |
|
|
41
|
+
| `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `discriminatedUnion` `record` `optional` `nullable` `refine` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` `.refine(r)` |
|
|
39
42
|
| `money` `timezone` `locale` `slug` `cursor` | `object(...).extend/.pick/.omit` | `string.min/.max/.pattern`, `number.min/.max/.int` |
|
|
40
43
|
|
|
41
44
|
`nullable` is a value the row holds; `optional` is the caller omitting the key. Both ship as a
|
|
@@ -43,6 +46,46 @@ namespace member (`t.nullable`) and a free function (`nullableSchema`) — symme
|
|
|
43
46
|
|
|
44
47
|
Unknown object keys are **dropped**, never forwarded — an action cannot be mass-assigned.
|
|
45
48
|
|
|
49
|
+
### Cross-field rules live on the schema
|
|
50
|
+
|
|
51
|
+
A rule the IR cannot state structurally still belongs to the schema, or it moves into a handler and
|
|
52
|
+
disappears from `openapi.json`, the MCP tool schema, the typed client and every form binding at
|
|
53
|
+
once — axiom 2 broken for every such rule in the product.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
const booking = t.object({ startDate: t.date, endDate: t.date }).refine({
|
|
57
|
+
name: 'end-after-start', // stable id → `x-ultimate-refinements`
|
|
58
|
+
message: 'endDate must be after startDate', // the rule, never the value
|
|
59
|
+
path: ['endDate'], // which field the issue lands on
|
|
60
|
+
check: (value) => value.endDate > value.startDate,
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The predicate runs on the **parsed** output and only after the shape passed, so it compares coerced
|
|
65
|
+
values and never defends against a type the schema already refused. What ships in the IR is the
|
|
66
|
+
declaration, not the closure: `node.refinements`, projected as an `x-ultimate-refinements`
|
|
67
|
+
extension **and** appended to `description`, which is the only field an LLM reading a tool schema
|
|
68
|
+
is guaranteed to see. `.refine()` returns a plain `Schema`, so refining after `extend`/`pick`/`omit`
|
|
69
|
+
— which rebuild from the shape and would drop the rule — is a type error rather than a comment.
|
|
70
|
+
|
|
71
|
+
### `discriminatedUnion` names the branch it judged
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const body = t.discriminatedUnion(
|
|
75
|
+
'kind',
|
|
76
|
+
t.object({ kind: t.literal('post'), slug: t.slug }),
|
|
77
|
+
t.object({ kind: t.literal('page'), title: t.string.min(3) }),
|
|
78
|
+
);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`t.union` reports every member's reasons at once, so one bad field in a `post` body arrives as N
|
|
82
|
+
contradictory complaints naming fields the caller never sent. This routes on `kind` first and
|
|
83
|
+
reports that branch's issues only. A member with no literal (or enum) at the discriminant, or two
|
|
84
|
+
members claiming one tag, is `X_SCHEMA_DISCRIMINANT_INVALID` **where the union is built** — a
|
|
85
|
+
branch nothing can route to is wrong for every input, not for one request. The node stays
|
|
86
|
+
`kind: 'union'` with a `discriminant` beside it, so every existing IR consumer keeps working, and
|
|
87
|
+
JSON Schema gains `discriminator: { propertyName }`.
|
|
88
|
+
|
|
46
89
|
**You rarely import this package.** `action`, `query`, `jobs` and `entity` each re-export the same
|
|
47
90
|
`t`, so an authoring file imports its primitive and nothing else. Import `@ultimat3/schema` directly
|
|
48
91
|
only where there is no primitive to hang the schema off — app config, a standalone view schema, a
|
|
@@ -56,10 +99,18 @@ parse(t.object({ postId: t.uuid, notify: t.boolean }), { postId: 'abc', notify:
|
|
|
56
99
|
|
|
57
100
|
```text
|
|
58
101
|
X_VALIDATION_FAILED: value did not match its schema
|
|
59
|
-
cause: postId: expected a uuid, received
|
|
102
|
+
cause: postId: expected a uuid, received a string of 3 characters; notify: expected a boolean, received a string of 3 characters
|
|
60
103
|
fix: send input with the field(s) named in cause corrected to the expected type
|
|
61
104
|
```
|
|
62
105
|
|
|
106
|
+
**An issue message names the shape of the rejected value, never its content**, and `received` on
|
|
107
|
+
the issue is empty for the same reason. `@ultimat3/http` folds these messages into
|
|
108
|
+
`X_BODY_INVALID`'s `cause`, which is returned to the caller *and* written to the log line — where
|
|
109
|
+
the logger redacts by key and a value baked into a string has no key left to redact. Echoing the
|
|
110
|
+
value meant a password-strength rule wrote every mistyped password to the central log index in
|
|
111
|
+
cleartext. There is no dev-only escape hatch: a flag is one misconfigured environment away from
|
|
112
|
+
being the same breach. `describe-value.ts` owns the rule and `describe-value.test.ts` enforces it.
|
|
113
|
+
|
|
63
114
|
`error.issues` is `{ path, expected, received, message }[]` with paths like `items[0].price`;
|
|
64
115
|
`formatIssues()` renders one line per issue for the dev overlay. `validate()` never throws and
|
|
65
116
|
returns the Standard Schema result. Async libraries: `parseAsync()` / `validateAsync()`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/schema",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
package/src/builder.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Single responsibility: the `Schema` type every builtin validator returns, and the factory
|
|
2
2
|
// that turns a check function plus an IR node into a Standard-Schema-conforming object.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import type
|
|
4
|
+
import { describeValue } from './describe-value';
|
|
5
|
+
import { SchemaError, ValidationFailedError, type ValidationIssue } from './errors';
|
|
6
|
+
import type { SchemaNode, SchemaRefinement } from './node';
|
|
6
7
|
import {
|
|
7
8
|
formatPath,
|
|
8
9
|
type InferInput,
|
|
@@ -42,21 +43,28 @@ export function failWith(issues: readonly StandardIssue[]): CheckErr {
|
|
|
42
43
|
return { ok: false, issues };
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
-
export function
|
|
47
|
-
return
|
|
46
|
+
/** An object with own keys — not null, not an array. The gate every object-ish check opens with. */
|
|
47
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
/**
|
|
52
|
+
* A rule the IR cannot state structurally — `endDate > startDate`, `total === sum(lines)`. It
|
|
53
|
+
* lives on the schema rather than in a handler so it reaches OpenAPI, the MCP tool schema, the
|
|
54
|
+
* typed client and the form binding from the one declaration, like every other constraint.
|
|
55
|
+
*
|
|
56
|
+
* `message` is authored by the developer and rendered verbatim, so it must state the RULE and
|
|
57
|
+
* never the value — see `describe-value.ts` for why an issue message is a public surface.
|
|
58
|
+
*/
|
|
59
|
+
export interface Refinement<Out> {
|
|
60
|
+
/** Stable machine id, kebab-case: `end-after-start`. Projected as `x-ultimate-refinements`. */
|
|
61
|
+
readonly name: string;
|
|
62
|
+
/** The rule as a sentence, e.g. `endDate must be after startDate`. Never interpolate a value. */
|
|
63
|
+
readonly message: string;
|
|
64
|
+
/** Runs on the PARSED output, so a cross-field rule compares coerced values, not raw input. */
|
|
65
|
+
readonly check: (value: Out) => boolean;
|
|
66
|
+
/** Which field the issue is reported against. Absent reports it against the object itself. */
|
|
67
|
+
readonly path?: readonly string[] | undefined;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
export interface Schema<In = unknown, Out = In> extends StandardSchemaV1<In, Out> {
|
|
@@ -73,6 +81,12 @@ export interface Schema<In = unknown, Out = In> extends StandardSchemaV1<In, Out
|
|
|
73
81
|
nullable(): Schema<In | null, Out | null>;
|
|
74
82
|
default(value: Out): Schema<In | undefined, Out>;
|
|
75
83
|
describe(description: string): Schema<In, Out>;
|
|
84
|
+
/**
|
|
85
|
+
* Attach a cross-field rule. Returns a plain `Schema` on purpose — `extend`/`pick`/`omit`
|
|
86
|
+
* rebuild from the shape and would silently drop the refinement, so refining last is a type
|
|
87
|
+
* error to get wrong rather than a comment asking you not to.
|
|
88
|
+
*/
|
|
89
|
+
refine(refinement: Refinement<Out>): Schema<In, Out>;
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
/** The general constraint for "some schema". Methods are bivariant, so this is a real supertype. */
|
|
@@ -102,6 +116,42 @@ export type ShapeInput<S extends Shape> = Simplify<
|
|
|
102
116
|
}
|
|
103
117
|
>;
|
|
104
118
|
|
|
119
|
+
/**
|
|
120
|
+
* One FRESH copy of a default per parse. `.default([])` handed every parse the same array, so a
|
|
121
|
+
* handler pushing onto it made that push the next request's starting value for the life of the
|
|
122
|
+
* process — cross-request data bleed declared in a schema.
|
|
123
|
+
*
|
|
124
|
+
* Primitives are immutable, so they are still shared and cost nothing. Everything else is decided
|
|
125
|
+
* ONCE, here at declaration: a value `structuredClone` can copy is copied per parse, and one it
|
|
126
|
+
* cannot is refused at the first import of the authoring file — the same "wrong for every input,
|
|
127
|
+
* so say so where it is written" rule `X_SCHEMA_DISCRIMINANT_INVALID` follows. What that refuses
|
|
128
|
+
* was never a working declaration: `node.default` is published as JSON, so a default carrying a
|
|
129
|
+
* function or a symbol could not reach OpenAPI either.
|
|
130
|
+
*
|
|
131
|
+
* Known narrow cost: a class instance clones to a plain object, so a default that relied on its
|
|
132
|
+
* prototype loses it. Schema defaults are wire values, which is the only thing `node.default` can
|
|
133
|
+
* publish, so that shape was already outside what a default may mean.
|
|
134
|
+
*/
|
|
135
|
+
function defaultFactory<Out>(fallback: Out): () => Out {
|
|
136
|
+
if (fallback === null || typeof fallback !== 'object') return () => fallback;
|
|
137
|
+
try {
|
|
138
|
+
structuredClone(fallback);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new SchemaError({
|
|
141
|
+
code: 'X_SCHEMA_DEFAULT_UNSHAREABLE',
|
|
142
|
+
cause: `default() received ${describeValue(fallback)}, which structuredClone cannot copy`,
|
|
143
|
+
fix: 'pass a JSON-shaped default (plain object, array, Date, Map, Set), or drop .default() and answer the absent value in the handler',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return () => structuredClone(fallback);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The default declaration, dropped — for a wrapper that can no longer reach it. */
|
|
150
|
+
function withoutDefault(node: SchemaNode): SchemaNode {
|
|
151
|
+
const { hasDefault: _hasDefault, default: _default, ...rest } = node;
|
|
152
|
+
return rest;
|
|
153
|
+
}
|
|
154
|
+
|
|
105
155
|
function toIssues(result: CheckErr): readonly ValidationIssue[] {
|
|
106
156
|
return result.issues.map((issue) => ({
|
|
107
157
|
path: formatPath(issue.path),
|
|
@@ -133,7 +183,10 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
|
|
|
133
183
|
},
|
|
134
184
|
optional(): Schema<In | undefined, Out | undefined> {
|
|
135
185
|
return makeSchema<In | undefined, Out | undefined>(
|
|
136
|
-
|
|
186
|
+
// `.default(x).optional()` published `default: x` while `parse` answered `undefined`:
|
|
187
|
+
// this wrapper short-circuits `undefined` before the default is ever reached, so a client
|
|
188
|
+
// honouring the published default assumed a value the server never produced.
|
|
189
|
+
withoutDefault({ ...node, optional: true }),
|
|
137
190
|
(value, path) => (value === undefined ? pass(undefined) : check(value, path)),
|
|
138
191
|
);
|
|
139
192
|
},
|
|
@@ -143,14 +196,36 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
|
|
|
143
196
|
);
|
|
144
197
|
},
|
|
145
198
|
default(fallback: Out): Schema<In | undefined, Out> {
|
|
199
|
+
const fresh = defaultFactory(fallback);
|
|
146
200
|
return makeSchema<In | undefined, Out>(
|
|
201
|
+
// The node keeps the DECLARATION, never a copy: `node.default` is what OpenAPI, the MCP
|
|
202
|
+
// tool schema and the typed client publish, and they describe what was written.
|
|
147
203
|
{ ...node, hasDefault: true, default: fallback },
|
|
148
|
-
(value, path) => (value === undefined ? pass(
|
|
204
|
+
(value, path) => (value === undefined ? pass(fresh()) : check(value, path)),
|
|
149
205
|
);
|
|
150
206
|
},
|
|
151
207
|
describe(description: string): Schema<In, Out> {
|
|
152
208
|
return makeSchema<In, Out>({ ...node, description }, check);
|
|
153
209
|
},
|
|
210
|
+
refine(refinement: Refinement<Out>): Schema<In, Out> {
|
|
211
|
+
const declared: SchemaRefinement = {
|
|
212
|
+
name: refinement.name,
|
|
213
|
+
message: refinement.message,
|
|
214
|
+
...(refinement.path === undefined ? {} : { path: [...refinement.path] }),
|
|
215
|
+
};
|
|
216
|
+
return makeSchema<In, Out>(
|
|
217
|
+
{ ...node, refinements: [...(node.refinements ?? []), declared] },
|
|
218
|
+
(value, path) => {
|
|
219
|
+
// Shape first: a predicate written against `Out` must never be handed an unparsed
|
|
220
|
+
// value, or every refinement grows a defensive typeof the schema already performed.
|
|
221
|
+
const result = check(value, path);
|
|
222
|
+
if (!result.ok) return result;
|
|
223
|
+
return refinement.check(result.value)
|
|
224
|
+
? result
|
|
225
|
+
: fail([...path, ...(refinement.path ?? [])], refinement.message);
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
},
|
|
154
229
|
};
|
|
155
230
|
return schema;
|
|
156
231
|
}
|
package/src/coerce.ts
CHANGED
|
@@ -8,6 +8,22 @@ import { tryIntrospect } from './provider';
|
|
|
8
8
|
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
9
9
|
const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
|
10
10
|
|
|
11
|
+
/** A numeric string as a number, or `undefined` for anything that is not confidently one. */
|
|
12
|
+
function numeric(raw: unknown): number | undefined {
|
|
13
|
+
if (typeof raw !== 'string' || raw.trim() === '') return undefined;
|
|
14
|
+
const value = Number(raw);
|
|
15
|
+
return Number.isFinite(value) ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A truthy/falsy query-string spelling as a boolean, or the raw value when it is neither. */
|
|
19
|
+
function booleanish(raw: unknown): unknown {
|
|
20
|
+
if (typeof raw !== 'string') return raw;
|
|
21
|
+
const lowered = raw.toLowerCase();
|
|
22
|
+
if (TRUE_VALUES.has(lowered)) return true;
|
|
23
|
+
if (FALSE_VALUES.has(lowered)) return false;
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
export type QuerySource =
|
|
12
28
|
| URLSearchParams
|
|
13
29
|
| Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
@@ -25,11 +41,17 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
25
41
|
const value = Number(raw);
|
|
26
42
|
return raw.trim() !== '' && Number.isFinite(value) ? value : raw;
|
|
27
43
|
}
|
|
28
|
-
case 'boolean':
|
|
44
|
+
case 'boolean':
|
|
45
|
+
return booleanish(raw);
|
|
46
|
+
case 'literal': {
|
|
47
|
+
// Toward the literal's OWN type, because `literalSchema` compares with `===`: without this
|
|
48
|
+
// a numeric or boolean `t.literal` was unsatisfiable over its own GET route — `t.literal(2)`
|
|
49
|
+
// received `"2"` and the endpoint 400d on every request — while the identical declaration
|
|
50
|
+
// worked over an action's JSON body and over MCP. A string literal needs nothing, which is
|
|
51
|
+
// exactly why the gap read as arbitrary.
|
|
29
52
|
if (typeof raw !== 'string') return raw;
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
if (FALSE_VALUES.has(lowered)) return false;
|
|
53
|
+
if (typeof node.literal === 'number') return numeric(raw) ?? raw;
|
|
54
|
+
if (typeof node.literal === 'boolean') return booleanish(raw);
|
|
33
55
|
return raw;
|
|
34
56
|
}
|
|
35
57
|
case 'date': {
|
|
@@ -44,7 +66,11 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
44
66
|
}
|
|
45
67
|
case 'record': {
|
|
46
68
|
if (typeof raw !== 'object' || node.valueNode === undefined) return raw;
|
|
47
|
-
|
|
69
|
+
// A null prototype for the reason `recordSchema` uses one: on a `{}` literal, assigning
|
|
70
|
+
// `out['__proto__']` hits the `Object.prototype` SETTER and the key vanishes, so the
|
|
71
|
+
// record validator's deliberate refusal of it never ran — the key was reported absent
|
|
72
|
+
// rather than rejected, on the one path (HTTP query) where it is caller-controlled.
|
|
73
|
+
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
48
74
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
49
75
|
out[key] = coerceNode(node.valueNode, value);
|
|
50
76
|
}
|
|
@@ -62,8 +88,16 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
62
88
|
case 'money': {
|
|
63
89
|
if (typeof raw !== 'object') return raw;
|
|
64
90
|
const source = raw as Record<string, unknown>;
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
// Through `numeric` for the same reason `scale` is: `Number('')` is 0, so a blank amount
|
|
92
|
+
// field converted here would reach the validator as a legitimate zero and book an empty
|
|
93
|
+
// price input as free. A blank stays a blank and fails validation, which is the real error.
|
|
94
|
+
const rawMinor = source['minor'];
|
|
95
|
+
const minor = typeof rawMinor === 'string' ? numeric(rawMinor) : rawMinor;
|
|
96
|
+
if (typeof minor !== 'number' || !Number.isFinite(minor)) return raw;
|
|
97
|
+
// `scale` arrives as text from a query string exactly as `minor` does. Left a string it
|
|
98
|
+
// would fail validation on a value whose `minor` the same request just had converted.
|
|
99
|
+
const scale = numeric(source['scale']);
|
|
100
|
+
return { ...source, minor, ...(scale === undefined ? {} : { scale }) };
|
|
67
101
|
}
|
|
68
102
|
case 'union': {
|
|
69
103
|
// Only unambiguous single-kind unions (e.g. `number | undefined`) are safe to coerce.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Single responsibility: render a REJECTED value as its shape, and the `expected X, received Y`
|
|
2
|
+
// line every builtin validator fails with. Its whole job is the rule in `describeValue`'s doc
|
|
3
|
+
// comment — the rejected value's content never appears in an issue message. Its own file so that
|
|
4
|
+
// rule has one place to live and one test file to enforce it.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shape of a rejected value — **never its content**.
|
|
8
|
+
*
|
|
9
|
+
* WHY: an issue message is not a private diagnostic. `@ultimat3/http` folds these messages into
|
|
10
|
+
* `X_BODY_INVALID`'s `cause`, which is returned to the caller AND written to the log line; the
|
|
11
|
+
* logger redacts `fields`/`contextFields` by key, and a value baked into a message string has no
|
|
12
|
+
* key left to redact. Echoing the value meant a signup form with a password-strength rule wrote
|
|
13
|
+
* every mistyped password to the central log index in cleartext (30-day retention) and into the
|
|
14
|
+
* user's own network tab — same for a card number, an SSN, or an API key pasted in the wrong box.
|
|
15
|
+
*
|
|
16
|
+
* So: length and type, which is what a min/max/type violation actually needs, and nothing else.
|
|
17
|
+
* There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away
|
|
18
|
+
* from being the same breach, and a dev overlay already holds the raw request body.
|
|
19
|
+
*
|
|
20
|
+
* Constants are exempt only where they carry no caller data: `undefined`, `null`, `NaN` and the
|
|
21
|
+
* infinities name themselves because "received a number" for a `NaN` reads as a lie.
|
|
22
|
+
*/
|
|
23
|
+
export function describeValue(value: unknown): string {
|
|
24
|
+
if (value === undefined) return 'undefined';
|
|
25
|
+
if (value === null) return 'null';
|
|
26
|
+
switch (typeof value) {
|
|
27
|
+
case 'string':
|
|
28
|
+
// Code units, not code points: the length checks in `validators.ts` use `.length` too, so a
|
|
29
|
+
// message quoting a different count than the rule that rejected it would send an agent
|
|
30
|
+
// chasing an off-by-one that is not there.
|
|
31
|
+
return countOf(value.length, 'string', 'character');
|
|
32
|
+
case 'number':
|
|
33
|
+
return describeNumber(value);
|
|
34
|
+
case 'boolean':
|
|
35
|
+
return 'a boolean';
|
|
36
|
+
case 'bigint':
|
|
37
|
+
return 'a bigint';
|
|
38
|
+
case 'symbol':
|
|
39
|
+
return 'a symbol';
|
|
40
|
+
case 'function':
|
|
41
|
+
return 'a function';
|
|
42
|
+
default:
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(value)) return countOf(value.length, 'array', 'item');
|
|
46
|
+
// `getTime()` rather than a value: an invalid Date is the one Date fact a caller can act on.
|
|
47
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'an invalid Date' : 'a Date';
|
|
48
|
+
return 'an object';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function describeNumber(value: number): string {
|
|
52
|
+
if (Number.isNaN(value)) return 'NaN';
|
|
53
|
+
if (value === Number.POSITIVE_INFINITY) return 'Infinity';
|
|
54
|
+
if (value === Number.NEGATIVE_INFINITY) return '-Infinity';
|
|
55
|
+
return 'a number';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function countOf(size: number, noun: string, unit: string): string {
|
|
59
|
+
if (size === 0) return `an empty ${noun}`;
|
|
60
|
+
const article = noun === 'array' ? 'an' : 'a';
|
|
61
|
+
return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `expected a uuid, received a string of 3 characters` — the message an agent can act on without
|
|
66
|
+
* guessing, and without the value ever leaving the process. `what` is authored by the schema, so
|
|
67
|
+
* it may say anything; the second half is `describeValue` and may not.
|
|
68
|
+
*/
|
|
69
|
+
export function expected(what: string, value: unknown): string {
|
|
70
|
+
return `expected ${what}, received ${describeValue(value)}`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Single responsibility: a union that dispatches on one literal key instead of trying every
|
|
2
|
+
// member. Its own file because the dispatch table and its two authoring refusals are the whole of
|
|
3
|
+
// it, and `validators.ts` is already at the size where one more composite stops being readable.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type AnySchema,
|
|
7
|
+
type Check,
|
|
8
|
+
checkOf,
|
|
9
|
+
fail,
|
|
10
|
+
isPlainObject,
|
|
11
|
+
makeSchema,
|
|
12
|
+
type Schema,
|
|
13
|
+
} from './builder';
|
|
14
|
+
import { expected } from './describe-value';
|
|
15
|
+
import { DiscriminantInvalidError } from './errors';
|
|
16
|
+
import type { SchemaNode } from './node';
|
|
17
|
+
import type { InferInput, InferOutput } from './standard';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a tag can be — exactly what `SchemaNode.literal` and `SchemaNode.values` can hold. Spelled
|
|
21
|
+
* out rather than left `unknown` because these reach a `cause:` through `String()`, and a value an
|
|
22
|
+
* app controls reaching a refusal's own text is how three constructors learned to throw instead
|
|
23
|
+
* of refusing (`bun run error-render`).
|
|
24
|
+
*/
|
|
25
|
+
type SchemaTag = string | number | boolean | null | undefined;
|
|
26
|
+
|
|
27
|
+
/** The literal(s) a member declares at the discriminant, or `undefined` if it declares none. */
|
|
28
|
+
function declaredValues(node: SchemaNode, discriminant: string): readonly SchemaTag[] | undefined {
|
|
29
|
+
const child = node.properties?.[discriminant];
|
|
30
|
+
if (child === undefined) return undefined;
|
|
31
|
+
// `kind` gates rather than `literal !== undefined`: `t.literal(false)` is a legal discriminant
|
|
32
|
+
// and a truthiness test would have called it undeclared.
|
|
33
|
+
if (child.kind === 'literal') return [child.literal];
|
|
34
|
+
// An enum member is a legal discriminant too — one branch owning several codes is a real shape
|
|
35
|
+
// (`status: t.enum(['queued', 'running'])`), and refusing it would force a duplicated branch.
|
|
36
|
+
if (child.kind === 'enum' && child.values !== undefined && child.values.length > 0) {
|
|
37
|
+
return [...child.values];
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function refuseUndeclared(discriminant: string, index: number, kind: string): never {
|
|
43
|
+
throw new DiscriminantInvalidError({
|
|
44
|
+
cause: `member #${index} of discriminatedUnion("${discriminant}") — IR kind "${kind}" — declares no literal at "${discriminant}"`,
|
|
45
|
+
fix: `give member #${index} a literal discriminant, t.object({ ${discriminant}: t.literal('…'), … }), or use t.union(...) if the members share no key`,
|
|
46
|
+
meta: { discriminant, index, kind },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function refuseDuplicate(discriminant: string, index: number, tag: SchemaTag): never {
|
|
51
|
+
throw new DiscriminantInvalidError({
|
|
52
|
+
cause: `member #${index} of discriminatedUnion("${discriminant}") claims the tag ${String(tag)}, which an earlier member already claims`,
|
|
53
|
+
fix: `give member #${index} its own value at "${discriminant}", or merge the two members into one`,
|
|
54
|
+
meta: { discriminant, index, tag: String(tag) },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `post | page | 2 | false` — every accepted tag, in declaration order. Developer data only. */
|
|
59
|
+
function renderKnown(known: readonly SchemaTag[]): string {
|
|
60
|
+
return known.map((value) => String(value)).join(' | ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A union whose branch is chosen by one key, not by trying every member in turn.
|
|
65
|
+
*
|
|
66
|
+
* Two things `t.union(...)` cannot do. The message: a failure reports the branch the discriminant
|
|
67
|
+
* NAMED — `t.union` reports every branch's reasons at once, so a typo in a `post` body arrived as
|
|
68
|
+
* N contradictory complaints and the field that was actually wrong was named in none of them. The
|
|
69
|
+
* IR: `discriminator.propertyName` reaches OpenAPI and the MCP tool schema, so a code generator
|
|
70
|
+
* emits one tagged type instead of an untagged `anyOf`.
|
|
71
|
+
*
|
|
72
|
+
* The node stays `kind: 'union'` with a `discriminant` beside it — see `node.ts` for why a new
|
|
73
|
+
* `SchemaKind` would have degraded silently in every consumer that already handles unions.
|
|
74
|
+
*/
|
|
75
|
+
export function discriminatedUnionSchema<S extends readonly [AnySchema, ...AnySchema[]]>(
|
|
76
|
+
discriminant: string,
|
|
77
|
+
...members: S
|
|
78
|
+
): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
|
|
79
|
+
type Out = InferOutput<S[number]>;
|
|
80
|
+
const branches = new Map<unknown, Check<Out>>();
|
|
81
|
+
const known: SchemaTag[] = [];
|
|
82
|
+
|
|
83
|
+
for (const [index, member] of members.entries()) {
|
|
84
|
+
const values = declaredValues(member.node, discriminant);
|
|
85
|
+
if (values === undefined) refuseUndeclared(discriminant, index, member.node.kind);
|
|
86
|
+
const check = checkOf(member) as Check<Out>;
|
|
87
|
+
for (const value of values) {
|
|
88
|
+
// Letting the first declaration win would leave a member that can never run, which is the
|
|
89
|
+
// same defect as an unreachable case and is invisible until the wrong branch validates.
|
|
90
|
+
if (branches.has(value)) refuseDuplicate(discriminant, index, value);
|
|
91
|
+
branches.set(value, check);
|
|
92
|
+
known.push(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const node: SchemaNode = {
|
|
97
|
+
kind: 'union',
|
|
98
|
+
discriminant,
|
|
99
|
+
anyOf: members.map((member) => member.node),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return makeSchema<InferInput<S[number]>, Out>(node, (value, path) => {
|
|
103
|
+
if (!isPlainObject(value)) {
|
|
104
|
+
return fail(path, expected(`an object with a "${discriminant}" discriminant`, value));
|
|
105
|
+
}
|
|
106
|
+
const branch = branches.get(value[discriminant]);
|
|
107
|
+
if (branch === undefined) {
|
|
108
|
+
return fail(
|
|
109
|
+
[...path, discriminant],
|
|
110
|
+
expected(`one of ${renderKnown(known)}`, value[discriminant]),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
// The named branch's issues, and only those: the caller already said which shape they meant,
|
|
114
|
+
// so the other branches' complaints are noise that hides the one real field error.
|
|
115
|
+
return branch(value, path);
|
|
116
|
+
});
|
|
117
|
+
}
|