@ultimat3/core 6.0.0 → 8.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 +78 -4
- package/README.md +12 -1
- package/package.json +7 -1
- package/src/async-context.ts +7 -5
- package/src/config.ts +29 -44
- 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/error-reporter-sentry.ts +1 -1
- package/src/exports/observability.ts +2 -0
- package/src/format-bytes.ts +47 -0
- package/src/index.ts +15 -8
- package/src/lifecycle.ts +73 -38
- package/src/logger.ts +22 -2
- package/src/nearest-name.ts +45 -0
- package/src/otlp-span-exporter.ts +2 -2
- package/src/otlp.ts +34 -1
- package/src/registrar.ts +37 -0
- package/src/roles.ts +1 -1
- package/src/route-vocabulary.ts +23 -0
- package/src/runtime-metrics.ts +1 -1
- package/src/telemetry.ts +21 -3
- package/src/type-pins.ts +49 -5
package/CLAUDE.md
CHANGED
|
@@ -14,6 +14,7 @@ is a change to every package.
|
|
|
14
14
|
| New code | add to `CORE_CODE_TITLES` in `error-codes.ts`, else the title is auto-humanised |
|
|
15
15
|
| Time | take a `Clock`; `Date.now()` / `new Date()` only inside `clock.ts` |
|
|
16
16
|
| Context | never thread `ctx` as a parameter — `useContext()` |
|
|
17
|
+
| A value ambient across an `await` | `asyncContext<T>(subject)` from `async-context.ts`, in **every** package — never `new AsyncLocalStorage` |
|
|
17
18
|
| Exports | add to `src/index.ts` explicitly; no `export *`. Three subjects that each span a dozen modules arrive through `src/exports/` — every name is still written out in `index.ts`, so the public surface is one file to read |
|
|
18
19
|
| Files | < 200 LOC, 500 hard ceiling, one responsibility, `kebab-case.ts`, test beside source |
|
|
19
20
|
| Type claims | `type-pins.ts`, never a `.test.ts` — `tsconfig.json` excludes tests, so `tsc` never reads one |
|
|
@@ -22,6 +23,24 @@ Deliberate cycles (safe — nothing is referenced at module-evaluation time):
|
|
|
22
23
|
`errors.ts ⇄ error-codes.ts`. Keep it that way: no top-level `UltimateError` use in
|
|
23
24
|
`error-codes.ts`.
|
|
24
25
|
|
|
26
|
+
**`async-context.ts` is the framework's ONE `AsyncLocalStorage`, and that is a framework rule
|
|
27
|
+
rather than a core one, `As of 2026-08-20`.** `asyncContext` is exported from `src/index.ts` and
|
|
28
|
+
six modules outside this package opened their own before they adopted it — `@ultimat3/db`'s
|
|
29
|
+
transaction, statement attribution and expected-loop scopes, `@ultimat3/entity`'s `crossTenant`,
|
|
30
|
+
`@ultimat3/ai`'s budget ledger and LLM stream sink. Each was a module-scope `new` a browser bundler
|
|
31
|
+
turns into `TypeError: undefined is not a constructor` at module EVALUATION, so importing any of
|
|
32
|
+
those packages from a client bundle failed before a line of app code ran. Reads degrade to
|
|
33
|
+
`undefined`, writes throw `X_ASYNC_CONTEXT_UNAVAILABLE`; deferring the construction changes nothing
|
|
34
|
+
a server can observe — the storage is built on the first `get()` or `run()` rather than at module
|
|
35
|
+
evaluation, and `getStore()` outside a scope answers `undefined` either way.
|
|
36
|
+
|
|
37
|
+
The mechanical half is `scripts/async-context-guard.ts`, collected by `x verify`'s `unit` step
|
|
38
|
+
through `scripts/async-context-guard.test.ts` — it refuses a `new AsyncLocalStorage` **and** the
|
|
39
|
+
import that binds the class, aliased or namespaced, anywhere but this one file. The browser-barrel
|
|
40
|
+
test in `async-context.test.ts` covers the same defect for core alone and cannot see another
|
|
41
|
+
package; the guard cannot see a runtime `await import('node:async_hooks')`. Neither is the other's
|
|
42
|
+
duplicate.
|
|
43
|
+
|
|
25
44
|
`error-render.ts` imports nothing, including from this package — an error factory that dies
|
|
26
45
|
formatting its own message is the failure it exists to prevent, so it cannot depend on anything
|
|
27
46
|
that could itself throw. The same defect shipped three times (`entity`, `flags`, `cli`) before
|
|
@@ -60,12 +79,17 @@ escape, so a cause could repaint the screen or hide the line above it. `@ultimat
|
|
|
60
79
|
deliberate duplicate for the tier-0 reason below, pinned behaviourally by
|
|
61
80
|
`single-line-pin.test.ts` in `@ultimat3/cli`.
|
|
62
81
|
|
|
63
|
-
`describeValue` in `error-render.ts` is a
|
|
82
|
+
`describeValue` in `error-render.ts` is a deliberate duplicate of `describeValue` in
|
|
64
83
|
`packages/schema/src/describe-value.ts`, for the same tier-0 reason `SCHEMA_ERROR_CODE_TITLES` is
|
|
65
84
|
one: schema and core are both tier 0 and `core → schema` is **not** a declared edge in
|
|
66
|
-
`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
|
|
67
88
|
`@ultimat3/cli` (which may legally import both) is the mechanical half, the same shape as
|
|
68
|
-
`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
|
|
69
93
|
HTTP problem document, redaction is by log FIELD key, and a value baked into a message string has
|
|
70
94
|
no key left to redact — so `parseId`/`uuidTimestamp` describe a rejected id and never echo it.
|
|
71
95
|
|
|
@@ -85,6 +109,7 @@ shape against a locally declared sample interface for exactly that reason.
|
|
|
85
109
|
|---|---|---|
|
|
86
110
|
| which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never declare a second env var for it |
|
|
87
111
|
| what this process does | `roles.ts` (`ROLE`) | |
|
|
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 |
|
|
88
113
|
| 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 |
|
|
89
114
|
| the values | `env.ts` | `checkEnv().values` holds REAL secrets — anything that prints goes through `maskedEnvValues()` |
|
|
90
115
|
| `.env.example` | `env-example.ts` | a projection of the schema, never hand-maintained |
|
|
@@ -155,6 +180,17 @@ NOT — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must n
|
|
|
155
180
|
orders a `text` column of digits lexically and a comparator guessing would trade one disagreement
|
|
156
181
|
with the SQL it printed for another.
|
|
157
182
|
|
|
183
|
+
`format-bytes.ts` is the same rule at its smallest, `As of 2026-08-22`: one `formatBytes(bytes)`,
|
|
184
|
+
1024-base, `b|kb|mb|gb`, for the byte count an error message carries. `@ultimat3/render` (t4) and
|
|
185
|
+
`@ultimat3/pwa` (t4) each had one and they had diverged — render's stopped at `kb`, so a 5 MiB route
|
|
186
|
+
read `5120kb` in `X_BUDGET_EXCEEDED` and `5mb` in the precache warning about the same bytes, and
|
|
187
|
+
`@ultimat3/cli`'s budget error imported render's. Deliberately NOT
|
|
188
|
+
`@ultimat3/ui`'s `formatBytes(bytes, locale)`, which is a different function and stays: that one is
|
|
189
|
+
`Intl`-formatted and DECIMAL (kB = 1000 B, which is what `Intl`'s unit means), for a human reading a
|
|
190
|
+
file picker, where this one must line up with a bundler's own KiB figures and must not move with the
|
|
191
|
+
reader's locale. **Not mechanised** — no gate refuses a third copy, unlike `render-modes.ts` for the
|
|
192
|
+
route vocabulary; a `formatBytes` reappearing in `packages/*/src` is caught by review only.
|
|
193
|
+
|
|
158
194
|
`mcp-exposure.ts` is the same shape for a declaration rather than an algorithm: `isMcpExposed` is
|
|
159
195
|
the ONE answer to "did this primitive opt into being an MCP tool?", asked by `action`, `query`
|
|
160
196
|
(t3), `mcp`, `ai`, `manifest` (t4) — five packages that cannot import each other, so core is the
|
|
@@ -187,6 +223,16 @@ which is what stops an exporter from turning 40k rps into 40k rps of spans. `con
|
|
|
187
223
|
takes a `Sampler`; the default reads `OTEL_TRACES_SAMPLER*` **at the first span, never at module
|
|
188
224
|
scope** (same call-time rule as `cursor.ts`'s secret). `resetTelemetry()` drops both.
|
|
189
225
|
|
|
226
|
+
**An empty `spanId` means "no inbound decision", and every reader must honour it, `As of
|
|
227
|
+
2026-08-22`.** `currentSpanContext()` synthesises `{ traceId, spanId: '', traceFlags: 1 }` from the
|
|
228
|
+
request context — a trace id this process minted, plus the header it would send onward. Handing
|
|
229
|
+
that to `Sampler.shouldSample` as a parent made `parentBasedRatioSampler` inherit a bit nobody sent,
|
|
230
|
+
so at ratio 0 a root span outside a request exported 0 and one inside exported 1 — and
|
|
231
|
+
`@ultimat3/http`'s `pipeline.ts` is `runWithContext` then `withSpan`, so **every HTTP root span was
|
|
232
|
+
exported at every ratio**. `startSpan` now narrows through `inboundParent()`: the trace id is
|
|
233
|
+
carried, the decision is not. It is the same discriminator `end()` already used to drop a synthetic
|
|
234
|
+
`parentSpanId`.
|
|
235
|
+
|
|
190
236
|
The OTLP exporters are built, not wrapped, and the case is in
|
|
191
237
|
[`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md): OTLP/HTTP JSON is `fetch`
|
|
192
238
|
plus `JSON.stringify`, while `@opentelemetry/api` would put a SECOND `Span` type in the framework
|
|
@@ -196,6 +242,15 @@ plus `JSON.stringify`, while `@opentelemetry/api` would put a SECOND `Span` type
|
|
|
196
242
|
non-`http/json` `OTEL_EXPORTER_OTLP_PROTOCOL` throw `X_OTLP_PROTOCOL_UNSUPPORTED` naming `:4318`.
|
|
197
243
|
A boot that must not throw asks `tryOtlpEndpoint(signal)` first.
|
|
198
244
|
|
|
245
|
+
**Three OTLP variables, three codes, `As of 2026-08-22`** — `X_OTLP_ENDPOINT_INVALID`,
|
|
246
|
+
`X_OTLP_HEADERS_INVALID`, `X_OTLP_PROTOCOL_UNSUPPORTED`, one per variable an operator sets. The
|
|
247
|
+
headers one is not a duplicate of the endpoint one: `otlpHeaders` percent-decodes, so `%zz` in
|
|
248
|
+
`OTEL_EXPORTER_OTLP_HEADERS` used to take the process down with a bare `URIError` at exporter
|
|
249
|
+
construction, and raising the ENDPOINT code instead would send the first reader of
|
|
250
|
+
`x errors explain` to inspect a variable that is fine. A title is what an agent reads first, and an
|
|
251
|
+
accurate `cause:` does not rescue one that misdirects. The header **key** is in the cause, the fix
|
|
252
|
+
and `meta`; the **value** is in none of them — it is the collector's credential.
|
|
253
|
+
|
|
199
254
|
`error-reporter.ts` is the same shape a third time: `ErrorReporter`, a no-op default, a memory
|
|
200
255
|
reporter for tests, and a transport on the wire (`error-reporter-sentry.ts`, an optional separate
|
|
201
256
|
export — the DSN is the app's typed env, never a constant here). `reportError` never throws and
|
|
@@ -227,13 +282,32 @@ bun run typecheck
|
|
|
227
282
|
`markReady()` means **bound**, and readiness means **usable** — two different facts since
|
|
228
283
|
`registerReadinessCheck(name, check)`. `/readyz` is ready only when the state is `ready` AND every
|
|
229
284
|
named check passes, and `HealthReport.checks` carries them by name because "alert on check
|
|
230
|
-
failures by check name" is not writable against a boolean.
|
|
285
|
+
failures by check name" is not writable against a boolean. `HealthReport.registered` carries the
|
|
286
|
+
COUNT beside it, `As of 2026-08-22`: `checks: {}` reads identically for "every check passed" and
|
|
287
|
+
"nobody registered one", and only the second is a `/readyz` meaning no more than "the socket is
|
|
288
|
+
bound". Reported, never enforced — **an empty registry is still `ready`, and `/readyz` still
|
|
289
|
+
answers 200**: `Object.values({}).every(…)` is vacuously true, and that is deliberate so a role
|
|
290
|
+
with no dependency does not have to invent a check to boot. `registered` is the field a caller
|
|
291
|
+
reads to tell "all checks passed" from "there were none".
|
|
292
|
+
|
|
293
|
+
`readinessChecks()` builds its record through `Object.fromEntries`, never by assigning
|
|
294
|
+
`results[name]` — assignment to the one name `__proto__` sets the PROTOTYPE rather than adding a
|
|
295
|
+
key, so a check by that name disappeared from the report and a `failing` one answered 200. Checks are **synchronous** on purpose:
|
|
231
296
|
a probe that awaits a network call turns a slow dependency into a wedged endpoint and a restart
|
|
232
297
|
loop, so the owner of the dependency keeps a boolean fresh and this reads it. Liveness ignores
|
|
233
298
|
them — a database outage that failed `/healthz` would restart the whole fleet into the same
|
|
234
299
|
outage. The registration returns its unregister, same shape and same ownership rule as
|
|
235
300
|
`onShutdown`; `readinessCheckCount()` is the leak probe.
|
|
236
301
|
|
|
302
|
+
**`drain()`'s memo is published BEFORE the first hook runs, `As of 2026-08-22`, and that ordering
|
|
303
|
+
is the whole of the function.** A hook may call back into `drain()` and one does — `handle.stop()`
|
|
304
|
+
in `@ultimat3/http` is `drain('manual')`, and an `accept` hook is exactly where a server stops
|
|
305
|
+
listening — while `settleWithin` invokes a hook SYNCHRONOUSLY. `drainPromise = (async () => …)()`
|
|
306
|
+
had therefore not assigned when the first hook ran: the re-entrant call read `undefined`, started a
|
|
307
|
+
second whole drain and recursed **~4,700 deep** until the stack ran out, every level swallowed by
|
|
308
|
+
`settleWithin` as `shutdown hook failed`. The guard and the registration are now one synchronous
|
|
309
|
+
step (`jobs`' `worker.ts` states the same rule), with the phases in `runDrain`.
|
|
310
|
+
|
|
237
311
|
**The drain deadline is enforced, not merely computed, and there is no unbounded state.**
|
|
238
312
|
`ShutdownReason.deadlineAt` was always handed to every hook and **no hook has ever read it** —
|
|
239
313
|
`jobs`' worker awaits every in-flight job and `driver.close()`, `jobs`' scheduler awaits its round,
|
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
9
9
|
| rendering an app's value into a `cause` / `fix` without throwing | `error-render.ts` |
|
|
10
10
|
| code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` |
|
|
11
11
|
| `Result<T, E>` for boundaries where throwing is wrong | `result.ts` |
|
|
12
|
-
|
|
|
12
|
+
| the one lazy `AsyncLocalStorage`, every ambient scope in the framework | `async-context.ts` |
|
|
13
|
+
| request context on that seam | `context.ts` |
|
|
13
14
|
| `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
|
|
14
15
|
| acting as another actor, with an origin and a reason | `impersonate.ts` |
|
|
15
16
|
| is an error worth retrying? one classification per code | `error-retry.ts` |
|
|
@@ -20,6 +21,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
20
21
|
| the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
|
|
21
22
|
| the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
|
|
22
23
|
| `defineConfig()` for `app.config.ts` | `config.ts` |
|
|
24
|
+
| the closed route vocabulary every renderer names | `route-vocabulary.ts` |
|
|
23
25
|
| runtime roles + `ROLE` resolution | `roles.ts` |
|
|
24
26
|
| `Clock` — the only source of "now" | `clock.ts` |
|
|
25
27
|
| UUIDv7, nanoid, branded ids | `ids.ts` |
|
|
@@ -320,6 +322,15 @@ nothing and still reports healthy.
|
|
|
320
322
|
once has to keep it: a discarded one is a hook per `start()`, each retaining the resource it
|
|
321
323
|
was going to drain, and the next drain runs every one of them against a torn-down copy.
|
|
322
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`.
|
|
323
334
|
- Anything that opens a socket calls `markListening(server.url.origin)` and releases it on close.
|
|
324
335
|
That is what tells the sealed test network a loopback request is this process, not egress.
|
|
325
336
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.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",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./src/context.ts",
|
|
9
|
+
"./src/lifecycle-errors.ts",
|
|
10
|
+
"./src/schema-error-codes.ts",
|
|
11
|
+
"./src/secrets-errors.ts"
|
|
12
|
+
],
|
|
7
13
|
"repository": {
|
|
8
14
|
"type": "git",
|
|
9
15
|
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
package/src/async-context.ts
CHANGED
|
@@ -16,16 +16,18 @@ export interface AsyncContext<T> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* The storage is constructed on first `run()`, never at module scope. That is the
|
|
20
|
-
* this file: a browser bundler stubs `node:async_hooks` to `{}` — Bun's
|
|
21
|
-
* `var { AsyncLocalStorage } = (() => ({}))` — so a module-scope `new` threw
|
|
19
|
+
* The storage is constructed on the first `get()` or `run()`, never at module scope. That is the
|
|
20
|
+
* whole point of this file: a browser bundler stubs `node:async_hooks` to `{}` — Bun's
|
|
21
|
+
* `target: 'browser'` emits `var { AsyncLocalStorage } = (() => ({}))` — so a module-scope `new` threw
|
|
22
22
|
* `TypeError: undefined is not a constructor` at module EVALUATION, and every package that
|
|
23
23
|
* transitively imports core was dead on arrival in a client bundle. `@ultimat3/ui` calls itself a
|
|
24
24
|
* SolidJS design system and could not be put on a client by the only client bundler the framework
|
|
25
25
|
* has, for this reason and no other.
|
|
26
26
|
*
|
|
27
|
-
* The
|
|
28
|
-
*
|
|
27
|
+
* The laziness buys the browser bundle, not a server allocation. `open()` runs on a READ as well as
|
|
28
|
+
* a write, so a server whose first call is `get()` constructs the storage there — it is deferred,
|
|
29
|
+
* never skipped. What deferring changes is nothing observable: `getStore()` outside a scope answers
|
|
30
|
+
* `undefined` whether the storage was ever constructed or not, which is what makes it safe.
|
|
29
31
|
*
|
|
30
32
|
* **Reads degrade, writes throw**, and that split is the doctrine rather than a convenience.
|
|
31
33
|
* `get()` answers `undefined` in a browser because that is TRUE — nothing is in flight there, so
|
package/src/config.ts
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { ConfigInvalidError } from './errors';
|
|
6
6
|
import { ROLES, type Role } from './roles';
|
|
7
|
+
// `app.config.ts` CONSUMES the route vocabulary; it does not own it. Declaring `OfflineStrategy`
|
|
8
|
+
// here is what made it copyable — `render`, `manifest` and `pwa` each wrote their own rather than
|
|
9
|
+
// import a name that reads like a config key.
|
|
10
|
+
import type { OfflineStrategy } from './route-vocabulary';
|
|
7
11
|
import { isIanaZoneName } from './time-zone-name';
|
|
8
12
|
|
|
9
13
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
10
|
-
export type OfflineStrategy = 'precache' | 'runtime' | 'network-only';
|
|
11
14
|
export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn';
|
|
12
15
|
export type RealtimeTier = 'channels' | 'live-queries' | 'local-first';
|
|
13
16
|
export type RealtimeTransport = 'memory' | 'nats' | 'redis';
|
|
@@ -19,38 +22,30 @@ export interface ThemeConfig {
|
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
|
-
* Where a browser that failed `auth: 'required'` is sent
|
|
25
|
+
* Where a browser that failed `auth: 'required'` is sent.
|
|
23
26
|
*
|
|
24
27
|
* `signInPath: null` is the default and the redirect stays off until an app names its page: the
|
|
25
28
|
* framework may not invent one of its app's routes, and an app that spells it `/login` would send
|
|
26
29
|
* every unauthenticated visitor to a 404. Null means the visitor gets the problem document — the
|
|
27
30
|
* right answer for an agent, and what a browser got in production until this existed.
|
|
31
|
+
*
|
|
32
|
+
* `afterSignInPath` was removed 2026-08 for the reason `urlEnv`, `poolSize` and `schema` were
|
|
33
|
+
* (below): accepted, defaulted and merged here, and read by NO file — `dummy/social-media-clone`
|
|
34
|
+
* set `/dashboard` and got whatever its sign-in route did on its own. The landing path belongs to
|
|
35
|
+
* the app's sign-in route, which is the only code that can honour it.
|
|
28
36
|
*/
|
|
29
37
|
export interface AuthConfig {
|
|
30
38
|
readonly signInPath: string | null;
|
|
31
|
-
/**
|
|
32
|
-
* Where sign-in lands when there is nowhere to return to, or `?next=` is not same-origin.
|
|
33
|
-
*
|
|
34
|
-
* **Consulted by nothing, `As of 2026-08.`** Accepted, defaulted and merged here and read by no
|
|
35
|
-
* file in the repo — `dummy/social-media-clone/app.config.ts` sets `/dashboard` and gets
|
|
36
|
-
* whatever the sign-in route does on its own. Same shape `urlEnv`, `poolSize` and `schema` were
|
|
37
|
-
* deleted for below; this one is not deleted yet only because its writer is a tracked app's
|
|
38
|
-
* config, so removing the key and the line that sets it is one commit across two file sets.
|
|
39
|
-
*/
|
|
40
|
-
readonly afterSignInPath: string;
|
|
41
39
|
}
|
|
42
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
|
|
43
|
+
* real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
|
|
44
|
+
* scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
|
|
45
|
+
*/
|
|
43
46
|
export interface PwaConfig {
|
|
44
47
|
readonly enabled: boolean;
|
|
45
48
|
readonly offline: OfflineStrategy;
|
|
46
|
-
/**
|
|
47
|
-
* **Consulted by nothing, `As of 2026-08.`** `wiki/Configuration.md` describes it as "render
|
|
48
|
-
* your own install affordance from the deferred event", both tracked apps set it, and
|
|
49
|
-
* `x new`'s scaffold writes it into every generated app — and no file reads it.
|
|
50
|
-
* `@ultimat3/pwa`'s `install.ts` is real and complete; nothing threads this flag into it.
|
|
51
|
-
* Delete the key or thread it; leaving it is a switch with no wire.
|
|
52
|
-
*/
|
|
53
|
-
readonly installPrompt: boolean;
|
|
54
49
|
readonly backgroundSync: boolean;
|
|
55
50
|
readonly push: boolean;
|
|
56
51
|
}
|
|
@@ -120,18 +115,16 @@ export interface McpConfig {
|
|
|
120
115
|
readonly path: string;
|
|
121
116
|
}
|
|
122
117
|
|
|
118
|
+
/**
|
|
119
|
+
* No `modelEnv`. It named the env KEY holding the model id, "so no model string is baked into the
|
|
120
|
+
* image" — and its only reader was this file's own merge, copying input to output. Nothing
|
|
121
|
+
* consumed the merged value, so `modelEnv: 'ANTHROPIC_MODEL'` selected no model: `@ultimat3/ai`
|
|
122
|
+
* reads env for API KEYS only, and the model is `request.model ?? DEFAULT_MODEL`, a compile-time
|
|
123
|
+
* constant in `models.ts`. The exact thing the key existed to prevent is what it delivered.
|
|
124
|
+
* Deleted 2026-08 — pass `model` on the request, or read your own env key and pass it.
|
|
125
|
+
*/
|
|
123
126
|
export interface AiConfig {
|
|
124
127
|
readonly mcp: McpConfig;
|
|
125
|
-
/**
|
|
126
|
-
* Env key for the model id, so no model string is baked into the image — **an intention, not a
|
|
127
|
-
* behaviour, `As of 2026-08`.** The only read of it in the repo is the merge two hundred lines
|
|
128
|
-
* below, which copies it from input to output; nothing consumes the merged value, so
|
|
129
|
-
* `examples/dummy`'s `modelEnv: 'ANTHROPIC_MODEL'` selects no model. `@ultimat3/ai` reads env
|
|
130
|
-
* for API KEYS only (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`); the model is
|
|
131
|
-
* `request.model ?? DEFAULT_MODEL`, a compile-time constant in `models.ts`. So the exact thing
|
|
132
|
-
* this key exists to prevent — a model string baked into the image — is what actually happens.
|
|
133
|
-
*/
|
|
134
|
-
readonly modelEnv: string | undefined;
|
|
135
128
|
}
|
|
136
129
|
|
|
137
130
|
export interface AppConfig {
|
|
@@ -153,7 +146,8 @@ export interface AppConfig {
|
|
|
153
146
|
|
|
154
147
|
type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
|
|
155
148
|
|
|
156
|
-
|
|
149
|
+
/** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
|
|
150
|
+
export interface AiConfigInput {
|
|
157
151
|
readonly mcp?: Input<McpConfig> | undefined;
|
|
158
152
|
}
|
|
159
153
|
|
|
@@ -221,14 +215,8 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
221
215
|
defaultTimeZone: 'UTC',
|
|
222
216
|
defaultCurrency: 'USD',
|
|
223
217
|
theme: { defaultMode: 'system', tokens: {} },
|
|
224
|
-
auth: { signInPath: null
|
|
225
|
-
pwa: {
|
|
226
|
-
enabled: false,
|
|
227
|
-
offline: 'network-only',
|
|
228
|
-
installPrompt: false,
|
|
229
|
-
backgroundSync: false,
|
|
230
|
-
push: false,
|
|
231
|
-
},
|
|
218
|
+
auth: { signInPath: null },
|
|
219
|
+
pwa: { enabled: false, offline: 'network-only', backgroundSync: false, push: false },
|
|
232
220
|
roles: [...ROLES],
|
|
233
221
|
database: { driver: 'postgres', ssl: false },
|
|
234
222
|
cache: { driver: 'memory', urlEnv: undefined, defaultTtlMs: 60_000, tiers: ['memo', 'lru'] },
|
|
@@ -240,7 +228,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
240
228
|
visibilityTimeoutMs: 30_000,
|
|
241
229
|
},
|
|
242
230
|
realtime: { enabled: false, tier: 'channels', transport: 'memory', urlEnv: undefined },
|
|
243
|
-
ai: { mcp: { expose: true, path: '/mcp' }
|
|
231
|
+
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
244
232
|
};
|
|
245
233
|
}
|
|
246
234
|
|
|
@@ -334,10 +322,7 @@ export function defineConfig(
|
|
|
334
322
|
cache: section(base.cache, merged.cache),
|
|
335
323
|
jobs: section(base.jobs, merged.jobs),
|
|
336
324
|
realtime: section(base.realtime, merged.realtime),
|
|
337
|
-
ai: {
|
|
338
|
-
mcp: section(base.ai.mcp, merged.ai?.mcp),
|
|
339
|
-
modelEnv: merged.ai?.modelEnv ?? base.ai.modelEnv,
|
|
340
|
-
},
|
|
325
|
+
ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
|
|
341
326
|
};
|
|
342
327
|
|
|
343
328
|
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
|
+
}
|
|
@@ -64,7 +64,7 @@ export function parseSentryDsn(dsn: string): SentryDsn {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** The protocol's own level names. `warning`/`error`/`fatal` happen to be the same three words. */
|
|
67
|
-
const LEVELS
|
|
67
|
+
const LEVELS = Object.freeze<Record<ErrorSeverity, string>>({
|
|
68
68
|
warning: 'warning',
|
|
69
69
|
error: 'error',
|
|
70
70
|
fatal: 'fatal',
|
|
@@ -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
|
@@ -31,6 +31,7 @@ export {
|
|
|
31
31
|
} from './actor';
|
|
32
32
|
export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
|
|
33
33
|
export { assert, assertNever, type InvariantOptions, invariant } from './assert';
|
|
34
|
+
export { type AsyncContext, asyncContext } from './async-context';
|
|
34
35
|
export { canonicalJson, fingerprint } from './canonical-json';
|
|
35
36
|
export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
|
|
36
37
|
export type {
|
|
@@ -45,7 +46,6 @@ export type {
|
|
|
45
46
|
DatabaseConfig,
|
|
46
47
|
JobsConfig,
|
|
47
48
|
McpConfig,
|
|
48
|
-
OfflineStrategy,
|
|
49
49
|
PwaConfig,
|
|
50
50
|
RealtimeConfig,
|
|
51
51
|
RealtimeTier,
|
|
@@ -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,
|
|
@@ -479,6 +484,8 @@ export type { Err, Ok, Result } from './result';
|
|
|
479
484
|
export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result';
|
|
480
485
|
export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
|
|
481
486
|
export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
|
|
487
|
+
export type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
|
|
488
|
+
export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from './route-vocabulary';
|
|
482
489
|
export { safeUrl, URL_ATTRIBUTES } from './safe-url';
|
|
483
490
|
export { defineService, resetServices, type ServiceFactory } from './service';
|
|
484
491
|
export { timingSafeEqual } from './timing-safe-equal';
|
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
|
@@ -17,7 +17,7 @@ export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', '
|
|
|
17
17
|
|
|
18
18
|
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
19
19
|
|
|
20
|
-
const LEVEL_WEIGHT
|
|
20
|
+
const LEVEL_WEIGHT = Object.freeze<Record<LogLevel, number>>({
|
|
21
21
|
trace: 10,
|
|
22
22
|
debug: 20,
|
|
23
23
|
info: 30,
|
|
@@ -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
|
+
};
|
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
} from './telemetry';
|
|
24
24
|
|
|
25
25
|
/** OTLP's `SpanKind` enum; `UNSPECIFIED` is 0 and Ultimate never emits it. */
|
|
26
|
-
const SPAN_KIND
|
|
26
|
+
const SPAN_KIND = Object.freeze<Record<SpanKind, number>>({
|
|
27
27
|
internal: 1,
|
|
28
28
|
server: 2,
|
|
29
29
|
client: 3,
|
|
@@ -31,7 +31,7 @@ const SPAN_KIND: Readonly<Record<SpanKind, number>> = Object.freeze({
|
|
|
31
31
|
consumer: 5,
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
const STATUS_CODE
|
|
34
|
+
const STATUS_CODE = Object.freeze<Record<SpanStatusCode, number>>({
|
|
35
35
|
unset: 0,
|
|
36
36
|
ok: 1,
|
|
37
37
|
error: 2,
|
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,43 @@ 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: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
|
|
63
|
+
] satisfies readonly PrimitiveFactory[]
|
|
64
|
+
).map((entry) => Object.freeze(entry)),
|
|
65
|
+
);
|
|
66
|
+
|
|
30
67
|
/**
|
|
31
68
|
* What a registrar hands back: the primitives it actually took, each carrying the name
|
|
32
69
|
* registration stamped on it. Returning the registered set — rather than nothing — is what lets
|
package/src/roles.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface RoleInfo {
|
|
|
26
26
|
readonly stateful: boolean;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export const ROLE_INFO
|
|
29
|
+
export const ROLE_INFO = Object.freeze<Record<Role, RoleInfo>>({
|
|
30
30
|
web: { role: 'web', scalesOn: 'rps', maxReplicas: null, stateful: false },
|
|
31
31
|
sync: { role: 'sync', scalesOn: 'ws-connections', maxReplicas: null, stateful: false },
|
|
32
32
|
worker: { role: 'worker', scalesOn: 'queue-depth', maxReplicas: null, stateful: false },
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Single responsibility: the three closed vocabularies a route is declared in — how it renders,
|
|
2
|
+
// how it survives offline, when it hydrates. Tier 0 so every package that names one imports it.
|
|
3
|
+
// Deliberately not `config.ts`: `app.config.ts` CONSUMES `OfflineStrategy`, it does not own it.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Each union is DERIVED from its array rather than written twice, so the pair cannot disagree:
|
|
7
|
+
* the array is the one place a member is added or removed and the type follows.
|
|
8
|
+
*
|
|
9
|
+
* This module exists because the alternative was measured. Twelve declarations of these three sets
|
|
10
|
+
* lived across six packages — `render` alone spelled `RenderMode` and `RENDER_MODES` separately —
|
|
11
|
+
* and `'spa'` was deleted from one of them while five others went on admitting it under a green
|
|
12
|
+
* project-wide typecheck. `@ultimat3/pwa`'s copy mapped `spa` to `cache-first`, the one strategy
|
|
13
|
+
* that gives an `app/` route a SHARED cache entry: one member's authed HTML served to the next.
|
|
14
|
+
* A copy is not a style question. `scripts/render-modes.test.ts` refuses a second declaration.
|
|
15
|
+
*/
|
|
16
|
+
export const RENDER_MODES = ['static', 'isr', 'ssr', 'stream'] as const;
|
|
17
|
+
export type RenderMode = (typeof RENDER_MODES)[number];
|
|
18
|
+
|
|
19
|
+
export const OFFLINE_STRATEGIES = ['precache', 'runtime', 'network-only'] as const;
|
|
20
|
+
export type OfflineStrategy = (typeof OFFLINE_STRATEGIES)[number];
|
|
21
|
+
|
|
22
|
+
export const HYDRATE_STRATEGIES = ['idle', 'visible', 'interaction', 'never'] as const;
|
|
23
|
+
export type HydrateStrategy = (typeof HYDRATE_STRATEGIES)[number];
|
package/src/runtime-metrics.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ScalingSignal } from './roles';
|
|
|
14
14
|
* own comment ("via the ingress metric adapter") already assumes. The other two are instantaneous
|
|
15
15
|
* values a scrape can read directly, so their series names are the chart's words verbatim.
|
|
16
16
|
*/
|
|
17
|
-
export const SCALING_METRICS
|
|
17
|
+
export const SCALING_METRICS = Object.freeze<Record<ScalingSignal, string | null>>({
|
|
18
18
|
rps: 'http_requests_total',
|
|
19
19
|
'ws-connections': 'connections',
|
|
20
20
|
'queue-depth': 'queue_depth',
|
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,11 +1,13 @@
|
|
|
1
|
-
// Compile-time pins for the actor-facts seam
|
|
2
|
-
//
|
|
3
|
-
// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b`
|
|
4
|
-
// type-level assertion written there can never fail. This module
|
|
5
|
-
// nothing anybody imports — a regression is a build error
|
|
1
|
+
// Compile-time pins for the actor-facts seam, the config surface, the request-context patch and
|
|
2
|
+
// the route vocabulary.
|
|
3
|
+
// Source, not a `.test.ts`, on purpose: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b`
|
|
4
|
+
// never reads a test file and a type-level assertion written there can never fail. This module
|
|
5
|
+
// emits nothing and exports nothing anybody imports — a regression is a build error.
|
|
6
6
|
|
|
7
7
|
import type { Actor, ActorFactMap, FactKeysOf, FactMapOf } from './actor';
|
|
8
8
|
import type { AppConfigInput, DatabaseConfig } from './config';
|
|
9
|
+
import type { CtxPatch } from './context';
|
|
10
|
+
import type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
|
|
9
11
|
|
|
10
12
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
11
13
|
type Assert<T extends true> = T;
|
|
@@ -98,3 +100,45 @@ type _DatabaseInputCarriesNoDeadField = Assert<
|
|
|
98
100
|
? true
|
|
99
101
|
: false
|
|
100
102
|
>;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Neither id a child context may patch. `withChildContext` forwards the parent's `buildId`
|
|
106
|
+
* verbatim, so `{ buildId }` on the patch was an option that read as honoured and was dropped
|
|
107
|
+
* without a word — the same silent-no-op class as the three `database` fields above, one tier
|
|
108
|
+
* lower. `requestId` is here beside it because the two are refused for the same reason.
|
|
109
|
+
*/
|
|
110
|
+
type UnpatchableCtxKey = 'requestId' | 'buildId';
|
|
111
|
+
|
|
112
|
+
type _CtxPatchRefusesTheIds = Assert<
|
|
113
|
+
Extract<keyof CtxPatch, UnpatchableCtxKey> extends never ? true : false
|
|
114
|
+
>;
|
|
115
|
+
|
|
116
|
+
/** And the keys a child MAY change are still there — a pin that empties the type is not a pin. */
|
|
117
|
+
type _CtxPatchStillPatchesTheRest = Assert<
|
|
118
|
+
'actor' | 'locale' | 'tz' extends keyof CtxPatch ? true : false
|
|
119
|
+
>;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Mutual assignability, not one-way. The tuples are load-bearing: a bare `A extends B` distributes
|
|
123
|
+
* over a union and answers `true` for every member separately, so it cannot see a widening — which
|
|
124
|
+
* is the only thing these three pins are looking for.
|
|
125
|
+
*/
|
|
126
|
+
type Exact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Each route vocabulary's union must stay DERIVED from its array. `(typeof ARRAY)[number]` is what
|
|
130
|
+
* makes the pair unable to disagree, and it is one careless edit from being a hand-written union
|
|
131
|
+
* again — which is the shape six packages shipped until `route-vocabulary.ts` existed. Restating
|
|
132
|
+
* the members here is a pin, not a copy: nothing imports these, and a member added to the array
|
|
133
|
+
* without a word in the changelog is a build error rather than a silent widening five packages
|
|
134
|
+
* inherit through a re-export.
|
|
135
|
+
*/
|
|
136
|
+
type _RenderModeIsItsArray = Assert<Exact<RenderMode, 'static' | 'isr' | 'ssr' | 'stream'>>;
|
|
137
|
+
|
|
138
|
+
type _OfflineStrategyIsItsArray = Assert<
|
|
139
|
+
Exact<OfflineStrategy, 'precache' | 'runtime' | 'network-only'>
|
|
140
|
+
>;
|
|
141
|
+
|
|
142
|
+
type _HydrateStrategyIsItsArray = Assert<
|
|
143
|
+
Exact<HydrateStrategy, 'idle' | 'visible' | 'interaction' | 'never'>
|
|
144
|
+
>;
|