@ultimat3/core 17.0.0 → 18.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 +31 -18
- package/README.md +12 -0
- package/package.json +5 -3
- package/src/config-pwa.ts +118 -0
- package/src/config.ts +74 -25
- package/src/error-render.ts +10 -53
- package/src/index.ts +5 -2
- package/src/intl-cache.ts +47 -1
- package/src/schema-error-codes.ts +27 -25
- package/src/time-zone-name.ts +13 -42
package/CLAUDE.md
CHANGED
|
@@ -77,17 +77,28 @@ It is not a general sanitiser: a cause is prose and keeps its quotes,
|
|
|
77
77
|
its backslashes and its percent signs — only the control range is touched. Line breaks are the
|
|
78
78
|
structural half; the rest of C0 and DEL ride along because a terminal reads a raw `\u001b` as an ANSI
|
|
79
79
|
escape, so a cause could repaint the screen or hide the line above it. `@ultimat3/schema` carries a
|
|
80
|
-
deliberate duplicate
|
|
81
|
-
`single-line-pin.test.ts`
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
deliberate duplicate — `schema -> core` stays forbidden, so that direction cannot be collapsed —
|
|
81
|
+
pinned behaviourally by `single-line-pin.test.ts`, which lives HERE `As of 2026-08-27`, at the tier
|
|
82
|
+
the invariant belongs to, and pins `ERROR_DOCS_URL` and the brand key beside it.
|
|
83
|
+
|
|
84
|
+
**`core -> schema` is a declared edge `As of 2026-08-27`, and FIVE copies went with it.**
|
|
85
|
+
`describeValue`, `charCount`, `CURRENCY_CODE_PATTERN`, `SCHEMA_ERROR_CODES` and `isIanaZoneName`
|
|
86
|
+
were all restated here because both packages are tier 0 and neither could import the other; they
|
|
87
|
+
were held equal by 394 lines of pin test in `@ultimat3/cli`, a TIER-5 package pinning a tier-0
|
|
88
|
+
invariant that no rule required to exist. `describeValue` is the one that made it urgent — it
|
|
89
|
+
prints INSTEAD of a rejected password, so the safety property of the framework's most
|
|
90
|
+
security-sensitive renderer rested on a 63-line behavioural pin at tier 5. `error-render.ts`
|
|
91
|
+
re-exports schema's now, and the four pin files are deleted.
|
|
92
|
+
|
|
93
|
+
The edge cost was MEASURED before it was declared, because axiom 6 makes it a measurement and not
|
|
94
|
+
an argument — `docs/architecture/01-package-map.md` carries the table. Short version: the edge
|
|
95
|
+
alone TRIPLED a core-only browser chunk (6,362 → 19,018 B), because importing schema's barrel with
|
|
96
|
+
no `sideEffects` field forces a bundler to keep every module it reaches; `@ultimat3/schema` now
|
|
97
|
+
declares `sideEffects: false`, which `bun run side-effects` had already measured as true of it, and
|
|
98
|
+
the cost drops to ~1 kB — while `moneyText` from `@ultimat3/ui`, which always carried schema, comes
|
|
99
|
+
out 7.8 kB SMALLER.
|
|
100
|
+
|
|
101
|
+
**A string's length is CODE POINTS, `As of 2026-08-22`**
|
|
91
102
|
— `validators.ts` rejects in that unit and `json-schema.ts` publishes `minLength` in it, so
|
|
92
103
|
`.length` made `t.string.min(3).safeParse('👍a')` say "at least 3 chars, received a string of 3
|
|
93
104
|
characters". The rule it enforces: a `cause` reaches the log index AND the
|
|
@@ -125,7 +136,7 @@ shape against a locally declared sample interface for exactly that reason.
|
|
|
125
136
|
| the five above, composed into one typed-client call | `client-flight.ts` + `client-wire.ts` | `@ultimat3/action` and `@ultimat3/query` both project a typed client and are both tier 3, so neither could import the other: it shipped as a byte-identical 288-line + 85-line copy in each, policed by a `client-twin.test.ts` in both. Both packages re-export these names, so their public surface is unchanged. Declares NO code of its own — `X_SUPERSEDED`, `X_TIMEOUT` and `X_FLIGHT_GATE_OVERLOADED` are already here |
|
|
126
137
|
| is this `unknown` a keyed record? | `json-object.ts` | `isJsonObject`, which was the same three terms in both those packages' `stable.ts`. A `Date` and a class instance PASS: it narrows a shape, it does not certify provenance |
|
|
127
138
|
| a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one way out, on purpose greppable |
|
|
128
|
-
| an `Intl` formatter cache, and the screen in front of it | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `assertLocale`, `MAX_CACHED_FORMATTERS`) | a locale and a zone arrive from a request header, so the tag must be REFUSED when it is not a tag (`X_LOCALE_INVALID`), the key must be canonical AND the cache bounded — never a second copy of any of the three |
|
|
139
|
+
| an `Intl` formatter cache, and the screen in front of it | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `assertLocale`, `MAX_CACHED_FORMATTERS`, `MAX_LOCALE_EXCERPT`) | a locale and a zone arrive from a request header, so the tag must be REFUSED when it is not a tag (`X_LOCALE_INVALID`), the key must be canonical AND the cache bounded — never a second copy of any of the three. The refusal is bounded too: the `cause` quotes back at most `MAX_LOCALE_EXCERPT` (35, RFC 5646 §4.4.1) code points and says so when it cut, and the whole tag rides in `meta.locale` — a `cause` reaches the 400 body and the log line, where a value with no key has nothing a redactor can address |
|
|
129
140
|
| the committed encrypted values | `secrets.ts` (envelope) + `secrets-store.ts` (files, `installSecrets`) | plaintext is a flat map of ENV NAMES; there is no `secrets.get()` |
|
|
130
141
|
|
|
131
142
|
`installSecrets()` is the ONLY path from `secrets.enc.json` to an app value, and it lands in
|
|
@@ -162,12 +173,14 @@ the master key) purely so *wrong key* and *edited file* are two codes and not on
|
|
|
162
173
|
cannot tell them apart.
|
|
163
174
|
|
|
164
175
|
`schema-error-codes.ts` is the same shape a second time, for codes this package does not even own.
|
|
165
|
-
`@ultimat3/schema` is tier 0 like `core` and
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
`@ultimat3/schema` is tier 0 like `core` and cannot call `registerErrorCodes()` itself — that would
|
|
177
|
+
be `schema -> core`, which stays forbidden. So core READS `SCHEMA_ERROR_CODES` over the declared
|
|
178
|
+
edge and registers it unconditionally at import time, and any process that imports core (not just
|
|
179
|
+
`@ultimat3/cli`, which used to be the only registrant) renders schema's real titles. The retry
|
|
180
|
+
classification is derived from the same set rather than typed out beside it: a fifth code would
|
|
181
|
+
have been silently absent from a hand-written list, and an unregistered code reads as UNCLASSIFIED,
|
|
182
|
+
so a schema refusal inside a job body burns the whole retry policy re-proving an answer no attempt
|
|
183
|
+
can change.
|
|
171
184
|
|
|
172
185
|
`timing-safe-equal.ts` holds the one constant-time string comparison `@ultimat3/auth` and
|
|
173
186
|
`@ultimat3/storage` both need — core is the lowest tier both can reach, so the shared code lives
|
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
30
30
|
| the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
|
|
31
31
|
| the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
|
|
32
32
|
| `defineConfig()` for `app.config.ts` | `config.ts` |
|
|
33
|
+
| the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
|
|
33
34
|
| the closed route vocabulary every renderer names | `route-vocabulary.ts` |
|
|
34
35
|
| runtime roles + `ROLE` resolution | `roles.ts` |
|
|
35
36
|
| `Clock` — the only source of "now" | `clock.ts` |
|
|
@@ -487,6 +488,17 @@ string, and the cap alone lets one locale evict itself under three spellings. A
|
|
|
487
488
|
`Intl` construction, never a wrong answer, which is what makes the bound safe. It lives here rather
|
|
488
489
|
than in `@ultimat3/time` because `@ultimat3/money` needs it too and tier 1 may not import sideways.
|
|
489
490
|
|
|
491
|
+
The **refusal** is bounded for the same reason the cache is. `X_LOCALE_INVALID` answers 400 and
|
|
492
|
+
`@ultimat3/http`'s `toProblem` copies a `cause` into the response `detail` **and** into the log
|
|
493
|
+
line, so a tag echoed verbatim is a stranger-chosen string of unbounded length in a shared log
|
|
494
|
+
index. `localeInvalid` quotes back the first `MAX_LOCALE_EXCERPT` (35) code points — RFC 5646
|
|
495
|
+
§4.4.1's own figure for the longest tag the registry can form — and appends
|
|
496
|
+
`(truncated at 35 characters)` when there were more, so a cut value is never read as a whole one.
|
|
497
|
+
`describeValue` is deliberately **not** used here: "a 5-character string" deletes the only
|
|
498
|
+
actionable content in a sentence whose job is to name the tag. The whole tag rides in
|
|
499
|
+
`meta.locale`, which is the half that gives a redactor a key to address; a value spliced into
|
|
500
|
+
prose has none.
|
|
501
|
+
|
|
490
502
|
## One flight layer — wait, classify, share, bound, fence
|
|
491
503
|
|
|
492
504
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "18.0.0",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,11 +30,13 @@
|
|
|
30
30
|
"LICENSE"
|
|
31
31
|
],
|
|
32
32
|
"engines": {
|
|
33
|
-
"bun": ">=1.
|
|
33
|
+
"bun": ">=1.4.0"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
37
37
|
"test": "bun test"
|
|
38
38
|
},
|
|
39
|
-
"dependencies": {
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@ultimat3/schema": "18.0.0"
|
|
41
|
+
}
|
|
40
42
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Single responsibility: the `pwa` block of `app.config.ts` — what an app must say before a
|
|
2
|
+
// browser will offer to install it, and the boot-time refusal when it has not.
|
|
3
|
+
//
|
|
4
|
+
// SPLIT OUT OF `config.ts` because that file reached its 500-line ceiling, and this is the seam
|
|
5
|
+
// that costs nothing to cross: `enabled` turns four other requirements on, so the shape, the screen
|
|
6
|
+
// and the remedy are one subject. `config.ts` keeps the one call.
|
|
7
|
+
//
|
|
8
|
+
// WHY THE SCREEN IS HERE AND NOT AT EMIT. `@ultimat3/pwa`'s `generateWebManifest` refuses a blank
|
|
9
|
+
// title too, and that refusal arrives at `x build` — which is the wrong moment: an agent that ran
|
|
10
|
+
// `x dev`, saw a served app and shipped it would meet the missing title in CI. Two checks, two
|
|
11
|
+
// subjects (a config file, and a function argument a library caller supplies directly).
|
|
12
|
+
|
|
13
|
+
import { describeValue } from './error-render';
|
|
14
|
+
// `app.config.ts` CONSUMES the route vocabulary; it does not own it — `config.ts`'s rule, and the
|
|
15
|
+
// reason `OfflineStrategy` is imported rather than restated.
|
|
16
|
+
import type { OfflineStrategy } from './route-vocabulary';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
|
|
20
|
+
* real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
|
|
21
|
+
* scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
|
|
22
|
+
*/
|
|
23
|
+
export interface PwaConfig {
|
|
24
|
+
readonly enabled: boolean;
|
|
25
|
+
readonly offline: OfflineStrategy;
|
|
26
|
+
readonly backgroundSync: boolean;
|
|
27
|
+
readonly push: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The install title, and the one manifest member nothing can derive. `AppConfig.name` is a slug
|
|
30
|
+
* (`^[a-z][a-z0-9-]{1,63}$`) and an install prompt shows a person a title, so `ledger-demo` is
|
|
31
|
+
* the wrong answer rather than a rough one. Required once `enabled` is true; `''` is what a
|
|
32
|
+
* disabled block resolves to.
|
|
33
|
+
*/
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/**
|
|
36
|
+
* `theme_color` and `background_color`, per scheme. Required once `enabled` is true, and
|
|
37
|
+
* `undefined` otherwise — never a colour the framework picked. There is no defensible default:
|
|
38
|
+
* an install splash painted in a colour nobody chose is a wrong-looking app that boots, which is
|
|
39
|
+
* worse than a boot that names the four values it needs.
|
|
40
|
+
*/
|
|
41
|
+
readonly colors: PwaColors | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The install chrome's two colours for one scheme, as CSS colour strings.
|
|
46
|
+
*
|
|
47
|
+
* ONE OF THE TWO PLACES A RAW COLOUR IS LEGAL, alongside `ThemeConfig.tokens` one section up, and
|
|
48
|
+
* for a stronger reason than that one has: a browser paints the install splash and the address bar
|
|
49
|
+
* from these before a single stylesheet has loaded, so there is no token to resolve them against
|
|
50
|
+
* and no component anywhere in the loop.
|
|
51
|
+
*/
|
|
52
|
+
export interface PwaSchemeColors {
|
|
53
|
+
readonly themeColor: string;
|
|
54
|
+
readonly backgroundColor: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Both schemes, because a web manifest carries exactly one `theme_color` and `<head>` carries a
|
|
59
|
+
* media-scoped `<meta name="theme-color">` per scheme. One value would make the dark answer the
|
|
60
|
+
* light one's, on the surface a reader sees before the app has painted anything.
|
|
61
|
+
*/
|
|
62
|
+
export interface PwaColors {
|
|
63
|
+
readonly light: PwaSchemeColors;
|
|
64
|
+
readonly dark: PwaSchemeColors;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The two schemes and the two colours `validate` screens, for `INBOX_RETENTION_KEYS`' reason: a
|
|
69
|
+
* third member added to `PwaColors` or `PwaSchemeColors` without a row here is a value an app can
|
|
70
|
+
* leave blank, and `config.test.ts` asserts both lists against the types so the omission is a red
|
|
71
|
+
* test rather than a manifest with an empty `theme_color`.
|
|
72
|
+
*/
|
|
73
|
+
export const PWA_SCHEMES = ['light', 'dark'] as const;
|
|
74
|
+
export const PWA_COLOR_KEYS = ['themeColor', 'backgroundColor'] as const;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Appended only when the `pwa` block is what failed, and it carries the whole block rather than
|
|
78
|
+
* the missing key: `pwa.enabled` is what turns four other requirements on, so a reader who set one
|
|
79
|
+
* boolean needs to see the complete shape and the opt-out in the same line.
|
|
80
|
+
*/
|
|
81
|
+
export const PWA_FIX =
|
|
82
|
+
"in app.config.ts, complete the pwa block: pwa: { enabled: true, offline: 'runtime', name: 'My App', colors: { light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' }, dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' } } } — a browser paints the install splash and the address bar from those four values before any stylesheet loads, so there is nothing for the framework to derive them from; or set pwa.enabled: false";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Every rule `validate` applies to the block, appended to the caller's own issue list. Answers
|
|
86
|
+
* whether IT found anything, so the pwa remedy rides only on a pwa finding: `issues` may already
|
|
87
|
+
* hold a bad locale, and a fix line naming the install block for that is axiom 4 broken.
|
|
88
|
+
*
|
|
89
|
+
* `describeValue` for the reason the retention windows use it — this is where an untyped config
|
|
90
|
+
* object crosses into the framework, so every value here is `unknown` however the interface types it.
|
|
91
|
+
*/
|
|
92
|
+
export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
|
|
93
|
+
if (!pwa.enabled) return false;
|
|
94
|
+
const before = issues.length;
|
|
95
|
+
const { name, colors } = pwa;
|
|
96
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
97
|
+
issues.push(`pwa.name is required when pwa.enabled is true, and is ${describeValue(name)}`);
|
|
98
|
+
}
|
|
99
|
+
// `typeof !== 'object' || null`, never `=== undefined`: an untyped `app.config.ts` writing
|
|
100
|
+
// `pwa.colors: null` reached `null[scheme]` one line down and took the boot out with a native
|
|
101
|
+
// `TypeError`, from the validator whose whole job is producing an instruction instead of one.
|
|
102
|
+
if (colors === null || typeof colors !== 'object') {
|
|
103
|
+
issues.push(
|
|
104
|
+
`pwa.colors is required when pwa.enabled is true, for both light and dark, and is ${describeValue(colors)}`,
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
for (const scheme of PWA_SCHEMES) {
|
|
108
|
+
for (const key of PWA_COLOR_KEYS) {
|
|
109
|
+
const value: unknown = colors[scheme]?.[key];
|
|
110
|
+
if (typeof value === 'string' && value.trim() !== '') continue;
|
|
111
|
+
issues.push(
|
|
112
|
+
`pwa.colors.${scheme}.${key} must be a CSS colour, not ${describeValue(value)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return issues.length > before;
|
|
118
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
// defaults, validated eagerly, and composable so a big app can split it across `config/*.ts`
|
|
3
3
|
// without inventing a second config mechanism.
|
|
4
4
|
|
|
5
|
+
import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
|
|
5
6
|
// Same rule for the same reason: `app.config.ts` CONSUMES the cache tier names, it does not own
|
|
6
7
|
// them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
|
|
7
8
|
// drift into two vocabularies with no map between them (issue #293).
|
|
8
9
|
import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
|
|
10
|
+
import type { PwaConfig } from './config-pwa';
|
|
11
|
+
import { PWA_FIX, pwaIssues } from './config-pwa';
|
|
12
|
+
import { describeValue } from './error-render';
|
|
9
13
|
import { ConfigInvalidError } from './errors';
|
|
10
14
|
import { ROLES, type Role } from './roles';
|
|
11
|
-
// `app.config.ts` CONSUMES the route vocabulary; it does not own it. Declaring `OfflineStrategy`
|
|
12
|
-
// here is what made it copyable — `render`, `manifest` and `pwa` each wrote their own rather than
|
|
13
|
-
// import a name that reads like a config key.
|
|
14
|
-
import type { OfflineStrategy } from './route-vocabulary';
|
|
15
15
|
import { isIanaZoneName } from './time-zone-name';
|
|
16
16
|
|
|
17
17
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
@@ -41,17 +41,37 @@ export interface AuthConfig {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* Retention for `x_notify_inbox`, and the one framework table whose window the framework may not
|
|
45
|
+
* pick. Every other one holds bookkeeping whose job ENDS — an idempotency key, a rate-limit
|
|
46
|
+
* bucket, an auth challenge, a delivery claim — and a sweep is unambiguously right. An inbox row
|
|
47
|
+
* is a message a person has not read yet, so when it disappears is a product decision, which is
|
|
48
|
+
* axiom 8: Ultimate ships mechanism, your app ships convention.
|
|
49
|
+
*
|
|
50
|
+
* BOTH DEFAULT TO `undefined`, meaning never swept, and that is the only safe default here: the
|
|
51
|
+
* failure mode of keeping rows is a table that grows, and the failure mode of guessing a number is
|
|
52
|
+
* a notification the recipient never got to read.
|
|
53
|
+
*
|
|
54
|
+
* TWO WINDOWS RATHER THAN ONE, because the axiom-8 objection is only about UNREAD messages. An app
|
|
55
|
+
* that wants read notices gone in a month and unread ones kept forever must be able to say exactly
|
|
56
|
+
* that, and `SQL_NOTIFY_INBOX_MARK_READ` already stamps the time that makes it expressible.
|
|
57
|
+
*
|
|
58
|
+
* Milliseconds and not a `DurationInput`: that type lives in `@ultimat3/jobs` (tier 3) and this
|
|
59
|
+
* package is tier 0, which is the same reason `cache.defaultTtlMs` and `jobs.visibilityTimeoutMs`
|
|
60
|
+
* are spelled this way.
|
|
47
61
|
*/
|
|
48
|
-
export interface
|
|
49
|
-
|
|
50
|
-
readonly
|
|
51
|
-
|
|
52
|
-
readonly
|
|
62
|
+
export interface NotifyConfig {
|
|
63
|
+
/** Age of a row's `read_at`, not its `created_at` — a row ages from when it was READ. */
|
|
64
|
+
readonly inboxReadRetentionMs: number | undefined;
|
|
65
|
+
/** Age of an unread row's `created_at`. Setting this deletes messages nobody has read. */
|
|
66
|
+
readonly inboxUnreadRetentionMs: number | undefined;
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Every window `validate` screens, derived from nothing — a third one added to `NotifyConfig`
|
|
71
|
+
* without a row here is a window an app can set to `-1`, and `config.test.ts` asserts the two
|
|
72
|
+
* lists agree so the omission is a red test rather than a silent hole.
|
|
73
|
+
*/
|
|
74
|
+
export const INBOX_RETENTION_KEYS = ['inboxReadRetentionMs', 'inboxUnreadRetentionMs'] as const;
|
|
55
75
|
/**
|
|
56
76
|
* Deliberately thin. `urlEnv`, `poolSize` and `schema` were removed 2026-08 because **nothing
|
|
57
77
|
* read them** — the only reader of any `config.database.*` field in the repo was this file's own
|
|
@@ -166,6 +186,7 @@ export interface AppConfig {
|
|
|
166
186
|
readonly cache: CacheConfig;
|
|
167
187
|
readonly jobs: JobsConfig;
|
|
168
188
|
readonly realtime: RealtimeConfig;
|
|
189
|
+
readonly notify: NotifyConfig;
|
|
169
190
|
readonly ai: AiConfig;
|
|
170
191
|
}
|
|
171
192
|
|
|
@@ -190,6 +211,7 @@ export interface AppConfigInput {
|
|
|
190
211
|
readonly cache?: Input<CacheConfig> | undefined;
|
|
191
212
|
readonly jobs?: Input<JobsConfig> | undefined;
|
|
192
213
|
readonly realtime?: Input<RealtimeConfig> | undefined;
|
|
214
|
+
readonly notify?: Input<NotifyConfig> | undefined;
|
|
193
215
|
readonly ai?: AiConfigInput | undefined;
|
|
194
216
|
}
|
|
195
217
|
|
|
@@ -212,18 +234,13 @@ function section<T extends object>(base: T, patch: Input<T> | undefined): T {
|
|
|
212
234
|
const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
213
235
|
|
|
214
236
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* character-for-character copy in `error-render.ts`.
|
|
221
|
-
*
|
|
222
|
-
* So keep the two identical, and keep the pattern inside the syntax ECMAScript, JSON Schema and
|
|
223
|
-
* POSIX ERE spell identically — a `defaultCurrency` this accepts and `t.money` refuses is an app
|
|
224
|
-
* whose configured currency cannot be written to a row.
|
|
237
|
+
* Built from `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the framework's ONE declaration of what
|
|
238
|
+
* an ISO 4217 code looks like — the same source `isCurrencyCode`, the published OpenAPI `pattern`
|
|
239
|
+
* and `@ultimat3/entity`'s Postgres CHECK all derive from. It was a character-for-character copy
|
|
240
|
+
* here until the `core -> schema` edge was declared (`scripts/lib/tiers.ts`), held equal only by a
|
|
241
|
+
* pin test in `@ultimat3/cli`.
|
|
225
242
|
*/
|
|
226
|
-
const CURRENCY_RE =
|
|
243
|
+
const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
|
|
227
244
|
|
|
228
245
|
function isLocale(value: string): boolean {
|
|
229
246
|
try {
|
|
@@ -241,7 +258,14 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
241
258
|
defaultCurrency: 'USD',
|
|
242
259
|
theme: { defaultMode: 'system', tokens: {} },
|
|
243
260
|
auth: { signInPath: null },
|
|
244
|
-
pwa: {
|
|
261
|
+
pwa: {
|
|
262
|
+
enabled: false,
|
|
263
|
+
offline: 'network-only',
|
|
264
|
+
backgroundSync: false,
|
|
265
|
+
push: false,
|
|
266
|
+
name: '',
|
|
267
|
+
colors: undefined,
|
|
268
|
+
},
|
|
245
269
|
roles: [...ROLES],
|
|
246
270
|
database: { driver: 'postgres', ssl: false },
|
|
247
271
|
cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
|
|
@@ -253,6 +277,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
253
277
|
visibilityTimeoutMs: 30_000,
|
|
254
278
|
},
|
|
255
279
|
realtime: { enabled: false, transport: 'memory', urlEnv: undefined },
|
|
280
|
+
notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
|
|
256
281
|
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
257
282
|
};
|
|
258
283
|
}
|
|
@@ -284,6 +309,8 @@ function validate(config: AppConfig): void {
|
|
|
284
309
|
// Same shape, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']` in an
|
|
285
310
|
// untyped config file reaches here rather than the compiler, and needs the new spelling.
|
|
286
311
|
const tierFix: string[] = [];
|
|
312
|
+
// Same shape again: carried only when an installable app is missing what an install needs.
|
|
313
|
+
const pwaFix: string[] = [];
|
|
287
314
|
|
|
288
315
|
if (!NAME_RE.test(config.name)) {
|
|
289
316
|
issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
|
|
@@ -313,6 +340,27 @@ function validate(config: AppConfig): void {
|
|
|
313
340
|
if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
|
|
314
341
|
issues.push(`realtime.transport "${config.realtime.transport}" requires realtime.urlEnv`);
|
|
315
342
|
}
|
|
343
|
+
// BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
|
|
344
|
+
// only other legal one is a positive, finite count of milliseconds. Zero is refused rather than
|
|
345
|
+
// read as "immediately": a sweep at age 0 deletes every row the instant it is written, which is
|
|
346
|
+
// an inbox that silently receives nothing, and nobody types it on purpose. `describeValue`
|
|
347
|
+
// rather than `${…}`: this validator is the boundary a JS app's untyped config crosses, so the
|
|
348
|
+
// value here is `unknown` in practice however the interface types it.
|
|
349
|
+
for (const key of INBOX_RETENTION_KEYS) {
|
|
350
|
+
const ms: unknown = config.notify[key];
|
|
351
|
+
if (ms === undefined) continue;
|
|
352
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) {
|
|
353
|
+
issues.push(
|
|
354
|
+
`notify.${key} must be a positive number of milliseconds, not ${describeValue(ms)}`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// What an install needs, asked at BOOT and not at emit — `config-pwa.ts` owns the rules and the
|
|
360
|
+
// remedy, because `pwa.enabled` turning four other requirements on is a question about that block
|
|
361
|
+
// and nothing else here.
|
|
362
|
+
if (pwaIssues(config.pwa, issues)) pwaFix.push(PWA_FIX);
|
|
363
|
+
|
|
316
364
|
// A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
|
|
317
365
|
// index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
|
|
318
366
|
// So an unknown tier is refused at boot rather than silently ignored or silently placed first.
|
|
@@ -327,7 +375,7 @@ function validate(config: AppConfig): void {
|
|
|
327
375
|
cause: issues.join('; '),
|
|
328
376
|
// The generic instruction goes LAST so the fix line still ends in a command that can be
|
|
329
377
|
// pasted — a trailing `.` after `x verify` is a command nobody can run.
|
|
330
|
-
fix: [...zoneFix, ...tierFix, BASE_FIX].join('. '),
|
|
378
|
+
fix: [...zoneFix, ...tierFix, ...pwaFix, BASE_FIX].join('. '),
|
|
331
379
|
meta: { issues },
|
|
332
380
|
});
|
|
333
381
|
}
|
|
@@ -363,6 +411,7 @@ export function defineConfig(
|
|
|
363
411
|
cache: section(base.cache, merged.cache),
|
|
364
412
|
jobs: section(base.jobs, merged.jobs),
|
|
365
413
|
realtime: section(base.realtime, merged.realtime),
|
|
414
|
+
notify: section(base.notify, merged.notify),
|
|
366
415
|
ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
|
|
367
416
|
};
|
|
368
417
|
|
package/src/error-render.ts
CHANGED
|
@@ -252,57 +252,14 @@ export function renderMetaRecord(
|
|
|
252
252
|
* There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away
|
|
253
253
|
* from being the same breach.
|
|
254
254
|
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
case 'string':
|
|
265
|
-
return countOf(charCount(value), 'string', 'character');
|
|
266
|
-
case 'number':
|
|
267
|
-
return describeNumber(value);
|
|
268
|
-
case 'boolean':
|
|
269
|
-
return 'a boolean';
|
|
270
|
-
case 'bigint':
|
|
271
|
-
return 'a bigint';
|
|
272
|
-
case 'symbol':
|
|
273
|
-
return 'a symbol';
|
|
274
|
-
case 'function':
|
|
275
|
-
return 'a function';
|
|
276
|
-
default:
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
if (Array.isArray(value)) return countOf(value.length, 'array', 'item');
|
|
280
|
-
// `getTime()` rather than a value: an invalid Date is the one Date fact a caller can act on.
|
|
281
|
-
if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'an invalid Date' : 'a Date';
|
|
282
|
-
return 'an object';
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function describeNumber(value: number): string {
|
|
286
|
-
if (Number.isNaN(value)) return 'NaN';
|
|
287
|
-
if (value === Number.POSITIVE_INFINITY) return 'Infinity';
|
|
288
|
-
if (value === Number.NEGATIVE_INFINITY) return '-Infinity';
|
|
289
|
-
return 'a number';
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function countOf(size: number, noun: string, unit: string): string {
|
|
293
|
-
if (size === 0) return `an empty ${noun}`;
|
|
294
|
-
const article = noun === 'array' ? 'an' : 'a';
|
|
295
|
-
return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* The twin of `@ultimat3/schema`'s `char-count.ts`, duplicated for the same reason `describeValue`
|
|
300
|
-
* is: both packages are tier 0 and neither may import the other. Code points, because the rules
|
|
301
|
-
* that reject a string count in them and the message must quote the same unit — `'👍'.length` is 2.
|
|
302
|
-
* Only a surrogate makes the two counts differ, so every ASCII value keeps the O(1) read.
|
|
255
|
+
* DECLARED IN `@ultimat3/schema` and re-exported here, `As of 2026-08-27`. It was a
|
|
256
|
+
* character-for-character copy of `packages/schema/src/describe-value.ts` — the two tier-0
|
|
257
|
+
* packages could not import each other — held equal by a 63-line behavioural pin in
|
|
258
|
+
* `@ultimat3/cli`, a TIER-5 package, that no rule required to exist. Whose function it is has not
|
|
259
|
+
* changed: schema's `expected()` is the other reader, and a widening that leaks a value is now one
|
|
260
|
+
* edit in one file rather than two files that can drift apart in silence.
|
|
261
|
+
*
|
|
262
|
+
* `charCount` went with it — the same duplication, one call deep — so the unit a message quotes
|
|
263
|
+
* and the unit a length rule counts in are the same code, not two files that agree today.
|
|
303
264
|
*/
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
function charCount(value: string): number {
|
|
307
|
-
return HAS_SURROGATE.test(value) ? [...value].length : value.length;
|
|
308
|
-
}
|
|
265
|
+
export { describeValue } from '@ultimat3/schema';
|
package/src/index.ts
CHANGED
|
@@ -65,13 +65,15 @@ export type {
|
|
|
65
65
|
DatabaseConfig,
|
|
66
66
|
JobsConfig,
|
|
67
67
|
McpConfig,
|
|
68
|
-
|
|
68
|
+
NotifyConfig,
|
|
69
69
|
RealtimeConfig,
|
|
70
70
|
RealtimeTransport,
|
|
71
71
|
ThemeConfig,
|
|
72
72
|
ThemeMode,
|
|
73
73
|
} from './config';
|
|
74
|
-
export { defineConfig } from './config';
|
|
74
|
+
export { defineConfig, INBOX_RETENTION_KEYS } from './config';
|
|
75
|
+
export type { PwaColors, PwaConfig, PwaSchemeColors } from './config-pwa';
|
|
76
|
+
export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
|
|
75
77
|
export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
|
|
76
78
|
export {
|
|
77
79
|
createContext,
|
|
@@ -461,6 +463,7 @@ export {
|
|
|
461
463
|
canonicalLocale,
|
|
462
464
|
localeInvalid,
|
|
463
465
|
MAX_CACHED_FORMATTERS,
|
|
466
|
+
MAX_LOCALE_EXCERPT,
|
|
464
467
|
} from './intl-cache';
|
|
465
468
|
export { isJsonObject } from './json-object';
|
|
466
469
|
export type {
|
package/src/intl-cache.ts
CHANGED
|
@@ -27,6 +27,16 @@ export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: ()
|
|
|
27
27
|
return formatter;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The cap on the tag an `X_LOCALE_INVALID` cause quotes back, in code points.
|
|
32
|
+
*
|
|
33
|
+
* 35 is RFC 5646 §4.4.1's own number — Figure 7 derives it as the longest tag the registry can
|
|
34
|
+
* form (language 8 + script 5 + region 4 + two variants 9+9), and the same section says a protocol
|
|
35
|
+
* with a fixed buffer "MUST allow for language tags of at least 35 characters". So every tag a
|
|
36
|
+
* caller could legitimately have meant fits, and nothing longer is a tag being debugged.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_LOCALE_EXCERPT = 35;
|
|
39
|
+
|
|
30
40
|
/**
|
|
31
41
|
* The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
|
|
32
42
|
* (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
|
|
@@ -53,11 +63,47 @@ export function canonicalLocale(locale: string): string | undefined {
|
|
|
53
63
|
export function localeInvalid(locale: string): UltimateError {
|
|
54
64
|
return new UltimateError({
|
|
55
65
|
code: 'X_LOCALE_INVALID',
|
|
56
|
-
cause:
|
|
66
|
+
cause: `${localeExcerpt(locale)} is not a well-formed BCP 47 language tag`,
|
|
57
67
|
fix: "pass a tag like 'en', 'en-GB' or 'de-DE' — screen a header-supplied value with Intl.DateTimeFormat.supportedLocalesOf([tag]) before it reaches a formatter",
|
|
68
|
+
// The raw tag, under a NAME. A `cause` is copied into the 400 body and into the log line by
|
|
69
|
+
// `@ultimat3/http`'s `toProblem`, and a logger redacts by key — so a caller's value spliced
|
|
70
|
+
// into prose has no key left to redact, which is the whole argument `describeValue` rests on.
|
|
71
|
+
// `meta` is the key, and it is machine-read, so it carries the value WHOLE: an excerpt here
|
|
72
|
+
// would be a value a redactor or a bug report reads as complete when it is not.
|
|
73
|
+
meta: { locale },
|
|
58
74
|
});
|
|
59
75
|
}
|
|
60
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The tag as a reader can act on it: the first `MAX_LOCALE_EXCERPT` code points, quoted, and SAID
|
|
79
|
+
* to be cut when there were more.
|
|
80
|
+
*
|
|
81
|
+
* `describeValue` is the usual answer for a caller-supplied value in a `cause` and it is the wrong
|
|
82
|
+
* one here — it renders `en_US` as "a 5-character string", deleting the only actionable content in
|
|
83
|
+
* a sentence whose entire job is to say WHICH tag was refused. Bounding it keeps the diagnostic and
|
|
84
|
+
* removes the part a stranger chooses: without a cap the whole of an `Accept-Language` value —
|
|
85
|
+
* megabytes, at the caller's option — became the error's `message`, its 400 body and its log line.
|
|
86
|
+
*
|
|
87
|
+
* Code points, never `slice`: cutting between a surrogate pair leaves a lone surrogate, which is
|
|
88
|
+
* not text and survives no encoder between here and the log index. The string iterator is lazy, so
|
|
89
|
+
* a megabyte tag costs 35 steps rather than a megabyte-long array.
|
|
90
|
+
*
|
|
91
|
+
* No escaping here: `UltimateError`'s constructor runs `singleLine` over every line-bearing field
|
|
92
|
+
* exactly once, which is what keeps a newline in a tag from writing a second line an operator reads
|
|
93
|
+
* as a genuine framework message. A second pass at this call site would be a second place that has
|
|
94
|
+
* to be right.
|
|
95
|
+
*/
|
|
96
|
+
function localeExcerpt(locale: string): string {
|
|
97
|
+
let head = '';
|
|
98
|
+
let taken = 0;
|
|
99
|
+
for (const char of locale) {
|
|
100
|
+
if (taken === MAX_LOCALE_EXCERPT) return `"${head}" (truncated at ${taken} characters)`;
|
|
101
|
+
head += char;
|
|
102
|
+
taken += 1;
|
|
103
|
+
}
|
|
104
|
+
return `"${head}"`;
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
/**
|
|
62
108
|
* The canonical spelling of a well-formed tag, or `X_LOCALE_INVALID`. The ONE screen a
|
|
63
109
|
* caller-supplied BCP 47 tag passes before it reaches an `Intl` constructor.
|
|
@@ -1,32 +1,33 @@
|
|
|
1
1
|
// Single responsibility: register `@ultimat3/schema`'s error codes so their titles render for any
|
|
2
2
|
// process that imports `@ultimat3/core` — not just the CLI. `@ultimat3/schema` is tier 0 alongside
|
|
3
|
-
// this package
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// this package and cannot call `registerErrorCodes()` itself: that would be `schema -> core`, an
|
|
4
|
+
// import this package's own dependency-free promise forbids in that direction and always will.
|
|
5
|
+
// The other direction is DECLARED (`core -> schema`, `scripts/lib/tiers.ts`), so the titles are
|
|
6
|
+
// READ from schema rather than restated here.
|
|
7
|
+
//
|
|
8
|
+
// They were a hand-kept duplicate until 2026-08-27, held equal by `schema-error-codes-pin.test.ts`
|
|
9
|
+
// in `@ultimat3/cli` — a tier-5 package pinning a tier-0 invariant, which nothing required to
|
|
10
|
+
// exist. It is deleted with this change.
|
|
9
11
|
|
|
12
|
+
import { SCHEMA_ERROR_CODES } from '@ultimat3/schema';
|
|
10
13
|
import { registerErrorCodes } from './error-codes';
|
|
11
14
|
import { registerErrorRetry } from './error-retry';
|
|
12
15
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Schema's own declarations, projected to titles. Kept as an export because it is public API
|
|
18
|
+
* shipped since 1.0; it is now DERIVED and can no longer disagree with its source.
|
|
19
|
+
*/
|
|
20
|
+
export const SCHEMA_ERROR_CODE_TITLES: Readonly<Record<string, string>> = Object.freeze(
|
|
21
|
+
Object.fromEntries(
|
|
22
|
+
Object.entries(SCHEMA_ERROR_CODES).map(([code, declared]) => [code, declared.title]),
|
|
23
|
+
),
|
|
24
|
+
);
|
|
20
25
|
|
|
21
26
|
// Registered here rather than in `error-codes.ts`'s `CORE_CODE_TITLES` because core does not own
|
|
22
27
|
// these codes — `@ultimat3/schema` does — and `registerErrorCodes` is the one mechanism that
|
|
23
28
|
// raises `X_ERROR_CODE_DUPLICATE` if a package that DOES own one of them ever tries to register it
|
|
24
|
-
// too, which pins ownership even though the
|
|
25
|
-
registerErrorCodes(
|
|
26
|
-
Object.fromEntries(
|
|
27
|
-
Object.entries(SCHEMA_ERROR_CODE_TITLES).map(([code, title]) => [code, { title }]),
|
|
28
|
-
),
|
|
29
|
-
);
|
|
29
|
+
// too, which pins ownership even though the registration happens here.
|
|
30
|
+
registerErrorCodes(SCHEMA_ERROR_CODES);
|
|
30
31
|
|
|
31
32
|
// And how each is RETRIED, here for the same tier reason the titles are here.
|
|
32
33
|
//
|
|
@@ -41,9 +42,10 @@ registerErrorCodes(
|
|
|
41
42
|
// `packages/scraping/src/cdp-target.ts` names the gap and says it cannot be closed from there.
|
|
42
43
|
//
|
|
43
44
|
// A value that does not match its schema does not match it on attempt five either.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
//
|
|
46
|
+
// DERIVED from the same set, so a code schema adds cannot arrive unclassified: the list was typed
|
|
47
|
+
// out here and a fifth code would have been silently missing from it, which is precisely the
|
|
48
|
+
// "unregistered reads as unclassified" failure the paragraph above describes.
|
|
49
|
+
registerErrorRetry(
|
|
50
|
+
Object.fromEntries(Object.keys(SCHEMA_ERROR_CODES).map((code) => [code, 'terminal' as const])),
|
|
51
|
+
);
|
package/src/time-zone-name.ts
CHANGED
|
@@ -1,43 +1,14 @@
|
|
|
1
|
-
// Single responsibility:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Single responsibility: re-export `@ultimat3/schema`'s IANA zone-name predicate, so `app.config.ts`
|
|
2
|
+
// and `t.timezone` judge a zone with ONE function.
|
|
3
|
+
//
|
|
4
|
+
// This file held a character-for-character copy of it until 2026-08-27 — `core` and `schema` are
|
|
5
|
+
// both tier 0 and neither could import the other — kept equal by a 123-line pin test in
|
|
6
|
+
// `@ultimat3/cli`, a tier-5 package pinning a tier-0 invariant that no rule required to exist.
|
|
7
|
+
// `core -> schema` is now a declared edge (`scripts/lib/tiers.ts`), so the copy is gone.
|
|
8
|
+
//
|
|
9
|
+
// The rule itself, and why a bare `new Intl.DateTimeFormat(…)` probe is not it — ICU 78 resolves
|
|
10
|
+
// `CET`, `EST`, `Japan`, `GMT` and `Zulu`, so `defaultTimeZone: 'CET'` passed validation at boot
|
|
11
|
+
// and threw `X_TIMEZONE_INVALID` on the first `format` call (issue #257) — is written down in
|
|
12
|
+
// `packages/schema/src/time-zone-name.ts` and in `packages/time/src/zone-canonical.ts`.
|
|
5
13
|
|
|
6
|
-
|
|
7
|
-
* A LEADING sign is a fixed offset, which carries no DST rules. `Etc/GMT+2` keeps its `+`.
|
|
8
|
-
*
|
|
9
|
-
* Unobservable on ICU 78 — `+01:00` resolves to itself, so the slash rule below already refuses it,
|
|
10
|
-
* and deleting this line changes no answer this package can currently produce. It stays because it
|
|
11
|
-
* guards the runtime that folds an offset into `Etc/GMT-1`, which WOULD carry a slash, and because
|
|
12
|
-
* `packages/time/src/zone-canonical.ts` carries the same line: two statements of one rule may not
|
|
13
|
-
* differ, least of all in the half that is hard to test.
|
|
14
|
-
*/
|
|
15
|
-
const NUMERIC_OFFSET = /^[+-]/;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Structural, and never delegated to `Intl` — the reasoning, and why a denylist is not the
|
|
19
|
-
* alternative, is `packages/time/src/zone-canonical.ts`'s and is not re-derived here. What this
|
|
20
|
-
* file enforces is that same rule for `app.config.ts`: an identifier is `Area/Location`, `UTC` is
|
|
21
|
-
* the one legal exception, and a leading sign is an offset rather than a name.
|
|
22
|
-
*
|
|
23
|
-
* It exists because a bare `new Intl.DateTimeFormat(…)` probe was the second answer to that
|
|
24
|
-
* question, and the two stopped agreeing: ICU 78 (Bun 1.4) resolves `CET`, `EST`, `Japan`, `GMT`,
|
|
25
|
-
* `Zulu` and the whole `backward`/abbreviation family, so `defaultTimeZone: 'CET'` passed validation
|
|
26
|
-
* at boot and threw `X_TIMEZONE_INVALID` on the first `format` call — a config file accepting a
|
|
27
|
-
* value nothing downstream can use (issue #257).
|
|
28
|
-
*
|
|
29
|
-
* The judgement is made on the RESOLVED name, not the input, so a casing (`utc`) and a runtime that
|
|
30
|
-
* folds an alias into its target (`Etc/UTC` → `UTC`) both answer correctly. No cache: this is read
|
|
31
|
-
* once per process at config validation, never off a request header — a zone that arrives from a
|
|
32
|
-
* caller goes through `@ultimat3/time`'s `canonicalTimeZone`, which is bounded and canonicalizing.
|
|
33
|
-
*/
|
|
34
|
-
export function isIanaZoneName(value: string): boolean {
|
|
35
|
-
if (value === '' || NUMERIC_OFFSET.test(value)) return false;
|
|
36
|
-
try {
|
|
37
|
-
const resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions()
|
|
38
|
-
.timeZone;
|
|
39
|
-
return resolved === 'UTC' || resolved.includes('/');
|
|
40
|
-
} catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
14
|
+
export { isIanaZoneName } from '@ultimat3/schema';
|