@ultimat3/schema 30.9.0 → 31.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 +6 -1
- package/README.md +17 -4
- package/package.json +2 -1
- package/src/adapter.ts +5 -0
- package/src/array-schema.ts +6 -1
- package/src/builder.ts +24 -11
- package/src/error-codes.ts +1 -0
- package/src/errors.ts +38 -4
- package/src/index.ts +9 -28
- package/src/issue-cap.ts +108 -0
- package/src/json-value.ts +4 -0
- package/src/record-schema.ts +5 -1
- package/src/standard.ts +65 -7
- package/src/validators.ts +10 -1
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
|
|
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()`,
|
|
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.
|
|
51
|
-
|
|
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
|
-
| `
|
|
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": "
|
|
3
|
+
"version": "31.1.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';
|
package/src/array-schema.ts
CHANGED
|
@@ -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
|
|
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(
|
|
234
|
-
return
|
|
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
|
|
256
|
-
if (result.
|
|
257
|
-
throw new ValidationFailedError(
|
|
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
|
-
|
|
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>(
|
package/src/error-codes.ts
CHANGED
|
@@ -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
|
-
|
|
173
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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';
|
package/src/issue-cap.ts
ADDED
|
@@ -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) {
|
package/src/record-schema.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
}
|