@ultimat3/schema 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +90 -0
- package/README.md +54 -3
- package/package.json +2 -1
- package/src/builder.ts +92 -17
- package/src/coerce.ts +41 -7
- package/src/describe-value.ts +71 -0
- package/src/discriminated-union.ts +117 -0
- package/src/errors.ts +20 -0
- package/src/index.ts +15 -12
- package/src/json-schema.ts +91 -9
- package/src/money-value.ts +142 -0
- package/src/node.ts +29 -0
- package/src/t.ts +25 -8
- package/src/validators.ts +102 -49
package/src/errors.ts
CHANGED
|
@@ -20,6 +20,12 @@ export const SCHEMA_ERROR_CODES: Readonly<Record<string, SchemaErrorCodeDeclarat
|
|
|
20
20
|
Object.freeze({
|
|
21
21
|
X_VALIDATION_FAILED: { title: 'value did not match its schema' },
|
|
22
22
|
X_SCHEMA_UNSUPPORTED: { title: 'the active schema provider cannot do this' },
|
|
23
|
+
X_SCHEMA_DISCRIMINANT_INVALID: {
|
|
24
|
+
title: 'a discriminated union member can never be dispatched to',
|
|
25
|
+
},
|
|
26
|
+
X_SCHEMA_DEFAULT_UNSHAREABLE: {
|
|
27
|
+
title: 'a schema default cannot be copied per parse',
|
|
28
|
+
},
|
|
23
29
|
});
|
|
24
30
|
|
|
25
31
|
/**
|
|
@@ -127,6 +133,20 @@ export class ValidationFailedError extends SchemaError {
|
|
|
127
133
|
}
|
|
128
134
|
}
|
|
129
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Thrown where the union is BUILT, not where a value is parsed: a member no tag can route to is
|
|
138
|
+
* wrong for every input, so the first import of the authoring file is the earliest honest place
|
|
139
|
+
* to say so — never a request that quietly took the wrong branch.
|
|
140
|
+
*/
|
|
141
|
+
export class DiscriminantInvalidError extends SchemaError {
|
|
142
|
+
static readonly code = 'X_SCHEMA_DISCRIMINANT_INVALID';
|
|
143
|
+
override readonly name = 'DiscriminantInvalidError';
|
|
144
|
+
|
|
145
|
+
constructor(init: Omit<SchemaErrorInit, 'code'>) {
|
|
146
|
+
super({ ...init, code: DiscriminantInvalidError.code });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
130
150
|
export class SchemaUnsupportedError extends SchemaError {
|
|
131
151
|
static readonly code = 'X_SCHEMA_UNSUPPORTED';
|
|
132
152
|
override readonly name = 'SchemaUnsupportedError';
|
package/src/index.ts
CHANGED
|
@@ -7,24 +7,18 @@ export type {
|
|
|
7
7
|
CheckOk,
|
|
8
8
|
CheckResult,
|
|
9
9
|
Path,
|
|
10
|
+
Refinement,
|
|
10
11
|
Schema,
|
|
11
12
|
Shape,
|
|
12
13
|
ShapeInput,
|
|
13
14
|
ShapeOutput,
|
|
14
15
|
Simplify,
|
|
15
16
|
} from './builder';
|
|
16
|
-
export {
|
|
17
|
-
checkOf,
|
|
18
|
-
describeValue,
|
|
19
|
-
expected,
|
|
20
|
-
fail,
|
|
21
|
-
failWith,
|
|
22
|
-
makeSchema,
|
|
23
|
-
pass,
|
|
24
|
-
VENDOR,
|
|
25
|
-
} from './builder';
|
|
17
|
+
export { checkOf, fail, failWith, makeSchema, pass, VENDOR } from './builder';
|
|
26
18
|
export type { QuerySource } from './coerce';
|
|
27
19
|
export { coerceInput, coerceNode, coerceQuery } from './coerce';
|
|
20
|
+
export { describeValue, expected } from './describe-value';
|
|
21
|
+
export { discriminatedUnionSchema } from './discriminated-union';
|
|
28
22
|
export type {
|
|
29
23
|
SchemaErrorCodeDeclaration,
|
|
30
24
|
SchemaErrorInit,
|
|
@@ -32,6 +26,7 @@ export type {
|
|
|
32
26
|
ValidationIssue,
|
|
33
27
|
} from './errors';
|
|
34
28
|
export {
|
|
29
|
+
DiscriminantInvalidError,
|
|
35
30
|
isSchemaError,
|
|
36
31
|
SCHEMA_ERROR_CODES,
|
|
37
32
|
SchemaError,
|
|
@@ -42,11 +37,19 @@ export {
|
|
|
42
37
|
export type {
|
|
43
38
|
JsonSchema,
|
|
44
39
|
JsonSchemaDialect,
|
|
40
|
+
JsonSchemaDiscriminator,
|
|
45
41
|
JsonSchemaType,
|
|
46
42
|
ToJsonSchemaOptions,
|
|
47
43
|
} from './json-schema';
|
|
48
44
|
export { nodeToJsonSchema, toJsonSchema, toMcpInputSchema } from './json-schema';
|
|
49
|
-
export type {
|
|
45
|
+
export type { MoneyValue } from './money-value';
|
|
46
|
+
export {
|
|
47
|
+
CURRENCY_CODE_PATTERN,
|
|
48
|
+
isCurrencyCode,
|
|
49
|
+
isMoneyScale,
|
|
50
|
+
MAX_MONEY_SCALE,
|
|
51
|
+
} from './money-value';
|
|
52
|
+
export type { SchemaFormat, SchemaKind, SchemaNode, SchemaRefinement } from './node';
|
|
50
53
|
export { isSchemaNode, nodeOf, requiredKeys } from './node';
|
|
51
54
|
export type { SchemaProvider } from './provider';
|
|
52
55
|
export {
|
|
@@ -83,7 +86,6 @@ export {
|
|
|
83
86
|
export type { Infer } from './t';
|
|
84
87
|
export { t } from './t';
|
|
85
88
|
export type {
|
|
86
|
-
MoneyValue,
|
|
87
89
|
NumberSchema,
|
|
88
90
|
ObjectSchema,
|
|
89
91
|
StringSchema,
|
|
@@ -98,5 +100,6 @@ export {
|
|
|
98
100
|
objectSchema,
|
|
99
101
|
optionalSchema,
|
|
100
102
|
recordSchema,
|
|
103
|
+
refineSchema,
|
|
101
104
|
unionSchema,
|
|
102
105
|
} from './validators';
|
package/src/json-schema.ts
CHANGED
|
@@ -2,10 +2,24 @@
|
|
|
2
2
|
// bodies and MCP tool `inputSchema` are both this function's output, so an agent's view of an
|
|
3
3
|
// action and an HTTP client's view can never drift.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { CURRENCY_CODE_PATTERN, MAX_MONEY_SCALE } from './money-value';
|
|
6
|
+
import { requiredKeys, type SchemaNode, type SchemaRefinement } from './node';
|
|
6
7
|
import { introspect } from './provider';
|
|
7
8
|
|
|
8
|
-
export type JsonSchemaType =
|
|
9
|
+
export type JsonSchemaType =
|
|
10
|
+
| 'string'
|
|
11
|
+
| 'number'
|
|
12
|
+
| 'integer'
|
|
13
|
+
| 'boolean'
|
|
14
|
+
| 'object'
|
|
15
|
+
| 'array'
|
|
16
|
+
/** Only ever emitted as the second branch of a nullable field's `anyOf` — see `annotate`. */
|
|
17
|
+
| 'null';
|
|
18
|
+
|
|
19
|
+
/** OpenAPI 3.1's tagged-union hint. `propertyName` alone: the branches here are inline, not `$ref`. */
|
|
20
|
+
export interface JsonSchemaDiscriminator {
|
|
21
|
+
readonly propertyName: string;
|
|
22
|
+
}
|
|
9
23
|
|
|
10
24
|
export interface JsonSchema {
|
|
11
25
|
readonly $schema?: string;
|
|
@@ -25,7 +39,14 @@ export interface JsonSchema {
|
|
|
25
39
|
readonly additionalProperties?: boolean | JsonSchema;
|
|
26
40
|
readonly items?: JsonSchema;
|
|
27
41
|
readonly anyOf?: readonly JsonSchema[];
|
|
42
|
+
readonly discriminator?: JsonSchemaDiscriminator;
|
|
28
43
|
readonly title?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The refinements a consumer can act on mechanically. The prose copy also lands in
|
|
46
|
+
* `description`, because that is the only field an LLM reading an MCP tool schema is
|
|
47
|
+
* guaranteed to see — the extension is for code generators, the description is for readers.
|
|
48
|
+
*/
|
|
49
|
+
readonly 'x-ultimate-refinements'?: readonly SchemaRefinement[];
|
|
29
50
|
}
|
|
30
51
|
|
|
31
52
|
export type JsonSchemaDialect = '2020-12' | 'draft-07';
|
|
@@ -64,12 +85,48 @@ function stringNode(node: SchemaNode): JsonSchema {
|
|
|
64
85
|
};
|
|
65
86
|
}
|
|
66
87
|
|
|
88
|
+
/**
|
|
89
|
+
* JSON Schema's `pattern` is an ECMA-262 source with no flag syntax, so a flagged pattern is
|
|
90
|
+
* stated in prose instead of silently narrowed: a consumer applying `pattern` alone would refuse
|
|
91
|
+
* values this schema accepts, and there is nowhere honest to hide that.
|
|
92
|
+
*/
|
|
93
|
+
function patternNote(node: SchemaNode): string | undefined {
|
|
94
|
+
if (node.pattern === undefined) return undefined;
|
|
95
|
+
const flags = node.patternFlags;
|
|
96
|
+
return flags === undefined || flags === ''
|
|
97
|
+
? undefined
|
|
98
|
+
: `pattern is applied with RegExp flags "${flags}"`;
|
|
99
|
+
}
|
|
100
|
+
|
|
67
101
|
function convert(node: SchemaNode): JsonSchema {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
102
|
+
const refinements = node.refinements ?? [];
|
|
103
|
+
const notes = [
|
|
104
|
+
node.description,
|
|
105
|
+
patternNote(node),
|
|
106
|
+
...refinements.map((refinement) => refinement.message),
|
|
107
|
+
].filter((part): part is string => part !== undefined);
|
|
108
|
+
const described = notes.length === 0 ? undefined : notes.join(' — ');
|
|
109
|
+
|
|
110
|
+
const annotations: JsonSchema = {
|
|
111
|
+
...(described === undefined ? {} : { description: described }),
|
|
71
112
|
...(node.hasDefault === true ? { default: node.default } : {}),
|
|
72
|
-
|
|
113
|
+
...(refinements.length === 0 ? {} : { 'x-ultimate-refinements': refinements }),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `null` is a VALUE the field holds, so it joins the type union rather than the annotations —
|
|
118
|
+
* `{ anyOf: [<converted>, { type: 'null' }] }`, which is how OpenAPI 3.1 / JSON Schema 2020-12
|
|
119
|
+
* spell it (3.0's `nullable: true` keyword is gone). Dropping it published a contract every
|
|
120
|
+
* surface at once disagreed with: OpenAPI bodies, MCP `inputSchema`, `respondToolFor` and the
|
|
121
|
+
* typed client all forbade a `null` the action's own `output:` validator returns.
|
|
122
|
+
*
|
|
123
|
+
* The annotations stay OUTSIDE the `anyOf` — they describe the field, not one branch of it —
|
|
124
|
+
* and `requiredKeys` is untouched, because nullable is not optional: the key is still sent.
|
|
125
|
+
*/
|
|
126
|
+
const annotate = (schema: JsonSchema): JsonSchema =>
|
|
127
|
+
node.nullable === true
|
|
128
|
+
? { anyOf: [schema, { type: 'null' }], ...annotations }
|
|
129
|
+
: { ...schema, ...annotations };
|
|
73
130
|
|
|
74
131
|
switch (node.kind) {
|
|
75
132
|
case 'string':
|
|
@@ -94,7 +151,14 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
94
151
|
items: node.items === undefined ? {} : convert(node.items),
|
|
95
152
|
});
|
|
96
153
|
case 'union':
|
|
97
|
-
return annotate({
|
|
154
|
+
return annotate({
|
|
155
|
+
anyOf: (node.anyOf ?? []).map(convert),
|
|
156
|
+
// Only when the union actually dispatches on a key: an untagged `t.union` carrying a
|
|
157
|
+
// `discriminator` would tell a generator to read a property no member declares.
|
|
158
|
+
...(node.discriminant === undefined
|
|
159
|
+
? {}
|
|
160
|
+
: { discriminator: { propertyName: node.discriminant } }),
|
|
161
|
+
});
|
|
98
162
|
case 'record':
|
|
99
163
|
return annotate({
|
|
100
164
|
type: 'object',
|
|
@@ -104,8 +168,26 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
104
168
|
return annotate({
|
|
105
169
|
type: 'object',
|
|
106
170
|
properties: {
|
|
107
|
-
minor: {
|
|
108
|
-
|
|
171
|
+
minor: {
|
|
172
|
+
type: 'integer',
|
|
173
|
+
description: 'amount in minor units, never a float',
|
|
174
|
+
// The safe-integer range the validator enforces, so a generated client refuses the
|
|
175
|
+
// same value the boundary does instead of learning about it from a 500.
|
|
176
|
+
minimum: -Number.MAX_SAFE_INTEGER,
|
|
177
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
178
|
+
},
|
|
179
|
+
// The pattern the validator applies, not a copy of it: this object IS the contract a
|
|
180
|
+
// generated client checks against, so a widened predicate here would be a client
|
|
181
|
+
// refusing a code the boundary accepts.
|
|
182
|
+
currency: { type: 'string', pattern: CURRENCY_CODE_PATTERN },
|
|
183
|
+
// Optional, never required: `additionalProperties: false` alone would make a generated
|
|
184
|
+
// client refuse a scaled amount the framework's own validator accepts.
|
|
185
|
+
scale: {
|
|
186
|
+
type: 'integer',
|
|
187
|
+
description: 'decimal places `minor` counts; absent means the currency’s own',
|
|
188
|
+
minimum: 0,
|
|
189
|
+
maximum: MAX_MONEY_SCALE,
|
|
190
|
+
},
|
|
109
191
|
},
|
|
110
192
|
required: ['minor', 'currency'],
|
|
111
193
|
additionalProperties: false,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Single responsibility: the framework's ONE money declaration and the validator that guards it.
|
|
2
|
+
// Split out of `validators.ts` because it is the only builtin whose *shape* other packages alias
|
|
3
|
+
// — `@ultimat3/money`'s `Money`, `@ultimat3/entity`'s `MoneyValue` — so it earns a file a reader
|
|
4
|
+
// can open by name instead of scrolling to.
|
|
5
|
+
|
|
6
|
+
import { fail, failWith, isPlainObject, makeSchema, pass, type Schema } from './builder';
|
|
7
|
+
import { expected } from './describe-value';
|
|
8
|
+
import type { StandardIssue } from './standard';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The shape of an ISO 4217 alphabetic code, as a pattern SOURCE — the one declaration every
|
|
12
|
+
* projection of that bound derives from. It is a string rather than a `RegExp` because the two
|
|
13
|
+
* surfaces that cannot call a predicate need the source itself: `json-schema.ts` emits it as the
|
|
14
|
+
* `pattern` of the published OpenAPI contract, and `@ultimat3/entity`'s `currencyCheck` emits it
|
|
15
|
+
* inside a Postgres `~` CHECK so a psql session cannot write a code the app would refuse to read.
|
|
16
|
+
*
|
|
17
|
+
* Keep it inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically — anchors,
|
|
18
|
+
* a literal character class, a bounded repetition. A construct only one of the three understands
|
|
19
|
+
* (`\d`, a lookahead, a non-greedy quantifier) makes the CHECK stop meaning what `isCurrencyCode`
|
|
20
|
+
* means, and a real server is the first thing that says so.
|
|
21
|
+
*/
|
|
22
|
+
export const CURRENCY_CODE_PATTERN = '^[A-Z]{3}$';
|
|
23
|
+
|
|
24
|
+
const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The framework's ONE declaration of a money value. `@ultimat3/money`'s `Money` and
|
|
28
|
+
* `@ultimat3/entity`'s `MoneyValue` are aliases of this type, not copies of its shape — three
|
|
29
|
+
* structural restatements are how `minor` became a `number` here and a `bigint` there, which made
|
|
30
|
+
* a row the entity layer produced fail `t.money` and throw inside `JSON.stringify`.
|
|
31
|
+
*
|
|
32
|
+
* It lives at tier 0 because that is the only tier every other package may import, and `number`
|
|
33
|
+
* rather than `bigint` because money crosses the wire on every surface this framework projects —
|
|
34
|
+
* `JSON.stringify` refuses a bigint, and this node is also the OpenAPI contract. A value past
|
|
35
|
+
* `Number.MAX_SAFE_INTEGER` is refused HERE, at the boundary, with the field path — and again
|
|
36
|
+
* where it is decoded; it is never widened.
|
|
37
|
+
*
|
|
38
|
+
* Never a float, and never an amount without its currency.
|
|
39
|
+
*/
|
|
40
|
+
export interface MoneyValue {
|
|
41
|
+
readonly minor: number;
|
|
42
|
+
readonly currency: string;
|
|
43
|
+
/**
|
|
44
|
+
* Decimal places `minor` counts, when they are not the currency's own. Absent — the shape every
|
|
45
|
+
* existing value and every existing row still has — means the currency's natural minor unit: 2
|
|
46
|
+
* for USD, 0 for JPY, 3 for KWD. `{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002.
|
|
47
|
+
*
|
|
48
|
+
* It exists because a cents-only value could not name a sub-cent amount at all, so the one
|
|
49
|
+
* place that needed one — a model call costing $0.00016 — rounded it up to a whole cent and
|
|
50
|
+
* reported 62x the real spend. The alternative was a second money type, which is the axiom-1
|
|
51
|
+
* violation this declaration exists to prevent.
|
|
52
|
+
*/
|
|
53
|
+
readonly scale?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The largest decimal exponent a money value may carry. 10^15 is the last power of ten that is
|
|
58
|
+
* itself a safe integer, so a finer scale could not name its own unit inside the range `minor` is
|
|
59
|
+
* already checked against.
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_MONEY_SCALE = 15;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What a legal `MoneyValue.currency` is — declared once, here, beside the type that carries it and
|
|
65
|
+
* beside `isMoneyScale`, which is the twin of this predicate and the precedent for it. Takes
|
|
66
|
+
* `unknown` because every caller is a boundary: a row off a `char(3)` column, a body off the wire,
|
|
67
|
+
* a `registerCurrency` argument from an untyped caller. `String(value).test(…)` on a symbol throws
|
|
68
|
+
* where a refusal was due, so the `typeof` half belongs in here rather than at each call.
|
|
69
|
+
*/
|
|
70
|
+
export function isCurrencyCode(value: unknown): value is string {
|
|
71
|
+
return typeof value === 'string' && CURRENCY_RE.test(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What a legal `MoneyValue.scale` is — declared once, here, beside the type that carries it. */
|
|
75
|
+
export function isMoneyScale(value: unknown): value is number {
|
|
76
|
+
return (
|
|
77
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_MONEY_SCALE
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue, MoneyValue>(
|
|
82
|
+
{
|
|
83
|
+
kind: 'money',
|
|
84
|
+
description: 'integer minor units plus an ISO 4217 currency code',
|
|
85
|
+
properties: {
|
|
86
|
+
minor: {
|
|
87
|
+
kind: 'number',
|
|
88
|
+
integer: true,
|
|
89
|
+
minimum: -Number.MAX_SAFE_INTEGER,
|
|
90
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
91
|
+
},
|
|
92
|
+
currency: { kind: 'string', pattern: CURRENCY_CODE_PATTERN },
|
|
93
|
+
scale: {
|
|
94
|
+
kind: 'number',
|
|
95
|
+
integer: true,
|
|
96
|
+
optional: true,
|
|
97
|
+
minimum: 0,
|
|
98
|
+
maximum: MAX_MONEY_SCALE,
|
|
99
|
+
description: 'decimal places `minor` counts; absent means the currency’s own',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
(value, path) => {
|
|
104
|
+
if (!isPlainObject(value)) return fail(path, expected('a Money object', value));
|
|
105
|
+
const minor = value['minor'];
|
|
106
|
+
const currency = value['currency'];
|
|
107
|
+
const scale = value['scale'];
|
|
108
|
+
const issues: StandardIssue[] = [];
|
|
109
|
+
// Safe, not merely whole: `money()` and `entity`'s `parseMinor` both demand a safe integer, so
|
|
110
|
+
// `Number.isInteger` here let 2^53 through the boundary as a 200 and failed at the row write
|
|
111
|
+
// as a 500 — the same value refused twice, once with a field path and once without.
|
|
112
|
+
if (typeof minor !== 'number' || !Number.isSafeInteger(minor)) {
|
|
113
|
+
issues.push({
|
|
114
|
+
message: expected('a safe integer number of minor units', minor),
|
|
115
|
+
path: [...path, 'minor'],
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (!isCurrencyCode(currency)) {
|
|
119
|
+
issues.push({
|
|
120
|
+
message: expected('a 3-letter ISO 4217 code', currency),
|
|
121
|
+
path: [...path, 'currency'],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (scale !== undefined && !isMoneyScale(scale)) {
|
|
125
|
+
issues.push({
|
|
126
|
+
message: expected(
|
|
127
|
+
`a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}`,
|
|
128
|
+
scale,
|
|
129
|
+
),
|
|
130
|
+
path: [...path, 'scale'],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (issues.length > 0) return failWith(issues);
|
|
134
|
+
// The key is carried only when it was sent: a value at the currency's own scale must
|
|
135
|
+
// round-trip byte-for-byte, or every stored amount in every app changes shape on one parse.
|
|
136
|
+
return pass({
|
|
137
|
+
minor: minor as number,
|
|
138
|
+
currency: currency as string,
|
|
139
|
+
...(scale === undefined ? {} : { scale: scale as number }),
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
);
|
package/src/node.ts
CHANGED
|
@@ -27,6 +27,17 @@ export type SchemaFormat =
|
|
|
27
27
|
| 'locale'
|
|
28
28
|
| 'cursor';
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* A refinement as the IR carries it: the predicate's *declaration*, never the predicate. A closure
|
|
32
|
+
* cannot cross into OpenAPI or an MCP tool schema, so what ships is the name and the rule text —
|
|
33
|
+
* enough for a generated client to state the constraint and for a form to label the failure.
|
|
34
|
+
*/
|
|
35
|
+
export interface SchemaRefinement {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly message: string;
|
|
38
|
+
readonly path?: readonly string[] | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export interface SchemaNode {
|
|
31
42
|
readonly kind: SchemaKind;
|
|
32
43
|
readonly optional?: boolean | undefined;
|
|
@@ -40,6 +51,12 @@ export interface SchemaNode {
|
|
|
40
51
|
readonly maxLength?: number | undefined;
|
|
41
52
|
/** Source string of the RegExp, so the node stays JSON-serialisable. */
|
|
42
53
|
readonly pattern?: string | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* The RegExp's flags, carried beside the source for the same reason. Dropping them made
|
|
56
|
+
* `t.string.pattern(/^[a-z]+$/i)` reject `ABC` while quoting the pattern that matches it.
|
|
57
|
+
* JSON Schema's `pattern` has no flags, so `json-schema.ts` states them in `description`.
|
|
58
|
+
*/
|
|
59
|
+
readonly patternFlags?: string | undefined;
|
|
43
60
|
readonly minimum?: number | undefined;
|
|
44
61
|
readonly maximum?: number | undefined;
|
|
45
62
|
readonly integer?: boolean | undefined;
|
|
@@ -48,7 +65,19 @@ export interface SchemaNode {
|
|
|
48
65
|
readonly values?: readonly (string | number)[] | undefined;
|
|
49
66
|
readonly literal?: string | number | boolean | null | undefined;
|
|
50
67
|
readonly anyOf?: readonly SchemaNode[] | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* The key a `union` dispatches on. Additive rather than a `'discriminatedUnion'` kind: every
|
|
70
|
+
* consumer that switches on `kind` — `json-schema.ts`, `coerce.ts`, `action`'s sample generator,
|
|
71
|
+
* the admin form generator — already handles `'union'` correctly, and a new kind would have
|
|
72
|
+
* fallen through each of their `default:` branches to an empty schema without failing anything.
|
|
73
|
+
*/
|
|
74
|
+
readonly discriminant?: string | undefined;
|
|
51
75
|
readonly valueNode?: SchemaNode | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Rules the structural fields cannot state. Carried beside `kind` rather than wrapping it for
|
|
78
|
+
* the reason `discriminant` is: a refined string must still read as a string everywhere.
|
|
79
|
+
*/
|
|
80
|
+
readonly refinements?: readonly SchemaRefinement[] | undefined;
|
|
52
81
|
}
|
|
53
82
|
|
|
54
83
|
export function isSchemaNode(value: unknown): value is SchemaNode {
|
package/src/t.ts
CHANGED
|
@@ -2,16 +2,11 @@
|
|
|
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 { AnySchema, Schema, Shape } from './builder';
|
|
5
|
+
import type { AnySchema, Refinement, Schema, Shape } from './builder';
|
|
6
|
+
import type { MoneyValue } from './money-value';
|
|
6
7
|
import { schemaProvider } from './provider';
|
|
7
8
|
import type { InferInput, InferOutput, StandardSchemaV1 } from './standard';
|
|
8
|
-
import type {
|
|
9
|
-
MoneyValue,
|
|
10
|
-
NumberSchema,
|
|
11
|
-
ObjectSchema,
|
|
12
|
-
StringSchema,
|
|
13
|
-
TNamespace,
|
|
14
|
-
} from './validators';
|
|
9
|
+
import type { NumberSchema, ObjectSchema, StringSchema, TNamespace } from './validators';
|
|
15
10
|
|
|
16
11
|
function provider(): TNamespace {
|
|
17
12
|
return schemaProvider().t;
|
|
@@ -81,6 +76,28 @@ export const t: TNamespace = {
|
|
|
81
76
|
): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
|
|
82
77
|
return provider().union(...members);
|
|
83
78
|
},
|
|
79
|
+
/**
|
|
80
|
+
* `t.discriminatedUnion('kind', postBody, pageBody)` — the blessed spelling for a tagged shape.
|
|
81
|
+
* A failure reports the branch `kind` named, so the caller reads one field error instead of
|
|
82
|
+
* every branch's reasons concatenated.
|
|
83
|
+
*/
|
|
84
|
+
discriminatedUnion<S extends readonly [AnySchema, ...AnySchema[]]>(
|
|
85
|
+
discriminant: string,
|
|
86
|
+
...members: S
|
|
87
|
+
): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
|
|
88
|
+
return provider().discriminatedUnion(discriminant, ...members);
|
|
89
|
+
},
|
|
90
|
+
/**
|
|
91
|
+
* `t.refine(range, { name: 'end-after-start', message: 'endDate must be after startDate', … })`
|
|
92
|
+
* — a cross-field rule declared ON the schema, so OpenAPI, the MCP tool schema and the form
|
|
93
|
+
* binding all state it instead of it living in a handler where no projection can see it.
|
|
94
|
+
*
|
|
95
|
+
* Delegates to the method for the reason `t.nullable` does: `refine` composes an existing
|
|
96
|
+
* schema rather than building one, so a swapped provider's schemas keep their own behaviour.
|
|
97
|
+
*/
|
|
98
|
+
refine<In, Out>(schema: Schema<In, Out>, refinement: Refinement<Out>): Schema<In, Out> {
|
|
99
|
+
return schema.refine(refinement);
|
|
100
|
+
},
|
|
84
101
|
record<S extends AnySchema>(
|
|
85
102
|
values: S,
|
|
86
103
|
): Schema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>> {
|