@ultimat3/schema 1.2.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/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 { SchemaFormat, SchemaKind, SchemaNode } from './node';
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';
@@ -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 { requiredKeys, type SchemaNode } from './node';
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 = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
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 annotate = (schema: JsonSchema): JsonSchema => ({
69
- ...schema,
70
- ...(node.description === undefined ? {} : { description: node.description }),
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({ anyOf: (node.anyOf ?? []).map(convert) });
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: { type: 'integer', description: 'amount in minor units, never a float' },
108
- currency: { type: 'string', pattern: '^[A-Z]{3}$' },
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>>> {