@ultimat3/core 23.0.0 → 24.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 +6 -0
- package/README.md +34 -3
- package/package.json +2 -2
- package/src/config-defaults.ts +46 -0
- package/src/config-merge.ts +8 -0
- package/src/config-shape.ts +112 -0
- package/src/config-site.ts +14 -3
- package/src/config.ts +74 -83
- package/src/context.ts +13 -1
- package/src/cookie.ts +35 -0
- package/src/cursor.ts +4 -1
- package/src/decimal-order.ts +5 -4
- package/src/dev-secrets.ts +1 -1
- package/src/error-reporter-sentry.ts +7 -3
- package/src/flight-gate.ts +16 -4
- package/src/fnv1a.ts +19 -0
- package/src/health-disclosure.ts +43 -0
- package/src/host-rules.ts +28 -1
- package/src/html-escape.ts +24 -0
- package/src/image/errors.ts +3 -1
- package/src/image/png-pixels.ts +29 -6
- package/src/image/probe.ts +7 -2
- package/src/image/raster.ts +3 -1
- package/src/index.ts +12 -0
- package/src/logger.ts +77 -10
- 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/pg-executor.ts +15 -0
- package/src/public-cause.ts +37 -0
- package/src/registrar.ts +21 -4
- package/src/retry.ts +15 -2
- 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/source-mask.ts +14 -8
- package/src/store-mode.ts +23 -0
package/CLAUDE.md
CHANGED
|
@@ -60,8 +60,10 @@ top-level `UltimateError` use in `error-codes.ts`.
|
|
|
60
60
|
| Concept | Owner | Note |
|
|
61
61
|
|---|---|---|
|
|
62
62
|
| which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never a second env var |
|
|
63
|
+
| one-home helpers | `store-mode` `html-escape` `cookie` `fnv1a` `pg-executor` | never copied (`X_HELPER_COPY`) |
|
|
63
64
|
| what this process does | `roles.ts` (`ROLE`) | |
|
|
64
65
|
| how a route renders, caches offline and hydrates | `route-vocabulary.ts` (`RENDER_MODES`, `OFFLINE_STRATEGIES`, `HYDRATE_STRATEGIES`) | every union is `(typeof ARRAY)[number]`, pinned in `type-pins.ts`; `scripts/render-modes.test.ts` refuses a second declaration. Re-export it, never restate it |
|
|
66
|
+
| which of two route patterns wins a pathname | `route-rank.ts` (`routeRank`) | the request router's order as one integer: segment by segment, literal 3 > `:param` 2 > `*catch-all` 1, ENDED 4, packed base 5 over 22 segments. Read by `@ultimat3/render`'s `compilePattern` and `@ultimat3/pwa`'s rule order — both tier 4, so the one copy lives here. `@ultimat3/http`'s trie encodes the same order by its walk, not by this number. Never a sum: 100/10/1 ranked `/:a/b/c` above `/a/:x/:y` |
|
|
65
67
|
| which rungs a cache ladder has | `cache-vocabulary.ts` (`CACHE_TIERS`) | `@ultimat3/cache`'s `TIER_ORDER` IS this array. `isr` is a `RenderMode`, never a tier |
|
|
66
68
|
| which build of the APP this is | `app-version.ts` (`APP_VERSION`) | one reader, `dev` by default |
|
|
67
69
|
| the values | `env.ts` | `checkEnv().values` holds REAL secrets — printing goes through `maskedEnvValues()` |
|
|
@@ -79,6 +81,8 @@ top-level `UltimateError` use in `error-codes.ts`.
|
|
|
79
81
|
| which row survives a conflict | `conflict-policy.ts` (`ConflictPolicy`, `resolveConflict`, `Row`) | read by `action`'s mutator and `realtime`'s rebase |
|
|
80
82
|
| the four shapes of an async region | `async-state.ts` (`AsyncState`) | `realtime` returns it, `ui` renders it. `bun run render-modes` refuses a second status union sharing three members |
|
|
81
83
|
| is this `unknown` a keyed record? | `json-object.ts` (`isJsonObject`) | narrows a shape; does not certify provenance |
|
|
84
|
+
| may a caller read this 5xx `cause`? | `public-cause.ts` (`hasPublicCause`) | one table for http, mcp, ai. `registerPublicCause` is `@ultimat3/http`'s `registerProblemMeta` writing it — never an app's door |
|
|
85
|
+
| is this field a credential? | `logger.ts` (`isRedactedKey`) | exact keys + `CREDENTIAL_NAME`; a bare `token` suffix is NOT one (`idempotencyToken`, `maxTokens`). Log line, monitor envelope and `action`'s audit ask it |
|
|
82
86
|
| a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one, greppable, way out |
|
|
83
87
|
| 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 arrives from a header: refuse a non-tag (`X_LOCALE_INVALID`), key canonically AND bound the cache — never a copy of any of the three. The cause quotes at most `MAX_LOCALE_EXCERPT` (35) code points; the whole tag rides in `meta.locale` |
|
|
84
88
|
| the text direction of a locale | `locale-direction.ts` (`directionOf`, `isRtl`, `Direction`) | re-exported by `@ultimat3/i18n`; lives here so `@ultimat3/ui` need not reach the i18n barrel |
|
|
@@ -192,6 +196,8 @@ is the bug:
|
|
|
192
196
|
WHOLE drain's; `DEFAULT_DEADLINE_MS` (25 s) always applies; it is real monotonic time
|
|
193
197
|
(`systemClock`), never the injected `clock`. `drainDeadlineMs()` is the one decision point.
|
|
194
198
|
`settleWithin` attaches a rejection handler unconditionally.
|
|
199
|
+
- **Who a health endpoint tells what is ONE rule, here** (`health-disclosure.ts`): `healthBody` +
|
|
200
|
+
`healthPeerListed`, called by http and by realtime's sync listener. Never a second copy.
|
|
195
201
|
- **A readiness grace runs before the `accept` phase** (`lifecycle-grace.ts`): `/readyz` answers 503
|
|
196
202
|
with the socket still open for `drain.readinessGraceMs`, ADDED to `deadlineMs` (a chart's
|
|
197
203
|
`terminationGracePeriodSeconds` must exceed the sum — 5 s + 25 s by default). Unset: 0 in
|
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
32
32
|
| typed env validated at boot | `env.ts` |
|
|
33
33
|
| `.env.example` rendered from that schema, and its drift check | `env-example.ts` |
|
|
34
34
|
| named environments + `ULTIMATE_ENV` resolution | `environment.ts` |
|
|
35
|
+
| which store backs a seam — `storeMode(env)`: `memory` under `test`, `database` everywhere else | `store-mode.ts` |
|
|
35
36
|
| the boot refusal of a shipped dev signing secret outside development/test — `X_CURSOR_SECRET_DEV` | `dev-secrets.ts` |
|
|
36
37
|
| a value that cannot be printed by accident | `secret.ts` |
|
|
37
38
|
| the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
|
|
@@ -40,8 +41,11 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
40
41
|
| the key ring those work under: the current key plus retired ones | `seal-keys.ts` |
|
|
41
42
|
| `defineConfig()` for `app.config.ts` | `config.ts` |
|
|
42
43
|
| how overlays layer onto it — per section, key by key | `config-merge.ts` |
|
|
44
|
+
| what each key is when no layer says | `config-defaults.ts` |
|
|
45
|
+
| the shape screens that run before any rule reads a value — section, list, boolean, closed set, path, locale list | `config-shape.ts` |
|
|
43
46
|
| the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
|
|
44
47
|
| the closed route vocabulary every renderer names | `route-vocabulary.ts` |
|
|
48
|
+
| which of two route patterns wins a pathname (`routeRank`) | `route-rank.ts` |
|
|
45
49
|
| runtime roles + `ROLE` resolution | `roles.ts` |
|
|
46
50
|
| `Clock` — the only source of "now" | `clock.ts` |
|
|
47
51
|
| UUIDv7, nanoid, branded ids | `ids.ts` |
|
|
@@ -51,6 +55,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
51
55
|
| OTLP/HTTP JSON: endpoint, headers, value encoding | `otlp.ts` |
|
|
52
56
|
| `SpanExporter` on the wire, batched | `otlp-span-exporter.ts` |
|
|
53
57
|
| `MetricExporter` on the wire | `otlp-metric-exporter.ts` |
|
|
58
|
+
| may a caller read this 5xx code's `cause`? `hasPublicCause(code)` — one predicate for the HTTP problem document, MCP error data and an agent `tool_result` | `public-cause.ts` |
|
|
54
59
|
| `reportError` + the `ErrorReporter` seam, no-op by default | `error-reporter.ts` |
|
|
55
60
|
| that seam on the wire, Sentry's envelope and DSN | `error-reporter-sentry.ts` |
|
|
56
61
|
| OTel-shaped counter / gauge / histogram, same seam | `metrics.ts` |
|
|
@@ -58,6 +63,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
58
63
|
| the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
|
|
59
64
|
| what the process itself costs: `process_resident_memory_bytes`, `process_heap_used_bytes`, `process_heap_total_bytes`, `process_external_memory_bytes`, `process_cpu_seconds_total`, `process_event_loop_lag_seconds`, `process_start_time_seconds`, `process_info{role}` — **server-only**, never on `@ultimat3/core/page` | `process-metrics.ts` (`startProcessMetrics`, `readProcess`) |
|
|
60
65
|
| graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
|
|
66
|
+
| what a health endpoint tells whom — `healthBody(report, role, detailed)`, `healthPeerListed(peers, address)`, `DEFAULT_HEALTH_DETAIL_PEERS`; the one rule `@ultimat3/http` and the sync node's own listener both call | `health-disclosure.ts` |
|
|
61
67
|
| the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
|
|
62
68
|
| SIGTERM/SIGINT → the one drain | `lifecycle-signals.ts` |
|
|
63
69
|
| which network an IP literal belongs to — `classifyAddress`, for SSRF screens | `address-class.ts` |
|
|
@@ -66,6 +72,13 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
66
72
|
| the registrar table one same-tier package reaches another through | `registrar.ts` |
|
|
67
73
|
| decode → resize → encode, the one image pipeline (over `Bun.Image`) | `image/` |
|
|
68
74
|
| `assertNever`, `invariant` | `assert.ts` |
|
|
75
|
+
| the one HTML character table — `escapeHtml`, text and attributes alike (`& < > " '`) | `html-escape.ts` |
|
|
76
|
+
| the one `Cookie:` reader — `readCookie(header, name)`, `null` when absent, never a throw | `cookie.ts` |
|
|
77
|
+
| 32-bit FNV-1a — a BUCKET (rollouts, factory seeds), never a sharing key (`fingerprint` is) | `fnv1a.ts` |
|
|
78
|
+
| `PgExecutor` — the structural `query(text, values)` seam every Postgres store takes | `pg-executor.ts` |
|
|
79
|
+
|
|
80
|
+
Each of the four, and `fingerprint`, `storeMode` and render's `contentHash`, has ONE implementation:
|
|
81
|
+
`bun run flight-copies` refuses a second by its shape (`X_HELPER_COPY`), whatever it is named.
|
|
69
82
|
|
|
70
83
|
## Errors are instructions
|
|
71
84
|
|
|
@@ -312,6 +325,10 @@ fallback of its own; the caller does.
|
|
|
312
325
|
is not our key. This is the twin of `roles.ts` — `ROLE` says what the process does,
|
|
313
326
|
`ULTIMATE_ENV` says which deploy it belongs to.
|
|
314
327
|
|
|
328
|
+
`storeMode(Bun.env)` is the one answer to "memory store or database store?" for a seam with both —
|
|
329
|
+
`memory` under `test` (no database client is installed there), `database` everywhere else, `x dev`'s
|
|
330
|
+
embedded PGlite included. Never a `DATABASE_URL` truthiness check: `x dev` sets none.
|
|
331
|
+
|
|
315
332
|
## A secret is redacted by value, not by name
|
|
316
333
|
|
|
317
334
|
```ts
|
|
@@ -320,7 +337,21 @@ logger.info('boot', { dsn }); // {"dsn":"[redacted]"}
|
|
|
320
337
|
connect(revealSecret(dsn)); // the one greppable way out
|
|
321
338
|
```
|
|
322
339
|
|
|
323
|
-
`
|
|
340
|
+
`isRedactedKey(key)` is the one answer to "is this field a credential?" — the log line, the error
|
|
341
|
+
monitor's envelope and `@ultimat3/action`'s audit row all ask it. It matches the exact names
|
|
342
|
+
`redactKeys()` holds (`defineEnv` adds every `secret: true` variable) **and** a credential-bearing
|
|
343
|
+
name it was never told about: `password` / `passphrase` anywhere, `secret` as the last word, any
|
|
344
|
+
`…token` that is not a dedupe or paging key (`resetToken`, `githubToken`, `NPM_TOKEN`), key
|
|
345
|
+
material by its qualifier (`signingKey`, `masterKey`, `accessKeyId`), a value that embeds a
|
|
346
|
+
credential (`connectionString`, `dsn`, `databaseUrl`), the one-time codes
|
|
347
|
+
(`totpCode`, `recoveryCode`) and a stored hash of any of them (`passwordHash`, `tokenHash`,
|
|
348
|
+
`keyHash`). It deliberately leaves `idempotencyToken`, a paging token, `maxTokens` and an error
|
|
349
|
+
`code` readable — a redacted field is one an operator cannot correlate on.
|
|
350
|
+
|
|
351
|
+
`LOG_LEVEL` is refused when it is not one of `LOG_LEVELS` (lowercase), exactly as
|
|
352
|
+
`createLogger({ level })` refuses it; unset or empty is `info`.
|
|
353
|
+
|
|
354
|
+
A `Secret`
|
|
324
355
|
box catches the other case: `String()`, template literals, `+`, `JSON.stringify`, `console.log`,
|
|
325
356
|
the logger and an error's `meta` all render `[redacted]`, whatever key it sits under. It is
|
|
326
357
|
frozen and everything but `label` is non-enumerable, so `{ ...dsn }` cannot spread the value back
|
|
@@ -554,7 +585,7 @@ never a silently wrong page.
|
|
|
554
585
|
| | |
|
|
555
586
|
|---|---|
|
|
556
587
|
| Signature | truncated HMAC-SHA256, compared in constant time |
|
|
557
|
-
| Secret | `configureCursorSigning()` at boot, else `ULTIMATE_CURSOR_SECRET`. **Read when a cursor is signed, never at import** — an app whose `openSecrets()` sets the variable during boot would otherwise sign every cursor with the dev key. Rotating it invalidates every open cursor |
|
|
588
|
+
| Secret | `configureCursorSigning()` at boot, else `ULTIMATE_CURSOR_SECRET`. An EMPTY value is unset — never an empty HMAC key — so `usesDevCursorSecret()` reports it and the boot refuses it outside a local environment. **Read when a cursor is signed, never at import** — an app whose `openSecrets()` sets the variable during boot would otherwise sign every cursor with the dev key. Rotating it invalidates every open cursor |
|
|
558
589
|
| Also keys | `keyedFingerprint(value, purpose)` — `h1:<key id>:<HMAC>` over `canonicalJson`, under a per-purpose key derived from this secret; the fingerprint to PERSIST (`@ultimat3/action`'s idempotency `requestHash`). `compareFingerprint` answers `match` / `mismatch` / `unverifiable` (other key), and still checks a legacy bare `fingerprint()` exactly. Rotating the secret makes in-window stored fingerprints `unverifiable` |
|
|
559
590
|
| Signed, not encrypted | the client already has these rows; what it must not do is *invent* a position |
|
|
560
591
|
| `usesDevCursorSecret()` | true while the shipped dev key is in use |
|
|
@@ -649,7 +680,7 @@ nothing consulted it before deciding to try again. `As of 2026-08-23`.
|
|
|
649
680
|
|---|---|---|
|
|
650
681
|
| `backoffDelay({ attempt, base, max, factor?, curve?, jitter?, random? })` | one curve — `exponential \| linear \| fixed`, `full \| equal \| none` — 1-based `attempt`, clamped to `max` **before** jitter, rounded, and `0` rather than `NaN` | how long to wait. `random` is injectable, so a schedule is a unit test rather than a range |
|
|
651
682
|
| `createSingleFlight({ deadlineMs?, schedule? })` → `run(key, work, join?)`, `size` | N callers on one key are ONE run | who pays for a miss. Eviction is identity-checked, so a load that settles late never drops the load that replaced it; `deadlineMs` frees the KEY a wedged load would hold forever — it never cancels the work and never rejects a joiner |
|
|
652
|
-
| `createFlightGate({ maxConcurrent, maxQueued }, { subject?, overflow? })` | one bound, one queue, one refusal | how many at once. Past the queue the answer is `X_FLIGHT_GATE_OVERLOADED` (503) and never a longer queue; the slot is HANDED to a waiter, never released and re-acquired |
|
|
683
|
+
| `createFlightGate({ maxConcurrent, maxQueued }, { subject?, overflow? })` | one bound, one queue, one refusal | how many at once. Past the queue the answer is `X_FLIGHT_GATE_OVERLOADED` (503) and never a longer queue; the slot is HANDED to a waiter, never released and re-acquired. Both limits are screened at construction (`finiteCount`, 0 allowed); a width of 0 refuses every caller rather than queueing for a slot that never frees |
|
|
653
684
|
| `createFence(subject)` → `generation()`, `bump()`, `guard(issued)` | whether an answer still applies | `X_SUPERSEDED` (499) and `isSuperseded(error)` — the piece nothing in the tree had. `guard` compares `!==`, never `<` |
|
|
654
685
|
| `isRetryableStatus(status)`, `RETRYABLE_STATUSES` | `>= 500`, plus 408, 409, 425, 429 | which HTTP answers are worth repeating |
|
|
655
686
|
| `retry(work, policy, { sleep, now?, random? })`, `retryDecision(policy, attempt, error, random?)` | the executor and the pure decision behind the classification | whether to try again at all. `createClientFlight` is its one caller in the framework; `jobs`, `ai` and `db` each keep their own loop and delegate only the arithmetic and the classification |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.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",
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
"test": "bun test"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ultimat3/schema": "
|
|
43
|
+
"@ultimat3/schema": "24.0.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Single responsibility: the value every `app.config.ts` key has when no layer says otherwise.
|
|
2
|
+
// Split from `config.ts`, which sits at its 500-line ceiling; literals only, so it reads no key.
|
|
3
|
+
|
|
4
|
+
import type { AppConfig } from './config';
|
|
5
|
+
import { defaultReadinessGraceMs } from './lifecycle-grace';
|
|
6
|
+
import { ROLES } from './roles';
|
|
7
|
+
|
|
8
|
+
/** The keys `config-site.ts`, `config-navigation.ts` and `config-islands.ts` default themselves. */
|
|
9
|
+
type Sectioned = 'name' | 'site' | 'seo' | 'navigation' | 'islands';
|
|
10
|
+
|
|
11
|
+
export function configDefaults(name: string): Omit<AppConfig, Sectioned> {
|
|
12
|
+
return {
|
|
13
|
+
locales: ['en'],
|
|
14
|
+
defaultLocale: 'en',
|
|
15
|
+
defaultTimeZone: 'UTC',
|
|
16
|
+
defaultCurrency: 'USD',
|
|
17
|
+
theme: { defaultMode: 'system', tokens: {} },
|
|
18
|
+
auth: { signInPath: null },
|
|
19
|
+
pwa: {
|
|
20
|
+
enabled: false,
|
|
21
|
+
offline: { fallback: null, image: null, font: null, neverCache: [], personalPages: 'never' },
|
|
22
|
+
backgroundSync: false,
|
|
23
|
+
push: false,
|
|
24
|
+
name: '',
|
|
25
|
+
colors: undefined,
|
|
26
|
+
},
|
|
27
|
+
roles: [...ROLES],
|
|
28
|
+
database: { driver: 'postgres', ssl: false },
|
|
29
|
+
cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
|
|
30
|
+
jobs: {
|
|
31
|
+
queues: [`${name}-default`],
|
|
32
|
+
concurrency: 8,
|
|
33
|
+
maxAttempts: 5,
|
|
34
|
+
backoff: 'exponential',
|
|
35
|
+
visibilityTimeoutMs: 30_000,
|
|
36
|
+
},
|
|
37
|
+
// ON by default since 22.0.0, when the boot began obeying the key: an app with no section
|
|
38
|
+
// keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
|
|
39
|
+
realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
|
|
40
|
+
notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
|
|
41
|
+
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
42
|
+
// Read from the process env when the config is DEFINED — the same env the drain will run in.
|
|
43
|
+
drain: { readinessGraceMs: defaultReadinessGraceMs() },
|
|
44
|
+
health: { readiness: 'dependencies' },
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/config-merge.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// per section and key by key. Carries no config KEY on purpose: `config-readers` counts a property
|
|
3
3
|
// access outside `config.ts` as a reader, so this file only ever sees sections as opaque records.
|
|
4
4
|
|
|
5
|
+
import { isJsonObject } from './json-object';
|
|
6
|
+
|
|
5
7
|
/** A section's patch: every key optional, and an explicit `undefined` meaning "not said". */
|
|
6
8
|
export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
|
|
7
9
|
|
|
@@ -11,6 +13,12 @@ export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
|
|
|
11
13
|
*/
|
|
12
14
|
export function section<T extends object>(base: T, patch: Input<T> | undefined): T {
|
|
13
15
|
if (patch === undefined) return base;
|
|
16
|
+
// A layer that wrote something other than an object (`null`, a string, a list) has no key to
|
|
17
|
+
// merge, and `Object.entries(null)` below was a native `TypeError` out of the validator's own
|
|
18
|
+
// caller. It is carried through AS WRITTEN — and stays, whatever later layers say — so the shape
|
|
19
|
+
// screen refuses it by name; dropped here, the app would run on defaults it believed it replaced.
|
|
20
|
+
if (!isJsonObject(base)) return base;
|
|
21
|
+
if (!isJsonObject(patch)) return patch as T;
|
|
14
22
|
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
15
23
|
for (const [key, value] of Object.entries(patch)) {
|
|
16
24
|
if (value !== undefined) out[key] = value;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Single responsibility: the SHAPE half of `app.config.ts` validation — is this a section, a list,
|
|
2
|
+
// a boolean, one of a closed set — asked before any rule reads the value. Takes every key as a
|
|
3
|
+
// string and every value as `unknown`, and names no config key of its own: `config-readers` counts
|
|
4
|
+
// a property access outside the declaring files as a reader, so this file must not make one.
|
|
5
|
+
|
|
6
|
+
import { describeValue } from './error-render';
|
|
7
|
+
import { isJsonObject } from './json-object';
|
|
8
|
+
|
|
9
|
+
/** A string is worth echoing — it is the typo; anything else is described by shape. */
|
|
10
|
+
const said = (value: unknown): string =>
|
|
11
|
+
typeof value === 'string' ? `"${value}"` : describeValue(value);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every section and list one LAYER wrote, compared with the same position in `reference` — the
|
|
15
|
+
* defaults merged with no layer, so the screen is derived and never a hand list of key names.
|
|
16
|
+
* Structure only: where the reference holds a section the layer may hold a section, where it holds
|
|
17
|
+
* a list, a list. `undefined` is a layer not saying; scalars are the per-key rules' business; and
|
|
18
|
+
* a position the reference leaves `null` or `undefined` (an optional block) is not judged here.
|
|
19
|
+
*
|
|
20
|
+
* It runs BEFORE the merge and its issues end the validation, because the merge and every rule
|
|
21
|
+
* after it read through the structure: `Object.entries(null)` and `null.length` are the native
|
|
22
|
+
* `TypeError`s the validator exists to replace with an instruction.
|
|
23
|
+
*/
|
|
24
|
+
export function shapeIssues(reference: unknown, layer: unknown, issues: string[], path = ''): void {
|
|
25
|
+
if (!isJsonObject(reference) || !isJsonObject(layer)) return;
|
|
26
|
+
for (const [key, expected] of Object.entries(reference)) {
|
|
27
|
+
const at = path === '' ? key : `${path}.${key}`;
|
|
28
|
+
const value: unknown = layer[key];
|
|
29
|
+
if (value === undefined) continue;
|
|
30
|
+
if (Array.isArray(expected)) {
|
|
31
|
+
if (!Array.isArray(value)) issues.push(`${at} must be a list, not ${describeValue(value)}`);
|
|
32
|
+
} else if (isJsonObject(expected)) {
|
|
33
|
+
if (isJsonObject(value)) shapeIssues(expected, value, issues, at);
|
|
34
|
+
else issues.push(`${at} must be an object, not ${describeValue(value)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Why `value` is not one of `allowed`, or `undefined` when it is. */
|
|
40
|
+
export function oneOfIssue(
|
|
41
|
+
key: string,
|
|
42
|
+
value: unknown,
|
|
43
|
+
allowed: readonly string[],
|
|
44
|
+
): string | undefined {
|
|
45
|
+
if (allowed.some((known) => known === value)) return undefined;
|
|
46
|
+
return `${key} ${said(value)} is not one of ${allowed.join(', ')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `typeof`, never truthiness: an untyped config writing `'false'` — a string out of an environment
|
|
51
|
+
* variable — is truthy, so the switch it meant to turn off stayed on and nothing said so.
|
|
52
|
+
*/
|
|
53
|
+
export function booleanIssue(key: string, value: unknown): string | undefined {
|
|
54
|
+
return typeof value === 'boolean'
|
|
55
|
+
? undefined
|
|
56
|
+
: `${key} must be true or false, not ${said(value)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A route path the framework mounts or redirects to: absolute, or the browser resolves it. */
|
|
60
|
+
export function routePathIssue(key: string, value: unknown): string | undefined {
|
|
61
|
+
return typeof value === 'string' && value.startsWith('/')
|
|
62
|
+
? undefined
|
|
63
|
+
: `${key} must be a path starting with /, not ${said(value)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A list that names things: at least one entry, each a non-empty string, `what` each. */
|
|
67
|
+
export function nameListIssues(
|
|
68
|
+
key: string,
|
|
69
|
+
list: readonly unknown[],
|
|
70
|
+
what: string,
|
|
71
|
+
issues: string[],
|
|
72
|
+
): void {
|
|
73
|
+
if (list.length === 0) issues.push(`${key} must list at least one ${what}`);
|
|
74
|
+
for (const entry of list) {
|
|
75
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
76
|
+
issues.push(`${key} contains ${said(entry)}, not a ${what} name`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
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-site.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Split from `config.ts` for `config-pwa.ts`' reason: that file sits at its 500-line ceiling.
|
|
4
4
|
|
|
5
5
|
import { type Input, layered } from './config-merge';
|
|
6
|
+
import { describeValue } from './error-render';
|
|
6
7
|
|
|
7
8
|
export interface SiteConfig {
|
|
8
9
|
/**
|
|
@@ -108,10 +109,20 @@ export function siteIssues(config: SiteSections, issues: string[]): void {
|
|
|
108
109
|
const issue = originIssue(origin);
|
|
109
110
|
if (issue !== undefined) issues.push(issue);
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
// `unknown` entries: an untyped config reaches here with whatever it listed, and `5.startsWith`
|
|
113
|
+
// was a native `TypeError` thrown by the validator itself.
|
|
114
|
+
for (const path of config.seo.robots.disallow as readonly unknown[]) {
|
|
115
|
+
if (typeof path !== 'string') {
|
|
116
|
+
issues.push(`seo.robots.disallow entry must be a path string, not ${describeValue(path)}`);
|
|
117
|
+
} else if (!path.startsWith('/')) {
|
|
118
|
+
issues.push(`seo.robots.disallow entry "${path}" must start with /`);
|
|
119
|
+
}
|
|
113
120
|
}
|
|
114
|
-
for (const path of config.seo.sitemap.extra) {
|
|
121
|
+
for (const path of config.seo.sitemap.extra as readonly unknown[]) {
|
|
122
|
+
if (typeof path !== 'string') {
|
|
123
|
+
issues.push(`seo.sitemap.extra entry must be a path string, not ${describeValue(path)}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
115
126
|
// A PATH, never a URL: every `<loc>` is built against the one declared origin, and a query or
|
|
116
127
|
// a fragment names a variant of a page, which a sitemap lists by its canonical URL alone.
|
|
117
128
|
if (!path.startsWith('/') || path.startsWith('//') || /[?#]/.test(path)) {
|
package/src/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
|
|
|
8
8
|
// drift into two vocabularies with no map between them (issue #293).
|
|
9
9
|
import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
|
|
10
10
|
import { countIssue } from './config-count';
|
|
11
|
+
import { configDefaults } from './config-defaults';
|
|
11
12
|
import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
|
|
12
13
|
import type { DrainConfig, HealthConfig } from './config-health';
|
|
13
14
|
import { readinessModeIssue } from './config-health';
|
|
@@ -18,15 +19,24 @@ import type { NavigationConfig, NavigationSectionInput } from './config-navigati
|
|
|
18
19
|
import { mergeNavigation, navigationIssues } from './config-navigation';
|
|
19
20
|
import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
|
|
20
21
|
import { PWA_FIX, pwaIssues } from './config-pwa';
|
|
22
|
+
import {
|
|
23
|
+
booleanIssue,
|
|
24
|
+
localeIssues,
|
|
25
|
+
nameListIssues,
|
|
26
|
+
oneOfIssue,
|
|
27
|
+
routePathIssue,
|
|
28
|
+
shapeIssues,
|
|
29
|
+
} from './config-shape';
|
|
21
30
|
import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
|
|
22
31
|
import { mergeSite, siteIssues } from './config-site';
|
|
23
32
|
import { describeValue } from './error-render';
|
|
24
33
|
import { ConfigInvalidError } from './errors';
|
|
25
|
-
import {
|
|
34
|
+
import { readinessGraceIssue } from './lifecycle-grace';
|
|
26
35
|
import { ROLES, type Role } from './roles';
|
|
27
36
|
import { isIanaZoneName } from './time-zone-name';
|
|
28
37
|
|
|
29
|
-
export
|
|
38
|
+
export const THEME_MODES = ['light', 'dark', 'system'] as const;
|
|
39
|
+
export type ThemeMode = (typeof THEME_MODES)[number];
|
|
30
40
|
/**
|
|
31
41
|
* The buses `@ultimat3/realtime`'s `selectTransport` builds, and nothing else. `'redis'` was in
|
|
32
42
|
* this union until 22.0.0 with no Redis transport anywhere: it booted whatever `NATS_URL` chose.
|
|
@@ -99,8 +109,10 @@ export const INBOX_RETENTION_KEYS = ['inboxReadRetentionMs', 'inboxUnreadRetenti
|
|
|
99
109
|
* 3 applied to configuration: a value that produces neither a build error nor a runtime effect is
|
|
100
110
|
* worse than no field, because an SRE sets `poolSize: 3`, redeploys, and nothing changes.
|
|
101
111
|
*/
|
|
112
|
+
const DATABASE_DRIVERS = ['postgres'] as const;
|
|
113
|
+
|
|
102
114
|
export interface DatabaseConfig {
|
|
103
|
-
readonly driver:
|
|
115
|
+
readonly driver: (typeof DATABASE_DRIVERS)[number];
|
|
104
116
|
readonly ssl: boolean;
|
|
105
117
|
}
|
|
106
118
|
|
|
@@ -125,6 +137,8 @@ export interface CacheConfig {
|
|
|
125
137
|
readonly tiers: readonly CacheTierName[];
|
|
126
138
|
}
|
|
127
139
|
|
|
140
|
+
const JOB_BACKOFFS = ['exponential', 'fixed'] as const;
|
|
141
|
+
|
|
128
142
|
export interface JobsConfig {
|
|
129
143
|
/**
|
|
130
144
|
* No `driver`. It accepted `'postgres' | 'redis' | 'nats'`, was read by NOTHING, and boot always
|
|
@@ -140,7 +154,7 @@ export interface JobsConfig {
|
|
|
140
154
|
readonly queues: readonly string[];
|
|
141
155
|
readonly concurrency: number;
|
|
142
156
|
readonly maxAttempts: number;
|
|
143
|
-
readonly backoff:
|
|
157
|
+
readonly backoff: (typeof JOB_BACKOFFS)[number];
|
|
144
158
|
readonly visibilityTimeoutMs: number;
|
|
145
159
|
}
|
|
146
160
|
|
|
@@ -255,52 +269,6 @@ const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
|
255
269
|
*/
|
|
256
270
|
const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
|
|
257
271
|
|
|
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
272
|
function validate(config: AppConfig): void {
|
|
305
273
|
const issues: string[] = [];
|
|
306
274
|
// Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
|
|
@@ -311,16 +279,13 @@ function validate(config: AppConfig): void {
|
|
|
311
279
|
// Same shape again: carried only when an installable app is missing what an install needs.
|
|
312
280
|
const pwaFix: string[] = [];
|
|
313
281
|
|
|
314
|
-
|
|
282
|
+
// `typeof` first: `NAME_RE.test(undefined)` tests the string "undefined", which matches.
|
|
283
|
+
if (typeof config.name !== 'string') {
|
|
284
|
+
issues.push(`name must be a string like "my-app", not ${describeValue(config.name)}`);
|
|
285
|
+
} else if (!NAME_RE.test(config.name)) {
|
|
315
286
|
issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
|
|
316
287
|
}
|
|
317
|
-
|
|
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
|
-
}
|
|
288
|
+
localeIssues(config.locales, config.defaultLocale, issues);
|
|
324
289
|
// `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
|
|
325
290
|
// `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
|
|
326
291
|
// `format` call, `task()` and `toZoned` below it can then do arithmetic in.
|
|
@@ -334,25 +299,33 @@ function validate(config: AppConfig): void {
|
|
|
334
299
|
issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
|
|
335
300
|
}
|
|
336
301
|
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
|
-
|
|
302
|
+
// ONE check per key, each against the key's own domain. A number is never a bare `< 1` — every
|
|
303
|
+
// comparison with `NaN` is false, so `concurrency < 1` passed `NaN`, `2.5` and `Infinity` — and
|
|
304
|
+
// a switch is never read for truthiness: `ssl: 'false'` and `enabled: 'false'` were both ON.
|
|
305
|
+
const perKey: readonly (string | undefined)[] = [
|
|
306
|
+
...config.roles.map((role) => oneOfIssue('roles', role, ROLES)),
|
|
340
307
|
countIssue('jobs.concurrency', config.jobs.concurrency, 1),
|
|
341
308
|
countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
|
|
342
309
|
countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
|
|
310
|
+
oneOfIssue('jobs.backoff', config.jobs.backoff, JOB_BACKOFFS),
|
|
343
311
|
countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
|
|
312
|
+
oneOfIssue('database.driver', config.database.driver, DATABASE_DRIVERS),
|
|
313
|
+
booleanIssue('database.ssl', config.database.ssl),
|
|
314
|
+
oneOfIssue('theme.defaultMode', config.theme.defaultMode, THEME_MODES),
|
|
315
|
+
// `null` is the documented "no redirect"; a path that is said must be one a browser can follow.
|
|
316
|
+
config.auth.signInPath === null
|
|
317
|
+
? undefined
|
|
318
|
+
: routePathIssue('auth.signInPath', config.auth.signInPath),
|
|
319
|
+
booleanIssue('ai.mcp.expose', config.ai.mcp.expose),
|
|
320
|
+
routePathIssue('ai.mcp.path', config.ai.mcp.path),
|
|
321
|
+
booleanIssue('realtime.enabled', config.realtime.enabled),
|
|
322
|
+
oneOfIssue('realtime.transport', config.realtime.transport, REALTIME_TRANSPORTS),
|
|
344
323
|
readinessGraceIssue(config.drain.readinessGraceMs),
|
|
345
324
|
readinessModeIssue(config.health.readiness),
|
|
346
325
|
];
|
|
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) {
|
|
326
|
+
for (const issue of perKey) if (issue !== undefined) issues.push(issue);
|
|
327
|
+
nameListIssues('jobs.queues', config.jobs.queues, 'queue', issues);
|
|
328
|
+
if (config.realtime.transport === 'nats' && config.realtime.urlEnv === undefined) {
|
|
356
329
|
issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
|
|
357
330
|
}
|
|
358
331
|
// BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
|
|
@@ -381,6 +354,9 @@ function validate(config: AppConfig): void {
|
|
|
381
354
|
// A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
|
|
382
355
|
// index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
|
|
383
356
|
// So an unknown tier is refused at boot rather than silently ignored or silently placed first.
|
|
357
|
+
// An EMPTY ladder is refused with the unknown rung: it builds no tier at all, so every read
|
|
358
|
+
// misses and nothing says the cache was configured away.
|
|
359
|
+
if (config.cache.tiers.length === 0) issues.push('cache.tiers must list at least one tier');
|
|
384
360
|
for (const tier of config.cache.tiers) {
|
|
385
361
|
if (CACHE_TIERS.includes(tier)) continue;
|
|
386
362
|
issues.push(`cache.tiers contains "${tier}", which is not one of ${CACHE_TIERS.join(', ')}`);
|
|
@@ -399,20 +375,14 @@ function validate(config: AppConfig): void {
|
|
|
399
375
|
}
|
|
400
376
|
|
|
401
377
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
378
|
+
* Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from the
|
|
379
|
+
* input alone because it identifies the app: an overlay may not rename it. No layer at all is the
|
|
380
|
+
* defaults, which is the reference `shapeIssues` compares each layer against.
|
|
404
381
|
*/
|
|
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,
|
|
382
|
+
function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
|
|
383
|
+
const base = configDefaults(name);
|
|
384
|
+
return {
|
|
385
|
+
name,
|
|
416
386
|
locales: lastSaid(
|
|
417
387
|
base.locales,
|
|
418
388
|
layers.map((layer) => layer.locales),
|
|
@@ -492,7 +462,28 @@ export function defineConfig(
|
|
|
492
462
|
...mergeNavigation(layers),
|
|
493
463
|
...mergeIslands(layers),
|
|
494
464
|
};
|
|
465
|
+
}
|
|
495
466
|
|
|
467
|
+
/**
|
|
468
|
+
* The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
|
|
469
|
+
* touching `app.config.ts`.
|
|
470
|
+
*/
|
|
471
|
+
export function defineConfig(
|
|
472
|
+
input: AppConfigInput,
|
|
473
|
+
...overlays: readonly AppConfigOverlay[]
|
|
474
|
+
): AppConfig {
|
|
475
|
+
const layers: readonly AppConfigOverlay[] = [input, ...overlays];
|
|
476
|
+
// Structure FIRST, per layer and before the merge: a section written as `null` or a list
|
|
477
|
+
// written as a string is what `Object.entries` and `.length` raised a native `TypeError` on.
|
|
478
|
+
const issues: string[] = [];
|
|
479
|
+
// `navigation` is left out: `config-navigation.ts` carries a wrong shape through AS WRITTEN and
|
|
480
|
+
// refuses it in its own words, with the surfaces it accepts.
|
|
481
|
+
const reference = { ...merge(input.name, []), navigation: undefined };
|
|
482
|
+
for (const layer of layers) shapeIssues(reference, layer, issues);
|
|
483
|
+
if (issues.length > 0) {
|
|
484
|
+
throw new ConfigInvalidError({ cause: issues.join('; '), fix: BASE_FIX, meta: { issues } });
|
|
485
|
+
}
|
|
486
|
+
const config = merge(input.name, layers);
|
|
496
487
|
validate(config);
|
|
497
488
|
return Object.freeze(config);
|
|
498
489
|
}
|
package/src/context.ts
CHANGED
|
@@ -272,6 +272,18 @@ function screenDeadline(value: number | null | undefined): number | null {
|
|
|
272
272
|
return finiteOption('the request context', 'deadlineAt', value);
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* A patched signal is ADDED to the parent's, never swapped for it — the deadline's own rule, one
|
|
277
|
+
* field over. `patch ?? parent` made a step that brought its own signal blind to the request it
|
|
278
|
+
* runs inside: the client disconnected, the request timed out, and `ctx.signal.aborted` stayed
|
|
279
|
+
* false in the child. The shared never-aborting default is skipped rather than composed, so a
|
|
280
|
+
* context with no request behind it does not grow a listener per child.
|
|
281
|
+
*/
|
|
282
|
+
function composeSignal(parent: AbortSignal, patch: AbortSignal | undefined): AbortSignal {
|
|
283
|
+
if (patch === undefined || patch === parent) return parent;
|
|
284
|
+
return parent === neverAborted ? patch : AbortSignal.any([parent, patch]);
|
|
285
|
+
}
|
|
286
|
+
|
|
275
287
|
/**
|
|
276
288
|
* Derive a narrowed context — impersonation, a locale switch, a per-step abort signal.
|
|
277
289
|
* `requestId` is deliberately not patchable: one request, one id.
|
|
@@ -301,7 +313,7 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
|
|
|
301
313
|
// that and did not do it — a patched hour replaced a parent's second outright, and
|
|
302
314
|
// `remainingBudgetMs` then put the hour on `x-request-timeout-ms` for the next hop.
|
|
303
315
|
deadlineAt: earliest(screenDeadline(patch.deadlineAt) ?? undefined, parent.deadlineAt),
|
|
304
|
-
signal:
|
|
316
|
+
signal: composeSignal(parent.signal, patch.signal),
|
|
305
317
|
services: { ...carried, ...(patch.services ?? {}) },
|
|
306
318
|
});
|
|
307
319
|
return requestContext.run(child, fn);
|