@ultimat3/schema 13.0.0 → 14.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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "13.0.0",
3
+ "version": "14.0.0",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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';
@@ -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
- if (node.integer === true && !Number.isInteger(value)) {
132
- return fail(path, expected('an integer', value));
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: [...path, key],
309
+ path: at,
287
310
  });
288
311
  continue;
289
312
  }
290
- const result = valueCheck(entry, [...path, key]);
313
+ const result = valueCheck(entry, at);
291
314
  if (result.ok) out[key] = result.value;
292
315
  else issues.push(...result.issues);
293
316
  }