@ultimat3/schema 5.0.0 → 6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "5.0.0",
3
+ "version": "6.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",
@@ -0,0 +1,51 @@
1
+ // Single responsibility: is a string an IANA zone NAME? Tier 0's second statement of the one rule
2
+ // `@ultimat3/time` enforces everywhere above it — a zone is `Area/Location`, and `UTC` is the one
3
+ // exception. It is stated a third time because `schema` is tier 0 and may import neither
4
+ // `@ultimat3/time` nor `@ultimat3/core`; `packages/time/src/zone-canonical.ts` is where the rule
5
+ // and its reasoning are written down.
6
+
7
+ /**
8
+ * A LEADING sign is a fixed offset, which carries no DST rules. `Etc/GMT+2` keeps its `+`.
9
+ *
10
+ * Unobservable on ICU 78 — `+01:00` resolves to itself, so the slash rule below already refuses it,
11
+ * and deleting this line changes no answer this package can currently produce. It stays because it
12
+ * guards the runtime that folds an offset into `Etc/GMT-1`, which WOULD carry a slash, and because
13
+ * `packages/core/src/time-zone-name.ts` and `packages/time/src/zone-canonical.ts` both carry the
14
+ * same line: three statements of one rule may not differ, least of all in the half that is hard to
15
+ * test.
16
+ */
17
+ const NUMERIC_OFFSET = /^[+-]/;
18
+
19
+ /**
20
+ * Structural, and never delegated to `Intl` — the reasoning, and why a denylist is not the
21
+ * alternative, is `packages/time/src/zone-canonical.ts`'s and is not re-derived here. What this
22
+ * file enforces is that same rule at the EDGE: an identifier is `Area/Location`, `UTC` is the one
23
+ * legal exception, and a leading sign is an offset rather than a name.
24
+ *
25
+ * `@ultimat3/core`'s `isIanaZoneName` is the same predicate for `app.config.ts`. Nothing below
26
+ * tier 5 can compare the two, so `timezone-validator-pin.test.ts` in `@ultimat3/cli` is the
27
+ * mechanical half — the arrangement `schema-error-codes-pin.test.ts` already uses for tier 0's
28
+ * other deliberate duplicate.
29
+ *
30
+ * Of the three copies this is the one that guards CALLER input: `t.timezone` is a request body
31
+ * field and a query parameter through `coerceQuery`, so the bare `new Intl.DateTimeFormat(…)`
32
+ * probe this replaced accepted `+01:00`, `CET` and `Japan` off the wire — since before 6.0.0 for
33
+ * the offset, which ES2024 `Intl` has always resolved — and handed them to a `format` call that
34
+ * answers `X_TIMEZONE_INVALID` three layers later.
35
+ *
36
+ * The judgement is made on the RESOLVED name, not the input, so a casing (`utc`) and a runtime that
37
+ * folds an alias into its target (`Etc/UTC` → `UTC`) both answer correctly. No cache here: a zone
38
+ * arriving from a caller is bounded and canonicalized by `@ultimat3/time`'s `canonicalTimeZone`,
39
+ * which is where a per-process memo belongs — a `Map` keyed on an unvalidated request value is the
40
+ * unbounded growth `@ultimat3/core`'s `intl-cache.ts` exists to prevent.
41
+ */
42
+ export function isIanaZoneName(value: string): boolean {
43
+ if (value === '' || NUMERIC_OFFSET.test(value)) return false;
44
+ try {
45
+ const resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions()
46
+ .timeZone;
47
+ return resolved === 'UTC' || resolved.includes('/');
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
package/src/validators.ts CHANGED
@@ -23,6 +23,7 @@ import { isZonelessDateTime } from './iso-date';
23
23
  import { type MoneyValue, moneySchema } from './money-value';
24
24
  import type { SchemaNode } from './node';
25
25
  import type { InferInput, InferOutput, StandardIssue } from './standard';
26
+ import { isIanaZoneName } from './time-zone-name';
26
27
 
27
28
  const EMAIL_RE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
28
29
  const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -334,15 +335,6 @@ export function refineSchema<In, Out>(
334
335
  return schema.refine(refinement);
335
336
  }
336
337
 
337
- function isTimeZone(value: string): boolean {
338
- try {
339
- new Intl.DateTimeFormat('en', { timeZone: value });
340
- return true;
341
- } catch {
342
- return false;
343
- }
344
- }
345
-
346
338
  function isLocale(value: string): boolean {
347
339
  try {
348
340
  return Intl.getCanonicalLocales(value).length === 1;
@@ -438,7 +430,7 @@ export const builtinT: TNamespace = Object.freeze({
438
430
  timezone: makeStringSchema(
439
431
  { kind: 'string', format: 'timezone' },
440
432
  'an IANA time zone',
441
- isTimeZone,
433
+ isIanaZoneName,
442
434
  ),
443
435
  locale: makeStringSchema({ kind: 'string', format: 'locale' }, 'a BCP-47 locale', isLocale),
444
436
  slug: makeStringSchema({ kind: 'string', format: 'slug', pattern: SLUG_RE.source }, 'a slug'),