@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 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 → validators → discriminated-union →
19
- provider → t`. `char-count.ts` is imported by BOTH `validators.ts` (which rejects on length) and
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 and `error-codes.ts`, a leaf of
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 third deliberate tier-0 duplicate, beside `singleLine` and
62
- `ULTIMATE_ERROR_BRAND` — one URL, spelled out, because `SchemaError` cannot import
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 three are the whole of the duplication that remains, and the edge does not reach them.**
72
- `core -> schema` was declared 2026-08-27 and collapsed five copies on the CORE side; `singleLine`,
73
- `ERROR_DOCS_URL` and `ULTIMATE_ERROR_BRAND` go the other way, and `schema -> core` stays forbidden
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
- - Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit.
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** — the one character Postgres
51
- `text` and `jsonb` cannot store, so a NUL that passed reached the row write as a 500. Tabs, newlines
52
- and the other C0 controls stay legal text. The issue names the fact, never the value:
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 three 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` |
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
- toMcpInputSchema(publishPost); // MCP tools/list (draft-07, no $schema)
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": "23.0.0",
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.0"
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 { SchemaError, ValidationFailedError, type ValidationIssue } from './errors';
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
- /** An object with own keys — not null, not an array. The gate every object-ish check opens with. */
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
- return typeof value === 'object' && value !== null && !Array.isArray(value);
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 instanceof Promise) {
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
- /** A numeric string as a number, or `undefined` for anything that is not confidently one. */
13
- function numeric(raw: unknown): number | undefined {
14
- if (typeof raw !== 'string' || raw.trim() === '') return undefined;
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
- if (typeof raw !== 'string') return raw;
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 (typeof raw !== 'object' || node.valueNode === undefined) return raw;
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 as Record<string, unknown>)) {
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
- if (typeof raw !== 'object' || node.properties === undefined) return raw;
88
- const source = raw as Record<string, unknown>;
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 (typeof raw !== 'object') return raw;
100
- const source = raw as Record<string, unknown>;
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
- // Only unambiguous single-kind unions (e.g. `number | undefined`) are safe to coerce.
114
- const kinds = new Set((node.anyOf ?? []).map((member) => member.kind));
115
- if (kinds.size !== 1) return raw;
116
- const [member] = node.anyOf ?? [];
117
- return member === undefined ? raw : coerceNode(member, raw);
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');
@@ -26,4 +26,5 @@ export const SCHEMA_ERROR_CODES: Readonly<Record<string, SchemaErrorCodeDeclarat
26
26
  X_SCHEMA_DEFAULT_UNSHAREABLE: {
27
27
  title: 'a schema default cannot be copied per parse',
28
28
  },
29
+ X_SCHEMA_DEFAULT_INVALID: { title: 'a schema default fails its own schema' },
29
30
  });
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 same `Symbol.for('ultimate.error')` brand — `isUltimateError()` still matches.
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 for the same reason the brand symbol above is: `schema` and `core`
14
- * are both tier 0, so `schema` may not import `core` (imports go DOWN, never sideways). A schema
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
- return [`${this.code}: ${this.title}`, ` cause: ${this.cause}`, ` fix: ${this.fix}`].join(
124
- '\n',
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
- meta: this.meta,
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, toMcpInputSchema } from './json-schema';
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';