@ultimat3/core 7.0.0 → 9.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 +59 -4
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/cache-vocabulary.ts +25 -0
- package/src/config.ts +66 -51
- package/src/context.ts +7 -1
- package/src/decimal-order.ts +5 -1
- package/src/error-codes.ts +3 -0
- package/src/error-render.ts +17 -5
- package/src/exports/observability.ts +2 -0
- package/src/format-bytes.ts +47 -0
- package/src/index.ts +13 -8
- package/src/lifecycle.ts +73 -38
- package/src/logger.ts +21 -1
- package/src/nearest-name.ts +45 -0
- package/src/otlp.ts +34 -1
- package/src/registrar.ts +38 -0
- package/src/telemetry.ts +21 -3
- package/src/type-pins.ts +69 -2
package/CLAUDE.md
CHANGED
|
@@ -79,12 +79,17 @@ escape, so a cause could repaint the screen or hide the line above it. `@ultimat
|
|
|
79
79
|
deliberate duplicate for the tier-0 reason below, pinned behaviourally by
|
|
80
80
|
`single-line-pin.test.ts` in `@ultimat3/cli`.
|
|
81
81
|
|
|
82
|
-
`describeValue` in `error-render.ts` is a
|
|
82
|
+
`describeValue` in `error-render.ts` is a deliberate duplicate of `describeValue` in
|
|
83
83
|
`packages/schema/src/describe-value.ts`, for the same tier-0 reason `SCHEMA_ERROR_CODE_TITLES` is
|
|
84
84
|
one: schema and core are both tier 0 and `core → schema` is **not** a declared edge in
|
|
85
|
-
`scripts/lib/tiers.ts`, so neither may import the other. Keep the two
|
|
85
|
+
`scripts/lib/tiers.ts`, so neither may import the other. Keep the two ANSWERING identically — that
|
|
86
|
+
is the contract, and the source is no longer character-for-character: schema counts characters
|
|
87
|
+
through `char-count.ts`, which core copies privately. A pin test in
|
|
86
88
|
`@ultimat3/cli` (which may legally import both) is the mechanical half, the same shape as
|
|
87
|
-
`schema-error-codes-pin.test.ts`.
|
|
89
|
+
`schema-error-codes-pin.test.ts`. **A string's length is CODE POINTS in both, `As of 2026-08-22`**
|
|
90
|
+
— `validators.ts` rejects in that unit and `json-schema.ts` publishes `minLength` in it, so
|
|
91
|
+
`.length` made `t.string.min(3).safeParse('👍a')` say "at least 3 chars, received a string of 3
|
|
92
|
+
characters". The rule it enforces: a `cause` reaches the log index AND the
|
|
88
93
|
HTTP problem document, redaction is by log FIELD key, and a value baked into a message string has
|
|
89
94
|
no key left to redact — so `parseId`/`uuidTimestamp` describe a rejected id and never echo it.
|
|
90
95
|
|
|
@@ -105,6 +110,7 @@ shape against a locally declared sample interface for exactly that reason.
|
|
|
105
110
|
| which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never declare a second env var for it |
|
|
106
111
|
| what this process does | `roles.ts` (`ROLE`) | |
|
|
107
112
|
| how a route renders, caches offline and hydrates | `route-vocabulary.ts` (`RENDER_MODES`, `OFFLINE_STRATEGIES`, `HYDRATE_STRATEGIES`) | tier 0 because SIX packages name them and imports only go down — `render`, `http`, `seo`, `manifest` and `pwa` each kept a hand-copy until 2026-08, and `'spa'` was deleted from one while five went on admitting it under a green typecheck. Every union is `(typeof ARRAY)[number]`, pinned in `type-pins.ts`; `scripts/render-modes.test.ts` refuses a second declaration anywhere in `packages/*/src`. Re-export it, never restate it |
|
|
113
|
+
| which rungs a cache ladder has | `cache-vocabulary.ts` (`CACHE_TIERS`) | tier 0 for the same reason, one tier lower down the stack: `app.config.ts` picks tiers by name and `@ultimat3/cache` builds them by name, and until 2026-08-22 those were two different vocabularies — config accepted `memo \| lru \| shared \| isr \| cdn`, the ladder ordered `request-memo \| lru \| redis \| cdn`, nothing mapped one onto the other, and `cache: { tiers: ['isr'] }` typechecked and selected nothing (issue #293). `TIER_ORDER` IS this array, so read order and the config vocabulary cannot disagree. `isr` is a `RenderMode`, never a tier. Pinned in `type-pins.ts` and by `scripts/render-modes.ts` |
|
|
108
114
|
| which build of the APP this is | `app-version.ts` (`APP_VERSION`) | one reader, `dev` by default: `db` writes it into `x_migrations` and `jobs` into `x_backfills`, and `jobs` cannot reach `db` for the answer |
|
|
109
115
|
| the values | `env.ts` | `checkEnv().values` holds REAL secrets — anything that prints goes through `maskedEnvValues()` |
|
|
110
116
|
| `.env.example` | `env-example.ts` | a projection of the schema, never hand-maintained |
|
|
@@ -175,6 +181,17 @@ NOT — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must n
|
|
|
175
181
|
orders a `text` column of digits lexically and a comparator guessing would trade one disagreement
|
|
176
182
|
with the SQL it printed for another.
|
|
177
183
|
|
|
184
|
+
`format-bytes.ts` is the same rule at its smallest, `As of 2026-08-22`: one `formatBytes(bytes)`,
|
|
185
|
+
1024-base, `b|kb|mb|gb`, for the byte count an error message carries. `@ultimat3/render` (t4) and
|
|
186
|
+
`@ultimat3/pwa` (t4) each had one and they had diverged — render's stopped at `kb`, so a 5 MiB route
|
|
187
|
+
read `5120kb` in `X_BUDGET_EXCEEDED` and `5mb` in the precache warning about the same bytes, and
|
|
188
|
+
`@ultimat3/cli`'s budget error imported render's. Deliberately NOT
|
|
189
|
+
`@ultimat3/ui`'s `formatBytes(bytes, locale)`, which is a different function and stays: that one is
|
|
190
|
+
`Intl`-formatted and DECIMAL (kB = 1000 B, which is what `Intl`'s unit means), for a human reading a
|
|
191
|
+
file picker, where this one must line up with a bundler's own KiB figures and must not move with the
|
|
192
|
+
reader's locale. **Not mechanised** — no gate refuses a third copy, unlike `render-modes.ts` for the
|
|
193
|
+
route vocabulary; a `formatBytes` reappearing in `packages/*/src` is caught by review only.
|
|
194
|
+
|
|
178
195
|
`mcp-exposure.ts` is the same shape for a declaration rather than an algorithm: `isMcpExposed` is
|
|
179
196
|
the ONE answer to "did this primitive opt into being an MCP tool?", asked by `action`, `query`
|
|
180
197
|
(t3), `mcp`, `ai`, `manifest` (t4) — five packages that cannot import each other, so core is the
|
|
@@ -207,6 +224,16 @@ which is what stops an exporter from turning 40k rps into 40k rps of spans. `con
|
|
|
207
224
|
takes a `Sampler`; the default reads `OTEL_TRACES_SAMPLER*` **at the first span, never at module
|
|
208
225
|
scope** (same call-time rule as `cursor.ts`'s secret). `resetTelemetry()` drops both.
|
|
209
226
|
|
|
227
|
+
**An empty `spanId` means "no inbound decision", and every reader must honour it, `As of
|
|
228
|
+
2026-08-22`.** `currentSpanContext()` synthesises `{ traceId, spanId: '', traceFlags: 1 }` from the
|
|
229
|
+
request context — a trace id this process minted, plus the header it would send onward. Handing
|
|
230
|
+
that to `Sampler.shouldSample` as a parent made `parentBasedRatioSampler` inherit a bit nobody sent,
|
|
231
|
+
so at ratio 0 a root span outside a request exported 0 and one inside exported 1 — and
|
|
232
|
+
`@ultimat3/http`'s `pipeline.ts` is `runWithContext` then `withSpan`, so **every HTTP root span was
|
|
233
|
+
exported at every ratio**. `startSpan` now narrows through `inboundParent()`: the trace id is
|
|
234
|
+
carried, the decision is not. It is the same discriminator `end()` already used to drop a synthetic
|
|
235
|
+
`parentSpanId`.
|
|
236
|
+
|
|
210
237
|
The OTLP exporters are built, not wrapped, and the case is in
|
|
211
238
|
[`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md): OTLP/HTTP JSON is `fetch`
|
|
212
239
|
plus `JSON.stringify`, while `@opentelemetry/api` would put a SECOND `Span` type in the framework
|
|
@@ -216,6 +243,15 @@ plus `JSON.stringify`, while `@opentelemetry/api` would put a SECOND `Span` type
|
|
|
216
243
|
non-`http/json` `OTEL_EXPORTER_OTLP_PROTOCOL` throw `X_OTLP_PROTOCOL_UNSUPPORTED` naming `:4318`.
|
|
217
244
|
A boot that must not throw asks `tryOtlpEndpoint(signal)` first.
|
|
218
245
|
|
|
246
|
+
**Three OTLP variables, three codes, `As of 2026-08-22`** — `X_OTLP_ENDPOINT_INVALID`,
|
|
247
|
+
`X_OTLP_HEADERS_INVALID`, `X_OTLP_PROTOCOL_UNSUPPORTED`, one per variable an operator sets. The
|
|
248
|
+
headers one is not a duplicate of the endpoint one: `otlpHeaders` percent-decodes, so `%zz` in
|
|
249
|
+
`OTEL_EXPORTER_OTLP_HEADERS` used to take the process down with a bare `URIError` at exporter
|
|
250
|
+
construction, and raising the ENDPOINT code instead would send the first reader of
|
|
251
|
+
`x errors explain` to inspect a variable that is fine. A title is what an agent reads first, and an
|
|
252
|
+
accurate `cause:` does not rescue one that misdirects. The header **key** is in the cause, the fix
|
|
253
|
+
and `meta`; the **value** is in none of them — it is the collector's credential.
|
|
254
|
+
|
|
219
255
|
`error-reporter.ts` is the same shape a third time: `ErrorReporter`, a no-op default, a memory
|
|
220
256
|
reporter for tests, and a transport on the wire (`error-reporter-sentry.ts`, an optional separate
|
|
221
257
|
export — the DSN is the app's typed env, never a constant here). `reportError` never throws and
|
|
@@ -247,13 +283,32 @@ bun run typecheck
|
|
|
247
283
|
`markReady()` means **bound**, and readiness means **usable** — two different facts since
|
|
248
284
|
`registerReadinessCheck(name, check)`. `/readyz` is ready only when the state is `ready` AND every
|
|
249
285
|
named check passes, and `HealthReport.checks` carries them by name because "alert on check
|
|
250
|
-
failures by check name" is not writable against a boolean.
|
|
286
|
+
failures by check name" is not writable against a boolean. `HealthReport.registered` carries the
|
|
287
|
+
COUNT beside it, `As of 2026-08-22`: `checks: {}` reads identically for "every check passed" and
|
|
288
|
+
"nobody registered one", and only the second is a `/readyz` meaning no more than "the socket is
|
|
289
|
+
bound". Reported, never enforced — **an empty registry is still `ready`, and `/readyz` still
|
|
290
|
+
answers 200**: `Object.values({}).every(…)` is vacuously true, and that is deliberate so a role
|
|
291
|
+
with no dependency does not have to invent a check to boot. `registered` is the field a caller
|
|
292
|
+
reads to tell "all checks passed" from "there were none".
|
|
293
|
+
|
|
294
|
+
`readinessChecks()` builds its record through `Object.fromEntries`, never by assigning
|
|
295
|
+
`results[name]` — assignment to the one name `__proto__` sets the PROTOTYPE rather than adding a
|
|
296
|
+
key, so a check by that name disappeared from the report and a `failing` one answered 200. Checks are **synchronous** on purpose:
|
|
251
297
|
a probe that awaits a network call turns a slow dependency into a wedged endpoint and a restart
|
|
252
298
|
loop, so the owner of the dependency keeps a boolean fresh and this reads it. Liveness ignores
|
|
253
299
|
them — a database outage that failed `/healthz` would restart the whole fleet into the same
|
|
254
300
|
outage. The registration returns its unregister, same shape and same ownership rule as
|
|
255
301
|
`onShutdown`; `readinessCheckCount()` is the leak probe.
|
|
256
302
|
|
|
303
|
+
**`drain()`'s memo is published BEFORE the first hook runs, `As of 2026-08-22`, and that ordering
|
|
304
|
+
is the whole of the function.** A hook may call back into `drain()` and one does — `handle.stop()`
|
|
305
|
+
in `@ultimat3/http` is `drain('manual')`, and an `accept` hook is exactly where a server stops
|
|
306
|
+
listening — while `settleWithin` invokes a hook SYNCHRONOUSLY. `drainPromise = (async () => …)()`
|
|
307
|
+
had therefore not assigned when the first hook ran: the re-entrant call read `undefined`, started a
|
|
308
|
+
second whole drain and recursed **~4,700 deep** until the stack ran out, every level swallowed by
|
|
309
|
+
`settleWithin` as `shutdown hook failed`. The guard and the registration are now one synchronous
|
|
310
|
+
step (`jobs`' `worker.ts` states the same rule), with the phases in `runDrain`.
|
|
311
|
+
|
|
257
312
|
**The drain deadline is enforced, not merely computed, and there is no unbounded state.**
|
|
258
313
|
`ShutdownReason.deadlineAt` was always handed to every hook and **no hook has ever read it** —
|
|
259
314
|
`jobs`' worker awaits every in-flight job and `driver.close()`, `jobs`' scheduler awaits its round,
|
package/README.md
CHANGED
|
@@ -322,6 +322,15 @@ nothing and still reports healthy.
|
|
|
322
322
|
once has to keep it: a discarded one is a hook per `start()`, each retaining the resource it
|
|
323
323
|
was going to drain, and the next drain runs every one of them against a torn-down copy.
|
|
324
324
|
`shutdownHookCount()` is the test-only probe that makes the leak assertable.
|
|
325
|
+
- `registerReadinessCheck(name, check)` is what makes `/readyz` mean **usable** rather than
|
|
326
|
+
**bound**. `ReadinessCheck` is `() => boolean` and must stay synchronous — a probe that awaits its
|
|
327
|
+
dependency turns a slow dependency into a wedged endpoint and then a restart loop; keep a boolean
|
|
328
|
+
fresh and let the check read it. It returns an unregister. `HealthReport.checks` is a map of name
|
|
329
|
+
→ `'ok' | 'failing'`, so "alert on check failures by check name" is writable.
|
|
330
|
+
- **`HealthReport.registered` is the third state.** `checks: {}` reads identically for "every check
|
|
331
|
+
passed" and "nobody registered one", and an **empty registry is still ready** — reported, never
|
|
332
|
+
enforced, so a role with no dependency does not have to invent a check to boot. Read `registered`
|
|
333
|
+
before trusting an empty `checks`.
|
|
325
334
|
- Anything that opens a socket calls `markListening(server.url.origin)` and releases it on close.
|
|
326
335
|
That is what tells the sealed test network a loopback request is this process, not egress.
|
|
327
336
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Single responsibility: the cache tiers' NAMES, in read order — the ladder itself is
|
|
2
|
+
// `@ultimat3/cache`'s. Tier 0 because that is the one place a tier-0 config declaration and a
|
|
3
|
+
// tier-1 implementation can both see; not `config.ts`, because `app.config.ts` consumes these
|
|
4
|
+
// names rather than owning them.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The rungs, near to far. **Order is load-bearing**: `sortTiers` in `@ultimat3/cache` orders a
|
|
8
|
+
* stack by index in this array, so a name's position here IS its distance from the request, and a
|
|
9
|
+
* name missing from it sorts to `-1` — ahead of the request memo.
|
|
10
|
+
*
|
|
11
|
+
* Spelled once because it was spelled twice and the two disagreed (issue #293): `app.config.ts`
|
|
12
|
+
* accepted `memo | lru | shared | isr | cdn` while the stack ordered `request-memo | lru | redis |
|
|
13
|
+
* cdn`, so `cache: { tiers: ['isr'] }` typechecked and selected nothing. `memo`/`request-memo` and
|
|
14
|
+
* `shared`/`redis` were one rung spelled twice; the ladder's spelling wins, because it is the one
|
|
15
|
+
* a `TierInvalidation`, a `TierFailure` and the `/_x` panel already report.
|
|
16
|
+
*
|
|
17
|
+
* **`isr` is not here and is not a cache tier.** It is a `RenderMode` (`route-vocabulary.ts`) — a
|
|
18
|
+
* per-route rendering decision, revalidated by a tag bust — and `@ultimat3/cache` has no ISR store
|
|
19
|
+
* to build: `'isr'` appears nowhere in `packages/cache/src` except one invalidation label. Serving
|
|
20
|
+
* ISR is `render: 'isr'` on the route, never a rung of this ladder.
|
|
21
|
+
*/
|
|
22
|
+
export const CACHE_TIERS = ['request-memo', 'lru', 'redis', 'cdn'] as const;
|
|
23
|
+
|
|
24
|
+
/** Derived from the array rather than written twice, so the pair cannot disagree. */
|
|
25
|
+
export type CacheTierName = (typeof CACHE_TIERS)[number];
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
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
|
+
// Same rule for the same reason: `app.config.ts` CONSUMES the cache tier names, it does not own
|
|
6
|
+
// them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
|
|
7
|
+
// drift into two vocabularies with no map between them (issue #293).
|
|
8
|
+
import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
|
|
5
9
|
import { ConfigInvalidError } from './errors';
|
|
6
10
|
import { ROLES, type Role } from './roles';
|
|
7
11
|
// `app.config.ts` CONSUMES the route vocabulary; it does not own it. Declaring `OfflineStrategy`
|
|
@@ -11,7 +15,6 @@ import type { OfflineStrategy } from './route-vocabulary';
|
|
|
11
15
|
import { isIanaZoneName } from './time-zone-name';
|
|
12
16
|
|
|
13
17
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
14
|
-
export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn';
|
|
15
18
|
export type RealtimeTier = 'channels' | 'live-queries' | 'local-first';
|
|
16
19
|
export type RealtimeTransport = 'memory' | 'nats' | 'redis';
|
|
17
20
|
|
|
@@ -22,38 +25,30 @@ export interface ThemeConfig {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/**
|
|
25
|
-
* Where a browser that failed `auth: 'required'` is sent
|
|
28
|
+
* Where a browser that failed `auth: 'required'` is sent.
|
|
26
29
|
*
|
|
27
30
|
* `signInPath: null` is the default and the redirect stays off until an app names its page: the
|
|
28
31
|
* framework may not invent one of its app's routes, and an app that spells it `/login` would send
|
|
29
32
|
* every unauthenticated visitor to a 404. Null means the visitor gets the problem document — the
|
|
30
33
|
* right answer for an agent, and what a browser got in production until this existed.
|
|
34
|
+
*
|
|
35
|
+
* `afterSignInPath` was removed 2026-08 for the reason `urlEnv`, `poolSize` and `schema` were
|
|
36
|
+
* (below): accepted, defaulted and merged here, and read by NO file — `dummy/social-media-clone`
|
|
37
|
+
* set `/dashboard` and got whatever its sign-in route did on its own. The landing path belongs to
|
|
38
|
+
* the app's sign-in route, which is the only code that can honour it.
|
|
31
39
|
*/
|
|
32
40
|
export interface AuthConfig {
|
|
33
41
|
readonly signInPath: string | null;
|
|
34
|
-
/**
|
|
35
|
-
* Where sign-in lands when there is nowhere to return to, or `?next=` is not same-origin.
|
|
36
|
-
*
|
|
37
|
-
* **Consulted by nothing, `As of 2026-08.`** Accepted, defaulted and merged here and read by no
|
|
38
|
-
* file in the repo — `dummy/social-media-clone/app.config.ts` sets `/dashboard` and gets
|
|
39
|
-
* whatever the sign-in route does on its own. Same shape `urlEnv`, `poolSize` and `schema` were
|
|
40
|
-
* deleted for below; this one is not deleted yet only because its writer is a tracked app's
|
|
41
|
-
* config, so removing the key and the line that sets it is one commit across two file sets.
|
|
42
|
-
*/
|
|
43
|
-
readonly afterSignInPath: string;
|
|
44
42
|
}
|
|
45
43
|
|
|
44
|
+
/**
|
|
45
|
+
* `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
|
|
46
|
+
* real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
|
|
47
|
+
* scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
|
|
48
|
+
*/
|
|
46
49
|
export interface PwaConfig {
|
|
47
50
|
readonly enabled: boolean;
|
|
48
51
|
readonly offline: OfflineStrategy;
|
|
49
|
-
/**
|
|
50
|
-
* **Consulted by nothing, `As of 2026-08.`** `wiki/Configuration.md` describes it as "render
|
|
51
|
-
* your own install affordance from the deferred event", both tracked apps set it, and
|
|
52
|
-
* `x new`'s scaffold writes it into every generated app — and no file reads it.
|
|
53
|
-
* `@ultimat3/pwa`'s `install.ts` is real and complete; nothing threads this flag into it.
|
|
54
|
-
* Delete the key or thread it; leaving it is a switch with no wire.
|
|
55
|
-
*/
|
|
56
|
-
readonly installPrompt: boolean;
|
|
57
52
|
readonly backgroundSync: boolean;
|
|
58
53
|
readonly push: boolean;
|
|
59
54
|
}
|
|
@@ -78,11 +73,25 @@ export interface DatabaseConfig {
|
|
|
78
73
|
readonly ssl: boolean;
|
|
79
74
|
}
|
|
80
75
|
|
|
76
|
+
/**
|
|
77
|
+
* No `CacheTier`. It was a SECOND spelling of the ladder — `memo | lru | shared | isr | cdn`
|
|
78
|
+
* against `@ultimat3/cache`'s `request-memo | lru | redis | cdn` — with nothing mapping one onto
|
|
79
|
+
* the other, so `cache: { tiers: ['isr'] }` typechecked and selected nothing. Deleted 2026-08-22
|
|
80
|
+
* in favour of `CacheTierName`, which is the ladder's own names and the only ones `sortTiers` can
|
|
81
|
+
* place. It was also the second exported type called `CacheTier` in the tree; the other is
|
|
82
|
+
* `@ultimat3/cache`'s tier INTERFACE, which is the one every implementation names.
|
|
83
|
+
*
|
|
84
|
+
* No `driver` and no `urlEnv` either, deleted 2026-08-22 and for the same reason one rung further
|
|
85
|
+
* up: `tiers` is what BUILDS the ladder (`packages/cli/src/dev-cache.ts`), so `driver: 'redis'`
|
|
86
|
+
* beside `tiers: ['request-memo', 'lru']` was a second selector that selected nothing — the shape
|
|
87
|
+
* `examples/dummy/app.config.ts` shipped. `urlEnv` was `database.urlEnv` verbatim: the Redis tier
|
|
88
|
+
* reads the literal `REDIS_URL`, so `urlEnv: 'MY_REDIS'` made nothing read `MY_REDIS`. Which rungs
|
|
89
|
+
* exist is `cache.tiers` and only that; a rung the environment cannot supply refuses the boot.
|
|
90
|
+
*/
|
|
81
91
|
export interface CacheConfig {
|
|
82
|
-
readonly driver: 'memory' | 'redis';
|
|
83
|
-
readonly urlEnv: string | undefined;
|
|
84
92
|
readonly defaultTtlMs: number;
|
|
85
|
-
|
|
93
|
+
/** Order is fixed by `TIER_ORDER`; listing order here selects rungs, it does not rank them. */
|
|
94
|
+
readonly tiers: readonly CacheTierName[];
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
export interface JobsConfig {
|
|
@@ -123,18 +132,16 @@ export interface McpConfig {
|
|
|
123
132
|
readonly path: string;
|
|
124
133
|
}
|
|
125
134
|
|
|
135
|
+
/**
|
|
136
|
+
* No `modelEnv`. It named the env KEY holding the model id, "so no model string is baked into the
|
|
137
|
+
* image" — and its only reader was this file's own merge, copying input to output. Nothing
|
|
138
|
+
* consumed the merged value, so `modelEnv: 'ANTHROPIC_MODEL'` selected no model: `@ultimat3/ai`
|
|
139
|
+
* reads env for API KEYS only, and the model is `request.model ?? DEFAULT_MODEL`, a compile-time
|
|
140
|
+
* constant in `models.ts`. The exact thing the key existed to prevent is what it delivered.
|
|
141
|
+
* Deleted 2026-08 — pass `model` on the request, or read your own env key and pass it.
|
|
142
|
+
*/
|
|
126
143
|
export interface AiConfig {
|
|
127
144
|
readonly mcp: McpConfig;
|
|
128
|
-
/**
|
|
129
|
-
* Env key for the model id, so no model string is baked into the image — **an intention, not a
|
|
130
|
-
* behaviour, `As of 2026-08`.** The only read of it in the repo is the merge two hundred lines
|
|
131
|
-
* below, which copies it from input to output; nothing consumes the merged value, so
|
|
132
|
-
* `examples/dummy`'s `modelEnv: 'ANTHROPIC_MODEL'` selects no model. `@ultimat3/ai` reads env
|
|
133
|
-
* for API KEYS only (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`); the model is
|
|
134
|
-
* `request.model ?? DEFAULT_MODEL`, a compile-time constant in `models.ts`. So the exact thing
|
|
135
|
-
* this key exists to prevent — a model string baked into the image — is what actually happens.
|
|
136
|
-
*/
|
|
137
|
-
readonly modelEnv: string | undefined;
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
export interface AppConfig {
|
|
@@ -156,7 +163,8 @@ export interface AppConfig {
|
|
|
156
163
|
|
|
157
164
|
type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
|
|
158
165
|
|
|
159
|
-
|
|
166
|
+
/** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
|
|
167
|
+
export interface AiConfigInput {
|
|
160
168
|
readonly mcp?: Input<McpConfig> | undefined;
|
|
161
169
|
}
|
|
162
170
|
|
|
@@ -224,17 +232,11 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
224
232
|
defaultTimeZone: 'UTC',
|
|
225
233
|
defaultCurrency: 'USD',
|
|
226
234
|
theme: { defaultMode: 'system', tokens: {} },
|
|
227
|
-
auth: { signInPath: null
|
|
228
|
-
pwa: {
|
|
229
|
-
enabled: false,
|
|
230
|
-
offline: 'network-only',
|
|
231
|
-
installPrompt: false,
|
|
232
|
-
backgroundSync: false,
|
|
233
|
-
push: false,
|
|
234
|
-
},
|
|
235
|
+
auth: { signInPath: null },
|
|
236
|
+
pwa: { enabled: false, offline: 'network-only', backgroundSync: false, push: false },
|
|
235
237
|
roles: [...ROLES],
|
|
236
238
|
database: { driver: 'postgres', ssl: false },
|
|
237
|
-
cache: {
|
|
239
|
+
cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
|
|
238
240
|
jobs: {
|
|
239
241
|
queues: [`${name}-default`],
|
|
240
242
|
concurrency: 8,
|
|
@@ -243,7 +245,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
243
245
|
visibilityTimeoutMs: 30_000,
|
|
244
246
|
},
|
|
245
247
|
realtime: { enabled: false, tier: 'channels', transport: 'memory', urlEnv: undefined },
|
|
246
|
-
ai: { mcp: { expose: true, path: '/mcp' }
|
|
248
|
+
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
247
249
|
};
|
|
248
250
|
}
|
|
249
251
|
|
|
@@ -259,10 +261,21 @@ const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run:
|
|
|
259
261
|
const TIMEZONE_FIX =
|
|
260
262
|
"set defaultTimeZone to an Area/Location name, or UTC — list every accepted one with bun -e \"console.log(Intl.supportedValuesOf('timeZone').join('\\n'))\" — where a legacy single-label name swaps mechanically (Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC), while an abbreviation or numeric offset (CET, EST5EDT, +01:00) carries no DST rule and has no replacement, so name the city whose clock you mean (Europe/Paris, America/New_York)";
|
|
261
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Appended only when a tier name is what failed, and it names the rename rather than the rule: the
|
|
266
|
+
* three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
|
|
267
|
+
* replacement while `isr` has none — it is a `RenderMode`, and no cache tier ever served it.
|
|
268
|
+
*/
|
|
269
|
+
const CACHE_TIER_FIX =
|
|
270
|
+
"in app.config.ts, rewrite cache.tiers with the rung names the ladder serves — request-memo, lru, redis, cdn — where memo becomes request-memo and shared becomes redis, and isr is dropped: it is a render mode, so move it to render: 'isr' on the routes that want it";
|
|
271
|
+
|
|
262
272
|
function validate(config: AppConfig): void {
|
|
263
273
|
const issues: string[] = [];
|
|
264
274
|
// Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
|
|
265
275
|
const zoneFix: string[] = [];
|
|
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.
|
|
278
|
+
const tierFix: string[] = [];
|
|
266
279
|
|
|
267
280
|
if (!NAME_RE.test(config.name)) {
|
|
268
281
|
issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
|
|
@@ -292,8 +305,13 @@ function validate(config: AppConfig): void {
|
|
|
292
305
|
if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
|
|
293
306
|
issues.push(`realtime.transport "${config.realtime.transport}" requires realtime.urlEnv`);
|
|
294
307
|
}
|
|
295
|
-
|
|
296
|
-
|
|
308
|
+
// A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
|
|
309
|
+
// index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
|
|
310
|
+
// So an unknown tier is refused at boot rather than silently ignored or silently placed first.
|
|
311
|
+
for (const tier of config.cache.tiers) {
|
|
312
|
+
if (CACHE_TIERS.includes(tier)) continue;
|
|
313
|
+
issues.push(`cache.tiers contains "${tier}", which is not one of ${CACHE_TIERS.join(', ')}`);
|
|
314
|
+
if (tierFix.length === 0) tierFix.push(CACHE_TIER_FIX);
|
|
297
315
|
}
|
|
298
316
|
|
|
299
317
|
if (issues.length > 0) {
|
|
@@ -301,7 +319,7 @@ function validate(config: AppConfig): void {
|
|
|
301
319
|
cause: issues.join('; '),
|
|
302
320
|
// The generic instruction goes LAST so the fix line still ends in a command that can be
|
|
303
321
|
// pasted — a trailing `.` after `x verify` is a command nobody can run.
|
|
304
|
-
fix: [...zoneFix, BASE_FIX].join('. '),
|
|
322
|
+
fix: [...zoneFix, ...tierFix, BASE_FIX].join('. '),
|
|
305
323
|
meta: { issues },
|
|
306
324
|
});
|
|
307
325
|
}
|
|
@@ -337,10 +355,7 @@ export function defineConfig(
|
|
|
337
355
|
cache: section(base.cache, merged.cache),
|
|
338
356
|
jobs: section(base.jobs, merged.jobs),
|
|
339
357
|
realtime: section(base.realtime, merged.realtime),
|
|
340
|
-
ai: {
|
|
341
|
-
mcp: section(base.ai.mcp, merged.ai?.mcp),
|
|
342
|
-
modelEnv: merged.ai?.modelEnv ?? base.ai.modelEnv,
|
|
343
|
-
},
|
|
358
|
+
ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
|
|
344
359
|
};
|
|
345
360
|
|
|
346
361
|
validate(config);
|
package/src/context.ts
CHANGED
|
@@ -70,7 +70,13 @@ export interface CtxInit {
|
|
|
70
70
|
readonly services?: ServiceBag | undefined;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Neither id a child may change. `requestId` because one request is one request however many
|
|
75
|
+
* scopes it opens; `buildId` because a child context is the same DEPLOY — `withChildContext`
|
|
76
|
+
* has always forwarded the parent's, so accepting the key was an option that read as honoured and
|
|
77
|
+
* was dropped in silence. Pinned in `type-pins.ts`.
|
|
78
|
+
*/
|
|
79
|
+
export type CtxPatch = Omit<CtxInit, 'requestId' | 'buildId'>;
|
|
74
80
|
|
|
75
81
|
/**
|
|
76
82
|
* `async-context.ts` owns why this is a lazily-opened seam rather than a module-scope
|
package/src/decimal-order.ts
CHANGED
|
@@ -48,12 +48,16 @@ function decimalOf(value: unknown): Decimal | undefined {
|
|
|
48
48
|
* a 38-digit `numeric` orders by its digits rather than by whatever a `Number` rounded it to.
|
|
49
49
|
*/
|
|
50
50
|
function compare(left: Decimal, right: Decimal): number {
|
|
51
|
-
if (left.negative !== right.negative) return left.negative ? -1 : 1;
|
|
52
51
|
const width = Math.max(left.fraction.length, right.fraction.length);
|
|
53
52
|
const scaled = (value: Decimal): bigint =>
|
|
54
53
|
BigInt(`${value.whole}${value.fraction.padEnd(width, '0')}`);
|
|
55
54
|
const first = scaled(left);
|
|
56
55
|
const second = scaled(right);
|
|
56
|
+
// Magnitude BEFORE sign, because a `numeric` has exactly one zero: `select '-0'::numeric =
|
|
57
|
+
// '0'::numeric` is true, and so is `'-0.00' = '0'`. Comparing the sign first answered `-1` for
|
|
58
|
+
// that pair and cut a keyset page boundary between two rows the database calls equal.
|
|
59
|
+
if (first === 0n && second === 0n) return 0;
|
|
60
|
+
if (left.negative !== right.negative) return left.negative ? -1 : 1;
|
|
57
61
|
// Never a subtraction: the difference between two `bigint`s is exact and the return type is a
|
|
58
62
|
// `number`, which cannot hold it.
|
|
59
63
|
const order = first < second ? -1 : first > second ? 1 : 0;
|
package/src/error-codes.ts
CHANGED
|
@@ -53,6 +53,9 @@ const CORE_CODE_TITLES = {
|
|
|
53
53
|
X_NO_CONTEXT: 'no request context is active',
|
|
54
54
|
X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
|
|
55
55
|
X_OTLP_ENDPOINT_INVALID: 'the OTLP collector endpoint is missing or malformed',
|
|
56
|
+
// Its own code rather than the endpoint's, because a title is what an agent reads first:
|
|
57
|
+
// `x errors explain X_OTLP_ENDPOINT_INVALID` would send it to inspect a variable that is fine.
|
|
58
|
+
X_OTLP_HEADERS_INVALID: 'OTEL_EXPORTER_OTLP_HEADERS is malformed',
|
|
56
59
|
X_OTLP_PROTOCOL_UNSUPPORTED: 'the OTLP protocol requested is not OTLP/HTTP JSON',
|
|
57
60
|
X_READINESS_CHECK_DUPLICATE: 'a readiness check name is registered twice',
|
|
58
61
|
X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
|
package/src/error-render.ts
CHANGED
|
@@ -252,17 +252,17 @@ export function renderMetaRecord(
|
|
|
252
252
|
* There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away
|
|
253
253
|
* from being the same breach.
|
|
254
254
|
*
|
|
255
|
-
* A deliberate
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
255
|
+
* A deliberate duplicate of `describeValue` in `packages/schema/src/describe-value.ts`, for the
|
|
256
|
+
* reason `SCHEMA_ERROR_CODE_TITLES` is one: `@ultimat3/schema` is tier 0 alongside this package,
|
|
257
|
+
* so neither may import the other. Keep the two answering IDENTICALLY — that is what
|
|
258
|
+
* `packages/cli/src/describe-value-pin.test.ts` holds — and changing one alone is the bug.
|
|
259
259
|
*/
|
|
260
260
|
export function describeValue(value: unknown): string {
|
|
261
261
|
if (value === undefined) return 'undefined';
|
|
262
262
|
if (value === null) return 'null';
|
|
263
263
|
switch (typeof value) {
|
|
264
264
|
case 'string':
|
|
265
|
-
return countOf(value
|
|
265
|
+
return countOf(charCount(value), 'string', 'character');
|
|
266
266
|
case 'number':
|
|
267
267
|
return describeNumber(value);
|
|
268
268
|
case 'boolean':
|
|
@@ -294,3 +294,15 @@ function countOf(size: number, noun: string, unit: string): string {
|
|
|
294
294
|
const article = noun === 'array' ? 'an' : 'a';
|
|
295
295
|
return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
|
|
296
296
|
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The twin of `@ultimat3/schema`'s `char-count.ts`, duplicated for the same reason `describeValue`
|
|
300
|
+
* is: both packages are tier 0 and neither may import the other. Code points, because the rules
|
|
301
|
+
* that reject a string count in them and the message must quote the same unit — `'👍'.length` is 2.
|
|
302
|
+
* Only a surrogate makes the two counts differ, so every ASCII value keeps the O(1) read.
|
|
303
|
+
*/
|
|
304
|
+
const HAS_SURROGATE = /[\uD800-\uDBFF]/;
|
|
305
|
+
|
|
306
|
+
function charCount(value: string): number {
|
|
307
|
+
return HAS_SURROGATE.test(value) ? [...value].length : value.length;
|
|
308
|
+
}
|
|
@@ -42,6 +42,7 @@ export {
|
|
|
42
42
|
REDACTED,
|
|
43
43
|
redactKeys,
|
|
44
44
|
setLoggerContextFields,
|
|
45
|
+
setLogStream,
|
|
45
46
|
} from '../logger';
|
|
46
47
|
export type {
|
|
47
48
|
Counter,
|
|
@@ -88,6 +89,7 @@ export {
|
|
|
88
89
|
OTLP_PROTOCOL_KEY,
|
|
89
90
|
OTLP_SCOPE,
|
|
90
91
|
OtlpEndpointInvalidError,
|
|
92
|
+
OtlpHeadersInvalidError,
|
|
91
93
|
OtlpProtocolUnsupportedError,
|
|
92
94
|
otlpAttributes,
|
|
93
95
|
otlpEndpoint,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Single responsibility: a byte count as the short, machine-ish string an error message carries.
|
|
2
|
+
//
|
|
3
|
+
// Tier 0 because two tier-4 packages need exactly this and neither may import the other:
|
|
4
|
+
// `@ultimat3/render`'s `X_BUDGET_EXCEEDED` cause and `@ultimat3/pwa`'s precache warning. Each kept
|
|
5
|
+
// its own copy and they had diverged — render's stopped at `kb`, so one 5 MiB route read `5120kb`
|
|
6
|
+
// in the budget error and `5mb` in the warning about the same bytes.
|
|
7
|
+
|
|
8
|
+
/** 1024-based, because every producer here counts bundle bytes, which tooling reports in KiB. */
|
|
9
|
+
const STEP = 1024;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ascending, so the index into it IS the power of `STEP`. `gb` is the last rung on purpose: a
|
|
13
|
+
* precache or a route bundle past a terabyte is a bug in the caller, not a unit this should grow.
|
|
14
|
+
*/
|
|
15
|
+
const UNITS = ['b', 'kb', 'mb', 'gb'] as const;
|
|
16
|
+
|
|
17
|
+
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A size a message can state — `1023b`, `4.5kb`, `5mb`, `1.2gb`.
|
|
21
|
+
*
|
|
22
|
+
* Not `@ultimat3/ui`'s `formatBytes(bytes, locale)`, which is `Intl`-formatted, DECIMAL (kB = 1000
|
|
23
|
+
* B, because that is what `Intl`'s unit means) and for a human reading a file picker. This one is
|
|
24
|
+
* for an error's `cause:`, where the number has to line up with a bundler's own KiB figures and
|
|
25
|
+
* must not change with the reader's locale.
|
|
26
|
+
*
|
|
27
|
+
* A negative or non-finite input answers `0b` rather than `-5b` or `NaNb`: axiom 4 says an error is
|
|
28
|
+
* an instruction, and `NaNb` instructs nobody. A size is never negative, so the input was already
|
|
29
|
+
* wrong by the time it arrived.
|
|
30
|
+
*/
|
|
31
|
+
export const formatBytes = (bytes: number): string => {
|
|
32
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return `0${UNITS[0]}`;
|
|
33
|
+
let value = bytes;
|
|
34
|
+
let index = 0;
|
|
35
|
+
while (index < UNITS.length - 1 && value >= STEP) {
|
|
36
|
+
value /= STEP;
|
|
37
|
+
index += 1;
|
|
38
|
+
}
|
|
39
|
+
// One more rung when ROUNDING crosses the boundary the raw value did not: 1048575 is under a
|
|
40
|
+
// mebibyte, but one decimal place renders it `1024kb` — a number that disagrees with its own
|
|
41
|
+
// unit, the same class of bug as render's missing `mb` branch.
|
|
42
|
+
if (index < UNITS.length - 1 && round1(value) >= STEP) {
|
|
43
|
+
value /= STEP;
|
|
44
|
+
index += 1;
|
|
45
|
+
}
|
|
46
|
+
return `${round1(value)}${UNITS[index] ?? 'b'}`;
|
|
47
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export {
|
|
|
32
32
|
export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
|
|
33
33
|
export { assert, assertNever, type InvariantOptions, invariant } from './assert';
|
|
34
34
|
export { type AsyncContext, asyncContext } from './async-context';
|
|
35
|
+
export { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
|
|
35
36
|
export { canonicalJson, fingerprint } from './canonical-json';
|
|
36
37
|
export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
|
|
37
38
|
export type {
|
|
@@ -42,7 +43,6 @@ export type {
|
|
|
42
43
|
AppConfigOverlay,
|
|
43
44
|
AuthConfig,
|
|
44
45
|
CacheConfig,
|
|
45
|
-
CacheTier,
|
|
46
46
|
DatabaseConfig,
|
|
47
47
|
JobsConfig,
|
|
48
48
|
McpConfig,
|
|
@@ -268,6 +268,7 @@ export {
|
|
|
268
268
|
OTLP_PROTOCOL_KEY,
|
|
269
269
|
OTLP_SCOPE,
|
|
270
270
|
OtlpEndpointInvalidError,
|
|
271
|
+
OtlpHeadersInvalidError,
|
|
271
272
|
OtlpProtocolUnsupportedError,
|
|
272
273
|
OVERFLOW_ATTRIBUTE,
|
|
273
274
|
otlpAttributes,
|
|
@@ -303,6 +304,7 @@ export {
|
|
|
303
304
|
sentryErrorReporter,
|
|
304
305
|
serviceResource,
|
|
305
306
|
setLoggerContextFields,
|
|
307
|
+
setLogStream,
|
|
306
308
|
startMetricExport,
|
|
307
309
|
startSpan,
|
|
308
310
|
traceparent,
|
|
@@ -369,6 +371,7 @@ export {
|
|
|
369
371
|
writeMasterKeyFile,
|
|
370
372
|
writeSecretsFile,
|
|
371
373
|
} from './exports/secrets';
|
|
374
|
+
export { formatBytes } from './format-bytes';
|
|
372
375
|
export type { Brand, Id } from './ids';
|
|
373
376
|
export {
|
|
374
377
|
isSpanId,
|
|
@@ -459,17 +462,19 @@ export {
|
|
|
459
462
|
SHUTDOWN_PHASES,
|
|
460
463
|
shutdownHookCount,
|
|
461
464
|
} from './lifecycle';
|
|
462
|
-
export {
|
|
463
|
-
isSelfOrigin,
|
|
464
|
-
listeningOrigins,
|
|
465
|
-
markListening,
|
|
466
|
-
resetListeners,
|
|
467
|
-
} from './listeners';
|
|
465
|
+
export { isSelfOrigin, listeningOrigins, markListening, resetListeners } from './listeners';
|
|
468
466
|
export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
|
|
467
|
+
export { nearestName } from './nearest-name';
|
|
469
468
|
export { type CappedBody, readWithinLimit } from './read-capped';
|
|
470
|
-
export type {
|
|
469
|
+
export type {
|
|
470
|
+
ModuleRegistrar,
|
|
471
|
+
PrimitiveFactory,
|
|
472
|
+
PrimitiveKind,
|
|
473
|
+
RegisteredPrimitive,
|
|
474
|
+
} from './registrar';
|
|
471
475
|
export {
|
|
472
476
|
hasPrimitiveRegistrar,
|
|
477
|
+
PRIMITIVE_FACTORIES,
|
|
473
478
|
PRIMITIVE_KINDS,
|
|
474
479
|
primitiveRegistrar,
|
|
475
480
|
registerPrimitiveRegistrar,
|
package/src/lifecycle.ts
CHANGED
|
@@ -82,6 +82,14 @@ export interface HealthReport {
|
|
|
82
82
|
readonly buildId: string;
|
|
83
83
|
/** Named, because "alert on check failures BY CHECK NAME" is not writable against a boolean. */
|
|
84
84
|
readonly checks: Readonly<Record<string, ReadinessStatus>>;
|
|
85
|
+
/**
|
|
86
|
+
* How many checks are registered. `checks: {}` reads identically for "every check passed" and
|
|
87
|
+
* "nobody registered one", and only the second is a `/readyz` that means no more than "the
|
|
88
|
+
* socket is bound" — which is what the chart's and compose's healthchecks route traffic on.
|
|
89
|
+
* Reported rather than enforced: an empty registry is still ready, so a role that genuinely has
|
|
90
|
+
* no dependency does not have to invent a check to boot.
|
|
91
|
+
*/
|
|
92
|
+
readonly registered: number;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
export interface HealthPayload {
|
|
@@ -192,18 +200,25 @@ function report(level: 'info' | 'warn' | 'error', message: string, fields: LogFi
|
|
|
192
200
|
}
|
|
193
201
|
}
|
|
194
202
|
|
|
195
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* Every check, run now, by name. A check that throws is `failing` — never an unhandled error.
|
|
205
|
+
*
|
|
206
|
+
* Built through `Object.fromEntries`, never by assigning `results[name]`: assignment to the one
|
|
207
|
+
* name `__proto__` sets the PROTOTYPE instead of adding a key, so that check vanished from the
|
|
208
|
+
* report, `ready` was computed over an empty object — vacuously true — and a failing check
|
|
209
|
+
* answered 200. `fromEntries` defines own properties and has no such name.
|
|
210
|
+
*/
|
|
196
211
|
export function readinessChecks(): Readonly<Record<string, ReadinessStatus>> {
|
|
197
|
-
const results:
|
|
212
|
+
const results: [string, ReadinessStatus][] = [];
|
|
198
213
|
for (const [name, check] of readiness) {
|
|
199
214
|
try {
|
|
200
|
-
results[name
|
|
215
|
+
results.push([name, check() ? 'ok' : 'failing']);
|
|
201
216
|
} catch (thrown) {
|
|
202
|
-
results[name
|
|
217
|
+
results.push([name, 'failing']);
|
|
203
218
|
report('warn', 'readiness check threw', { check: name, error: thrown });
|
|
204
219
|
}
|
|
205
220
|
}
|
|
206
|
-
return results;
|
|
221
|
+
return Object.fromEntries(results);
|
|
207
222
|
}
|
|
208
223
|
|
|
209
224
|
export function inflightCount(): number {
|
|
@@ -328,43 +343,62 @@ async function runPhase(phase: ShutdownPhase, reason: ShutdownReason): Promise<v
|
|
|
328
343
|
}
|
|
329
344
|
}
|
|
330
345
|
|
|
331
|
-
/**
|
|
346
|
+
/** The three phases, in order, under one budget. Never rejects — `drain()` depends on that. */
|
|
347
|
+
async function runDrain(signal: string, reason: ShutdownReason): Promise<void> {
|
|
348
|
+
try {
|
|
349
|
+
report('info', 'draining', { signal, deadlineMs, inflight });
|
|
350
|
+
await runPhase('accept', reason);
|
|
351
|
+
|
|
352
|
+
// Real monotonic, like `deadlineAt` itself: `waitForIdle` sleeps on a real `setTimeout`, and
|
|
353
|
+
// a budget read off an injected clock is a number that timer will never honour.
|
|
354
|
+
const remaining = Math.max(0, reason.deadlineAt - systemClock.monotonic());
|
|
355
|
+
const idle = await waitForIdle(remaining);
|
|
356
|
+
if (!idle) {
|
|
357
|
+
report('warn', 'X_SHUTDOWN_TIMEOUT', {
|
|
358
|
+
code: 'X_SHUTDOWN_TIMEOUT',
|
|
359
|
+
cause: `${inflight} in-flight operations still running after ${deadlineMs}ms`,
|
|
360
|
+
fix: 'raise the budget past the slowest handler — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds, or shorten the handler',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
await runPhase('inflight', reason);
|
|
365
|
+
await runPhase('close', reason);
|
|
366
|
+
} catch (thrown) {
|
|
367
|
+
// Nothing above should reach here — every hook is caught by `settleWithin` and every line
|
|
368
|
+
// goes through `report`. If something does, the drain still ENDS: a rejected `drainPromise`
|
|
369
|
+
// is a memo that re-rejects for every later caller and an unhandled rejection that kills the
|
|
370
|
+
// process mid-drain, which is strictly worse than a drain that finished badly and said so.
|
|
371
|
+
report('error', 'drain failed', { signal, error: thrown });
|
|
372
|
+
} finally {
|
|
373
|
+
state = 'stopped';
|
|
374
|
+
}
|
|
375
|
+
report('info', 'stopped', { signal });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Idempotent: concurrent signals join the same drain, and so does a RE-ENTRANT one.
|
|
380
|
+
*
|
|
381
|
+
* The memo is published before `runDrain` is called, and that ordering is the whole of this
|
|
382
|
+
* function. A hook may call back in here — `handle.stop()` in `@ultimat3/http` is `drain('manual')`
|
|
383
|
+
* and an `accept` hook is exactly where a server stops listening — and `settleWithin` invokes a
|
|
384
|
+
* hook SYNCHRONOUSLY, so the old `drainPromise = (async () => …)()` had not assigned yet when the
|
|
385
|
+
* first hook ran: the re-entrant call saw `undefined`, started a second whole drain, and recursed
|
|
386
|
+
* ~4,700 deep until the stack ran out, every level swallowed by `settleWithin` as
|
|
387
|
+
* `shutdown hook failed`. Same rule as `packages/jobs/src/worker.ts` — guard and registration in
|
|
388
|
+
* one synchronous step.
|
|
389
|
+
*/
|
|
332
390
|
export function drain(signal = 'manual'): Promise<void> {
|
|
333
391
|
if (drainPromise !== undefined) return drainPromise;
|
|
334
392
|
state = 'draining';
|
|
335
393
|
const reason: ShutdownReason = { signal, deadlineAt: systemClock.monotonic() + deadlineMs };
|
|
336
|
-
|
|
337
|
-
drainPromise =
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const remaining = Math.max(0, reason.deadlineAt - systemClock.monotonic());
|
|
345
|
-
const idle = await waitForIdle(remaining);
|
|
346
|
-
if (!idle) {
|
|
347
|
-
report('warn', 'X_SHUTDOWN_TIMEOUT', {
|
|
348
|
-
code: 'X_SHUTDOWN_TIMEOUT',
|
|
349
|
-
cause: `${inflight} in-flight operations still running after ${deadlineMs}ms`,
|
|
350
|
-
fix: 'raise the budget past the slowest handler — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds, or shorten the handler',
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
await runPhase('inflight', reason);
|
|
355
|
-
await runPhase('close', reason);
|
|
356
|
-
} catch (thrown) {
|
|
357
|
-
// Nothing above should reach here — every hook is caught by `settleWithin` and every line
|
|
358
|
-
// goes through `report`. If something does, the drain still ENDS: a rejected `drainPromise`
|
|
359
|
-
// is a memo that re-rejects for every later caller and an unhandled rejection that kills the
|
|
360
|
-
// process mid-drain, which is strictly worse than a drain that finished badly and said so.
|
|
361
|
-
report('error', 'drain failed', { signal, error: thrown });
|
|
362
|
-
} finally {
|
|
363
|
-
state = 'stopped';
|
|
364
|
-
}
|
|
365
|
-
report('info', 'stopped', { signal });
|
|
366
|
-
})();
|
|
367
|
-
|
|
394
|
+
let published!: () => void;
|
|
395
|
+
drainPromise = new Promise<void>((resolve) => {
|
|
396
|
+
published = resolve;
|
|
397
|
+
});
|
|
398
|
+
// Both settle paths, for the reason `installSignalHandlers` gives below: `runDrain` cannot
|
|
399
|
+
// reject today — that is its `try/finally`, not luck — and a rejected memo would re-reject for
|
|
400
|
+
// every later caller and end the process the drain was trying to end cleanly.
|
|
401
|
+
void runDrain(signal, reason).then(published, published);
|
|
368
402
|
return drainPromise;
|
|
369
403
|
}
|
|
370
404
|
|
|
@@ -410,6 +444,7 @@ export function healthReport(): HealthReport {
|
|
|
410
444
|
inflight,
|
|
411
445
|
buildId: process.env['BUILD_ID'] ?? 'dev',
|
|
412
446
|
checks,
|
|
447
|
+
registered: readiness.size,
|
|
413
448
|
};
|
|
414
449
|
}
|
|
415
450
|
|
package/src/logger.ts
CHANGED
|
@@ -101,8 +101,28 @@ export function setLoggerContextFields(provider: () => LogFields | undefined): v
|
|
|
101
101
|
contextFields = provider;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Where a line with no explicit writer lands, for everything below `error`. A fact about the
|
|
106
|
+
* PROCESS and not about the line: a container's stdout IS its log stream (12-factor), while a
|
|
107
|
+
* CLI's stdout is the answer it was asked for. `x db migrate --json` printed the boot logger's
|
|
108
|
+
* `ultimate migrate applied` and then the command's own JSON to fd 1, so a caller doing what
|
|
109
|
+
* `--json` exists for — parsing the output — raised on the second object.
|
|
110
|
+
*/
|
|
111
|
+
let logStream: 'stdout' | 'stderr' = 'stdout';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Send everything below `error` to stderr, or back to stdout. The process's own call, made once at
|
|
115
|
+
* entry: a per-line choice would be the second logging path axiom 1 refuses, and a per-logger one
|
|
116
|
+
* already exists as `LoggerOptions.writer` — what had no seam is the module-scope `logger`, which
|
|
117
|
+
* is the one `serve.ts` and every boot path write through.
|
|
118
|
+
*/
|
|
119
|
+
export function setLogStream(stream: 'stdout' | 'stderr'): void {
|
|
120
|
+
logStream = stream;
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
function defaultWriter(line: string, level: LogLevel): void {
|
|
105
|
-
const
|
|
124
|
+
const toStderr = logStream === 'stderr' || LEVEL_WEIGHT[level] >= LEVEL_WEIGHT.error;
|
|
125
|
+
const stream = toStderr ? process.stderr : process.stdout;
|
|
106
126
|
stream.write(`${line}\n`);
|
|
107
127
|
}
|
|
108
128
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Single responsibility: the declared name a mistyped one most likely meant, so any error can lead
|
|
2
|
+
// with the real one. It lives in core because three packages need the same answer — `@ultimat3/cli`
|
|
3
|
+
// for an unknown command, flag or positional, `@ultimat3/policy` for an unknown permission — and
|
|
4
|
+
// two copies of one cutoff are two suggestions for one typo.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Levenshtein distance. A grid rather than two rolling rows because `noUncheckedIndexedAccess`
|
|
8
|
+
* makes every read an `?? 0`, and one `at()` reads better than four of them.
|
|
9
|
+
*/
|
|
10
|
+
const distance = (a: string, b: string): number => {
|
|
11
|
+
const rows = a.length + 1;
|
|
12
|
+
const cols = b.length + 1;
|
|
13
|
+
const grid: number[] = new Array<number>(rows * cols).fill(0);
|
|
14
|
+
const at = (r: number, c: number): number => grid[r * cols + c] ?? 0;
|
|
15
|
+
for (let r = 0; r < rows; r += 1) grid[r * cols] = r;
|
|
16
|
+
for (let c = 0; c < cols; c += 1) grid[c] = c;
|
|
17
|
+
for (let r = 1; r < rows; r += 1) {
|
|
18
|
+
for (let c = 1; c < cols; c += 1) {
|
|
19
|
+
const cost = a[r - 1] === b[c - 1] ? 0 : 1;
|
|
20
|
+
grid[r * cols + c] = Math.min(at(r - 1, c) + 1, at(r, c - 1) + 1, at(r - 1, c - 1) + cost);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return at(rows - 1, cols - 1);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Past this many edits the "suggestion" is a different word, and a wrong lead is worse than none. */
|
|
27
|
+
const MAX_EDITS = 3;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The nearest candidate within `MAX_EDITS`, or `undefined` when nothing is close enough. Ties keep
|
|
31
|
+
* the FIRST candidate, which is the order the caller declared them in — `definePermissions([...])`
|
|
32
|
+
* and a `CommandSpec` list are both authored orders, and a stable answer is what lets a test pin one.
|
|
33
|
+
*/
|
|
34
|
+
export const nearestName = (input: string, candidates: readonly string[]): string | undefined => {
|
|
35
|
+
let best: string | undefined;
|
|
36
|
+
let bestScore = MAX_EDITS + 1;
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
const score = distance(input, candidate);
|
|
39
|
+
if (score < bestScore) {
|
|
40
|
+
best = candidate;
|
|
41
|
+
bestScore = score;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return best;
|
|
45
|
+
};
|
package/src/otlp.ts
CHANGED
|
@@ -15,6 +15,20 @@ export class OtlpEndpointInvalidError extends UltimateError {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Separate from `OtlpEndpointInvalidError` on purpose. The endpoint code's title says the ENDPOINT
|
|
20
|
+
* is missing or malformed, so raising it for a bad header escape sends the first reader of
|
|
21
|
+
* `x errors explain` to inspect `OTEL_EXPORTER_OTLP_ENDPOINT` — a variable that is fine. An
|
|
22
|
+
* accurate `cause:` does not rescue a title that misdirects.
|
|
23
|
+
*/
|
|
24
|
+
export class OtlpHeadersInvalidError extends UltimateError {
|
|
25
|
+
static readonly code = 'X_OTLP_HEADERS_INVALID';
|
|
26
|
+
override readonly name = 'OtlpHeadersInvalidError';
|
|
27
|
+
constructor(init: CodedErrorInit) {
|
|
28
|
+
super({ ...init, code: OtlpHeadersInvalidError.code });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export class OtlpProtocolUnsupportedError extends UltimateError {
|
|
19
33
|
static readonly code = 'X_OTLP_PROTOCOL_UNSUPPORTED';
|
|
20
34
|
override readonly name = 'OtlpProtocolUnsupportedError';
|
|
@@ -112,6 +126,25 @@ export function otlpEndpoint(
|
|
|
112
126
|
});
|
|
113
127
|
}
|
|
114
128
|
|
|
129
|
+
/**
|
|
130
|
+
* One header value, percent-decoded. `decodeURIComponent` throws a bare `URIError` on a malformed
|
|
131
|
+
* escape (`%zz`, a lone `%`), and the whole of `otlpHeaders` runs at exporter construction — so a
|
|
132
|
+
* typo in an operator-set variable took the process down with an error carrying no code, no cause
|
|
133
|
+
* and no fix. Refused instead, naming the variable and the header KEY: the value is the
|
|
134
|
+
* collector's credential and a `cause:` is folded into a log line.
|
|
135
|
+
*/
|
|
136
|
+
function decodeHeaderValue(key: string, raw: string): string {
|
|
137
|
+
try {
|
|
138
|
+
return decodeURIComponent(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new OtlpHeadersInvalidError({
|
|
141
|
+
cause: `${OTLP_HEADERS_KEY} carries a malformed percent-escape in the "${key}" value, so the header cannot be decoded`,
|
|
142
|
+
fix: `set ${OTLP_HEADERS_KEY}=${key}=<encoded>, where <encoded> is what bun -e 'console.log(encodeURIComponent(process.argv[1]))' <value> prints — or drop the stray % from the "${key}" value if it was meant literally`,
|
|
143
|
+
meta: { header: key },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
115
148
|
/** `key=value,key2=value2`, percent-decoded — the spec's format for collector auth headers. */
|
|
116
149
|
export function otlpHeaders(
|
|
117
150
|
explicit?: Readonly<Record<string, string>> | undefined,
|
|
@@ -125,7 +158,7 @@ export function otlpHeaders(
|
|
|
125
158
|
if (index <= 0) continue;
|
|
126
159
|
const key = pair.slice(0, index).trim().toLowerCase();
|
|
127
160
|
if (key === '') continue;
|
|
128
|
-
headers[key] =
|
|
161
|
+
headers[key] = decodeHeaderValue(key, pair.slice(index + 1).trim());
|
|
129
162
|
}
|
|
130
163
|
}
|
|
131
164
|
for (const [key, value] of Object.entries(explicit ?? {})) headers[key.toLowerCase()] = value;
|
package/src/registrar.ts
CHANGED
|
@@ -27,6 +27,44 @@ export const PRIMITIVE_KINDS = [
|
|
|
27
27
|
|
|
28
28
|
export type PrimitiveKind = (typeof PRIMITIVE_KINDS)[number];
|
|
29
29
|
|
|
30
|
+
/** One factory over one primitive: the export's name, the package that ships it, what it returns. */
|
|
31
|
+
export interface PrimitiveFactory {
|
|
32
|
+
readonly factory: string;
|
|
33
|
+
/** The package specifier the factory is imported from, so a `fix:` can be pasted. */
|
|
34
|
+
readonly pkg: string;
|
|
35
|
+
readonly kind: PrimitiveKind;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The other half of "never invent a ninth": the factories that already exist, in one table.
|
|
40
|
+
*
|
|
41
|
+
* Prose counted them — "the fourth instance of the framework's factory rule" — in three files that
|
|
42
|
+
* cannot see each other, so every ordinal was wrong the moment a fifth landed and none of them
|
|
43
|
+
* could be checked. A list here can be: `@ultimat3/cli` is tier 5, may import `ai`, `jobs` and
|
|
44
|
+
* `scraping`, and pins that every exported function returning an `Action`/`JobHandle` from outside
|
|
45
|
+
* their owning packages has a row. Adding a factory means adding a row, not editing a sentence.
|
|
46
|
+
*
|
|
47
|
+
* Sorted by package then name so the diff of a new row is one line.
|
|
48
|
+
*
|
|
49
|
+
* Every ROW is frozen, not just the list. `readonly` fields are a compile-time claim and this is a
|
|
50
|
+
* public export: freezing the array alone left `PRIMITIVE_FACTORIES[0].kind = 'entity'` a silent
|
|
51
|
+
* write from any untyped caller, which is the same defect `@ultimat3/money`'s currency rows
|
|
52
|
+
* carried — a table the framework hands out is a constant at RUNTIME or it is not a constant.
|
|
53
|
+
*/
|
|
54
|
+
export const PRIMITIVE_FACTORIES = Object.freeze<readonly PrimitiveFactory[]>(
|
|
55
|
+
(
|
|
56
|
+
[
|
|
57
|
+
{ factory: 'agent', pkg: '@ultimat3/ai', kind: 'action' },
|
|
58
|
+
{ factory: 'agentJob', pkg: '@ultimat3/ai', kind: 'job' },
|
|
59
|
+
{ factory: 'hive', pkg: '@ultimat3/ai', kind: 'action' },
|
|
60
|
+
{ factory: 'llm', pkg: '@ultimat3/ai', kind: 'action' },
|
|
61
|
+
{ factory: 'backfill', pkg: '@ultimat3/jobs', kind: 'job' },
|
|
62
|
+
{ factory: 'purge', pkg: '@ultimat3/jobs', kind: 'job' },
|
|
63
|
+
{ factory: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
|
|
64
|
+
] satisfies readonly PrimitiveFactory[]
|
|
65
|
+
).map((entry) => Object.freeze(entry)),
|
|
66
|
+
);
|
|
67
|
+
|
|
30
68
|
/**
|
|
31
69
|
* What a registrar hands back: the primitives it actually took, each carrying the name
|
|
32
70
|
* registration stamped on it. Returning the registered set — rather than nothing — is what lets
|
package/src/telemetry.ts
CHANGED
|
@@ -167,7 +167,13 @@ export function currentSpan(): Span | undefined {
|
|
|
167
167
|
return activeSpan.get();
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* The trace the caller is inside: active span, else the request context, else a fresh trace.
|
|
172
|
+
*
|
|
173
|
+
* The context branch carries an EMPTY `spanId` on purpose, and that emptiness is the discriminator
|
|
174
|
+
* every reader must honour: it is a trace id this process minted, not a span an upstream reported.
|
|
175
|
+
* `traceFlags: 1` here is the header this process would send onward, never a decision it received.
|
|
176
|
+
*/
|
|
171
177
|
export function currentSpanContext(): SpanContext | undefined {
|
|
172
178
|
const span = activeSpan.get();
|
|
173
179
|
if (span !== undefined) return span.context;
|
|
@@ -176,8 +182,20 @@ export function currentSpanContext(): SpanContext | undefined {
|
|
|
176
182
|
return { traceId: ctx.traceId, spanId: '', traceFlags: 1 };
|
|
177
183
|
}
|
|
178
184
|
|
|
185
|
+
/**
|
|
186
|
+
* A parent an upstream actually reported, as opposed to the synthetic one `currentSpanContext()`
|
|
187
|
+
* builds from a request context. Only the first carries a sampling decision: reading the synthetic
|
|
188
|
+
* one as inbound made `parentBasedRatioSampler` inherit a bit nobody sent, so every HTTP root span
|
|
189
|
+
* was exported at every ratio — `pipeline.ts` is `runWithContext` then `withSpan`, which is that
|
|
190
|
+
* exact pair — and the one lever between "tracing is on" and "the collector melts" did nothing.
|
|
191
|
+
*/
|
|
192
|
+
function inboundParent(parent: SpanContext | undefined): SpanContext | undefined {
|
|
193
|
+
return parent === undefined || parent.spanId === '' ? undefined : parent;
|
|
194
|
+
}
|
|
195
|
+
|
|
179
196
|
export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
180
197
|
const parent = options?.parent ?? currentSpanContext();
|
|
198
|
+
const inbound = inboundParent(parent);
|
|
181
199
|
const attributes: Record<string, AttributeValue> = { ...(options?.attributes ?? {}) };
|
|
182
200
|
// The bit is decided ONCE, here, and every child of this span inherits it through `parent` —
|
|
183
201
|
// so one trace is sampled or not sampled as a whole. Before this, `traceFlags` was hardcoded to
|
|
@@ -186,7 +204,7 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
|
186
204
|
const context: SpanContext = {
|
|
187
205
|
traceId: parent?.traceId ?? newTraceId(),
|
|
188
206
|
spanId: newSpanId(),
|
|
189
|
-
traceFlags: currentSampler().shouldSample(name,
|
|
207
|
+
traceFlags: currentSampler().shouldSample(name, inbound, attributes) ? 1 : 0,
|
|
190
208
|
};
|
|
191
209
|
const events: SpanEvent[] = [];
|
|
192
210
|
const startedAt = clock.now().getTime();
|
|
@@ -245,7 +263,7 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
|
|
|
245
263
|
// `traceparent`; it is simply not exported.
|
|
246
264
|
if ((context.traceFlags & 1) === 0) return;
|
|
247
265
|
const endedAt = clock.now().getTime();
|
|
248
|
-
const parentSpanId =
|
|
266
|
+
const parentSpanId = inbound?.spanId;
|
|
249
267
|
exporter.export({
|
|
250
268
|
name,
|
|
251
269
|
kind: options?.kind ?? 'internal',
|
package/src/type-pins.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
// Compile-time pins for the actor-facts seam, the config surface
|
|
1
|
+
// Compile-time pins for the actor-facts seam, the config surface, the request-context patch and
|
|
2
|
+
// the route and cache-tier vocabularies.
|
|
2
3
|
// Source, not a `.test.ts`, on purpose: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b`
|
|
3
4
|
// never reads a test file and a type-level assertion written there can never fail. This module
|
|
4
5
|
// emits nothing and exports nothing anybody imports — a regression is a build error.
|
|
5
6
|
|
|
6
7
|
import type { Actor, ActorFactMap, FactKeysOf, FactMapOf } from './actor';
|
|
7
|
-
import type {
|
|
8
|
+
import type { CacheTierName } from './cache-vocabulary';
|
|
9
|
+
import type { AppConfigInput, CacheConfig, DatabaseConfig } from './config';
|
|
10
|
+
import type { CtxPatch } from './context';
|
|
8
11
|
import type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
|
|
9
12
|
|
|
10
13
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
@@ -99,6 +102,40 @@ type _DatabaseInputCarriesNoDeadField = Assert<
|
|
|
99
102
|
: false
|
|
100
103
|
>;
|
|
101
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The two `config.cache` fields deleted 2026-08-22, held down for the reason the `database` three
|
|
107
|
+
* are: `cache.tiers` is what BUILDS the ladder, so `driver: 'redis'` was a second selector that
|
|
108
|
+
* selected nothing and `urlEnv` named an env key the Redis tier never reads — it reads the literal
|
|
109
|
+
* `REDIS_URL`. Re-adding either restores a knob an SRE sets, redeploys, and sees no effect from.
|
|
110
|
+
*/
|
|
111
|
+
type DeadCacheField = 'driver' | 'urlEnv';
|
|
112
|
+
|
|
113
|
+
type _CacheConfigCarriesNoDeadField = Assert<
|
|
114
|
+
Extract<keyof CacheConfig, DeadCacheField> extends never ? true : false
|
|
115
|
+
>;
|
|
116
|
+
|
|
117
|
+
/** And the input side with it — `Input<CacheConfig>` is what an `app.config.ts` writes. */
|
|
118
|
+
type _CacheInputCarriesNoDeadField = Assert<
|
|
119
|
+
Extract<keyof NonNullable<AppConfigInput['cache']>, DeadCacheField> extends never ? true : false
|
|
120
|
+
>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Neither id a child context may patch. `withChildContext` forwards the parent's `buildId`
|
|
124
|
+
* verbatim, so `{ buildId }` on the patch was an option that read as honoured and was dropped
|
|
125
|
+
* without a word — the same silent-no-op class as the three `database` fields above, one tier
|
|
126
|
+
* lower. `requestId` is here beside it because the two are refused for the same reason.
|
|
127
|
+
*/
|
|
128
|
+
type UnpatchableCtxKey = 'requestId' | 'buildId';
|
|
129
|
+
|
|
130
|
+
type _CtxPatchRefusesTheIds = Assert<
|
|
131
|
+
Extract<keyof CtxPatch, UnpatchableCtxKey> extends never ? true : false
|
|
132
|
+
>;
|
|
133
|
+
|
|
134
|
+
/** And the keys a child MAY change are still there — a pin that empties the type is not a pin. */
|
|
135
|
+
type _CtxPatchStillPatchesTheRest = Assert<
|
|
136
|
+
'actor' | 'locale' | 'tz' extends keyof CtxPatch ? true : false
|
|
137
|
+
>;
|
|
138
|
+
|
|
102
139
|
/**
|
|
103
140
|
* Mutual assignability, not one-way. The tuples are load-bearing: a bare `A extends B` distributes
|
|
104
141
|
* over a union and answers `true` for every member separately, so it cannot see a widening — which
|
|
@@ -123,3 +160,33 @@ type _OfflineStrategyIsItsArray = Assert<
|
|
|
123
160
|
type _HydrateStrategyIsItsArray = Assert<
|
|
124
161
|
Exact<HydrateStrategy, 'idle' | 'visible' | 'interaction' | 'never'>
|
|
125
162
|
>;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The cache ladder's rungs, same rule as the three above and for a defect that shipped: 8.0.0's
|
|
166
|
+
* `CacheTier` was a hand-written union in `config.ts` — `memo | lru | shared | isr | cdn` — while
|
|
167
|
+
* `@ultimat3/cache` ordered `request-memo | lru | redis | cdn`, so `cache: { tiers: ['isr'] }`
|
|
168
|
+
* typechecked and selected nothing (issue #293). Derived from `CACHE_TIERS` now, and pinned here
|
|
169
|
+
* because a `@ts-expect-error` in an excluded test file asserts nothing.
|
|
170
|
+
*/
|
|
171
|
+
type _CacheTierNameIsItsArray = Assert<
|
|
172
|
+
Exact<CacheTierName, 'request-memo' | 'lru' | 'redis' | 'cdn'>
|
|
173
|
+
>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The three spellings deleted in 9.0.0 must stay deleted. `memo` and `shared` were the ladder's
|
|
177
|
+
* near and shared rungs under a second name; `isr` was never a tier at all — it is a `RenderMode`,
|
|
178
|
+
* and re-admitting it would put a rung `sortTiers` places at `-1` (AHEAD of the request memo)
|
|
179
|
+
* back within reach of `app.config.ts`.
|
|
180
|
+
*/
|
|
181
|
+
type DeadCacheTier = 'memo' | 'shared' | 'isr';
|
|
182
|
+
|
|
183
|
+
type _CacheTiersRefuseTheOldSpellings = Assert<
|
|
184
|
+
Extract<CacheTierName, DeadCacheTier> extends never ? true : false
|
|
185
|
+
>;
|
|
186
|
+
|
|
187
|
+
/** And `cache.tiers` is that vocabulary, not a second one — the whole of the fix. */
|
|
188
|
+
type _CacheConfigNamesTheLadder = Assert<Exact<CacheConfig['tiers'], readonly CacheTierName[]>>;
|
|
189
|
+
|
|
190
|
+
type _CacheInputNamesTheLadder = Assert<
|
|
191
|
+
Exact<NonNullable<AppConfigInput['cache']>['tiers'], readonly CacheTierName[] | undefined>
|
|
192
|
+
>;
|