@ultimat3/core 24.0.0 → 25.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 +24 -27
- package/README.md +71 -34
- package/package.json +4 -7
- package/src/actor.ts +9 -0
- package/src/address-class.ts +40 -4
- package/src/assert.ts +9 -5
- package/src/audit.ts +144 -0
- package/src/aws-sigv4.ts +275 -0
- package/src/backoff.ts +16 -0
- package/src/bunfs.ts +17 -0
- package/src/client-dispatch.ts +24 -3
- package/src/client-flight.ts +68 -13
- package/src/client-problem.ts +62 -6
- package/src/client-retry-after.ts +47 -0
- package/src/client-transport.ts +3 -1
- package/src/client-wire.ts +27 -3
- package/src/config-ai.ts +32 -0
- package/src/config-defaults.ts +18 -11
- package/src/config-fixes.ts +0 -10
- package/src/config-health.ts +9 -2
- package/src/config-jobs.ts +51 -0
- package/src/config-keys.ts +170 -0
- package/src/config-mail.ts +73 -0
- package/src/config-merge.ts +1 -1
- package/src/config-navigation.ts +1 -25
- package/src/config-pwa.ts +42 -5
- package/src/config-removed.ts +131 -0
- package/src/config-shape.ts +0 -33
- package/src/config.ts +71 -94
- package/src/context.ts +16 -12
- package/src/cookie.ts +267 -3
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +23 -5
- package/src/deprecation.ts +77 -0
- package/src/dev-secrets.ts +18 -7
- package/src/drain-deadline.ts +43 -0
- package/src/env-example.ts +9 -29
- package/src/errors.ts +18 -9
- package/src/exports/error-contract.ts +0 -1
- package/src/exports/observability.ts +1 -1
- package/src/exports/secrets.ts +3 -0
- package/src/finite-option.ts +1 -1
- package/src/flight-gate.ts +29 -14
- package/src/generation-fence.ts +1 -1
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/pipeline.ts +17 -5
- package/src/image/raster.ts +24 -1
- package/src/index.ts +89 -35
- package/src/iso-date.ts +1 -1
- package/src/lifecycle-errors.ts +1 -1
- package/src/lifecycle-readiness.ts +60 -2
- package/src/lifecycle-signals.ts +27 -2
- package/src/lifecycle-types.ts +96 -0
- package/src/lifecycle.ts +44 -133
- package/src/locale-direction.ts +1 -1
- package/src/logger.ts +16 -7
- package/src/mcp-exposure.ts +70 -8
- package/src/measurement-actor.ts +16 -1
- package/src/metric-errors.ts +32 -0
- package/src/metric-registry.ts +151 -0
- package/src/metric-series.ts +94 -0
- package/src/metrics.ts +9 -255
- package/src/page.ts +4 -2
- package/src/registrar.ts +1 -0
- package/src/retry.ts +25 -5
- package/src/secrets-key-file.ts +139 -0
- package/src/secrets-store.ts +32 -16
- package/src/service.ts +5 -5
- package/src/single-flight.ts +1 -1
- package/src/telemetry.ts +1 -1
- package/src/theme-storage.ts +12 -0
- package/src/type-pins.ts +51 -1
- package/src/image/fixtures.ts +0 -263
- package/src/time-zone-name.ts +0 -14
package/src/config-navigation.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
// were: shape, merge and screen are one subject.
|
|
7
7
|
|
|
8
8
|
import { describeValue } from './error-render';
|
|
9
|
-
import { ConfigInvalidError } from './errors';
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* The surfaces that render documents a browser navigates between. `api` answers JSON and `shared`
|
|
@@ -73,7 +72,7 @@ export interface NavigationSectionInput {
|
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
/**
|
|
76
|
-
* Whole-value keys: the last layer that listed surfaces wins, as `
|
|
75
|
+
* Whole-value keys: the last layer that listed surfaces wins, as `roles` does — and so does the
|
|
77
76
|
* last one that set `speculation.prefetch` or listed `speculation.exclude`, each on its own.
|
|
78
77
|
*/
|
|
79
78
|
export function mergeNavigation(layers: readonly NavigationSectionInput[]): NavigationSection {
|
|
@@ -138,29 +137,6 @@ function speculationIssues(speculation: unknown, issues: string[]): void {
|
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
139
|
|
|
141
|
-
/**
|
|
142
|
-
* `navigation.speculation` as some reader OUTSIDE `defineConfig` found it (`@ultimat3/cli` imports
|
|
143
|
-
* the app's config module structurally): the defaults for what it does not say, and the SAME
|
|
144
|
-
* refusal `defineConfig` gives for what it says wrongly. One validator — a second reader that
|
|
145
|
-
* coerced `'eager'` to `'moderate'` or dropped a bad pattern would serve rules the app never wrote.
|
|
146
|
-
*/
|
|
147
|
-
export function resolveSpeculation(said: unknown): SpeculationConfig {
|
|
148
|
-
if (said === undefined) return DEFAULT_SPECULATION;
|
|
149
|
-
const { speculation } = mergeNavigation([
|
|
150
|
-
{ navigation: { speculation: said as SpeculationInput } },
|
|
151
|
-
]).navigation;
|
|
152
|
-
const issues: string[] = [];
|
|
153
|
-
speculationIssues(speculation, issues);
|
|
154
|
-
if (issues.length > 0) {
|
|
155
|
-
throw new ConfigInvalidError({
|
|
156
|
-
cause: issues.join('; '),
|
|
157
|
-
fix: 'Correct navigation.speculation in app.config.ts: prefetch is "moderate", "conservative" or false, and exclude is a list of path patterns starting with "/"',
|
|
158
|
-
meta: { issues },
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
return speculation;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
140
|
/** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
|
|
165
141
|
export function navigationIssues(config: NavigationSection, issues: string[]): void {
|
|
166
142
|
speculationIssues(config.navigation.speculation, issues);
|
package/src/config-pwa.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { describeValue } from './error-render';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `
|
|
16
|
+
* `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `installController` is
|
|
17
17
|
* real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
|
|
18
18
|
* scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
|
|
19
19
|
*/
|
|
@@ -115,8 +115,8 @@ export interface PwaScreenshot {
|
|
|
115
115
|
/**
|
|
116
116
|
* The install chrome's two colours for one scheme, as CSS colour strings.
|
|
117
117
|
*
|
|
118
|
-
* ONE
|
|
119
|
-
*
|
|
118
|
+
* THE ONE PLACE IN `app.config.ts` A RAW COLOUR IS LEGAL (`theme.tokens`, the other, was deleted in
|
|
119
|
+
* 25.0.0 — the theme is `defineTheme` in `@ultimat3/ui`), because a browser paints the install splash and the address bar
|
|
120
120
|
* from these before a single stylesheet has loaded, so there is no token to resolve them against
|
|
121
121
|
* and no component anywhere in the loop.
|
|
122
122
|
*/
|
|
@@ -173,10 +173,20 @@ export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
|
|
|
173
173
|
// `pwa.enabled` means an installable app, and an installable app that shows the browser's error
|
|
174
174
|
// page offline is the failure the whole block exists to prevent, so this is required rather
|
|
175
175
|
// than optional: the alternative is two meanings for one switch (axiom 1).
|
|
176
|
+
// Same origin, too: `//host/offline` and `/\host/offline` start with `/` and are still another
|
|
177
|
+
// host, which the worker would precache and serve as this app's offline page.
|
|
176
178
|
const fallback: unknown = pwa.offline?.fallback;
|
|
177
|
-
if (
|
|
179
|
+
if (!isSameOriginPath(fallback)) {
|
|
178
180
|
issues.push(
|
|
179
|
-
`pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path like "/offline", and is ${describeValue(fallback)}`,
|
|
181
|
+
`pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path on this origin like "/offline", and is ${describeValue(fallback)}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
for (const key of ['image', 'font'] as const) {
|
|
185
|
+
const placeholder: unknown = pwa.offline?.[key];
|
|
186
|
+
if (placeholder === undefined || placeholder === null || isSameOriginPath(placeholder))
|
|
187
|
+
continue;
|
|
188
|
+
issues.push(
|
|
189
|
+
`pwa.offline.${key} must be a path on this origin like "/offline.svg", and is ${describeValue(placeholder)}`,
|
|
180
190
|
);
|
|
181
191
|
}
|
|
182
192
|
const personal: unknown = pwa.offline?.personalPages;
|
|
@@ -233,3 +243,30 @@ function manifestIssues(pwa: PwaConfig, issues: string[]): void {
|
|
|
233
243
|
}
|
|
234
244
|
}
|
|
235
245
|
}
|
|
246
|
+
|
|
247
|
+
/** An origin no relative path can reach: a value that resolves anywhere else left this origin. */
|
|
248
|
+
const PROBE_ORIGIN = 'http://x.invalid';
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A path on THIS origin, as a browser will resolve it — the one predicate for every URL the worker
|
|
252
|
+
* precaches and serves as an offline answer (`@ultimat3/pwa`'s build asks the same question).
|
|
253
|
+
* Judged by RESOLUTION, never by the raw prefix: the URL parser strips a tab, CR or LF anywhere and
|
|
254
|
+
* reads `\` as `/`, so `/\t/evil.test/x` is `//evil.test/x` once parsed. Every C0 control and DEL
|
|
255
|
+
* is refused before the parse, as `@ultimat3/http`'s sign-in redirect does; a dot segment that
|
|
256
|
+
* leaves a `//` pathname (`/.//evil.test`) is refused after it, since any reader without the base
|
|
257
|
+
* takes that pathname as a host.
|
|
258
|
+
*/
|
|
259
|
+
export function isSameOriginPath(value: unknown): value is string {
|
|
260
|
+
if (typeof value !== 'string' || !value.startsWith('/')) return false;
|
|
261
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
262
|
+
const code = value.charCodeAt(index);
|
|
263
|
+
if (code < 0x20 || code === 0x7f) return false;
|
|
264
|
+
}
|
|
265
|
+
let resolved: URL;
|
|
266
|
+
try {
|
|
267
|
+
resolved = new URL(value, PROBE_ORIGIN);
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return resolved.origin === PROBE_ORIGIN && !resolved.pathname.startsWith('//');
|
|
272
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Single responsibility: the `app.config.ts` keys a major DELETED, and the refusal an app still
|
|
2
|
+
// writing one gets. A deleted key the validator ignores is a switch with no wire — an operator sets
|
|
3
|
+
// it, redeploys and nothing changes — so each is refused by name, with the line that replaces it.
|
|
4
|
+
|
|
5
|
+
import { isJsonObject } from './json-object';
|
|
6
|
+
|
|
7
|
+
export interface RemovedConfigKey {
|
|
8
|
+
/** The major that deleted it. */
|
|
9
|
+
readonly removedIn: string;
|
|
10
|
+
/** What an app writes instead — the second half of the `fix:`. */
|
|
11
|
+
readonly instead: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dotted paths, one row per deleted leaf. Every row older than 25.0.0 was silently carried through
|
|
16
|
+
* `section()` until 25.0.0 refused it — this table by name, then `config-keys.ts` closing the
|
|
17
|
+
* shape for any other key; a row is what turns "not a key" into the line that replaces it. A row
|
|
18
|
+
* is never removed: a config written against an older major must keep getting the instruction,
|
|
19
|
+
* not silence. `Object.freeze`d, read through `Object.hasOwn`, so `__proto__` in a layer names no
|
|
20
|
+
* row.
|
|
21
|
+
*/
|
|
22
|
+
export const REMOVED_CONFIG_KEYS: Readonly<Record<string, RemovedConfigKey>> = Object.freeze({
|
|
23
|
+
locales: {
|
|
24
|
+
removedIn: '25.0.0',
|
|
25
|
+
instead:
|
|
26
|
+
"the app's locales are the keys of its catalogs: defineCatalogs({ default: 'en', locales: { en, es } }) from @ultimat3/i18n, in packages/i18n/src/index.ts",
|
|
27
|
+
},
|
|
28
|
+
defaultLocale: {
|
|
29
|
+
removedIn: '25.0.0',
|
|
30
|
+
instead:
|
|
31
|
+
"the fallback locale is defineCatalogs({ default: 'en', locales: { en } })'s default, from @ultimat3/i18n",
|
|
32
|
+
},
|
|
33
|
+
defaultTimeZone: {
|
|
34
|
+
removedIn: '25.0.0',
|
|
35
|
+
instead:
|
|
36
|
+
"nothing read it; there is no ambient zone — pass one at every call: formatDate(at, { locale, zone: 'Europe/Paris' }), task({ tz }), userActor({ tz })",
|
|
37
|
+
},
|
|
38
|
+
defaultCurrency: {
|
|
39
|
+
removedIn: '25.0.0',
|
|
40
|
+
instead:
|
|
41
|
+
"nothing read it; every Money carries its own currency ({ minor, currency: 'USD' }) — an app that wants a default declares its own constant in an app module",
|
|
42
|
+
},
|
|
43
|
+
'jobs.driver': {
|
|
44
|
+
removedIn: '5.0.0',
|
|
45
|
+
instead:
|
|
46
|
+
'nothing read it and boot always built Postgres; the driver is code — setJobDriver(postgresJobDriver({ executor })) from @ultimat3/jobs, or setJobDriver(memoryJobDriver()) in a test',
|
|
47
|
+
},
|
|
48
|
+
'realtime.heartbeatMs': {
|
|
49
|
+
removedIn: '4.0.0',
|
|
50
|
+
instead:
|
|
51
|
+
"nothing read it; the page socket's beat is the sync node's, named in its hello reply, and the presence beat is a third of the presence ttl",
|
|
52
|
+
},
|
|
53
|
+
'database.urlEnv': {
|
|
54
|
+
removedIn: '4.0.0',
|
|
55
|
+
instead:
|
|
56
|
+
'nothing read it; the connection string is the DATABASE_URL environment variable, read by @ultimat3/db',
|
|
57
|
+
},
|
|
58
|
+
'database.poolSize': {
|
|
59
|
+
removedIn: '4.0.0',
|
|
60
|
+
instead:
|
|
61
|
+
'nothing read it; the pool is sized by the DATABASE_POOL_MAX environment variable, per process',
|
|
62
|
+
},
|
|
63
|
+
'database.schema': {
|
|
64
|
+
removedIn: '4.0.0',
|
|
65
|
+
instead:
|
|
66
|
+
'nothing read it and nothing emits SET search_path; entity() tables live in public, and there is no replacement',
|
|
67
|
+
},
|
|
68
|
+
'pwa.installPrompt': {
|
|
69
|
+
removedIn: '8.0.0',
|
|
70
|
+
instead:
|
|
71
|
+
'nothing read it; call installController() from @ultimat3/pwa in your own install affordance',
|
|
72
|
+
},
|
|
73
|
+
'auth.afterSignInPath': {
|
|
74
|
+
removedIn: '8.0.0',
|
|
75
|
+
instead:
|
|
76
|
+
'nothing read it; send the visitor where you mean from your sign-in route, the only code that can honour it',
|
|
77
|
+
},
|
|
78
|
+
'ai.modelEnv': {
|
|
79
|
+
removedIn: '8.0.0',
|
|
80
|
+
instead:
|
|
81
|
+
"nothing read it; the model is the prompt's own, or llm({ model }) from @ultimat3/ai — read your own env key and pass it there",
|
|
82
|
+
},
|
|
83
|
+
'cache.driver': {
|
|
84
|
+
removedIn: '9.0.0',
|
|
85
|
+
instead:
|
|
86
|
+
"cache.tiers is the one selector — name the rung: cache: { tiers: ['request-memo', 'lru', 'redis'] }",
|
|
87
|
+
},
|
|
88
|
+
'cache.urlEnv': {
|
|
89
|
+
removedIn: '9.0.0',
|
|
90
|
+
instead:
|
|
91
|
+
"nothing read it; the redis tier reads the REDIS_URL environment variable, so name 'redis' in cache.tiers and set REDIS_URL",
|
|
92
|
+
},
|
|
93
|
+
'realtime.tier': {
|
|
94
|
+
removedIn: '10.0.0',
|
|
95
|
+
instead:
|
|
96
|
+
"nothing read it; an app's realtime tier is what it declares — a channel() topic, a live: true query, persist: true on an entity",
|
|
97
|
+
},
|
|
98
|
+
'theme.tokens': {
|
|
99
|
+
removedIn: '25.0.0',
|
|
100
|
+
instead:
|
|
101
|
+
"nothing read it; the app's theme is declared once with export const brand = defineTheme({ … }) from @ultimat3/ui, in apps/web/shared/theme.ts",
|
|
102
|
+
},
|
|
103
|
+
'ai.mcp.path': {
|
|
104
|
+
removedIn: '25.0.0',
|
|
105
|
+
instead:
|
|
106
|
+
"an MCP endpoint's path is its own: defineAppMcp({ path: '/mcp' }) from @ultimat3/mcp, in apps/<app>/mcp.ts — the default is /mcp, and every endpoint mounts where its own metadata says",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const said = (layer: unknown, path: string): boolean => {
|
|
111
|
+
let at: unknown = layer;
|
|
112
|
+
for (const segment of path.split('.')) {
|
|
113
|
+
if (!isJsonObject(at) || !Object.hasOwn(at, segment)) return false;
|
|
114
|
+
at = at[segment];
|
|
115
|
+
}
|
|
116
|
+
// `undefined` is a layer not saying, the rule every other key follows.
|
|
117
|
+
return at !== undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The removed keys one layer still writes, in table order. */
|
|
121
|
+
export function removedKeysIn(layer: unknown): readonly string[] {
|
|
122
|
+
return Object.keys(REMOVED_CONFIG_KEYS).filter((path) => said(layer, path));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One cause line per removed key, naming the major. */
|
|
126
|
+
export const removedKeyIssue = (path: string): string =>
|
|
127
|
+
`${path} was removed in ${REMOVED_CONFIG_KEYS[path]?.removedIn ?? 'a major'} and is no longer read`;
|
|
128
|
+
|
|
129
|
+
/** One instruction per removed key: delete the line, and what replaces it. */
|
|
130
|
+
export const removedKeyFix = (path: string): string =>
|
|
131
|
+
`delete ${path} from app.config.ts — ${REMOVED_CONFIG_KEYS[path]?.instead ?? ''}`;
|
package/src/config-shape.ts
CHANGED
|
@@ -77,36 +77,3 @@ export function nameListIssues(
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
|
|
81
|
-
function canonicalTag(tag: unknown): string | undefined {
|
|
82
|
-
if (typeof tag !== 'string') return undefined;
|
|
83
|
-
try {
|
|
84
|
-
const canonical = Intl.getCanonicalLocales(tag);
|
|
85
|
-
return canonical.length === 1 ? canonical[0] : undefined;
|
|
86
|
-
} catch {
|
|
87
|
-
return undefined;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* The locale list and the tag that must be in it. Two spellings of ONE locale are refused: the
|
|
93
|
-
* list keys a catalog, a route prefix and an `hreflang` each, and `['EN', 'en']` is two of every
|
|
94
|
-
* one of them for a single language.
|
|
95
|
-
*/
|
|
96
|
-
export function localeIssues(tags: readonly unknown[], fallback: unknown, issues: string[]): void {
|
|
97
|
-
if (tags.length === 0) issues.push('locales must list at least one locale');
|
|
98
|
-
const seen = new Map<string, unknown>();
|
|
99
|
-
for (const tag of tags) {
|
|
100
|
-
const canonical = canonicalTag(tag);
|
|
101
|
-
if (canonical === undefined) {
|
|
102
|
-
issues.push(`locales contains ${said(tag)}, not a BCP-47 tag`);
|
|
103
|
-
} else if (seen.has(canonical)) {
|
|
104
|
-
issues.push(
|
|
105
|
-
`locales lists ${canonical} twice, as ${said(seen.get(canonical))} and ${said(tag)}`,
|
|
106
|
-
);
|
|
107
|
-
} else {
|
|
108
|
-
seen.set(canonical, tag);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (!tags.includes(fallback)) issues.push(`defaultLocale ${said(fallback)} is not in locales`);
|
|
112
|
-
}
|
package/src/config.ts
CHANGED
|
@@ -2,26 +2,28 @@
|
|
|
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';
|
|
6
5
|
// Same rule for the same reason: `app.config.ts` CONSUMES the cache tier names, it does not own
|
|
7
6
|
// them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
|
|
8
7
|
// drift into two vocabularies with no map between them (issue #293).
|
|
9
8
|
import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
|
|
9
|
+
import type { AiConfig, AiConfigInput } from './config-ai';
|
|
10
10
|
import { countIssue } from './config-count';
|
|
11
11
|
import { configDefaults } from './config-defaults';
|
|
12
|
-
import { BASE_FIX, CACHE_TIER_FIX
|
|
13
|
-
import type
|
|
14
|
-
import { readinessModeIssue } from './config-health';
|
|
12
|
+
import { BASE_FIX, CACHE_TIER_FIX } from './config-fixes';
|
|
13
|
+
import { type DrainConfig, type HealthConfig, readinessModeIssue } from './config-health';
|
|
15
14
|
import type { IslandsConfig, IslandsSectionInput } from './config-islands';
|
|
16
15
|
import { islandsIssues, mergeIslands } from './config-islands';
|
|
16
|
+
import { type JobsConcurrency, jobsConcurrencyIssues } from './config-jobs';
|
|
17
|
+
import { refuseUnknownKeys } from './config-keys';
|
|
18
|
+
import { type MailConfig, type MailSectionInput, mailIssues, mergeMail } from './config-mail';
|
|
17
19
|
import { type Input, lastSaid, layered } from './config-merge';
|
|
18
20
|
import type { NavigationConfig, NavigationSectionInput } from './config-navigation';
|
|
19
21
|
import { mergeNavigation, navigationIssues } from './config-navigation';
|
|
20
22
|
import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
|
|
21
23
|
import { PWA_FIX, pwaIssues } from './config-pwa';
|
|
24
|
+
import { removedKeyFix, removedKeyIssue, removedKeysIn } from './config-removed';
|
|
22
25
|
import {
|
|
23
26
|
booleanIssue,
|
|
24
|
-
localeIssues,
|
|
25
27
|
nameListIssues,
|
|
26
28
|
oneOfIssue,
|
|
27
29
|
routePathIssue,
|
|
@@ -29,11 +31,10 @@ import {
|
|
|
29
31
|
} from './config-shape';
|
|
30
32
|
import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
|
|
31
33
|
import { mergeSite, siteIssues } from './config-site';
|
|
34
|
+
import { drainIssues } from './drain-deadline';
|
|
32
35
|
import { describeValue } from './error-render';
|
|
33
36
|
import { ConfigInvalidError } from './errors';
|
|
34
|
-
import { readinessGraceIssue } from './lifecycle-grace';
|
|
35
37
|
import { ROLES, type Role } from './roles';
|
|
36
|
-
import { isIanaZoneName } from './time-zone-name';
|
|
37
38
|
|
|
38
39
|
export const THEME_MODES = ['light', 'dark', 'system'] as const;
|
|
39
40
|
export type ThemeMode = (typeof THEME_MODES)[number];
|
|
@@ -44,10 +45,12 @@ export type ThemeMode = (typeof THEME_MODES)[number];
|
|
|
44
45
|
export const REALTIME_TRANSPORTS = ['memory', 'nats'] as const;
|
|
45
46
|
export type RealtimeTransport = (typeof REALTIME_TRANSPORTS)[number];
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* No `tokens` (deleted in 25.0.0): read by nothing once the theme seam landed — the app's theme is
|
|
50
|
+
* `export const brand = defineTheme(…)` from `@ultimat3/ui`. A second theming path nothing read.
|
|
51
|
+
*/
|
|
47
52
|
export interface ThemeConfig {
|
|
48
53
|
readonly defaultMode: ThemeMode;
|
|
49
|
-
/** Semantic design tokens. Raw hex is a lint error in components, never here. */
|
|
50
|
-
readonly tokens: Readonly<Record<string, string>>;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
/**
|
|
@@ -133,7 +136,7 @@ export interface DatabaseConfig {
|
|
|
133
136
|
*/
|
|
134
137
|
export interface CacheConfig {
|
|
135
138
|
readonly defaultTtlMs: number;
|
|
136
|
-
/** Order is fixed by `
|
|
139
|
+
/** Order is fixed by `CACHE_TIERS`; listing order here selects rungs, it does not rank them. */
|
|
137
140
|
readonly tiers: readonly CacheTierName[];
|
|
138
141
|
}
|
|
139
142
|
|
|
@@ -142,17 +145,21 @@ const JOB_BACKOFFS = ['exponential', 'fixed'] as const;
|
|
|
142
145
|
export interface JobsConfig {
|
|
143
146
|
/**
|
|
144
147
|
* No `driver`. It accepted `'postgres' | 'redis' | 'nats'`, was read by NOTHING, and boot always
|
|
145
|
-
* built
|
|
146
|
-
* silently gave you Postgres. Deleted 2026-08-20
|
|
147
|
-
* `
|
|
148
|
+
* built the Postgres driver — so `jobs: { driver: 'redis' }` did not throw, did not warn, and
|
|
149
|
+
* silently gave you Postgres. Deleted 2026-08-20 (5.0.0); since 25.0.0 a config still writing it
|
|
150
|
+
* is REFUSED by name (`config-removed.ts`) rather than carried through the spread.
|
|
148
151
|
*
|
|
149
|
-
* The seam that works is `setJobDriver(driver)` —
|
|
150
|
-
* or `setJobDriver(
|
|
151
|
-
* is the whole of what the `JobDriver`
|
|
152
|
-
* cannot be honoured is worse than none.
|
|
152
|
+
* The seam that works is `setJobDriver(driver)` —
|
|
153
|
+
* `setJobDriver(postgresJobDriver({ executor }))`, or `setJobDriver(memoryJobDriver())` in a
|
|
154
|
+
* test. Swap the driver, zero job-code change, which is the whole of what the `JobDriver`
|
|
155
|
+
* interface buys. There is no config line, and one that cannot be honoured is worse than none.
|
|
153
156
|
*/
|
|
154
157
|
readonly queues: readonly string[];
|
|
155
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Slots per worker process: one number for every queue it serves, or a table per queue
|
|
160
|
+
* (`{ banks: 4, 'banks-long': 2 }`, a queue it does not name at `JOBS_CONCURRENCY_DEFAULT`).
|
|
161
|
+
*/
|
|
162
|
+
readonly concurrency: JobsConcurrency;
|
|
156
163
|
readonly maxAttempts: number;
|
|
157
164
|
readonly backoff: (typeof JOB_BACKOFFS)[number];
|
|
158
165
|
readonly visibilityTimeoutMs: number;
|
|
@@ -167,37 +174,28 @@ export interface JobsConfig {
|
|
|
167
174
|
*
|
|
168
175
|
* No `tier` either (deleted 2026-08-23): no file read it, so `tier: 'local-first'` bought nothing.
|
|
169
176
|
* An app's realtime tier is what it DECLARES — a `channel()` topic, a `live: true` query, a local
|
|
170
|
-
* store — never a config key
|
|
177
|
+
* store — never a config key. `transport`, `urlEnv` and the two per-actor caps (read by the `sync`
|
|
178
|
+
* role into its registry and node) are the fields code reads.
|
|
171
179
|
*/
|
|
172
180
|
export interface RealtimeConfig {
|
|
173
181
|
readonly enabled: boolean;
|
|
174
182
|
readonly transport: RealtimeTransport;
|
|
175
183
|
readonly urlEnv: string | undefined;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
readonly
|
|
180
|
-
readonly path: string;
|
|
184
|
+
/** Live subscriptions one actor (or anonymous network) may hold per sync node. Unset: 1,000. */
|
|
185
|
+
readonly maxSubscriptionsPerActor?: number | undefined;
|
|
186
|
+
/** Sockets one actor (or anonymous network) may hold per sync node. Unset: 16. */
|
|
187
|
+
readonly maxSocketsPerActor?: number | undefined;
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
/**
|
|
184
|
-
* No `
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* Deleted 2026-08 — pass `model` on the request, or read your own env key and pass it.
|
|
191
|
+
* No `locales` / `defaultLocale`, `defaultTimeZone` or `defaultCurrency` (deleted in 25.0.0, and
|
|
192
|
+
* REFUSED by name when written — `config-removed.ts`). The locales were a second declaration of
|
|
193
|
+
* `defineCatalogs({ default })`; the zone and the currency were read by nothing, and an ambient
|
|
194
|
+
* default for either is the defect the framework forbids (every format call takes its zone, every
|
|
195
|
+
* `Money` its currency).
|
|
190
196
|
*/
|
|
191
|
-
export interface AiConfig {
|
|
192
|
-
readonly mcp: McpConfig;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
197
|
export interface AppConfig {
|
|
196
198
|
readonly name: string;
|
|
197
|
-
readonly locales: readonly string[];
|
|
198
|
-
readonly defaultLocale: string;
|
|
199
|
-
readonly defaultTimeZone: string;
|
|
200
|
-
readonly defaultCurrency: string;
|
|
201
199
|
readonly theme: ThemeConfig;
|
|
202
200
|
readonly auth: AuthConfig;
|
|
203
201
|
readonly pwa: PwaConfig;
|
|
@@ -214,11 +212,7 @@ export interface AppConfig {
|
|
|
214
212
|
readonly seo: SeoConfig;
|
|
215
213
|
readonly navigation: NavigationConfig;
|
|
216
214
|
readonly islands: IslandsConfig;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
/** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
|
|
220
|
-
export interface AiConfigInput {
|
|
221
|
-
readonly mcp?: Input<McpConfig> | undefined;
|
|
215
|
+
readonly mail: MailConfig;
|
|
222
216
|
}
|
|
223
217
|
|
|
224
218
|
/**
|
|
@@ -235,12 +229,9 @@ export interface PwaConfigInput extends Omit<Input<PwaConfig>, 'offline'> {
|
|
|
235
229
|
export interface AppConfigInput
|
|
236
230
|
extends SiteSectionsInput,
|
|
237
231
|
NavigationSectionInput,
|
|
238
|
-
IslandsSectionInput
|
|
232
|
+
IslandsSectionInput,
|
|
233
|
+
MailSectionInput {
|
|
239
234
|
readonly name: string;
|
|
240
|
-
readonly locales?: readonly string[] | undefined;
|
|
241
|
-
readonly defaultLocale?: string | undefined;
|
|
242
|
-
readonly defaultTimeZone?: string | undefined;
|
|
243
|
-
readonly defaultCurrency?: string | undefined;
|
|
244
235
|
readonly theme?: Input<ThemeConfig> | undefined;
|
|
245
236
|
readonly auth?: Input<AuthConfig> | undefined;
|
|
246
237
|
readonly pwa?: PwaConfigInput | undefined;
|
|
@@ -260,21 +251,10 @@ export type AppConfigOverlay = Omit<AppConfigInput, 'name'> & { readonly name?:
|
|
|
260
251
|
|
|
261
252
|
const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
262
253
|
|
|
263
|
-
/**
|
|
264
|
-
* Built from `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the framework's ONE declaration of what
|
|
265
|
-
* an ISO 4217 code looks like — the same source `isCurrencyCode`, the published OpenAPI `pattern`
|
|
266
|
-
* and `@ultimat3/entity`'s Postgres CHECK all derive from. It was a character-for-character copy
|
|
267
|
-
* here until the `core -> schema` edge was declared (`scripts/lib/tiers.ts`), held equal only by a
|
|
268
|
-
* pin test in `@ultimat3/cli`.
|
|
269
|
-
*/
|
|
270
|
-
const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
|
|
271
|
-
|
|
272
254
|
function validate(config: AppConfig): void {
|
|
273
255
|
const issues: string[] = [];
|
|
274
|
-
// Zero or one entry
|
|
275
|
-
|
|
276
|
-
// Same shape, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']` in an
|
|
277
|
-
// untyped config file reaches here rather than the compiler, and needs the new spelling.
|
|
256
|
+
// Zero or one entry, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']`
|
|
257
|
+
// in an untyped config file reaches here rather than the compiler, and needs the new spelling.
|
|
278
258
|
const tierFix: string[] = [];
|
|
279
259
|
// Same shape again: carried only when an installable app is missing what an install needs.
|
|
280
260
|
const pwaFix: string[] = [];
|
|
@@ -285,26 +265,13 @@ function validate(config: AppConfig): void {
|
|
|
285
265
|
} else if (!NAME_RE.test(config.name)) {
|
|
286
266
|
issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
|
|
287
267
|
}
|
|
288
|
-
localeIssues(config.locales, config.defaultLocale, issues);
|
|
289
|
-
// `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
|
|
290
|
-
// `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
|
|
291
|
-
// `format` call, `task()` and `toZoned` below it can then do arithmetic in.
|
|
292
|
-
if (!isIanaZoneName(config.defaultTimeZone)) {
|
|
293
|
-
issues.push(
|
|
294
|
-
`defaultTimeZone "${config.defaultTimeZone}" is not an IANA Area/Location zone name`,
|
|
295
|
-
);
|
|
296
|
-
zoneFix.push(TIMEZONE_FIX);
|
|
297
|
-
}
|
|
298
|
-
if (!CURRENCY_RE.test(config.defaultCurrency)) {
|
|
299
|
-
issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
|
|
300
|
-
}
|
|
301
268
|
if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
|
|
302
269
|
// ONE check per key, each against the key's own domain. A number is never a bare `< 1` — every
|
|
303
270
|
// comparison with `NaN` is false, so `concurrency < 1` passed `NaN`, `2.5` and `Infinity` — and
|
|
304
271
|
// a switch is never read for truthiness: `ssl: 'false'` and `enabled: 'false'` were both ON.
|
|
305
272
|
const perKey: readonly (string | undefined)[] = [
|
|
306
273
|
...config.roles.map((role) => oneOfIssue('roles', role, ROLES)),
|
|
307
|
-
|
|
274
|
+
...jobsConcurrencyIssues(config.jobs.concurrency),
|
|
308
275
|
countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
|
|
309
276
|
countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
|
|
310
277
|
oneOfIssue('jobs.backoff', config.jobs.backoff, JOB_BACKOFFS),
|
|
@@ -317,10 +284,14 @@ function validate(config: AppConfig): void {
|
|
|
317
284
|
? undefined
|
|
318
285
|
: routePathIssue('auth.signInPath', config.auth.signInPath),
|
|
319
286
|
booleanIssue('ai.mcp.expose', config.ai.mcp.expose),
|
|
320
|
-
routePathIssue('ai.mcp.path', config.ai.mcp.path),
|
|
321
287
|
booleanIssue('realtime.enabled', config.realtime.enabled),
|
|
322
288
|
oneOfIssue('realtime.transport', config.realtime.transport, REALTIME_TRANSPORTS),
|
|
323
|
-
|
|
289
|
+
...(['maxSubscriptionsPerActor', 'maxSocketsPerActor'] as const).map((key) =>
|
|
290
|
+
config.realtime[key] === undefined
|
|
291
|
+
? undefined
|
|
292
|
+
: countIssue(`realtime.${key}`, config.realtime[key], 1),
|
|
293
|
+
),
|
|
294
|
+
...drainIssues(config.drain),
|
|
324
295
|
readinessModeIssue(config.health.readiness),
|
|
325
296
|
];
|
|
326
297
|
for (const issue of perKey) if (issue !== undefined) issues.push(issue);
|
|
@@ -350,6 +321,7 @@ function validate(config: AppConfig): void {
|
|
|
350
321
|
siteIssues(config, issues);
|
|
351
322
|
navigationIssues(config, issues);
|
|
352
323
|
islandsIssues(config, issues);
|
|
324
|
+
mailIssues(config, issues);
|
|
353
325
|
|
|
354
326
|
// A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
|
|
355
327
|
// index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
|
|
@@ -368,7 +340,7 @@ function validate(config: AppConfig): void {
|
|
|
368
340
|
cause: issues.join('; '),
|
|
369
341
|
// The generic instruction goes LAST so the fix line still ends in a command that can be
|
|
370
342
|
// pasted — a trailing `.` after `x verify` is a command nobody can run.
|
|
371
|
-
fix: [...
|
|
343
|
+
fix: [...tierFix, ...pwaFix, BASE_FIX].join('. '),
|
|
372
344
|
meta: { issues },
|
|
373
345
|
});
|
|
374
346
|
}
|
|
@@ -383,22 +355,6 @@ function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
|
|
|
383
355
|
const base = configDefaults(name);
|
|
384
356
|
return {
|
|
385
357
|
name,
|
|
386
|
-
locales: lastSaid(
|
|
387
|
-
base.locales,
|
|
388
|
-
layers.map((layer) => layer.locales),
|
|
389
|
-
),
|
|
390
|
-
defaultLocale: lastSaid(
|
|
391
|
-
base.defaultLocale,
|
|
392
|
-
layers.map((layer) => layer.defaultLocale),
|
|
393
|
-
),
|
|
394
|
-
defaultTimeZone: lastSaid(
|
|
395
|
-
base.defaultTimeZone,
|
|
396
|
-
layers.map((layer) => layer.defaultTimeZone),
|
|
397
|
-
),
|
|
398
|
-
defaultCurrency: lastSaid(
|
|
399
|
-
base.defaultCurrency,
|
|
400
|
-
layers.map((layer) => layer.defaultCurrency),
|
|
401
|
-
),
|
|
402
358
|
theme: layered(
|
|
403
359
|
base.theme,
|
|
404
360
|
layers.map((layer) => layer.theme),
|
|
@@ -461,9 +417,27 @@ function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
|
|
|
461
417
|
...mergeSite(layers),
|
|
462
418
|
...mergeNavigation(layers),
|
|
463
419
|
...mergeIslands(layers),
|
|
420
|
+
...mergeMail(layers),
|
|
464
421
|
};
|
|
465
422
|
}
|
|
466
423
|
|
|
424
|
+
/**
|
|
425
|
+
* FIRST, before shape: a removed key is an instruction the app is still giving, and the answer is
|
|
426
|
+
* the line to delete and what replaces it — never silence, and never a shape complaint about a
|
|
427
|
+
* section (`theme.tokens: null`) that no longer has the key at all. Every layer is asked, so a
|
|
428
|
+
* stale `config/theme.ts` overlay is caught as surely as the base.
|
|
429
|
+
*/
|
|
430
|
+
function refuseRemovedKeys(layers: readonly unknown[]): void {
|
|
431
|
+
const removed = [...new Set(layers.flatMap((layer) => removedKeysIn(layer)))];
|
|
432
|
+
if (removed.length === 0) return;
|
|
433
|
+
const issues = removed.map(removedKeyIssue);
|
|
434
|
+
throw new ConfigInvalidError({
|
|
435
|
+
cause: issues.join('; '),
|
|
436
|
+
fix: [...removed.map(removedKeyFix), BASE_FIX].join('. '),
|
|
437
|
+
meta: { issues, removed },
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
467
441
|
/**
|
|
468
442
|
* The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
|
|
469
443
|
* touching `app.config.ts`.
|
|
@@ -473,12 +447,15 @@ export function defineConfig(
|
|
|
473
447
|
...overlays: readonly AppConfigOverlay[]
|
|
474
448
|
): AppConfig {
|
|
475
449
|
const layers: readonly AppConfigOverlay[] = [input, ...overlays];
|
|
450
|
+
refuseRemovedKeys(layers);
|
|
476
451
|
// Structure FIRST, per layer and before the merge: a section written as `null` or a list
|
|
477
452
|
// written as a string is what `Object.entries` and `.length` raised a native `TypeError` on.
|
|
478
453
|
const issues: string[] = [];
|
|
479
454
|
// `navigation` is left out: `config-navigation.ts` carries a wrong shape through AS WRITTEN and
|
|
480
455
|
// refuses it in its own words, with the surfaces it accepts.
|
|
481
456
|
const reference = { ...merge(input.name, []), navigation: undefined };
|
|
457
|
+
// Closed BEFORE shape: a typo'd section (`drian: null`) is the wrong name, not the wrong kind.
|
|
458
|
+
refuseUnknownKeys(reference, layers);
|
|
482
459
|
for (const layer of layers) shapeIssues(reference, layer, issues);
|
|
483
460
|
if (issues.length > 0) {
|
|
484
461
|
throw new ConfigInvalidError({ cause: issues.join('; '), fix: BASE_FIX, meta: { issues } });
|