@ultimat3/schema 23.0.0 → 25.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 +43 -10
- package/README.md +53 -7
- package/package.json +2 -2
- package/src/absolute-url.ts +20 -0
- package/src/builder.ts +39 -4
- package/src/coerce.ts +42 -17
- package/src/error-brand.ts +10 -0
- package/src/error-codes.ts +1 -0
- package/src/errors.ts +36 -11
- package/src/index.ts +9 -3
- package/src/iso-date.ts +28 -4
- package/src/json-schema.ts +28 -1
- package/src/json-value.ts +205 -0
- package/src/money-value.ts +1 -1
- package/src/node-fits.ts +51 -0
- package/src/node.ts +10 -1
- package/src/render-meta.ts +53 -0
- package/src/standard.ts +12 -1
- package/src/t.ts +9 -0
- package/src/time-zone-name.ts +4 -5
- package/src/unstorable-text.ts +20 -0
- package/src/validators.ts +17 -16
- package/src/wire-schema.ts +126 -0
package/CLAUDE.md
CHANGED
|
@@ -5,7 +5,7 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
|
|
|
5
5
|
| Rule | |
|
|
6
6
|
|---|---|
|
|
7
7
|
| Deps | none (`bun-types` only) |
|
|
8
|
-
| Errors | `SchemaError` mirrors `UltimateError` field-for-field **and message-for-message** (`code: title — cause`); keep `Symbol.for('ultimate.error')` |
|
|
8
|
+
| Errors | `SchemaError` mirrors `UltimateError` field-for-field **and message-for-message** (`code: title — cause`), `format({ docs })`, `retry` (always `'terminal'`) and a `toJSON().meta` that cannot throw (`render-meta.ts`, core's `renderMetaRecord` restated); keep `Symbol.for('ultimate.error')` |
|
|
9
9
|
| New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` |
|
|
10
10
|
| IR | every schema carries `.node: SchemaNode`; generators read that, never the closure |
|
|
11
11
|
| **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
|
|
@@ -15,12 +15,14 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
|
|
|
15
15
|
| Re-exports | `action`, `query`, `jobs`, `entity` re-export `t` verbatim so an authoring file imports one package — never let them wrap or copy it |
|
|
16
16
|
|
|
17
17
|
Module order (no cycles):
|
|
18
|
-
`char-count → describe-value → node → builder → money-value →
|
|
19
|
-
provider → t
|
|
18
|
+
`char-count → describe-value → node → builder → money-value → json-value → validators →
|
|
19
|
+
discriminated-union → provider → t` (`node-fits` and `json-schema` read `json-value` too).
|
|
20
|
+
`char-count.ts` is imported by BOTH `validators.ts` (which rejects on length) and
|
|
20
21
|
`describe-value.ts` (which renders the length in the same message), because they disagreed: the
|
|
21
22
|
rule counted code points and the message counted UTF-16 units, so `t.string.min(3)` refused `'👍a'`
|
|
22
23
|
with "at least 3 chars, received a string of 3 characters".
|
|
23
|
-
`standard.ts` and `errors.ts` depend on nothing but each other
|
|
24
|
+
`standard.ts` and `errors.ts` depend on nothing but each other, `render-meta.ts` (which reaches
|
|
25
|
+
`describe-value`) and `error-codes.ts`, a leaf of
|
|
24
26
|
plain data that core imports ALONE so a browser graph never keeps the `SchemaError` classes. `iso-date.ts` imports nothing and
|
|
25
27
|
is imported by `validators.ts` and `coerce.ts` — the two doors a `t.date` string comes through, so
|
|
26
28
|
the rule that a clock time must carry an offset or `Z` has one copy, not one per door.
|
|
@@ -58,8 +60,8 @@ is the whole edit `As of 2026-08-27`** — the title and its `terminal` retry cl
|
|
|
58
60
|
derived from this set, where they used to be a hand-kept duplicate in core plus a hand-written
|
|
59
61
|
retry list a fifth code would have been silently missing from.
|
|
60
62
|
|
|
61
|
-
`ERROR_DOCS_URL` in `errors.ts` is the
|
|
62
|
-
|
|
63
|
+
`ERROR_DOCS_URL` in `errors.ts` is the second deliberate tier-0 duplicate, beside `singleLine` —
|
|
64
|
+
one URL, spelled out, because `SchemaError` cannot import
|
|
63
65
|
`@ultimat3/core`'s constant. There is no per-code URL anywhere in the framework: codes live in
|
|
64
66
|
`wiki/Error-Codes.md` as table ROWS and a table row has no anchor, so
|
|
65
67
|
`https://ultimate.dev/errors/<code>` was a 404 on every error and was deleted `As of 2026-08-23`. Change it
|
|
@@ -68,9 +70,11 @@ here and in `packages/core/src/error-codes.ts` in the same edit. **It is pinned
|
|
|
68
70
|
`UltimateError`'s, which was the one copy with no mechanical check at all and the one that decides
|
|
69
71
|
where every schema refusal sends its reader.
|
|
70
72
|
|
|
71
|
-
**These
|
|
72
|
-
`core -> schema` was declared 2026-08-27 and collapsed five copies on the CORE side
|
|
73
|
-
`
|
|
73
|
+
**These two are the whole of the duplication that remains, and the edge does not reach them.**
|
|
74
|
+
`core -> schema` was declared 2026-08-27 and collapsed five copies on the CORE side, and
|
|
75
|
+
`ULTIMATE_ERROR_BRAND` in 25.0.0 — declared HERE once, imported by core's `errors.ts`, because one
|
|
76
|
+
value name has one declaring package (`scripts/factory-names.ts`). `singleLine` and
|
|
77
|
+
`ERROR_DOCS_URL` go the other way, and `schema -> core` stays forbidden
|
|
74
78
|
on its merits — `t` is in every bundle graph an app has, so a dependency here is a dependency
|
|
75
79
|
everywhere.
|
|
76
80
|
|
|
@@ -144,7 +148,29 @@ Gotchas:
|
|
|
144
148
|
22.0.0): `'March 14, 2026'`, `'3/14/2026'` and `'12'` all parsed at the host's LOCAL midnight.
|
|
145
149
|
`iso-date.test.ts` runs the refusal set under two `TZ` values in subprocesses and requires one
|
|
146
150
|
answer. A number (epoch ms) still passes — it names an instant on every host.
|
|
147
|
-
-
|
|
151
|
+
- **`isIsoDateTime` checks the day against the month** (`As of 2026-10`): `new Date('2026-02-30')`
|
|
152
|
+
answers March 2nd. `DAYS_IN_MONTH` restates `daysInMonth` in `packages/time/src/plain-date.ts`
|
|
153
|
+
(tier 0 cannot import `time`); the `CALENDAR_PARITY` table in `iso-date.test.ts` and its twin in
|
|
154
|
+
`packages/time/src/plain-date.test.ts` hold them equal — the `time` copy asks BOTH predicates.
|
|
155
|
+
- **`t.url` is `isAbsoluteUrl` (`absolute-url.ts`), not bare `URL.canParse`**: input the parser
|
|
156
|
+
would strip (edge spaces / C0 controls, any tab or newline) is refused, since the validator
|
|
157
|
+
returns the string as written. Not `href === value` — that refuses `https://example.com`.
|
|
158
|
+
Non-http schemes still pass; narrowing them is an owner call nobody has made.
|
|
159
|
+
- **`isPlainObject` is a PROTOTYPE test** (`Object.prototype` or `null`). A `Map`, a `Date` and a
|
|
160
|
+
class instance parsed to `{}`. Cost: an object from another realm is refused too.
|
|
161
|
+
- **`.default(v)` runs `v` through the schema at declaration** — `X_SCHEMA_DEFAULT_INVALID`, the
|
|
162
|
+
sibling of `X_SCHEMA_DEFAULT_UNSHAREABLE`, whose title (cannot be copied) does not state this.
|
|
163
|
+
- **Async is "has a callable `then`"** (`isThenable` in `standard.ts`), never `instanceof Promise`:
|
|
164
|
+
a non-native thenable read as a result has no `issues`, so it was a success with no value.
|
|
165
|
+
- **`coerceNode` on a union tries every member** and takes the first whose result `fits`
|
|
166
|
+
(`node-fits.ts`, one level deep, never validation). A string some member takes as a string is
|
|
167
|
+
returned untouched first, so `number | string` never turns `01234` into 1234. Numerics are
|
|
168
|
+
decimal only (`DECIMAL`); `Number()` also reads `0x10`.
|
|
169
|
+
- Adding a `SchemaKind` means updating `json-schema.ts`, `coerce.ts` and `node-fits.ts` in the same commit.
|
|
170
|
+
- **`wire-schema.ts` is the one MCP tool-schema narrowing** (`toWireSchema`, `toWireOutputSchema`,
|
|
171
|
+
`WireJsonSchema`): `@ultimat3/mcp`'s `tools/list` (`toolFrom`, the one tool projection
|
|
172
|
+
since 25.0.0 deleted `.tool()`) publishes through it. It lived in `mcp` (tier 4) while
|
|
173
|
+
`action` carried a second projection, and the two documents differed (`format`, output bounds).
|
|
148
174
|
- **`ToJsonSchemaOptions.dialect` is a closed vocabulary read with `Object.hasOwn`** (`As of
|
|
149
175
|
2026-09-06`). `DIALECTS[dialect]` on an object literal answered the `Object` FUNCTION for
|
|
150
176
|
`dialect: 'constructor'` — measured: `$schema` held it, `JSON.stringify` dropped the key in
|
|
@@ -162,6 +188,13 @@ Gotchas:
|
|
|
162
188
|
a schema change is not allowed to edit. `refinements` and `discriminant` are both fields for that
|
|
163
189
|
reason: a refined string still reads as a string, and a discriminated union still reads as a
|
|
164
190
|
union, everywhere. `lazy` and `tuple` cannot be — which is why neither has shipped.
|
|
191
|
+
- **`t.json()` is a `SchemaKind`, the one exception to the line above** (`As of 2026-10`):
|
|
192
|
+
`unknown` means the IR cannot describe a value, `json` that it can — and every foreign
|
|
193
|
+
`default:` already projects it correctly (`{}`, raw pass-through, a `null` sample, admin's JSON
|
|
194
|
+
textarea). `JsonValue` in `json-value.ts` is the framework's one declaration; `cli`, `render`,
|
|
195
|
+
`manifest` and `realtime` still restate it. Its path segments are POSITIONS (the `t.record`
|
|
196
|
+
rule), shared values are memoised by height so a DAG is linear, and `JSON_MAX_DEPTH` is fixed:
|
|
197
|
+
a caller-set bound needs a declaration-time refusal code this package does not have yet.
|
|
165
198
|
- A refinement is carried as a **declaration** (`name`, `message`, `path`), never a closure: a
|
|
166
199
|
predicate cannot cross into OpenAPI or an MCP tool schema, and `refine`'s `message` is rendered
|
|
167
200
|
verbatim on both — so it states the rule and never interpolates a value.
|
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ type, a JSON Schema, an OpenAPI body, an MCP tool `inputSchema` and HTTP query c
|
|
|
11
11
|
| the introspectable IR every generator walks | `node.ts` |
|
|
12
12
|
| `Schema` factory, `Infer` machinery, `.refine()` | `builder.ts` |
|
|
13
13
|
| how a rejected value is described — its shape, never its content | `describe-value.ts` |
|
|
14
|
+
| `JsonValue`, `t.json()` / `jsonSchema()`, `JSON_MAX_DEPTH` | `json-value.ts` |
|
|
14
15
|
| a union routed by one literal key | `discriminated-union.ts` |
|
|
15
16
|
| `configureSchemaProvider()` — the swap point | `provider.ts` |
|
|
16
17
|
| schema → JSON Schema (OpenAPI + MCP) | `json-schema.ts` |
|
|
@@ -39,17 +40,49 @@ type PublishPost = Infer<typeof publishPost>;
|
|
|
39
40
|
| Value schemas | Factories | Methods |
|
|
40
41
|
|---|---|---|
|
|
41
42
|
| `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)` |
|
|
42
|
-
| `money` `timezone` `locale` `slug` `cursor` | `object(...).extend/.pick/.omit` | `string.min/.max/.pattern`, `number.min/.max/.int` |
|
|
43
|
+
| `money` `timezone` `locale` `slug` `cursor` `json()` | `object(...).extend/.pick/.omit` | `string.min/.max/.pattern`, `number.min/.max/.int` |
|
|
43
44
|
|
|
44
45
|
`nullable` is a value the row holds; `optional` is the caller omitting the key. Both ship as a
|
|
45
46
|
namespace member (`t.nullable`) and a free function (`nullableSchema`) — symmetrically.
|
|
46
47
|
|
|
47
48
|
Unknown object keys are **dropped**, never forwarded — an action cannot be mass-assigned.
|
|
48
49
|
|
|
50
|
+
Refused at the boundary rather than guessed at (`As of 2026-10`):
|
|
51
|
+
|
|
52
|
+
| Schema | Refuses | Because |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `t.date` | a day its month does not have — `2026-02-30`, `2026-04-31`, month `13` | `new Date` rolls it over to March 2nd; `@ultimat3/time`'s `plainDate` already refused it |
|
|
55
|
+
| `t.url` | leading / trailing spaces and C0 controls, a tab or newline anywhere | the URL parser strips them in silence and the validator returns the string as written. Trim before parsing |
|
|
56
|
+
| `t.object` `t.record` `t.money` | anything whose prototype is not `Object.prototype` or `null` — a `Map`, a `Date`, a class instance | none has the own keys the schema declared, so it parsed to `{}` |
|
|
57
|
+
| `.default(v)` | a `v` the schema itself rejects — `X_SCHEMA_DEFAULT_INVALID`, thrown where it is declared | an omitted field parsed to a value the same schema refuses when sent |
|
|
58
|
+
|
|
59
|
+
### `t.json()` — a payload whose shape is somebody else's
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { t, type Infer, type JsonValue } from '@ultimat3/schema';
|
|
63
|
+
|
|
64
|
+
export const hook = t.object({ event: t.slug, payload: t.json() });
|
|
65
|
+
export const payload: JsonValue = ({} as Infer<typeof hook>).payload;
|
|
66
|
+
// entity column: json(t.json()) — a jsonb that stores what it was sent
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Accepts | Refuses (with the issue's path) |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `null`, booleans, finite numbers, strings, arrays and plain / null-prototype objects of them, recursively | `undefined` (an array hole too), functions, symbols, bigints, `NaN` / `±Infinity`, a `Date`, a `Map` / `Set` / class instance / typed array, a cycle |
|
|
72
|
+
| every key, `__proto__` and `constructor` included — answered on a null-prototype copy | a NUL or a lone UTF-16 surrogate in a string or a key — Postgres `jsonb` refuses both (measured, PG 17) |
|
|
73
|
+
| at most `JSON_MAX_DEPTH` (256) nested containers | one more: the walk recurses per level, and an unbounded one was a `RangeError` |
|
|
74
|
+
|
|
75
|
+
A path into a JSON object is the entry's **position** (`payload[2][0]`), never its key — the
|
|
76
|
+
`t.record` rule. A value shared twice is walked once, so a DAG is linear, not exponential. The
|
|
77
|
+
JSON Schema is `{}` with the depth bound in `description`; the bound is fixed — narrow it with
|
|
78
|
+
`.refine()`. Node `kind: 'json'`, distinct from `unknown` ("the IR cannot say").
|
|
79
|
+
|
|
49
80
|
Every string-backed schema (`string` `uuid` `email` `url` `timezone` `locale` `slug` `cursor`, with
|
|
50
|
-
any `.min/.max/.pattern`) and every `t.record` key **refuses U+0000
|
|
51
|
-
|
|
52
|
-
|
|
81
|
+
any `.min/.max/.pattern`) and every `t.record` key **refuses U+0000 and a lone UTF-16 surrogate** —
|
|
82
|
+
the rule `t.json()` applies, one predicate (`unstorable-text.ts`). Postgres `text` and `jsonb` cannot
|
|
83
|
+
store a NUL, `jsonb` refuses a lone surrogate, and into `text` Bun's sql writes U+FFFD in its place,
|
|
84
|
+
so either reached the row write as a 500 or a silent rewrite. A well-formed pair (an emoji), tabs,
|
|
85
|
+
newlines and the other C0 controls stay legal text. The issue names the fact, never the value:
|
|
53
86
|
`title: expected a non-empty string, received a string of 3 characters that contains a NUL
|
|
54
87
|
character (U+0000)`. Not published in the JSON Schema — it is a storage fact, not a format.
|
|
55
88
|
|
|
@@ -75,6 +108,11 @@ extension **and** appended to `description`, which is the only field an LLM read
|
|
|
75
108
|
is guaranteed to see. `.refine()` returns a plain `Schema`, so refining after `extend`/`pick`/`omit`
|
|
76
109
|
— which rebuild from the shape and would drop the rule — is a type error rather than a comment.
|
|
77
110
|
|
|
111
|
+
A flagged `.pattern(/^[a-z]+$/i)` keeps its flags the same two ways: JSON Schema's `pattern` has no
|
|
112
|
+
flag syntax (an inline `(?i)` is not ECMA-262), so the projection publishes `pattern` plus an
|
|
113
|
+
`x-ultimate-pattern-flags: 'i'` extension a compiling consumer reads (`@ultimat3/mcp`'s arg
|
|
114
|
+
validator), and states the flags in `description` for a reader.
|
|
115
|
+
|
|
78
116
|
### `discriminatedUnion` names the branch it judged
|
|
79
117
|
|
|
80
118
|
```ts
|
|
@@ -146,8 +184,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
146
184
|
|
|
147
185
|
| Class | Code | Declared in |
|
|
148
186
|
|---|---|---|
|
|
187
|
+
| `DefaultInvalidError` (extends `SchemaError`) | `X_SCHEMA_DEFAULT_INVALID` | `src/errors.ts` |
|
|
149
188
|
| `DiscriminantInvalidError` (extends `SchemaError`) | `X_SCHEMA_DISCRIMINANT_INVALID` | `src/errors.ts` |
|
|
150
|
-
| `SchemaError` | any schema code; the base of the
|
|
189
|
+
| `SchemaError` | any schema code; the base of the four that extend it. Extends `Error`, not core's `UltimateError` — schema imports nothing — and carries the same `Symbol.for('ultimate.error')` brand so `isUltimateError` answers `true` | `src/errors.ts` |
|
|
151
190
|
| `SchemaUnsupportedError` (extends `SchemaError`) | `X_SCHEMA_UNSUPPORTED` | `src/errors.ts` |
|
|
152
191
|
| `ValidationFailedError` (extends `SchemaError`) | `X_VALIDATION_FAILED` | `src/errors.ts` |
|
|
153
192
|
|
|
@@ -173,11 +212,18 @@ swap. `introspect()` is what OpenAPI/MCP generation needs; a provider without it
|
|
|
173
212
|
|
|
174
213
|
```ts
|
|
175
214
|
toJsonSchema(publishPost); // OpenAPI 3.1 (2020-12)
|
|
176
|
-
|
|
215
|
+
toWireSchema(publishPost); // an MCP tool's inputSchema: what tools/list serves
|
|
216
|
+
toWireOutputSchema(publishPost); // its outputSchema: structure only; undefined for a non-object root
|
|
177
217
|
toJsonSchema(publishPost, { dialect: 'x' }); // X_SCHEMA_UNSUPPORTED — the two spellings are closed
|
|
178
218
|
parse(publishPost, coerceQuery(publishPost, url.searchParams));
|
|
219
|
+
numeric(' 12 '); // 12 — numeric('0x10'), numeric(' '): undefined
|
|
179
220
|
```
|
|
180
221
|
|
|
181
222
|
Coercion is separate from validation on purpose: only the HTTP layer has strings that mean
|
|
182
223
|
numbers. `coerceQuery` promotes repeated params to arrays and leaves anything ambiguous
|
|
183
|
-
untouched so validation still produces the real error.
|
|
224
|
+
untouched so validation still produces the real error. It never invents data: only a **decimal**
|
|
225
|
+
numeral becomes a number (`0x10` stays text; `numeric` is that one reader, for a boundary that
|
|
226
|
+
holds a string and no schema node — admin's form decode), only a plain object is read as one (an array is never
|
|
227
|
+
spread into `{ 0: … }`), and a union tries **every** member in declaration order — a string some
|
|
228
|
+
member already accepts is left alone, and a union of objects coerces through the member whose
|
|
229
|
+
literal fields the value carries.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/schema",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
],
|
|
28
28
|
"engines": {
|
|
29
|
-
"bun": ">=1.4.
|
|
29
|
+
"bun": ">=1.4.2"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Single responsibility: the one rule deciding whether a string is an absolute URL `t.url` may
|
|
2
|
+
// return as written — parseable, and not a string the parser had to cut before it could read it.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the URL parser DISCARDS before it reads anything: leading and trailing C0 controls and
|
|
6
|
+
* spaces, and every tab or newline wherever it sits (WHATWG URL §4.4, the two "validation error"
|
|
7
|
+
* strips). `URL.canParse` reports none of it, and this validator returns the string it was given —
|
|
8
|
+
* so `' https://a.b'` validated, was stored untrimmed, and rendered into an `href` as a relative
|
|
9
|
+
* path. The parsed `href` is only stable for input the parser did not have to cut.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately NOT `new URL(value).href === value`: that refuses `https://example.com` (the href
|
|
12
|
+
* gains a `/`) and any upper-case host, which are the same URL written differently, not a
|
|
13
|
+
* different string than the one validated.
|
|
14
|
+
*/
|
|
15
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the controls are the rule.
|
|
16
|
+
const URL_PARSER_STRIPS = /^[\u0000-\u0020]|[\u0000-\u0020]$|[\t\n\r]/;
|
|
17
|
+
|
|
18
|
+
export function isAbsoluteUrl(value: string): boolean {
|
|
19
|
+
return !URL_PARSER_STRIPS.test(value) && URL.canParse(value);
|
|
20
|
+
}
|
package/src/builder.ts
CHANGED
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
// that turns a check function plus an IR node into a Standard-Schema-conforming object.
|
|
3
3
|
|
|
4
4
|
import { describeValue } from './describe-value';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DefaultInvalidError,
|
|
7
|
+
SchemaError,
|
|
8
|
+
ValidationFailedError,
|
|
9
|
+
type ValidationIssue,
|
|
10
|
+
} from './errors';
|
|
6
11
|
import type { SchemaNode, SchemaRefinement } from './node';
|
|
7
12
|
import {
|
|
13
|
+
formatIssues,
|
|
8
14
|
formatPath,
|
|
9
15
|
type InferInput,
|
|
10
16
|
type InferOutput,
|
|
17
|
+
isThenable,
|
|
11
18
|
type StandardIssue,
|
|
12
19
|
type StandardResult,
|
|
13
20
|
type StandardSchemaV1,
|
|
@@ -43,9 +50,19 @@ export function failWith(issues: readonly StandardIssue[]): CheckErr {
|
|
|
43
50
|
return { ok: false, issues };
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A record of own keys: an object literal, or a null-prototype one (what this package's own
|
|
55
|
+
* object and record parsers answer). The gate every object-ish check opens with.
|
|
56
|
+
*
|
|
57
|
+
* The PROTOTYPE is the test, not "an object that is not an array": a `Map`, a `Date` and a class
|
|
58
|
+
* instance are all that, and each has no own enumerable keys the schema declared — so
|
|
59
|
+
* `t.record(t.number)` parsed a `Map` of anything to `{}` and an all-optional `t.object` parsed a
|
|
60
|
+
* `Date` to `{}`, a success carrying none of what was sent.
|
|
61
|
+
*/
|
|
47
62
|
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
48
|
-
|
|
63
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
64
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
65
|
+
return prototype === Object.prototype || prototype === null;
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
/**
|
|
@@ -146,6 +163,22 @@ function defaultFactory<Out>(fallback: Out): () => Out {
|
|
|
146
163
|
return () => structuredClone(fallback);
|
|
147
164
|
}
|
|
148
165
|
|
|
166
|
+
/**
|
|
167
|
+
* A fallback the schema itself refuses, refused where it is WRITTEN. `t.number.min(5).default(1)`
|
|
168
|
+
* parsed an omitted field to 1 — a value the same schema rejects when a caller sends it — and
|
|
169
|
+
* published `minimum: 5, default: 1` to every generated client. Wrong for every parse that omits
|
|
170
|
+
* the field, so the first import of the authoring file says so, as `defaultFactory` does.
|
|
171
|
+
*/
|
|
172
|
+
function assertDefaultValid<Out>(check: Check<Out>, fallback: Out): void {
|
|
173
|
+
const result = check(fallback, []);
|
|
174
|
+
if (result.ok) return;
|
|
175
|
+
throw new DefaultInvalidError({
|
|
176
|
+
// The issue text is the RULE (`describeValue` never echoes content), so quoting it is safe.
|
|
177
|
+
cause: `default() received ${describeValue(fallback)}, which this schema refuses — ${formatIssues(result.issues).join('; ')}`,
|
|
178
|
+
fix: 'edit the .default(…) named in the stack so its value satisfies the rule quoted in cause, or relax that rule on the schema',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
149
182
|
/** The default declaration, dropped — for a wrapper that can no longer reach it. */
|
|
150
183
|
function withoutDefault(node: SchemaNode): SchemaNode {
|
|
151
184
|
const { hasDefault: _hasDefault, default: _default, ...rest } = node;
|
|
@@ -196,6 +229,8 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
|
|
|
196
229
|
);
|
|
197
230
|
},
|
|
198
231
|
default(fallback: Out): Schema<In | undefined, Out> {
|
|
232
|
+
// Rule before copy: a refused fallback has to be replaced, so its clone problem is moot.
|
|
233
|
+
assertDefaultValid(check, fallback);
|
|
199
234
|
const fresh = defaultFactory(fallback);
|
|
200
235
|
return makeSchema<In | undefined, Out>(
|
|
201
236
|
// The node keeps the DECLARATION, never a copy: `node.default` is what OpenAPI, the MCP
|
|
@@ -234,7 +269,7 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
|
|
|
234
269
|
export function checkOf<In, Out>(schema: Schema<In, Out>): Check<Out> {
|
|
235
270
|
return (value, path) => {
|
|
236
271
|
const result = schema['~standard'].validate(value);
|
|
237
|
-
if (result
|
|
272
|
+
if (isThenable(result)) {
|
|
238
273
|
return fail(path, 'expected a synchronous schema, received an async one');
|
|
239
274
|
}
|
|
240
275
|
if (result.issues === undefined) return pass(result.value);
|
package/src/coerce.ts
CHANGED
|
@@ -2,16 +2,29 @@
|
|
|
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 { isPlainObject } from './builder';
|
|
5
6
|
import { isIsoDateTime } from './iso-date';
|
|
6
7
|
import type { SchemaNode } from './node';
|
|
8
|
+
import { fits } from './node-fits';
|
|
7
9
|
import { tryIntrospect } from './provider';
|
|
8
10
|
|
|
9
11
|
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
10
12
|
const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
|
11
13
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* A DECIMAL numeral, and nothing else `Number()` reads: it also takes `0x10`, `0b11` and `0o17`,
|
|
16
|
+
* so `?page=0x10` arrived as page 16 — a number the caller never wrote in the only notation a
|
|
17
|
+
* query string, a form field and the published `type: number` have in common.
|
|
18
|
+
*/
|
|
19
|
+
const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A numeric string as a number, or `undefined` for anything that is not confidently one. Exported
|
|
23
|
+
* for the other string-to-number boundary, admin's form decode: two readers of one posted numeral
|
|
24
|
+
* would disagree about `0x10`, which is the drift this rule exists to end.
|
|
25
|
+
*/
|
|
26
|
+
export function numeric(raw: unknown): number | undefined {
|
|
27
|
+
if (typeof raw !== 'string' || !DECIMAL.test(raw.trim())) return undefined;
|
|
15
28
|
const value = Number(raw);
|
|
16
29
|
return Number.isFinite(value) ? value : undefined;
|
|
17
30
|
}
|
|
@@ -38,9 +51,7 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
38
51
|
|
|
39
52
|
switch (node.kind) {
|
|
40
53
|
case 'number': {
|
|
41
|
-
|
|
42
|
-
const value = Number(raw);
|
|
43
|
-
return raw.trim() !== '' && Number.isFinite(value) ? value : raw;
|
|
54
|
+
return numeric(raw) ?? raw;
|
|
44
55
|
}
|
|
45
56
|
case 'boolean':
|
|
46
57
|
return booleanish(raw);
|
|
@@ -72,20 +83,23 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
72
83
|
return itemNode === undefined ? items : items.map((item) => coerceNode(itemNode, item));
|
|
73
84
|
}
|
|
74
85
|
case 'record': {
|
|
75
|
-
if (
|
|
86
|
+
if (!isPlainObject(raw) || node.valueNode === undefined) return raw;
|
|
76
87
|
// A null prototype for the reason `recordSchema` uses one: on a `{}` literal, assigning
|
|
77
88
|
// `out['__proto__']` hits the `Object.prototype` SETTER and the key vanishes, so the
|
|
78
89
|
// record validator's deliberate refusal of it never ran — the key was reported absent
|
|
79
90
|
// rather than rejected, on the one path (HTTP query) where it is caller-controlled.
|
|
80
91
|
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
81
|
-
for (const [key, value] of Object.entries(raw
|
|
92
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
82
93
|
out[key] = coerceNode(node.valueNode, value);
|
|
83
94
|
}
|
|
84
95
|
return out;
|
|
85
96
|
}
|
|
86
97
|
case 'object': {
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
// A plain object or nothing: `{ ...['x'] }` is `{ 0: 'x' }` and `{ ...new Date() }` is `{}`,
|
|
99
|
+
// so spreading anything else MADE an object the caller never sent — and an all-optional
|
|
100
|
+
// schema then accepted it. Untouched, validation says "expected an object".
|
|
101
|
+
if (!isPlainObject(raw) || node.properties === undefined) return raw;
|
|
102
|
+
const source = raw;
|
|
89
103
|
const out: Record<string, unknown> = { ...source };
|
|
90
104
|
for (const [key, child] of Object.entries(node.properties)) {
|
|
91
105
|
// `Object.hasOwn`, never `key in source`: `{ ...source }` above already dropped what a
|
|
@@ -96,8 +110,8 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
96
110
|
return out;
|
|
97
111
|
}
|
|
98
112
|
case 'money': {
|
|
99
|
-
if (
|
|
100
|
-
const source = raw
|
|
113
|
+
if (!isPlainObject(raw)) return raw;
|
|
114
|
+
const source = raw;
|
|
101
115
|
// Through `numeric` for the same reason `scale` is: `Number('')` is 0, so a blank amount
|
|
102
116
|
// field converted here would reach the validator as a legitimate zero and book an empty
|
|
103
117
|
// price input as free. A blank stays a blank and fails validation, which is the real error.
|
|
@@ -110,12 +124,23 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
110
124
|
return { ...source, minor, ...(scale === undefined ? {} : { scale }) };
|
|
111
125
|
}
|
|
112
126
|
case 'union': {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
const members = node.anyOf ?? [];
|
|
128
|
+
// A string some member takes AS A STRING is never converted behind its back: under
|
|
129
|
+
// `number | string` a postcode `01234` must not arrive as 1234.
|
|
130
|
+
if (typeof raw === 'string' && members.some((member) => fits(member, raw))) return raw;
|
|
131
|
+
// Every member is tried, in declaration order, and the first whose coercion FITS it wins.
|
|
132
|
+
// The first member alone used to decide: `'auto' | 2` left `"2"` a string because `'auto'`
|
|
133
|
+
// needs no conversion, and a union of objects coerced every value by its first branch.
|
|
134
|
+
for (const member of members) {
|
|
135
|
+
const coerced = coerceNode(member, raw);
|
|
136
|
+
if (fits(member, coerced)) return coerced;
|
|
137
|
+
}
|
|
138
|
+
return raw;
|
|
118
139
|
}
|
|
140
|
+
// Named rather than left to `default`: a query parameter is TEXT, and text is already a JSON
|
|
141
|
+
// value. Parsing it would make `?q=12` a number for one caller and a string for the next.
|
|
142
|
+
case 'json':
|
|
143
|
+
return raw;
|
|
119
144
|
default:
|
|
120
145
|
return raw;
|
|
121
146
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Single responsibility: the ONE brand every framework error carries — `SchemaError` here,
|
|
2
|
+
// `UltimateError` in `@ultimat3/core`, which imports this declaration over the `core -> schema`
|
|
3
|
+
// edge. A leaf of its own for `error-codes.ts`'s reason: core's error contract is in every browser
|
|
4
|
+
// bundle, and importing the brand from `errors.ts` would drag `SchemaError` and its table in too.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Never renamed: the `Symbol.for` key is what a duplicated module instance recognises, so
|
|
8
|
+
* `isUltimateError()` stays true across two copies of a package in one process.
|
|
9
|
+
*/
|
|
10
|
+
export const ULTIMATE_ERROR_BRAND: unique symbol = Symbol.for('ultimate.error');
|
package/src/error-codes.ts
CHANGED
package/src/errors.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Single responsibility: this package's error codes. `@ultimat3/schema` is tier 0 and may not
|
|
2
2
|
// import `@ultimat3/core`, so `SchemaError` reproduces the `UltimateError` shape structurally
|
|
3
|
-
// and carries the
|
|
3
|
+
// and carries the one brand both classes share (`error-brand.ts`) — `isUltimateError()` matches.
|
|
4
4
|
|
|
5
|
+
import { ULTIMATE_ERROR_BRAND } from './error-brand';
|
|
5
6
|
import { SCHEMA_ERROR_CODES } from './error-codes';
|
|
7
|
+
import { renderMetaRecord } from './render-meta';
|
|
6
8
|
import { formatIssues } from './standard';
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -10,8 +12,8 @@ import { formatIssues } from './standard';
|
|
|
10
12
|
* there — briefly: the 3-line format is line-oriented, and a `cause` may hold a value the caller
|
|
11
13
|
* chose, so a newline in one writes a line an operator reads as a genuine framework message.
|
|
12
14
|
*
|
|
13
|
-
* Copied rather than imported
|
|
14
|
-
*
|
|
15
|
+
* Copied rather than imported: `schema` and `core` are both tier 0, and only `core -> schema` is a
|
|
16
|
+
* declared edge, so `schema` may not import `core`. A schema
|
|
15
17
|
* cause is the one most likely to carry a hostile string — it describes the value that failed
|
|
16
18
|
* validation, which is the request body.
|
|
17
19
|
*/
|
|
@@ -34,9 +36,6 @@ const singleLine = (text: string): string =>
|
|
|
34
36
|
(char) => ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
35
37
|
);
|
|
36
38
|
|
|
37
|
-
/** Same well-known symbol `@ultimat3/core` brands with. Keep in sync, never rename. */
|
|
38
|
-
export const ULTIMATE_ERROR_BRAND: unique symbol = Symbol.for('ultimate.error');
|
|
39
|
-
|
|
40
39
|
/**
|
|
41
40
|
* The same one URL `@ultimat3/core`'s `ERROR_DOCS_URL` holds. **Keep in sync**, and read the
|
|
42
41
|
* reason there — briefly: `wiki/` is the only public documentation surface, codes live on that
|
|
@@ -78,9 +77,16 @@ export interface SchemaErrorJSON {
|
|
|
78
77
|
readonly cause: string;
|
|
79
78
|
readonly fix: string;
|
|
80
79
|
readonly docs: string;
|
|
80
|
+
/** Always present, as on `UltimateErrorJSON` — a client never has to infer it. */
|
|
81
|
+
readonly retry: 'terminal';
|
|
81
82
|
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
export interface SchemaFormatOptions {
|
|
86
|
+
/** Append a 4th `docs:` line. Off by default, as `UltimateError.format()` has it. */
|
|
87
|
+
readonly docs?: boolean | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
84
90
|
export class SchemaError extends Error {
|
|
85
91
|
readonly [ULTIMATE_ERROR_BRAND] = true;
|
|
86
92
|
override readonly name: string = 'SchemaError';
|
|
@@ -89,6 +95,12 @@ export class SchemaError extends Error {
|
|
|
89
95
|
declare readonly cause: string;
|
|
90
96
|
readonly fix: string;
|
|
91
97
|
readonly docs: string;
|
|
98
|
+
/**
|
|
99
|
+
* `terminal` on every instance: a value that does not match its schema does not match it on
|
|
100
|
+
* attempt five. Carried HERE as well as in core's retry registry because `errorRetryOf` reads
|
|
101
|
+
* the field off the error, and a process that never imported core has no registry to ask.
|
|
102
|
+
*/
|
|
103
|
+
readonly retry = 'terminal' as const;
|
|
92
104
|
readonly meta: Readonly<Record<string, unknown>> | undefined;
|
|
93
105
|
|
|
94
106
|
constructor(init: SchemaErrorInit) {
|
|
@@ -119,10 +131,10 @@ export class SchemaError extends Error {
|
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
/** The same 3-line rendering as `UltimateError.format()`, escaped in the same place: neither. */
|
|
122
|
-
format(): string {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
);
|
|
134
|
+
format(options?: SchemaFormatOptions): string {
|
|
135
|
+
const lines = [`${this.code}: ${this.title}`, ` cause: ${this.cause}`, ` fix: ${this.fix}`];
|
|
136
|
+
if (options?.docs === true) lines.push(` docs: ${this.docs}`);
|
|
137
|
+
return lines.join('\n');
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
toJSON(): SchemaErrorJSON {
|
|
@@ -132,7 +144,10 @@ export class SchemaError extends Error {
|
|
|
132
144
|
cause: this.cause,
|
|
133
145
|
fix: this.fix,
|
|
134
146
|
docs: this.docs,
|
|
135
|
-
|
|
147
|
+
retry: this.retry,
|
|
148
|
+
// A schema error's `meta` can hold what the caller sent; a `bigint` or a cycle in it threw
|
|
149
|
+
// here, at `--json` render time, one layer past a constructor that had already succeeded.
|
|
150
|
+
meta: renderMetaRecord(this.meta),
|
|
136
151
|
};
|
|
137
152
|
}
|
|
138
153
|
}
|
|
@@ -185,6 +200,16 @@ export class DiscriminantInvalidError extends SchemaError {
|
|
|
185
200
|
}
|
|
186
201
|
}
|
|
187
202
|
|
|
203
|
+
/** Thrown where `.default()` is WRITTEN: a fallback the schema itself refuses is wrong for every parse. */
|
|
204
|
+
export class DefaultInvalidError extends SchemaError {
|
|
205
|
+
static readonly code = 'X_SCHEMA_DEFAULT_INVALID';
|
|
206
|
+
override readonly name = 'DefaultInvalidError';
|
|
207
|
+
|
|
208
|
+
constructor(init: Omit<SchemaErrorInit, 'code'>) {
|
|
209
|
+
super({ ...init, code: DefaultInvalidError.code });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
188
213
|
export class SchemaUnsupportedError extends SchemaError {
|
|
189
214
|
static readonly code = 'X_SCHEMA_UNSUPPORTED';
|
|
190
215
|
override readonly name = 'SchemaUnsupportedError';
|
package/src/index.ts
CHANGED
|
@@ -22,22 +22,24 @@ export { checkOf, fail, failWith, makeSchema, pass, VENDOR } from './builder';
|
|
|
22
22
|
// not import this package — but `mcp` is tier 4 and can, so a copy there would have no excuse.
|
|
23
23
|
export { charCount } from './char-count';
|
|
24
24
|
export type { QuerySource } from './coerce';
|
|
25
|
-
export { coerceInput, coerceNode, coerceQuery } from './coerce';
|
|
25
|
+
export { coerceInput, coerceNode, coerceQuery, numeric } from './coerce';
|
|
26
26
|
export { describeValue, expected } from './describe-value';
|
|
27
27
|
export { discriminatedUnionSchema } from './discriminated-union';
|
|
28
|
+
export { ULTIMATE_ERROR_BRAND } from './error-brand';
|
|
28
29
|
export type { SchemaErrorCodeDeclaration } from './error-codes';
|
|
29
30
|
export { SCHEMA_ERROR_CODES } from './error-codes';
|
|
30
31
|
export type {
|
|
31
32
|
SchemaErrorInit,
|
|
32
33
|
SchemaErrorJSON,
|
|
34
|
+
SchemaFormatOptions,
|
|
33
35
|
ValidationIssue,
|
|
34
36
|
} from './errors';
|
|
35
37
|
export {
|
|
38
|
+
DefaultInvalidError,
|
|
36
39
|
DiscriminantInvalidError,
|
|
37
40
|
isSchemaError,
|
|
38
41
|
SchemaError,
|
|
39
42
|
SchemaUnsupportedError,
|
|
40
|
-
ULTIMATE_ERROR_BRAND,
|
|
41
43
|
ValidationFailedError,
|
|
42
44
|
} from './errors';
|
|
43
45
|
// Exported for `@ultimat3/time`'s `fromIso`: one ISO-shape rule, whichever door a date comes in by.
|
|
@@ -49,7 +51,9 @@ export type {
|
|
|
49
51
|
JsonSchemaType,
|
|
50
52
|
ToJsonSchemaOptions,
|
|
51
53
|
} from './json-schema';
|
|
52
|
-
export { nodeToJsonSchema, toJsonSchema
|
|
54
|
+
export { nodeToJsonSchema, toJsonSchema } from './json-schema';
|
|
55
|
+
export type { JsonValue } from './json-value';
|
|
56
|
+
export { JSON_MAX_DEPTH, jsonSchema } from './json-value';
|
|
53
57
|
export type { MoneyValue } from './money-value';
|
|
54
58
|
export {
|
|
55
59
|
CURRENCY_CODE_PATTERN,
|
|
@@ -112,3 +116,5 @@ export {
|
|
|
112
116
|
refineSchema,
|
|
113
117
|
unionSchema,
|
|
114
118
|
} from './validators';
|
|
119
|
+
export type { WireJsonSchema } from './wire-schema';
|
|
120
|
+
export { toWireOutputSchema, toWireSchema } from './wire-schema';
|