@ultimat3/schema 5.0.1 → 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 +1 -1
- package/src/time-zone-name.ts +51 -0
- package/src/validators.ts +2 -10
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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'),
|