@ultimat3/core 23.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 +29 -26
- package/README.md +98 -30
- 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 +53 -0
- 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 +9 -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 +79 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +141 -173
- package/src/context.ts +29 -13
- package/src/cookie.ts +299 -0
- package/src/core-error-codes.ts +2 -0
- package/src/cursor-page.ts +41 -0
- package/src/cursor.ts +26 -5
- package/src/decimal-order.ts +5 -4
- 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/error-reporter-sentry.ts +7 -3
- 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 +43 -16
- package/src/fnv1a.ts +19 -0
- package/src/generation-fence.ts +1 -1
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/ids.ts +7 -7
- package/src/image/canvas.ts +76 -5
- package/src/image/errors.ts +3 -1
- package/src/image/pipeline.ts +17 -5
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +27 -2
- package/src/index.ts +99 -33
- 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 +92 -16
- 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/nearest-name.ts +11 -2
- package/src/otlp-metric-exporter.ts +1 -1
- package/src/otlp-span-exporter.ts +1 -1
- package/src/otlp.ts +44 -13
- package/src/page.ts +4 -2
- package/src/pg-executor.ts +15 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +22 -4
- package/src/retry.ts +40 -7
- package/src/route-rank.ts +36 -0
- package/src/same-origin.ts +1 -1
- package/src/sampler.ts +6 -2
- package/src/secrets-errors.ts +14 -3
- 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/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
- 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.ts
CHANGED
|
@@ -2,31 +2,42 @@
|
|
|
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
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import { readinessModeIssue } from './config-health';
|
|
11
|
+
import { configDefaults } from './config-defaults';
|
|
12
|
+
import { BASE_FIX, CACHE_TIER_FIX } from './config-fixes';
|
|
13
|
+
import { type DrainConfig, type HealthConfig, readinessModeIssue } from './config-health';
|
|
14
14
|
import type { IslandsConfig, IslandsSectionInput } from './config-islands';
|
|
15
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';
|
|
16
19
|
import { type Input, lastSaid, layered } from './config-merge';
|
|
17
20
|
import type { NavigationConfig, NavigationSectionInput } from './config-navigation';
|
|
18
21
|
import { mergeNavigation, navigationIssues } from './config-navigation';
|
|
19
22
|
import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
|
|
20
23
|
import { PWA_FIX, pwaIssues } from './config-pwa';
|
|
24
|
+
import { removedKeyFix, removedKeyIssue, removedKeysIn } from './config-removed';
|
|
25
|
+
import {
|
|
26
|
+
booleanIssue,
|
|
27
|
+
nameListIssues,
|
|
28
|
+
oneOfIssue,
|
|
29
|
+
routePathIssue,
|
|
30
|
+
shapeIssues,
|
|
31
|
+
} from './config-shape';
|
|
21
32
|
import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
|
|
22
33
|
import { mergeSite, siteIssues } from './config-site';
|
|
34
|
+
import { drainIssues } from './drain-deadline';
|
|
23
35
|
import { describeValue } from './error-render';
|
|
24
36
|
import { ConfigInvalidError } from './errors';
|
|
25
|
-
import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
|
|
26
37
|
import { ROLES, type Role } from './roles';
|
|
27
|
-
import { isIanaZoneName } from './time-zone-name';
|
|
28
38
|
|
|
29
|
-
export
|
|
39
|
+
export const THEME_MODES = ['light', 'dark', 'system'] as const;
|
|
40
|
+
export type ThemeMode = (typeof THEME_MODES)[number];
|
|
30
41
|
/**
|
|
31
42
|
* The buses `@ultimat3/realtime`'s `selectTransport` builds, and nothing else. `'redis'` was in
|
|
32
43
|
* this union until 22.0.0 with no Redis transport anywhere: it booted whatever `NATS_URL` chose.
|
|
@@ -34,10 +45,12 @@ export type ThemeMode = 'light' | 'dark' | 'system';
|
|
|
34
45
|
export const REALTIME_TRANSPORTS = ['memory', 'nats'] as const;
|
|
35
46
|
export type RealtimeTransport = (typeof REALTIME_TRANSPORTS)[number];
|
|
36
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
|
+
*/
|
|
37
52
|
export interface ThemeConfig {
|
|
38
53
|
readonly defaultMode: ThemeMode;
|
|
39
|
-
/** Semantic design tokens. Raw hex is a lint error in components, never here. */
|
|
40
|
-
readonly tokens: Readonly<Record<string, string>>;
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
/**
|
|
@@ -99,8 +112,10 @@ export const INBOX_RETENTION_KEYS = ['inboxReadRetentionMs', 'inboxUnreadRetenti
|
|
|
99
112
|
* 3 applied to configuration: a value that produces neither a build error nor a runtime effect is
|
|
100
113
|
* worse than no field, because an SRE sets `poolSize: 3`, redeploys, and nothing changes.
|
|
101
114
|
*/
|
|
115
|
+
const DATABASE_DRIVERS = ['postgres'] as const;
|
|
116
|
+
|
|
102
117
|
export interface DatabaseConfig {
|
|
103
|
-
readonly driver:
|
|
118
|
+
readonly driver: (typeof DATABASE_DRIVERS)[number];
|
|
104
119
|
readonly ssl: boolean;
|
|
105
120
|
}
|
|
106
121
|
|
|
@@ -121,26 +136,32 @@ export interface DatabaseConfig {
|
|
|
121
136
|
*/
|
|
122
137
|
export interface CacheConfig {
|
|
123
138
|
readonly defaultTtlMs: number;
|
|
124
|
-
/** Order is fixed by `
|
|
139
|
+
/** Order is fixed by `CACHE_TIERS`; listing order here selects rungs, it does not rank them. */
|
|
125
140
|
readonly tiers: readonly CacheTierName[];
|
|
126
141
|
}
|
|
127
142
|
|
|
143
|
+
const JOB_BACKOFFS = ['exponential', 'fixed'] as const;
|
|
144
|
+
|
|
128
145
|
export interface JobsConfig {
|
|
129
146
|
/**
|
|
130
147
|
* No `driver`. It accepted `'postgres' | 'redis' | 'nats'`, was read by NOTHING, and boot always
|
|
131
|
-
* built
|
|
132
|
-
* silently gave you Postgres. Deleted 2026-08-20
|
|
133
|
-
* `
|
|
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.
|
|
134
151
|
*
|
|
135
|
-
* The seam that works is `setJobDriver(driver)` —
|
|
136
|
-
* or `setJobDriver(
|
|
137
|
-
* is the whole of what the `JobDriver`
|
|
138
|
-
* 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.
|
|
139
156
|
*/
|
|
140
157
|
readonly queues: readonly string[];
|
|
141
|
-
|
|
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;
|
|
142
163
|
readonly maxAttempts: number;
|
|
143
|
-
readonly backoff:
|
|
164
|
+
readonly backoff: (typeof JOB_BACKOFFS)[number];
|
|
144
165
|
readonly visibilityTimeoutMs: number;
|
|
145
166
|
}
|
|
146
167
|
|
|
@@ -153,37 +174,28 @@ export interface JobsConfig {
|
|
|
153
174
|
*
|
|
154
175
|
* No `tier` either (deleted 2026-08-23): no file read it, so `tier: 'local-first'` bought nothing.
|
|
155
176
|
* An app's realtime tier is what it DECLARES — a `channel()` topic, a `live: true` query, a local
|
|
156
|
-
* 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.
|
|
157
179
|
*/
|
|
158
180
|
export interface RealtimeConfig {
|
|
159
181
|
readonly enabled: boolean;
|
|
160
182
|
readonly transport: RealtimeTransport;
|
|
161
183
|
readonly urlEnv: string | undefined;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
readonly
|
|
166
|
-
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;
|
|
167
188
|
}
|
|
168
189
|
|
|
169
190
|
/**
|
|
170
|
-
* No `
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* 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).
|
|
176
196
|
*/
|
|
177
|
-
export interface AiConfig {
|
|
178
|
-
readonly mcp: McpConfig;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
197
|
export interface AppConfig {
|
|
182
198
|
readonly name: string;
|
|
183
|
-
readonly locales: readonly string[];
|
|
184
|
-
readonly defaultLocale: string;
|
|
185
|
-
readonly defaultTimeZone: string;
|
|
186
|
-
readonly defaultCurrency: string;
|
|
187
199
|
readonly theme: ThemeConfig;
|
|
188
200
|
readonly auth: AuthConfig;
|
|
189
201
|
readonly pwa: PwaConfig;
|
|
@@ -200,11 +212,7 @@ export interface AppConfig {
|
|
|
200
212
|
readonly seo: SeoConfig;
|
|
201
213
|
readonly navigation: NavigationConfig;
|
|
202
214
|
readonly islands: IslandsConfig;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
/** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
|
|
206
|
-
export interface AiConfigInput {
|
|
207
|
-
readonly mcp?: Input<McpConfig> | undefined;
|
|
215
|
+
readonly mail: MailConfig;
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
/**
|
|
@@ -221,12 +229,9 @@ export interface PwaConfigInput extends Omit<Input<PwaConfig>, 'offline'> {
|
|
|
221
229
|
export interface AppConfigInput
|
|
222
230
|
extends SiteSectionsInput,
|
|
223
231
|
NavigationSectionInput,
|
|
224
|
-
IslandsSectionInput
|
|
232
|
+
IslandsSectionInput,
|
|
233
|
+
MailSectionInput {
|
|
225
234
|
readonly name: string;
|
|
226
|
-
readonly locales?: readonly string[] | undefined;
|
|
227
|
-
readonly defaultLocale?: string | undefined;
|
|
228
|
-
readonly defaultTimeZone?: string | undefined;
|
|
229
|
-
readonly defaultCurrency?: string | undefined;
|
|
230
235
|
readonly theme?: Input<ThemeConfig> | undefined;
|
|
231
236
|
readonly auth?: Input<AuthConfig> | undefined;
|
|
232
237
|
readonly pwa?: PwaConfigInput | undefined;
|
|
@@ -246,113 +251,52 @@ export type AppConfigOverlay = Omit<AppConfigInput, 'name'> & { readonly name?:
|
|
|
246
251
|
|
|
247
252
|
const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
248
253
|
|
|
249
|
-
/**
|
|
250
|
-
* Built from `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the framework's ONE declaration of what
|
|
251
|
-
* an ISO 4217 code looks like — the same source `isCurrencyCode`, the published OpenAPI `pattern`
|
|
252
|
-
* and `@ultimat3/entity`'s Postgres CHECK all derive from. It was a character-for-character copy
|
|
253
|
-
* here until the `core -> schema` edge was declared (`scripts/lib/tiers.ts`), held equal only by a
|
|
254
|
-
* pin test in `@ultimat3/cli`.
|
|
255
|
-
*/
|
|
256
|
-
const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
|
|
257
|
-
|
|
258
|
-
function isLocale(value: string): boolean {
|
|
259
|
-
try {
|
|
260
|
-
return Intl.getCanonicalLocales(value).length === 1;
|
|
261
|
-
} catch {
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
type Sectioned = 'name' | 'site' | 'seo' | 'navigation' | 'islands';
|
|
267
|
-
function defaults(name: string): Omit<AppConfig, Sectioned> {
|
|
268
|
-
return {
|
|
269
|
-
locales: ['en'],
|
|
270
|
-
defaultLocale: 'en',
|
|
271
|
-
defaultTimeZone: 'UTC',
|
|
272
|
-
defaultCurrency: 'USD',
|
|
273
|
-
theme: { defaultMode: 'system', tokens: {} },
|
|
274
|
-
auth: { signInPath: null },
|
|
275
|
-
pwa: {
|
|
276
|
-
enabled: false,
|
|
277
|
-
offline: { fallback: null, image: null, font: null, neverCache: [], personalPages: 'never' },
|
|
278
|
-
backgroundSync: false,
|
|
279
|
-
push: false,
|
|
280
|
-
name: '',
|
|
281
|
-
colors: undefined,
|
|
282
|
-
},
|
|
283
|
-
roles: [...ROLES],
|
|
284
|
-
database: { driver: 'postgres', ssl: false },
|
|
285
|
-
cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
|
|
286
|
-
jobs: {
|
|
287
|
-
queues: [`${name}-default`],
|
|
288
|
-
concurrency: 8,
|
|
289
|
-
maxAttempts: 5,
|
|
290
|
-
backoff: 'exponential',
|
|
291
|
-
visibilityTimeoutMs: 30_000,
|
|
292
|
-
},
|
|
293
|
-
// ON by default since 22.0.0, when the boot began obeying the key: an app with no section
|
|
294
|
-
// keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
|
|
295
|
-
realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
|
|
296
|
-
notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
|
|
297
|
-
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
298
|
-
// Read from the process env when the config is DEFINED — the same env the drain will run in.
|
|
299
|
-
drain: { readinessGraceMs: defaultReadinessGraceMs() },
|
|
300
|
-
health: { readiness: 'dependencies' },
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
|
|
304
254
|
function validate(config: AppConfig): void {
|
|
305
255
|
const issues: string[] = [];
|
|
306
|
-
// Zero or one entry
|
|
307
|
-
|
|
308
|
-
// Same shape, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']` in an
|
|
309
|
-
// 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.
|
|
310
258
|
const tierFix: string[] = [];
|
|
311
259
|
// Same shape again: carried only when an installable app is missing what an install needs.
|
|
312
260
|
const pwaFix: string[] = [];
|
|
313
261
|
|
|
314
|
-
|
|
262
|
+
// `typeof` first: `NAME_RE.test(undefined)` tests the string "undefined", which matches.
|
|
263
|
+
if (typeof config.name !== 'string') {
|
|
264
|
+
issues.push(`name must be a string like "my-app", not ${describeValue(config.name)}`);
|
|
265
|
+
} else if (!NAME_RE.test(config.name)) {
|
|
315
266
|
issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
|
|
316
267
|
}
|
|
317
|
-
if (config.locales.length === 0) issues.push('locales must list at least one locale');
|
|
318
|
-
for (const locale of config.locales) {
|
|
319
|
-
if (!isLocale(locale)) issues.push(`locales contains "${locale}", not a BCP-47 tag`);
|
|
320
|
-
}
|
|
321
|
-
if (!config.locales.includes(config.defaultLocale)) {
|
|
322
|
-
issues.push(`defaultLocale "${config.defaultLocale}" is not in locales`);
|
|
323
|
-
}
|
|
324
|
-
// `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
|
|
325
|
-
// `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
|
|
326
|
-
// `format` call, `task()` and `toZoned` below it can then do arithmetic in.
|
|
327
|
-
if (!isIanaZoneName(config.defaultTimeZone)) {
|
|
328
|
-
issues.push(
|
|
329
|
-
`defaultTimeZone "${config.defaultTimeZone}" is not an IANA Area/Location zone name`,
|
|
330
|
-
);
|
|
331
|
-
zoneFix.push(TIMEZONE_FIX);
|
|
332
|
-
}
|
|
333
|
-
if (!CURRENCY_RE.test(config.defaultCurrency)) {
|
|
334
|
-
issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
|
|
335
|
-
}
|
|
336
268
|
if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
|
|
337
|
-
//
|
|
338
|
-
// `concurrency < 1` passed `NaN`, `2.5` and `Infinity
|
|
339
|
-
|
|
340
|
-
|
|
269
|
+
// ONE check per key, each against the key's own domain. A number is never a bare `< 1` — every
|
|
270
|
+
// comparison with `NaN` is false, so `concurrency < 1` passed `NaN`, `2.5` and `Infinity` — and
|
|
271
|
+
// a switch is never read for truthiness: `ssl: 'false'` and `enabled: 'false'` were both ON.
|
|
272
|
+
const perKey: readonly (string | undefined)[] = [
|
|
273
|
+
...config.roles.map((role) => oneOfIssue('roles', role, ROLES)),
|
|
274
|
+
...jobsConcurrencyIssues(config.jobs.concurrency),
|
|
341
275
|
countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
|
|
342
276
|
countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
|
|
277
|
+
oneOfIssue('jobs.backoff', config.jobs.backoff, JOB_BACKOFFS),
|
|
343
278
|
countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
|
|
344
|
-
|
|
279
|
+
oneOfIssue('database.driver', config.database.driver, DATABASE_DRIVERS),
|
|
280
|
+
booleanIssue('database.ssl', config.database.ssl),
|
|
281
|
+
oneOfIssue('theme.defaultMode', config.theme.defaultMode, THEME_MODES),
|
|
282
|
+
// `null` is the documented "no redirect"; a path that is said must be one a browser can follow.
|
|
283
|
+
config.auth.signInPath === null
|
|
284
|
+
? undefined
|
|
285
|
+
: routePathIssue('auth.signInPath', config.auth.signInPath),
|
|
286
|
+
booleanIssue('ai.mcp.expose', config.ai.mcp.expose),
|
|
287
|
+
booleanIssue('realtime.enabled', config.realtime.enabled),
|
|
288
|
+
oneOfIssue('realtime.transport', config.realtime.transport, REALTIME_TRANSPORTS),
|
|
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),
|
|
345
295
|
readinessModeIssue(config.health.readiness),
|
|
346
296
|
];
|
|
347
|
-
for (const issue of
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// anything else goes through `describeValue` rather than `${…}`.
|
|
351
|
-
const transport: unknown = config.realtime.transport;
|
|
352
|
-
if (!REALTIME_TRANSPORTS.some((known) => known === transport)) {
|
|
353
|
-
const said = typeof transport === 'string' ? `"${transport}"` : describeValue(transport);
|
|
354
|
-
issues.push(`realtime.transport ${said} is not one of ${REALTIME_TRANSPORTS.join(', ')}`);
|
|
355
|
-
} else if (transport === 'nats' && config.realtime.urlEnv === undefined) {
|
|
297
|
+
for (const issue of perKey) if (issue !== undefined) issues.push(issue);
|
|
298
|
+
nameListIssues('jobs.queues', config.jobs.queues, 'queue', issues);
|
|
299
|
+
if (config.realtime.transport === 'nats' && config.realtime.urlEnv === undefined) {
|
|
356
300
|
issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
|
|
357
301
|
}
|
|
358
302
|
// BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
|
|
@@ -377,10 +321,14 @@ function validate(config: AppConfig): void {
|
|
|
377
321
|
siteIssues(config, issues);
|
|
378
322
|
navigationIssues(config, issues);
|
|
379
323
|
islandsIssues(config, issues);
|
|
324
|
+
mailIssues(config, issues);
|
|
380
325
|
|
|
381
326
|
// A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
|
|
382
327
|
// index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
|
|
383
328
|
// So an unknown tier is refused at boot rather than silently ignored or silently placed first.
|
|
329
|
+
// An EMPTY ladder is refused with the unknown rung: it builds no tier at all, so every read
|
|
330
|
+
// misses and nothing says the cache was configured away.
|
|
331
|
+
if (config.cache.tiers.length === 0) issues.push('cache.tiers must list at least one tier');
|
|
384
332
|
for (const tier of config.cache.tiers) {
|
|
385
333
|
if (CACHE_TIERS.includes(tier)) continue;
|
|
386
334
|
issues.push(`cache.tiers contains "${tier}", which is not one of ${CACHE_TIERS.join(', ')}`);
|
|
@@ -392,43 +340,21 @@ function validate(config: AppConfig): void {
|
|
|
392
340
|
cause: issues.join('; '),
|
|
393
341
|
// The generic instruction goes LAST so the fix line still ends in a command that can be
|
|
394
342
|
// pasted — a trailing `.` after `x verify` is a command nobody can run.
|
|
395
|
-
fix: [...
|
|
343
|
+
fix: [...tierFix, ...pwaFix, BASE_FIX].join('. '),
|
|
396
344
|
meta: { issues },
|
|
397
345
|
});
|
|
398
346
|
}
|
|
399
347
|
}
|
|
400
348
|
|
|
401
349
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
350
|
+
* Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from the
|
|
351
|
+
* input alone because it identifies the app: an overlay may not rename it. No layer at all is the
|
|
352
|
+
* defaults, which is the reference `shapeIssues` compares each layer against.
|
|
404
353
|
*/
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const base = defaults(input.name);
|
|
410
|
-
// Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from
|
|
411
|
-
// the input alone because it identifies the app: an overlay may not rename it.
|
|
412
|
-
const layers: readonly AppConfigOverlay[] = [input, ...overlays];
|
|
413
|
-
|
|
414
|
-
const config: AppConfig = {
|
|
415
|
-
name: input.name,
|
|
416
|
-
locales: lastSaid(
|
|
417
|
-
base.locales,
|
|
418
|
-
layers.map((layer) => layer.locales),
|
|
419
|
-
),
|
|
420
|
-
defaultLocale: lastSaid(
|
|
421
|
-
base.defaultLocale,
|
|
422
|
-
layers.map((layer) => layer.defaultLocale),
|
|
423
|
-
),
|
|
424
|
-
defaultTimeZone: lastSaid(
|
|
425
|
-
base.defaultTimeZone,
|
|
426
|
-
layers.map((layer) => layer.defaultTimeZone),
|
|
427
|
-
),
|
|
428
|
-
defaultCurrency: lastSaid(
|
|
429
|
-
base.defaultCurrency,
|
|
430
|
-
layers.map((layer) => layer.defaultCurrency),
|
|
431
|
-
),
|
|
354
|
+
function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
|
|
355
|
+
const base = configDefaults(name);
|
|
356
|
+
return {
|
|
357
|
+
name,
|
|
432
358
|
theme: layered(
|
|
433
359
|
base.theme,
|
|
434
360
|
layers.map((layer) => layer.theme),
|
|
@@ -491,8 +417,50 @@ export function defineConfig(
|
|
|
491
417
|
...mergeSite(layers),
|
|
492
418
|
...mergeNavigation(layers),
|
|
493
419
|
...mergeIslands(layers),
|
|
420
|
+
...mergeMail(layers),
|
|
494
421
|
};
|
|
422
|
+
}
|
|
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
|
+
}
|
|
495
440
|
|
|
441
|
+
/**
|
|
442
|
+
* The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
|
|
443
|
+
* touching `app.config.ts`.
|
|
444
|
+
*/
|
|
445
|
+
export function defineConfig(
|
|
446
|
+
input: AppConfigInput,
|
|
447
|
+
...overlays: readonly AppConfigOverlay[]
|
|
448
|
+
): AppConfig {
|
|
449
|
+
const layers: readonly AppConfigOverlay[] = [input, ...overlays];
|
|
450
|
+
refuseRemovedKeys(layers);
|
|
451
|
+
// Structure FIRST, per layer and before the merge: a section written as `null` or a list
|
|
452
|
+
// written as a string is what `Object.entries` and `.length` raised a native `TypeError` on.
|
|
453
|
+
const issues: string[] = [];
|
|
454
|
+
// `navigation` is left out: `config-navigation.ts` carries a wrong shape through AS WRITTEN and
|
|
455
|
+
// refuses it in its own words, with the surfaces it accepts.
|
|
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);
|
|
459
|
+
for (const layer of layers) shapeIssues(reference, layer, issues);
|
|
460
|
+
if (issues.length > 0) {
|
|
461
|
+
throw new ConfigInvalidError({ cause: issues.join('; '), fix: BASE_FIX, meta: { issues } });
|
|
462
|
+
}
|
|
463
|
+
const config = merge(input.name, layers);
|
|
496
464
|
validate(config);
|
|
497
465
|
return Object.freeze(config);
|
|
498
466
|
}
|
package/src/context.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// parameters — otherwise every signature in the framework grows a `ctx` argument twice.
|
|
4
4
|
//
|
|
5
5
|
// THERE IS EXACTLY ONE ASSERTION IN THIS FILE AND IT IS IRREDUCIBLE (`As of 2026-08-24`). It is
|
|
6
|
-
// the `as Ctx` in `
|
|
6
|
+
// the `as Ctx` in `ctxOf`, and it is the LAST one: the second — over `preview` — is gone,
|
|
7
7
|
// because `CtxFacts` gives that value an honest type, and `@ultimat3/http`'s
|
|
8
|
-
// `
|
|
8
|
+
// `requestContext` now composes this function instead of building a second context beside
|
|
9
9
|
// it, so that package has none at all.
|
|
10
10
|
//
|
|
11
11
|
// Why the last one cannot go. `Ctx extends CtxServices`, and `CtxServices` is the seam an app
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
// Four alternatives were built and measured before this line was kept. Making the augmented half
|
|
21
21
|
// `Partial<CtxServices>` removes the assertion and turns `ctx.posts` into `PostRepo | undefined`
|
|
22
22
|
// for every app — true, and a breaking change to the documented seam. Requiring `CtxInit.services`
|
|
23
|
-
// to be a `CtxServices` moves the proof to the caller and breaks every internal `
|
|
23
|
+
// to be a `CtxServices` moves the proof to the caller and breaks every internal `ctxOf()`
|
|
24
24
|
// in an app's program, because an app typechecks the framework's sources through its project
|
|
25
|
-
// references. A generic `
|
|
25
|
+
// references. A generic `ctxOf<S>` returns a context no framework caller can pass where a
|
|
26
26
|
// `Ctx` is wanted. And an overload whose implementation signature returns the looser type compiles
|
|
27
27
|
// only through TypeScript's documented bivariance hole — the same assertion, laundered.
|
|
28
28
|
//
|
|
@@ -35,7 +35,7 @@ import { asyncContext } from './async-context';
|
|
|
35
35
|
import { type Clock, systemClock } from './clock';
|
|
36
36
|
import { UltimateError } from './errors';
|
|
37
37
|
import { finiteOption } from './finite-option';
|
|
38
|
-
import { traceId as newTraceId,
|
|
38
|
+
import { traceId as newTraceId, uuidV7 } from './ids';
|
|
39
39
|
import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
|
|
40
40
|
import { installTraceHeaders } from './outbound-headers';
|
|
41
41
|
import { type Role, resolveRole } from './roles';
|
|
@@ -73,7 +73,7 @@ export interface ServiceBag {
|
|
|
73
73
|
* against a type carrying members only the app's boot knows about: `Ctx extends CtxServices`, an
|
|
74
74
|
* app augments `CtxServices` with `declare module`, and every service it declares then became a
|
|
75
75
|
* REQUIRED member of every context literal in the framework. `@ultimat3/http`'s
|
|
76
|
-
* `
|
|
76
|
+
* `requestContext` stopped compiling inside `examples/dummy` for exactly that reason
|
|
77
77
|
* (`TS2739: missing posts, orgs`), while the framework's own gate — which augments nothing —
|
|
78
78
|
* stayed green.
|
|
79
79
|
*
|
|
@@ -157,6 +157,10 @@ const requestContext = asyncContext<Ctx>('the request context');
|
|
|
157
157
|
|
|
158
158
|
const neverAborted = new AbortController().signal;
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The framework's default locale — the ONE declaration: `@ultimat3/i18n` imports it rather than
|
|
162
|
+
* restating it (a second `'en'` there could drift from the context's own default).
|
|
163
|
+
*/
|
|
160
164
|
export const DEFAULT_LOCALE = 'en';
|
|
161
165
|
export const DEFAULT_TIME_ZONE = 'UTC';
|
|
162
166
|
|
|
@@ -164,9 +168,9 @@ function buildId(): string {
|
|
|
164
168
|
return process.env['BUILD_ID'] ?? 'dev';
|
|
165
169
|
}
|
|
166
170
|
|
|
167
|
-
export function
|
|
171
|
+
export function ctxOf(init: CtxInit = {}): Ctx {
|
|
168
172
|
const clock = init.clock ?? systemClock;
|
|
169
|
-
const requestId = init.requestId ??
|
|
173
|
+
const requestId = init.requestId ?? uuidV7(clock);
|
|
170
174
|
const trace = init.traceId ?? newTraceId();
|
|
171
175
|
const base = init.logger ?? rootLogger;
|
|
172
176
|
const explicit: ServiceBag = Object.freeze({ ...(init.services ?? {}) });
|
|
@@ -233,7 +237,7 @@ export function useContext(): Ctx {
|
|
|
233
237
|
throw new UltimateError({
|
|
234
238
|
code: 'X_NO_CONTEXT',
|
|
235
239
|
cause: 'useContext() was called outside of runWithContext()',
|
|
236
|
-
fix: 'wrap the entry point in runWithContext(
|
|
240
|
+
fix: 'wrap the entry point in runWithContext(ctxOf({ ... }), fn)',
|
|
237
241
|
});
|
|
238
242
|
}
|
|
239
243
|
return ctx;
|
|
@@ -272,6 +276,18 @@ function screenDeadline(value: number | null | undefined): number | null {
|
|
|
272
276
|
return finiteOption('the request context', 'deadlineAt', value);
|
|
273
277
|
}
|
|
274
278
|
|
|
279
|
+
/**
|
|
280
|
+
* A patched signal is ADDED to the parent's, never swapped for it — the deadline's own rule, one
|
|
281
|
+
* field over. `patch ?? parent` made a step that brought its own signal blind to the request it
|
|
282
|
+
* runs inside: the client disconnected, the request timed out, and `ctx.signal.aborted` stayed
|
|
283
|
+
* false in the child. The shared never-aborting default is skipped rather than composed, so a
|
|
284
|
+
* context with no request behind it does not grow a listener per child.
|
|
285
|
+
*/
|
|
286
|
+
function composeSignal(parent: AbortSignal, patch: AbortSignal | undefined): AbortSignal {
|
|
287
|
+
if (patch === undefined || patch === parent) return parent;
|
|
288
|
+
return parent === neverAborted ? patch : AbortSignal.any([parent, patch]);
|
|
289
|
+
}
|
|
290
|
+
|
|
275
291
|
/**
|
|
276
292
|
* Derive a narrowed context — impersonation, a locale switch, a per-step abort signal.
|
|
277
293
|
* `requestId` is deliberately not patchable: one request, one id.
|
|
@@ -279,13 +295,13 @@ function screenDeadline(value: number | null | undefined): number | null {
|
|
|
279
295
|
export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
|
|
280
296
|
const parent = useContext();
|
|
281
297
|
// A factory-managed service was built for the PARENT's actor; forwarding it verbatim into an
|
|
282
|
-
// impersonated child would answer every call with the parent's tenant. `
|
|
298
|
+
// impersonated child would answer every call with the parent's tenant. `ctxOf` below
|
|
283
299
|
// rebuilds every registered factory fresh against the child's own actor, so only services no
|
|
284
300
|
// factory owns — a hand-built mock nothing registered — carry forward unrebuilt.
|
|
285
301
|
const carried = Object.fromEntries(
|
|
286
302
|
Object.entries(parent.services).filter(([name]) => !isManagedService(name)),
|
|
287
303
|
);
|
|
288
|
-
const child =
|
|
304
|
+
const child = ctxOf({
|
|
289
305
|
requestId: parent.requestId,
|
|
290
306
|
traceId: patch.traceId ?? parent.traceId,
|
|
291
307
|
actor: patch.actor ?? parent.actor,
|
|
@@ -301,7 +317,7 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
|
|
|
301
317
|
// that and did not do it — a patched hour replaced a parent's second outright, and
|
|
302
318
|
// `remainingBudgetMs` then put the hour on `x-request-timeout-ms` for the next hop.
|
|
303
319
|
deadlineAt: earliest(screenDeadline(patch.deadlineAt) ?? undefined, parent.deadlineAt),
|
|
304
|
-
signal:
|
|
320
|
+
signal: composeSignal(parent.signal, patch.signal),
|
|
305
321
|
services: { ...carried, ...(patch.services ?? {}) },
|
|
306
322
|
});
|
|
307
323
|
return requestContext.run(child, fn);
|
|
@@ -318,7 +334,7 @@ export function useService<T>(name: string): T {
|
|
|
318
334
|
throw new UltimateError({
|
|
319
335
|
code: 'X_SERVICE_MISSING',
|
|
320
336
|
cause: `"${name}" is not on ctx.services (have: ${Object.keys(ctx.services).join(', ')})`,
|
|
321
|
-
fix: `pass it in
|
|
337
|
+
fix: `pass it in ctxOf({ services: { ${name} } })`,
|
|
322
338
|
meta: { name },
|
|
323
339
|
});
|
|
324
340
|
}
|