@ultimat3/schema 25.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md 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 → validators →
19
- discriminated-union → provider → t` (`node-fits` and `json-schema` read `json-value` too).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "25.0.0",
3
+ "version": "25.1.0",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
+ }
@@ -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,
@@ -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
- array<S extends AnySchema>(items: S): Schema<readonly InferInput<S>[], InferOutput<S>[]> {
65
- return provider().array(items);
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<S extends AnySchema>(items: S): Schema<readonly InferInput<S>[], InferOutput<S>[]>;
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[]]>(
@@ -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 }),