@ultimat3/schema 13.0.0 → 15.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 +23 -0
- package/README.md +12 -0
- package/package.json +1 -1
- package/src/describe-value.ts +8 -0
- package/src/json-schema.ts +34 -5
- package/src/validators.ts +28 -5
package/CLAUDE.md
CHANGED
|
@@ -9,6 +9,7 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
|
|
|
9
9
|
| New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` |
|
|
10
10
|
| IR | every schema carries `.node: SchemaNode`; generators read that, never the closure |
|
|
11
11
|
| **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
|
|
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]`) |
|
|
12
13
|
| Coercion | HTTP boundary only — never call it from actions, jobs or MCP |
|
|
13
14
|
| Exports | explicit in `src/index.ts`; no `export *`; a namespace member and its free function ship together (`t.nullable`/`nullableSchema`) |
|
|
14
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 |
|
|
@@ -23,6 +24,17 @@ with "at least 3 chars, received a string of 3 characters".
|
|
|
23
24
|
is imported by `validators.ts` and `coerce.ts` — the two doors a `t.date` string comes through, so
|
|
24
25
|
the rule that a clock time must carry an offset or `Z` has one copy, not one per door.
|
|
25
26
|
|
|
27
|
+
**An issue PATH is the same public surface as its message, `As of 2026-08-25`.** It travels
|
|
28
|
+
`formatIssue` -> `@ultimat3/http`'s `bodyInvalid` -> `X_BODY_INVALID`'s `cause` -> the problem
|
|
29
|
+
document **and** the log line, and `bodyInvalid`'s own doc block promises the `issues` it renders
|
|
30
|
+
"name only facts the framework itself chose". A `t.record` key is not one: a record keyed by an
|
|
31
|
+
email address, a phone number or a pasted credential wrote every one of them into the central log
|
|
32
|
+
index, in the shape the password bug did. `recordSchema` emits the entry's INDEX — the segment
|
|
33
|
+
`arraySchema` already uses, so `formatPath` renders `meta[3]` with no new spelling — because
|
|
34
|
+
dropping the segment entirely makes three failing entries render three identical lines. It is
|
|
35
|
+
`Object.entries` order, so it names an entry that exists rather than a byte offset in the body.
|
|
36
|
+
A `t.object` segment stays as written: the schema author chose it, not the caller.
|
|
37
|
+
|
|
26
38
|
**An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`,
|
|
27
39
|
which is returned to the caller AND interpolated into the log line — and core's logger redacts by
|
|
28
40
|
KEY, so a value baked into a string has no key left to redact. `'password'` being in `redactedKeys`
|
|
@@ -85,6 +97,17 @@ bun test # from packages/schema
|
|
|
85
97
|
bun run typecheck
|
|
86
98
|
```
|
|
87
99
|
|
|
100
|
+
`t.number.int()` demands `Number.isSafeInteger`, not `Number.isInteger` (`As of 2026-08-25`) — the
|
|
101
|
+
defect `money-value.ts` carries the write-up for having fixed one file over, and `@ultimat3/entity`'s
|
|
102
|
+
`columns.ts` had it right too. `t.number.int()` was the one that did not get the fix: it accepted
|
|
103
|
+
`2 ** 53` at the boundary as a 200, the policy gate and the handler ran, and the ROW WRITE refused
|
|
104
|
+
it as a 500 — the same value refused twice, once with a field path and once without. `json-schema.ts`
|
|
105
|
+
publishes the same bound (`minimum`/`maximum` at `±Number.MAX_SAFE_INTEGER`, a caller's own bound
|
|
106
|
+
when it is narrower and clamped when it is not), because a contract promising what the parser
|
|
107
|
+
refuses is that disagreement one layer out. The IR node is untouched — `@ultimat3/action`'s
|
|
108
|
+
`sampleNumber` reads `node.minimum`, and a default there would make every generated contract sample
|
|
109
|
+
`-9007199254740991`.
|
|
110
|
+
|
|
88
111
|
Gotchas:
|
|
89
112
|
- `Schema<In, Out>` splits input from output: `.default()` makes the key optional on input and
|
|
90
113
|
present on output. Object key optionality is derived from that — don't hand-roll it.
|
package/README.md
CHANGED
|
@@ -111,6 +111,18 @@ value meant a password-strength rule wrote every mistyped password to the centra
|
|
|
111
111
|
cleartext. There is no dev-only escape hatch: a flag is one misconfigured environment away from
|
|
112
112
|
being the same breach. `describe-value.ts` owns the rule and `describe-value.test.ts` enforces it.
|
|
113
113
|
|
|
114
|
+
**The issue's PATH is the same public surface**, so it too names only what the framework chose. A
|
|
115
|
+
`t.object` segment is a declared field name and stays as written; a `t.record` KEY is the caller's
|
|
116
|
+
data, so a failing record entry is named by POSITION — `meta[3]`, the segment `t.array` already
|
|
117
|
+
uses. `@ultimat3/http`'s `bodyInvalid` states this contract in its own doc block: the `issues` it
|
|
118
|
+
renders "name only facts the framework itself chose". A record keyed by an email address, a phone
|
|
119
|
+
number or a pasted credential wrote every one of them into the log index otherwise.
|
|
120
|
+
|
|
121
|
+
`t.number.int()` demands a **safe** integer. `2 ** 53` is a whole number, and accepting it here
|
|
122
|
+
meant the boundary answered 200 and the row write answered 500 for the same value. The published
|
|
123
|
+
JSON Schema carries the same bound: `minimum`/`maximum` at `±Number.MAX_SAFE_INTEGER`, or your own
|
|
124
|
+
bound where it is narrower.
|
|
125
|
+
|
|
114
126
|
`error.issues` is `{ path, expected, received, message }[]` with paths like `items[0].price`;
|
|
115
127
|
`formatIssues()` renders one line per issue for the dev overlay. `validate()` never throws and
|
|
116
128
|
returns the Standard Schema result. Async libraries: `parseAsync()` / `validateAsync()`.
|
package/package.json
CHANGED
package/src/describe-value.ts
CHANGED
|
@@ -21,6 +21,14 @@ import { charCount } from './char-count';
|
|
|
21
21
|
*
|
|
22
22
|
* Constants are exempt only where they carry no caller data: `undefined`, `null`, `NaN` and the
|
|
23
23
|
* infinities name themselves because "received a number" for a `NaN` reads as a lie.
|
|
24
|
+
*
|
|
25
|
+
* **The absolute is about caller data, not only about the rejected VALUE.** An issue's `path` is
|
|
26
|
+
* the other half of the same string, and a `t.record` KEY is the caller's — so `recordSchema`
|
|
27
|
+
* names a failing entry by its POSITION and never by its key, and `validators.test.ts` is that
|
|
28
|
+
* half's enforcement as `describe-value.test.ts` is this half's. A `t.object` path segment is a
|
|
29
|
+
* DECLARED field name and stays as written: the schema author chose it, not the caller.
|
|
30
|
+
* `@ultimat3/http`'s `bodyInvalid` is the seam that makes this binding rather than tasteful — its
|
|
31
|
+
* own doc block promises the `issues` it renders "name only facts the framework itself chose".
|
|
24
32
|
*/
|
|
25
33
|
export function describeValue(value: unknown): string {
|
|
26
34
|
if (value === undefined) return 'undefined';
|
package/src/json-schema.ts
CHANGED
|
@@ -94,6 +94,39 @@ function stringNode(node: SchemaNode): JsonSchema {
|
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The range an `integer` node's validator actually enforces — `Number.isSafeInteger`, the rule
|
|
99
|
+
* `validators.ts` applies and `money-value.ts` already published. Spelled `-MAX_SAFE_INTEGER`
|
|
100
|
+
* rather than `MIN_SAFE_INTEGER` (they are the same number) so the two projections of one rule
|
|
101
|
+
* read identically.
|
|
102
|
+
*
|
|
103
|
+
* A caller's own bound is the published one when it is NARROWER, and is clamped when it is not:
|
|
104
|
+
* `t.number.int().max(2 ** 60)` refuses `2 ** 60` at the boundary whatever the node says, so
|
|
105
|
+
* publishing it would be a promise the parser breaks — which is the disagreement this whole
|
|
106
|
+
* function exists to prevent, one layer out.
|
|
107
|
+
*/
|
|
108
|
+
const SAFE_INTEGER_MIN = -Number.MAX_SAFE_INTEGER;
|
|
109
|
+
const SAFE_INTEGER_MAX = Number.MAX_SAFE_INTEGER;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A non-integer `t.number` publishes only what the caller declared: it accepts every finite
|
|
113
|
+
* double, so a safe-integer range on it would tell a generated client to refuse `0.5`.
|
|
114
|
+
*/
|
|
115
|
+
function numberNode(node: SchemaNode): JsonSchema {
|
|
116
|
+
const integer = node.integer === true;
|
|
117
|
+
const minimum = integer
|
|
118
|
+
? Math.max(node.minimum ?? SAFE_INTEGER_MIN, SAFE_INTEGER_MIN)
|
|
119
|
+
: node.minimum;
|
|
120
|
+
const maximum = integer
|
|
121
|
+
? Math.min(node.maximum ?? SAFE_INTEGER_MAX, SAFE_INTEGER_MAX)
|
|
122
|
+
: node.maximum;
|
|
123
|
+
return {
|
|
124
|
+
type: integer ? 'integer' : 'number',
|
|
125
|
+
...(minimum === undefined ? {} : { minimum }),
|
|
126
|
+
...(maximum === undefined ? {} : { maximum }),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
97
130
|
/**
|
|
98
131
|
* JSON Schema's `pattern` is an ECMA-262 source with no flag syntax, so a flagged pattern is
|
|
99
132
|
* stated in prose instead of silently narrowed: a consumer applying `pattern` alone would refuse
|
|
@@ -141,11 +174,7 @@ function convert(node: SchemaNode): JsonSchema {
|
|
|
141
174
|
case 'string':
|
|
142
175
|
return annotate(stringNode(node));
|
|
143
176
|
case 'number':
|
|
144
|
-
return annotate(
|
|
145
|
-
type: node.integer === true ? 'integer' : 'number',
|
|
146
|
-
...(node.minimum === undefined ? {} : { minimum: node.minimum }),
|
|
147
|
-
...(node.maximum === undefined ? {} : { maximum: node.maximum }),
|
|
148
|
-
});
|
|
177
|
+
return annotate(numberNode(node));
|
|
149
178
|
case 'boolean':
|
|
150
179
|
return annotate({ type: 'boolean' });
|
|
151
180
|
case 'date':
|
package/src/validators.ts
CHANGED
|
@@ -128,8 +128,14 @@ function makeNumberSchema(node: SchemaNode): NumberSchema {
|
|
|
128
128
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
129
129
|
return fail(path, expected('a finite number', value));
|
|
130
130
|
}
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
// Safe, not merely whole — the same rule `money-value.ts` states one file over, for the same
|
|
132
|
+
// reason: `Number.isInteger` let 2^53 through the boundary as a 200, so the policy gate and
|
|
133
|
+
// the handler ran and the ROW WRITE refused it as a 500 (`entity`'s `columns.ts` demands
|
|
134
|
+
// `Number.isSafeInteger`). The same value refused twice, once with a field path and once
|
|
135
|
+
// without. Above 2^53 a double cannot name its own successor, so a "whole" number there is
|
|
136
|
+
// already a rounded one.
|
|
137
|
+
if (node.integer === true && !Number.isSafeInteger(value)) {
|
|
138
|
+
return fail(path, expected('a safe integer', value));
|
|
133
139
|
}
|
|
134
140
|
if (node.minimum !== undefined && value < node.minimum) {
|
|
135
141
|
return fail(path, expected(`a number >= ${node.minimum}`, value));
|
|
@@ -279,15 +285,32 @@ export function recordSchema<S extends AnySchema>(
|
|
|
279
285
|
if (!isPlainObject(value)) return fail(path, expected('an object', value));
|
|
280
286
|
const issues: StandardIssue[] = [];
|
|
281
287
|
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
282
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
288
|
+
for (const [index, [key, entry]] of Object.entries(value).entries()) {
|
|
289
|
+
// The entry's POSITION, never its key. `describe-value.ts`'s absolute is stated about the
|
|
290
|
+
// rejected value, and a record's KEY is the caller's data just as much: the path travels
|
|
291
|
+
// `formatIssue` -> `bodyInvalid`'s `cause` -> the problem document AND the log line, and
|
|
292
|
+
// core's logger redacts by key, so a key baked into a string has no key left to redact.
|
|
293
|
+
// `@ultimat3/http`'s `bodyInvalid` states the contract this was breaking in its own doc
|
|
294
|
+
// block — *"`issues` … must name only facts the framework itself chose"*. A record keyed
|
|
295
|
+
// by an email address, a phone number or a pasted credential wrote every one of them into
|
|
296
|
+
// the central log index, in the same shape the password bug did.
|
|
297
|
+
//
|
|
298
|
+
// The index rather than nothing at all: the segment's whole job is to say WHICH entry
|
|
299
|
+
// failed, and dropping it makes three failing entries render three identical lines. It is
|
|
300
|
+
// the same segment `arraySchema` already uses, so `formatPath` renders `meta[3]` with no
|
|
301
|
+
// new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of
|
|
302
|
+
// string ones — so it names an entry that exists rather than a byte offset in the body.
|
|
303
|
+
const at = [...path, index];
|
|
283
304
|
if (PROTOTYPE_KEYS.has(key)) {
|
|
284
305
|
issues.push({
|
|
306
|
+
// The refused NAMES are a closed set declared right here, so the message may state
|
|
307
|
+
// them: that is the one part of this line the caller did not choose.
|
|
285
308
|
message: expected(`a record key that is not ${[...PROTOTYPE_KEYS].join(' | ')}`, key),
|
|
286
|
-
path:
|
|
309
|
+
path: at,
|
|
287
310
|
});
|
|
288
311
|
continue;
|
|
289
312
|
}
|
|
290
|
-
const result = valueCheck(entry,
|
|
313
|
+
const result = valueCheck(entry, at);
|
|
291
314
|
if (result.ok) out[key] = result.value;
|
|
292
315
|
else issues.push(...result.issues);
|
|
293
316
|
}
|