@ultimat3/schema 1.2.0 → 3.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 +55 -11
- 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/provider.ts +4 -2
- package/src/t.ts +25 -8
- package/src/validators.ts +102 -49
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Single responsibility: a union that dispatches on one literal key instead of trying every
|
|
2
|
+
// member. Its own file because the dispatch table and its two authoring refusals are the whole of
|
|
3
|
+
// it, and `validators.ts` is already at the size where one more composite stops being readable.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type AnySchema,
|
|
7
|
+
type Check,
|
|
8
|
+
checkOf,
|
|
9
|
+
fail,
|
|
10
|
+
isPlainObject,
|
|
11
|
+
makeSchema,
|
|
12
|
+
type Schema,
|
|
13
|
+
} from './builder';
|
|
14
|
+
import { expected } from './describe-value';
|
|
15
|
+
import { DiscriminantInvalidError } from './errors';
|
|
16
|
+
import type { SchemaNode } from './node';
|
|
17
|
+
import type { InferInput, InferOutput } from './standard';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a tag can be — exactly what `SchemaNode.literal` and `SchemaNode.values` can hold. Spelled
|
|
21
|
+
* out rather than left `unknown` because these reach a `cause:` through `String()`, and a value an
|
|
22
|
+
* app controls reaching a refusal's own text is how three constructors learned to throw instead
|
|
23
|
+
* of refusing (`bun run error-render`).
|
|
24
|
+
*/
|
|
25
|
+
type SchemaTag = string | number | boolean | null | undefined;
|
|
26
|
+
|
|
27
|
+
/** The literal(s) a member declares at the discriminant, or `undefined` if it declares none. */
|
|
28
|
+
function declaredValues(node: SchemaNode, discriminant: string): readonly SchemaTag[] | undefined {
|
|
29
|
+
const child = node.properties?.[discriminant];
|
|
30
|
+
if (child === undefined) return undefined;
|
|
31
|
+
// `kind` gates rather than `literal !== undefined`: `t.literal(false)` is a legal discriminant
|
|
32
|
+
// and a truthiness test would have called it undeclared.
|
|
33
|
+
if (child.kind === 'literal') return [child.literal];
|
|
34
|
+
// An enum member is a legal discriminant too — one branch owning several codes is a real shape
|
|
35
|
+
// (`status: t.enum(['queued', 'running'])`), and refusing it would force a duplicated branch.
|
|
36
|
+
if (child.kind === 'enum' && child.values !== undefined && child.values.length > 0) {
|
|
37
|
+
return [...child.values];
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function refuseUndeclared(discriminant: string, index: number, kind: string): never {
|
|
43
|
+
throw new DiscriminantInvalidError({
|
|
44
|
+
cause: `member #${index} of discriminatedUnion("${discriminant}") — IR kind "${kind}" — declares no literal at "${discriminant}"`,
|
|
45
|
+
fix: `give member #${index} a literal discriminant, t.object({ ${discriminant}: t.literal('…'), … }), or use t.union(...) if the members share no key`,
|
|
46
|
+
meta: { discriminant, index, kind },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function refuseDuplicate(discriminant: string, index: number, tag: SchemaTag): never {
|
|
51
|
+
throw new DiscriminantInvalidError({
|
|
52
|
+
cause: `member #${index} of discriminatedUnion("${discriminant}") claims the tag ${String(tag)}, which an earlier member already claims`,
|
|
53
|
+
fix: `give member #${index} its own value at "${discriminant}", or merge the two members into one`,
|
|
54
|
+
meta: { discriminant, index, tag: String(tag) },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `post | page | 2 | false` — every accepted tag, in declaration order. Developer data only. */
|
|
59
|
+
function renderKnown(known: readonly SchemaTag[]): string {
|
|
60
|
+
return known.map((value) => String(value)).join(' | ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A union whose branch is chosen by one key, not by trying every member in turn.
|
|
65
|
+
*
|
|
66
|
+
* Two things `t.union(...)` cannot do. The message: a failure reports the branch the discriminant
|
|
67
|
+
* NAMED — `t.union` reports every branch's reasons at once, so a typo in a `post` body arrived as
|
|
68
|
+
* N contradictory complaints and the field that was actually wrong was named in none of them. The
|
|
69
|
+
* IR: `discriminator.propertyName` reaches OpenAPI and the MCP tool schema, so a code generator
|
|
70
|
+
* emits one tagged type instead of an untagged `anyOf`.
|
|
71
|
+
*
|
|
72
|
+
* The node stays `kind: 'union'` with a `discriminant` beside it — see `node.ts` for why a new
|
|
73
|
+
* `SchemaKind` would have degraded silently in every consumer that already handles unions.
|
|
74
|
+
*/
|
|
75
|
+
export function discriminatedUnionSchema<S extends readonly [AnySchema, ...AnySchema[]]>(
|
|
76
|
+
discriminant: string,
|
|
77
|
+
...members: S
|
|
78
|
+
): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
|
|
79
|
+
type Out = InferOutput<S[number]>;
|
|
80
|
+
const branches = new Map<unknown, Check<Out>>();
|
|
81
|
+
const known: SchemaTag[] = [];
|
|
82
|
+
|
|
83
|
+
for (const [index, member] of members.entries()) {
|
|
84
|
+
const values = declaredValues(member.node, discriminant);
|
|
85
|
+
if (values === undefined) refuseUndeclared(discriminant, index, member.node.kind);
|
|
86
|
+
const check = checkOf(member) as Check<Out>;
|
|
87
|
+
for (const value of values) {
|
|
88
|
+
// Letting the first declaration win would leave a member that can never run, which is the
|
|
89
|
+
// same defect as an unreachable case and is invisible until the wrong branch validates.
|
|
90
|
+
if (branches.has(value)) refuseDuplicate(discriminant, index, value);
|
|
91
|
+
branches.set(value, check);
|
|
92
|
+
known.push(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const node: SchemaNode = {
|
|
97
|
+
kind: 'union',
|
|
98
|
+
discriminant,
|
|
99
|
+
anyOf: members.map((member) => member.node),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return makeSchema<InferInput<S[number]>, Out>(node, (value, path) => {
|
|
103
|
+
if (!isPlainObject(value)) {
|
|
104
|
+
return fail(path, expected(`an object with a "${discriminant}" discriminant`, value));
|
|
105
|
+
}
|
|
106
|
+
const branch = branches.get(value[discriminant]);
|
|
107
|
+
if (branch === undefined) {
|
|
108
|
+
return fail(
|
|
109
|
+
[...path, discriminant],
|
|
110
|
+
expected(`one of ${renderKnown(known)}`, value[discriminant]),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
// The named branch's issues, and only those: the caller already said which shape they meant,
|
|
114
|
+
// so the other branches' complaints are noise that hides the one real field error.
|
|
115
|
+
return branch(value, path);
|
|
116
|
+
});
|
|
117
|
+
}
|
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/provider.ts
CHANGED
|
@@ -11,8 +11,10 @@ export interface SchemaProvider {
|
|
|
11
11
|
readonly vendor: string;
|
|
12
12
|
readonly t: TNamespace;
|
|
13
13
|
/**
|
|
14
|
-
* Return the IR for one of this provider's schemas. Required for OpenAPI
|
|
15
|
-
*
|
|
14
|
+
* Return the IR for one of this provider's schemas. Required for OpenAPI, MCP tool schemas
|
|
15
|
+
* and the admin form generator: `toJsonSchema()` calls it unconditionally and throws
|
|
16
|
+
* `X_SCHEMA_UNSUPPORTED` without it. There is no second way to describe a schema — the IR is
|
|
17
|
+
* the one projection surface, and every generator reads it.
|
|
16
18
|
*/
|
|
17
19
|
introspect?(schema: unknown): SchemaNode | undefined;
|
|
18
20
|
}
|
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>>> {
|