@ultimat3/schema 30.9.0 → 31.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 CHANGED
@@ -11,13 +11,18 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
11
11
  | **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
12
12
  | **Issue paths** | framework-chosen segments only. A `t.object` segment is a DECLARED field name; a `t.record` KEY is the caller's, so `recordSchema` names a failing entry by POSITION (`meta[3]`) |
13
13
  | Coercion | HTTP boundary only — never call it from actions, jobs or MCP |
14
- | Exports | explicit in `src/index.ts`; no `export *`; a namespace member and its free function ship together (`t.nullable`/`nullableSchema`) |
14
+ | Exports | explicit in `src/index.ts`; no `export *`; a composer ships once, as its `t` member — its free twin (`arraySchema`, `discriminatedUnionSchema`, `enumSchema`, `nullableSchema`, `optionalSchema`, `refineSchema`, `unionSchema`) stays internal; `jsonSchema`, `recordSchema`, `literalSchema`, `objectSchema` stay exported; provider accessors on `./adapter` (`src/adapter.ts`) |
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
18
  `char-count → describe-value → node → builder → money-value → json-value → array-schema →
19
19
  validators → record-schema → discriminated-union → provider → t` (`node-fits` and `json-schema` read
20
20
  `json-value` too). `record-schema.ts` (`t.record`) split from `validators.ts` at the ceiling.
21
+ `issue-cap.ts` is a leaf (type-only `standard`) read by `builder`, `standard` and every collecting
22
+ loop: **a loop that collects issues asks `stopCollecting(count, hasMore)` after each member**, and a
23
+ union that discards member issues restores `issuesStopped()`. `standard.large.test.ts` pins 10k bad
24
+ items → 100 issues, `issuesTruncated`, ≤ 100 item checks (a work count, never wall time). The cap is ambient module state because builtin
25
+ validation is synchronous; `withIssueCap` is re-entrant and only the outermost run reports.
21
26
  `schema-rules.ts` is a leaf (`SCHEMA_RULES`, `issueRuleFacts`), imported by `standard.ts`,
22
27
  `errors.ts` (type only) and `builder.ts`. `sensitive.ts` reads `provider.ts` and is not in `t`'s graph.
23
28
 
package/README.md CHANGED
@@ -13,9 +13,10 @@ type, a JSON Schema, an OpenAPI body, an MCP tool `inputSchema` and HTTP query c
13
13
  | the introspectable IR every generator walks | `node.ts` |
14
14
  | `Schema` factory, `Infer` machinery, `.refine()` | `builder.ts` |
15
15
  | how a rejected value is described — its shape, never its content | `describe-value.ts` |
16
- | `JsonValue`, `t.json()` / `jsonSchema()`, `JSON_MAX_DEPTH` | `json-value.ts` |
16
+ | `JsonValue`, `t.json()` / `jsonSchema()`, the 256-level nesting cap | `json-value.ts` |
17
17
  | a union routed by one literal key | `discriminated-union.ts` |
18
18
  | `configureSchemaProvider()` — the swap point | `provider.ts` |
19
+ | `@ultimat3/schema/adapter`, off the barrel: `builtinProvider`, `schemaProvider()` (the active one), `resetSchemaProvider()` — for a provider author and test teardown | `adapter.ts` |
19
20
  | schema → JSON Schema (OpenAPI + MCP) | `json-schema.ts` |
20
21
  | HTTP-boundary coercion, kept out of validation | `coerce.ts` |
21
22
  | `X_VALIDATION_FAILED` with path-annotated issues | `errors.ts` |
@@ -47,8 +48,11 @@ type PublishPost = Infer<typeof publishPost>;
47
48
  | `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `discriminatedUnion` `record` `optional` `nullable` `refine` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` `.sensitive()` `.refine(r)` |
48
49
  | `money` `timezone` `locale` `slug` `cursor` `json()` | `object(...).extend/.pick/.omit` | `string.min/.max/.pattern`, `number.min/.max/.int` |
49
50
 
50
- `nullable` is a value the row holds; `optional` is the caller omitting the key. Both ship as a
51
- namespace member (`t.nullable`) and a free function (`nullableSchema`) — symmetrically.
51
+ `nullable` is a value the row holds; `optional` is the caller omitting the key. Every composer
52
+ ships once, as a `t` member (`t.nullable`, `t.refine`, …); since v31 its free twin is internal —
53
+ `arraySchema`, `discriminatedUnionSchema`, `enumSchema`, `nullableSchema`, `optionalSchema`,
54
+ `refineSchema`, `unionSchema`. `jsonSchema`, `recordSchema`, `literalSchema` and `objectSchema` stay
55
+ exported beside `t`.
52
56
 
53
57
  Unknown object keys are **dropped**, never forwarded — an action cannot be mass-assigned.
54
58
 
@@ -215,6 +219,13 @@ bound where it is narrower.
215
219
  `formatIssues()` renders one line per issue for the dev overlay. `validate()` never throws and
216
220
  returns the Standard Schema result. Async libraries: `parseAsync()` / `validateAsync()`.
217
221
 
222
+ **A refusal is bounded.** Every door — `validate()`, `parse()`, `safeParse()`, `~standard.validate`
223
+ — stops collecting at `MAX_ISSUES` (100): a builtin array, object, record or `t.json()` leaves the
224
+ rest unchecked, and the result carries `issuesTruncated: true` (`ValidationFailedError.issuesTruncated`,
225
+ and `meta.issuesTruncated`). A foreign library's list is cut to the cap. `validate(schema, value,
226
+ { maxIssues })` / `validateAsync(…, { maxIssues })` narrow or widen it; below 1 is
227
+ `X_SCHEMA_ISSUE_CAP_INVALID`. A `SchemaError` cause is cut at 4 KiB, as core's is.
228
+
218
229
  This package is tier 0 and cannot import `@ultimat3/core`, so `SchemaError` reproduces
219
230
  `UltimateError` structurally and carries the same `Symbol.for('ultimate.error')` brand —
220
231
  `isUltimateError()` from core matches it. Register the codes once from any package that imports
@@ -230,8 +241,10 @@ a job boundary the class is gone and the `code` is what survives — match on th
230
241
  | `BoundsInvalidError` (extends `SchemaError`) | `X_SCHEMA_BOUNDS_INVALID` | `src/errors.ts` |
231
242
  | `DefaultInvalidError` (extends `SchemaError`) | `X_SCHEMA_DEFAULT_INVALID` | `src/errors.ts` |
232
243
  | `DiscriminantInvalidError` (extends `SchemaError`) | `X_SCHEMA_DISCRIMINANT_INVALID` | `src/errors.ts` |
233
- | `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` |
244
+ | `IssueCapInvalidError` (extends `SchemaError`) | `X_SCHEMA_ISSUE_CAP_INVALID` | `src/errors.ts` |
245
+ | `SchemaError` | any schema code; the base of every class that extends 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` |
234
246
  | `SchemaUnsupportedError` (extends `SchemaError`) | `X_SCHEMA_UNSUPPORTED` | `src/errors.ts` |
247
+ | `SensitiveOutputError` (extends `SchemaError`) | `X_SCHEMA_SENSITIVE_OUTPUT` | `src/errors.ts` |
235
248
  | `ValidationFailedError` (extends `SchemaError`) | `X_VALIDATION_FAILED` | `src/errors.ts` |
236
249
 
237
250
  ## Swapping the library
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "30.9.0",
3
+ "version": "31.0.0",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "exports": {
28
28
  ".": "./src/index.ts",
29
+ "./adapter": "./src/adapter.ts",
29
30
  "./sample": "./src/sample.ts"
30
31
  },
31
32
  "files": [
package/src/adapter.ts ADDED
@@ -0,0 +1,5 @@
1
+ // `@ultimat3/schema/adapter` — what a schema provider author reads: the built-in provider and the
2
+ // active-provider accessor and reset. Never the `.` barrel: an app installs a provider with
3
+ // `configureSchemaProvider()`, so the only callers here are a provider author and test teardown.
4
+
5
+ export { builtinProvider, resetSchemaProvider, schemaProvider } from './provider';
@@ -14,6 +14,7 @@ import {
14
14
  } from './builder';
15
15
  import { describeValue, expected } from './describe-value';
16
16
  import { BoundsInvalidError } from './errors';
17
+ import { stopCollecting } from './issue-cap';
17
18
  import type { SchemaNode } from './node';
18
19
  import type { InferInput, InferOutput, StandardIssue } from './standard';
19
20
 
@@ -84,7 +85,11 @@ export function arraySchema<S extends AnySchema>(
84
85
  for (const [index, member] of value.entries()) {
85
86
  const result = itemCheck(member, [...path, index]);
86
87
  if (result.ok) out.push(result.value);
87
- else issues.push(...result.issues);
88
+ else {
89
+ issues.push(...result.issues);
90
+ // At the issue cap the rest is not read: 10k bad items cost a hundred checks, not 10k.
91
+ if (stopCollecting(issues.length, index < value.length - 1)) break;
92
+ }
88
93
  }
89
94
  if (issues.length > 0) return failWith(issues);
90
95
  return pass(out as InferOutput<S>[]);
package/src/builder.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  ValidationFailedError,
9
9
  type ValidationIssue,
10
10
  } from './errors';
11
+ import { cappedFailure, MAX_ISSUES, withIssueCap } from './issue-cap';
11
12
  import type { SchemaNode, SchemaRefinement } from './node';
12
13
  import { issueRuleFacts, type SchemaIssueParams, type SchemaRule } from './schema-rules';
13
14
  import {
@@ -230,8 +231,8 @@ function withoutDefault(node: SchemaNode): SchemaNode {
230
231
  return rest;
231
232
  }
232
233
 
233
- function toIssues(result: CheckErr): readonly ValidationIssue[] {
234
- return result.issues.map((issue) => ({
234
+ function toIssues(issues: readonly StandardIssue[]): readonly ValidationIssue[] {
235
+ return issues.map((issue) => ({
235
236
  path: formatPath(issue.path),
236
237
  expected: issue.message,
237
238
  received: '',
@@ -240,25 +241,37 @@ function toIssues(result: CheckErr): readonly ValidationIssue[] {
240
241
  }));
241
242
  }
242
243
 
244
+ /**
245
+ * One whole validation: the check under the issue cap (`issue-cap.ts`), its failure held to it.
246
+ * Every door into a builtin schema goes through here, so none of them is unbounded — a nested
247
+ * call (a composite reading a member through `checkOf`) joins its caller's cap.
248
+ */
249
+ function capped<Out>(check: Check<Out>, value: unknown): StandardResult<Out> {
250
+ const run = withIssueCap(MAX_ISSUES, () => check(value, []));
251
+ return run.value.ok
252
+ ? { value: run.value.value }
253
+ : cappedFailure(run.value.issues, run.cap, run.stopped);
254
+ }
255
+
243
256
  export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema<In, Out> {
244
257
  const schema: Schema<In, Out> = {
245
258
  '~standard': {
246
259
  version: 1,
247
260
  vendor: VENDOR,
248
- validate: (value: unknown): StandardResult<Out> => {
249
- const result = check(value, []);
250
- return result.ok ? { value: result.value } : { issues: result.issues };
251
- },
261
+ validate: (value: unknown): StandardResult<Out> => capped(check, value),
252
262
  },
253
263
  node,
254
264
  parse(value: unknown, root = 'value'): Out {
255
- const result = check(value, []);
256
- if (result.ok) return result.value;
257
- throw new ValidationFailedError(toIssues(result), root);
265
+ const result = capped(check, value);
266
+ if (result.issues === undefined) return result.value;
267
+ throw new ValidationFailedError(
268
+ toIssues(result.issues),
269
+ root,
270
+ result.issuesTruncated === true,
271
+ );
258
272
  },
259
273
  safeParse(value: unknown): StandardResult<Out> {
260
- const result = check(value, []);
261
- return result.ok ? { value: result.value } : { issues: result.issues };
274
+ return capped(check, value);
262
275
  },
263
276
  optional(): Schema<In | undefined, Out | undefined> {
264
277
  return makeSchema<In | undefined, Out | undefined>(
@@ -29,4 +29,5 @@ export const SCHEMA_ERROR_CODES: Readonly<Record<string, SchemaErrorCodeDeclarat
29
29
  X_SCHEMA_DEFAULT_INVALID: { title: 'a schema default fails its own schema' },
30
30
  X_SCHEMA_BOUNDS_INVALID: { title: 'an array bound no array can satisfy' },
31
31
  X_SCHEMA_SENSITIVE_OUTPUT: { title: 'a sensitive field is reachable from an output' },
32
+ X_SCHEMA_ISSUE_CAP_INVALID: { title: 'a validation issue cap no list can meet' },
32
33
  });
package/src/errors.ts CHANGED
@@ -37,6 +37,22 @@ const singleLine = (text: string): string =>
37
37
  (char) => ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,
38
38
  );
39
39
 
40
+ /**
41
+ * The same cap `@ultimat3/core`'s `boundedCause` applies. **Keep in sync** — read the reason there;
42
+ * briefly: a cause describes a refusal and is copied into `message`, the log line and the problem
43
+ * `detail`, so a 10k-issue validation cause (3.6 MiB) became four copies of a request body.
44
+ * Copied for `singleLine`'s reason; `single-line-pin.test.ts` in core pins the two cut identically.
45
+ */
46
+ const MAX_CAUSE_LENGTH = 4096;
47
+ function boundedCause(text: string): string {
48
+ if (text.length <= MAX_CAUSE_LENGTH) return text;
49
+ const reserve = `... (${text.length} more characters)`.length;
50
+ let keep = MAX_CAUSE_LENGTH - reserve;
51
+ const last = text.charCodeAt(keep - 1);
52
+ if (last >= 0xd800 && last <= 0xdbff) keep -= 1;
53
+ return `${text.slice(0, keep)}... (${text.length - keep} more characters)`;
54
+ }
55
+
40
56
  /**
41
57
  * The same one URL `@ultimat3/core`'s `ERROR_DOCS_URL` holds. **Keep in sync**, and read the
42
58
  * reason there — briefly: `wiki/` is the only public documentation surface, codes live on that
@@ -116,7 +132,7 @@ export class SchemaError extends Error {
116
132
  // `@ultimat3/action`'s `IRREGULAR[word]`.
117
133
  const declared = Object.hasOwn(TITLES, init.code) ? TITLES[init.code] : undefined;
118
134
  const title = singleLine(declared ?? humanize(init.code));
119
- const cause = singleLine(init.cause);
135
+ const cause = boundedCause(singleLine(init.cause));
120
136
  // The cause is in `message` for the reason `UltimateError`'s constructor gives: `message` is
121
137
  // the ONLY field a runtime prints when an error escapes uncaught — a worker log, a CI
122
138
  // transcript, a stack trace — and `code: title` alone names which rule fired but not which
@@ -169,17 +185,25 @@ export class ValidationFailedError extends SchemaError {
169
185
  override readonly name = 'ValidationFailedError';
170
186
  readonly issues: readonly ValidationIssue[];
171
187
 
172
- constructor(issues: readonly ValidationIssue[], root = 'value') {
173
- const cause = issues
188
+ /**
189
+ * `true` when validation stopped at its issue cap (`MAX_ISSUES`): `issues` is a prefix, and
190
+ * correcting every one of them may surface more.
191
+ */
192
+ readonly issuesTruncated: boolean;
193
+
194
+ constructor(issues: readonly ValidationIssue[], root = 'value', issuesTruncated = false) {
195
+ const listed = issues
174
196
  .map((issue) => `${issue.path === '' ? root : issue.path}: ${issue.message}`)
175
197
  .join('; ');
198
+ const cause = issuesTruncated ? `${listed}; (stopped after ${issues.length} issues)` : listed;
176
199
  super({
177
200
  code: ValidationFailedError.code,
178
201
  cause,
179
202
  fix: `send ${root} with the field(s) named in cause corrected to the expected type`,
180
- meta: { issues },
203
+ meta: issuesTruncated ? { issues, issuesTruncated: true } : { issues },
181
204
  });
182
205
  this.issues = issues;
206
+ this.issuesTruncated = issuesTruncated;
183
207
  }
184
208
 
185
209
  /** One line per issue — what the dev overlay and `x test --json` render. */
@@ -240,6 +264,16 @@ export class SensitiveOutputError extends SchemaError {
240
264
  }
241
265
  }
242
266
 
267
+ /** Thrown where `validate(…, { maxIssues })` is CALLED: a cap no list can meet bounds nothing. */
268
+ export class IssueCapInvalidError extends SchemaError {
269
+ static readonly code = 'X_SCHEMA_ISSUE_CAP_INVALID';
270
+ override readonly name = 'IssueCapInvalidError';
271
+
272
+ constructor(init: Omit<SchemaErrorInit, 'code'>) {
273
+ super({ ...init, code: IssueCapInvalidError.code });
274
+ }
275
+ }
276
+
243
277
  export class SchemaUnsupportedError extends SchemaError {
244
278
  static readonly code = 'X_SCHEMA_UNSUPPORTED';
245
279
  override readonly name = 'SchemaUnsupportedError';
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  // Single responsibility: the public API of @ultimat3/schema. Explicit named exports only.
2
2
 
3
3
  export type { ArrayBounds } from './array-schema';
4
- export { arraySchema } from './array-schema';
5
4
  export type {
6
5
  AnySchema,
7
6
  Check,
@@ -26,7 +25,6 @@ export { charCount } from './char-count';
26
25
  export type { QuerySource } from './coerce';
27
26
  export { coerceInput, coerceNode, coerceQuery, numeric } from './coerce';
28
27
  export { describeValue, expected } from './describe-value';
29
- export { discriminatedUnionSchema } from './discriminated-union';
30
28
  export { ULTIMATE_ERROR_BRAND } from './error-brand';
31
29
  export type { SchemaErrorCodeDeclaration } from './error-codes';
32
30
  export { SCHEMA_ERROR_CODES } from './error-codes';
@@ -40,6 +38,7 @@ export {
40
38
  BoundsInvalidError,
41
39
  DefaultInvalidError,
42
40
  DiscriminantInvalidError,
41
+ IssueCapInvalidError,
43
42
  isSchemaError,
44
43
  SchemaError,
45
44
  SchemaUnsupportedError,
@@ -48,6 +47,9 @@ export {
48
47
  } from './errors';
49
48
  // Exported for `@ultimat3/time`'s `fromIso`: one ISO-shape rule, whichever door a date comes in by.
50
49
  export { isIsoDateTime } from './iso-date';
50
+ export type { IssueCapOptions } from './issue-cap';
51
+ // The cap every validation stops at, and the bound `@ultimat3/action`'s `issuesFromWire` reads to.
52
+ export { MAX_ISSUES } from './issue-cap';
51
53
  export type {
52
54
  JsonSchema,
53
55
  JsonSchemaDialect,
@@ -57,7 +59,7 @@ export type {
57
59
  } from './json-schema';
58
60
  export { nodeToJsonSchema, toJsonSchema } from './json-schema';
59
61
  export type { JsonValue } from './json-value';
60
- export { JSON_MAX_DEPTH, jsonSchema } from './json-value';
62
+ export { jsonSchema } from './json-value';
61
63
  export type { MoneyValue } from './money-value';
62
64
  export {
63
65
  CURRENCY_CODE_PATTERN,
@@ -68,16 +70,9 @@ export {
68
70
  export type { SchemaFormat, SchemaKind, SchemaNode, SchemaRefinement } from './node';
69
71
  export { isSchemaNode, nodeOf, requiredKeys } from './node';
70
72
  export type { SchemaProvider } from './provider';
71
- export {
72
- builtinProvider,
73
- configureSchemaProvider,
74
- introspect,
75
- resetSchemaProvider,
76
- schemaProvider,
77
- tryIntrospect,
78
- } from './provider';
73
+ export { configureSchemaProvider, introspect, tryIntrospect } from './provider';
79
74
  export { recordSchema } from './record-schema';
80
- export type { IssueRuleFacts, SchemaIssueParams, SchemaRule } from './schema-rules';
75
+ export type { IssueRuleFacts, SchemaIssueParams } from './schema-rules';
81
76
  export { issueRuleFacts, SCHEMA_RULES } from './schema-rules';
82
77
  export { assertNoSensitiveOutput, maskSensitive } from './sensitive';
83
78
  export type {
@@ -106,21 +101,7 @@ export {
106
101
  export type { Infer } from './t';
107
102
  export { t } from './t';
108
103
  export { isIanaZoneName } from './time-zone-name';
109
- export type {
110
- NumberSchema,
111
- ObjectSchema,
112
- StringSchema,
113
- TNamespace,
114
- } from './validators';
115
- export {
116
- builtinT,
117
- enumSchema,
118
- literalSchema,
119
- nullableSchema,
120
- objectSchema,
121
- optionalSchema,
122
- refineSchema,
123
- unionSchema,
124
- } from './validators';
104
+ export type { NumberSchema, ObjectSchema, StringSchema, TNamespace } from './validators';
105
+ export { literalSchema, objectSchema } from './validators';
125
106
  export type { WireJsonSchema } from './wire-schema';
126
107
  export { toWireOutputSchema, toWireSchema } from './wire-schema';
@@ -0,0 +1,108 @@
1
+ // Single responsibility: the bound on how many issues one validation collects. A public action's
2
+ // body is the attack surface: 10k invalid items cost 289 ms of sync CPU and a 7.2 MiB problem
3
+ // document before this, for a list no client renders past `MAX_ISSUES` (`@ultimat3/action`'s
4
+ // `wire-issues.ts` refuses a longer one on arrival).
5
+
6
+ import type { StandardIssue } from './standard';
7
+
8
+ /**
9
+ * The default cap, and the bound the typed client's `issuesFromWire` imports: a list longer than
10
+ * this is not a form's worth of rejections, it is a body meant to be expensive.
11
+ */
12
+ export const MAX_ISSUES = 100;
13
+
14
+ export interface IssueCapOptions {
15
+ /** Stop collecting at this many issues. Default `MAX_ISSUES`; a whole number from 1 up. */
16
+ readonly maxIssues?: number | undefined;
17
+ }
18
+
19
+ /**
20
+ * The cap of the validation in flight, `undefined` between validations. Module state, not a
21
+ * parameter on every check: `Check` is a public signature, and builtin validation is synchronous,
22
+ * so exactly one validation is ever in flight on this thread while a check runs.
23
+ */
24
+ let active: number | undefined;
25
+ /** Set when a loop left input unchecked because the cap was reached. */
26
+ let stopped = false;
27
+
28
+ /**
29
+ * Why `maxIssues` is no cap, or `undefined` when it is one. The refusal is thrown by `standard.ts`,
30
+ * which already imports `errors.ts`: this module stays a leaf so `builder.ts` can read it.
31
+ */
32
+ export function issueCapFault(maxIssues: number): string | undefined {
33
+ return Number.isSafeInteger(maxIssues) && maxIssues >= 1
34
+ ? undefined
35
+ : 'maxIssues must be a whole number from 1 up';
36
+ }
37
+
38
+ /**
39
+ * Has a loop collected enough? Asked after each member a builtin composite checks; outside any
40
+ * validation (a `Check` called directly) the default cap applies, so no door is unbounded.
41
+ */
42
+ export function issueCapReached(count: number): boolean {
43
+ return count >= (active ?? MAX_ISSUES);
44
+ }
45
+
46
+ /**
47
+ * The question every collecting loop asks after a member: stop here? `true` once `count` has
48
+ * reached the cap — and when input is left unchecked (`hasMore`), the result is marked a prefix.
49
+ */
50
+ export function stopCollecting(count: number, hasMore: boolean): boolean {
51
+ if (!issueCapReached(count)) return false;
52
+ if (hasMore) stopped = true;
53
+ return true;
54
+ }
55
+
56
+ /** A loop is leaving input unchecked: the result says so (`issuesTruncated`). */
57
+ export function markIssuesStopped(): void {
58
+ stopped = true;
59
+ }
60
+
61
+ /**
62
+ * `markIssuesStopped`'s flag as it stands, and a way to put it back. A union tries members and
63
+ * DISCARDS a failing member's issues, so a member that stopped early must not mark the result.
64
+ */
65
+ export function issuesStopped(): boolean {
66
+ return stopped;
67
+ }
68
+
69
+ export function restoreIssuesStopped(previous: boolean): void {
70
+ stopped = previous;
71
+ }
72
+
73
+ export interface CappedRun<T> {
74
+ readonly value: T;
75
+ readonly cap: number;
76
+ /** Only the OUTERMOST run reports: a nested validation is part of its caller's. */
77
+ readonly stopped: boolean;
78
+ }
79
+
80
+ /** Run one validation under a cap. Re-entrant: a nested call joins the outer run's cap. */
81
+ export function withIssueCap<T>(cap: number, run: () => T): CappedRun<T> {
82
+ if (active !== undefined) return { value: run(), cap: active, stopped: false };
83
+ active = cap;
84
+ stopped = false;
85
+ try {
86
+ const value = run();
87
+ return { value, cap, stopped };
88
+ } finally {
89
+ active = undefined;
90
+ stopped = false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * A failure result held to `cap`. `issuesTruncated: true` when issues were dropped here OR a loop
96
+ * stopped checking — either way the list is a prefix, and a reader must not treat it as complete.
97
+ */
98
+ export function cappedFailure(
99
+ issues: readonly StandardIssue[],
100
+ cap: number,
101
+ stoppedEarly: boolean,
102
+ ): { readonly issues: readonly StandardIssue[]; readonly issuesTruncated?: true } {
103
+ const over = issues.length > cap;
104
+ // A nested validation cut here is its caller's truncation too: the flag the outermost run reads.
105
+ if (over) stopped = true;
106
+ const kept = over ? issues.slice(0, cap) : issues;
107
+ return over || stoppedEarly ? { issues: kept, issuesTruncated: true } : { issues: kept };
108
+ }
package/src/json-value.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type Schema,
13
13
  } from './builder';
14
14
  import { expected } from './describe-value';
15
+ import { stopCollecting } from './issue-cap';
15
16
  import type { SchemaNode } from './node';
16
17
  import type { StandardIssue } from './standard';
17
18
  import { unstorable } from './unstorable-text';
@@ -132,6 +133,8 @@ function walkArray(
132
133
  // An index loop, never `entries()` on a sparse array alone: a hole reads `undefined` and is
133
134
  // refused at its index, where `JSON.stringify` would have written `null` in silence.
134
135
  for (let index = 0; index < items.length; index += 1) {
136
+ // At the issue cap the rest is not walked (`issue-cap.ts`); issues already exist, so this fails.
137
+ if (stopCollecting(state.issues.length, true)) return null;
135
138
  const step = walk(items[index], { parent: at, segment: index }, depth + 1, state);
136
139
  if (step === null) ok = false;
137
140
  else {
@@ -156,6 +159,7 @@ function walkObject(
156
159
  // The entry's POSITION is its path segment, never its key — the rule `recordSchema` states:
157
160
  // a key is caller data, and an issue path reaches the problem document and the log line.
158
161
  for (const [index, key] of Object.keys(source).entries()) {
162
+ if (stopCollecting(state.issues.length, true)) return null;
159
163
  const link: At = { parent: at, segment: index };
160
164
  const reason = unstorable(key);
161
165
  if (reason !== undefined) {
@@ -13,6 +13,7 @@ import {
13
13
  type Schema,
14
14
  } from './builder';
15
15
  import { expected } from './describe-value';
16
+ import { stopCollecting } from './issue-cap';
16
17
  import type { SchemaNode } from './node';
17
18
  import { isPrototypeKey, PROTOTYPE_KEYS } from './prototype-keys';
18
19
  import type { InferInput, InferOutput, StandardIssue } from './standard';
@@ -30,7 +31,10 @@ export function recordSchema<S extends AnySchema>(
30
31
  return mistyped(path, expected('an object', value), value, 'record');
31
32
  const issues: StandardIssue[] = [];
32
33
  const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
33
- for (const [index, [key, entry]] of Object.entries(value).entries()) {
34
+ const entries = Object.entries(value);
35
+ for (const [index, [key, entry]] of entries.entries()) {
36
+ // At the issue cap the rest is not read (`issue-cap.ts`): a 10k-key body is a hundred.
37
+ if (stopCollecting(issues.length, true)) break;
34
38
  // The entry's POSITION, never its key. `describe-value.ts`'s absolute is stated about the
35
39
  // rejected value, and a record's KEY is the caller's data just as much: the path travels
36
40
  // `formatIssue` -> `bodyInvalid`'s `cause` -> the problem document AND the log line, and
package/src/standard.ts CHANGED
@@ -2,7 +2,19 @@
2
2
  // This is the swap point: every Ultimate primitive accepts any conforming schema, so ArkType,
3
3
  // Zod and Valibot are all first-class without a line of adapter code.
4
4
 
5
- import { SchemaUnsupportedError, ValidationFailedError, type ValidationIssue } from './errors';
5
+ import {
6
+ IssueCapInvalidError,
7
+ SchemaUnsupportedError,
8
+ ValidationFailedError,
9
+ type ValidationIssue,
10
+ } from './errors';
11
+ import {
12
+ cappedFailure,
13
+ type IssueCapOptions,
14
+ issueCapFault,
15
+ MAX_ISSUES,
16
+ withIssueCap,
17
+ } from './issue-cap';
6
18
  import { issueRuleFacts, type SchemaIssueParams } from './schema-rules';
7
19
 
8
20
  export interface StandardSchemaV1<Input = unknown, Output = Input> {
@@ -29,6 +41,11 @@ export interface StandardSuccessResult<Output> {
29
41
 
30
42
  export interface StandardFailureResult {
31
43
  readonly issues: readonly StandardIssue[];
44
+ /**
45
+ * Ultimate's extension: `true` when validation stopped at its issue cap (`MAX_ISSUES`, or
46
+ * `validate(…, { maxIssues })`), so `issues` is a prefix of what is wrong, never all of it.
47
+ */
48
+ readonly issuesTruncated?: true | undefined;
32
49
  }
33
50
 
34
51
  export type StandardResult<Output> = StandardSuccessResult<Output> | StandardFailureResult;
@@ -130,21 +147,54 @@ function assertSync<Output>(
130
147
  return result;
131
148
  }
132
149
 
133
- /** Validate against ANY conforming schema. Never throws on invalid input. */
150
+ function capOf(options: IssueCapOptions | undefined): number {
151
+ const max = options?.maxIssues;
152
+ if (max === undefined) return MAX_ISSUES;
153
+ const fault = issueCapFault(max);
154
+ if (fault === undefined) return max;
155
+ throw new IssueCapInvalidError({
156
+ cause: `${fault}; got ${Number.isFinite(max) ? String(max) : typeof max}`,
157
+ fix: 'validate(schema, value, { maxIssues: 100 }) # or omit maxIssues for the default MAX_ISSUES',
158
+ meta: { maxIssues: max },
159
+ });
160
+ }
161
+
162
+ /** A failure held to its cap; a success passes through untouched. */
163
+ function bounded<Output>(
164
+ result: StandardResult<Output>,
165
+ cap: number,
166
+ stopped: boolean,
167
+ ): StandardResult<Output> {
168
+ if (result.issues === undefined) return result;
169
+ return cappedFailure(result.issues, cap, stopped || result.issuesTruncated === true);
170
+ }
171
+
172
+ /**
173
+ * Validate against ANY conforming schema. Never throws on invalid input. At most `maxIssues`
174
+ * (default `MAX_ISSUES`) issues come back: a builtin schema STOPS checking there, so a hostile
175
+ * 10k-item body costs a hundred refusals, not ten thousand; a foreign library's list is cut.
176
+ */
134
177
  export function validate<S extends StandardSchemaV1>(
135
178
  schema: S,
136
179
  value: unknown,
180
+ options?: IssueCapOptions,
137
181
  ): StandardResult<InferOutput<S>> {
138
182
  const props = schema['~standard'];
139
- return assertSync(props.validate(value), props.vendor) as StandardResult<InferOutput<S>>;
183
+ const run = withIssueCap(capOf(options), () => props.validate(value));
184
+ const result = assertSync(run.value, props.vendor) as StandardResult<InferOutput<S>>;
185
+ return bounded(result, run.cap, run.stopped);
140
186
  }
141
187
 
142
188
  export async function validateAsync<S extends StandardSchemaV1>(
143
189
  schema: S,
144
190
  value: unknown,
191
+ options?: IssueCapOptions,
145
192
  ): Promise<StandardResult<InferOutput<S>>> {
146
- const result = await schema['~standard'].validate(value);
147
- return result as StandardResult<InferOutput<S>>;
193
+ // The cap spans the SYNCHRONOUS part of the call — all of a builtin schema's work. A library
194
+ // that validates asynchronously finishes outside it, and its list is cut below instead.
195
+ const run = withIssueCap(capOf(options), () => schema['~standard'].validate(value));
196
+ const result = (await run.value) as StandardResult<InferOutput<S>>;
197
+ return bounded(result, run.cap, run.stopped);
148
198
  }
149
199
 
150
200
  /** Validate or throw `X_VALIDATION_FAILED` with a path-annotated issue list. */
@@ -155,7 +205,11 @@ export function parse<S extends StandardSchemaV1>(
155
205
  ): InferOutput<S> {
156
206
  const result = validate(schema, value);
157
207
  if (result.issues === undefined) return result.value;
158
- throw new ValidationFailedError(toValidationIssues(result.issues), root);
208
+ throw new ValidationFailedError(
209
+ toValidationIssues(result.issues),
210
+ root,
211
+ result.issuesTruncated === true,
212
+ );
159
213
  }
160
214
 
161
215
  export async function parseAsync<S extends StandardSchemaV1>(
@@ -165,7 +219,11 @@ export async function parseAsync<S extends StandardSchemaV1>(
165
219
  ): Promise<InferOutput<S>> {
166
220
  const result = await validateAsync(schema, value);
167
221
  if (result.issues === undefined) return result.value;
168
- throw new ValidationFailedError(toValidationIssues(result.issues), root);
222
+ throw new ValidationFailedError(
223
+ toValidationIssues(result.issues),
224
+ root,
225
+ result.issuesTruncated === true,
226
+ );
169
227
  }
170
228
 
171
229
  export function isStandardSchema(value: unknown): value is StandardSchemaV1 {
package/src/validators.ts CHANGED
@@ -24,6 +24,7 @@ import { charCount } from './char-count';
24
24
  import { expected } from './describe-value';
25
25
  import { discriminatedUnionSchema } from './discriminated-union';
26
26
  import { isIsoDateTime, isZonelessDateTime } from './iso-date';
27
+ import { issuesStopped, restoreIssuesStopped, stopCollecting } from './issue-cap';
27
28
  import { type JsonValue, jsonSchema } from './json-value';
28
29
  import { type MoneyValue, moneySchema } from './money-value';
29
30
  import type { SchemaNode } from './node';
@@ -216,6 +217,7 @@ export function objectSchema<S extends Shape>(shape: S): ObjectSchema<S> {
216
217
  // back off a value nobody sent.
217
218
  const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
218
219
  for (const [key, memberCheck] of checks) {
220
+ if (stopCollecting(issues.length, true)) break;
219
221
  // `Object.hasOwn`, never a raw index — the guard `coerce.ts` already applies to the same
220
222
  // read. `value['toString']` answered the INHERITED function for an input that carried no
221
223
  // such key, so a field named after a prototype member was unsatisfiable for every input,
@@ -292,11 +294,18 @@ export function unionSchema<S extends readonly [AnySchema, ...AnySchema[]]>(
292
294
  const node: SchemaNode = { kind: 'union', anyOf: members.map((member) => member.node) };
293
295
  return makeSchema<InferInput<S[number]>, InferOutput<S[number]>>(node, (value, path) => {
294
296
  const reasons: string[] = [];
297
+ // A member's issues are DISCARDED here, so a member that stopped at the cap must not mark the
298
+ // result a prefix: the union answers one issue of its own, which is the whole of it.
299
+ const stoppedBefore = issuesStopped();
295
300
  for (const memberCheck of checks) {
296
301
  const result = memberCheck(value, path);
297
- if (result.ok) return pass(result.value as InferOutput<S[number]>);
302
+ if (result.ok) {
303
+ restoreIssuesStopped(stoppedBefore);
304
+ return pass(result.value as InferOutput<S[number]>);
305
+ }
298
306
  reasons.push(result.issues.map((issue) => issue.message).join(', '));
299
307
  }
308
+ restoreIssuesStopped(stoppedBefore);
300
309
  return rejected(path, `no union member matched (${reasons.join(' | ')})`, 'union');
301
310
  });
302
311
  }