@ultimat3/schema 23.0.0 → 25.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 +43 -10
- package/README.md +53 -7
- package/package.json +2 -2
- package/src/absolute-url.ts +20 -0
- package/src/builder.ts +39 -4
- package/src/coerce.ts +42 -17
- package/src/error-brand.ts +10 -0
- package/src/error-codes.ts +1 -0
- package/src/errors.ts +36 -11
- package/src/index.ts +9 -3
- package/src/iso-date.ts +28 -4
- package/src/json-schema.ts +28 -1
- package/src/json-value.ts +205 -0
- package/src/money-value.ts +1 -1
- package/src/node-fits.ts +51 -0
- package/src/node.ts +10 -1
- package/src/render-meta.ts +53 -0
- package/src/standard.ts +12 -1
- package/src/t.ts +9 -0
- package/src/time-zone-name.ts +4 -5
- package/src/unstorable-text.ts +20 -0
- package/src/validators.ts +17 -16
- package/src/wire-schema.ts +126 -0
package/src/iso-date.ts
CHANGED
|
@@ -20,7 +20,28 @@ const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
|
|
|
20
20
|
* because RFC 3339 §5.6 permits a lowercase `t` and `z`.
|
|
21
21
|
*/
|
|
22
22
|
const ISO_SHAPE =
|
|
23
|
-
|
|
23
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/i;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Days per month, January first, in a common year. **Twin: `daysInMonth` in
|
|
27
|
+
* `packages/time/src/plain-date.ts`** — this package is tier 0 and cannot import `@ultimat3/time`,
|
|
28
|
+
* so the Gregorian table is restated rather than shared. The two are held equal by the parity
|
|
29
|
+
* table in `iso-date.test.ts` and `packages/time/src/plain-date.test.ts`; edit both or neither.
|
|
30
|
+
*/
|
|
31
|
+
const DAYS_IN_MONTH: readonly number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
32
|
+
|
|
33
|
+
const isLeapYear = (year: number): boolean =>
|
|
34
|
+
(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The half a regex cannot hold. `new Date('2026-02-30')` does not refuse — it answers March 2nd —
|
|
38
|
+
* so a typo validated, and the instant that was stored is one the caller never wrote.
|
|
39
|
+
*/
|
|
40
|
+
function isCalendarDay(year: number, month: number, day: number): boolean {
|
|
41
|
+
const days = DAYS_IN_MONTH[month - 1];
|
|
42
|
+
if (days === undefined) return false;
|
|
43
|
+
return day >= 1 && day <= (month === 2 && isLeapYear(year) ? 29 : days);
|
|
44
|
+
}
|
|
24
45
|
|
|
25
46
|
/** What a `t.date` string must not be: a clock time with no offset and no `Z`. */
|
|
26
47
|
export function isZonelessDateTime(value: string): boolean {
|
|
@@ -28,9 +49,12 @@ export function isZonelessDateTime(value: string): boolean {
|
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
/**
|
|
31
|
-
* True when `value` is an ISO-8601 date, or date-time carrying `Z` or an offset
|
|
32
|
-
* whose instant is the same on every host
|
|
52
|
+
* True when `value` is an ISO-8601 date, or date-time carrying `Z` or an offset, naming a day its
|
|
53
|
+
* month has: the only strings whose instant is the same on every host and is the one written.
|
|
54
|
+
* Check it before `new Date(value)`, every time.
|
|
33
55
|
*/
|
|
34
56
|
export function isIsoDateTime(value: string): boolean {
|
|
35
|
-
|
|
57
|
+
const match = ISO_SHAPE.exec(value);
|
|
58
|
+
if (match === null || isZonelessDateTime(value)) return false;
|
|
59
|
+
return isCalendarDay(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
36
60
|
}
|
package/src/json-schema.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { describeValue } from './describe-value';
|
|
7
7
|
import { SchemaUnsupportedError } from './errors';
|
|
8
|
+
import { JSON_MAX_DEPTH } from './json-value';
|
|
8
9
|
import { CURRENCY_CODE_PATTERN, MAX_MONEY_SCALE } from './money-value';
|
|
9
10
|
import { requiredKeys, type SchemaNode, type SchemaRefinement } from './node';
|
|
10
11
|
import { PROTOTYPE_KEYS } from './prototype-keys';
|
|
@@ -34,6 +35,13 @@ export interface JsonSchema {
|
|
|
34
35
|
readonly minLength?: number;
|
|
35
36
|
readonly maxLength?: number;
|
|
36
37
|
readonly pattern?: string;
|
|
38
|
+
/**
|
|
39
|
+
* The RegExp flags `pattern` is applied with, when there are any. JSON Schema's `pattern` has no
|
|
40
|
+
* flag syntax and an inline `(?i)` is not ECMA-262, so they travel beside it as an extension a
|
|
41
|
+
* consumer that compiles the pattern (`@ultimat3/mcp`'s arg validator) reads; the prose copy in
|
|
42
|
+
* `description` is for a reader.
|
|
43
|
+
*/
|
|
44
|
+
readonly 'x-ultimate-pattern-flags'?: string;
|
|
37
45
|
readonly minimum?: number;
|
|
38
46
|
readonly maximum?: number;
|
|
39
47
|
readonly enum?: readonly (string | number)[];
|
|
@@ -99,6 +107,9 @@ function stringNode(node: SchemaNode): JsonSchema {
|
|
|
99
107
|
...(node.minLength === undefined ? {} : { minLength: node.minLength }),
|
|
100
108
|
...(node.maxLength === undefined ? {} : { maxLength: node.maxLength }),
|
|
101
109
|
...(node.pattern === undefined ? {} : { pattern: node.pattern }),
|
|
110
|
+
...(node.pattern === undefined || node.patternFlags === undefined || node.patternFlags === ''
|
|
111
|
+
? {}
|
|
112
|
+
: { 'x-ultimate-pattern-flags': node.patternFlags }),
|
|
102
113
|
};
|
|
103
114
|
}
|
|
104
115
|
|
|
@@ -148,10 +159,22 @@ function patternNote(node: SchemaNode): string | undefined {
|
|
|
148
159
|
: `pattern is applied with RegExp flags "${flags}"`;
|
|
149
160
|
}
|
|
150
161
|
|
|
162
|
+
/**
|
|
163
|
+
* JSON Schema has no keyword for nesting depth, so `t.json()`'s bound is stated in prose — the
|
|
164
|
+
* narrower-of-the-two rule this file's header states: `{}` alone promised a document 300 levels
|
|
165
|
+
* deep that the parser refuses.
|
|
166
|
+
*/
|
|
167
|
+
function jsonNote(node: SchemaNode): string | undefined {
|
|
168
|
+
return node.kind === 'json'
|
|
169
|
+
? `any JSON value, nested at most ${JSON_MAX_DEPTH} levels deep`
|
|
170
|
+
: undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
151
173
|
function convert(node: SchemaNode): JsonSchema {
|
|
152
174
|
const refinements = node.refinements ?? [];
|
|
153
175
|
const notes = [
|
|
154
176
|
node.description,
|
|
177
|
+
jsonNote(node),
|
|
155
178
|
patternNote(node),
|
|
156
179
|
...refinements.map((refinement) => refinement.message),
|
|
157
180
|
].filter((part): part is string => part !== undefined);
|
|
@@ -272,6 +295,10 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
272
295
|
additionalProperties: false,
|
|
273
296
|
});
|
|
274
297
|
}
|
|
298
|
+
// No `type`: an empty schema is JSON Schema's own spelling of "any JSON value", and every
|
|
299
|
+
// instance it validates is JSON already. The depth bound rides in `description` (`jsonNote`).
|
|
300
|
+
case 'json':
|
|
301
|
+
return annotate({});
|
|
275
302
|
default:
|
|
276
303
|
return annotate({});
|
|
277
304
|
}
|
|
@@ -315,7 +342,7 @@ export function toJsonSchema(schema: unknown, options?: ToJsonSchemaOptions): Js
|
|
|
315
342
|
};
|
|
316
343
|
}
|
|
317
344
|
|
|
318
|
-
/**
|
|
345
|
+
/** Draft-07, no `$schema`: the document `wire-schema.ts` narrows to what an MCP tool publishes. */
|
|
319
346
|
export function toMcpInputSchema(schema: unknown): JsonSchema {
|
|
320
347
|
return toJsonSchema(schema, { dialect: 'draft-07', includeDialect: false });
|
|
321
348
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Single responsibility: the framework's ONE `JsonValue` declaration and `t.json()`, the validator
|
|
2
|
+
// that admits exactly the values `JSON.stringify` and a Postgres `jsonb` round-trip unchanged.
|
|
3
|
+
// Its own file for `money-value.ts`'s reason: it is a builtin whose SHAPE other packages alias.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Check,
|
|
7
|
+
fail,
|
|
8
|
+
failWith,
|
|
9
|
+
isPlainObject,
|
|
10
|
+
makeSchema,
|
|
11
|
+
pass,
|
|
12
|
+
type Schema,
|
|
13
|
+
} from './builder';
|
|
14
|
+
import { expected } from './describe-value';
|
|
15
|
+
import type { SchemaNode } from './node';
|
|
16
|
+
import type { StandardIssue } from './standard';
|
|
17
|
+
import { unstorable } from './unstorable-text';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Any value JSON can carry. Readonly, because a parsed payload is data the handler reads; the copy
|
|
21
|
+
* `t.json()` answers is its own, so nothing it holds is shared with the caller's input.
|
|
22
|
+
*/
|
|
23
|
+
export type JsonValue =
|
|
24
|
+
| string
|
|
25
|
+
| number
|
|
26
|
+
| boolean
|
|
27
|
+
| null
|
|
28
|
+
| readonly JsonValue[]
|
|
29
|
+
| { readonly [key: string]: JsonValue };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many containers `t.json()` lets nest. A FIXED bound, not an option: the walk recurses once
|
|
33
|
+
* per level and `JSON.parse` builds 100 000 levels from 200 KB of brackets, so an unbounded walk
|
|
34
|
+
* was a `RangeError` — a 500 — for any caller who sent one. 256 holds a rich-text document (each
|
|
35
|
+
* list level is an array and an object) with room to spare. Narrower is `.refine()`'s job.
|
|
36
|
+
*/
|
|
37
|
+
export const JSON_MAX_DEPTH = 256;
|
|
38
|
+
|
|
39
|
+
/** A path segment chain, materialised only for an issue: a copy per node is O(n × depth). */
|
|
40
|
+
interface At {
|
|
41
|
+
readonly parent: At | readonly PropertyKey[];
|
|
42
|
+
readonly segment: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pathOf(at: At | readonly PropertyKey[]): PropertyKey[] {
|
|
46
|
+
const segments: number[] = [];
|
|
47
|
+
let cursor = at;
|
|
48
|
+
while (!Array.isArray(cursor)) {
|
|
49
|
+
const link = cursor as At;
|
|
50
|
+
segments.push(link.segment);
|
|
51
|
+
cursor = link.parent;
|
|
52
|
+
}
|
|
53
|
+
return [...(cursor as readonly PropertyKey[]), ...segments.reverse()];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Walk {
|
|
57
|
+
readonly issues: StandardIssue[];
|
|
58
|
+
/** The containers enclosing the current one: meeting one again is a cycle. */
|
|
59
|
+
readonly open: Set<object>;
|
|
60
|
+
/** A container already accepted, with its height, so a shared one is walked ONCE. */
|
|
61
|
+
readonly done: Map<object, { readonly value: JsonValue; readonly height: number }>;
|
|
62
|
+
/** A container already refused: its issues are reported, at the first position it was met. */
|
|
63
|
+
readonly refused: Set<object>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Step = { readonly ok: true; readonly value: JsonValue; readonly height: number } | null;
|
|
67
|
+
|
|
68
|
+
const CYCLE = 'expected a JSON value, received a cycle (a reference back to an enclosing value)';
|
|
69
|
+
const NOT_PLAIN = 'expected a JSON value, received an object that is not a plain object or array';
|
|
70
|
+
|
|
71
|
+
function refuse(state: Walk, at: At | readonly PropertyKey[], message: string): null {
|
|
72
|
+
state.issues.push({ message, path: pathOf(at) });
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function scalar(value: unknown, at: At | readonly PropertyKey[], state: Walk): Step {
|
|
77
|
+
if (value === null || typeof value === 'boolean') return { ok: true, value, height: 0 };
|
|
78
|
+
if (typeof value === 'number') {
|
|
79
|
+
return Number.isFinite(value)
|
|
80
|
+
? { ok: true, value, height: 0 }
|
|
81
|
+
: refuse(state, at, expected('a JSON value', value));
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === 'string') {
|
|
84
|
+
const reason = unstorable(value);
|
|
85
|
+
return reason === undefined
|
|
86
|
+
? { ok: true, value, height: 0 }
|
|
87
|
+
: refuse(state, at, `${expected('a JSON value', value)} ${reason}`);
|
|
88
|
+
}
|
|
89
|
+
return refuse(state, at, expected('a JSON value', value));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function walk(value: unknown, at: At | readonly PropertyKey[], depth: number, state: Walk): Step {
|
|
93
|
+
if (typeof value !== 'object' || value === null) return scalar(value, at, state);
|
|
94
|
+
const array = Array.isArray(value);
|
|
95
|
+
if (!array && !isPlainObject(value)) {
|
|
96
|
+
// A Date names itself (`describeValue`); a Map, a Set or a class instance is "an object" there.
|
|
97
|
+
return refuse(state, at, value instanceof Date ? expected('a JSON value', value) : NOT_PLAIN);
|
|
98
|
+
}
|
|
99
|
+
if (state.open.has(value)) return refuse(state, at, CYCLE);
|
|
100
|
+
if (state.refused.has(value)) return null;
|
|
101
|
+
const seen = state.done.get(value);
|
|
102
|
+
const tooDeep = expected(`JSON nested at most ${JSON_MAX_DEPTH} levels deep`, value);
|
|
103
|
+
if (seen !== undefined) {
|
|
104
|
+
return depth + seen.height <= JSON_MAX_DEPTH
|
|
105
|
+
? { ok: true, ...seen }
|
|
106
|
+
: refuse(state, at, tooDeep);
|
|
107
|
+
}
|
|
108
|
+
if (depth >= JSON_MAX_DEPTH) return refuse(state, at, tooDeep);
|
|
109
|
+
|
|
110
|
+
state.open.add(value);
|
|
111
|
+
const built = array ? walkArray(value, at, depth, state) : walkObject(value, at, depth, state);
|
|
112
|
+
state.open.delete(value);
|
|
113
|
+
if (built === null) {
|
|
114
|
+
state.refused.add(value);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
state.done.set(value, built);
|
|
118
|
+
return { ok: true, ...built };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type Built = { readonly value: JsonValue; readonly height: number } | null;
|
|
122
|
+
|
|
123
|
+
function walkArray(
|
|
124
|
+
items: readonly unknown[],
|
|
125
|
+
at: At | readonly PropertyKey[],
|
|
126
|
+
depth: number,
|
|
127
|
+
state: Walk,
|
|
128
|
+
): Built {
|
|
129
|
+
const out: JsonValue[] = [];
|
|
130
|
+
let height = 0;
|
|
131
|
+
let ok = true;
|
|
132
|
+
// An index loop, never `entries()` on a sparse array alone: a hole reads `undefined` and is
|
|
133
|
+
// refused at its index, where `JSON.stringify` would have written `null` in silence.
|
|
134
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
135
|
+
const step = walk(items[index], { parent: at, segment: index }, depth + 1, state);
|
|
136
|
+
if (step === null) ok = false;
|
|
137
|
+
else {
|
|
138
|
+
out.push(step.value);
|
|
139
|
+
height = Math.max(height, step.height);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return ok ? { value: out, height: height + 1 } : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function walkObject(
|
|
146
|
+
source: Record<string, unknown>,
|
|
147
|
+
at: At | readonly PropertyKey[],
|
|
148
|
+
depth: number,
|
|
149
|
+
state: Walk,
|
|
150
|
+
): Built {
|
|
151
|
+
// Null-prototype, for `recordSchema`'s reason: every key here is the caller's, and on a `{}`
|
|
152
|
+
// literal `out['__proto__'] = …` re-prototypes the output instead of storing the key.
|
|
153
|
+
const out: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>;
|
|
154
|
+
let height = 0;
|
|
155
|
+
let ok = true;
|
|
156
|
+
// The entry's POSITION is its path segment, never its key — the rule `recordSchema` states:
|
|
157
|
+
// a key is caller data, and an issue path reaches the problem document and the log line.
|
|
158
|
+
for (const [index, key] of Object.keys(source).entries()) {
|
|
159
|
+
const link: At = { parent: at, segment: index };
|
|
160
|
+
const reason = unstorable(key);
|
|
161
|
+
if (reason !== undefined) {
|
|
162
|
+
refuse(state, link, `expected an object key, received one ${reason}`);
|
|
163
|
+
ok = false;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const step = walk(source[key], link, depth + 1, state);
|
|
167
|
+
if (step === null) ok = false;
|
|
168
|
+
else {
|
|
169
|
+
out[key] = step.value;
|
|
170
|
+
height = Math.max(height, step.height);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return ok ? { value: out, height: height + 1 } : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const checkJson: Check<JsonValue> = (value, path) => {
|
|
177
|
+
const state: Walk = { issues: [], open: new Set(), done: new Map(), refused: new Set() };
|
|
178
|
+
const step = walk(value, path, 0, state);
|
|
179
|
+
if (step !== null) return pass(step.value);
|
|
180
|
+
// Every refusal pushes its issue; the fallback only keeps a refusal from ever reading as success.
|
|
181
|
+
return state.issues.length > 0
|
|
182
|
+
? failWith(state.issues)
|
|
183
|
+
: fail(path, expected('a JSON value', value));
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether a value has a JSON SHAPE, one level deep — `node-fits.ts`'s question for a union
|
|
188
|
+
* member. Not validation: a nested `undefined` still fits, and is refused by the parse after.
|
|
189
|
+
*/
|
|
190
|
+
export function isJsonShape(value: unknown): boolean {
|
|
191
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') return true;
|
|
192
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
193
|
+
return Array.isArray(value) || isPlainObject(value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const JSON_NODE: SchemaNode = Object.freeze({ kind: 'json' });
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `t.json()` as a free function, shipped beside the namespace member as `nullableSchema` is.
|
|
200
|
+
* Every key is kept, `__proto__` and `constructor` included — a webhook body is somebody else's
|
|
201
|
+
* document — and answered on a null-prototype copy, so no key reaches a prototype.
|
|
202
|
+
*/
|
|
203
|
+
export function jsonSchema(): Schema<JsonValue, JsonValue> {
|
|
204
|
+
return makeSchema<JsonValue, JsonValue>(JSON_NODE, checkJson);
|
|
205
|
+
}
|
package/src/money-value.ts
CHANGED
|
@@ -106,7 +106,7 @@ export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue
|
|
|
106
106
|
const currency = value['currency'];
|
|
107
107
|
const scale = value['scale'];
|
|
108
108
|
const issues: StandardIssue[] = [];
|
|
109
|
-
// Safe, not merely whole: `
|
|
109
|
+
// Safe, not merely whole: `fromMinor()` and `entity`'s `parseMinor` both demand a safe integer, so
|
|
110
110
|
// `Number.isInteger` here let 2^53 through the boundary as a 200 and failed at the row write
|
|
111
111
|
// as a 500 — the same value refused twice, once with a field path and once without.
|
|
112
112
|
if (typeof minor !== 'number' || !Number.isSafeInteger(minor)) {
|
package/src/node-fits.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Single responsibility: does a value have the SHAPE a node names, one level deep — the question
|
|
2
|
+
// `coerce.ts` asks to pick the union member a raw HTTP value was written for.
|
|
3
|
+
|
|
4
|
+
import { isPlainObject } from './builder';
|
|
5
|
+
import { isJsonShape } from './json-value';
|
|
6
|
+
import type { SchemaNode } from './node';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether `value` is the SHAPE `node` names — one level deep, which is all a union needs to pick
|
|
10
|
+
* a branch. Not validation: it never reads a bound, a pattern or a refinement, so a value that
|
|
11
|
+
* fits can still be refused, with the real message, by the schema it is handed to next.
|
|
12
|
+
*
|
|
13
|
+
* An object fits when every LITERAL field it declares holds that literal — the tag of a
|
|
14
|
+
* discriminated union, read off the IR without asking which key the discriminant is.
|
|
15
|
+
*/
|
|
16
|
+
export function fits(node: SchemaNode, value: unknown): boolean {
|
|
17
|
+
switch (node.kind) {
|
|
18
|
+
case 'string':
|
|
19
|
+
return typeof value === 'string';
|
|
20
|
+
case 'number':
|
|
21
|
+
return typeof value === 'number';
|
|
22
|
+
case 'boolean':
|
|
23
|
+
return typeof value === 'boolean';
|
|
24
|
+
case 'date':
|
|
25
|
+
return value instanceof Date;
|
|
26
|
+
case 'literal':
|
|
27
|
+
return value === node.literal;
|
|
28
|
+
case 'enum':
|
|
29
|
+
return (node.values ?? []).some((member) => member === value);
|
|
30
|
+
case 'array':
|
|
31
|
+
return Array.isArray(value);
|
|
32
|
+
case 'record':
|
|
33
|
+
case 'money':
|
|
34
|
+
return isPlainObject(value);
|
|
35
|
+
case 'object': {
|
|
36
|
+
if (!isPlainObject(value)) return false;
|
|
37
|
+
return Object.entries(node.properties ?? {}).every(
|
|
38
|
+
([key, child]) =>
|
|
39
|
+
child.kind !== 'literal' ||
|
|
40
|
+
(child.optional === true && !Object.hasOwn(value, key)) ||
|
|
41
|
+
(Object.hasOwn(value, key) && value[key] === child.literal),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
case 'union':
|
|
45
|
+
return (node.anyOf ?? []).some((member) => fits(member, value));
|
|
46
|
+
case 'json':
|
|
47
|
+
return isJsonShape(value);
|
|
48
|
+
default:
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/node.ts
CHANGED
|
@@ -14,6 +14,14 @@ export type SchemaKind =
|
|
|
14
14
|
| 'union'
|
|
15
15
|
| 'record'
|
|
16
16
|
| 'money'
|
|
17
|
+
/**
|
|
18
|
+
* Any JSON value — `t.json()`. A kind and not a field on `unknown`, because they are different
|
|
19
|
+
* facts: `unknown` is the IR saying it CANNOT describe a value (`@ultimat3/entity`'s view of a
|
|
20
|
+
* column it has no node for), `json` is the IR saying exactly what it holds. Every foreign
|
|
21
|
+
* `default:` already degrades to the right projection — `{}`, a raw pass-through, a `null`
|
|
22
|
+
* sample, admin's JSON textarea — so a consumer that has never heard of it is still correct.
|
|
23
|
+
*/
|
|
24
|
+
| 'json'
|
|
17
25
|
| 'unknown';
|
|
18
26
|
|
|
19
27
|
/** JSON Schema `format`, plus the framework's own semantic formats. */
|
|
@@ -54,7 +62,8 @@ export interface SchemaNode {
|
|
|
54
62
|
/**
|
|
55
63
|
* The RegExp's flags, carried beside the source for the same reason. Dropping them made
|
|
56
64
|
* `t.string.pattern(/^[a-z]+$/i)` reject `ABC` while quoting the pattern that matches it.
|
|
57
|
-
* JSON Schema's `pattern` has no flags, so `json-schema.ts`
|
|
65
|
+
* JSON Schema's `pattern` has no flags, so `json-schema.ts` publishes them as
|
|
66
|
+
* `x-ultimate-pattern-flags` beside it, and states them in `description` for a reader.
|
|
58
67
|
*/
|
|
59
68
|
readonly patternFlags?: string | undefined;
|
|
60
69
|
readonly minimum?: number | undefined;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Single responsibility: an error's `meta` as a record `JSON.stringify` cannot throw on. The twin
|
|
2
|
+
// of `renderMetaRecord` in `packages/core/src/error-render.ts`, restated because this package is
|
|
3
|
+
// tier 0 and may not import core — `SchemaError.toJSON()` makes the same `--json` promise.
|
|
4
|
+
|
|
5
|
+
import { describeValue } from './describe-value';
|
|
6
|
+
|
|
7
|
+
type Meta = Readonly<Record<string, unknown>>;
|
|
8
|
+
|
|
9
|
+
/** `JSON.stringify` with its throw removed: did the value survive being serialised at all? */
|
|
10
|
+
function canRender(value: unknown): boolean {
|
|
11
|
+
try {
|
|
12
|
+
JSON.stringify(value);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A record's own keys, or none — a `Proxy` may refuse to be enumerated. */
|
|
20
|
+
function metaKeys(meta: Meta): readonly string[] {
|
|
21
|
+
try {
|
|
22
|
+
return Object.keys(meta);
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One entry, kept as it is when it serialises. What does not is DESCRIBED, never echoed: a schema
|
|
30
|
+
* error's `meta` holds the value a caller sent, so `describeValue` — shape and length, no content —
|
|
31
|
+
* is the only rendering this package has for one. A function is described too: `JSON.stringify`
|
|
32
|
+
* drops it without throwing, but copying a `toJSON` across would have it invoked one layer out.
|
|
33
|
+
*/
|
|
34
|
+
function metaEntry(meta: Meta, key: string): unknown {
|
|
35
|
+
try {
|
|
36
|
+
const value = meta[key];
|
|
37
|
+
return canRender(value) && typeof value !== 'function' ? value : describeValue(value);
|
|
38
|
+
} catch {
|
|
39
|
+
return 'a value that cannot be read';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `meta` is machine-read, so a record that serialises is returned UNCHANGED, identity included.
|
|
45
|
+
* Only what cannot be rendered degrades, one key at a time — a `bigint` or a cycle in one entry
|
|
46
|
+
* must not cost the reader the entries beside it, and must not throw at `--json` render time.
|
|
47
|
+
*/
|
|
48
|
+
export function renderMetaRecord(meta: Meta | undefined): Meta | undefined {
|
|
49
|
+
if (meta === undefined || canRender(meta)) return meta;
|
|
50
|
+
const out: Record<string, unknown> = {};
|
|
51
|
+
for (const key of metaKeys(meta)) out[key] = metaEntry(meta, key);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
package/src/standard.ts
CHANGED
|
@@ -96,11 +96,22 @@ export function toValidationIssues(issues: readonly StandardIssue[]): readonly V
|
|
|
96
96
|
}));
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Async by the rule `await` itself uses — a callable `then` — never `instanceof Promise`. A
|
|
101
|
+
* library's own promise class, a polyfill or a Promise from another realm fails that test, and was
|
|
102
|
+
* then read as a RESULT: no `issues` key, so a success, with `value` undefined. `parse` returned
|
|
103
|
+
* `undefined` for input nothing had validated.
|
|
104
|
+
*/
|
|
105
|
+
export function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
106
|
+
if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return false;
|
|
107
|
+
return typeof (value as { then?: unknown }).then === 'function';
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
function assertSync<Output>(
|
|
100
111
|
result: StandardResult<Output> | Promise<StandardResult<Output>>,
|
|
101
112
|
vendor: string,
|
|
102
113
|
): StandardResult<Output> {
|
|
103
|
-
if (result
|
|
114
|
+
if (isThenable(result)) {
|
|
104
115
|
throw new SchemaUnsupportedError({
|
|
105
116
|
cause: `${vendor} validated asynchronously in a synchronous call site`,
|
|
106
117
|
fix: 'call validateAsync()/parseAsync() instead of validate()/parse()',
|
package/src/t.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// even for modules that captured `t` at import time.
|
|
4
4
|
|
|
5
5
|
import type { AnySchema, Refinement, Schema, Shape } from './builder';
|
|
6
|
+
import type { JsonValue } from './json-value';
|
|
6
7
|
import type { MoneyValue } from './money-value';
|
|
7
8
|
import { schemaProvider } from './provider';
|
|
8
9
|
import type { InferInput, InferOutput, StandardSchemaV1 } from './standard';
|
|
@@ -49,6 +50,14 @@ export const t: TNamespace = {
|
|
|
49
50
|
get cursor(): StringSchema {
|
|
50
51
|
return provider().cursor;
|
|
51
52
|
},
|
|
53
|
+
/**
|
|
54
|
+
* `t.json()` — any JSON value: null, a boolean, a finite number, a string, and arrays and plain
|
|
55
|
+
* objects of them, at most `JSON_MAX_DEPTH` deep. The column it pairs with is entity's
|
|
56
|
+
* `json(t.json())`, a `jsonb` that stores what it was sent.
|
|
57
|
+
*/
|
|
58
|
+
json(): Schema<JsonValue, JsonValue> {
|
|
59
|
+
return provider().json();
|
|
60
|
+
},
|
|
52
61
|
object<S extends Shape>(shape: S): ObjectSchema<S> {
|
|
53
62
|
return provider().object(shape);
|
|
54
63
|
},
|
package/src/time-zone-name.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single responsibility: is a string an IANA zone NAME? TIER 0'S ONE STATEMENT of the rule
|
|
2
2
|
// `@ultimat3/time` enforces everywhere above it — a zone is `Area/Location`, and `UTC` is the one
|
|
3
|
-
// exception. `@ultimat3/core`
|
|
4
|
-
//
|
|
3
|
+
// exception. `@ultimat3/core` carried its own copy until 2026-08-27, then a re-export for the
|
|
4
|
+
// `defaultTimeZone` validator, deleted with that key in 25.0.0.
|
|
5
5
|
//
|
|
6
6
|
// `@ultimat3/time`'s `canonicalTimeZone` is NOT a fourth copy and is deliberately left alone: it
|
|
7
7
|
// answers a different question (the canonical SPELLING, memoised over ~445 listed zones plus a
|
|
@@ -14,9 +14,8 @@
|
|
|
14
14
|
* Unobservable on ICU 78 — `+01:00` resolves to itself, so the slash rule below already refuses it,
|
|
15
15
|
* and deleting this line changes no answer this package can currently produce. It stays because it
|
|
16
16
|
* guards the runtime that folds an offset into `Etc/GMT-1`, which WOULD carry a slash, and because
|
|
17
|
-
* `packages/
|
|
18
|
-
*
|
|
19
|
-
* test.
|
|
17
|
+
* `packages/time/src/zone-canonical.ts` carries the same line: two statements of one rule may not
|
|
18
|
+
* differ, least of all in the half that is hard to test.
|
|
20
19
|
*/
|
|
21
20
|
const NUMERIC_OFFSET = /^[+-]/;
|
|
22
21
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Single responsibility: the ONE predicate naming a string Postgres cannot store as written — the
|
|
2
|
+
// rule every string-backed validator (`t.string` and its kin, a `t.record` key, `t.json()`) shares,
|
|
3
|
+
// so the three can never disagree about which input reaches the row write.
|
|
4
|
+
|
|
5
|
+
const NUL = '\u0000';
|
|
6
|
+
/** A lone UTF-16 surrogate. `String#isWellFormed` is ES2024 and the lib is ES2023. */
|
|
7
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Why a string cannot be stored, or `undefined`. Both MEASURED against Postgres 17: a NUL is
|
|
11
|
+
* refused by `text` and `jsonb` alike (22021 / 22P05), a lone surrogate by `jsonb` (22P02) — and
|
|
12
|
+
* into `text`, Bun's sql encodes it as U+FFFD, so the row holds a different string than the
|
|
13
|
+
* caller sent while a memory store holds the original. Passing either moved the failure to the
|
|
14
|
+
* row write, as a 500 with no field path. Names the fact, never the content (`describe-value.ts`).
|
|
15
|
+
*/
|
|
16
|
+
export function unstorable(text: string): string | undefined {
|
|
17
|
+
if (text.includes(NUL)) return 'that contains a NUL character (U+0000)';
|
|
18
|
+
if (LONE_SURROGATE.test(text)) return 'that contains a lone UTF-16 surrogate';
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
package/src/validators.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// ArkType or Zod replace them wholesale via `configureSchemaProvider()` — neither ships; the IR and the
|
|
3
3
|
// Standard Schema surface are what the rest of the framework actually depends on.
|
|
4
4
|
|
|
5
|
+
import { isAbsoluteUrl } from './absolute-url';
|
|
5
6
|
import {
|
|
6
7
|
type AnySchema,
|
|
7
8
|
type Check,
|
|
@@ -21,19 +22,17 @@ import { charCount } from './char-count';
|
|
|
21
22
|
import { expected } from './describe-value';
|
|
22
23
|
import { discriminatedUnionSchema } from './discriminated-union';
|
|
23
24
|
import { isIsoDateTime, isZonelessDateTime } from './iso-date';
|
|
25
|
+
import { type JsonValue, jsonSchema } from './json-value';
|
|
24
26
|
import { type MoneyValue, moneySchema } from './money-value';
|
|
25
27
|
import type { SchemaNode } from './node';
|
|
26
28
|
import { isPrototypeKey, PROTOTYPE_KEYS } from './prototype-keys';
|
|
27
29
|
import type { InferInput, InferOutput, StandardIssue } from './standard';
|
|
28
30
|
import { isIanaZoneName } from './time-zone-name';
|
|
31
|
+
import { unstorable } from './unstorable-text';
|
|
29
32
|
|
|
30
33
|
const EMAIL_RE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
|
|
31
34
|
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
32
35
|
const CURSOR_RE = /^[A-Za-z0-9_-]+$/;
|
|
33
|
-
/** The one code point no Postgres `text` or `jsonb` value can hold — see `stringLike`. */
|
|
34
|
-
const NUL = '\u0000';
|
|
35
|
-
/** Names the fact, never the content: the issue-message rule in `describe-value.ts`. */
|
|
36
|
-
const NUL_REFUSAL = 'that contains a NUL character (U+0000)';
|
|
37
36
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
38
37
|
|
|
39
38
|
export interface StringSchema extends Schema<string, string> {
|
|
@@ -88,11 +87,11 @@ function stringLike(node: SchemaNode, what: string, test?: (value: string) => bo
|
|
|
88
87
|
const matchesPattern = patternTester(node);
|
|
89
88
|
const check: Check<string> = (value, path) => {
|
|
90
89
|
if (typeof value !== 'string') return fail(path, expected(what, value));
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
if (
|
|
90
|
+
// A NUL or a lone surrogate passed here reached the row write — or a `where` bind — as
|
|
91
|
+
// `X_DB_STATEMENT_FAILED`, a 500 blaming the server for what the caller sent (`unstorable`).
|
|
92
|
+
// Only those: a tab, a newline or any other C0 control is legal text Postgres stores unchanged.
|
|
93
|
+
const unfit = unstorable(value);
|
|
94
|
+
if (unfit !== undefined) return fail(path, `${expected(what, value)} ${unfit}`);
|
|
96
95
|
if (node.minLength !== undefined && charCount(value) < node.minLength) {
|
|
97
96
|
return fail(path, expected(`${what} of at least ${node.minLength} chars`, value));
|
|
98
97
|
}
|
|
@@ -312,10 +311,11 @@ export function recordSchema<S extends AnySchema>(
|
|
|
312
311
|
// new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of
|
|
313
312
|
// string ones — so it names an entry that exists rather than a byte offset in the body.
|
|
314
313
|
const at = [...path, index];
|
|
315
|
-
// A record is stored as `jsonb` far more often than not, and jsonb refuses
|
|
316
|
-
// exactly as it does
|
|
317
|
-
|
|
318
|
-
|
|
314
|
+
// A record is stored as `jsonb` far more often than not, and jsonb refuses an unstorable
|
|
315
|
+
// KEY exactly as it does a value — so the key is refused here, by position, not quoted.
|
|
316
|
+
const unfitKey = unstorable(key);
|
|
317
|
+
if (unfitKey !== undefined) {
|
|
318
|
+
issues.push({ message: `expected a record key, received one ${unfitKey}`, path: at });
|
|
319
319
|
continue;
|
|
320
320
|
}
|
|
321
321
|
if (isPrototypeKey(key)) {
|
|
@@ -413,6 +413,8 @@ export interface TNamespace {
|
|
|
413
413
|
readonly locale: StringSchema;
|
|
414
414
|
readonly slug: StringSchema;
|
|
415
415
|
readonly cursor: StringSchema;
|
|
416
|
+
/** Any JSON value, bounded in depth — a payload whose shape is somebody else's. */
|
|
417
|
+
json(): Schema<JsonValue, JsonValue>;
|
|
416
418
|
object<S extends Shape>(shape: S): ObjectSchema<S>;
|
|
417
419
|
array<S extends AnySchema>(items: S): Schema<readonly InferInput<S>[], InferOutput<S>[]>;
|
|
418
420
|
enum<const V extends readonly [string, ...string[]]>(values: V): Schema<V[number], V[number]>;
|
|
@@ -453,9 +455,7 @@ export const builtinT: TNamespace = Object.freeze({
|
|
|
453
455
|
email: makeStringSchema({ kind: 'string', format: 'email' }, 'an email address', (value) =>
|
|
454
456
|
EMAIL_RE.test(value),
|
|
455
457
|
),
|
|
456
|
-
url: makeStringSchema({ kind: 'string', format: 'uri' }, 'an absolute URL',
|
|
457
|
-
URL.canParse(value),
|
|
458
|
-
),
|
|
458
|
+
url: makeStringSchema({ kind: 'string', format: 'uri' }, 'an absolute URL', isAbsoluteUrl),
|
|
459
459
|
date: dateSchema,
|
|
460
460
|
money: moneySchema,
|
|
461
461
|
timezone: makeStringSchema(
|
|
@@ -469,6 +469,7 @@ export const builtinT: TNamespace = Object.freeze({
|
|
|
469
469
|
{ kind: 'string', format: 'cursor', pattern: CURSOR_RE.source },
|
|
470
470
|
'an opaque base64url cursor',
|
|
471
471
|
),
|
|
472
|
+
json: jsonSchema,
|
|
472
473
|
object: objectSchema,
|
|
473
474
|
array: arraySchema,
|
|
474
475
|
enum: enumSchema,
|