@ultimat3/core 3.0.0 → 4.1.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 +44 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/actor.ts +27 -1
- package/src/canonical-json.ts +102 -0
- package/src/config.ts +33 -10
- package/src/decimal-order.ts +76 -0
- package/src/env-example.ts +9 -1
- package/src/error-render.ts +47 -0
- package/src/error-retry.ts +25 -11
- package/src/errors.ts +15 -3
- package/src/exports/error-contract.ts +1 -0
- package/src/image/errors.ts +1 -1
- package/src/index.ts +9 -12
- package/src/logger.ts +21 -2
- package/src/metrics-types.ts +102 -0
- package/src/metrics.ts +73 -100
- package/src/otlp-metric-exporter.ts +26 -3
- package/src/otlp-span-exporter.ts +28 -3
- package/src/type-pins.ts +10 -3
package/CLAUDE.md
CHANGED
|
@@ -8,6 +8,7 @@ is a change to every package.
|
|
|
8
8
|
| Deps | none (`bun-types` only) |
|
|
9
9
|
| Errors | subclass `UltimateError`; never `throw new Error` |
|
|
10
10
|
| Values in a message | `renderCauseValue()` / `renderFixLiteral()`; never raw `JSON.stringify`, `String()` or `${…}` on an `unknown` |
|
|
11
|
+
| Rendering the 3-line format | `singleLine()` on every interpolated field. A `string` renders fine and can still carry a newline, which writes a line an operator reads as a genuine message |
|
|
11
12
|
| A value a CALLER supplied | `describeValue()` — shape, never content. `renderCauseValue` is safe against throwing, not against leaking |
|
|
12
13
|
| Reading a caught value | `renderThrowable()` / `isThrownError()` / `stringField()`; never `error.message`, `error instanceof Error` or `typeof error.code === 'string'` directly — the probe throws before the renderer runs |
|
|
13
14
|
| New code | add to `CORE_CODE_TITLES` in `error-codes.ts`, else the title is auto-humanised |
|
|
@@ -32,6 +33,23 @@ parameters typed `unknown` that reach a `cause:` / `fix:`, and it cannot see a v
|
|
|
32
33
|
through a local helper first (`packages/ui/src/components/ErrorState.tsx` builds a `message`
|
|
33
34
|
const, then assigns it).
|
|
34
35
|
|
|
36
|
+
`singleLine` is the escape that keeps the 3-line contract to three lines, and it exists because
|
|
37
|
+
`scripts/error-render.ts` **cannot see this class**. That gate refuses a parameter typed
|
|
38
|
+
`unknown`/`any` reaching a `cause:`; a value that is already a `string` renders without throwing, so
|
|
39
|
+
there is nothing for it to object to — while a newline in one adds a line to a format that is
|
|
40
|
+
line-oriented in the terminal, in CI logs and inside the dev overlay's `<pre>`. Three holes shipped
|
|
41
|
+
in `@ultimat3/auth` under a green check, the worst reachable by an unauthenticated stranger with one
|
|
42
|
+
crafted OIDC token (issue #97). It is applied at the SIX renderers of that format and never at the
|
|
43
|
+
call sites — `UltimateError.format()` here, `SchemaError.format()` in `@ultimat3/schema`,
|
|
44
|
+
`renderErrorLines` in `@ultimat3/http`, `renderFrameworkError` in `@ultimat3/mcp`, and
|
|
45
|
+
`renderFinding` / `detailLines` in `@ultimat3/cli` — because a rule every author must remember is a
|
|
46
|
+
rule the next author forgets. It is not a general sanitiser: a cause is prose and keeps its quotes,
|
|
47
|
+
its backslashes and its percent signs — only the control range is touched. Line breaks are the
|
|
48
|
+
structural half; the rest of C0 and DEL ride along because a terminal reads a raw `\u001b` as an ANSI
|
|
49
|
+
escape, so a cause could repaint the screen or hide the line above it. `@ultimat3/schema` carries a
|
|
50
|
+
deliberate duplicate for the tier-0 reason below, pinned behaviourally by
|
|
51
|
+
`single-line-pin.test.ts` in `@ultimat3/cli`.
|
|
52
|
+
|
|
35
53
|
`describeValue` in `error-render.ts` is a character-for-character duplicate of `describeValue` in
|
|
36
54
|
`packages/schema/src/describe-value.ts`, for the same tier-0 reason `SCHEMA_ERROR_CODE_TITLES` is
|
|
37
55
|
one: schema and core are both tier 0 and `core → schema` is **not** a declared edge in
|
|
@@ -101,6 +119,32 @@ the pin (`schema-error-codes-pin.test.ts`) lives in `@ultimat3/cli`, which may l
|
|
|
101
119
|
`@ultimat3/storage` both need — core is the lowest tier both can reach, so the shared code lives
|
|
102
120
|
here rather than in either package copying the other's file.
|
|
103
121
|
|
|
122
|
+
`canonical-json.ts` is the same shape for the hash every SHARING key in the framework is taken
|
|
123
|
+
over, `As of 2026-08`. `canonicalJson` is an INJECTIVE canonical form and `fingerprint` is
|
|
124
|
+
SHA-256/16 of it, and three tier-3 packages needed exactly this while none may import another:
|
|
125
|
+
`@ultimat3/action`'s `requestHash` and job dedupe key, `@ultimat3/query`'s `queryHash` (a
|
|
126
|
+
read-cache entry, a cursor scope, a live query id) and `@ultimat3/realtime`'s `qid`. Each kept its
|
|
127
|
+
own copy and the copies had **diverged in a way that leaked**: query's had no `Date` branch, so
|
|
128
|
+
`Object.keys(date)` was `[]`, every date rendered `{}`, and one cache key, one cursor scope and one
|
|
129
|
+
live window answered for every date window of a read — reachable straight off a query string, since
|
|
130
|
+
`coerceQuery` turns a `t.date` member into a real `Date`. Injective is the whole requirement, not a
|
|
131
|
+
formatting preference: every one of those keys decides which of two callers is served the other's
|
|
132
|
+
answer. So `NaN`, `±Infinity` and `-0` are bare tokens the quoting `string` branch cannot spell,
|
|
133
|
+
and a `Date`, a `Map` and a `Set` — the three values with no own enumerable key — are TAGGED. Never
|
|
134
|
+
add a fourth copy, and never make it parseable: `@ultimat3/action`'s `stableStringify` is the
|
|
135
|
+
DOCUMENT form for that (it publishes `openapi.json`), and it is a different function on purpose.
|
|
136
|
+
|
|
137
|
+
`decimal-order.ts` is the third instance of the same rule, over a value rather than a shape.
|
|
138
|
+
`compareDecimalText` is the exact ordering of two decimals however long the digits run — the order
|
|
139
|
+
Postgres gives a `numeric` or an `int8` over the TEXT `@ultimat3/entity`'s `bigint()` and
|
|
140
|
+
`decimal()` hand back, where `String(left) < String(right)` answers `["10","100","2","9"]` for
|
|
141
|
+
`["2","9","10","100"]` and cuts a keyset page where the database does not. It answers **`undefined`**
|
|
142
|
+
when either side is not a plain decimal, and that is the contract, not a convenience: a caller that
|
|
143
|
+
knows the column's declared kind asks (`@ultimat3/entity`'s `compareByKind`), and a caller that does
|
|
144
|
+
NOT — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must never, because Postgres
|
|
145
|
+
orders a `text` column of digits lexically and a comparator guessing would trade one disagreement
|
|
146
|
+
with the SQL it printed for another.
|
|
147
|
+
|
|
104
148
|
`mcp-exposure.ts` is the same shape for a declaration rather than an algorithm: `isMcpExposed` is
|
|
105
149
|
the ONE answer to "did this primitive opt into being an MCP tool?", asked by `action`, `query`
|
|
106
150
|
(t3), `mcp`, `ai`, `manifest` (t4) — five packages that cannot import each other, so core is the
|
package/README.md
CHANGED
|
@@ -343,6 +343,7 @@ collectMetrics(); // the same numbers as data, for a MetricExporter
|
|
|
343
343
|
| Names | lowercase `snake_case`, the intersection every exposition format accepts. Dotted OTel names survive OTLP and die at a Prometheus scrape |
|
|
344
344
|
| Attributes | `string \| number \| boolean` only — each distinct set is a stored series, so a user id here is an outage |
|
|
345
345
|
| Cardinality | enforced, not advised: `maxSeries` per instrument (default `DEFAULT_MAX_SERIES`), and past it every new label set folds into one `otel_metric_overflow="true"` series with `X_METRIC_CARDINALITY` logged once, naming the instrument |
|
|
346
|
+
| Async gauges | an `observe()` that throws, or answers a non-finite number, costs that instrument its point and **nothing beside it** — `X_METRIC_VALUE_INVALID` logged once, naming the instrument. Unguarded it took the whole `/metrics` body down with it, and `startMetricExport`'s timer callback raised where nothing can catch it |
|
|
346
347
|
| Driver seam | `MetricExporter`, defaulting to a no-op. `memoryMetricExporter()` for tests, `startMetricExport(ms)` for a periodic push, `otlpMetricExporter()` for a collector |
|
|
347
348
|
|
|
348
349
|
`runtime-metrics.ts` holds the series every process emits, and `SCALING_METRICS` maps each
|
package/package.json
CHANGED
package/src/actor.ts
CHANGED
|
@@ -76,8 +76,27 @@ export interface Actor {
|
|
|
76
76
|
readonly orgId?: string | undefined;
|
|
77
77
|
/** Application roles (`admin`, `editor`). Unrelated to the runtime `Role`. */
|
|
78
78
|
readonly roles: readonly string[];
|
|
79
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* FRAMEWORK capabilities, checked with `hasScope()`. One reader in the whole framework —
|
|
81
|
+
* `@ultimat3/entity`'s `crossTenant()`, gating `tenancy:cross` — and that narrowness is the
|
|
82
|
+
* point: a scope is an escape hatch the framework itself honours, never the app's authz
|
|
83
|
+
* vocabulary. That is `roles` and `permissions`, which `@ultimat3/policy` reads.
|
|
84
|
+
*/
|
|
80
85
|
readonly scopes: readonly string[];
|
|
86
|
+
/**
|
|
87
|
+
* DIRECT grants, bypassing roles: what a service token or a break-glass account holds
|
|
88
|
+
* (`post:publish`). `@ultimat3/policy` flattens these together with every grant `roles` expands
|
|
89
|
+
* to, so the two are one set by the time a predicate runs, and neither is `scopes`.
|
|
90
|
+
*
|
|
91
|
+
* Declared here rather than on policy's own `Actor` (`As of 2026-08-19`). It was policy's, which
|
|
92
|
+
* made it unreachable from the one place actors are built: core is tier 0 and cannot import
|
|
93
|
+
* policy, so `build()` below had no field to carry and `userActor({ permissions })` compiled and
|
|
94
|
+
* silently discarded the argument. Every caller worked around it with
|
|
95
|
+
* `{ ...userActor({ id }), permissions: [...] }` — a spread over a frozen actor, producing an
|
|
96
|
+
* UNFROZEN one — so the fixtures proving authz had a shape no request ever mints. `@ultimat3/auth`
|
|
97
|
+
* carried a second, hand-synced copy of the declaration for the same tier reason.
|
|
98
|
+
*/
|
|
99
|
+
readonly permissions: readonly string[];
|
|
81
100
|
/** App-declared facts. Read it through `actorFact()`; never logged — `actorLabel` is id-only. */
|
|
82
101
|
readonly facts?: ActorFactMap | undefined;
|
|
83
102
|
/**
|
|
@@ -93,6 +112,8 @@ export interface ActorInit {
|
|
|
93
112
|
readonly orgId?: string | undefined;
|
|
94
113
|
readonly roles?: readonly string[] | undefined;
|
|
95
114
|
readonly scopes?: readonly string[] | undefined;
|
|
115
|
+
/** Direct grants. Absent means none — never "inherit some"; there is nothing to inherit from. */
|
|
116
|
+
readonly permissions?: readonly string[] | undefined;
|
|
96
117
|
readonly facts?: ActorFactMap | undefined;
|
|
97
118
|
/** For a session that already recorded an impersonation; `impersonate()` sets it otherwise. */
|
|
98
119
|
readonly onBehalfOf?: ActorOrigin | undefined;
|
|
@@ -105,6 +126,7 @@ const ANONYMOUS: Actor = Object.freeze({
|
|
|
105
126
|
id: 'anonymous',
|
|
106
127
|
roles: Object.freeze([]),
|
|
107
128
|
scopes: Object.freeze([]),
|
|
129
|
+
permissions: Object.freeze([]),
|
|
108
130
|
facts: NO_FACTS,
|
|
109
131
|
});
|
|
110
132
|
|
|
@@ -115,6 +137,10 @@ function build(kind: ActorKind, init: ActorInit): Actor {
|
|
|
115
137
|
orgId: init.orgId,
|
|
116
138
|
roles: Object.freeze([...(init.roles ?? [])]),
|
|
117
139
|
scopes: Object.freeze([...(init.scopes ?? [])]),
|
|
140
|
+
// Copied and frozen like the two above, and for the sharper reason: this list IS the actor's
|
|
141
|
+
// authz. Handing back the caller's array would let whoever still holds it `push` a grant into
|
|
142
|
+
// a decision already made about a frozen actor.
|
|
143
|
+
permissions: Object.freeze([...(init.permissions ?? [])]),
|
|
118
144
|
facts: Object.freeze({ ...init.facts }),
|
|
119
145
|
onBehalfOf: init.onBehalfOf === undefined ? undefined : Object.freeze({ ...init.onBehalfOf }),
|
|
120
146
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The INJECTIVE canonical form of a value, and the sharing key taken over it.
|
|
3
|
+
*
|
|
4
|
+
* Tier 0 because three tier-3 packages need exactly this and none of them may import another:
|
|
5
|
+
* `@ultimat3/action`'s `requestHash` and job dedupe key, `@ultimat3/query`'s `queryHash` (the
|
|
6
|
+
* read-cache entry, the cursor scope, the live query id) and `@ultimat3/realtime`'s `qid`. Each
|
|
7
|
+
* kept its own copy, and the copies had already diverged — query's rendered every `Date` as `{}`,
|
|
8
|
+
* so one key answered for every date window a read ever served.
|
|
9
|
+
*
|
|
10
|
+
* Injective is the whole requirement: every one of those keys decides which of two callers is
|
|
11
|
+
* served the other's answer, so two distinct inputs sharing one string is a leak and not a
|
|
12
|
+
* collision. Nothing here is ever parsed back — `@ultimat3/action`'s `stableStringify` is the
|
|
13
|
+
* DOCUMENT form for that, and it is a different function on purpose.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bare tokens, never quoted and never `'null'`. This output is only ever hashed, so an unquoted
|
|
18
|
+
* word cannot collide with the `string` branch (which always quotes), while `'null'` collided with
|
|
19
|
+
* JSON `null` itself — `{ n: NaN }`, `{ n: Infinity }`, `{ n: -Infinity }` and `{ n: null }` were
|
|
20
|
+
* one key and therefore one idempotency record, one cache entry and one cursor scope. `-0` is
|
|
21
|
+
* spelled out for the same reason: `String(-0)` is `"0"`.
|
|
22
|
+
*/
|
|
23
|
+
function hashNumber(value: number): string {
|
|
24
|
+
if (Number.isNaN(value)) return 'NaN';
|
|
25
|
+
if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
|
|
26
|
+
return Object.is(value, -0) ? '-0' : String(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The epoch, TAGGED — the same per-type tagging `hashNumber` uses for `NaN` and `-0`, for the same
|
|
31
|
+
* reason. Untagged, a `t.date` field and a `t.number` field holding that field's epoch would be one
|
|
32
|
+
* key, which is the collision this form exists to refuse; an Invalid Date would be the bare `NaN`
|
|
33
|
+
* token a `t.number` field already owns.
|
|
34
|
+
*/
|
|
35
|
+
function hashDate(value: Date): string {
|
|
36
|
+
return `Date(${hashNumber(value.getTime())})`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Tagged for the reason a `Date` is: a `Map`, a `Set` and `{}` all have no own enumerable key, so
|
|
41
|
+
* the object branch rendered all three `{}` and one hash answered for three payloads sharing
|
|
42
|
+
* nothing. Entries are SORTED, as an object's keys are: insertion order is not part of what a Map
|
|
43
|
+
* or a Set holds, so two spellings of one payload stay one key.
|
|
44
|
+
*/
|
|
45
|
+
function hashCollection(value: Map<unknown, unknown> | Set<unknown>): string {
|
|
46
|
+
const entries =
|
|
47
|
+
value instanceof Map
|
|
48
|
+
? [...value].map(([key, item]) => `${canonicalJson(key)}:${canonicalJson(item)}`)
|
|
49
|
+
: [...value].map((item) => canonicalJson(item));
|
|
50
|
+
return `${value instanceof Map ? 'Map' : 'Set'}(${entries.sort().join(',')})`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* JSON with object keys sorted at every depth, and every value a JSON document would fold onto
|
|
55
|
+
* `null` or `{}` given a token of its own. No timestamps, no insertion-order leaks.
|
|
56
|
+
*/
|
|
57
|
+
export function canonicalJson(value: unknown): string {
|
|
58
|
+
if (value === null) return 'null';
|
|
59
|
+
switch (typeof value) {
|
|
60
|
+
case 'string':
|
|
61
|
+
return JSON.stringify(value);
|
|
62
|
+
case 'number':
|
|
63
|
+
return hashNumber(value);
|
|
64
|
+
case 'boolean':
|
|
65
|
+
return String(value);
|
|
66
|
+
case 'bigint':
|
|
67
|
+
return JSON.stringify(`${value}n`);
|
|
68
|
+
case 'undefined':
|
|
69
|
+
case 'function':
|
|
70
|
+
case 'symbol':
|
|
71
|
+
return 'null';
|
|
72
|
+
default:
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
// Ahead of the object branch, because none of the three has an own enumerable key: `Object.keys`
|
|
76
|
+
// is empty for all of them and the branch below would answer `{}` for every date, every map and
|
|
77
|
+
// every set alike.
|
|
78
|
+
if (value instanceof Date) return hashDate(value);
|
|
79
|
+
if (value instanceof Map || value instanceof Set) return hashCollection(value);
|
|
80
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
81
|
+
const record = value as Record<string, unknown>;
|
|
82
|
+
const keys = Object.keys(record)
|
|
83
|
+
.filter((key) => record[key] !== undefined)
|
|
84
|
+
.sort();
|
|
85
|
+
const entries = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
|
|
86
|
+
return `{${entries.join(',')}}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* SHA-256, first 16 hex characters — the same primitive and width `@ultimat3/entity`'s `planScope`
|
|
91
|
+
* already chose, and for the same reason.
|
|
92
|
+
*
|
|
93
|
+
* This is a SHARING key over input a client chooses, not a checksum. It decides "same request,
|
|
94
|
+
* replay the stored response", which read-cache entry two callers are served from, which scope a
|
|
95
|
+
* cursor is bound to and which subscribers are served out of one live window — so a collision
|
|
96
|
+
* hands one caller another's rows. FNV-1a/32, which two of the three copies started as, is
|
|
97
|
+
* 4x10^9 values and brute-forceable offline in seconds: an input landing on another read's key was
|
|
98
|
+
* something an attacker could mint rather than something they had to wait for.
|
|
99
|
+
*/
|
|
100
|
+
export function fingerprint(value: unknown): string {
|
|
101
|
+
return new Bun.CryptoHasher('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
|
|
102
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -28,13 +28,28 @@ export interface ThemeConfig {
|
|
|
28
28
|
*/
|
|
29
29
|
export interface AuthConfig {
|
|
30
30
|
readonly signInPath: string | null;
|
|
31
|
-
/**
|
|
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
|
+
*/
|
|
32
40
|
readonly afterSignInPath: string;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export interface PwaConfig {
|
|
36
44
|
readonly enabled: boolean;
|
|
37
45
|
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
|
+
*/
|
|
38
53
|
readonly installPrompt: boolean;
|
|
39
54
|
readonly backgroundSync: boolean;
|
|
40
55
|
readonly push: boolean;
|
|
@@ -76,12 +91,18 @@ export interface JobsConfig {
|
|
|
76
91
|
readonly visibilityTimeoutMs: number;
|
|
77
92
|
}
|
|
78
93
|
|
|
94
|
+
/**
|
|
95
|
+
* No `heartbeatMs`. It was declared here, defaulted to 15_000, and read by NOTHING — deleted
|
|
96
|
+
* 2026-08-19. The socket beat is `new LiveClient({ heartbeatMs })`, browser code that cannot read
|
|
97
|
+
* server config, and the presence beat is DERIVED (`PresenceRegistry.heartbeatMs` is
|
|
98
|
+
* `max(1000, floor(ttlMs / 3))`). A second knob is a second number that can disagree with the one
|
|
99
|
+
* it is a fraction of, and a knob nothing reads is a knob nothing enforces — axioms 1 and 3.
|
|
100
|
+
*/
|
|
79
101
|
export interface RealtimeConfig {
|
|
80
102
|
readonly enabled: boolean;
|
|
81
103
|
readonly tier: RealtimeTier;
|
|
82
104
|
readonly transport: RealtimeTransport;
|
|
83
105
|
readonly urlEnv: string | undefined;
|
|
84
|
-
readonly heartbeatMs: number;
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
export interface McpConfig {
|
|
@@ -91,7 +112,15 @@ export interface McpConfig {
|
|
|
91
112
|
|
|
92
113
|
export interface AiConfig {
|
|
93
114
|
readonly mcp: McpConfig;
|
|
94
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Env key for the model id, so no model string is baked into the image — **an intention, not a
|
|
117
|
+
* behaviour, `As of 2026-08`.** The only read of it in the repo is the merge two hundred lines
|
|
118
|
+
* below, which copies it from input to output; nothing consumes the merged value, so
|
|
119
|
+
* `examples/dummy`'s `modelEnv: 'ANTHROPIC_MODEL'` selects no model. `@ultimat3/ai` reads env
|
|
120
|
+
* for API KEYS only (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`); the model is
|
|
121
|
+
* `request.model ?? DEFAULT_MODEL`, a compile-time constant in `models.ts`. So the exact thing
|
|
122
|
+
* this key exists to prevent — a model string baked into the image — is what actually happens.
|
|
123
|
+
*/
|
|
95
124
|
readonly modelEnv: string | undefined;
|
|
96
125
|
}
|
|
97
126
|
|
|
@@ -210,13 +239,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
210
239
|
backoff: 'exponential',
|
|
211
240
|
visibilityTimeoutMs: 30_000,
|
|
212
241
|
},
|
|
213
|
-
realtime: {
|
|
214
|
-
enabled: false,
|
|
215
|
-
tier: 'channels',
|
|
216
|
-
transport: 'memory',
|
|
217
|
-
urlEnv: undefined,
|
|
218
|
-
heartbeatMs: 15_000,
|
|
219
|
-
},
|
|
242
|
+
realtime: { enabled: false, tier: 'channels', transport: 'memory', urlEnv: undefined },
|
|
220
243
|
ai: { mcp: { expose: true, path: '/mcp' }, modelEnv: undefined },
|
|
221
244
|
};
|
|
222
245
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two decimals compared EXACTLY, however long the digits run — the ordering Postgres gives a
|
|
3
|
+
* `numeric` or an `int8`, over the TEXT those columns' row values are.
|
|
4
|
+
*
|
|
5
|
+
* Tier 0 because the values are text and the fix has to be available wherever they arrive.
|
|
6
|
+
* `@ultimat3/entity`'s `bigint()` and `decimal()` both hand digits back as a string on purpose — a
|
|
7
|
+
* JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53, exactly
|
|
8
|
+
* where a legacy `int8` key lives — so no `typeof` branch catches them: `String(left) <
|
|
9
|
+
* String(right)` answered `["10","100","2","9"]` where the database answers `["2","9","10","100"]`,
|
|
10
|
+
* and a keyset page boundary was cut where the database never cuts one.
|
|
11
|
+
*
|
|
12
|
+
* It answers `undefined` rather than guessing, and that is the whole of its contract: a caller
|
|
13
|
+
* that knows the column's declared kind (`@ultimat3/entity`'s `compareByKind`) asks; a caller that
|
|
14
|
+
* does NOT know it — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must not,
|
|
15
|
+
* because Postgres orders a `text` column holding `"10"` and `"9"` lexically and a comparator
|
|
16
|
+
* guessing "both sides look like decimals" would disagree with the SQL it printed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A decimal, split so two of them can be compared exactly however long the digits run. */
|
|
20
|
+
interface Decimal {
|
|
21
|
+
readonly negative: boolean;
|
|
22
|
+
readonly whole: string;
|
|
23
|
+
readonly fraction: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DECIMAL_SHAPE = /^([+-]?)(\d+)(?:\.(\d*))?$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The digits, or `undefined` for anything that is not a plain decimal — an exponent
|
|
30
|
+
* (`String(1e21)` is `"1e+21"`), a `NaN`, an empty string. Those are not values a `numeric` column
|
|
31
|
+
* can hold, so they are not values Postgres would be ordering either.
|
|
32
|
+
*/
|
|
33
|
+
function decimalOf(value: unknown): Decimal | undefined {
|
|
34
|
+
const text =
|
|
35
|
+
typeof value === 'bigint' || typeof value === 'number'
|
|
36
|
+
? String(value)
|
|
37
|
+
: typeof value === 'string'
|
|
38
|
+
? value.trim()
|
|
39
|
+
: undefined;
|
|
40
|
+
const parts = text === undefined ? null : DECIMAL_SHAPE.exec(text);
|
|
41
|
+
const whole = parts?.[2];
|
|
42
|
+
if (parts === null || whole === undefined) return undefined;
|
|
43
|
+
return { negative: parts[1] === '-', whole, fraction: parts[3] ?? '' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Exact at any width: the fractions are padded to one length and both sides become one integer, so
|
|
48
|
+
* a 38-digit `numeric` orders by its digits rather than by whatever a `Number` rounded it to.
|
|
49
|
+
*/
|
|
50
|
+
function compare(left: Decimal, right: Decimal): number {
|
|
51
|
+
if (left.negative !== right.negative) return left.negative ? -1 : 1;
|
|
52
|
+
const width = Math.max(left.fraction.length, right.fraction.length);
|
|
53
|
+
const scaled = (value: Decimal): bigint =>
|
|
54
|
+
BigInt(`${value.whole}${value.fraction.padEnd(width, '0')}`);
|
|
55
|
+
const first = scaled(left);
|
|
56
|
+
const second = scaled(right);
|
|
57
|
+
// Never a subtraction: the difference between two `bigint`s is exact and the return type is a
|
|
58
|
+
// `number`, which cannot hold it.
|
|
59
|
+
const order = first < second ? -1 : first > second ? 1 : 0;
|
|
60
|
+
// Guarded rather than negated: `-0` is a different value from `0` to `Object.is` and to a caller
|
|
61
|
+
// writing `=== 0`, and two equal negatives are a tie.
|
|
62
|
+
return left.negative && order !== 0 ? -order : order;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `-1`, `0` or `1` for two plain decimals; `undefined` when EITHER side is not one.
|
|
67
|
+
*
|
|
68
|
+
* Both, or neither: one decimal against a value that is not one is not a numeric comparison, and
|
|
69
|
+
* answering for that pair would order a mixed column by a rule the database does not use.
|
|
70
|
+
*/
|
|
71
|
+
export function compareDecimalText(left: unknown, right: unknown): number | undefined {
|
|
72
|
+
const first = decimalOf(left);
|
|
73
|
+
if (first === undefined) return undefined;
|
|
74
|
+
const second = decimalOf(right);
|
|
75
|
+
return second === undefined ? undefined : compare(first, second);
|
|
76
|
+
}
|
package/src/env-example.ts
CHANGED
|
@@ -96,7 +96,15 @@ export interface EnvExampleReport {
|
|
|
96
96
|
readonly ok: boolean;
|
|
97
97
|
/** Declared in the schema, absent from the file. Always a defect. */
|
|
98
98
|
readonly missing: readonly string[];
|
|
99
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* In the file, not in the schema — never fatal, because apps set keys nothing declares.
|
|
101
|
+
*
|
|
102
|
+
* NOT reported on its own, and the comment here said it was. `ok` is `missing.length === 0`, so
|
|
103
|
+
* an example carrying only extra keys returns `ok: true` and `assertEnvExample` never builds an
|
|
104
|
+
* error: the list reaches a surface only as `meta` on a drift some MISSING key already raised.
|
|
105
|
+
* A caller that wants it reads `checkEnvExample(...).extra` itself, which is why this stays
|
|
106
|
+
* public. `env-example.test.ts` pins both halves.
|
|
107
|
+
*/
|
|
100
108
|
readonly extra: readonly string[];
|
|
101
109
|
}
|
|
102
110
|
|
package/src/error-render.ts
CHANGED
|
@@ -64,6 +64,53 @@ export function renderCauseValue(value: unknown): string {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* One line, always. Escapes every character that a line-oriented reader would treat as a line
|
|
69
|
+
* break, leaving everything else byte-identical.
|
|
70
|
+
*
|
|
71
|
+
* WHY this exists at all: `renderCauseValue` above is safe against a value that THROWS while
|
|
72
|
+
* rendering, and safe against a value that LEAKS. It is not safe against a value that is already
|
|
73
|
+
* a `string` — a `string` renders fine, so nothing objected to it — and a caller-controlled
|
|
74
|
+
* string can carry a newline. The 3-line contract format (`<code>` / ` cause: …` / ` fix: …`)
|
|
75
|
+
* is line-oriented in the terminal, in CI logs and inside the dev overlay's `<pre>`, so one
|
|
76
|
+
* newline in a `cause` writes a second line an operator reads as a genuine framework message.
|
|
77
|
+
* Reproduced with a forged OIDC `iss` claim: an unauthenticated stranger with one crafted token.
|
|
78
|
+
*
|
|
79
|
+
* At the RENDERER, not at the call site, deliberately. `bun run error-render` cannot see this
|
|
80
|
+
* class — its rule is about `unknown` reaching a `cause:` — and three holes shipped in
|
|
81
|
+
* `@ultimat3/auth` alone under a green check. A rule every call site must remember is a rule the
|
|
82
|
+
* 296th call site forgets; there are six renderers of this format and no more, so escaping there
|
|
83
|
+
* is one place instead of every place (issue #97, option 3).
|
|
84
|
+
*
|
|
85
|
+
* `\u2028` and `\u2029` are included because they terminate a line for a JavaScript parser and
|
|
86
|
+
* for several log viewers, while `String.prototype.split('\n')` never sees them.
|
|
87
|
+
*
|
|
88
|
+
* The set is every C0 control, DEL, and `\u2028`/`\u2029`. Line breaks are the structural half —
|
|
89
|
+
* they add a line to a line-oriented format — and the rest ride along because a terminal reads them
|
|
90
|
+
* as commands of its own: a raw `\u001b` in a cause is an ANSI escape, so a value could repaint the
|
|
91
|
+
* screen, hide the line above it or move the cursor over what a reader had already been shown.
|
|
92
|
+
*
|
|
93
|
+
* NOT a general sanitiser. A `cause` is prose and keeps its quotes, its backslashes, its percent
|
|
94
|
+
* signs and every printable character it arrived with; only the control range is touched.
|
|
95
|
+
*/
|
|
96
|
+
export function singleLine(text: string): string {
|
|
97
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: escaping them is the point.
|
|
98
|
+
return text.replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, (char) => {
|
|
99
|
+
const known = CONTROL_ESCAPES[char];
|
|
100
|
+
if (known !== undefined) return known;
|
|
101
|
+
return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The spellings a reader already knows from a JSON string, so the escape reads as an escape. */
|
|
106
|
+
const CONTROL_ESCAPES: Readonly<Record<string, string>> = {
|
|
107
|
+
'\n': String.raw`\n`,
|
|
108
|
+
'\r': String.raw`\r`,
|
|
109
|
+
'\t': String.raw`\t`,
|
|
110
|
+
'\b': String.raw`\b`,
|
|
111
|
+
'\f': String.raw`\f`,
|
|
112
|
+
};
|
|
113
|
+
|
|
67
114
|
/**
|
|
68
115
|
* `value instanceof Error`, made total. The test itself can throw: a `Proxy`'s `getPrototypeOf`
|
|
69
116
|
* trap runs during `instanceof`, and the one place this question is asked is a `catch` block that
|
package/src/error-retry.ts
CHANGED
|
@@ -27,15 +27,29 @@ export const DEFAULT_ERROR_RETRY: ErrorRetry = 'terminal';
|
|
|
27
27
|
* app whose clients stop retrying a rolling restart, which is the one case retrying always wins.
|
|
28
28
|
* Only the exceptions are listed — everything else is `terminal` by the default above.
|
|
29
29
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
// A `Map`, not a frozen object: `code` is a caller's string on every read below, and
|
|
31
|
+
// `CORE_ERROR_RETRY['constructor']` on an object literal answers the `Object` FUNCTION — which
|
|
32
|
+
// `retryFor` then returned as an `ErrorRetry`, into every `UltimateError.retry` and `toJSON()`.
|
|
33
|
+
const CORE_ERROR_RETRY: ReadonlyMap<string, ErrorRetry> = new Map(
|
|
34
|
+
Object.entries({
|
|
35
|
+
X_DRAINING: 'retryable',
|
|
36
|
+
// A deadline that expired is the canonical back-off-and-try-again case: nothing about the
|
|
37
|
+
// request was wrong, the budget ran out. Deliberately NOT `retry-after` — that spelling means
|
|
38
|
+
// the responder named a time, and a timeout by definition produced no such answer. Its twin
|
|
39
|
+
// `X_ABORTED` (the caller went away) is left to the `terminal` DEFAULT rather than listed here:
|
|
40
|
+
// the answer is the same, and listing it would close a door nobody has asked to open.
|
|
41
|
+
X_TIMEOUT: 'retryable',
|
|
42
|
+
// Listed even though `terminal` is the default, and that is the whole point: `classifyThrown`
|
|
43
|
+
// reads an UNREGISTERED code carrying `terminal` as unclassified, because a per-instance
|
|
44
|
+
// `terminal` is indistinguishable from the default and honouring it would dead-letter the
|
|
45
|
+
// first attempt of every job in every app whose codes nobody has classified. So a stub that
|
|
46
|
+
// says "this build does not have the feature" fell through to the attempt count and burned a
|
|
47
|
+
// job's whole retry policy on a fact that cannot change between attempt 1 and attempt 5.
|
|
48
|
+
// It is core's code — every `notImplemented()` stub in the framework raises it — so it is
|
|
49
|
+
// classified once here rather than by each package that happens to throw it.
|
|
50
|
+
X_NOT_IMPLEMENTED: 'terminal',
|
|
51
|
+
} as const),
|
|
52
|
+
);
|
|
39
53
|
|
|
40
54
|
const REGISTERED = new Map<string, ErrorRetry>();
|
|
41
55
|
|
|
@@ -70,7 +84,7 @@ export function registerErrorRetry(retries: Readonly<Record<string, ErrorRetry>>
|
|
|
70
84
|
if (!isErrorRetry(retry)) {
|
|
71
85
|
throw retryInvalid(code, `"${String(retry)}" is not ${ERROR_RETRY_KINDS.join(' | ')}`);
|
|
72
86
|
}
|
|
73
|
-
const core = CORE_ERROR_RETRY
|
|
87
|
+
const core = CORE_ERROR_RETRY.get(code);
|
|
74
88
|
if (core !== undefined) {
|
|
75
89
|
throw retryInvalid(code, `the framework already classifies it as ${core}`);
|
|
76
90
|
}
|
|
@@ -99,7 +113,7 @@ export function resetErrorRetry(): void {
|
|
|
99
113
|
* Core table first, for the same belt-and-braces reason `retryFor` had it first.
|
|
100
114
|
*/
|
|
101
115
|
export function declaredErrorRetry(code: string): ErrorRetry | undefined {
|
|
102
|
-
return CORE_ERROR_RETRY
|
|
116
|
+
return CORE_ERROR_RETRY.get(code) ?? REGISTERED.get(code);
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
export function retryFor(code: string): ErrorRetry {
|
package/src/errors.ts
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
// overlay and `--json`. Never throw a bare Error anywhere in the framework.
|
|
4
4
|
|
|
5
5
|
import { describeErrorCode } from './error-codes';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
isThrownError,
|
|
8
|
+
renderCauseValue,
|
|
9
|
+
renderMetaRecord,
|
|
10
|
+
renderThrowable,
|
|
11
|
+
singleLine,
|
|
12
|
+
} from './error-render';
|
|
7
13
|
import { DEFAULT_ERROR_RETRY, type ErrorRetry, isErrorRetry, retryFor } from './error-retry';
|
|
8
14
|
|
|
9
15
|
/**
|
|
@@ -89,8 +95,14 @@ export class UltimateError extends Error {
|
|
|
89
95
|
* ```
|
|
90
96
|
*/
|
|
91
97
|
format(options?: FormatErrorOptions): string {
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
// `singleLine`, because this format is line-oriented and `cause` may hold a caller's string:
|
|
99
|
+
// one newline in it writes a second line an operator reads as a genuine framework message.
|
|
100
|
+
const lines = [
|
|
101
|
+
`${singleLine(this.code)}: ${singleLine(this.title)}`,
|
|
102
|
+
` cause: ${singleLine(this.cause)}`,
|
|
103
|
+
` fix: ${singleLine(this.fix)}`,
|
|
104
|
+
];
|
|
105
|
+
if (options?.docs === true) lines.push(` docs: ${singleLine(this.docs)}`);
|
|
94
106
|
return lines.join('\n');
|
|
95
107
|
}
|
|
96
108
|
|
package/src/image/errors.ts
CHANGED
|
@@ -42,7 +42,7 @@ export const imageDecodeFailed = (
|
|
|
42
42
|
): ImageDecodeFailedError =>
|
|
43
43
|
new ImageDecodeFailedError(
|
|
44
44
|
cause,
|
|
45
|
-
'
|
|
45
|
+
're-export the image from its source: `file <path>` reports what these bytes actually are',
|
|
46
46
|
meta,
|
|
47
47
|
);
|
|
48
48
|
|
package/src/index.ts
CHANGED
|
@@ -30,10 +30,9 @@ export {
|
|
|
30
30
|
withFacts,
|
|
31
31
|
} from './actor';
|
|
32
32
|
export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
|
|
33
|
-
export type
|
|
34
|
-
export {
|
|
35
|
-
export type
|
|
36
|
-
export { frozenClock, systemClock } from './clock';
|
|
33
|
+
export { assert, assertNever, type InvariantOptions, invariant } from './assert';
|
|
34
|
+
export { canonicalJson, fingerprint } from './canonical-json';
|
|
35
|
+
export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
|
|
37
36
|
export type {
|
|
38
37
|
AiConfig,
|
|
39
38
|
AiConfigInput,
|
|
@@ -78,6 +77,7 @@ export {
|
|
|
78
77
|
resetCursorSigning,
|
|
79
78
|
usesDevCursorSecret,
|
|
80
79
|
} from './cursor';
|
|
80
|
+
export { compareDecimalText } from './decimal-order';
|
|
81
81
|
export type {
|
|
82
82
|
Env,
|
|
83
83
|
EnvBooleanVar,
|
|
@@ -159,6 +159,7 @@ export {
|
|
|
159
159
|
resetErrorRetry,
|
|
160
160
|
retryFor,
|
|
161
161
|
SCHEMA_ERROR_CODE_TITLES,
|
|
162
|
+
singleLine,
|
|
162
163
|
stringField,
|
|
163
164
|
toUltimateError,
|
|
164
165
|
ULTIMATE_ERROR_BRAND,
|
|
@@ -425,8 +426,7 @@ export {
|
|
|
425
426
|
MAX_IMAGE_PIXELS,
|
|
426
427
|
rasterFrom,
|
|
427
428
|
} from './image/raster';
|
|
428
|
-
export type
|
|
429
|
-
export { fitBox, resizeRaster, scaledToFit } from './image/resize';
|
|
429
|
+
export { fitBox, type ImageFit, type ResizeSpec, resizeRaster, scaledToFit } from './image/resize';
|
|
430
430
|
export { impersonate, impersonationReason, isImpersonating } from './impersonate';
|
|
431
431
|
export { cachedFormatter, canonicalLocale, MAX_CACHED_FORMATTERS } from './intl-cache';
|
|
432
432
|
export type {
|
|
@@ -471,10 +471,8 @@ export {
|
|
|
471
471
|
markListening,
|
|
472
472
|
resetListeners,
|
|
473
473
|
} from './listeners';
|
|
474
|
-
export type
|
|
475
|
-
export {
|
|
476
|
-
export type { CappedBody } from './read-capped';
|
|
477
|
-
export { readWithinLimit } from './read-capped';
|
|
474
|
+
export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
|
|
475
|
+
export { type CappedBody, readWithinLimit } from './read-capped';
|
|
478
476
|
export type { ModuleRegistrar, PrimitiveKind, RegisteredPrimitive } from './registrar';
|
|
479
477
|
export {
|
|
480
478
|
hasPrimitiveRegistrar,
|
|
@@ -488,8 +486,7 @@ export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from '.
|
|
|
488
486
|
export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
|
|
489
487
|
export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
|
|
490
488
|
export { safeUrl, URL_ATTRIBUTES } from './safe-url';
|
|
491
|
-
export type
|
|
492
|
-
export { defineService, resetServices } from './service';
|
|
489
|
+
export { defineService, resetServices, type ServiceFactory } from './service';
|
|
493
490
|
export { timingSafeEqual } from './timing-safe-equal';
|
|
494
491
|
export {
|
|
495
492
|
frameworkVersion,
|
package/src/logger.ts
CHANGED
|
@@ -10,6 +10,9 @@ import { isSecret, REDACTED } from './secret';
|
|
|
10
10
|
// it without importing the logger, and two constants spelled the same is one rename from a leak.
|
|
11
11
|
export { REDACTED } from './secret';
|
|
12
12
|
|
|
13
|
+
/** What a `Date` this file cannot render says instead — the line survives, the value is named. */
|
|
14
|
+
const INVALID_DATE = 'an invalid Date';
|
|
15
|
+
|
|
13
16
|
export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'] as const;
|
|
14
17
|
|
|
15
18
|
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
@@ -133,7 +136,7 @@ function serialise(value: unknown, depth: number): unknown {
|
|
|
133
136
|
// `toISOString()` THROWS on an invalid Date, and an invalid Date is exactly the value worth
|
|
134
137
|
// logging when a schedule went wrong.
|
|
135
138
|
if (value instanceof Date) {
|
|
136
|
-
return Number.isNaN(value.getTime()) ?
|
|
139
|
+
return Number.isNaN(value.getTime()) ? INVALID_DATE : value.toISOString();
|
|
137
140
|
}
|
|
138
141
|
if (isUltimateError(value)) return value.toJSON();
|
|
139
142
|
if (value instanceof Error) return { name: value.name, message: value.message };
|
|
@@ -198,6 +201,22 @@ function renderLine(
|
|
|
198
201
|
}
|
|
199
202
|
}
|
|
200
203
|
|
|
204
|
+
/**
|
|
205
|
+
* The one value in a line that is not the caller's, and it was the one read left unguarded:
|
|
206
|
+
* `toISOString()` raises `RangeError` on an invalid `Date`, and a `Clock` is injected — a frozen
|
|
207
|
+
* clock set from a bad string, or a clock whose `now()` throws, took the whole line with it. The
|
|
208
|
+
* same marker `serialise` gives an invalid `Date` in a FIELD, so one vocabulary covers both.
|
|
209
|
+
*/
|
|
210
|
+
function timestamp(clock: Clock): string {
|
|
211
|
+
try {
|
|
212
|
+
const at = clock.now();
|
|
213
|
+
if (at instanceof Date && !Number.isNaN(at.getTime())) return at.toISOString();
|
|
214
|
+
} catch {
|
|
215
|
+
// A clock that fights being read is exactly the moment a line is worth keeping.
|
|
216
|
+
}
|
|
217
|
+
return INVALID_DATE;
|
|
218
|
+
}
|
|
219
|
+
|
|
201
220
|
function envLevel(): LogLevel {
|
|
202
221
|
const raw = process.env['LOG_LEVEL'];
|
|
203
222
|
return raw !== undefined && (LOG_LEVELS as readonly string[]).includes(raw)
|
|
@@ -215,7 +234,7 @@ export function createLogger(options?: LoggerOptions): Logger {
|
|
|
215
234
|
function emit(lineLevel: LogLevel, message: string, fields?: LogFields): void {
|
|
216
235
|
if (LEVEL_WEIGHT[lineLevel] < threshold) return;
|
|
217
236
|
const line = {
|
|
218
|
-
ts: clock
|
|
237
|
+
ts: timestamp(clock),
|
|
219
238
|
level: lineLevel,
|
|
220
239
|
msg: message,
|
|
221
240
|
...redactFields(bound),
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Single responsibility: the OpenTelemetry-shaped metric DATA MODEL — what a point, a
|
|
2
|
+
// descriptor, a collection and an instrument's options are. No registry and no state: `metrics.ts`
|
|
3
|
+
// owns those, and a reader (`metrics-text.ts`, an exporter) needs the shapes without them.
|
|
4
|
+
|
|
5
|
+
import type { SpanResource } from './telemetry';
|
|
6
|
+
|
|
7
|
+
export type MetricKind = 'counter' | 'gauge' | 'histogram';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Narrower than a span attribute on purpose: metric attributes become time-series labels, every
|
|
11
|
+
* distinct combination is a stored series, and an array label has no meaning in any exposition
|
|
12
|
+
* format. Keep the cardinality low — a user id here is an outage, and `maxSeries` is the ceiling
|
|
13
|
+
* that makes "keep it low" a mechanism instead of this sentence.
|
|
14
|
+
*/
|
|
15
|
+
export type MetricAttributeValue = string | number | boolean;
|
|
16
|
+
|
|
17
|
+
export interface MetricAttributes {
|
|
18
|
+
readonly [key: string]: MetricAttributeValue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MetricDescriptor {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly kind: MetricKind;
|
|
24
|
+
/** UCUM, as OTel spells it: `1`, `s`, `By`, `{request}`. */
|
|
25
|
+
readonly unit: string;
|
|
26
|
+
readonly description: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MetricPoint {
|
|
30
|
+
readonly attributes: MetricAttributes;
|
|
31
|
+
/** Counter: cumulative sum since process start. Gauge: last value. Histogram: sum. */
|
|
32
|
+
readonly value: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HistogramPoint extends MetricPoint {
|
|
36
|
+
readonly count: number;
|
|
37
|
+
readonly min: number;
|
|
38
|
+
readonly max: number;
|
|
39
|
+
/** Explicit upper bounds; `buckets` is one longer, the last being the `+Inf` overflow. */
|
|
40
|
+
readonly bounds: readonly number[];
|
|
41
|
+
readonly buckets: readonly number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ReadableMetric {
|
|
45
|
+
readonly descriptor: MetricDescriptor;
|
|
46
|
+
readonly points: readonly MetricPoint[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MetricCollection {
|
|
50
|
+
/** Epoch milliseconds, from the configured clock. */
|
|
51
|
+
readonly at: number;
|
|
52
|
+
readonly resource: SpanResource;
|
|
53
|
+
readonly metrics: readonly ReadableMetric[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The driver seam. OTLP, Prometheus remote-write or a vendor SDK all arrive as one of these. */
|
|
57
|
+
export interface MetricExporter {
|
|
58
|
+
export(collection: MetricCollection): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Counter {
|
|
62
|
+
add(value?: number, attributes?: MetricAttributes): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Gauge {
|
|
66
|
+
/** Set the current value. */
|
|
67
|
+
record(value: number, attributes?: MetricAttributes): void;
|
|
68
|
+
/** Move the current value — `+1` on connect, `-1` on disconnect. */
|
|
69
|
+
add(delta: number, attributes?: MetricAttributes): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface Histogram {
|
|
73
|
+
record(value: number, attributes?: MetricAttributes): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface InstrumentOptions {
|
|
77
|
+
readonly unit?: string | undefined;
|
|
78
|
+
readonly description?: string | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Distinct label sets this instrument may store. Past it every new set folds into one overflow
|
|
81
|
+
* series. Defaults to `DEFAULT_MAX_SERIES`; the first declaration of a name wins.
|
|
82
|
+
*/
|
|
83
|
+
readonly maxSeries?: number | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface GaugeOptions extends InstrumentOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Async instrument: read at collection time instead of being pushed. Never stale.
|
|
89
|
+
* Stated twice for one name with two different callbacks is `X_METRIC_NAME_INVALID`, not a
|
|
90
|
+
* silent win for the first — see `assertSameDeclaration`.
|
|
91
|
+
*/
|
|
92
|
+
readonly observe?: (() => number) | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface HistogramOptions extends InstrumentOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Explicit bucket boundaries, ascending. Defaults to the OTel latency-in-seconds set.
|
|
98
|
+
* Stated twice for one name with two different sets is `X_METRIC_NAME_INVALID`, not a silent
|
|
99
|
+
* win for the first — see `assertSameDeclaration`.
|
|
100
|
+
*/
|
|
101
|
+
readonly bounds?: readonly number[] | undefined;
|
|
102
|
+
}
|
package/src/metrics.ts
CHANGED
|
@@ -3,9 +3,44 @@
|
|
|
3
3
|
// always on, a no-op exporter by default, and the wire format supplied by a driver, never here.
|
|
4
4
|
|
|
5
5
|
import { type Clock, systemClock } from './clock';
|
|
6
|
+
import { renderThrowable } from './error-render';
|
|
6
7
|
import { type CodedErrorInit, UltimateError } from './errors';
|
|
7
8
|
import { logger } from './logger';
|
|
8
|
-
import
|
|
9
|
+
import type {
|
|
10
|
+
Counter,
|
|
11
|
+
Gauge,
|
|
12
|
+
GaugeOptions,
|
|
13
|
+
Histogram,
|
|
14
|
+
HistogramOptions,
|
|
15
|
+
InstrumentOptions,
|
|
16
|
+
MetricAttributes,
|
|
17
|
+
MetricCollection,
|
|
18
|
+
MetricDescriptor,
|
|
19
|
+
MetricExporter,
|
|
20
|
+
MetricKind,
|
|
21
|
+
MetricPoint,
|
|
22
|
+
} from './metrics-types';
|
|
23
|
+
import { serviceResource } from './telemetry';
|
|
24
|
+
|
|
25
|
+
// The data model is a module of its own; the public surface is unchanged, so nothing that imports
|
|
26
|
+
// a metric type from here has to learn a second path.
|
|
27
|
+
export type {
|
|
28
|
+
Counter,
|
|
29
|
+
Gauge,
|
|
30
|
+
GaugeOptions,
|
|
31
|
+
Histogram,
|
|
32
|
+
HistogramOptions,
|
|
33
|
+
HistogramPoint,
|
|
34
|
+
InstrumentOptions,
|
|
35
|
+
MetricAttributes,
|
|
36
|
+
MetricAttributeValue,
|
|
37
|
+
MetricCollection,
|
|
38
|
+
MetricDescriptor,
|
|
39
|
+
MetricExporter,
|
|
40
|
+
MetricKind,
|
|
41
|
+
MetricPoint,
|
|
42
|
+
ReadableMetric,
|
|
43
|
+
} from './metrics-types';
|
|
9
44
|
|
|
10
45
|
export class MetricNameInvalidError extends UltimateError {
|
|
11
46
|
static readonly code = 'X_METRIC_NAME_INVALID';
|
|
@@ -31,103 +66,6 @@ export class MetricCardinalityError extends UltimateError {
|
|
|
31
66
|
}
|
|
32
67
|
}
|
|
33
68
|
|
|
34
|
-
export type MetricKind = 'counter' | 'gauge' | 'histogram';
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Narrower than a span attribute on purpose: metric attributes become time-series labels, every
|
|
38
|
-
* distinct combination is a stored series, and an array label has no meaning in any exposition
|
|
39
|
-
* format. Keep the cardinality low — a user id here is an outage, and `maxSeries` is the ceiling
|
|
40
|
-
* that makes "keep it low" a mechanism instead of this sentence.
|
|
41
|
-
*/
|
|
42
|
-
export type MetricAttributeValue = string | number | boolean;
|
|
43
|
-
|
|
44
|
-
export interface MetricAttributes {
|
|
45
|
-
readonly [key: string]: MetricAttributeValue;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface MetricDescriptor {
|
|
49
|
-
readonly name: string;
|
|
50
|
-
readonly kind: MetricKind;
|
|
51
|
-
/** UCUM, as OTel spells it: `1`, `s`, `By`, `{request}`. */
|
|
52
|
-
readonly unit: string;
|
|
53
|
-
readonly description: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface MetricPoint {
|
|
57
|
-
readonly attributes: MetricAttributes;
|
|
58
|
-
/** Counter: cumulative sum since process start. Gauge: last value. Histogram: sum. */
|
|
59
|
-
readonly value: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface HistogramPoint extends MetricPoint {
|
|
63
|
-
readonly count: number;
|
|
64
|
-
readonly min: number;
|
|
65
|
-
readonly max: number;
|
|
66
|
-
/** Explicit upper bounds; `buckets` is one longer, the last being the `+Inf` overflow. */
|
|
67
|
-
readonly bounds: readonly number[];
|
|
68
|
-
readonly buckets: readonly number[];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface ReadableMetric {
|
|
72
|
-
readonly descriptor: MetricDescriptor;
|
|
73
|
-
readonly points: readonly MetricPoint[];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface MetricCollection {
|
|
77
|
-
/** Epoch milliseconds, from the configured clock. */
|
|
78
|
-
readonly at: number;
|
|
79
|
-
readonly resource: SpanResource;
|
|
80
|
-
readonly metrics: readonly ReadableMetric[];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** The driver seam. OTLP, Prometheus remote-write or a vendor SDK all arrive as one of these. */
|
|
84
|
-
export interface MetricExporter {
|
|
85
|
-
export(collection: MetricCollection): void;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface Counter {
|
|
89
|
-
add(value?: number, attributes?: MetricAttributes): void;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface Gauge {
|
|
93
|
-
/** Set the current value. */
|
|
94
|
-
record(value: number, attributes?: MetricAttributes): void;
|
|
95
|
-
/** Move the current value — `+1` on connect, `-1` on disconnect. */
|
|
96
|
-
add(delta: number, attributes?: MetricAttributes): void;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface Histogram {
|
|
100
|
-
record(value: number, attributes?: MetricAttributes): void;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export interface InstrumentOptions {
|
|
104
|
-
readonly unit?: string | undefined;
|
|
105
|
-
readonly description?: string | undefined;
|
|
106
|
-
/**
|
|
107
|
-
* Distinct label sets this instrument may store. Past it every new set folds into one overflow
|
|
108
|
-
* series. Defaults to `DEFAULT_MAX_SERIES`; the first declaration of a name wins.
|
|
109
|
-
*/
|
|
110
|
-
readonly maxSeries?: number | undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface GaugeOptions extends InstrumentOptions {
|
|
114
|
-
/**
|
|
115
|
-
* Async instrument: read at collection time instead of being pushed. Never stale.
|
|
116
|
-
* Stated twice for one name with two different callbacks is `X_METRIC_NAME_INVALID`, not a
|
|
117
|
-
* silent win for the first — see `assertSameDeclaration`.
|
|
118
|
-
*/
|
|
119
|
-
readonly observe?: (() => number) | undefined;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export interface HistogramOptions extends InstrumentOptions {
|
|
123
|
-
/**
|
|
124
|
-
* Explicit bucket boundaries, ascending. Defaults to the OTel latency-in-seconds set.
|
|
125
|
-
* Stated twice for one name with two different sets is `X_METRIC_NAME_INVALID`, not a silent
|
|
126
|
-
* win for the first — see `assertSameDeclaration`.
|
|
127
|
-
*/
|
|
128
|
-
readonly bounds?: readonly number[] | undefined;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
69
|
/**
|
|
132
70
|
* The per-instrument series ceiling. 2000 is roomy for a bounded label set — every route pattern
|
|
133
71
|
* times every status class times every method — and small enough that the process notices an
|
|
@@ -204,6 +142,8 @@ interface Instrument {
|
|
|
204
142
|
readonly maxSeries: number;
|
|
205
143
|
/** Reported once. A cardinality blow-up is one bug, not one log line per call. */
|
|
206
144
|
overflowed: boolean;
|
|
145
|
+
/** Reported once, for the same reason: a scrape every 15s must not become a log every 15s. */
|
|
146
|
+
observeFailed: boolean;
|
|
207
147
|
}
|
|
208
148
|
|
|
209
149
|
const instruments = new Map<string, Instrument>();
|
|
@@ -230,6 +170,7 @@ export function resetMetrics(): void {
|
|
|
230
170
|
for (const instrument of instruments.values()) {
|
|
231
171
|
instrument.series.clear();
|
|
232
172
|
instrument.overflowed = false;
|
|
173
|
+
instrument.observeFailed = false;
|
|
233
174
|
}
|
|
234
175
|
}
|
|
235
176
|
|
|
@@ -294,6 +235,7 @@ function declare(name: string, kind: MetricKind, options: GaugeOptions & Histogr
|
|
|
294
235
|
observe: options.observe,
|
|
295
236
|
maxSeries,
|
|
296
237
|
overflowed: false,
|
|
238
|
+
observeFailed: false,
|
|
297
239
|
};
|
|
298
240
|
instruments.set(name, instrument);
|
|
299
241
|
return instrument;
|
|
@@ -350,6 +292,26 @@ function reportOverflow(instrument: Instrument): void {
|
|
|
350
292
|
logger.error(error.format(), { code: error.code, metric: name });
|
|
351
293
|
}
|
|
352
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Reported through the logger for `reportOverflow`'s reason and once for the same one — a scrape
|
|
297
|
+
* runs on a timer, so a permanently broken observer would otherwise write a log line every
|
|
298
|
+
* interval forever. A recurrence after the first is therefore silent by design; the missing series
|
|
299
|
+
* is the signal that outlives the line.
|
|
300
|
+
*/
|
|
301
|
+
function reportObserveFailure(instrument: Instrument, thrown: unknown): void {
|
|
302
|
+
if (instrument.observeFailed) return;
|
|
303
|
+
instrument.observeFailed = true;
|
|
304
|
+
const { name, kind } = instrument.descriptor;
|
|
305
|
+
const error = new MetricValueInvalidError({
|
|
306
|
+
// `renderThrowable`, never `${thrown}`: the value is whatever the app's callback threw, and a
|
|
307
|
+
// `.message` read on it is the one that throws where there is nothing left to answer with.
|
|
308
|
+
cause: `the observe() callback of ${name} did not produce a value: ${renderThrowable(thrown)}; this instrument contributes no point until it does`,
|
|
309
|
+
fix: `make the observe() callback of ${name} total — return a finite number when the resource it reads is gone, e.g. ${kind}('${name}', { observe: () => pool?.size ?? 0 })`,
|
|
310
|
+
meta: { metric: name },
|
|
311
|
+
});
|
|
312
|
+
logger.error(error.format(), { code: error.code, metric: name });
|
|
313
|
+
}
|
|
314
|
+
|
|
353
315
|
function createSeries(instrument: Instrument, key: string, attributes: MetricAttributes): Series {
|
|
354
316
|
const created: Series = {
|
|
355
317
|
attributes,
|
|
@@ -433,8 +395,19 @@ export function histogram(name: string, options?: HistogramOptions): Histogram {
|
|
|
433
395
|
}
|
|
434
396
|
|
|
435
397
|
function pointsOf(instrument: Instrument): readonly MetricPoint[] {
|
|
436
|
-
|
|
437
|
-
|
|
398
|
+
const observe = instrument.observe;
|
|
399
|
+
if (observe !== undefined) {
|
|
400
|
+
try {
|
|
401
|
+
return [{ attributes: {}, value: finite(instrument.descriptor.name, observe()) }];
|
|
402
|
+
} catch (thrown) {
|
|
403
|
+
// The callback is the app's, run at SCRAPE time with no call site to blame: `() => pool.size`
|
|
404
|
+
// after a drain throws, and an unguarded read here took every other instrument down with it
|
|
405
|
+
// — /metrics 500s, `http_requests_total` goes invisible, and `startMetricExport`'s timer
|
|
406
|
+
// callback raises where nothing can catch it. One hostile observer costs its own point only,
|
|
407
|
+
// the same degradation `readinessChecks()` and the logger's per-key walk already make.
|
|
408
|
+
reportObserveFailure(instrument, thrown);
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
438
411
|
}
|
|
439
412
|
return [...instrument.series.values()].map((series) =>
|
|
440
413
|
instrument.descriptor.kind === 'histogram'
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Single responsibility: a `MetricExporter` that POSTs OTLP/HTTP JSON to a collector. No batching
|
|
2
2
|
// — `collectMetrics()` already produces one whole snapshot per tick, so a tick is a request.
|
|
3
3
|
|
|
4
|
+
import { renderThrowable } from './error-render';
|
|
5
|
+
import { logger } from './logger';
|
|
4
6
|
import type {
|
|
5
7
|
HistogramPoint,
|
|
6
8
|
MetricCollection,
|
|
@@ -124,10 +126,31 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
|
|
|
124
126
|
return {
|
|
125
127
|
export(collection: MetricCollection): void {
|
|
126
128
|
startedAtMs ??= collection.at;
|
|
127
|
-
|
|
129
|
+
let body: string;
|
|
130
|
+
try {
|
|
131
|
+
// `export` is called from a timer, not awaited by anyone, so this throw had nowhere to go
|
|
132
|
+
// but into the metric loop that called it: `MetricAttributeValue` is a compile-time claim,
|
|
133
|
+
// and an attribute the app spelled as an object or a bigint reaches `otlpAttributes` as a
|
|
134
|
+
// TypeError. Dropped with a line, the same degradation `postOtlp` already applies to a
|
|
135
|
+
// collector that is down — telemetry is best-effort and must never end the process.
|
|
136
|
+
body = JSON.stringify(otlpMetricsRequest(collection, startedAtMs));
|
|
137
|
+
} catch (failure) {
|
|
138
|
+
logger.warn('otlp metric snapshot dropped', {
|
|
139
|
+
url,
|
|
140
|
+
metrics: collection.metrics.length,
|
|
141
|
+
error: renderThrowable(failure),
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
128
145
|
// Chained, so a slow collector cannot make two snapshots arrive out of order and turn a
|
|
129
|
-
// cumulative counter into an apparent reset.
|
|
130
|
-
|
|
146
|
+
// cumulative counter into an apparent reset. Chained on a SETTLED shadow, for the reason
|
|
147
|
+
// `otlp-span-exporter.ts` spells out: a chain that carries a rejection forward stops calling
|
|
148
|
+
// `postOtlp` for the life of the process, in silence.
|
|
149
|
+
const settled = inflight.then(
|
|
150
|
+
() => undefined,
|
|
151
|
+
() => undefined,
|
|
152
|
+
);
|
|
153
|
+
inflight = settled.then(() => postOtlp({ url, headers, body, timeoutMs, fetch: send }));
|
|
131
154
|
},
|
|
132
155
|
flush(): Promise<void> {
|
|
133
156
|
return inflight;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Single responsibility: a `SpanExporter` that POSTs OTLP/HTTP JSON to a collector. Batched,
|
|
2
2
|
// because `SpanExporter.export` is one span and a request per span is a second load generator.
|
|
3
3
|
|
|
4
|
+
import { renderThrowable } from './error-render';
|
|
5
|
+
import { logger } from './logger';
|
|
4
6
|
import {
|
|
5
7
|
OTLP_SCOPE,
|
|
6
8
|
type OtlpKeyValue,
|
|
@@ -134,15 +136,38 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
134
136
|
const post = (batch: readonly ReadableSpan[]): Promise<void> => {
|
|
135
137
|
const first = batch[0];
|
|
136
138
|
if (first === undefined) return Promise.resolve();
|
|
137
|
-
|
|
139
|
+
let body: string;
|
|
140
|
+
try {
|
|
141
|
+
// The one synchronous throw on this path, and the only way `inflight` can reject at all:
|
|
142
|
+
// `AttributeValue` is a compile-time claim, so an attribute the app spelled as an object, a
|
|
143
|
+
// bigint or a cycle reaches `anyValue`'s `value.map(...)` as a TypeError. Dropped with a
|
|
144
|
+
// line, the same degradation `postOtlp` already applies to a collector that is down —
|
|
145
|
+
// telemetry is best-effort and must never become the process's exit code.
|
|
146
|
+
body = JSON.stringify(otlpTraceRequest(batch, first.resource));
|
|
147
|
+
} catch (failure) {
|
|
148
|
+
logger.warn('otlp span batch dropped', {
|
|
149
|
+
url,
|
|
150
|
+
spans: batch.length,
|
|
151
|
+
error: renderThrowable(failure),
|
|
152
|
+
});
|
|
153
|
+
return Promise.resolve();
|
|
154
|
+
}
|
|
138
155
|
return postOtlp({ url, headers, body, timeoutMs, fetch: send });
|
|
139
156
|
};
|
|
140
157
|
|
|
141
158
|
const drainQueue = (): Promise<void> => {
|
|
142
159
|
const batch = queue.splice(0, queue.length);
|
|
143
160
|
// Chained, not concurrent: a collector reordering batches from one process turns a parent's
|
|
144
|
-
// span arriving after its child into a broken trace on the read side.
|
|
145
|
-
|
|
161
|
+
// span arriving after its child into a broken trace on the read side. Chained on a SETTLED
|
|
162
|
+
// shadow, because a chain that carries a rejection forward is poisoned for the life of the
|
|
163
|
+
// process: `post` is never called again while the queue keeps emptying, so every later span is
|
|
164
|
+
// dropped in silence and every timer tick mints a fresh unhandled rejection — which Bun ends
|
|
165
|
+
// the process on. Same shape as `offline-queue.ts`'s drain chain.
|
|
166
|
+
const settled = inflight.then(
|
|
167
|
+
() => undefined,
|
|
168
|
+
() => undefined,
|
|
169
|
+
);
|
|
170
|
+
inflight = settled.then(() => post(batch));
|
|
146
171
|
return inflight;
|
|
147
172
|
};
|
|
148
173
|
|
package/src/type-pins.ts
CHANGED
|
@@ -52,9 +52,15 @@ type _ActorFactMapAcceptsNothing = Assert<
|
|
|
52
52
|
>;
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* The seam is additive: an actor literal
|
|
56
|
-
* optional for that reason and not only for the denial rule —
|
|
57
|
-
* a breaking change to a tier-0 type every package depends on.
|
|
55
|
+
* The seam is additive: an actor literal carrying the required members but no `facts` still is an
|
|
56
|
+
* `Actor`. `facts` is optional for that reason and not only for the denial rule — making it
|
|
57
|
+
* required would be a breaking change to a tier-0 type every package depends on.
|
|
58
|
+
*
|
|
59
|
+
* `permissions` joined the required set in 4.0.0 and is spelled here deliberately. That WAS such a
|
|
60
|
+
* breaking change, made knowingly and with a migration: policy's `PolicyActorFields` held it, so
|
|
61
|
+
* `userActor({ permissions })` silently dropped it and no builder could spell a direct grant. This
|
|
62
|
+
* pin is what makes the next one impossible to add by accident — a new required member fails here
|
|
63
|
+
* before it fails in an app.
|
|
58
64
|
*/
|
|
59
65
|
type _ActorWithoutFactsIsStillAnActor = Assert<
|
|
60
66
|
[
|
|
@@ -63,6 +69,7 @@ type _ActorWithoutFactsIsStillAnActor = Assert<
|
|
|
63
69
|
readonly id: string;
|
|
64
70
|
readonly roles: readonly string[];
|
|
65
71
|
readonly scopes: readonly string[];
|
|
72
|
+
readonly permissions: readonly string[];
|
|
66
73
|
},
|
|
67
74
|
] extends [Actor]
|
|
68
75
|
? true
|