@ultimat3/schema 24.0.0 → 25.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +26 -10
- package/README.md +35 -7
- package/package.json +2 -2
- package/src/array-schema.ts +79 -0
- package/src/coerce.ts +10 -2
- package/src/error-brand.ts +10 -0
- package/src/error-codes.ts +1 -0
- package/src/errors.ts +17 -6
- package/src/index.ts +7 -3
- package/src/json-schema.ts +21 -0
- package/src/json-value.ts +205 -0
- package/src/money-value.ts +1 -1
- package/src/node-fits.ts +3 -0
- package/src/node.ts +11 -0
- package/src/t.ts +19 -2
- package/src/time-zone-name.ts +4 -5
- package/src/unstorable-text.ts +20 -0
- package/src/validators.ts +21 -33
- package/src/wire-schema.ts +6 -2
package/CLAUDE.md
CHANGED
|
@@ -15,8 +15,10 @@ 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 → array-schema →
|
|
19
|
+
validators → discriminated-union → provider → t` (`node-fits` and `json-schema` read `json-value`
|
|
20
|
+
too).
|
|
21
|
+
`char-count.ts` is imported by BOTH `validators.ts` (which rejects on length) and
|
|
20
22
|
`describe-value.ts` (which renders the length in the same message), because they disagreed: the
|
|
21
23
|
rule counted code points and the message counted UTF-16 units, so `t.string.min(3)` refused `'👍a'`
|
|
22
24
|
with "at least 3 chars, received a string of 3 characters".
|
|
@@ -46,6 +48,11 @@ else, in CHARACTERS (`char-count.ts`) — the unit the rule that rejected it cou
|
|
|
46
48
|
re-enables the echo — one misconfigured environment is the same breach, and a dev
|
|
47
49
|
overlay already holds the raw body. `describe-value.test.ts` is the enforcement.
|
|
48
50
|
|
|
51
|
+
`t.array(items, { min, max })` (`array-schema.ts`) carries its bounds as `minItems`/`maxItems` ON
|
|
52
|
+
the node — JSON Schema's names — and `json-schema.ts` and `wire-schema.ts` publish both;
|
|
53
|
+
`@ultimat3/action`'s `sampleFor` samples `minItems` items. A bound no count meets is
|
|
54
|
+
`X_SCHEMA_BOUNDS_INVALID` where it is declared, the same rule as the discriminant below.
|
|
55
|
+
|
|
49
56
|
`X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
|
|
50
57
|
is parsed. A member with no literal at the discriminant, or a second member claiming a tag the
|
|
51
58
|
first already owns, can never be routed to — so it is wrong for every input, and the first import
|
|
@@ -59,8 +66,8 @@ is the whole edit `As of 2026-08-27`** — the title and its `terminal` retry cl
|
|
|
59
66
|
derived from this set, where they used to be a hand-kept duplicate in core plus a hand-written
|
|
60
67
|
retry list a fifth code would have been silently missing from.
|
|
61
68
|
|
|
62
|
-
`ERROR_DOCS_URL` in `errors.ts` is the
|
|
63
|
-
|
|
69
|
+
`ERROR_DOCS_URL` in `errors.ts` is the second deliberate tier-0 duplicate, beside `singleLine` —
|
|
70
|
+
one URL, spelled out, because `SchemaError` cannot import
|
|
64
71
|
`@ultimat3/core`'s constant. There is no per-code URL anywhere in the framework: codes live in
|
|
65
72
|
`wiki/Error-Codes.md` as table ROWS and a table row has no anchor, so
|
|
66
73
|
`https://ultimate.dev/errors/<code>` was a 404 on every error and was deleted `As of 2026-08-23`. Change it
|
|
@@ -69,9 +76,11 @@ here and in `packages/core/src/error-codes.ts` in the same edit. **It is pinned
|
|
|
69
76
|
`UltimateError`'s, which was the one copy with no mechanical check at all and the one that decides
|
|
70
77
|
where every schema refusal sends its reader.
|
|
71
78
|
|
|
72
|
-
**These
|
|
73
|
-
`core -> schema` was declared 2026-08-27 and collapsed five copies on the CORE side
|
|
74
|
-
`
|
|
79
|
+
**These two are the whole of the duplication that remains, and the edge does not reach them.**
|
|
80
|
+
`core -> schema` was declared 2026-08-27 and collapsed five copies on the CORE side, and
|
|
81
|
+
`ULTIMATE_ERROR_BRAND` in 25.0.0 — declared HERE once, imported by core's `errors.ts`, because one
|
|
82
|
+
value name has one declaring package (`scripts/factory-names.ts`). `singleLine` and
|
|
83
|
+
`ERROR_DOCS_URL` go the other way, and `schema -> core` stays forbidden
|
|
75
84
|
on its merits — `t` is in every bundle graph an app has, so a dependency here is a dependency
|
|
76
85
|
everywhere.
|
|
77
86
|
|
|
@@ -165,9 +174,9 @@ Gotchas:
|
|
|
165
174
|
decimal only (`DECIMAL`); `Number()` also reads `0x10`.
|
|
166
175
|
- Adding a `SchemaKind` means updating `json-schema.ts`, `coerce.ts` and `node-fits.ts` in the same commit.
|
|
167
176
|
- **`wire-schema.ts` is the one MCP tool-schema narrowing** (`toWireSchema`, `toWireOutputSchema`,
|
|
168
|
-
`WireJsonSchema`): `@ultimat3/mcp`'s `tools/list`
|
|
169
|
-
|
|
170
|
-
`action`
|
|
177
|
+
`WireJsonSchema`): `@ultimat3/mcp`'s `tools/list` (`toolFrom`, the one tool projection
|
|
178
|
+
since 25.0.0 deleted `.tool()`) publishes through it. It lived in `mcp` (tier 4) while
|
|
179
|
+
`action` carried a second projection, and the two documents differed (`format`, output bounds).
|
|
171
180
|
- **`ToJsonSchemaOptions.dialect` is a closed vocabulary read with `Object.hasOwn`** (`As of
|
|
172
181
|
2026-09-06`). `DIALECTS[dialect]` on an object literal answered the `Object` FUNCTION for
|
|
173
182
|
`dialect: 'constructor'` — measured: `$schema` held it, `JSON.stringify` dropped the key in
|
|
@@ -185,6 +194,13 @@ Gotchas:
|
|
|
185
194
|
a schema change is not allowed to edit. `refinements` and `discriminant` are both fields for that
|
|
186
195
|
reason: a refined string still reads as a string, and a discriminated union still reads as a
|
|
187
196
|
union, everywhere. `lazy` and `tuple` cannot be — which is why neither has shipped.
|
|
197
|
+
- **`t.json()` is a `SchemaKind`, the one exception to the line above** (`As of 2026-10`):
|
|
198
|
+
`unknown` means the IR cannot describe a value, `json` that it can — and every foreign
|
|
199
|
+
`default:` already projects it correctly (`{}`, raw pass-through, a `null` sample, admin's JSON
|
|
200
|
+
textarea). `JsonValue` in `json-value.ts` is the framework's one declaration; `cli`, `render`,
|
|
201
|
+
`manifest` and `realtime` still restate it. Its path segments are POSITIONS (the `t.record`
|
|
202
|
+
rule), shared values are memoised by height so a DAG is linear, and `JSON_MAX_DEPTH` is fixed:
|
|
203
|
+
a caller-set bound needs a declaration-time refusal code this package does not have yet.
|
|
188
204
|
- A refinement is carried as a **declaration** (`name`, `message`, `path`), never a closure: a
|
|
189
205
|
predicate cannot cross into OpenAPI or an MCP tool schema, and `refine`'s `message` is rendered
|
|
190
206
|
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` |
|
|
@@ -26,7 +27,7 @@ export const publishPost = t.object({
|
|
|
26
27
|
postId: t.uuid,
|
|
27
28
|
notify: t.boolean.default(true),
|
|
28
29
|
title: t.string.min(3).max(80),
|
|
29
|
-
tags: t.array(t.slug),
|
|
30
|
+
tags: t.array(t.slug, { max: 10 }), // inclusive item-count bounds, both optional
|
|
30
31
|
price: t.money, // { minor: 1999, currency: 'EUR' } — never a float
|
|
31
32
|
// { minor: 2, currency: 'USD', scale: 6 } is $0.000002
|
|
32
33
|
timeZone: t.timezone, // real IANA validation, not an annotation
|
|
@@ -39,7 +40,7 @@ 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.
|
|
@@ -54,11 +55,35 @@ Refused at the boundary rather than guessed at (`As of 2026-10`):
|
|
|
54
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 |
|
|
55
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 `{}` |
|
|
56
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
|
+
| `t.array(items, { min, max })` | fewer than `min` or more than `max` items, before any item is read; at declaration, a bound that is not a whole number from 0 up, or `min > max` — `X_SCHEMA_BOUNDS_INVALID` | the bounds sit on the array node, so OpenAPI and the MCP tool schema publish them as `minItems`/`maxItems` — a `t.refine` count was prose no generated client could enforce |
|
|
59
|
+
|
|
60
|
+
### `t.json()` — a payload whose shape is somebody else's
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { t, type Infer, type JsonValue } from '@ultimat3/schema';
|
|
64
|
+
|
|
65
|
+
export const hook = t.object({ event: t.slug, payload: t.json() });
|
|
66
|
+
export const payload: JsonValue = ({} as Infer<typeof hook>).payload;
|
|
67
|
+
// entity column: json(t.json()) — a jsonb that stores what it was sent
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Accepts | Refuses (with the issue's path) |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `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 |
|
|
73
|
+
| 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) |
|
|
74
|
+
| at most `JSON_MAX_DEPTH` (256) nested containers | one more: the walk recurses per level, and an unbounded one was a `RangeError` |
|
|
75
|
+
|
|
76
|
+
A path into a JSON object is the entry's **position** (`payload[2][0]`), never its key — the
|
|
77
|
+
`t.record` rule. A value shared twice is walked once, so a DAG is linear, not exponential. The
|
|
78
|
+
JSON Schema is `{}` with the depth bound in `description`; the bound is fixed — narrow it with
|
|
79
|
+
`.refine()`. Node `kind: 'json'`, distinct from `unknown` ("the IR cannot say").
|
|
57
80
|
|
|
58
81
|
Every string-backed schema (`string` `uuid` `email` `url` `timezone` `locale` `slug` `cursor`, with
|
|
59
|
-
any `.min/.max/.pattern`) and every `t.record` key **refuses U+0000
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
any `.min/.max/.pattern`) and every `t.record` key **refuses U+0000 and a lone UTF-16 surrogate** —
|
|
83
|
+
the rule `t.json()` applies, one predicate (`unstorable-text.ts`). Postgres `text` and `jsonb` cannot
|
|
84
|
+
store a NUL, `jsonb` refuses a lone surrogate, and into `text` Bun's sql writes U+FFFD in its place,
|
|
85
|
+
so either reached the row write as a 500 or a silent rewrite. A well-formed pair (an emoji), tabs,
|
|
86
|
+
newlines and the other C0 controls stay legal text. The issue names the fact, never the value:
|
|
62
87
|
`title: expected a non-empty string, received a string of 3 characters that contains a NUL
|
|
63
88
|
character (U+0000)`. Not published in the JSON Schema — it is a storage fact, not a format.
|
|
64
89
|
|
|
@@ -160,6 +185,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
160
185
|
|
|
161
186
|
| Class | Code | Declared in |
|
|
162
187
|
|---|---|---|
|
|
188
|
+
| `BoundsInvalidError` (extends `SchemaError`) | `X_SCHEMA_BOUNDS_INVALID` | `src/errors.ts` |
|
|
163
189
|
| `DefaultInvalidError` (extends `SchemaError`) | `X_SCHEMA_DEFAULT_INVALID` | `src/errors.ts` |
|
|
164
190
|
| `DiscriminantInvalidError` (extends `SchemaError`) | `X_SCHEMA_DISCRIMINANT_INVALID` | `src/errors.ts` |
|
|
165
191
|
| `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` |
|
|
@@ -188,16 +214,18 @@ swap. `introspect()` is what OpenAPI/MCP generation needs; a provider without it
|
|
|
188
214
|
|
|
189
215
|
```ts
|
|
190
216
|
toJsonSchema(publishPost); // OpenAPI 3.1 (2020-12)
|
|
191
|
-
toWireSchema(publishPost); // an MCP tool's inputSchema: what tools/list serves
|
|
217
|
+
toWireSchema(publishPost); // an MCP tool's inputSchema: what tools/list serves
|
|
192
218
|
toWireOutputSchema(publishPost); // its outputSchema: structure only; undefined for a non-object root
|
|
193
219
|
toJsonSchema(publishPost, { dialect: 'x' }); // X_SCHEMA_UNSUPPORTED — the two spellings are closed
|
|
194
220
|
parse(publishPost, coerceQuery(publishPost, url.searchParams));
|
|
221
|
+
numeric(' 12 '); // 12 — numeric('0x10'), numeric(' '): undefined
|
|
195
222
|
```
|
|
196
223
|
|
|
197
224
|
Coercion is separate from validation on purpose: only the HTTP layer has strings that mean
|
|
198
225
|
numbers. `coerceQuery` promotes repeated params to arrays and leaves anything ambiguous
|
|
199
226
|
untouched so validation still produces the real error. It never invents data: only a **decimal**
|
|
200
|
-
numeral becomes a number (`0x10` stays text
|
|
227
|
+
numeral becomes a number (`0x10` stays text; `numeric` is that one reader, for a boundary that
|
|
228
|
+
holds a string and no schema node — admin's form decode), only a plain object is read as one (an array is never
|
|
201
229
|
spread into `{ 0: … }`), and a union tries **every** member in declaration order — a string some
|
|
202
230
|
member already accepts is left alone, and a union of objects coerces through the member whose
|
|
203
231
|
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.1.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,79 @@
|
|
|
1
|
+
// Single responsibility: `t.array(items, { min, max })` — the list validator and its item-count
|
|
2
|
+
// bounds. Its own file because the bounds are a declaration-time refusal as well as a parse rule,
|
|
3
|
+
// and `validators.ts` was at the line ceiling.
|
|
4
|
+
|
|
5
|
+
import { type AnySchema, checkOf, fail, failWith, makeSchema, pass, type Schema } from './builder';
|
|
6
|
+
import { describeValue, expected } from './describe-value';
|
|
7
|
+
import { BoundsInvalidError } from './errors';
|
|
8
|
+
import type { SchemaNode } from './node';
|
|
9
|
+
import type { InferInput, InferOutput, StandardIssue } from './standard';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Item-count bounds, inclusive. ON the array node, never an element rule or a `t.refine`: the node
|
|
13
|
+
* is what OpenAPI, the MCP tool schema and the contract sampler read, so a bound kept anywhere
|
|
14
|
+
* else is one every projection omits.
|
|
15
|
+
*/
|
|
16
|
+
export interface ArrayBounds {
|
|
17
|
+
readonly min?: number | undefined;
|
|
18
|
+
readonly max?: number | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const items = (count: number): string => `${count} item${count === 1 ? '' : 's'}`;
|
|
22
|
+
|
|
23
|
+
/** A bound is a whole count from zero up — anything else matches no array, or every array. */
|
|
24
|
+
function boundFault(name: 'min' | 'max', bound: number | undefined): string | undefined {
|
|
25
|
+
if (bound === undefined || (Number.isSafeInteger(bound) && bound >= 0)) return undefined;
|
|
26
|
+
// `describeValue` for the shape, the value whole in `meta`: the house rule for a cause.
|
|
27
|
+
return `t.array's ${name} must be a whole number from 0 up; got ${describeValue(bound)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertBounds(bounds: ArrayBounds): void {
|
|
31
|
+
const { min, max } = bounds;
|
|
32
|
+
const fault =
|
|
33
|
+
boundFault('min', min) ??
|
|
34
|
+
boundFault('max', max) ??
|
|
35
|
+
(min !== undefined && max !== undefined && min > max
|
|
36
|
+
? `t.array's min (${min}) is above its max (${max}), so no array satisfies it`
|
|
37
|
+
: undefined);
|
|
38
|
+
if (fault === undefined) return;
|
|
39
|
+
throw new BoundsInvalidError({
|
|
40
|
+
cause: fault,
|
|
41
|
+
fix: 't.array(items, { min: 1, max: 20 }) # whole numbers, 0 <= min <= max; omit a bound to leave that side open',
|
|
42
|
+
meta: { min, max },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function arraySchema<S extends AnySchema>(
|
|
47
|
+
item: S,
|
|
48
|
+
bounds: ArrayBounds = {},
|
|
49
|
+
): Schema<readonly InferInput<S>[], InferOutput<S>[]> {
|
|
50
|
+
assertBounds(bounds);
|
|
51
|
+
const { min, max } = bounds;
|
|
52
|
+
const itemCheck = checkOf(item);
|
|
53
|
+
const node: SchemaNode = {
|
|
54
|
+
kind: 'array',
|
|
55
|
+
items: item.node,
|
|
56
|
+
...(min === undefined ? {} : { minItems: min }),
|
|
57
|
+
...(max === undefined ? {} : { maxItems: max }),
|
|
58
|
+
};
|
|
59
|
+
return makeSchema<readonly InferInput<S>[], InferOutput<S>[]>(node, (value, path) => {
|
|
60
|
+
if (!Array.isArray(value)) return fail(path, expected('an array', value));
|
|
61
|
+
// The count before the items: an over-long array is refused without reading a member, so a
|
|
62
|
+
// caller's 10,000-element body costs one comparison, not 10,000 item checks.
|
|
63
|
+
if (min !== undefined && value.length < min) {
|
|
64
|
+
return fail(path, expected(`an array of at least ${items(min)}`, value));
|
|
65
|
+
}
|
|
66
|
+
if (max !== undefined && value.length > max) {
|
|
67
|
+
return fail(path, expected(`an array of at most ${items(max)}`, value));
|
|
68
|
+
}
|
|
69
|
+
const issues: StandardIssue[] = [];
|
|
70
|
+
const out: unknown[] = [];
|
|
71
|
+
for (const [index, member] of value.entries()) {
|
|
72
|
+
const result = itemCheck(member, [...path, index]);
|
|
73
|
+
if (result.ok) out.push(result.value);
|
|
74
|
+
else issues.push(...result.issues);
|
|
75
|
+
}
|
|
76
|
+
if (issues.length > 0) return failWith(issues);
|
|
77
|
+
return pass(out as InferOutput<S>[]);
|
|
78
|
+
});
|
|
79
|
+
}
|
package/src/coerce.ts
CHANGED
|
@@ -18,8 +18,12 @@ const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
|
|
18
18
|
*/
|
|
19
19
|
const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
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 {
|
|
23
27
|
if (typeof raw !== 'string' || !DECIMAL.test(raw.trim())) return undefined;
|
|
24
28
|
const value = Number(raw);
|
|
25
29
|
return Number.isFinite(value) ? value : undefined;
|
|
@@ -133,6 +137,10 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
|
|
|
133
137
|
}
|
|
134
138
|
return raw;
|
|
135
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;
|
|
136
144
|
default:
|
|
137
145
|
return raw;
|
|
138
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
|
@@ -27,4 +27,5 @@ export const SCHEMA_ERROR_CODES: Readonly<Record<string, SchemaErrorCodeDeclarat
|
|
|
27
27
|
title: 'a schema default cannot be copied per parse',
|
|
28
28
|
},
|
|
29
29
|
X_SCHEMA_DEFAULT_INVALID: { title: 'a schema default fails its own schema' },
|
|
30
|
+
X_SCHEMA_BOUNDS_INVALID: { title: 'an array bound no array can satisfy' },
|
|
30
31
|
});
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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';
|
|
6
7
|
import { renderMetaRecord } from './render-meta';
|
|
7
8
|
import { formatIssues } from './standard';
|
|
@@ -11,8 +12,8 @@ import { formatIssues } from './standard';
|
|
|
11
12
|
* there — briefly: the 3-line format is line-oriented, and a `cause` may hold a value the caller
|
|
12
13
|
* chose, so a newline in one writes a line an operator reads as a genuine framework message.
|
|
13
14
|
*
|
|
14
|
-
* Copied rather than imported
|
|
15
|
-
*
|
|
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
|
|
16
17
|
* cause is the one most likely to carry a hostile string — it describes the value that failed
|
|
17
18
|
* validation, which is the request body.
|
|
18
19
|
*/
|
|
@@ -35,9 +36,6 @@ const singleLine = (text: string): string =>
|
|
|
35
36
|
(char) => ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
36
37
|
);
|
|
37
38
|
|
|
38
|
-
/** Same well-known symbol `@ultimat3/core` brands with. Keep in sync, never rename. */
|
|
39
|
-
export const ULTIMATE_ERROR_BRAND: unique symbol = Symbol.for('ultimate.error');
|
|
40
|
-
|
|
41
39
|
/**
|
|
42
40
|
* The same one URL `@ultimat3/core`'s `ERROR_DOCS_URL` holds. **Keep in sync**, and read the
|
|
43
41
|
* reason there — briefly: `wiki/` is the only public documentation surface, codes live on that
|
|
@@ -212,6 +210,19 @@ export class DefaultInvalidError extends SchemaError {
|
|
|
212
210
|
}
|
|
213
211
|
}
|
|
214
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Thrown where `t.array(items, { min, max })` is WRITTEN: a bound no item count meets refuses every
|
|
215
|
+
* input, and the first import of the authoring file is the earliest honest place to say so.
|
|
216
|
+
*/
|
|
217
|
+
export class BoundsInvalidError extends SchemaError {
|
|
218
|
+
static readonly code = 'X_SCHEMA_BOUNDS_INVALID';
|
|
219
|
+
override readonly name = 'BoundsInvalidError';
|
|
220
|
+
|
|
221
|
+
constructor(init: Omit<SchemaErrorInit, 'code'>) {
|
|
222
|
+
super({ ...init, code: BoundsInvalidError.code });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
215
226
|
export class SchemaUnsupportedError extends SchemaError {
|
|
216
227
|
static readonly code = 'X_SCHEMA_UNSUPPORTED';
|
|
217
228
|
override readonly name = 'SchemaUnsupportedError';
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Single responsibility: the public API of @ultimat3/schema. Explicit named exports only.
|
|
2
2
|
|
|
3
|
+
export type { ArrayBounds } from './array-schema';
|
|
4
|
+
export { arraySchema } from './array-schema';
|
|
3
5
|
export type {
|
|
4
6
|
AnySchema,
|
|
5
7
|
Check,
|
|
@@ -22,9 +24,10 @@ export { checkOf, fail, failWith, makeSchema, pass, VENDOR } from './builder';
|
|
|
22
24
|
// not import this package — but `mcp` is tier 4 and can, so a copy there would have no excuse.
|
|
23
25
|
export { charCount } from './char-count';
|
|
24
26
|
export type { QuerySource } from './coerce';
|
|
25
|
-
export { coerceInput, coerceNode, coerceQuery } from './coerce';
|
|
27
|
+
export { coerceInput, coerceNode, coerceQuery, numeric } from './coerce';
|
|
26
28
|
export { describeValue, expected } from './describe-value';
|
|
27
29
|
export { discriminatedUnionSchema } from './discriminated-union';
|
|
30
|
+
export { ULTIMATE_ERROR_BRAND } from './error-brand';
|
|
28
31
|
export type { SchemaErrorCodeDeclaration } from './error-codes';
|
|
29
32
|
export { SCHEMA_ERROR_CODES } from './error-codes';
|
|
30
33
|
export type {
|
|
@@ -34,12 +37,12 @@ export type {
|
|
|
34
37
|
ValidationIssue,
|
|
35
38
|
} from './errors';
|
|
36
39
|
export {
|
|
40
|
+
BoundsInvalidError,
|
|
37
41
|
DefaultInvalidError,
|
|
38
42
|
DiscriminantInvalidError,
|
|
39
43
|
isSchemaError,
|
|
40
44
|
SchemaError,
|
|
41
45
|
SchemaUnsupportedError,
|
|
42
|
-
ULTIMATE_ERROR_BRAND,
|
|
43
46
|
ValidationFailedError,
|
|
44
47
|
} from './errors';
|
|
45
48
|
// Exported for `@ultimat3/time`'s `fromIso`: one ISO-shape rule, whichever door a date comes in by.
|
|
@@ -52,6 +55,8 @@ export type {
|
|
|
52
55
|
ToJsonSchemaOptions,
|
|
53
56
|
} from './json-schema';
|
|
54
57
|
export { nodeToJsonSchema, toJsonSchema } from './json-schema';
|
|
58
|
+
export type { JsonValue } from './json-value';
|
|
59
|
+
export { JSON_MAX_DEPTH, jsonSchema } from './json-value';
|
|
55
60
|
export type { MoneyValue } from './money-value';
|
|
56
61
|
export {
|
|
57
62
|
CURRENCY_CODE_PATTERN,
|
|
@@ -103,7 +108,6 @@ export type {
|
|
|
103
108
|
TNamespace,
|
|
104
109
|
} from './validators';
|
|
105
110
|
export {
|
|
106
|
-
arraySchema,
|
|
107
111
|
builtinT,
|
|
108
112
|
enumSchema,
|
|
109
113
|
literalSchema,
|
package/src/json-schema.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { describeValue } from './describe-value';
|
|
7
7
|
import { SchemaUnsupportedError } from './errors';
|
|
8
|
+
import { JSON_MAX_DEPTH } from './json-value';
|
|
8
9
|
import { CURRENCY_CODE_PATTERN, MAX_MONEY_SCALE } from './money-value';
|
|
9
10
|
import { requiredKeys, type SchemaNode, type SchemaRefinement } from './node';
|
|
10
11
|
import { PROTOTYPE_KEYS } from './prototype-keys';
|
|
@@ -53,6 +54,8 @@ export interface JsonSchema {
|
|
|
53
54
|
/** Negation. Emitted only inside `propertyNames`, to publish a closed set of refused keys. */
|
|
54
55
|
readonly not?: JsonSchema;
|
|
55
56
|
readonly items?: JsonSchema;
|
|
57
|
+
readonly minItems?: number;
|
|
58
|
+
readonly maxItems?: number;
|
|
56
59
|
readonly anyOf?: readonly JsonSchema[];
|
|
57
60
|
readonly discriminator?: JsonSchemaDiscriminator;
|
|
58
61
|
readonly title?: string;
|
|
@@ -158,10 +161,22 @@ function patternNote(node: SchemaNode): string | undefined {
|
|
|
158
161
|
: `pattern is applied with RegExp flags "${flags}"`;
|
|
159
162
|
}
|
|
160
163
|
|
|
164
|
+
/**
|
|
165
|
+
* JSON Schema has no keyword for nesting depth, so `t.json()`'s bound is stated in prose — the
|
|
166
|
+
* narrower-of-the-two rule this file's header states: `{}` alone promised a document 300 levels
|
|
167
|
+
* deep that the parser refuses.
|
|
168
|
+
*/
|
|
169
|
+
function jsonNote(node: SchemaNode): string | undefined {
|
|
170
|
+
return node.kind === 'json'
|
|
171
|
+
? `any JSON value, nested at most ${JSON_MAX_DEPTH} levels deep`
|
|
172
|
+
: undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
161
175
|
function convert(node: SchemaNode): JsonSchema {
|
|
162
176
|
const refinements = node.refinements ?? [];
|
|
163
177
|
const notes = [
|
|
164
178
|
node.description,
|
|
179
|
+
jsonNote(node),
|
|
165
180
|
patternNote(node),
|
|
166
181
|
...refinements.map((refinement) => refinement.message),
|
|
167
182
|
].filter((part): part is string => part !== undefined);
|
|
@@ -214,6 +229,8 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
214
229
|
return annotate({
|
|
215
230
|
type: 'array',
|
|
216
231
|
items: node.items === undefined ? {} : convert(node.items),
|
|
232
|
+
...(node.minItems === undefined ? {} : { minItems: node.minItems }),
|
|
233
|
+
...(node.maxItems === undefined ? {} : { maxItems: node.maxItems }),
|
|
217
234
|
});
|
|
218
235
|
case 'union':
|
|
219
236
|
return annotate({
|
|
@@ -282,6 +299,10 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
282
299
|
additionalProperties: false,
|
|
283
300
|
});
|
|
284
301
|
}
|
|
302
|
+
// No `type`: an empty schema is JSON Schema's own spelling of "any JSON value", and every
|
|
303
|
+
// instance it validates is JSON already. The depth bound rides in `description` (`jsonNote`).
|
|
304
|
+
case 'json':
|
|
305
|
+
return annotate({});
|
|
285
306
|
default:
|
|
286
307
|
return annotate({});
|
|
287
308
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Single responsibility: the framework's ONE `JsonValue` declaration and `t.json()`, the validator
|
|
2
|
+
// that admits exactly the values `JSON.stringify` and a Postgres `jsonb` round-trip unchanged.
|
|
3
|
+
// Its own file for `money-value.ts`'s reason: it is a builtin whose SHAPE other packages alias.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Check,
|
|
7
|
+
fail,
|
|
8
|
+
failWith,
|
|
9
|
+
isPlainObject,
|
|
10
|
+
makeSchema,
|
|
11
|
+
pass,
|
|
12
|
+
type Schema,
|
|
13
|
+
} from './builder';
|
|
14
|
+
import { expected } from './describe-value';
|
|
15
|
+
import type { SchemaNode } from './node';
|
|
16
|
+
import type { StandardIssue } from './standard';
|
|
17
|
+
import { unstorable } from './unstorable-text';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Any value JSON can carry. Readonly, because a parsed payload is data the handler reads; the copy
|
|
21
|
+
* `t.json()` answers is its own, so nothing it holds is shared with the caller's input.
|
|
22
|
+
*/
|
|
23
|
+
export type JsonValue =
|
|
24
|
+
| string
|
|
25
|
+
| number
|
|
26
|
+
| boolean
|
|
27
|
+
| null
|
|
28
|
+
| readonly JsonValue[]
|
|
29
|
+
| { readonly [key: string]: JsonValue };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many containers `t.json()` lets nest. A FIXED bound, not an option: the walk recurses once
|
|
33
|
+
* per level and `JSON.parse` builds 100 000 levels from 200 KB of brackets, so an unbounded walk
|
|
34
|
+
* was a `RangeError` — a 500 — for any caller who sent one. 256 holds a rich-text document (each
|
|
35
|
+
* list level is an array and an object) with room to spare. Narrower is `.refine()`'s job.
|
|
36
|
+
*/
|
|
37
|
+
export const JSON_MAX_DEPTH = 256;
|
|
38
|
+
|
|
39
|
+
/** A path segment chain, materialised only for an issue: a copy per node is O(n × depth). */
|
|
40
|
+
interface At {
|
|
41
|
+
readonly parent: At | readonly PropertyKey[];
|
|
42
|
+
readonly segment: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pathOf(at: At | readonly PropertyKey[]): PropertyKey[] {
|
|
46
|
+
const segments: number[] = [];
|
|
47
|
+
let cursor = at;
|
|
48
|
+
while (!Array.isArray(cursor)) {
|
|
49
|
+
const link = cursor as At;
|
|
50
|
+
segments.push(link.segment);
|
|
51
|
+
cursor = link.parent;
|
|
52
|
+
}
|
|
53
|
+
return [...(cursor as readonly PropertyKey[]), ...segments.reverse()];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Walk {
|
|
57
|
+
readonly issues: StandardIssue[];
|
|
58
|
+
/** The containers enclosing the current one: meeting one again is a cycle. */
|
|
59
|
+
readonly open: Set<object>;
|
|
60
|
+
/** A container already accepted, with its height, so a shared one is walked ONCE. */
|
|
61
|
+
readonly done: Map<object, { readonly value: JsonValue; readonly height: number }>;
|
|
62
|
+
/** A container already refused: its issues are reported, at the first position it was met. */
|
|
63
|
+
readonly refused: Set<object>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Step = { readonly ok: true; readonly value: JsonValue; readonly height: number } | null;
|
|
67
|
+
|
|
68
|
+
const CYCLE = 'expected a JSON value, received a cycle (a reference back to an enclosing value)';
|
|
69
|
+
const NOT_PLAIN = 'expected a JSON value, received an object that is not a plain object or array';
|
|
70
|
+
|
|
71
|
+
function refuse(state: Walk, at: At | readonly PropertyKey[], message: string): null {
|
|
72
|
+
state.issues.push({ message, path: pathOf(at) });
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function scalar(value: unknown, at: At | readonly PropertyKey[], state: Walk): Step {
|
|
77
|
+
if (value === null || typeof value === 'boolean') return { ok: true, value, height: 0 };
|
|
78
|
+
if (typeof value === 'number') {
|
|
79
|
+
return Number.isFinite(value)
|
|
80
|
+
? { ok: true, value, height: 0 }
|
|
81
|
+
: refuse(state, at, expected('a JSON value', value));
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === 'string') {
|
|
84
|
+
const reason = unstorable(value);
|
|
85
|
+
return reason === undefined
|
|
86
|
+
? { ok: true, value, height: 0 }
|
|
87
|
+
: refuse(state, at, `${expected('a JSON value', value)} ${reason}`);
|
|
88
|
+
}
|
|
89
|
+
return refuse(state, at, expected('a JSON value', value));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function walk(value: unknown, at: At | readonly PropertyKey[], depth: number, state: Walk): Step {
|
|
93
|
+
if (typeof value !== 'object' || value === null) return scalar(value, at, state);
|
|
94
|
+
const array = Array.isArray(value);
|
|
95
|
+
if (!array && !isPlainObject(value)) {
|
|
96
|
+
// A Date names itself (`describeValue`); a Map, a Set or a class instance is "an object" there.
|
|
97
|
+
return refuse(state, at, value instanceof Date ? expected('a JSON value', value) : NOT_PLAIN);
|
|
98
|
+
}
|
|
99
|
+
if (state.open.has(value)) return refuse(state, at, CYCLE);
|
|
100
|
+
if (state.refused.has(value)) return null;
|
|
101
|
+
const seen = state.done.get(value);
|
|
102
|
+
const tooDeep = expected(`JSON nested at most ${JSON_MAX_DEPTH} levels deep`, value);
|
|
103
|
+
if (seen !== undefined) {
|
|
104
|
+
return depth + seen.height <= JSON_MAX_DEPTH
|
|
105
|
+
? { ok: true, ...seen }
|
|
106
|
+
: refuse(state, at, tooDeep);
|
|
107
|
+
}
|
|
108
|
+
if (depth >= JSON_MAX_DEPTH) return refuse(state, at, tooDeep);
|
|
109
|
+
|
|
110
|
+
state.open.add(value);
|
|
111
|
+
const built = array ? walkArray(value, at, depth, state) : walkObject(value, at, depth, state);
|
|
112
|
+
state.open.delete(value);
|
|
113
|
+
if (built === null) {
|
|
114
|
+
state.refused.add(value);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
state.done.set(value, built);
|
|
118
|
+
return { ok: true, ...built };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type Built = { readonly value: JsonValue; readonly height: number } | null;
|
|
122
|
+
|
|
123
|
+
function walkArray(
|
|
124
|
+
items: readonly unknown[],
|
|
125
|
+
at: At | readonly PropertyKey[],
|
|
126
|
+
depth: number,
|
|
127
|
+
state: Walk,
|
|
128
|
+
): Built {
|
|
129
|
+
const out: JsonValue[] = [];
|
|
130
|
+
let height = 0;
|
|
131
|
+
let ok = true;
|
|
132
|
+
// An index loop, never `entries()` on a sparse array alone: a hole reads `undefined` and is
|
|
133
|
+
// refused at its index, where `JSON.stringify` would have written `null` in silence.
|
|
134
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
135
|
+
const step = walk(items[index], { parent: at, segment: index }, depth + 1, state);
|
|
136
|
+
if (step === null) ok = false;
|
|
137
|
+
else {
|
|
138
|
+
out.push(step.value);
|
|
139
|
+
height = Math.max(height, step.height);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return ok ? { value: out, height: height + 1 } : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function walkObject(
|
|
146
|
+
source: Record<string, unknown>,
|
|
147
|
+
at: At | readonly PropertyKey[],
|
|
148
|
+
depth: number,
|
|
149
|
+
state: Walk,
|
|
150
|
+
): Built {
|
|
151
|
+
// Null-prototype, for `recordSchema`'s reason: every key here is the caller's, and on a `{}`
|
|
152
|
+
// literal `out['__proto__'] = …` re-prototypes the output instead of storing the key.
|
|
153
|
+
const out: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>;
|
|
154
|
+
let height = 0;
|
|
155
|
+
let ok = true;
|
|
156
|
+
// The entry's POSITION is its path segment, never its key — the rule `recordSchema` states:
|
|
157
|
+
// a key is caller data, and an issue path reaches the problem document and the log line.
|
|
158
|
+
for (const [index, key] of Object.keys(source).entries()) {
|
|
159
|
+
const link: At = { parent: at, segment: index };
|
|
160
|
+
const reason = unstorable(key);
|
|
161
|
+
if (reason !== undefined) {
|
|
162
|
+
refuse(state, link, `expected an object key, received one ${reason}`);
|
|
163
|
+
ok = false;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const step = walk(source[key], link, depth + 1, state);
|
|
167
|
+
if (step === null) ok = false;
|
|
168
|
+
else {
|
|
169
|
+
out[key] = step.value;
|
|
170
|
+
height = Math.max(height, step.height);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return ok ? { value: out, height: height + 1 } : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const checkJson: Check<JsonValue> = (value, path) => {
|
|
177
|
+
const state: Walk = { issues: [], open: new Set(), done: new Map(), refused: new Set() };
|
|
178
|
+
const step = walk(value, path, 0, state);
|
|
179
|
+
if (step !== null) return pass(step.value);
|
|
180
|
+
// Every refusal pushes its issue; the fallback only keeps a refusal from ever reading as success.
|
|
181
|
+
return state.issues.length > 0
|
|
182
|
+
? failWith(state.issues)
|
|
183
|
+
: fail(path, expected('a JSON value', value));
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether a value has a JSON SHAPE, one level deep — `node-fits.ts`'s question for a union
|
|
188
|
+
* member. Not validation: a nested `undefined` still fits, and is refused by the parse after.
|
|
189
|
+
*/
|
|
190
|
+
export function isJsonShape(value: unknown): boolean {
|
|
191
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') return true;
|
|
192
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
193
|
+
return Array.isArray(value) || isPlainObject(value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const JSON_NODE: SchemaNode = Object.freeze({ kind: 'json' });
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `t.json()` as a free function, shipped beside the namespace member as `nullableSchema` is.
|
|
200
|
+
* Every key is kept, `__proto__` and `constructor` included — a webhook body is somebody else's
|
|
201
|
+
* document — and answered on a null-prototype copy, so no key reaches a prototype.
|
|
202
|
+
*/
|
|
203
|
+
export function jsonSchema(): Schema<JsonValue, JsonValue> {
|
|
204
|
+
return makeSchema<JsonValue, JsonValue>(JSON_NODE, checkJson);
|
|
205
|
+
}
|
package/src/money-value.ts
CHANGED
|
@@ -106,7 +106,7 @@ export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue
|
|
|
106
106
|
const currency = value['currency'];
|
|
107
107
|
const scale = value['scale'];
|
|
108
108
|
const issues: StandardIssue[] = [];
|
|
109
|
-
// Safe, not merely whole: `
|
|
109
|
+
// Safe, not merely whole: `fromMinor()` and `entity`'s `parseMinor` both demand a safe integer, so
|
|
110
110
|
// `Number.isInteger` here let 2^53 through the boundary as a 200 and failed at the row write
|
|
111
111
|
// as a 500 — the same value refused twice, once with a field path and once without.
|
|
112
112
|
if (typeof minor !== 'number' || !Number.isSafeInteger(minor)) {
|
package/src/node-fits.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// `coerce.ts` asks to pick the union member a raw HTTP value was written for.
|
|
3
3
|
|
|
4
4
|
import { isPlainObject } from './builder';
|
|
5
|
+
import { isJsonShape } from './json-value';
|
|
5
6
|
import type { SchemaNode } from './node';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -42,6 +43,8 @@ export function fits(node: SchemaNode, value: unknown): boolean {
|
|
|
42
43
|
}
|
|
43
44
|
case 'union':
|
|
44
45
|
return (node.anyOf ?? []).some((member) => fits(member, value));
|
|
46
|
+
case 'json':
|
|
47
|
+
return isJsonShape(value);
|
|
45
48
|
default:
|
|
46
49
|
return false;
|
|
47
50
|
}
|
package/src/node.ts
CHANGED
|
@@ -14,6 +14,14 @@ export type SchemaKind =
|
|
|
14
14
|
| 'union'
|
|
15
15
|
| 'record'
|
|
16
16
|
| 'money'
|
|
17
|
+
/**
|
|
18
|
+
* Any JSON value — `t.json()`. A kind and not a field on `unknown`, because they are different
|
|
19
|
+
* facts: `unknown` is the IR saying it CANNOT describe a value (`@ultimat3/entity`'s view of a
|
|
20
|
+
* column it has no node for), `json` is the IR saying exactly what it holds. Every foreign
|
|
21
|
+
* `default:` already degrades to the right projection — `{}`, a raw pass-through, a `null`
|
|
22
|
+
* sample, admin's JSON textarea — so a consumer that has never heard of it is still correct.
|
|
23
|
+
*/
|
|
24
|
+
| 'json'
|
|
17
25
|
| 'unknown';
|
|
18
26
|
|
|
19
27
|
/** JSON Schema `format`, plus the framework's own semantic formats. */
|
|
@@ -63,6 +71,9 @@ export interface SchemaNode {
|
|
|
63
71
|
readonly integer?: boolean | undefined;
|
|
64
72
|
readonly properties?: Readonly<Record<string, SchemaNode>> | undefined;
|
|
65
73
|
readonly items?: SchemaNode | undefined;
|
|
74
|
+
/** An array's inclusive item-count bounds — JSON Schema's `minItems`/`maxItems`, by the same name. */
|
|
75
|
+
readonly minItems?: number | undefined;
|
|
76
|
+
readonly maxItems?: number | undefined;
|
|
66
77
|
readonly values?: readonly (string | number)[] | undefined;
|
|
67
78
|
readonly literal?: string | number | boolean | null | undefined;
|
|
68
79
|
readonly anyOf?: readonly SchemaNode[] | undefined;
|
package/src/t.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Every member delegates to the active provider, so `configureSchemaProvider()` takes effect
|
|
3
3
|
// even for modules that captured `t` at import time.
|
|
4
4
|
|
|
5
|
+
import type { ArrayBounds } from './array-schema';
|
|
5
6
|
import type { AnySchema, Refinement, Schema, Shape } from './builder';
|
|
7
|
+
import type { JsonValue } from './json-value';
|
|
6
8
|
import type { MoneyValue } from './money-value';
|
|
7
9
|
import { schemaProvider } from './provider';
|
|
8
10
|
import type { InferInput, InferOutput, StandardSchemaV1 } from './standard';
|
|
@@ -49,11 +51,26 @@ export const t: TNamespace = {
|
|
|
49
51
|
get cursor(): StringSchema {
|
|
50
52
|
return provider().cursor;
|
|
51
53
|
},
|
|
54
|
+
/**
|
|
55
|
+
* `t.json()` — any JSON value: null, a boolean, a finite number, a string, and arrays and plain
|
|
56
|
+
* objects of them, at most `JSON_MAX_DEPTH` deep. The column it pairs with is entity's
|
|
57
|
+
* `json(t.json())`, a `jsonb` that stores what it was sent.
|
|
58
|
+
*/
|
|
59
|
+
json(): Schema<JsonValue, JsonValue> {
|
|
60
|
+
return provider().json();
|
|
61
|
+
},
|
|
52
62
|
object<S extends Shape>(shape: S): ObjectSchema<S> {
|
|
53
63
|
return provider().object(shape);
|
|
54
64
|
},
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
/**
|
|
66
|
+
* `t.array(t.uuid, { min: 1, max: 20 })` — inclusive item-count bounds on the array itself, so
|
|
67
|
+
* OpenAPI and the MCP tool schema publish them as `minItems`/`maxItems`. Omitted, any length.
|
|
68
|
+
*/
|
|
69
|
+
array<S extends AnySchema>(
|
|
70
|
+
items: S,
|
|
71
|
+
bounds?: ArrayBounds,
|
|
72
|
+
): Schema<readonly InferInput<S>[], InferOutput<S>[]> {
|
|
73
|
+
return provider().array(items, bounds);
|
|
57
74
|
},
|
|
58
75
|
enum<const V extends readonly [string, ...string[]]>(values: V): Schema<V[number], V[number]> {
|
|
59
76
|
return provider().enum(values);
|
package/src/time-zone-name.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single responsibility: is a string an IANA zone NAME? TIER 0'S ONE STATEMENT of the rule
|
|
2
2
|
// `@ultimat3/time` enforces everywhere above it — a zone is `Area/Location`, and `UTC` is the one
|
|
3
|
-
// exception. `@ultimat3/core`
|
|
4
|
-
//
|
|
3
|
+
// exception. `@ultimat3/core` carried its own copy until 2026-08-27, then a re-export for the
|
|
4
|
+
// `defaultTimeZone` validator, deleted with that key in 25.0.0.
|
|
5
5
|
//
|
|
6
6
|
// `@ultimat3/time`'s `canonicalTimeZone` is NOT a fourth copy and is deliberately left alone: it
|
|
7
7
|
// answers a different question (the canonical SPELLING, memoised over ~445 listed zones plus a
|
|
@@ -14,9 +14,8 @@
|
|
|
14
14
|
* Unobservable on ICU 78 — `+01:00` resolves to itself, so the slash rule below already refuses it,
|
|
15
15
|
* and deleting this line changes no answer this package can currently produce. It stays because it
|
|
16
16
|
* guards the runtime that folds an offset into `Etc/GMT-1`, which WOULD carry a slash, and because
|
|
17
|
-
* `packages/
|
|
18
|
-
*
|
|
19
|
-
* test.
|
|
17
|
+
* `packages/time/src/zone-canonical.ts` carries the same line: two statements of one rule may not
|
|
18
|
+
* differ, least of all in the half that is hard to test.
|
|
20
19
|
*/
|
|
21
20
|
const NUMERIC_OFFSET = /^[+-]/;
|
|
22
21
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Single responsibility: the ONE predicate naming a string Postgres cannot store as written — the
|
|
2
|
+
// rule every string-backed validator (`t.string` and its kin, a `t.record` key, `t.json()`) shares,
|
|
3
|
+
// so the three can never disagree about which input reaches the row write.
|
|
4
|
+
|
|
5
|
+
const NUL = '\u0000';
|
|
6
|
+
/** A lone UTF-16 surrogate. `String#isWellFormed` is ES2024 and the lib is ES2023. */
|
|
7
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Why a string cannot be stored, or `undefined`. Both MEASURED against Postgres 17: a NUL is
|
|
11
|
+
* refused by `text` and `jsonb` alike (22021 / 22P05), a lone surrogate by `jsonb` (22P02) — and
|
|
12
|
+
* into `text`, Bun's sql encodes it as U+FFFD, so the row holds a different string than the
|
|
13
|
+
* caller sent while a memory store holds the original. Passing either moved the failure to the
|
|
14
|
+
* row write, as a 500 with no field path. Names the fact, never the content (`describe-value.ts`).
|
|
15
|
+
*/
|
|
16
|
+
export function unstorable(text: string): string | undefined {
|
|
17
|
+
if (text.includes(NUL)) return 'that contains a NUL character (U+0000)';
|
|
18
|
+
if (LONE_SURROGATE.test(text)) return 'that contains a lone UTF-16 surrogate';
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
package/src/validators.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Standard Schema surface are what the rest of the framework actually depends on.
|
|
4
4
|
|
|
5
5
|
import { isAbsoluteUrl } from './absolute-url';
|
|
6
|
+
import { type ArrayBounds, arraySchema } from './array-schema';
|
|
6
7
|
import {
|
|
7
8
|
type AnySchema,
|
|
8
9
|
type Check,
|
|
@@ -22,19 +23,17 @@ import { charCount } from './char-count';
|
|
|
22
23
|
import { expected } from './describe-value';
|
|
23
24
|
import { discriminatedUnionSchema } from './discriminated-union';
|
|
24
25
|
import { isIsoDateTime, isZonelessDateTime } from './iso-date';
|
|
26
|
+
import { type JsonValue, jsonSchema } from './json-value';
|
|
25
27
|
import { type MoneyValue, moneySchema } from './money-value';
|
|
26
28
|
import type { SchemaNode } from './node';
|
|
27
29
|
import { isPrototypeKey, PROTOTYPE_KEYS } from './prototype-keys';
|
|
28
30
|
import type { InferInput, InferOutput, StandardIssue } from './standard';
|
|
29
31
|
import { isIanaZoneName } from './time-zone-name';
|
|
32
|
+
import { unstorable } from './unstorable-text';
|
|
30
33
|
|
|
31
34
|
const EMAIL_RE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
|
|
32
35
|
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
33
36
|
const CURSOR_RE = /^[A-Za-z0-9_-]+$/;
|
|
34
|
-
/** The one code point no Postgres `text` or `jsonb` value can hold — see `stringLike`. */
|
|
35
|
-
const NUL = '\u0000';
|
|
36
|
-
/** Names the fact, never the content: the issue-message rule in `describe-value.ts`. */
|
|
37
|
-
const NUL_REFUSAL = 'that contains a NUL character (U+0000)';
|
|
38
37
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
39
38
|
|
|
40
39
|
export interface StringSchema extends Schema<string, string> {
|
|
@@ -89,11 +88,11 @@ function stringLike(node: SchemaNode, what: string, test?: (value: string) => bo
|
|
|
89
88
|
const matchesPattern = patternTester(node);
|
|
90
89
|
const check: Check<string> = (value, path) => {
|
|
91
90
|
if (typeof value !== 'string') return fail(path, expected(what, value));
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
if (
|
|
91
|
+
// A NUL or a lone surrogate passed here reached the row write — or a `where` bind — as
|
|
92
|
+
// `X_DB_STATEMENT_FAILED`, a 500 blaming the server for what the caller sent (`unstorable`).
|
|
93
|
+
// Only those: a tab, a newline or any other C0 control is legal text Postgres stores unchanged.
|
|
94
|
+
const unfit = unstorable(value);
|
|
95
|
+
if (unfit !== undefined) return fail(path, `${expected(what, value)} ${unfit}`);
|
|
97
96
|
if (node.minLength !== undefined && charCount(value) < node.minLength) {
|
|
98
97
|
return fail(path, expected(`${what} of at least ${node.minLength} chars`, value));
|
|
99
98
|
}
|
|
@@ -232,25 +231,6 @@ export function objectSchema<S extends Shape>(shape: S): ObjectSchema<S> {
|
|
|
232
231
|
};
|
|
233
232
|
}
|
|
234
233
|
|
|
235
|
-
export function arraySchema<S extends AnySchema>(
|
|
236
|
-
items: S,
|
|
237
|
-
): Schema<readonly InferInput<S>[], InferOutput<S>[]> {
|
|
238
|
-
const itemCheck = checkOf(items);
|
|
239
|
-
const node: SchemaNode = { kind: 'array', items: items.node };
|
|
240
|
-
return makeSchema<readonly InferInput<S>[], InferOutput<S>[]>(node, (value, path) => {
|
|
241
|
-
if (!Array.isArray(value)) return fail(path, expected('an array', value));
|
|
242
|
-
const issues: StandardIssue[] = [];
|
|
243
|
-
const out: unknown[] = [];
|
|
244
|
-
for (const [index, item] of value.entries()) {
|
|
245
|
-
const result = itemCheck(item, [...path, index]);
|
|
246
|
-
if (result.ok) out.push(result.value);
|
|
247
|
-
else issues.push(...result.issues);
|
|
248
|
-
}
|
|
249
|
-
if (issues.length > 0) return failWith(issues);
|
|
250
|
-
return pass(out as InferOutput<S>[]);
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
234
|
export function enumSchema<const V extends readonly [string, ...string[]]>(
|
|
255
235
|
values: V,
|
|
256
236
|
): Schema<V[number], V[number]> {
|
|
@@ -313,10 +293,11 @@ export function recordSchema<S extends AnySchema>(
|
|
|
313
293
|
// new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of
|
|
314
294
|
// string ones — so it names an entry that exists rather than a byte offset in the body.
|
|
315
295
|
const at = [...path, index];
|
|
316
|
-
// A record is stored as `jsonb` far more often than not, and jsonb refuses
|
|
317
|
-
// exactly as it does
|
|
318
|
-
|
|
319
|
-
|
|
296
|
+
// A record is stored as `jsonb` far more often than not, and jsonb refuses an unstorable
|
|
297
|
+
// KEY exactly as it does a value — so the key is refused here, by position, not quoted.
|
|
298
|
+
const unfitKey = unstorable(key);
|
|
299
|
+
if (unfitKey !== undefined) {
|
|
300
|
+
issues.push({ message: `expected a record key, received one ${unfitKey}`, path: at });
|
|
320
301
|
continue;
|
|
321
302
|
}
|
|
322
303
|
if (isPrototypeKey(key)) {
|
|
@@ -414,8 +395,14 @@ export interface TNamespace {
|
|
|
414
395
|
readonly locale: StringSchema;
|
|
415
396
|
readonly slug: StringSchema;
|
|
416
397
|
readonly cursor: StringSchema;
|
|
398
|
+
/** Any JSON value, bounded in depth — a payload whose shape is somebody else's. */
|
|
399
|
+
json(): Schema<JsonValue, JsonValue>;
|
|
417
400
|
object<S extends Shape>(shape: S): ObjectSchema<S>;
|
|
418
|
-
array
|
|
401
|
+
/** `t.array(items, { min, max })` — inclusive item-count bounds, both optional. */
|
|
402
|
+
array<S extends AnySchema>(
|
|
403
|
+
items: S,
|
|
404
|
+
bounds?: ArrayBounds,
|
|
405
|
+
): Schema<readonly InferInput<S>[], InferOutput<S>[]>;
|
|
419
406
|
enum<const V extends readonly [string, ...string[]]>(values: V): Schema<V[number], V[number]>;
|
|
420
407
|
/** Variadic `enum`; the blessed spelling at call sites. */
|
|
421
408
|
enumerated<const V extends readonly [string, ...string[]]>(
|
|
@@ -468,6 +455,7 @@ export const builtinT: TNamespace = Object.freeze({
|
|
|
468
455
|
{ kind: 'string', format: 'cursor', pattern: CURSOR_RE.source },
|
|
469
456
|
'an opaque base64url cursor',
|
|
470
457
|
),
|
|
458
|
+
json: jsonSchema,
|
|
471
459
|
object: objectSchema,
|
|
472
460
|
array: arraySchema,
|
|
473
461
|
enum: enumSchema,
|
package/src/wire-schema.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single responsibility: the JSON Schema subset an MCP tool publishes, and the two narrowings onto
|
|
2
|
-
// it — an input's and an output's. One home, tier 0
|
|
3
|
-
//
|
|
2
|
+
// it — an input's and an output's. One home, tier 0: `@ultimat3/mcp`'s `tools/list` is the one
|
|
3
|
+
// tool projection, and no second one may publish a different document for the same declaration.
|
|
4
4
|
|
|
5
5
|
import type { JsonSchema } from './json-schema';
|
|
6
6
|
import { toMcpInputSchema } from './json-schema';
|
|
@@ -18,6 +18,8 @@ export interface WireJsonSchema {
|
|
|
18
18
|
readonly required?: readonly string[];
|
|
19
19
|
readonly additionalProperties?: boolean;
|
|
20
20
|
readonly items?: WireJsonSchema;
|
|
21
|
+
readonly minItems?: number;
|
|
22
|
+
readonly maxItems?: number;
|
|
21
23
|
readonly enum?: readonly (string | number | boolean | null)[];
|
|
22
24
|
readonly const?: string | number | boolean | null;
|
|
23
25
|
readonly default?: unknown;
|
|
@@ -76,6 +78,8 @@ function narrow(source: JsonSchema): WireJsonSchema {
|
|
|
76
78
|
? {}
|
|
77
79
|
: { additionalProperties: source.additionalProperties !== false }),
|
|
78
80
|
...(source.items === undefined ? {} : { items: narrow(source.items) }),
|
|
81
|
+
...(source.minItems === undefined ? {} : { minItems: source.minItems }),
|
|
82
|
+
...(source.maxItems === undefined ? {} : { maxItems: source.maxItems }),
|
|
79
83
|
...(source.enum === undefined ? {} : { enum: source.enum }),
|
|
80
84
|
...(source.const === undefined ? {} : { const: source.const }),
|
|
81
85
|
...(source.default === undefined ? {} : { default: source.default }),
|