@ultimat3/schema
Advanced tools
+23
-0
@@ -12,2 +12,3 @@ # @ultimat3/schema — agent notes | ||
| | **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` | | ||
| | **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]`) | | ||
| | Coercion | HTTP boundary only — never call it from actions, jobs or MCP | | ||
@@ -27,2 +28,13 @@ | Exports | explicit in `src/index.ts`; no `export *`; a namespace member and its free function ship together (`t.nullable`/`nullableSchema`) | | ||
| **An issue PATH is the same public surface as its message, `As of 2026-08-25`.** It travels | ||
| `formatIssue` -> `@ultimat3/http`'s `bodyInvalid` -> `X_BODY_INVALID`'s `cause` -> the problem | ||
| document **and** the log line, and `bodyInvalid`'s own doc block promises the `issues` it renders | ||
| "name only facts the framework itself chose". A `t.record` key is not one: a record keyed by an | ||
| email address, a phone number or a pasted credential wrote every one of them into the central log | ||
| index, in the shape the password bug did. `recordSchema` emits the entry's INDEX — the segment | ||
| `arraySchema` already uses, so `formatPath` renders `meta[3]` with no new spelling — because | ||
| dropping the segment entirely makes three failing entries render three identical lines. It is | ||
| `Object.entries` order, so it names an entry that exists rather than a byte offset in the body. | ||
| A `t.object` segment stays as written: the schema author chose it, not the caller. | ||
| **An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`, | ||
@@ -90,2 +102,13 @@ which is returned to the caller AND interpolated into the log line — and core's logger redacts by | ||
| `t.number.int()` demands `Number.isSafeInteger`, not `Number.isInteger` (`As of 2026-08-25`) — the | ||
| defect `money-value.ts` carries the write-up for having fixed one file over, and `@ultimat3/entity`'s | ||
| `columns.ts` had it right too. `t.number.int()` was the one that did not get the fix: it accepted | ||
| `2 ** 53` at the boundary as a 200, the policy gate and the handler ran, and the ROW WRITE refused | ||
| it as a 500 — the same value refused twice, once with a field path and once without. `json-schema.ts` | ||
| publishes the same bound (`minimum`/`maximum` at `±Number.MAX_SAFE_INTEGER`, a caller's own bound | ||
| when it is narrower and clamped when it is not), because a contract promising what the parser | ||
| refuses is that disagreement one layer out. The IR node is untouched — `@ultimat3/action`'s | ||
| `sampleNumber` reads `node.minimum`, and a default there would make every generated contract sample | ||
| `-9007199254740991`. | ||
| Gotchas: | ||
@@ -92,0 +115,0 @@ - `Schema<In, Out>` splits input from output: `.default()` makes the key optional on input and |
+1
-1
| { | ||
| "name": "@ultimat3/schema", | ||
| "version": "13.0.0", | ||
| "version": "14.0.0", | ||
| "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+12
-0
@@ -114,2 +114,14 @@ # ✅ @ultimat3/schema | ||
| **The issue's PATH is the same public surface**, so it too names only what the framework chose. A | ||
| `t.object` segment is a declared field name and stays as written; a `t.record` KEY is the caller's | ||
| data, so a failing record entry is named by POSITION — `meta[3]`, the segment `t.array` already | ||
| uses. `@ultimat3/http`'s `bodyInvalid` states this contract in its own doc block: the `issues` it | ||
| renders "name only facts the framework itself chose". A record keyed by an email address, a phone | ||
| number or a pasted credential wrote every one of them into the log index otherwise. | ||
| `t.number.int()` demands a **safe** integer. `2 ** 53` is a whole number, and accepting it here | ||
| meant the boundary answered 200 and the row write answered 500 for the same value. The published | ||
| JSON Schema carries the same bound: `minimum`/`maximum` at `±Number.MAX_SAFE_INTEGER`, or your own | ||
| bound where it is narrower. | ||
| `error.issues` is `{ path, expected, received, message }[]` with paths like `items[0].price`; | ||
@@ -116,0 +128,0 @@ `formatIssues()` renders one line per issue for the dev overlay. `validate()` never throws and |
@@ -24,2 +24,10 @@ // Single responsibility: render a REJECTED value as its shape, and the `expected X, received Y` | ||
| * infinities name themselves because "received a number" for a `NaN` reads as a lie. | ||
| * | ||
| * **The absolute is about caller data, not only about the rejected VALUE.** An issue's `path` is | ||
| * the other half of the same string, and a `t.record` KEY is the caller's — so `recordSchema` | ||
| * names a failing entry by its POSITION and never by its key, and `validators.test.ts` is that | ||
| * half's enforcement as `describe-value.test.ts` is this half's. A `t.object` path segment is a | ||
| * DECLARED field name and stays as written: the schema author chose it, not the caller. | ||
| * `@ultimat3/http`'s `bodyInvalid` is the seam that makes this binding rather than tasteful — its | ||
| * own doc block promises the `issues` it renders "name only facts the framework itself chose". | ||
| */ | ||
@@ -26,0 +34,0 @@ export function describeValue(value: unknown): string { |
+34
-5
@@ -98,2 +98,35 @@ // Single responsibility: SchemaNode -> JSON Schema. Load-bearing: OpenAPI request/response | ||
| /** | ||
| * The range an `integer` node's validator actually enforces — `Number.isSafeInteger`, the rule | ||
| * `validators.ts` applies and `money-value.ts` already published. Spelled `-MAX_SAFE_INTEGER` | ||
| * rather than `MIN_SAFE_INTEGER` (they are the same number) so the two projections of one rule | ||
| * read identically. | ||
| * | ||
| * A caller's own bound is the published one when it is NARROWER, and is clamped when it is not: | ||
| * `t.number.int().max(2 ** 60)` refuses `2 ** 60` at the boundary whatever the node says, so | ||
| * publishing it would be a promise the parser breaks — which is the disagreement this whole | ||
| * function exists to prevent, one layer out. | ||
| */ | ||
| const SAFE_INTEGER_MIN = -Number.MAX_SAFE_INTEGER; | ||
| const SAFE_INTEGER_MAX = Number.MAX_SAFE_INTEGER; | ||
| /** | ||
| * A non-integer `t.number` publishes only what the caller declared: it accepts every finite | ||
| * double, so a safe-integer range on it would tell a generated client to refuse `0.5`. | ||
| */ | ||
| function numberNode(node: SchemaNode): JsonSchema { | ||
| const integer = node.integer === true; | ||
| const minimum = integer | ||
| ? Math.max(node.minimum ?? SAFE_INTEGER_MIN, SAFE_INTEGER_MIN) | ||
| : node.minimum; | ||
| const maximum = integer | ||
| ? Math.min(node.maximum ?? SAFE_INTEGER_MAX, SAFE_INTEGER_MAX) | ||
| : node.maximum; | ||
| return { | ||
| type: integer ? 'integer' : 'number', | ||
| ...(minimum === undefined ? {} : { minimum }), | ||
| ...(maximum === undefined ? {} : { maximum }), | ||
| }; | ||
| } | ||
| /** | ||
| * JSON Schema's `pattern` is an ECMA-262 source with no flag syntax, so a flagged pattern is | ||
@@ -145,7 +178,3 @@ * stated in prose instead of silently narrowed: a consumer applying `pattern` alone would refuse | ||
| case 'number': | ||
| return annotate({ | ||
| type: node.integer === true ? 'integer' : 'number', | ||
| ...(node.minimum === undefined ? {} : { minimum: node.minimum }), | ||
| ...(node.maximum === undefined ? {} : { maximum: node.maximum }), | ||
| }); | ||
| return annotate(numberNode(node)); | ||
| case 'boolean': | ||
@@ -152,0 +181,0 @@ return annotate({ type: 'boolean' }); |
+28
-5
@@ -131,4 +131,10 @@ // Single responsibility: the builtin, dependency-free validators behind `t`. Small on purpose — | ||
| } | ||
| if (node.integer === true && !Number.isInteger(value)) { | ||
| return fail(path, expected('an integer', value)); | ||
| // Safe, not merely whole — the same rule `money-value.ts` states one file over, for the same | ||
| // reason: `Number.isInteger` let 2^53 through the boundary as a 200, so the policy gate and | ||
| // the handler ran and the ROW WRITE refused it as a 500 (`entity`'s `columns.ts` demands | ||
| // `Number.isSafeInteger`). The same value refused twice, once with a field path and once | ||
| // without. Above 2^53 a double cannot name its own successor, so a "whole" number there is | ||
| // already a rounded one. | ||
| if (node.integer === true && !Number.isSafeInteger(value)) { | ||
| return fail(path, expected('a safe integer', value)); | ||
| } | ||
@@ -283,11 +289,28 @@ if (node.minimum !== undefined && value < node.minimum) { | ||
| const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>; | ||
| for (const [key, entry] of Object.entries(value)) { | ||
| for (const [index, [key, entry]] of Object.entries(value).entries()) { | ||
| // The entry's POSITION, never its key. `describe-value.ts`'s absolute is stated about the | ||
| // rejected value, and a record's KEY is the caller's data just as much: the path travels | ||
| // `formatIssue` -> `bodyInvalid`'s `cause` -> the problem document AND the log line, and | ||
| // core's logger redacts by key, so a key baked into a string has no key left to redact. | ||
| // `@ultimat3/http`'s `bodyInvalid` states the contract this was breaking in its own doc | ||
| // block — *"`issues` … must name only facts the framework itself chose"*. A record keyed | ||
| // by an email address, a phone number or a pasted credential wrote every one of them into | ||
| // the central log index, in the same shape the password bug did. | ||
| // | ||
| // The index rather than nothing at all: the segment's whole job is to say WHICH entry | ||
| // failed, and dropping it makes three failing entries render three identical lines. It is | ||
| // the same segment `arraySchema` already uses, so `formatPath` renders `meta[3]` with no | ||
| // new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of | ||
| // string ones — so it names an entry that exists rather than a byte offset in the body. | ||
| const at = [...path, index]; | ||
| if (PROTOTYPE_KEYS.has(key)) { | ||
| issues.push({ | ||
| // The refused NAMES are a closed set declared right here, so the message may state | ||
| // them: that is the one part of this line the caller did not choose. | ||
| message: expected(`a record key that is not ${[...PROTOTYPE_KEYS].join(' | ')}`, key), | ||
| path: [...path, key], | ||
| path: at, | ||
| }); | ||
| continue; | ||
| } | ||
| const result = valueCheck(entry, [...path, key]); | ||
| const result = valueCheck(entry, at); | ||
| if (result.ok) out[key] = result.value; | ||
@@ -294,0 +317,0 @@ else issues.push(...result.issues); |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
120846
6.01%2190
2.72%164
7.89%