@ultimat3/schema
Advanced tools
| // Single responsibility: the one rule deciding whether a date-time STRING names an instant on its | ||
| // own. Its own file because both the validator and the HTTP coercion have to answer identically — | ||
| // two copies of this pair is how the wire path and the validation path came to disagree. | ||
| /** | ||
| * A clock time, and the zone it is stated in. `2026-08-19T10:00` carries the first and not the | ||
| * second, so `new Date` resolves it through the HOST process's zone: the same wire value is | ||
| * `14:00Z` on a `TZ=America/New_York` pod and `10:00Z` on a `TZ=UTC` one, from one request. | ||
| * That is the framework's "no date without an explicit zone, no ambient default anywhere" rule | ||
| * failing at the parse end rather than the format end. | ||
| * | ||
| * A date-only form carries no clock time and is UTC by specification, so it is not this. | ||
| * | ||
| * The same pair `@ultimat3/time`'s `fromIso` refuses on, character for character: one wire value, | ||
| * one rule, whichever door it comes through. | ||
| */ | ||
| const CLOCK_TIME = /[t ]\d{1,2}:\d{2}/i; | ||
| const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i; | ||
| /** What a `t.date` string must not be: a clock time with no offset and no `Z`. */ | ||
| export function isZonelessDateTime(value: string): boolean { | ||
| return CLOCK_TIME.test(value) && !UTC_OFFSET.test(value); | ||
| } |
+11
-1
@@ -18,3 +18,5 @@ # @ultimat3/schema — agent notes | ||
| `describe-value → node → builder → money-value → validators → discriminated-union → provider → t`. | ||
| `standard.ts` and `errors.ts` depend on nothing but each other. | ||
| `standard.ts` and `errors.ts` depend on nothing but each other. `iso-date.ts` imports nothing and | ||
| is imported by `validators.ts` and `coerce.ts` — the two doors a `t.date` string comes through, so | ||
| the rule that a clock time must carry an offset or `Z` has one copy, not one per door. | ||
@@ -79,2 +81,10 @@ **An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`, | ||
| - Unknown object keys are dropped by design; JSON Schema says `additionalProperties: false`. | ||
| - **An object parse reads every declared field with `Object.hasOwn` and answers a null-prototype | ||
| object** — the same two halves `recordSchema` has. A raw `value[key]` read `toString` off the | ||
| PROTOTYPE, so a field named after one was unsatisfiable for every input and its `.default()` | ||
| never fired; a `{}` output let a declared `__proto__` field write through the setter. Never | ||
| reintroduce either half, and never assume a parsed object has `Object.prototype` on it. | ||
| - `t.date` refuses a clock time with no offset and no `Z` (`iso-date.ts`): a zone-less string is a | ||
| different instant per host `TZ`, and `coerceQuery` puts it one query parameter from the wire. | ||
| A date-only string carries no clock time and is UTC by spec, so it still parses. | ||
| - Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit. | ||
@@ -81,0 +91,0 @@ - **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind` |
+1
-1
| { | ||
| "name": "@ultimat3/schema", | ||
| "version": "3.0.0", | ||
| "version": "4.0.0", | ||
| "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+5
-0
@@ -5,2 +5,3 @@ // Single responsibility: HTTP-boundary coercion. Kept out of validation on purpose — only the | ||
| import { isZonelessDateTime } from './iso-date'; | ||
| import type { SchemaNode } from './node'; | ||
@@ -60,2 +61,6 @@ import { tryIntrospect } from './provider'; | ||
| if (typeof raw !== 'string') return raw; | ||
| // Returned untouched rather than converted, this function's standing contract: converting | ||
| // it resolves a zone-less clock time through the CONTAINER's `TZ`, so `?at=2026-08-19T10:00` | ||
| // meant a different instant per pod. Validation states the refusal. | ||
| if (isZonelessDateTime(raw)) return raw; | ||
| const parsed = new Date(raw); | ||
@@ -62,0 +67,0 @@ return Number.isNaN(parsed.getTime()) ? raw : parsed; |
+20
-2
@@ -22,2 +22,3 @@ // Single responsibility: the builtin, dependency-free validators behind `t`. Small on purpose — | ||
| import { discriminatedUnionSchema } from './discriminated-union'; | ||
| import { isZonelessDateTime } from './iso-date'; | ||
| import { type MoneyValue, moneySchema } from './money-value'; | ||
@@ -176,5 +177,15 @@ import type { SchemaNode } from './node'; | ||
| const issues: StandardIssue[] = []; | ||
| const out: Record<string, unknown> = {}; | ||
| // `Object.create(null)`, for `recordSchema`'s reason one screen down: on a `{}` literal | ||
| // `out['__proto__'] = value` hits the `Object.prototype` SETTER, so a DECLARED `__proto__` | ||
| // field parsed to `{}` whose prototype was the caller's object — every key of it then read | ||
| // back off a value nobody sent. | ||
| const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>; | ||
| for (const [key, memberCheck] of checks) { | ||
| const result = memberCheck(value[key], [...path, key]); | ||
| // `Object.hasOwn`, never a raw index — the guard `coerce.ts` already applies to the same | ||
| // read. `value['toString']` answered the INHERITED function for an input that carried no | ||
| // such key, so a field named after a prototype member was unsatisfiable for every input, | ||
| // its `.default()` could never fire, and the HTTP path and this one disagreed about what | ||
| // the caller sent. | ||
| const raw = Object.hasOwn(value, key) ? value[key] : undefined; | ||
| const result = memberCheck(raw, [...path, key]); | ||
| if (result.ok) { | ||
@@ -354,2 +365,9 @@ // Unknown keys are dropped, never forwarded — no mass assignment through an action. | ||
| if (typeof value === 'string' || typeof value === 'number') { | ||
| // Enforced, not documented: the node publishes `format: 'date-time'` and this refusal has | ||
| // always said ISO-8601, while `new Date` resolved a zone-less clock time through the host | ||
| // process's zone — reachable from the wire, since `coerceQuery` routes a `t.date` field | ||
| // here and a container's `TZ` then decided which instant a query parameter meant. | ||
| if (typeof value === 'string' && isZonelessDateTime(value)) { | ||
| return fail(path, expected('an ISO-8601 date-time with an offset or Z', value)); | ||
| } | ||
| const parsed = new Date(value); | ||
@@ -356,0 +374,0 @@ return Number.isNaN(parsed.getTime()) |
104592
4.03%18
5.88%2012
2.24%