@ultimat3/schema 25.0.0 → 25.2.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 +8 -2
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/array-schema.ts +79 -0
- package/src/error-codes.ts +1 -0
- package/src/errors.ts +13 -0
- package/src/index.ts +3 -1
- package/src/json-schema.ts +4 -0
- package/src/node.ts +3 -0
- package/src/t.ts +10 -2
- package/src/validators.ts +6 -20
- package/src/wire-schema.ts +4 -0
package/CLAUDE.md
CHANGED
|
@@ -15,8 +15,9 @@ 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 → json-value →
|
|
19
|
-
discriminated-union → provider → t` (`node-fits` and `json-schema` read `json-value`
|
|
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).
|
|
20
21
|
`char-count.ts` is imported by BOTH `validators.ts` (which rejects on length) and
|
|
21
22
|
`describe-value.ts` (which renders the length in the same message), because they disagreed: the
|
|
22
23
|
rule counted code points and the message counted UTF-16 units, so `t.string.min(3)` refused `'👍a'`
|
|
@@ -47,6 +48,11 @@ else, in CHARACTERS (`char-count.ts`) — the unit the rule that rejected it cou
|
|
|
47
48
|
re-enables the echo — one misconfigured environment is the same breach, and a dev
|
|
48
49
|
overlay already holds the raw body. `describe-value.test.ts` is the enforcement.
|
|
49
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
|
+
|
|
50
56
|
`X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
|
|
51
57
|
is parsed. A member with no literal at the discriminant, or a second member claiming a tag the
|
|
52
58
|
first already owns, can never be routed to — so it is wrong for every input, and the first import
|
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ export const publishPost = t.object({
|
|
|
27
27
|
postId: t.uuid,
|
|
28
28
|
notify: t.boolean.default(true),
|
|
29
29
|
title: t.string.min(3).max(80),
|
|
30
|
-
tags: t.array(t.slug),
|
|
30
|
+
tags: t.array(t.slug, { max: 10 }), // inclusive item-count bounds, both optional
|
|
31
31
|
price: t.money, // { minor: 1999, currency: 'EUR' } — never a float
|
|
32
32
|
// { minor: 2, currency: 'USD', scale: 6 } is $0.000002
|
|
33
33
|
timeZone: t.timezone, // real IANA validation, not an annotation
|
|
@@ -55,6 +55,7 @@ Refused at the boundary rather than guessed at (`As of 2026-10`):
|
|
|
55
55
|
| `t.url` | leading / trailing spaces and C0 controls, a tab or newline anywhere | the URL parser strips them in silence and the validator returns the string as written. Trim before parsing |
|
|
56
56
|
| `t.object` `t.record` `t.money` | anything whose prototype is not `Object.prototype` or `null` — a `Map`, a `Date`, a class instance | none has the own keys the schema declared, so it parsed to `{}` |
|
|
57
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 |
|
|
58
59
|
|
|
59
60
|
### `t.json()` — a payload whose shape is somebody else's
|
|
60
61
|
|
|
@@ -184,6 +185,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
184
185
|
|
|
185
186
|
| Class | Code | Declared in |
|
|
186
187
|
|---|---|---|
|
|
188
|
+
| `BoundsInvalidError` (extends `SchemaError`) | `X_SCHEMA_BOUNDS_INVALID` | `src/errors.ts` |
|
|
187
189
|
| `DefaultInvalidError` (extends `SchemaError`) | `X_SCHEMA_DEFAULT_INVALID` | `src/errors.ts` |
|
|
188
190
|
| `DiscriminantInvalidError` (extends `SchemaError`) | `X_SCHEMA_DISCRIMINANT_INVALID` | `src/errors.ts` |
|
|
189
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` |
|
package/package.json
CHANGED
|
@@ -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/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
|
@@ -210,6 +210,19 @@ export class DefaultInvalidError extends SchemaError {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
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
|
+
|
|
213
226
|
export class SchemaUnsupportedError extends SchemaError {
|
|
214
227
|
static readonly code = 'X_SCHEMA_UNSUPPORTED';
|
|
215
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,
|
|
@@ -35,6 +37,7 @@ export type {
|
|
|
35
37
|
ValidationIssue,
|
|
36
38
|
} from './errors';
|
|
37
39
|
export {
|
|
40
|
+
BoundsInvalidError,
|
|
38
41
|
DefaultInvalidError,
|
|
39
42
|
DiscriminantInvalidError,
|
|
40
43
|
isSchemaError,
|
|
@@ -105,7 +108,6 @@ export type {
|
|
|
105
108
|
TNamespace,
|
|
106
109
|
} from './validators';
|
|
107
110
|
export {
|
|
108
|
-
arraySchema,
|
|
109
111
|
builtinT,
|
|
110
112
|
enumSchema,
|
|
111
113
|
literalSchema,
|
package/src/json-schema.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface JsonSchema {
|
|
|
54
54
|
/** Negation. Emitted only inside `propertyNames`, to publish a closed set of refused keys. */
|
|
55
55
|
readonly not?: JsonSchema;
|
|
56
56
|
readonly items?: JsonSchema;
|
|
57
|
+
readonly minItems?: number;
|
|
58
|
+
readonly maxItems?: number;
|
|
57
59
|
readonly anyOf?: readonly JsonSchema[];
|
|
58
60
|
readonly discriminator?: JsonSchemaDiscriminator;
|
|
59
61
|
readonly title?: string;
|
|
@@ -227,6 +229,8 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
227
229
|
return annotate({
|
|
228
230
|
type: 'array',
|
|
229
231
|
items: node.items === undefined ? {} : convert(node.items),
|
|
232
|
+
...(node.minItems === undefined ? {} : { minItems: node.minItems }),
|
|
233
|
+
...(node.maxItems === undefined ? {} : { maxItems: node.maxItems }),
|
|
230
234
|
});
|
|
231
235
|
case 'union':
|
|
232
236
|
return annotate({
|
package/src/node.ts
CHANGED
|
@@ -71,6 +71,9 @@ export interface SchemaNode {
|
|
|
71
71
|
readonly integer?: boolean | undefined;
|
|
72
72
|
readonly properties?: Readonly<Record<string, SchemaNode>> | undefined;
|
|
73
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;
|
|
74
77
|
readonly values?: readonly (string | number)[] | undefined;
|
|
75
78
|
readonly literal?: string | number | boolean | null | undefined;
|
|
76
79
|
readonly anyOf?: readonly SchemaNode[] | undefined;
|
package/src/t.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
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';
|
|
6
7
|
import type { JsonValue } from './json-value';
|
|
7
8
|
import type { MoneyValue } from './money-value';
|
|
@@ -61,8 +62,15 @@ export const t: TNamespace = {
|
|
|
61
62
|
object<S extends Shape>(shape: S): ObjectSchema<S> {
|
|
62
63
|
return provider().object(shape);
|
|
63
64
|
},
|
|
64
|
-
|
|
65
|
-
|
|
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);
|
|
66
74
|
},
|
|
67
75
|
enum<const V extends readonly [string, ...string[]]>(values: V): Schema<V[number], V[number]> {
|
|
68
76
|
return provider().enum(values);
|
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,
|
|
@@ -230,25 +231,6 @@ export function objectSchema<S extends Shape>(shape: S): ObjectSchema<S> {
|
|
|
230
231
|
};
|
|
231
232
|
}
|
|
232
233
|
|
|
233
|
-
export function arraySchema<S extends AnySchema>(
|
|
234
|
-
items: S,
|
|
235
|
-
): Schema<readonly InferInput<S>[], InferOutput<S>[]> {
|
|
236
|
-
const itemCheck = checkOf(items);
|
|
237
|
-
const node: SchemaNode = { kind: 'array', items: items.node };
|
|
238
|
-
return makeSchema<readonly InferInput<S>[], InferOutput<S>[]>(node, (value, path) => {
|
|
239
|
-
if (!Array.isArray(value)) return fail(path, expected('an array', value));
|
|
240
|
-
const issues: StandardIssue[] = [];
|
|
241
|
-
const out: unknown[] = [];
|
|
242
|
-
for (const [index, item] of value.entries()) {
|
|
243
|
-
const result = itemCheck(item, [...path, index]);
|
|
244
|
-
if (result.ok) out.push(result.value);
|
|
245
|
-
else issues.push(...result.issues);
|
|
246
|
-
}
|
|
247
|
-
if (issues.length > 0) return failWith(issues);
|
|
248
|
-
return pass(out as InferOutput<S>[]);
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
|
|
252
234
|
export function enumSchema<const V extends readonly [string, ...string[]]>(
|
|
253
235
|
values: V,
|
|
254
236
|
): Schema<V[number], V[number]> {
|
|
@@ -416,7 +398,11 @@ export interface TNamespace {
|
|
|
416
398
|
/** Any JSON value, bounded in depth — a payload whose shape is somebody else's. */
|
|
417
399
|
json(): Schema<JsonValue, JsonValue>;
|
|
418
400
|
object<S extends Shape>(shape: S): ObjectSchema<S>;
|
|
419
|
-
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>[]>;
|
|
420
406
|
enum<const V extends readonly [string, ...string[]]>(values: V): Schema<V[number], V[number]>;
|
|
421
407
|
/** Variadic `enum`; the blessed spelling at call sites. */
|
|
422
408
|
enumerated<const V extends readonly [string, ...string[]]>(
|
package/src/wire-schema.ts
CHANGED
|
@@ -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 }),
|