@ultimat3/schema
Advanced tools
+90
| # @ultimat3/schema — agent notes | ||
| Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.** | ||
| | Rule | | | ||
| |---|---| | ||
| | Deps | none (`bun-types` only) | | ||
| | Errors | `SchemaError` mirrors `UltimateError` field-for-field; keep `Symbol.for('ultimate.error')` | | ||
| | New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` | | ||
| | IR | every schema carries `.node: SchemaNode`; generators read that, never the closure | | ||
| | **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` | | ||
| | Coercion | HTTP boundary only — never call it from actions, jobs or MCP | | ||
| | Exports | explicit in `src/index.ts`; no `export *`; a namespace member and its free function ship together (`t.nullable`/`nullableSchema`) | | ||
| | Re-exports | `action`, `query`, `jobs`, `entity` re-export `t` verbatim so an authoring file imports one package — never let them wrap or copy it | | ||
| Module order (no cycles): | ||
| `describe-value → node → builder → money-value → validators → discriminated-union → provider → t`. | ||
| `standard.ts` and `errors.ts` depend on nothing but each other. | ||
| **An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`, | ||
| which is returned to the caller AND interpolated into the log line — and core's logger redacts by | ||
| KEY, so a value baked into a string has no key left to redact. `'password'` being in `redactedKeys` | ||
| did not help: `expected(…, value)` had already written `received "hunter2"` before the logger saw | ||
| it. Every rejected value goes through `describeValue`, which reports length and type and nothing | ||
| else. No dev flag re-enables the echo — one misconfigured environment is the same breach, and a dev | ||
| overlay already holds the raw body. `describe-value.test.ts` is the enforcement. | ||
| `X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value | ||
| is parsed. A member with no literal at the discriminant, or a second member claiming a tag the | ||
| first already owns, can never be routed to — so it is wrong for every input, and the first import | ||
| of the authoring file is the earliest honest place to say so. | ||
| `SCHEMA_ERROR_CODES` in `errors.ts` is data, not a `registerErrorCodes()` call — this package is | ||
| tier 0 and cannot import `@ultimat3/core` to reach it. `@ultimat3/core`'s `schema-error-codes.ts` | ||
| carries a duplicate of these titles and registers them unconditionally, so every process gets the | ||
| real titles just by importing core. Add a code here **and** update that duplicate in the same | ||
| change — `schema-error-codes-pin.test.ts` in `@ultimat3/cli` fails the build if they disagree. | ||
| `MoneyValue` in `money-value.ts` — its own file, because it is the only builtin whose *shape* other | ||
| packages alias — is the framework's **one** declaration of a money value. Tier 0 is | ||
| the only tier every package may import, and `@ultimat3/money`'s `Money` and `@ultimat3/entity`'s | ||
| `MoneyValue` are aliases of it. Never let either restate the shape: it was three structural copies, | ||
| entity's had a `bigint` `minor`, and a row that layer decoded then failed both `t.money` and | ||
| `JSON.stringify`. `minor` stays a `number` for the same reason it is a `number` here — this node is | ||
| the OpenAPI contract, and money crosses every wire the framework projects. | ||
| `MoneyValue.scale` is the **optional** decimal exponent `minor` counts in, `0…MAX_MONEY_SCALE` | ||
| (15, the last power of ten that is itself a safe integer). Absent means the currency's own minor | ||
| unit, which is every value that predates it — so `{ minor, currency }` parses to exactly | ||
| `{ minor, currency }`, key for key, and the validator adds nothing. What a legal scale is lives in | ||
| `isMoneyScale` here and nowhere else; `@ultimat3/money` imports it rather than restating the | ||
| bound. Adding it to the type means adding it in three more places in the same change — the node's | ||
| `properties`, `json-schema.ts` (optional, never `required`, or a generated client refuses a value | ||
| this validator accepts) and `coerce.ts` (a query string carries it as text like everything else). | ||
| `MoneyValue.currency` is bounded by `isCurrencyCode`, `isMoneyScale`'s twin, over | ||
| `CURRENCY_CODE_PATTERN` — the pattern **source**, exported because the two projections that cannot | ||
| call a predicate need the string: `json-schema.ts`'s published `pattern` and `@ultimat3/entity`'s | ||
| Postgres `~` CHECK (`currencyCheck`). It was four copies of `^[A-Z]{3}$` across three packages, | ||
| each individually correct, and only a psql session would have seen them diverge. Keep the pattern | ||
| inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically — anchors, a literal | ||
| class, a bounded repetition. `@ultimat3/entity`'s `currency-check.live.test.ts` is what proves a | ||
| real server still reads it the way the predicate does; a `\d` or a lookahead is where that stops. | ||
| `t` delegates through `schemaProvider()` on every property access — that is what makes | ||
| `configureSchemaProvider()` work for modules that already imported `t`. Do not cache members. | ||
| ```bash | ||
| bun test # from packages/schema | ||
| bun run typecheck | ||
| ``` | ||
| Gotchas: | ||
| - `Schema<In, Out>` splits input from output: `.default()` makes the key optional on input and | ||
| present on output. Object key optionality is derived from that — don't hand-roll it. | ||
| - `AnySchema = Schema<unknown, unknown>` is the general constraint. Never `any`. | ||
| - Unknown object keys are dropped by design; JSON Schema says `additionalProperties: false`. | ||
| - Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit. | ||
| - **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind` | ||
| has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw | ||
| value through, `@ultimat3/action`'s `sample-input.ts` answers `null` — and they live in packages | ||
| a schema change is not allowed to edit. `refinements` and `discriminant` are both fields for that | ||
| reason: a refined string still reads as a string, and a discriminated union still reads as a | ||
| union, everywhere. `lazy` and `tuple` cannot be — which is why neither has shipped. | ||
| - A refinement is carried as a **declaration** (`name`, `message`, `path`), never a closure: a | ||
| predicate cannot cross into OpenAPI or an MCP tool schema, and `refine`'s `message` is rendered | ||
| verbatim on both — so it states the rule and never interpolates a value. | ||
| - `@ultimat3/action`'s `sampleInput` does not read `refinements`, so a contract test over a refined | ||
| schema reports `X_CONTRACT_DRIFT` exactly as it already does for a bare `pattern`. Documented | ||
| there; fixing it is that package's change, not this one's. |
| // Single responsibility: render a REJECTED value as its shape, and the `expected X, received Y` | ||
| // line every builtin validator fails with. Its whole job is the rule in `describeValue`'s doc | ||
| // comment — the rejected value's content never appears in an issue message. Its own file so that | ||
| // rule has one place to live and one test file to enforce it. | ||
| /** | ||
| * The shape of a rejected value — **never its content**. | ||
| * | ||
| * WHY: an issue message is not a private diagnostic. `@ultimat3/http` folds these messages into | ||
| * `X_BODY_INVALID`'s `cause`, which is returned to the caller AND written to the log line; the | ||
| * logger redacts `fields`/`contextFields` by key, and a value baked into a message string has no | ||
| * key left to redact. Echoing the value meant a signup form with a password-strength rule wrote | ||
| * every mistyped password to the central log index in cleartext (30-day retention) and into the | ||
| * user's own network tab — same for a card number, an SSN, or an API key pasted in the wrong box. | ||
| * | ||
| * So: length and type, which is what a min/max/type violation actually needs, and nothing else. | ||
| * There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away | ||
| * from being the same breach, and a dev overlay already holds the raw request body. | ||
| * | ||
| * Constants are exempt only where they carry no caller data: `undefined`, `null`, `NaN` and the | ||
| * infinities name themselves because "received a number" for a `NaN` reads as a lie. | ||
| */ | ||
| export function describeValue(value: unknown): string { | ||
| if (value === undefined) return 'undefined'; | ||
| if (value === null) return 'null'; | ||
| switch (typeof value) { | ||
| case 'string': | ||
| // Code units, not code points: the length checks in `validators.ts` use `.length` too, so a | ||
| // message quoting a different count than the rule that rejected it would send an agent | ||
| // chasing an off-by-one that is not there. | ||
| return countOf(value.length, 'string', 'character'); | ||
| case 'number': | ||
| return describeNumber(value); | ||
| case 'boolean': | ||
| return 'a boolean'; | ||
| case 'bigint': | ||
| return 'a bigint'; | ||
| case 'symbol': | ||
| return 'a symbol'; | ||
| case 'function': | ||
| return 'a function'; | ||
| default: | ||
| break; | ||
| } | ||
| if (Array.isArray(value)) return countOf(value.length, 'array', 'item'); | ||
| // `getTime()` rather than a value: an invalid Date is the one Date fact a caller can act on. | ||
| if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'an invalid Date' : 'a Date'; | ||
| return 'an object'; | ||
| } | ||
| function describeNumber(value: number): string { | ||
| if (Number.isNaN(value)) return 'NaN'; | ||
| if (value === Number.POSITIVE_INFINITY) return 'Infinity'; | ||
| if (value === Number.NEGATIVE_INFINITY) return '-Infinity'; | ||
| return 'a number'; | ||
| } | ||
| function countOf(size: number, noun: string, unit: string): string { | ||
| if (size === 0) return `an empty ${noun}`; | ||
| const article = noun === 'array' ? 'an' : 'a'; | ||
| return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`; | ||
| } | ||
| /** | ||
| * `expected a uuid, received a string of 3 characters` — the message an agent can act on without | ||
| * guessing, and without the value ever leaving the process. `what` is authored by the schema, so | ||
| * it may say anything; the second half is `describeValue` and may not. | ||
| */ | ||
| export function expected(what: string, value: unknown): string { | ||
| return `expected ${what}, received ${describeValue(value)}`; | ||
| } |
| // Single responsibility: a union that dispatches on one literal key instead of trying every | ||
| // member. Its own file because the dispatch table and its two authoring refusals are the whole of | ||
| // it, and `validators.ts` is already at the size where one more composite stops being readable. | ||
| import { | ||
| type AnySchema, | ||
| type Check, | ||
| checkOf, | ||
| fail, | ||
| isPlainObject, | ||
| makeSchema, | ||
| type Schema, | ||
| } from './builder'; | ||
| import { expected } from './describe-value'; | ||
| import { DiscriminantInvalidError } from './errors'; | ||
| import type { SchemaNode } from './node'; | ||
| import type { InferInput, InferOutput } from './standard'; | ||
| /** | ||
| * What a tag can be — exactly what `SchemaNode.literal` and `SchemaNode.values` can hold. Spelled | ||
| * out rather than left `unknown` because these reach a `cause:` through `String()`, and a value an | ||
| * app controls reaching a refusal's own text is how three constructors learned to throw instead | ||
| * of refusing (`bun run error-render`). | ||
| */ | ||
| type SchemaTag = string | number | boolean | null | undefined; | ||
| /** The literal(s) a member declares at the discriminant, or `undefined` if it declares none. */ | ||
| function declaredValues(node: SchemaNode, discriminant: string): readonly SchemaTag[] | undefined { | ||
| const child = node.properties?.[discriminant]; | ||
| if (child === undefined) return undefined; | ||
| // `kind` gates rather than `literal !== undefined`: `t.literal(false)` is a legal discriminant | ||
| // and a truthiness test would have called it undeclared. | ||
| if (child.kind === 'literal') return [child.literal]; | ||
| // An enum member is a legal discriminant too — one branch owning several codes is a real shape | ||
| // (`status: t.enum(['queued', 'running'])`), and refusing it would force a duplicated branch. | ||
| if (child.kind === 'enum' && child.values !== undefined && child.values.length > 0) { | ||
| return [...child.values]; | ||
| } | ||
| return undefined; | ||
| } | ||
| function refuseUndeclared(discriminant: string, index: number, kind: string): never { | ||
| throw new DiscriminantInvalidError({ | ||
| cause: `member #${index} of discriminatedUnion("${discriminant}") — IR kind "${kind}" — declares no literal at "${discriminant}"`, | ||
| fix: `give member #${index} a literal discriminant, t.object({ ${discriminant}: t.literal('…'), … }), or use t.union(...) if the members share no key`, | ||
| meta: { discriminant, index, kind }, | ||
| }); | ||
| } | ||
| function refuseDuplicate(discriminant: string, index: number, tag: SchemaTag): never { | ||
| throw new DiscriminantInvalidError({ | ||
| cause: `member #${index} of discriminatedUnion("${discriminant}") claims the tag ${String(tag)}, which an earlier member already claims`, | ||
| fix: `give member #${index} its own value at "${discriminant}", or merge the two members into one`, | ||
| meta: { discriminant, index, tag: String(tag) }, | ||
| }); | ||
| } | ||
| /** `post | page | 2 | false` — every accepted tag, in declaration order. Developer data only. */ | ||
| function renderKnown(known: readonly SchemaTag[]): string { | ||
| return known.map((value) => String(value)).join(' | '); | ||
| } | ||
| /** | ||
| * A union whose branch is chosen by one key, not by trying every member in turn. | ||
| * | ||
| * Two things `t.union(...)` cannot do. The message: a failure reports the branch the discriminant | ||
| * NAMED — `t.union` reports every branch's reasons at once, so a typo in a `post` body arrived as | ||
| * N contradictory complaints and the field that was actually wrong was named in none of them. The | ||
| * IR: `discriminator.propertyName` reaches OpenAPI and the MCP tool schema, so a code generator | ||
| * emits one tagged type instead of an untagged `anyOf`. | ||
| * | ||
| * The node stays `kind: 'union'` with a `discriminant` beside it — see `node.ts` for why a new | ||
| * `SchemaKind` would have degraded silently in every consumer that already handles unions. | ||
| */ | ||
| export function discriminatedUnionSchema<S extends readonly [AnySchema, ...AnySchema[]]>( | ||
| discriminant: string, | ||
| ...members: S | ||
| ): Schema<InferInput<S[number]>, InferOutput<S[number]>> { | ||
| type Out = InferOutput<S[number]>; | ||
| const branches = new Map<unknown, Check<Out>>(); | ||
| const known: SchemaTag[] = []; | ||
| for (const [index, member] of members.entries()) { | ||
| const values = declaredValues(member.node, discriminant); | ||
| if (values === undefined) refuseUndeclared(discriminant, index, member.node.kind); | ||
| const check = checkOf(member) as Check<Out>; | ||
| for (const value of values) { | ||
| // Letting the first declaration win would leave a member that can never run, which is the | ||
| // same defect as an unreachable case and is invisible until the wrong branch validates. | ||
| if (branches.has(value)) refuseDuplicate(discriminant, index, value); | ||
| branches.set(value, check); | ||
| known.push(value); | ||
| } | ||
| } | ||
| const node: SchemaNode = { | ||
| kind: 'union', | ||
| discriminant, | ||
| anyOf: members.map((member) => member.node), | ||
| }; | ||
| return makeSchema<InferInput<S[number]>, Out>(node, (value, path) => { | ||
| if (!isPlainObject(value)) { | ||
| return fail(path, expected(`an object with a "${discriminant}" discriminant`, value)); | ||
| } | ||
| const branch = branches.get(value[discriminant]); | ||
| if (branch === undefined) { | ||
| return fail( | ||
| [...path, discriminant], | ||
| expected(`one of ${renderKnown(known)}`, value[discriminant]), | ||
| ); | ||
| } | ||
| // The named branch's issues, and only those: the caller already said which shape they meant, | ||
| // so the other branches' complaints are noise that hides the one real field error. | ||
| return branch(value, path); | ||
| }); | ||
| } |
| // Single responsibility: the framework's ONE money declaration and the validator that guards it. | ||
| // Split out of `validators.ts` because it is the only builtin whose *shape* other packages alias | ||
| // — `@ultimat3/money`'s `Money`, `@ultimat3/entity`'s `MoneyValue` — so it earns a file a reader | ||
| // can open by name instead of scrolling to. | ||
| import { fail, failWith, isPlainObject, makeSchema, pass, type Schema } from './builder'; | ||
| import { expected } from './describe-value'; | ||
| import type { StandardIssue } from './standard'; | ||
| /** | ||
| * The shape of an ISO 4217 alphabetic code, as a pattern SOURCE — the one declaration every | ||
| * projection of that bound derives from. It is a string rather than a `RegExp` because the two | ||
| * surfaces that cannot call a predicate need the source itself: `json-schema.ts` emits it as the | ||
| * `pattern` of the published OpenAPI contract, and `@ultimat3/entity`'s `currencyCheck` emits it | ||
| * inside a Postgres `~` CHECK so a psql session cannot write a code the app would refuse to read. | ||
| * | ||
| * Keep it inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically — anchors, | ||
| * a literal character class, a bounded repetition. A construct only one of the three understands | ||
| * (`\d`, a lookahead, a non-greedy quantifier) makes the CHECK stop meaning what `isCurrencyCode` | ||
| * means, and a real server is the first thing that says so. | ||
| */ | ||
| export const CURRENCY_CODE_PATTERN = '^[A-Z]{3}$'; | ||
| const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN); | ||
| /** | ||
| * The framework's ONE declaration of a money value. `@ultimat3/money`'s `Money` and | ||
| * `@ultimat3/entity`'s `MoneyValue` are aliases of this type, not copies of its shape — three | ||
| * structural restatements are how `minor` became a `number` here and a `bigint` there, which made | ||
| * a row the entity layer produced fail `t.money` and throw inside `JSON.stringify`. | ||
| * | ||
| * It lives at tier 0 because that is the only tier every other package may import, and `number` | ||
| * rather than `bigint` because money crosses the wire on every surface this framework projects — | ||
| * `JSON.stringify` refuses a bigint, and this node is also the OpenAPI contract. A value past | ||
| * `Number.MAX_SAFE_INTEGER` is refused HERE, at the boundary, with the field path — and again | ||
| * where it is decoded; it is never widened. | ||
| * | ||
| * Never a float, and never an amount without its currency. | ||
| */ | ||
| export interface MoneyValue { | ||
| readonly minor: number; | ||
| readonly currency: string; | ||
| /** | ||
| * Decimal places `minor` counts, when they are not the currency's own. Absent — the shape every | ||
| * existing value and every existing row still has — means the currency's natural minor unit: 2 | ||
| * for USD, 0 for JPY, 3 for KWD. `{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002. | ||
| * | ||
| * It exists because a cents-only value could not name a sub-cent amount at all, so the one | ||
| * place that needed one — a model call costing $0.00016 — rounded it up to a whole cent and | ||
| * reported 62x the real spend. The alternative was a second money type, which is the axiom-1 | ||
| * violation this declaration exists to prevent. | ||
| */ | ||
| readonly scale?: number; | ||
| } | ||
| /** | ||
| * The largest decimal exponent a money value may carry. 10^15 is the last power of ten that is | ||
| * itself a safe integer, so a finer scale could not name its own unit inside the range `minor` is | ||
| * already checked against. | ||
| */ | ||
| export const MAX_MONEY_SCALE = 15; | ||
| /** | ||
| * What a legal `MoneyValue.currency` is — declared once, here, beside the type that carries it and | ||
| * beside `isMoneyScale`, which is the twin of this predicate and the precedent for it. Takes | ||
| * `unknown` because every caller is a boundary: a row off a `char(3)` column, a body off the wire, | ||
| * a `registerCurrency` argument from an untyped caller. `String(value).test(…)` on a symbol throws | ||
| * where a refusal was due, so the `typeof` half belongs in here rather than at each call. | ||
| */ | ||
| export function isCurrencyCode(value: unknown): value is string { | ||
| return typeof value === 'string' && CURRENCY_RE.test(value); | ||
| } | ||
| /** What a legal `MoneyValue.scale` is — declared once, here, beside the type that carries it. */ | ||
| export function isMoneyScale(value: unknown): value is number { | ||
| return ( | ||
| typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_MONEY_SCALE | ||
| ); | ||
| } | ||
| export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue, MoneyValue>( | ||
| { | ||
| kind: 'money', | ||
| description: 'integer minor units plus an ISO 4217 currency code', | ||
| properties: { | ||
| minor: { | ||
| kind: 'number', | ||
| integer: true, | ||
| minimum: -Number.MAX_SAFE_INTEGER, | ||
| maximum: Number.MAX_SAFE_INTEGER, | ||
| }, | ||
| currency: { kind: 'string', pattern: CURRENCY_CODE_PATTERN }, | ||
| scale: { | ||
| kind: 'number', | ||
| integer: true, | ||
| optional: true, | ||
| minimum: 0, | ||
| maximum: MAX_MONEY_SCALE, | ||
| description: 'decimal places `minor` counts; absent means the currency’s own', | ||
| }, | ||
| }, | ||
| }, | ||
| (value, path) => { | ||
| if (!isPlainObject(value)) return fail(path, expected('a Money object', value)); | ||
| const minor = value['minor']; | ||
| const currency = value['currency']; | ||
| const scale = value['scale']; | ||
| const issues: StandardIssue[] = []; | ||
| // Safe, not merely whole: `money()` and `entity`'s `parseMinor` both demand a safe integer, so | ||
| // `Number.isInteger` here let 2^53 through the boundary as a 200 and failed at the row write | ||
| // as a 500 — the same value refused twice, once with a field path and once without. | ||
| if (typeof minor !== 'number' || !Number.isSafeInteger(minor)) { | ||
| issues.push({ | ||
| message: expected('a safe integer number of minor units', minor), | ||
| path: [...path, 'minor'], | ||
| }); | ||
| } | ||
| if (!isCurrencyCode(currency)) { | ||
| issues.push({ | ||
| message: expected('a 3-letter ISO 4217 code', currency), | ||
| path: [...path, 'currency'], | ||
| }); | ||
| } | ||
| if (scale !== undefined && !isMoneyScale(scale)) { | ||
| issues.push({ | ||
| message: expected( | ||
| `a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}`, | ||
| scale, | ||
| ), | ||
| path: [...path, 'scale'], | ||
| }); | ||
| } | ||
| if (issues.length > 0) return failWith(issues); | ||
| // The key is carried only when it was sent: a value at the currency's own scale must | ||
| // round-trip byte-for-byte, or every stored amount in every app changes shape on one parse. | ||
| return pass({ | ||
| minor: minor as number, | ||
| currency: currency as string, | ||
| ...(scale === undefined ? {} : { scale: scale as number }), | ||
| }); | ||
| }, | ||
| ); |
+2
-1
| { | ||
| "name": "@ultimat3/schema", | ||
| "version": "1.2.0", | ||
| "version": "2.0.0", | ||
| "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output", | ||
@@ -22,2 +22,3 @@ "license": "MIT", | ||
| "!src/**/*.test.ts", | ||
| "CLAUDE.md", | ||
| "README.md", | ||
@@ -24,0 +25,0 @@ "LICENSE" |
+54
-3
@@ -12,3 +12,5 @@ # ✅ @ultimat3/schema | ||
| | the introspectable IR every generator walks | `node.ts` | | ||
| | `Schema` factory, `Infer` machinery | `builder.ts` | | ||
| | `Schema` factory, `Infer` machinery, `.refine()` | `builder.ts` | | ||
| | how a rejected value is described — its shape, never its content | `describe-value.ts` | | ||
| | a union routed by one literal key | `discriminated-union.ts` | | ||
| | `configureSchemaProvider()` — the swap point | `provider.ts` | | ||
@@ -30,2 +32,3 @@ | schema → JSON Schema (OpenAPI + MCP) | `json-schema.ts` | | ||
| price: t.money, // { minor: 1999, currency: 'EUR' } — never a float | ||
| // { minor: 2, currency: 'USD', scale: 6 } is $0.000002 | ||
| timeZone: t.timezone, // real IANA validation, not an annotation | ||
@@ -40,3 +43,3 @@ cursor: t.optional(t.cursor), | ||
| |---|---|---| | ||
| | `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `record` `optional` `nullable` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` | | ||
| | `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `discriminatedUnion` `record` `optional` `nullable` `refine` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` `.refine(r)` | | ||
| | `money` `timezone` `locale` `slug` `cursor` | `object(...).extend/.pick/.omit` | `string.min/.max/.pattern`, `number.min/.max/.int` | | ||
@@ -49,2 +52,42 @@ | ||
| ### Cross-field rules live on the schema | ||
| A rule the IR cannot state structurally still belongs to the schema, or it moves into a handler and | ||
| disappears from `openapi.json`, the MCP tool schema, the typed client and every form binding at | ||
| once — axiom 2 broken for every such rule in the product. | ||
| ```ts | ||
| const booking = t.object({ startDate: t.date, endDate: t.date }).refine({ | ||
| name: 'end-after-start', // stable id → `x-ultimate-refinements` | ||
| message: 'endDate must be after startDate', // the rule, never the value | ||
| path: ['endDate'], // which field the issue lands on | ||
| check: (value) => value.endDate > value.startDate, | ||
| }); | ||
| ``` | ||
| The predicate runs on the **parsed** output and only after the shape passed, so it compares coerced | ||
| values and never defends against a type the schema already refused. What ships in the IR is the | ||
| declaration, not the closure: `node.refinements`, projected as an `x-ultimate-refinements` | ||
| extension **and** appended to `description`, which is the only field an LLM reading a tool schema | ||
| is guaranteed to see. `.refine()` returns a plain `Schema`, so refining after `extend`/`pick`/`omit` | ||
| — which rebuild from the shape and would drop the rule — is a type error rather than a comment. | ||
| ### `discriminatedUnion` names the branch it judged | ||
| ```ts | ||
| const body = t.discriminatedUnion( | ||
| 'kind', | ||
| t.object({ kind: t.literal('post'), slug: t.slug }), | ||
| t.object({ kind: t.literal('page'), title: t.string.min(3) }), | ||
| ); | ||
| ``` | ||
| `t.union` reports every member's reasons at once, so one bad field in a `post` body arrives as N | ||
| contradictory complaints naming fields the caller never sent. This routes on `kind` first and | ||
| reports that branch's issues only. A member with no literal (or enum) at the discriminant, or two | ||
| members claiming one tag, is `X_SCHEMA_DISCRIMINANT_INVALID` **where the union is built** — a | ||
| branch nothing can route to is wrong for every input, not for one request. The node stays | ||
| `kind: 'union'` with a `discriminant` beside it, so every existing IR consumer keeps working, and | ||
| JSON Schema gains `discriminator: { propertyName }`. | ||
| **You rarely import this package.** `action`, `query`, `jobs` and `entity` each re-export the same | ||
@@ -63,6 +106,14 @@ `t`, so an authoring file imports its primitive and nothing else. Import `@ultimat3/schema` directly | ||
| X_VALIDATION_FAILED: value did not match its schema | ||
| cause: postId: expected a uuid, received "abc"; notify: expected a boolean, received "yes" | ||
| cause: postId: expected a uuid, received a string of 3 characters; notify: expected a boolean, received a string of 3 characters | ||
| fix: send input with the field(s) named in cause corrected to the expected type | ||
| ``` | ||
| **An issue message names the shape of the rejected value, never its content**, and `received` on | ||
| the issue is empty for the same reason. `@ultimat3/http` folds these messages into | ||
| `X_BODY_INVALID`'s `cause`, which is returned to the caller *and* written to the log line — where | ||
| the logger redacts by key and a value baked into a string has no key left to redact. Echoing the | ||
| value meant a password-strength rule wrote every mistyped password to the central log index in | ||
| cleartext. There is no dev-only escape hatch: a flag is one misconfigured environment away from | ||
| being the same breach. `describe-value.ts` owns the rule and `describe-value.test.ts` enforces it. | ||
| `error.issues` is `{ path, expected, received, message }[]` with paths like `items[0].price`; | ||
@@ -69,0 +120,0 @@ `formatIssues()` renders one line per issue for the dev overlay. `validate()` never throws and |
+92
-17
| // Single responsibility: the `Schema` type every builtin validator returns, and the factory | ||
| // that turns a check function plus an IR node into a Standard-Schema-conforming object. | ||
| import { ValidationFailedError, type ValidationIssue } from './errors'; | ||
| import type { SchemaNode } from './node'; | ||
| import { describeValue } from './describe-value'; | ||
| import { SchemaError, ValidationFailedError, type ValidationIssue } from './errors'; | ||
| import type { SchemaNode, SchemaRefinement } from './node'; | ||
| import { | ||
@@ -45,17 +46,24 @@ formatPath, | ||
| /** `expected uuid, received "abc"` — the message an agent can act on without guessing. */ | ||
| export function expected(what: string, value: unknown): string { | ||
| return `expected ${what}, received ${describeValue(value)}`; | ||
| /** An object with own keys — not null, not an array. The gate every object-ish check opens with. */ | ||
| export function isPlainObject(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| } | ||
| export function describeValue(value: unknown): string { | ||
| if (value === undefined) return 'undefined'; | ||
| if (value === null) return 'null'; | ||
| if (typeof value === 'string') return JSON.stringify(value); | ||
| if (typeof value === 'number' || typeof value === 'boolean') return String(value); | ||
| if (Array.isArray(value)) return `array(${value.length})`; | ||
| if (value instanceof Date) { | ||
| return Number.isNaN(value.getTime()) ? 'Date(Invalid Date)' : `Date(${value.toISOString()})`; | ||
| } | ||
| return typeof value; | ||
| /** | ||
| * A rule the IR cannot state structurally — `endDate > startDate`, `total === sum(lines)`. It | ||
| * lives on the schema rather than in a handler so it reaches OpenAPI, the MCP tool schema, the | ||
| * typed client and the form binding from the one declaration, like every other constraint. | ||
| * | ||
| * `message` is authored by the developer and rendered verbatim, so it must state the RULE and | ||
| * never the value — see `describe-value.ts` for why an issue message is a public surface. | ||
| */ | ||
| export interface Refinement<Out> { | ||
| /** Stable machine id, kebab-case: `end-after-start`. Projected as `x-ultimate-refinements`. */ | ||
| readonly name: string; | ||
| /** The rule as a sentence, e.g. `endDate must be after startDate`. Never interpolate a value. */ | ||
| readonly message: string; | ||
| /** Runs on the PARSED output, so a cross-field rule compares coerced values, not raw input. */ | ||
| readonly check: (value: Out) => boolean; | ||
| /** Which field the issue is reported against. Absent reports it against the object itself. */ | ||
| readonly path?: readonly string[] | undefined; | ||
| } | ||
@@ -77,2 +85,8 @@ | ||
| describe(description: string): Schema<In, Out>; | ||
| /** | ||
| * Attach a cross-field rule. Returns a plain `Schema` on purpose — `extend`/`pick`/`omit` | ||
| * rebuild from the shape and would silently drop the refinement, so refining last is a type | ||
| * error to get wrong rather than a comment asking you not to. | ||
| */ | ||
| refine(refinement: Refinement<Out>): Schema<In, Out>; | ||
| } | ||
@@ -107,2 +121,38 @@ | ||
| /** | ||
| * One FRESH copy of a default per parse. `.default([])` handed every parse the same array, so a | ||
| * handler pushing onto it made that push the next request's starting value for the life of the | ||
| * process — cross-request data bleed declared in a schema. | ||
| * | ||
| * Primitives are immutable, so they are still shared and cost nothing. Everything else is decided | ||
| * ONCE, here at declaration: a value `structuredClone` can copy is copied per parse, and one it | ||
| * cannot is refused at the first import of the authoring file — the same "wrong for every input, | ||
| * so say so where it is written" rule `X_SCHEMA_DISCRIMINANT_INVALID` follows. What that refuses | ||
| * was never a working declaration: `node.default` is published as JSON, so a default carrying a | ||
| * function or a symbol could not reach OpenAPI either. | ||
| * | ||
| * Known narrow cost: a class instance clones to a plain object, so a default that relied on its | ||
| * prototype loses it. Schema defaults are wire values, which is the only thing `node.default` can | ||
| * publish, so that shape was already outside what a default may mean. | ||
| */ | ||
| function defaultFactory<Out>(fallback: Out): () => Out { | ||
| if (fallback === null || typeof fallback !== 'object') return () => fallback; | ||
| try { | ||
| structuredClone(fallback); | ||
| } catch { | ||
| throw new SchemaError({ | ||
| code: 'X_SCHEMA_DEFAULT_UNSHAREABLE', | ||
| cause: `default() received ${describeValue(fallback)}, which structuredClone cannot copy`, | ||
| fix: 'pass a JSON-shaped default (plain object, array, Date, Map, Set), or drop .default() and answer the absent value in the handler', | ||
| }); | ||
| } | ||
| return () => structuredClone(fallback); | ||
| } | ||
| /** The default declaration, dropped — for a wrapper that can no longer reach it. */ | ||
| function withoutDefault(node: SchemaNode): SchemaNode { | ||
| const { hasDefault: _hasDefault, default: _default, ...rest } = node; | ||
| return rest; | ||
| } | ||
| function toIssues(result: CheckErr): readonly ValidationIssue[] { | ||
@@ -139,3 +189,6 @@ return result.issues.map((issue) => ({ | ||
| return makeSchema<In | undefined, Out | undefined>( | ||
| { ...node, optional: true }, | ||
| // `.default(x).optional()` published `default: x` while `parse` answered `undefined`: | ||
| // this wrapper short-circuits `undefined` before the default is ever reached, so a client | ||
| // honouring the published default assumed a value the server never produced. | ||
| withoutDefault({ ...node, optional: true }), | ||
| (value, path) => (value === undefined ? pass(undefined) : check(value, path)), | ||
@@ -150,5 +203,8 @@ ); | ||
| default(fallback: Out): Schema<In | undefined, Out> { | ||
| const fresh = defaultFactory(fallback); | ||
| return makeSchema<In | undefined, Out>( | ||
| // The node keeps the DECLARATION, never a copy: `node.default` is what OpenAPI, the MCP | ||
| // tool schema and the typed client publish, and they describe what was written. | ||
| { ...node, hasDefault: true, default: fallback }, | ||
| (value, path) => (value === undefined ? pass(fallback) : check(value, path)), | ||
| (value, path) => (value === undefined ? pass(fresh()) : check(value, path)), | ||
| ); | ||
@@ -159,2 +215,21 @@ }, | ||
| }, | ||
| refine(refinement: Refinement<Out>): Schema<In, Out> { | ||
| const declared: SchemaRefinement = { | ||
| name: refinement.name, | ||
| message: refinement.message, | ||
| ...(refinement.path === undefined ? {} : { path: [...refinement.path] }), | ||
| }; | ||
| return makeSchema<In, Out>( | ||
| { ...node, refinements: [...(node.refinements ?? []), declared] }, | ||
| (value, path) => { | ||
| // Shape first: a predicate written against `Out` must never be handed an unparsed | ||
| // value, or every refinement grows a defensive typeof the schema already performed. | ||
| const result = check(value, path); | ||
| if (!result.ok) return result; | ||
| return refinement.check(result.value) | ||
| ? result | ||
| : fail([...path, ...(refinement.path ?? [])], refinement.message); | ||
| }, | ||
| ); | ||
| }, | ||
| }; | ||
@@ -161,0 +236,0 @@ return schema; |
+41
-7
@@ -11,2 +11,18 @@ // Single responsibility: HTTP-boundary coercion. Kept out of validation on purpose — only the | ||
| /** A numeric string as a number, or `undefined` for anything that is not confidently one. */ | ||
| function numeric(raw: unknown): number | undefined { | ||
| if (typeof raw !== 'string' || raw.trim() === '') return undefined; | ||
| const value = Number(raw); | ||
| return Number.isFinite(value) ? value : undefined; | ||
| } | ||
| /** A truthy/falsy query-string spelling as a boolean, or the raw value when it is neither. */ | ||
| function booleanish(raw: unknown): unknown { | ||
| if (typeof raw !== 'string') return raw; | ||
| const lowered = raw.toLowerCase(); | ||
| if (TRUE_VALUES.has(lowered)) return true; | ||
| if (FALSE_VALUES.has(lowered)) return false; | ||
| return raw; | ||
| } | ||
| export type QuerySource = | ||
@@ -29,7 +45,13 @@ | URLSearchParams | ||
| } | ||
| case 'boolean': { | ||
| case 'boolean': | ||
| return booleanish(raw); | ||
| case 'literal': { | ||
| // Toward the literal's OWN type, because `literalSchema` compares with `===`: without this | ||
| // a numeric or boolean `t.literal` was unsatisfiable over its own GET route — `t.literal(2)` | ||
| // received `"2"` and the endpoint 400d on every request — while the identical declaration | ||
| // worked over an action's JSON body and over MCP. A string literal needs nothing, which is | ||
| // exactly why the gap read as arbitrary. | ||
| if (typeof raw !== 'string') return raw; | ||
| const lowered = raw.toLowerCase(); | ||
| if (TRUE_VALUES.has(lowered)) return true; | ||
| if (FALSE_VALUES.has(lowered)) return false; | ||
| if (typeof node.literal === 'number') return numeric(raw) ?? raw; | ||
| if (typeof node.literal === 'boolean') return booleanish(raw); | ||
| return raw; | ||
@@ -49,3 +71,7 @@ } | ||
| if (typeof raw !== 'object' || node.valueNode === undefined) return raw; | ||
| const out: Record<string, unknown> = {}; | ||
| // A null prototype for the reason `recordSchema` uses one: on a `{}` literal, assigning | ||
| // `out['__proto__']` hits the `Object.prototype` SETTER and the key vanishes, so the | ||
| // record validator's deliberate refusal of it never ran — the key was reported absent | ||
| // rather than rejected, on the one path (HTTP query) where it is caller-controlled. | ||
| const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>; | ||
| for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { | ||
@@ -68,4 +94,12 @@ out[key] = coerceNode(node.valueNode, value); | ||
| const source = raw as Record<string, unknown>; | ||
| const minor = typeof source['minor'] === 'string' ? Number(source['minor']) : source['minor']; | ||
| return Number.isFinite(minor) ? { ...source, minor } : raw; | ||
| // Through `numeric` for the same reason `scale` is: `Number('')` is 0, so a blank amount | ||
| // field converted here would reach the validator as a legitimate zero and book an empty | ||
| // price input as free. A blank stays a blank and fails validation, which is the real error. | ||
| const rawMinor = source['minor']; | ||
| const minor = typeof rawMinor === 'string' ? numeric(rawMinor) : rawMinor; | ||
| if (typeof minor !== 'number' || !Number.isFinite(minor)) return raw; | ||
| // `scale` arrives as text from a query string exactly as `minor` does. Left a string it | ||
| // would fail validation on a value whose `minor` the same request just had converted. | ||
| const scale = numeric(source['scale']); | ||
| return { ...source, minor, ...(scale === undefined ? {} : { scale }) }; | ||
| } | ||
@@ -72,0 +106,0 @@ case 'union': { |
+20
-0
@@ -23,2 +23,8 @@ // Single responsibility: this package's error codes. `@ultimat3/schema` is tier 0 and may not | ||
| X_SCHEMA_UNSUPPORTED: { title: 'the active schema provider cannot do this' }, | ||
| X_SCHEMA_DISCRIMINANT_INVALID: { | ||
| title: 'a discriminated union member can never be dispatched to', | ||
| }, | ||
| X_SCHEMA_DEFAULT_UNSHAREABLE: { | ||
| title: 'a schema default cannot be copied per parse', | ||
| }, | ||
| }); | ||
@@ -131,2 +137,16 @@ | ||
| /** | ||
| * Thrown where the union is BUILT, not where a value is parsed: a member no tag can route to is | ||
| * wrong for every input, so the first import of the authoring file is the earliest honest place | ||
| * to say so — never a request that quietly took the wrong branch. | ||
| */ | ||
| export class DiscriminantInvalidError extends SchemaError { | ||
| static readonly code = 'X_SCHEMA_DISCRIMINANT_INVALID'; | ||
| override readonly name = 'DiscriminantInvalidError'; | ||
| constructor(init: Omit<SchemaErrorInit, 'code'>) { | ||
| super({ ...init, code: DiscriminantInvalidError.code }); | ||
| } | ||
| } | ||
| export class SchemaUnsupportedError extends SchemaError { | ||
@@ -133,0 +153,0 @@ static readonly code = 'X_SCHEMA_UNSUPPORTED'; |
+15
-12
@@ -10,2 +10,3 @@ // Single responsibility: the public API of @ultimat3/schema. Explicit named exports only. | ||
| Path, | ||
| Refinement, | ||
| Schema, | ||
@@ -17,14 +18,7 @@ Shape, | ||
| } from './builder'; | ||
| export { | ||
| checkOf, | ||
| describeValue, | ||
| expected, | ||
| fail, | ||
| failWith, | ||
| makeSchema, | ||
| pass, | ||
| VENDOR, | ||
| } from './builder'; | ||
| export { checkOf, fail, failWith, makeSchema, pass, VENDOR } from './builder'; | ||
| export type { QuerySource } from './coerce'; | ||
| export { coerceInput, coerceNode, coerceQuery } from './coerce'; | ||
| export { describeValue, expected } from './describe-value'; | ||
| export { discriminatedUnionSchema } from './discriminated-union'; | ||
| export type { | ||
@@ -37,2 +31,3 @@ SchemaErrorCodeDeclaration, | ||
| export { | ||
| DiscriminantInvalidError, | ||
| isSchemaError, | ||
@@ -48,2 +43,3 @@ SCHEMA_ERROR_CODES, | ||
| JsonSchemaDialect, | ||
| JsonSchemaDiscriminator, | ||
| JsonSchemaType, | ||
@@ -53,3 +49,10 @@ ToJsonSchemaOptions, | ||
| export { nodeToJsonSchema, toJsonSchema, toMcpInputSchema } from './json-schema'; | ||
| export type { SchemaFormat, SchemaKind, SchemaNode } from './node'; | ||
| export type { MoneyValue } from './money-value'; | ||
| export { | ||
| CURRENCY_CODE_PATTERN, | ||
| isCurrencyCode, | ||
| isMoneyScale, | ||
| MAX_MONEY_SCALE, | ||
| } from './money-value'; | ||
| export type { SchemaFormat, SchemaKind, SchemaNode, SchemaRefinement } from './node'; | ||
| export { isSchemaNode, nodeOf, requiredKeys } from './node'; | ||
@@ -91,3 +94,2 @@ export type { SchemaProvider } from './provider'; | ||
| export type { | ||
| MoneyValue, | ||
| NumberSchema, | ||
@@ -107,3 +109,4 @@ ObjectSchema, | ||
| recordSchema, | ||
| refineSchema, | ||
| unionSchema, | ||
| } from './validators'; |
+91
-9
@@ -5,7 +5,21 @@ // Single responsibility: SchemaNode -> JSON Schema. Load-bearing: OpenAPI request/response | ||
| import { requiredKeys, type SchemaNode } from './node'; | ||
| import { CURRENCY_CODE_PATTERN, MAX_MONEY_SCALE } from './money-value'; | ||
| import { requiredKeys, type SchemaNode, type SchemaRefinement } from './node'; | ||
| import { introspect } from './provider'; | ||
| export type JsonSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'; | ||
| export type JsonSchemaType = | ||
| | 'string' | ||
| | 'number' | ||
| | 'integer' | ||
| | 'boolean' | ||
| | 'object' | ||
| | 'array' | ||
| /** Only ever emitted as the second branch of a nullable field's `anyOf` — see `annotate`. */ | ||
| | 'null'; | ||
| /** OpenAPI 3.1's tagged-union hint. `propertyName` alone: the branches here are inline, not `$ref`. */ | ||
| export interface JsonSchemaDiscriminator { | ||
| readonly propertyName: string; | ||
| } | ||
| export interface JsonSchema { | ||
@@ -29,3 +43,10 @@ readonly $schema?: string; | ||
| readonly anyOf?: readonly JsonSchema[]; | ||
| readonly discriminator?: JsonSchemaDiscriminator; | ||
| readonly title?: string; | ||
| /** | ||
| * The refinements a consumer can act on mechanically. The prose copy also lands in | ||
| * `description`, because that is the only field an LLM reading an MCP tool schema is | ||
| * guaranteed to see — the extension is for code generators, the description is for readers. | ||
| */ | ||
| readonly 'x-ultimate-refinements'?: readonly SchemaRefinement[]; | ||
| } | ||
@@ -69,9 +90,45 @@ | ||
| /** | ||
| * JSON Schema's `pattern` is an ECMA-262 source with no flag syntax, so a flagged pattern is | ||
| * stated in prose instead of silently narrowed: a consumer applying `pattern` alone would refuse | ||
| * values this schema accepts, and there is nowhere honest to hide that. | ||
| */ | ||
| function patternNote(node: SchemaNode): string | undefined { | ||
| if (node.pattern === undefined) return undefined; | ||
| const flags = node.patternFlags; | ||
| return flags === undefined || flags === '' | ||
| ? undefined | ||
| : `pattern is applied with RegExp flags "${flags}"`; | ||
| } | ||
| function convert(node: SchemaNode): JsonSchema { | ||
| const annotate = (schema: JsonSchema): JsonSchema => ({ | ||
| ...schema, | ||
| ...(node.description === undefined ? {} : { description: node.description }), | ||
| const refinements = node.refinements ?? []; | ||
| const notes = [ | ||
| node.description, | ||
| patternNote(node), | ||
| ...refinements.map((refinement) => refinement.message), | ||
| ].filter((part): part is string => part !== undefined); | ||
| const described = notes.length === 0 ? undefined : notes.join(' — '); | ||
| const annotations: JsonSchema = { | ||
| ...(described === undefined ? {} : { description: described }), | ||
| ...(node.hasDefault === true ? { default: node.default } : {}), | ||
| }); | ||
| ...(refinements.length === 0 ? {} : { 'x-ultimate-refinements': refinements }), | ||
| }; | ||
| /** | ||
| * `null` is a VALUE the field holds, so it joins the type union rather than the annotations — | ||
| * `{ anyOf: [<converted>, { type: 'null' }] }`, which is how OpenAPI 3.1 / JSON Schema 2020-12 | ||
| * spell it (3.0's `nullable: true` keyword is gone). Dropping it published a contract every | ||
| * surface at once disagreed with: OpenAPI bodies, MCP `inputSchema`, `respondToolFor` and the | ||
| * typed client all forbade a `null` the action's own `output:` validator returns. | ||
| * | ||
| * The annotations stay OUTSIDE the `anyOf` — they describe the field, not one branch of it — | ||
| * and `requiredKeys` is untouched, because nullable is not optional: the key is still sent. | ||
| */ | ||
| const annotate = (schema: JsonSchema): JsonSchema => | ||
| node.nullable === true | ||
| ? { anyOf: [schema, { type: 'null' }], ...annotations } | ||
| : { ...schema, ...annotations }; | ||
| switch (node.kind) { | ||
@@ -100,3 +157,10 @@ case 'string': | ||
| case 'union': | ||
| return annotate({ anyOf: (node.anyOf ?? []).map(convert) }); | ||
| return annotate({ | ||
| anyOf: (node.anyOf ?? []).map(convert), | ||
| // Only when the union actually dispatches on a key: an untagged `t.union` carrying a | ||
| // `discriminator` would tell a generator to read a property no member declares. | ||
| ...(node.discriminant === undefined | ||
| ? {} | ||
| : { discriminator: { propertyName: node.discriminant } }), | ||
| }); | ||
| case 'record': | ||
@@ -111,4 +175,22 @@ return annotate({ | ||
| properties: { | ||
| minor: { type: 'integer', description: 'amount in minor units, never a float' }, | ||
| currency: { type: 'string', pattern: '^[A-Z]{3}$' }, | ||
| minor: { | ||
| type: 'integer', | ||
| description: 'amount in minor units, never a float', | ||
| // The safe-integer range the validator enforces, so a generated client refuses the | ||
| // same value the boundary does instead of learning about it from a 500. | ||
| minimum: -Number.MAX_SAFE_INTEGER, | ||
| maximum: Number.MAX_SAFE_INTEGER, | ||
| }, | ||
| // The pattern the validator applies, not a copy of it: this object IS the contract a | ||
| // generated client checks against, so a widened predicate here would be a client | ||
| // refusing a code the boundary accepts. | ||
| currency: { type: 'string', pattern: CURRENCY_CODE_PATTERN }, | ||
| // Optional, never required: `additionalProperties: false` alone would make a generated | ||
| // client refuse a scaled amount the framework's own validator accepts. | ||
| scale: { | ||
| type: 'integer', | ||
| description: 'decimal places `minor` counts; absent means the currency’s own', | ||
| minimum: 0, | ||
| maximum: MAX_MONEY_SCALE, | ||
| }, | ||
| }, | ||
@@ -115,0 +197,0 @@ required: ['minor', 'currency'], |
+29
-0
@@ -30,2 +30,13 @@ // Single responsibility: the introspectable schema IR. Flat on purpose — OpenAPI generation, | ||
| /** | ||
| * A refinement as the IR carries it: the predicate's *declaration*, never the predicate. A closure | ||
| * cannot cross into OpenAPI or an MCP tool schema, so what ships is the name and the rule text — | ||
| * enough for a generated client to state the constraint and for a form to label the failure. | ||
| */ | ||
| export interface SchemaRefinement { | ||
| readonly name: string; | ||
| readonly message: string; | ||
| readonly path?: readonly string[] | undefined; | ||
| } | ||
| export interface SchemaNode { | ||
@@ -44,2 +55,8 @@ readonly kind: SchemaKind; | ||
| readonly pattern?: string | undefined; | ||
| /** | ||
| * The RegExp's flags, carried beside the source for the same reason. Dropping them made | ||
| * `t.string.pattern(/^[a-z]+$/i)` reject `ABC` while quoting the pattern that matches it. | ||
| * JSON Schema's `pattern` has no flags, so `json-schema.ts` states them in `description`. | ||
| */ | ||
| readonly patternFlags?: string | undefined; | ||
| readonly minimum?: number | undefined; | ||
@@ -53,3 +70,15 @@ readonly maximum?: number | undefined; | ||
| readonly anyOf?: readonly SchemaNode[] | undefined; | ||
| /** | ||
| * The key a `union` dispatches on. Additive rather than a `'discriminatedUnion'` kind: every | ||
| * consumer that switches on `kind` — `json-schema.ts`, `coerce.ts`, `action`'s sample generator, | ||
| * the admin form generator — already handles `'union'` correctly, and a new kind would have | ||
| * fallen through each of their `default:` branches to an empty schema without failing anything. | ||
| */ | ||
| readonly discriminant?: string | undefined; | ||
| readonly valueNode?: SchemaNode | undefined; | ||
| /** | ||
| * Rules the structural fields cannot state. Carried beside `kind` rather than wrapping it for | ||
| * the reason `discriminant` is: a refined string must still read as a string everywhere. | ||
| */ | ||
| readonly refinements?: readonly SchemaRefinement[] | undefined; | ||
| } | ||
@@ -56,0 +85,0 @@ |
+25
-8
@@ -5,12 +5,7 @@ // Single responsibility: the blessed `t` namespace. One import, one way to declare a schema. | ||
| import type { AnySchema, Schema, Shape } from './builder'; | ||
| import type { AnySchema, Refinement, Schema, Shape } from './builder'; | ||
| import type { MoneyValue } from './money-value'; | ||
| import { schemaProvider } from './provider'; | ||
| import type { InferInput, InferOutput, StandardSchemaV1 } from './standard'; | ||
| import type { | ||
| MoneyValue, | ||
| NumberSchema, | ||
| ObjectSchema, | ||
| StringSchema, | ||
| TNamespace, | ||
| } from './validators'; | ||
| import type { NumberSchema, ObjectSchema, StringSchema, TNamespace } from './validators'; | ||
@@ -85,2 +80,24 @@ function provider(): TNamespace { | ||
| }, | ||
| /** | ||
| * `t.discriminatedUnion('kind', postBody, pageBody)` — the blessed spelling for a tagged shape. | ||
| * A failure reports the branch `kind` named, so the caller reads one field error instead of | ||
| * every branch's reasons concatenated. | ||
| */ | ||
| discriminatedUnion<S extends readonly [AnySchema, ...AnySchema[]]>( | ||
| discriminant: string, | ||
| ...members: S | ||
| ): Schema<InferInput<S[number]>, InferOutput<S[number]>> { | ||
| return provider().discriminatedUnion(discriminant, ...members); | ||
| }, | ||
| /** | ||
| * `t.refine(range, { name: 'end-after-start', message: 'endDate must be after startDate', … })` | ||
| * — a cross-field rule declared ON the schema, so OpenAPI, the MCP tool schema and the form | ||
| * binding all state it instead of it living in a handler where no projection can see it. | ||
| * | ||
| * Delegates to the method for the reason `t.nullable` does: `refine` composes an existing | ||
| * schema rather than building one, so a swapped provider's schemas keep their own behaviour. | ||
| */ | ||
| refine<In, Out>(schema: Schema<In, Out>, refinement: Refinement<Out>): Schema<In, Out> { | ||
| return schema.refine(refinement); | ||
| }, | ||
| record<S extends AnySchema>( | ||
@@ -87,0 +104,0 @@ values: S, |
+102
-49
@@ -9,7 +9,8 @@ // Single responsibility: the builtin, dependency-free validators behind `t`. Small on purpose — | ||
| checkOf, | ||
| expected, | ||
| fail, | ||
| failWith, | ||
| isPlainObject, | ||
| makeSchema, | ||
| pass, | ||
| type Refinement, | ||
| type Schema, | ||
@@ -20,11 +21,8 @@ type Shape, | ||
| } from './builder'; | ||
| import { expected } from './describe-value'; | ||
| import { discriminatedUnionSchema } from './discriminated-union'; | ||
| import { type MoneyValue, moneySchema } from './money-value'; | ||
| import type { SchemaNode } from './node'; | ||
| import type { InferInput, InferOutput, StandardIssue } from './standard'; | ||
| /** Structurally identical to `Money` in `@ultimat3/money`. Never a float. */ | ||
| export interface MoneyValue { | ||
| readonly minor: number; | ||
| readonly currency: string; | ||
| } | ||
| const EMAIL_RE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/; | ||
@@ -34,3 +32,2 @@ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; | ||
| 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; | ||
| const CURRENCY_RE = /^[A-Z]{3}$/; | ||
@@ -58,13 +55,56 @@ export interface StringSchema extends Schema<string, string> { | ||
| /** | ||
| * The literal the caller wrote, flags included. An error quoting `/^[a-z]+$/` for a pattern | ||
| * carrying `i` names something that would have matched the value it just refused. | ||
| */ | ||
| function describePattern(node: SchemaNode): string { | ||
| return node.patternFlags === undefined || node.patternFlags === '' | ||
| ? String(node.pattern) | ||
| : `/${node.pattern}/${node.patternFlags}`; | ||
| } | ||
| /** | ||
| * Characters, not UTF-16 code units — the unit `json-schema.ts` already promises, since JSON | ||
| * Schema defines `minLength`/`maxLength` over code points and the message here has always said | ||
| * "chars". `'👍'.length` is 2, so `t.string.max(1)` refused a value the published schema, a human | ||
| * and Postgres' `char_length` all count as one. | ||
| * | ||
| * Only a surrogate makes the two counts differ, so the string is walked only when one is present | ||
| * — every ASCII value keeps the O(1) read this replaced. | ||
| */ | ||
| const HAS_SURROGATE = /[\uD800-\uDBFF]/; | ||
| function charCount(value: string): number { | ||
| return HAS_SURROGATE.test(value) ? [...value].length : value.length; | ||
| } | ||
| /** | ||
| * Compiled once per schema, not once per validation. `lastIndex` is reset because a `g` or `y` | ||
| * pattern carries it between calls — a cached global RegExp answers `false` for the second | ||
| * `.test()` of the very value it just accepted, which the per-call construction hid. | ||
| */ | ||
| function patternTester(node: SchemaNode): ((value: string) => boolean) | undefined { | ||
| if (node.pattern === undefined) return undefined; | ||
| const source = node.pattern; | ||
| const flags = node.patternFlags; | ||
| let compiled: RegExp | undefined; | ||
| return (value) => { | ||
| compiled ??= new RegExp(source, flags); | ||
| compiled.lastIndex = 0; | ||
| return compiled.test(value); | ||
| }; | ||
| } | ||
| function stringLike(node: SchemaNode, what: string, test?: (value: string) => boolean) { | ||
| const matchesPattern = patternTester(node); | ||
| const check: Check<string> = (value, path) => { | ||
| if (typeof value !== 'string') return fail(path, expected(what, value)); | ||
| if (node.minLength !== undefined && value.length < node.minLength) { | ||
| if (node.minLength !== undefined && charCount(value) < node.minLength) { | ||
| return fail(path, expected(`${what} of at least ${node.minLength} chars`, value)); | ||
| } | ||
| if (node.maxLength !== undefined && value.length > node.maxLength) { | ||
| if (node.maxLength !== undefined && charCount(value) > node.maxLength) { | ||
| return fail(path, expected(`${what} of at most ${node.maxLength} chars`, value)); | ||
| } | ||
| if (node.pattern !== undefined && !new RegExp(node.pattern).test(value)) { | ||
| return fail(path, expected(`${what} matching ${node.pattern}`, value)); | ||
| if (matchesPattern !== undefined && !matchesPattern(value)) { | ||
| return fail(path, expected(`${what} matching ${describePattern(node)}`, value)); | ||
| } | ||
@@ -87,3 +127,14 @@ if (test !== undefined && !test(value)) return fail(path, expected(what, value)); | ||
| max: (length) => makeStringSchema({ ...node, maxLength: length }, what, test), | ||
| pattern: (regex) => makeStringSchema({ ...node, pattern: regex.source }, what, test), | ||
| pattern: (regex) => | ||
| makeStringSchema( | ||
| // Flags travel with the source: a node holding only `source` rebuilt a *different* | ||
| // RegExp, so `/^[a-z]+$/i` refused `ABC` and quoted the pattern that matches it. | ||
| { | ||
| ...node, | ||
| pattern: regex.source, | ||
| ...(regex.flags === '' ? {} : { patternFlags: regex.flags }), | ||
| }, | ||
| what, | ||
| test, | ||
| ), | ||
| }; | ||
@@ -117,6 +168,2 @@ } | ||
| function isPlainObject(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| } | ||
| export function objectSchema<S extends Shape>(shape: S): ObjectSchema<S> { | ||
@@ -223,2 +270,11 @@ const properties: Record<string, SchemaNode> = {}; | ||
| /** | ||
| * Keys that reach an object's prototype rather than its own properties. A record's keys are the | ||
| * caller's, so `{"__proto__":{…}}` on a `{}` literal set the OUTPUT's prototype: `Object.keys` | ||
| * answered `[]` while `settings[k] ?? fallback` handed a handler the attacker's value for a key | ||
| * that was never sent. Refused by name AND built on a null prototype — the null prototype alone | ||
| * would keep `__proto__` as a silent own key nobody declared. | ||
| */ | ||
| const PROTOTYPE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype']); | ||
| export function recordSchema<S extends AnySchema>( | ||
@@ -234,4 +290,11 @@ values: S, | ||
| const issues: StandardIssue[] = []; | ||
| const out: Record<string, unknown> = {}; | ||
| const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>; | ||
| for (const [key, entry] of Object.entries(value)) { | ||
| if (PROTOTYPE_KEYS.has(key)) { | ||
| issues.push({ | ||
| message: expected(`a record key that is not ${[...PROTOTYPE_KEYS].join(' | ')}`, key), | ||
| path: [...path, key], | ||
| }); | ||
| continue; | ||
| } | ||
| const result = valueCheck(entry, [...path, key]); | ||
@@ -259,2 +322,14 @@ if (result.ok) out[key] = result.value; | ||
| /** | ||
| * The free-function spelling of `schema.refine(...)`, shipped beside `t.refine` for the same | ||
| * reason `nullableSchema` ships beside `t.nullable`: a call site that already holds a schema | ||
| * should not have to reach for the namespace. | ||
| */ | ||
| export function refineSchema<In, Out>( | ||
| schema: Schema<In, Out>, | ||
| refinement: Refinement<Out>, | ||
| ): Schema<In, Out> { | ||
| return schema.refine(refinement); | ||
| } | ||
| function isTimeZone(value: string): boolean { | ||
@@ -295,33 +370,2 @@ try { | ||
| const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue, MoneyValue>( | ||
| { | ||
| kind: 'money', | ||
| description: 'integer minor units plus an ISO 4217 currency code', | ||
| properties: { | ||
| minor: { kind: 'number', integer: true }, | ||
| currency: { kind: 'string', pattern: CURRENCY_RE.source }, | ||
| }, | ||
| }, | ||
| (value, path) => { | ||
| if (!isPlainObject(value)) return fail(path, expected('a Money object', value)); | ||
| const minor = value['minor']; | ||
| const currency = value['currency']; | ||
| const issues: StandardIssue[] = []; | ||
| if (typeof minor !== 'number' || !Number.isInteger(minor)) { | ||
| issues.push({ | ||
| message: expected('an integer number of minor units', minor), | ||
| path: [...path, 'minor'], | ||
| }); | ||
| } | ||
| if (typeof currency !== 'string' || !CURRENCY_RE.test(currency)) { | ||
| issues.push({ | ||
| message: expected('a 3-letter ISO 4217 code', currency), | ||
| path: [...path, 'currency'], | ||
| }); | ||
| } | ||
| if (issues.length > 0) return failWith(issues); | ||
| return pass({ minor: minor as number, currency: currency as string }); | ||
| }, | ||
| ); | ||
| /** The shape a schema provider must implement to back `t`. */ | ||
@@ -352,2 +396,9 @@ export interface TNamespace { | ||
| ): Schema<InferInput<S[number]>, InferOutput<S[number]>>; | ||
| /** A union routed by one literal key: one branch's issues on failure, not every branch's. */ | ||
| discriminatedUnion<S extends readonly [AnySchema, ...AnySchema[]]>( | ||
| discriminant: string, | ||
| ...members: S | ||
| ): Schema<InferInput<S[number]>, InferOutput<S[number]>>; | ||
| /** A rule the IR cannot state structurally — cross-field, cross-row, or arithmetic. */ | ||
| refine<In, Out>(schema: Schema<In, Out>, refinement: Refinement<Out>): Schema<In, Out>; | ||
| record<S extends AnySchema>( | ||
@@ -397,2 +448,4 @@ values: S, | ||
| union: unionSchema, | ||
| discriminatedUnion: discriminatedUnionSchema, | ||
| refine: refineSchema, | ||
| record: recordSchema, | ||
@@ -399,0 +452,0 @@ nullable: nullableSchema, |
99567
72.84%17
30.77%1956
45%152
50.5%