@ultimat3/schema 20.2.1 → 22.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
@@ -139,6 +139,10 @@ Gotchas:
139
139
  - `t.date` refuses a clock time with no offset and no `Z` (`iso-date.ts`): a zone-less string is a
140
140
  different instant per host `TZ`, and `coerceQuery` puts it one query parameter from the wire.
141
141
  A date-only string carries no clock time and is UTC by spec, so it still parses.
142
+ - `t.date` refuses any string that is not ISO-8601 in shape (`isIsoDateTime`, `As of 2026-09-23`,
143
+ 22.0.0): `'March 14, 2026'`, `'3/14/2026'` and `'12'` all parsed at the host's LOCAL midnight.
144
+ `iso-date.test.ts` runs the refusal set under two `TZ` values in subprocesses and requires one
145
+ answer. A number (epoch ms) still passes — it names an instant on every host.
142
146
  - Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit.
143
147
  - **`ToJsonSchemaOptions.dialect` is a closed vocabulary read with `Object.hasOwn`** (`As of
144
148
  2026-09-06`). `DIALECTS[dialect]` on an object literal answered the `Object` FUNCTION for
package/README.md CHANGED
@@ -132,6 +132,18 @@ This package is tier 0 and cannot import `@ultimat3/core`, so `SchemaError` repr
132
132
  `isUltimateError()` from core matches it. Register the codes once from any package that imports
133
133
  both: `registerErrorCodes(SCHEMA_ERROR_CODES)`.
134
134
 
135
+ ### Error classes
136
+
137
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
138
+ a job boundary the class is gone and the `code` is what survives — match on that.
139
+
140
+ | Class | Code | Declared in |
141
+ |---|---|---|
142
+ | `DiscriminantInvalidError` (extends `SchemaError`) | `X_SCHEMA_DISCRIMINANT_INVALID` | `src/errors.ts` |
143
+ | `SchemaError` | any schema code; the base of the three that extend it. Extends `Error`, not core's `UltimateError` — schema imports nothing — and carries the same `Symbol.for('ultimate.error')` brand so `isUltimateError` answers `true` | `src/errors.ts` |
144
+ | `SchemaUnsupportedError` (extends `SchemaError`) | `X_SCHEMA_UNSUPPORTED` | `src/errors.ts` |
145
+ | `ValidationFailedError` (extends `SchemaError`) | `X_VALIDATION_FAILED` | `src/errors.ts` |
146
+
135
147
  ## Swapping the library
136
148
 
137
149
  The builtin validators (`validators.ts`) are the shipped default — small, dependency-free, no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "20.2.1",
3
+ "version": "22.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",
package/src/coerce.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // HTTP layer has strings that "mean" numbers. Actions, jobs and MCP calls receive real JSON and
3
3
  // must never get this leniency.
4
4
 
5
- import { isZonelessDateTime } from './iso-date';
5
+ import { isIsoDateTime } from './iso-date';
6
6
  import type { SchemaNode } from './node';
7
7
  import { tryIntrospect } from './provider';
8
8
 
@@ -60,7 +60,9 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
60
60
  // Returned untouched rather than converted, this function's standing contract: converting
61
61
  // it resolves a zone-less clock time through the CONTAINER's `TZ`, so `?at=2026-08-19T10:00`
62
62
  // meant a different instant per pod. Validation states the refusal.
63
- if (isZonelessDateTime(raw)) return raw;
63
+ // Non-ISO text (`3/14/2026`) is left untouched for the same reason: `new Date` reads it at
64
+ // the host's local midnight.
65
+ if (!isIsoDateTime(raw)) return raw;
64
66
  const parsed = new Date(raw);
65
67
  return Number.isNaN(parsed.getTime()) ? raw : parsed;
66
68
  }
package/src/index.ts CHANGED
@@ -40,6 +40,8 @@ export {
40
40
  ULTIMATE_ERROR_BRAND,
41
41
  ValidationFailedError,
42
42
  } from './errors';
43
+ // Exported for `@ultimat3/time`'s `fromIso`: one ISO-shape rule, whichever door a date comes in by.
44
+ export { isIsoDateTime } from './iso-date';
43
45
  export type {
44
46
  JsonSchema,
45
47
  JsonSchemaDialect,
package/src/iso-date.ts CHANGED
@@ -1,6 +1,6 @@
1
- // Single responsibility: the one rule deciding whether a date-time STRING names an instant on its
2
- // own. Its own file because both the validator and the HTTP coercion have to answer identically —
3
- // two copies of this pair is how the wire path and the validation path came to disagree.
1
+ // Single responsibility: the one rule deciding whether a STRING is an ISO-8601 date or date-time
2
+ // that names an instant on its own. Its own file because the validator, the HTTP coercion and
3
+ // `@ultimat3/time`'s `fromIso` have to answer identically — two copies is how they came to disagree.
4
4
 
5
5
  /**
6
6
  * A clock time, and the zone it is stated in. `2026-08-19T10:00` carries the first and not the
@@ -10,14 +10,27 @@
10
10
  * failing at the parse end rather than the format end.
11
11
  *
12
12
  * A date-only form carries no clock time and is UTC by specification, so it is not this.
13
- *
14
- * The same pair `@ultimat3/time`'s `fromIso` refuses on, character for character: one wire value,
15
- * one rule, whichever door it comes through.
16
13
  */
17
14
  const CLOCK_TIME = /[t ]\d{1,2}:\d{2}/i;
18
15
  const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
19
16
 
17
+ /**
18
+ * The ISO-8601 shape, and nothing else. `new Date` also parses `'March 14, 2026'`, `'3/14/2026'`
19
+ * and `'12'` — each at the host's LOCAL midnight, so the instant depended on `TZ`. Case-insensitive
20
+ * because RFC 3339 §5.6 permits a lowercase `t` and `z`.
21
+ */
22
+ const ISO_SHAPE =
23
+ /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/i;
24
+
20
25
  /** What a `t.date` string must not be: a clock time with no offset and no `Z`. */
21
26
  export function isZonelessDateTime(value: string): boolean {
22
27
  return CLOCK_TIME.test(value) && !UTC_OFFSET.test(value);
23
28
  }
29
+
30
+ /**
31
+ * True when `value` is an ISO-8601 date, or date-time carrying `Z` or an offset: the only strings
32
+ * whose instant is the same on every host. Check it before `new Date(value)`, every time.
33
+ */
34
+ export function isIsoDateTime(value: string): boolean {
35
+ return ISO_SHAPE.test(value) && !isZonelessDateTime(value);
36
+ }
package/src/validators.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  import { charCount } from './char-count';
21
21
  import { expected } from './describe-value';
22
22
  import { discriminatedUnionSchema } from './discriminated-union';
23
- import { isZonelessDateTime } from './iso-date';
23
+ import { isIsoDateTime, isZonelessDateTime } from './iso-date';
24
24
  import { type MoneyValue, moneySchema } from './money-value';
25
25
  import type { SchemaNode } from './node';
26
26
  import { isPrototypeKey, PROTOTYPE_KEYS } from './prototype-keys';
@@ -370,6 +370,11 @@ const dateSchema: Schema<Date | string | number, Date> = makeSchema<Date | strin
370
370
  if (typeof value === 'string' && isZonelessDateTime(value)) {
371
371
  return fail(path, expected('an ISO-8601 date-time with an offset or Z', value));
372
372
  }
373
+ // `new Date` also parses `'March 14, 2026'` and `'12'` at the host's local midnight, so the
374
+ // shape is checked first: a string that is not ISO-8601 is refused, not guessed at.
375
+ if (typeof value === 'string' && !isIsoDateTime(value)) {
376
+ return fail(path, expected('an ISO-8601 date-time', value));
377
+ }
373
378
  const parsed = new Date(value);
374
379
  return Number.isNaN(parsed.getTime())
375
380
  ? fail(path, expected('an ISO-8601 date-time', value))