@ultimat3/core 17.0.0 → 19.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 +149 -0
- package/src/config.ts +93 -27
- package/src/error-render.ts +10 -53
- package/src/index.ts +17 -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": "19.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": "19.0.0"
|
|
41
|
+
}
|
|
40
42
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
|
|
17
|
+
* real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
|
|
18
|
+
* scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* What the service worker needs that no route can say for itself.
|
|
22
|
+
*
|
|
23
|
+
* `fallback` is the document a navigation gets when the network is gone and the cache has no
|
|
24
|
+
* answer — the one thing an offline app cannot do without, and the reason this block replaced a
|
|
25
|
+
* bare `offline: OfflineStrategy`. That key was an app-wide DEFAULT for a field `defineRoute`
|
|
26
|
+
* makes **required** on every route (`route.ts` refuses a route without one), so it defaulted
|
|
27
|
+
* nothing, was read by nobody, and could not be given a reader without inventing a meaning for it
|
|
28
|
+
* (#390).
|
|
29
|
+
*
|
|
30
|
+
* The other three are `@ultimat3/pwa`'s `OfflineConfig` verbatim, and each is read by the emitter
|
|
31
|
+
* that builds `sw.js`: a placeholder image, a placeholder font, and the request patterns that must
|
|
32
|
+
* never be answered from a cache — auth and payments, where a stale 200 is worse than a failure.
|
|
33
|
+
*/
|
|
34
|
+
export interface PwaOfflineConfig {
|
|
35
|
+
/** Absolute route path of the offline document, e.g. `/offline`. Required once `enabled`. */
|
|
36
|
+
readonly fallback: string | null;
|
|
37
|
+
readonly image: string | null;
|
|
38
|
+
readonly font: string | null;
|
|
39
|
+
readonly neverCache: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PwaConfig {
|
|
43
|
+
readonly enabled: boolean;
|
|
44
|
+
readonly offline: PwaOfflineConfig;
|
|
45
|
+
readonly backgroundSync: boolean;
|
|
46
|
+
readonly push: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The install title, and the one manifest member nothing can derive. `AppConfig.name` is a slug
|
|
49
|
+
* (`^[a-z][a-z0-9-]{1,63}$`) and an install prompt shows a person a title, so `ledger-demo` is
|
|
50
|
+
* the wrong answer rather than a rough one. Required once `enabled` is true; `''` is what a
|
|
51
|
+
* disabled block resolves to.
|
|
52
|
+
*/
|
|
53
|
+
readonly name: string;
|
|
54
|
+
/**
|
|
55
|
+
* `theme_color` and `background_color`, per scheme. Required once `enabled` is true, and
|
|
56
|
+
* `undefined` otherwise — never a colour the framework picked. There is no defensible default:
|
|
57
|
+
* an install splash painted in a colour nobody chose is a wrong-looking app that boots, which is
|
|
58
|
+
* worse than a boot that names the four values it needs.
|
|
59
|
+
*/
|
|
60
|
+
readonly colors: PwaColors | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The install chrome's two colours for one scheme, as CSS colour strings.
|
|
65
|
+
*
|
|
66
|
+
* ONE OF THE TWO PLACES A RAW COLOUR IS LEGAL, alongside `ThemeConfig.tokens` one section up, and
|
|
67
|
+
* for a stronger reason than that one has: a browser paints the install splash and the address bar
|
|
68
|
+
* from these before a single stylesheet has loaded, so there is no token to resolve them against
|
|
69
|
+
* and no component anywhere in the loop.
|
|
70
|
+
*/
|
|
71
|
+
export interface PwaSchemeColors {
|
|
72
|
+
readonly themeColor: string;
|
|
73
|
+
readonly backgroundColor: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Both schemes, because a web manifest carries exactly one `theme_color` and `<head>` carries a
|
|
78
|
+
* media-scoped `<meta name="theme-color">` per scheme. One value would make the dark answer the
|
|
79
|
+
* light one's, on the surface a reader sees before the app has painted anything.
|
|
80
|
+
*/
|
|
81
|
+
export interface PwaColors {
|
|
82
|
+
readonly light: PwaSchemeColors;
|
|
83
|
+
readonly dark: PwaSchemeColors;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The two schemes and the two colours `validate` screens, for `INBOX_RETENTION_KEYS`' reason: a
|
|
88
|
+
* third member added to `PwaColors` or `PwaSchemeColors` without a row here is a value an app can
|
|
89
|
+
* leave blank, and `config.test.ts` asserts both lists against the types so the omission is a red
|
|
90
|
+
* test rather than a manifest with an empty `theme_color`.
|
|
91
|
+
*/
|
|
92
|
+
export const PWA_SCHEMES = ['light', 'dark'] as const;
|
|
93
|
+
export const PWA_COLOR_KEYS = ['themeColor', 'backgroundColor'] as const;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Appended only when the `pwa` block is what failed, and it carries the whole block rather than
|
|
97
|
+
* the missing key: `pwa.enabled` is what turns four other requirements on, so a reader who set one
|
|
98
|
+
* boolean needs to see the complete shape and the opt-out in the same line.
|
|
99
|
+
*/
|
|
100
|
+
export const PWA_FIX =
|
|
101
|
+
"in app.config.ts, complete the pwa block: pwa: { enabled: true, offline: { fallback: '/offline' }, 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, and offline.fallback is the document the emitted sw.js serves when the network is gone, so add a route at that path too; or set pwa.enabled: false";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every rule `validate` applies to the block, appended to the caller's own issue list. Answers
|
|
105
|
+
* whether IT found anything, so the pwa remedy rides only on a pwa finding: `issues` may already
|
|
106
|
+
* hold a bad locale, and a fix line naming the install block for that is axiom 4 broken.
|
|
107
|
+
*
|
|
108
|
+
* `describeValue` for the reason the retention windows use it — this is where an untyped config
|
|
109
|
+
* object crosses into the framework, so every value here is `unknown` however the interface types it.
|
|
110
|
+
*/
|
|
111
|
+
export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
|
|
112
|
+
if (!pwa.enabled) return false;
|
|
113
|
+
const before = issues.length;
|
|
114
|
+
const { name, colors } = pwa;
|
|
115
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
116
|
+
issues.push(`pwa.name is required when pwa.enabled is true, and is ${describeValue(name)}`);
|
|
117
|
+
}
|
|
118
|
+
// An ABSOLUTE path, screened here rather than at emit for this file's own stated reason: a
|
|
119
|
+
// relative one resolves against whatever document registered the worker, so `offline` served
|
|
120
|
+
// under `/posts/1` is `/posts/offline` — a 404 cached as the answer to every offline navigation.
|
|
121
|
+
// `pwa.enabled` means an installable app, and an installable app that shows the browser's error
|
|
122
|
+
// page offline is the failure the whole block exists to prevent, so this is required rather
|
|
123
|
+
// than optional: the alternative is two meanings for one switch (axiom 1).
|
|
124
|
+
const fallback: unknown = pwa.offline?.fallback;
|
|
125
|
+
if (typeof fallback !== 'string' || !fallback.startsWith('/')) {
|
|
126
|
+
issues.push(
|
|
127
|
+
`pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path like "/offline", and is ${describeValue(fallback)}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
// `typeof !== 'object' || null`, never `=== undefined`: an untyped `app.config.ts` writing
|
|
131
|
+
// `pwa.colors: null` reached `null[scheme]` one line down and took the boot out with a native
|
|
132
|
+
// `TypeError`, from the validator whose whole job is producing an instruction instead of one.
|
|
133
|
+
if (colors === null || typeof colors !== 'object') {
|
|
134
|
+
issues.push(
|
|
135
|
+
`pwa.colors is required when pwa.enabled is true, for both light and dark, and is ${describeValue(colors)}`,
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
for (const scheme of PWA_SCHEMES) {
|
|
139
|
+
for (const key of PWA_COLOR_KEYS) {
|
|
140
|
+
const value: unknown = colors[scheme]?.[key];
|
|
141
|
+
if (typeof value === 'string' && value.trim() !== '') continue;
|
|
142
|
+
issues.push(
|
|
143
|
+
`pwa.colors.${scheme}.${key} must be a CSS colour, not ${describeValue(value)}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return issues.length > before;
|
|
149
|
+
}
|
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, PwaOfflineConfig } 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
|
|
|
@@ -176,6 +197,17 @@ export interface AiConfigInput {
|
|
|
176
197
|
readonly mcp?: Input<McpConfig> | undefined;
|
|
177
198
|
}
|
|
178
199
|
|
|
200
|
+
/**
|
|
201
|
+
* `offline` is NESTED for `AiConfigInput`'s reason, and here it is load-bearing rather than
|
|
202
|
+
* ergonomic: `section` applies a patch ONE level deep, so an app writing
|
|
203
|
+
* `offline: { fallback: '/offline' }` under a flat `Input<PwaConfig>` would replace the whole
|
|
204
|
+
* default block — leaving `image`, `font` and `neverCache` absent at run time while the type says
|
|
205
|
+
* they are there, which is the exact shape of defect this repo keeps re-shipping.
|
|
206
|
+
*/
|
|
207
|
+
export interface PwaConfigInput extends Omit<Input<PwaConfig>, 'offline'> {
|
|
208
|
+
readonly offline?: Input<PwaOfflineConfig> | undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
179
211
|
export interface AppConfigInput {
|
|
180
212
|
readonly name: string;
|
|
181
213
|
readonly locales?: readonly string[] | undefined;
|
|
@@ -184,12 +216,13 @@ export interface AppConfigInput {
|
|
|
184
216
|
readonly defaultCurrency?: string | undefined;
|
|
185
217
|
readonly theme?: Input<ThemeConfig> | undefined;
|
|
186
218
|
readonly auth?: Input<AuthConfig> | undefined;
|
|
187
|
-
readonly pwa?:
|
|
219
|
+
readonly pwa?: PwaConfigInput | undefined;
|
|
188
220
|
readonly roles?: readonly Role[] | undefined;
|
|
189
221
|
readonly database?: Input<DatabaseConfig> | undefined;
|
|
190
222
|
readonly cache?: Input<CacheConfig> | undefined;
|
|
191
223
|
readonly jobs?: Input<JobsConfig> | undefined;
|
|
192
224
|
readonly realtime?: Input<RealtimeConfig> | undefined;
|
|
225
|
+
readonly notify?: Input<NotifyConfig> | undefined;
|
|
193
226
|
readonly ai?: AiConfigInput | undefined;
|
|
194
227
|
}
|
|
195
228
|
|
|
@@ -212,18 +245,13 @@ function section<T extends object>(base: T, patch: Input<T> | undefined): T {
|
|
|
212
245
|
const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
213
246
|
|
|
214
247
|
/**
|
|
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.
|
|
248
|
+
* Built from `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the framework's ONE declaration of what
|
|
249
|
+
* an ISO 4217 code looks like — the same source `isCurrencyCode`, the published OpenAPI `pattern`
|
|
250
|
+
* and `@ultimat3/entity`'s Postgres CHECK all derive from. It was a character-for-character copy
|
|
251
|
+
* here until the `core -> schema` edge was declared (`scripts/lib/tiers.ts`), held equal only by a
|
|
252
|
+
* pin test in `@ultimat3/cli`.
|
|
225
253
|
*/
|
|
226
|
-
const CURRENCY_RE =
|
|
254
|
+
const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
|
|
227
255
|
|
|
228
256
|
function isLocale(value: string): boolean {
|
|
229
257
|
try {
|
|
@@ -241,7 +269,14 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
241
269
|
defaultCurrency: 'USD',
|
|
242
270
|
theme: { defaultMode: 'system', tokens: {} },
|
|
243
271
|
auth: { signInPath: null },
|
|
244
|
-
pwa: {
|
|
272
|
+
pwa: {
|
|
273
|
+
enabled: false,
|
|
274
|
+
offline: { fallback: null, image: null, font: null, neverCache: [] },
|
|
275
|
+
backgroundSync: false,
|
|
276
|
+
push: false,
|
|
277
|
+
name: '',
|
|
278
|
+
colors: undefined,
|
|
279
|
+
},
|
|
245
280
|
roles: [...ROLES],
|
|
246
281
|
database: { driver: 'postgres', ssl: false },
|
|
247
282
|
cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
|
|
@@ -253,6 +288,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
253
288
|
visibilityTimeoutMs: 30_000,
|
|
254
289
|
},
|
|
255
290
|
realtime: { enabled: false, transport: 'memory', urlEnv: undefined },
|
|
291
|
+
notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
|
|
256
292
|
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
257
293
|
};
|
|
258
294
|
}
|
|
@@ -284,6 +320,8 @@ function validate(config: AppConfig): void {
|
|
|
284
320
|
// Same shape, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']` in an
|
|
285
321
|
// untyped config file reaches here rather than the compiler, and needs the new spelling.
|
|
286
322
|
const tierFix: string[] = [];
|
|
323
|
+
// Same shape again: carried only when an installable app is missing what an install needs.
|
|
324
|
+
const pwaFix: string[] = [];
|
|
287
325
|
|
|
288
326
|
if (!NAME_RE.test(config.name)) {
|
|
289
327
|
issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
|
|
@@ -313,6 +351,27 @@ function validate(config: AppConfig): void {
|
|
|
313
351
|
if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
|
|
314
352
|
issues.push(`realtime.transport "${config.realtime.transport}" requires realtime.urlEnv`);
|
|
315
353
|
}
|
|
354
|
+
// BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
|
|
355
|
+
// only other legal one is a positive, finite count of milliseconds. Zero is refused rather than
|
|
356
|
+
// read as "immediately": a sweep at age 0 deletes every row the instant it is written, which is
|
|
357
|
+
// an inbox that silently receives nothing, and nobody types it on purpose. `describeValue`
|
|
358
|
+
// rather than `${…}`: this validator is the boundary a JS app's untyped config crosses, so the
|
|
359
|
+
// value here is `unknown` in practice however the interface types it.
|
|
360
|
+
for (const key of INBOX_RETENTION_KEYS) {
|
|
361
|
+
const ms: unknown = config.notify[key];
|
|
362
|
+
if (ms === undefined) continue;
|
|
363
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) {
|
|
364
|
+
issues.push(
|
|
365
|
+
`notify.${key} must be a positive number of milliseconds, not ${describeValue(ms)}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// What an install needs, asked at BOOT and not at emit — `config-pwa.ts` owns the rules and the
|
|
371
|
+
// remedy, because `pwa.enabled` turning four other requirements on is a question about that block
|
|
372
|
+
// and nothing else here.
|
|
373
|
+
if (pwaIssues(config.pwa, issues)) pwaFix.push(PWA_FIX);
|
|
374
|
+
|
|
316
375
|
// A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
|
|
317
376
|
// index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
|
|
318
377
|
// So an unknown tier is refused at boot rather than silently ignored or silently placed first.
|
|
@@ -327,7 +386,7 @@ function validate(config: AppConfig): void {
|
|
|
327
386
|
cause: issues.join('; '),
|
|
328
387
|
// The generic instruction goes LAST so the fix line still ends in a command that can be
|
|
329
388
|
// pasted — a trailing `.` after `x verify` is a command nobody can run.
|
|
330
|
-
fix: [...zoneFix, ...tierFix, BASE_FIX].join('. '),
|
|
389
|
+
fix: [...zoneFix, ...tierFix, ...pwaFix, BASE_FIX].join('. '),
|
|
331
390
|
meta: { issues },
|
|
332
391
|
});
|
|
333
392
|
}
|
|
@@ -357,12 +416,19 @@ export function defineConfig(
|
|
|
357
416
|
defaultCurrency: merged.defaultCurrency ?? base.defaultCurrency,
|
|
358
417
|
theme: section(base.theme, merged.theme),
|
|
359
418
|
auth: section(base.auth, merged.auth),
|
|
360
|
-
|
|
419
|
+
// Two `section` calls, one per level: the outer one may not see `offline` at all, or it would
|
|
420
|
+
// drop the nested defaults `PwaConfigInput` exists to keep. Hence the cast — the outer patch is
|
|
421
|
+
// this block minus the key the inner call owns.
|
|
422
|
+
pwa: {
|
|
423
|
+
...section(base.pwa, { ...merged.pwa, offline: undefined } as Input<PwaConfig>),
|
|
424
|
+
offline: section(base.pwa.offline, merged.pwa?.offline),
|
|
425
|
+
},
|
|
361
426
|
roles: merged.roles ?? base.roles,
|
|
362
427
|
database: section(base.database, merged.database),
|
|
363
428
|
cache: section(base.cache, merged.cache),
|
|
364
429
|
jobs: section(base.jobs, merged.jobs),
|
|
365
430
|
realtime: section(base.realtime, merged.realtime),
|
|
431
|
+
notify: section(base.notify, merged.notify),
|
|
366
432
|
ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
|
|
367
433
|
};
|
|
368
434
|
|
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
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
// secrets — because each is one subject spread over a dozen modules. Every name they carry is
|
|
6
6
|
// still written out below: `export *` would make the contract something a reader has to resolve.
|
|
7
7
|
|
|
8
|
+
// Anchored on purpose, and NOT by the `sideEffects` array: Bun reads any array as `false` and drops
|
|
9
|
+
// the module regardless (oven-sh/bun#40650). This module registers @ultimat3/schema's error TITLES,
|
|
10
|
+
// because schema is tier 0 and cannot register its own — and what reads them is `UltimateError`'s
|
|
11
|
+
// constructor, which never imports this file. Shaken out, every X_VALIDATION_FAILED renders
|
|
12
|
+
// untitled in the browser with nothing to say why. `SIDE_EFFECTS_ANCHORS` carries the argument and
|
|
13
|
+
// `bun run side-effects` enforces it. `context.ts`, `lifecycle-errors.ts` and `secrets-errors.ts`
|
|
14
|
+
// are declared side-effecting too and are deliberately NOT anchored: each is reached by whatever
|
|
15
|
+
// uses it, and anchoring `context.ts` alone measured +3,485 B on a browser chunk for a provider a
|
|
16
|
+
// browser can never fire.
|
|
17
|
+
import './schema-error-codes';
|
|
18
|
+
|
|
8
19
|
export type {
|
|
9
20
|
Actor,
|
|
10
21
|
ActorFactKey,
|
|
@@ -65,13 +76,16 @@ export type {
|
|
|
65
76
|
DatabaseConfig,
|
|
66
77
|
JobsConfig,
|
|
67
78
|
McpConfig,
|
|
68
|
-
|
|
79
|
+
NotifyConfig,
|
|
80
|
+
PwaConfigInput,
|
|
69
81
|
RealtimeConfig,
|
|
70
82
|
RealtimeTransport,
|
|
71
83
|
ThemeConfig,
|
|
72
84
|
ThemeMode,
|
|
73
85
|
} from './config';
|
|
74
|
-
export { defineConfig } from './config';
|
|
86
|
+
export { defineConfig, INBOX_RETENTION_KEYS } from './config';
|
|
87
|
+
export type { PwaColors, PwaConfig, PwaOfflineConfig, PwaSchemeColors } from './config-pwa';
|
|
88
|
+
export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
|
|
75
89
|
export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
|
|
76
90
|
export {
|
|
77
91
|
createContext,
|
|
@@ -461,6 +475,7 @@ export {
|
|
|
461
475
|
canonicalLocale,
|
|
462
476
|
localeInvalid,
|
|
463
477
|
MAX_CACHED_FORMATTERS,
|
|
478
|
+
MAX_LOCALE_EXCERPT,
|
|
464
479
|
} from './intl-cache';
|
|
465
480
|
export { isJsonObject } from './json-object';
|
|
466
481
|
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';
|