@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 +4 -0
- package/README.md +12 -0
- package/package.json +1 -1
- package/src/coerce.ts +4 -2
- package/src/index.ts +2 -0
- package/src/iso-date.ts +19 -6
- package/src/validators.ts +6 -1
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
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 {
|
|
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
|
-
|
|
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
|
|
2
|
-
// own. Its own file because
|
|
3
|
-
//
|
|
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))
|