@ultimat3/core 20.2.1 → 22.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 +189 -523
- package/README.md +62 -1
- package/package.json +6 -3
- package/src/actor.ts +14 -0
- package/src/address-class.ts +143 -0
- package/src/async-state.ts +19 -0
- package/src/canonical-json.ts +24 -1
- package/src/client-dispatch.ts +163 -0
- package/src/client-flight.ts +22 -3
- package/src/client-paths.ts +79 -0
- package/src/client-problem.ts +94 -0
- package/src/client-scope-error.ts +18 -0
- package/src/client-scope.ts +59 -0
- package/src/client-transport.ts +86 -0
- package/src/config-count.ts +19 -0
- package/src/config-fixes.ts +23 -0
- package/src/config-merge.ts +36 -0
- package/src/config.ts +122 -65
- package/src/conflict-policy.ts +48 -0
- package/src/context.ts +12 -1
- package/src/core-error-codes.ts +78 -0
- package/src/dev-secrets.ts +45 -0
- package/src/error-codes.ts +17 -66
- package/src/error-retry.ts +3 -0
- package/src/exports/error-contract.ts +2 -2
- package/src/exports/secrets.ts +1 -0
- package/src/generation-fence.ts +9 -2
- package/src/host-rules.ts +71 -0
- package/src/image/exif-orientation.ts +40 -0
- package/src/image/probe.ts +13 -1
- package/src/in-process-fetch.ts +39 -0
- package/src/index.ts +80 -30
- package/src/iso-date.ts +5 -0
- package/src/lifecycle-grace.ts +44 -0
- package/src/lifecycle-signals.ts +35 -0
- package/src/lifecycle.ts +44 -36
- package/src/logger.ts +22 -3
- package/src/measurement-actor.ts +52 -0
- package/src/metrics-text.ts +10 -2
- package/src/otlp-metric-exporter.ts +39 -12
- package/src/otlp-span-exporter.ts +38 -15
- package/src/outbound-headers.ts +16 -0
- package/src/outbox-drain.ts +14 -0
- package/src/page-meta.ts +41 -0
- package/src/page.ts +52 -0
- package/src/pending-records.ts +58 -0
- package/src/record-envelope-openapi.ts +41 -0
- package/src/record-envelope.ts +98 -0
- package/src/record-sink.ts +129 -0
- package/src/schema-error-codes.ts +1 -1
- package/src/secrets-errors.ts +1 -1
- package/src/secrets-store.ts +51 -5
- package/src/service.ts +9 -0
- package/src/source-mask.ts +30 -0
- package/src/telemetry.ts +3 -0
- package/src/type-pins.ts +9 -0
- package/src/write-digest.ts +29 -0
- package/src/write-origin.ts +31 -0
- package/src/result.ts +0 -78
package/src/config.ts
CHANGED
|
@@ -7,15 +7,24 @@ import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
|
|
|
7
7
|
// them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
|
|
8
8
|
// drift into two vocabularies with no map between them (issue #293).
|
|
9
9
|
import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
|
|
10
|
+
import { countIssue } from './config-count';
|
|
11
|
+
import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
|
|
12
|
+
import { type Input, lastSaid, layered } from './config-merge';
|
|
10
13
|
import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
|
|
11
14
|
import { PWA_FIX, pwaIssues } from './config-pwa';
|
|
12
15
|
import { describeValue } from './error-render';
|
|
13
16
|
import { ConfigInvalidError } from './errors';
|
|
17
|
+
import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
|
|
14
18
|
import { ROLES, type Role } from './roles';
|
|
15
19
|
import { isIanaZoneName } from './time-zone-name';
|
|
16
20
|
|
|
17
21
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
18
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The buses `@ultimat3/realtime`'s `selectTransport` builds, and nothing else. `'redis'` was in
|
|
24
|
+
* this union until 22.0.0 with no Redis transport anywhere: it booted whatever `NATS_URL` chose.
|
|
25
|
+
*/
|
|
26
|
+
export const REALTIME_TRANSPORTS = ['memory', 'nats'] as const;
|
|
27
|
+
export type RealtimeTransport = (typeof REALTIME_TRANSPORTS)[number];
|
|
19
28
|
|
|
20
29
|
export interface ThemeConfig {
|
|
21
30
|
readonly defaultMode: ThemeMode;
|
|
@@ -172,6 +181,20 @@ export interface AiConfig {
|
|
|
172
181
|
readonly mcp: McpConfig;
|
|
173
182
|
}
|
|
174
183
|
|
|
184
|
+
/**
|
|
185
|
+
* How a SIGTERM'd process leaves the load balancer. Read by `@ultimat3/http`'s `createServer`
|
|
186
|
+
* (`ServerOptions.drain`), which hands it to core's `configureLifecycle`.
|
|
187
|
+
*/
|
|
188
|
+
export interface DrainConfig {
|
|
189
|
+
/**
|
|
190
|
+
* `/readyz` answers 503 for this long before the listener closes, so endpoints stop routing here
|
|
191
|
+
* first. Default 0 in development/test and 5000 everywhere else — a process naming NO environment
|
|
192
|
+
* included. A whole number, 0–60000. The chart's `terminationGracePeriodSeconds` must exceed it
|
|
193
|
+
* plus the drain budget.
|
|
194
|
+
*/
|
|
195
|
+
readonly readinessGraceMs: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
175
198
|
export interface AppConfig {
|
|
176
199
|
readonly name: string;
|
|
177
200
|
readonly locales: readonly string[];
|
|
@@ -188,10 +211,9 @@ export interface AppConfig {
|
|
|
188
211
|
readonly realtime: RealtimeConfig;
|
|
189
212
|
readonly notify: NotifyConfig;
|
|
190
213
|
readonly ai: AiConfig;
|
|
214
|
+
readonly drain: DrainConfig;
|
|
191
215
|
}
|
|
192
216
|
|
|
193
|
-
type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
|
|
194
|
-
|
|
195
217
|
/** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
|
|
196
218
|
export interface AiConfigInput {
|
|
197
219
|
readonly mcp?: Input<McpConfig> | undefined;
|
|
@@ -224,24 +246,12 @@ export interface AppConfigInput {
|
|
|
224
246
|
readonly realtime?: Input<RealtimeConfig> | undefined;
|
|
225
247
|
readonly notify?: Input<NotifyConfig> | undefined;
|
|
226
248
|
readonly ai?: AiConfigInput | undefined;
|
|
249
|
+
readonly drain?: Input<DrainConfig> | undefined;
|
|
227
250
|
}
|
|
228
251
|
|
|
229
252
|
/** An overlay from `config/<concern>.ts`. No `name` — the base owns it. */
|
|
230
253
|
export type AppConfigOverlay = Omit<AppConfigInput, 'name'> & { readonly name?: string };
|
|
231
254
|
|
|
232
|
-
/**
|
|
233
|
-
* Apply a partial section over its defaults. Explicit `undefined` never wins — that is what
|
|
234
|
-
* makes every config field deeply optional without `exactOptionalPropertyTypes` fighting back.
|
|
235
|
-
*/
|
|
236
|
-
function section<T extends object>(base: T, patch: Input<T> | undefined): T {
|
|
237
|
-
if (patch === undefined) return base;
|
|
238
|
-
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
239
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
240
|
-
if (value !== undefined) out[key] = value;
|
|
241
|
-
}
|
|
242
|
-
return out as T;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
255
|
const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
246
256
|
|
|
247
257
|
/**
|
|
@@ -287,32 +297,16 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
|
|
|
287
297
|
backoff: 'exponential',
|
|
288
298
|
visibilityTimeoutMs: 30_000,
|
|
289
299
|
},
|
|
290
|
-
|
|
300
|
+
// ON by default since 22.0.0, when the boot began obeying the key: an app with no section
|
|
301
|
+
// keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
|
|
302
|
+
realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
|
|
291
303
|
notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
|
|
292
304
|
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
305
|
+
// Read from the process env when the config is DEFINED — the same env the drain will run in.
|
|
306
|
+
drain: { readinessGraceMs: defaultReadinessGraceMs() },
|
|
293
307
|
};
|
|
294
308
|
}
|
|
295
309
|
|
|
296
|
-
const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
|
|
300
|
-
* spelling to write, and the two refused classes have different remedies — a single-label legacy
|
|
301
|
-
* name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
|
|
302
|
-
* no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
|
|
303
|
-
* two refuse the same strings and an operator may meet either first.
|
|
304
|
-
*/
|
|
305
|
-
const TIMEZONE_FIX =
|
|
306
|
-
"set defaultTimeZone to an Area/Location name, or UTC — list every accepted one with bun -e \"console.log(Intl.supportedValuesOf('timeZone').join('\\n'))\" — where a legacy single-label name swaps mechanically (Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC), while an abbreviation or numeric offset (CET, EST5EDT, +01:00) carries no DST rule and has no replacement, so name the city whose clock you mean (Europe/Paris, America/New_York)";
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Appended only when a tier name is what failed, and it names the rename rather than the rule: the
|
|
310
|
-
* three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
|
|
311
|
-
* replacement while `isr` has none — it is a `RenderMode`, and no cache tier ever served it.
|
|
312
|
-
*/
|
|
313
|
-
const CACHE_TIER_FIX =
|
|
314
|
-
"in app.config.ts, rewrite cache.tiers with the rung names the ladder serves — request-memo, lru, redis, cdn — where memo becomes request-memo and shared becomes redis, and isr is dropped: it is a render mode, so move it to render: 'isr' on the routes that want it";
|
|
315
|
-
|
|
316
310
|
function validate(config: AppConfig): void {
|
|
317
311
|
const issues: string[] = [];
|
|
318
312
|
// Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
|
|
@@ -346,10 +340,25 @@ function validate(config: AppConfig): void {
|
|
|
346
340
|
issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
|
|
347
341
|
}
|
|
348
342
|
if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
|
|
349
|
-
|
|
343
|
+
// A domain per numeric key, never a bare `< 1`: every comparison with `NaN` is false, so the old
|
|
344
|
+
// `concurrency < 1` passed `NaN`, `2.5` and `Infinity`, and nothing screened the other three.
|
|
345
|
+
const counts: readonly (string | undefined)[] = [
|
|
346
|
+
countIssue('jobs.concurrency', config.jobs.concurrency, 1),
|
|
347
|
+
countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
|
|
348
|
+
countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
|
|
349
|
+
countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
|
|
350
|
+
readinessGraceIssue(config.drain.readinessGraceMs),
|
|
351
|
+
];
|
|
352
|
+
for (const issue of counts) if (issue !== undefined) issues.push(issue);
|
|
350
353
|
if (config.jobs.queues.length === 0) issues.push('jobs.queues must list at least one queue');
|
|
351
|
-
|
|
352
|
-
|
|
354
|
+
// An untyped config reaches here with whatever it wrote: a string is a name worth echoing, and
|
|
355
|
+
// anything else goes through `describeValue` rather than `${…}`.
|
|
356
|
+
const transport: unknown = config.realtime.transport;
|
|
357
|
+
if (!REALTIME_TRANSPORTS.some((known) => known === transport)) {
|
|
358
|
+
const said = typeof transport === 'string' ? `"${transport}"` : describeValue(transport);
|
|
359
|
+
issues.push(`realtime.transport ${said} is not one of ${REALTIME_TRANSPORTS.join(', ')}`);
|
|
360
|
+
} else if (transport === 'nats' && config.realtime.urlEnv === undefined) {
|
|
361
|
+
issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
|
|
353
362
|
}
|
|
354
363
|
// BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
|
|
355
364
|
// only other legal one is a positive, finite count of milliseconds. Zero is refused rather than
|
|
@@ -401,35 +410,83 @@ export function defineConfig(
|
|
|
401
410
|
...overlays: readonly AppConfigOverlay[]
|
|
402
411
|
): AppConfig {
|
|
403
412
|
const base = defaults(input.name);
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
const merged: AppConfigInput = Object.assign({}, input, ...overlays, {
|
|
408
|
-
name: input.name,
|
|
409
|
-
}) as AppConfigInput;
|
|
413
|
+
// Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from
|
|
414
|
+
// the input alone because it identifies the app: an overlay may not rename it.
|
|
415
|
+
const layers: readonly AppConfigOverlay[] = [input, ...overlays];
|
|
410
416
|
|
|
411
417
|
const config: AppConfig = {
|
|
412
|
-
name:
|
|
413
|
-
locales:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
418
|
+
name: input.name,
|
|
419
|
+
locales: lastSaid(
|
|
420
|
+
base.locales,
|
|
421
|
+
layers.map((layer) => layer.locales),
|
|
422
|
+
),
|
|
423
|
+
defaultLocale: lastSaid(
|
|
424
|
+
base.defaultLocale,
|
|
425
|
+
layers.map((layer) => layer.defaultLocale),
|
|
426
|
+
),
|
|
427
|
+
defaultTimeZone: lastSaid(
|
|
428
|
+
base.defaultTimeZone,
|
|
429
|
+
layers.map((layer) => layer.defaultTimeZone),
|
|
430
|
+
),
|
|
431
|
+
defaultCurrency: lastSaid(
|
|
432
|
+
base.defaultCurrency,
|
|
433
|
+
layers.map((layer) => layer.defaultCurrency),
|
|
434
|
+
),
|
|
435
|
+
theme: layered(
|
|
436
|
+
base.theme,
|
|
437
|
+
layers.map((layer) => layer.theme),
|
|
438
|
+
),
|
|
439
|
+
auth: layered(
|
|
440
|
+
base.auth,
|
|
441
|
+
layers.map((layer) => layer.auth),
|
|
442
|
+
),
|
|
443
|
+
// Two merges, one per level: the outer one may not see `offline` at all, or it would drop the
|
|
444
|
+
// nested defaults `PwaConfigInput` exists to keep. Hence the cast — the outer patch is this
|
|
445
|
+
// block minus the key the inner merge owns.
|
|
422
446
|
pwa: {
|
|
423
|
-
...
|
|
424
|
-
|
|
447
|
+
...layered(
|
|
448
|
+
base.pwa,
|
|
449
|
+
layers.map((layer) => ({ ...layer.pwa, offline: undefined }) as Input<PwaConfig>),
|
|
450
|
+
),
|
|
451
|
+
offline: layered(
|
|
452
|
+
base.pwa.offline,
|
|
453
|
+
layers.map((layer) => layer.pwa?.offline),
|
|
454
|
+
),
|
|
455
|
+
},
|
|
456
|
+
roles: lastSaid(
|
|
457
|
+
base.roles,
|
|
458
|
+
layers.map((layer) => layer.roles),
|
|
459
|
+
),
|
|
460
|
+
database: layered(
|
|
461
|
+
base.database,
|
|
462
|
+
layers.map((layer) => layer.database),
|
|
463
|
+
),
|
|
464
|
+
cache: layered(
|
|
465
|
+
base.cache,
|
|
466
|
+
layers.map((layer) => layer.cache),
|
|
467
|
+
),
|
|
468
|
+
jobs: layered(
|
|
469
|
+
base.jobs,
|
|
470
|
+
layers.map((layer) => layer.jobs),
|
|
471
|
+
),
|
|
472
|
+
realtime: layered(
|
|
473
|
+
base.realtime,
|
|
474
|
+
layers.map((layer) => layer.realtime),
|
|
475
|
+
),
|
|
476
|
+
notify: layered(
|
|
477
|
+
base.notify,
|
|
478
|
+
layers.map((layer) => layer.notify),
|
|
479
|
+
),
|
|
480
|
+
ai: {
|
|
481
|
+
mcp: layered(
|
|
482
|
+
base.ai.mcp,
|
|
483
|
+
layers.map((layer) => layer.ai?.mcp),
|
|
484
|
+
),
|
|
425
485
|
},
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
realtime: section(base.realtime, merged.realtime),
|
|
431
|
-
notify: section(base.notify, merged.notify),
|
|
432
|
-
ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
|
|
486
|
+
drain: layered(
|
|
487
|
+
base.drain,
|
|
488
|
+
layers.map((layer) => layer.drain),
|
|
489
|
+
),
|
|
433
490
|
};
|
|
434
491
|
|
|
435
492
|
validate(config);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE conflict vocabulary: which row survives when an optimistic local write and the server's
|
|
3
|
+
* answer disagree. Row-shaped because the client store is — a merge over a mutator's OUTPUT had
|
|
4
|
+
* nowhere to land, and realtime's rebase silently dropped it. Tier 0 so `action` and `realtime`
|
|
5
|
+
* (both tier 3) name the same type.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One record as the client store holds it: a JSON object, keyed by field name. */
|
|
9
|
+
export type Row = Readonly<Record<string, unknown>>;
|
|
10
|
+
|
|
11
|
+
export type ConflictPolicy =
|
|
12
|
+
| 'server-wins'
|
|
13
|
+
| 'last-write-wins'
|
|
14
|
+
| { readonly kind: 'custom'; readonly merge: (local: Row, server: Row) => Row };
|
|
15
|
+
|
|
16
|
+
export interface ResolveConflictOptions {
|
|
17
|
+
/**
|
|
18
|
+
* The field `last-write-wins` compares — a finite number (epoch ms) the SERVER wrote. Default
|
|
19
|
+
* `updatedAt`, the name realtime's rebase has always read.
|
|
20
|
+
*/
|
|
21
|
+
readonly clockField?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The surviving row. `last-write-wins` keeps the local row only when its clock is provably newer
|
|
26
|
+
* by the server's own field; a missing or non-numeric clock on either side is no proof, so the
|
|
27
|
+
* server's row stands — the store must never keep a guess over an answer.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveConflict(
|
|
30
|
+
policy: ConflictPolicy,
|
|
31
|
+
local: Row,
|
|
32
|
+
server: Row,
|
|
33
|
+
options: ResolveConflictOptions = {},
|
|
34
|
+
): Row {
|
|
35
|
+
if (typeof policy !== 'string') return policy.merge(local, server);
|
|
36
|
+
if (policy === 'server-wins') return server;
|
|
37
|
+
const field = options.clockField ?? 'updatedAt';
|
|
38
|
+
const localAt = clockOf(local, field);
|
|
39
|
+
const serverAt = clockOf(server, field);
|
|
40
|
+
return localAt !== undefined && serverAt !== undefined && localAt > serverAt ? local : server;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clockOf(row: Row, field: string): number | undefined {
|
|
44
|
+
// Own keys only: a field named `constructor` must not read `Object.prototype`'s.
|
|
45
|
+
if (!Object.hasOwn(row, field)) return undefined;
|
|
46
|
+
const value = row[field];
|
|
47
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
48
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { UltimateError } from './errors';
|
|
|
37
37
|
import { finiteOption } from './finite-option';
|
|
38
38
|
import { traceId as newTraceId, uuid } from './ids';
|
|
39
39
|
import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
|
|
40
|
+
import { installTraceHeaders } from './outbound-headers';
|
|
40
41
|
import { type Role, resolveRole } from './roles';
|
|
41
42
|
import { installedServices, isManagedService } from './service';
|
|
42
43
|
|
|
@@ -130,6 +131,13 @@ export interface CtxInit {
|
|
|
130
131
|
/** Epoch ms. `@ultimat3/http`'s `startDeadline` is the one production writer. */
|
|
131
132
|
readonly deadlineAt?: number | undefined;
|
|
132
133
|
readonly services?: ServiceBag | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* `false` installs no `defineService` factory — only `services` — and leaves the registered
|
|
136
|
+
* ones to the caller. `@ultimat3/http` is that caller: its context exists before the `auth`
|
|
137
|
+
* stage names the actor, and a service built here would act as anonymous for the whole request.
|
|
138
|
+
* Default `true`.
|
|
139
|
+
*/
|
|
140
|
+
readonly installServices?: boolean | undefined;
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
/**
|
|
@@ -184,7 +192,8 @@ export function createContext(init: CtxInit = {}): Ctx {
|
|
|
184
192
|
// wins over an auto-installed one of the same name — a test's hand-built mock overrides the
|
|
185
193
|
// real thing on purpose.
|
|
186
194
|
const preview: CtxFacts = Object.freeze({ ...explicit, ...fields, services: explicit });
|
|
187
|
-
const
|
|
195
|
+
const installed = init.installServices === false ? {} : installedServices(preview);
|
|
196
|
+
const services: ServiceBag = Object.freeze({ ...installed, ...explicit });
|
|
188
197
|
const ctx = {
|
|
189
198
|
// Services ride ON the context, not only under `ctx.services`: `CtxServices` exists to be
|
|
190
199
|
// augmented, so `ctx.posts` has to BE the service. Spread first, so a service that collides
|
|
@@ -208,6 +217,8 @@ export function createContext(init: CtxInit = {}): Ctx {
|
|
|
208
217
|
}
|
|
209
218
|
|
|
210
219
|
export function runWithContext<T>(ctx: Ctx, fn: () => T): T {
|
|
220
|
+
// A request scope is what gives an outbound typed call a budget to forward; see the module.
|
|
221
|
+
installTraceHeaders();
|
|
211
222
|
return requestContext.run(ctx, fn);
|
|
212
223
|
}
|
|
213
224
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Single responsibility: core's OWN error-code titles, registered at import. A side-effect
|
|
2
|
+
// anchor, bare-imported by the barrel — the same shape as `schema-error-codes.ts` — so the table
|
|
3
|
+
// rides every barrel import and none of the light paths: `UltimateError` alone no longer carries
|
|
4
|
+
// 40 titles into a browser island that throws one code. Listed in `SIDE_EFFECTS_ANCHORS`.
|
|
5
|
+
|
|
6
|
+
import type { ErrorCodeDescriptor } from './error-codes';
|
|
7
|
+
import { descriptor, registerCoreErrorCodes } from './error-codes';
|
|
8
|
+
|
|
9
|
+
/** Codes owned by `@ultimat3/core`. Every other package calls `registerErrorCodes()`. */
|
|
10
|
+
const CORE_CODE_TITLES = {
|
|
11
|
+
X_ABORTED: 'operation aborted',
|
|
12
|
+
X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable',
|
|
13
|
+
// The browser seam's three (`client-transport.ts`, `record-envelope.ts`, `client-scope.ts`).
|
|
14
|
+
X_CLIENT_RECORD_ENVELOPE_INVALID: 'a response marked as a records envelope has the wrong shape',
|
|
15
|
+
X_CLIENT_SCOPE_CHANGED: 'the page changed principal while this request was in flight',
|
|
16
|
+
X_CLIENT_TRANSPORT_FAILED: 'a browser request got no answer from the app',
|
|
17
|
+
X_CONFIG_INVALID: 'app.config.ts is invalid',
|
|
18
|
+
X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
|
|
19
|
+
// `x doctor` reports it; `assertNoDevSecretsOutsideLocal()` throws it at boot.
|
|
20
|
+
X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
|
|
21
|
+
X_DRAINING: 'process is draining and refuses new work',
|
|
22
|
+
X_ENV_EXAMPLE_DRIFT: '.env.example does not declare every variable the schema requires',
|
|
23
|
+
X_ENV_MISSING: 'required environment variables are missing or invalid',
|
|
24
|
+
X_ENVIRONMENT_INVALID: 'ULTIMATE_ENV is not a known environment',
|
|
25
|
+
X_ERROR_CODE_DUPLICATE: 'error code registered twice',
|
|
26
|
+
X_ERROR_REPORTER_DSN_INVALID: 'the error monitor DSN is malformed',
|
|
27
|
+
X_ERROR_RETRY_INVALID: 'error retry classification is unknown or already claimed',
|
|
28
|
+
// Core's own, and deliberately NOT `@ultimat3/http`'s `X_OVERLOADED`: that code is owned by a
|
|
29
|
+
// tier-2 package, and a tier-0 gate borrowing it upward is an import core may not make. The two
|
|
30
|
+
// read alike to an operator and the fix lines say which ceiling to widen.
|
|
31
|
+
X_FLIGHT_GATE_OVERLOADED: 'a concurrency gate is at its ceiling and its queue is full',
|
|
32
|
+
X_ID_INVALID: 'value is not a valid id',
|
|
33
|
+
X_IMAGE_DECODE_FAILED: 'image bytes are malformed, truncated or internally inconsistent',
|
|
34
|
+
X_IMAGE_TOO_LARGE: 'image exceeds the pipeline pixel ceiling',
|
|
35
|
+
X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
|
|
36
|
+
X_INTERNAL: 'unexpected internal framework error',
|
|
37
|
+
X_INVARIANT: 'invariant violated',
|
|
38
|
+
// Owned here rather than by `@ultimat3/time`, which declared it until 16.x: `@ultimat3/money`
|
|
39
|
+
// needs the same screen and tier 1 may not import sideways. One code, one declaration.
|
|
40
|
+
X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
|
|
41
|
+
X_METRIC_CARDINALITY:
|
|
42
|
+
'a metric exceeded its series ceiling and is folding into one overflow series',
|
|
43
|
+
X_METRIC_NAME_INVALID:
|
|
44
|
+
'metric name is malformed, or redeclared with a different kind, bounds or observer',
|
|
45
|
+
X_METRIC_VALUE_INVALID: 'metric value is not recordable',
|
|
46
|
+
X_NO_CONTEXT: 'no request context is active',
|
|
47
|
+
X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
|
|
48
|
+
X_OTLP_ENDPOINT_INVALID: 'the OTLP collector endpoint is missing or malformed',
|
|
49
|
+
// Its own code rather than the endpoint's, because a title is what an agent reads first:
|
|
50
|
+
// `x errors explain X_OTLP_ENDPOINT_INVALID` would send it to inspect a variable that is fine.
|
|
51
|
+
X_OTLP_HEADERS_INVALID: 'OTEL_EXPORTER_OTLP_HEADERS is malformed',
|
|
52
|
+
X_OTLP_PROTOCOL_UNSUPPORTED: 'the OTLP protocol requested is not OTLP/HTTP JSON',
|
|
53
|
+
X_READINESS_CHECK_DUPLICATE: 'a readiness check name is registered twice',
|
|
54
|
+
X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
|
|
55
|
+
X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
|
|
56
|
+
X_ROLE_INVALID: 'ROLE is not a known runtime role',
|
|
57
|
+
X_SERVICE_DUPLICATE: 'a service name is registered twice',
|
|
58
|
+
X_SERVICE_MISSING: 'service is not registered on the request context',
|
|
59
|
+
X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
|
|
60
|
+
X_SUPERSEDED: 'a later generation superseded this work',
|
|
61
|
+
X_TELEMETRY_SAMPLER_ARG_INVALID: 'the trace sampling ratio is not a number between 0 and 1',
|
|
62
|
+
// Core's, though core does not throw it — the twin of `X_ABORTED`, and `@ultimat3/http` already
|
|
63
|
+
// calls it "borrowed (core's concept)" in `HTTP_BORROWED_ERROR_CODES`. A deadline that expired
|
|
64
|
+
// and a caller that went away are one pair of facts, so they are titled and classified in one
|
|
65
|
+
// place rather than by whichever package happened to raise one first.
|
|
66
|
+
X_TIMEOUT: 'operation exceeded its deadline',
|
|
67
|
+
X_UNREACHABLE: 'unreachable branch was reached',
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
export type CoreErrorCode = keyof typeof CORE_CODE_TITLES;
|
|
71
|
+
|
|
72
|
+
export const CORE_ERROR_CODES: Readonly<Record<CoreErrorCode, ErrorCodeDescriptor>> = Object.freeze(
|
|
73
|
+
Object.fromEntries(
|
|
74
|
+
Object.entries(CORE_CODE_TITLES).map(([code, title]) => [code, descriptor({ title })]),
|
|
75
|
+
) as Record<CoreErrorCode, ErrorCodeDescriptor>,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
registerCoreErrorCodes(CORE_ERROR_CODES);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Single responsibility: the boot refusal of a shipped development signing secret outside a local
|
|
2
|
+
// environment. `x doctor` reported it; nothing failed — so a production pod that forgot
|
|
3
|
+
// ULTIMATE_CURSOR_SECRET signed every cursor with a key published in this package.
|
|
4
|
+
|
|
5
|
+
import { usesDevCursorSecret } from './cursor';
|
|
6
|
+
import { isLocal } from './environment';
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `X_CURSOR_SECRET_DEV`, the code `x doctor` already reports for this key — one condition, one code.
|
|
11
|
+
* Doctor warns; this refuses the boot.
|
|
12
|
+
*/
|
|
13
|
+
export class CursorSecretDevError extends UltimateError {
|
|
14
|
+
static readonly code = 'X_CURSOR_SECRET_DEV';
|
|
15
|
+
override readonly name = 'CursorSecretDevError';
|
|
16
|
+
|
|
17
|
+
// One secret today, so the fix is a literal: a spliced name would be a value in a pasted command.
|
|
18
|
+
constructor() {
|
|
19
|
+
super({
|
|
20
|
+
code: CursorSecretDevError.code,
|
|
21
|
+
cause:
|
|
22
|
+
'ULTIMATE_CURSOR_SECRET is unset, so this process signs cursors with the development key the framework ships — anyone can forge a page position',
|
|
23
|
+
fix: "x secrets set ULTIMATE_CURSOR_SECRET — or export ULTIMATE_CURSOR_SECRET from the platform's secret store",
|
|
24
|
+
meta: { variable: 'ULTIMATE_CURSOR_SECRET' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DevSecretsOptions {
|
|
30
|
+
/** Which environment the boot is — defaults to `process.env`. */
|
|
31
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Throws when a shipped dev secret is in use and the environment is not `development`/`test`.
|
|
36
|
+
*
|
|
37
|
+
* FAILS CLOSED: a process with neither `ULTIMATE_ENV` nor `NODE_ENV` resolves as `production`
|
|
38
|
+
* here, the answer `templates/scaffold-auth.ts` already gives — `isLocal()`'s own fallback is
|
|
39
|
+
* `development`, which is exactly the process that forgot to say. The secret itself is read where
|
|
40
|
+
* signing reads it, so this refuses what the process WILL sign with, not what `env` claims.
|
|
41
|
+
*/
|
|
42
|
+
export function assertNoDevSecretsOutsideLocal(options: DevSecretsOptions = {}): void {
|
|
43
|
+
if (isLocal({ env: options.env, fallback: 'production' })) return;
|
|
44
|
+
if (usesDevCursorSecret()) throw new CursorSecretDevError();
|
|
45
|
+
}
|
package/src/error-codes.ts
CHANGED
|
@@ -28,75 +28,26 @@ export interface ErrorCodeEntry extends ErrorCodeDescriptor {
|
|
|
28
28
|
*/
|
|
29
29
|
export const ERROR_DOCS_URL = 'https://github.com/developerz-ai/ultimate/wiki/Error-Codes';
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
const CORE_CODE_TITLES = {
|
|
33
|
-
X_ABORTED: 'operation aborted',
|
|
34
|
-
X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable',
|
|
35
|
-
X_CONFIG_INVALID: 'app.config.ts is invalid',
|
|
36
|
-
X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
|
|
37
|
-
X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
|
|
38
|
-
X_DRAINING: 'process is draining and refuses new work',
|
|
39
|
-
X_ENV_EXAMPLE_DRIFT: '.env.example does not declare every variable the schema requires',
|
|
40
|
-
X_ENV_MISSING: 'required environment variables are missing or invalid',
|
|
41
|
-
X_ENVIRONMENT_INVALID: 'ULTIMATE_ENV is not a known environment',
|
|
42
|
-
X_ERROR_CODE_DUPLICATE: 'error code registered twice',
|
|
43
|
-
X_ERROR_REPORTER_DSN_INVALID: 'the error monitor DSN is malformed',
|
|
44
|
-
X_ERROR_RETRY_INVALID: 'error retry classification is unknown or already claimed',
|
|
45
|
-
// Core's own, and deliberately NOT `@ultimat3/http`'s `X_OVERLOADED`: that code is owned by a
|
|
46
|
-
// tier-2 package, and a tier-0 gate borrowing it upward is an import core may not make. The two
|
|
47
|
-
// read alike to an operator and the fix lines say which ceiling to widen.
|
|
48
|
-
X_FLIGHT_GATE_OVERLOADED: 'a concurrency gate is at its ceiling and its queue is full',
|
|
49
|
-
X_ID_INVALID: 'value is not a valid id',
|
|
50
|
-
X_IMAGE_DECODE_FAILED: 'image bytes are malformed, truncated or internally inconsistent',
|
|
51
|
-
X_IMAGE_TOO_LARGE: 'image exceeds the pipeline pixel ceiling',
|
|
52
|
-
X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
|
|
53
|
-
X_INTERNAL: 'unexpected internal framework error',
|
|
54
|
-
X_INVARIANT: 'invariant violated',
|
|
55
|
-
// Owned here rather than by `@ultimat3/time`, which declared it until 16.x: `@ultimat3/money`
|
|
56
|
-
// needs the same screen and tier 1 may not import sideways. One code, one declaration.
|
|
57
|
-
X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
|
|
58
|
-
X_METRIC_CARDINALITY:
|
|
59
|
-
'a metric exceeded its series ceiling and is folding into one overflow series',
|
|
60
|
-
X_METRIC_NAME_INVALID:
|
|
61
|
-
'metric name is malformed, or redeclared with a different kind, bounds or observer',
|
|
62
|
-
X_METRIC_VALUE_INVALID: 'metric value is not recordable',
|
|
63
|
-
X_NO_CONTEXT: 'no request context is active',
|
|
64
|
-
X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
|
|
65
|
-
X_OTLP_ENDPOINT_INVALID: 'the OTLP collector endpoint is missing or malformed',
|
|
66
|
-
// Its own code rather than the endpoint's, because a title is what an agent reads first:
|
|
67
|
-
// `x errors explain X_OTLP_ENDPOINT_INVALID` would send it to inspect a variable that is fine.
|
|
68
|
-
X_OTLP_HEADERS_INVALID: 'OTEL_EXPORTER_OTLP_HEADERS is malformed',
|
|
69
|
-
X_OTLP_PROTOCOL_UNSUPPORTED: 'the OTLP protocol requested is not OTLP/HTTP JSON',
|
|
70
|
-
X_READINESS_CHECK_DUPLICATE: 'a readiness check name is registered twice',
|
|
71
|
-
X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
|
|
72
|
-
X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
|
|
73
|
-
X_ROLE_INVALID: 'ROLE is not a known runtime role',
|
|
74
|
-
X_SERVICE_DUPLICATE: 'a service name is registered twice',
|
|
75
|
-
X_SERVICE_MISSING: 'service is not registered on the request context',
|
|
76
|
-
X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
|
|
77
|
-
X_SUPERSEDED: 'a later generation superseded this work',
|
|
78
|
-
X_TELEMETRY_SAMPLER_ARG_INVALID: 'the trace sampling ratio is not a number between 0 and 1',
|
|
79
|
-
// Core's, though core does not throw it — the twin of `X_ABORTED`, and `@ultimat3/http` already
|
|
80
|
-
// calls it "borrowed (core's concept)" in `HTTP_BORROWED_ERROR_CODES`. A deadline that expired
|
|
81
|
-
// and a caller that went away are one pair of facts, so they are titled and classified in one
|
|
82
|
-
// place rather than by whichever package happened to raise one first.
|
|
83
|
-
X_TIMEOUT: 'operation exceeded its deadline',
|
|
84
|
-
X_UNREACHABLE: 'unreachable branch was reached',
|
|
85
|
-
} as const;
|
|
86
|
-
|
|
87
|
-
export type CoreErrorCode = keyof typeof CORE_CODE_TITLES;
|
|
88
|
-
|
|
89
|
-
function descriptor(declaration: ErrorCodeDeclaration): ErrorCodeDescriptor {
|
|
31
|
+
export function descriptor(declaration: ErrorCodeDeclaration): ErrorCodeDescriptor {
|
|
90
32
|
return Object.freeze({ title: declaration.title, docs: declaration.docs ?? ERROR_DOCS_URL });
|
|
91
33
|
}
|
|
92
34
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Empty at load, on purpose: core's own titles live in `core-error-codes.ts`, which the barrel
|
|
37
|
+
* bare-imports as a side-effect anchor. A browser module that constructs an `UltimateError` from a
|
|
38
|
+
* light path pays for the class and this lookup, not a 40-row table — an untitled code renders
|
|
39
|
+
* through `humanize`, the trade `@ultimat3/realtime` already made for its own titles.
|
|
40
|
+
*/
|
|
41
|
+
const registry = new Map<string, ErrorCodeDescriptor>();
|
|
98
42
|
|
|
99
|
-
|
|
43
|
+
/** What `resetErrorCodes` restores: the codes `registerCoreErrorCodes` installed. */
|
|
44
|
+
const coreCodes = new Map<string, ErrorCodeDescriptor>();
|
|
45
|
+
|
|
46
|
+
/** `core-error-codes.ts`'s one call. Registered like any package's, and remembered for a reset. */
|
|
47
|
+
export function registerCoreErrorCodes(codes: Readonly<Record<string, ErrorCodeDescriptor>>): void {
|
|
48
|
+
registerErrorCodes(codes);
|
|
49
|
+
for (const [code, value] of Object.entries(codes)) coreCodes.set(code, value);
|
|
50
|
+
}
|
|
100
51
|
|
|
101
52
|
/**
|
|
102
53
|
* Register a package's codes. Throws `X_ERROR_CODE_DUPLICATE` on collision so two packages
|
|
@@ -145,7 +96,7 @@ export function listErrorCodes(): readonly ErrorCodeEntry[] {
|
|
|
145
96
|
/** Test-only: drop everything a package registered, keeping core's codes. */
|
|
146
97
|
export function resetErrorCodes(): void {
|
|
147
98
|
registry.clear();
|
|
148
|
-
for (const [code, value] of
|
|
99
|
+
for (const [code, value] of coreCodes) registry.set(code, value);
|
|
149
100
|
}
|
|
150
101
|
|
|
151
102
|
/**
|
package/src/error-retry.ts
CHANGED
|
@@ -58,6 +58,9 @@ const CORE_ERROR_RETRY: ReadonlyMap<string, ErrorRetry> = new Map(
|
|
|
58
58
|
// re-run produces the same refusal by construction, so an UNCLASSIFIED reading would spend a
|
|
59
59
|
// job's whole retry policy proving that the world has still moved on.
|
|
60
60
|
X_SUPERSEDED: 'terminal',
|
|
61
|
+
// The principal fence's twin of `X_SUPERSEDED`, listed for the same reason: re-sending a read
|
|
62
|
+
// from the previous principal's scope is refused identically every time.
|
|
63
|
+
X_CLIENT_SCOPE_CHANGED: 'terminal',
|
|
61
64
|
} as const),
|
|
62
65
|
);
|
|
63
66
|
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
// built with, and the retry classification a code carries. One group because a code, its title,
|
|
4
4
|
// its rendering and its retry class are one contract; `index.ts` re-exports every name explicitly.
|
|
5
5
|
|
|
6
|
+
export type { CoreErrorCode } from '../core-error-codes';
|
|
7
|
+
export { CORE_ERROR_CODES } from '../core-error-codes';
|
|
6
8
|
export type {
|
|
7
|
-
CoreErrorCode,
|
|
8
9
|
ErrorCodeDeclaration,
|
|
9
10
|
ErrorCodeDescriptor,
|
|
10
11
|
ErrorCodeEntry,
|
|
11
12
|
} from '../error-codes';
|
|
12
13
|
export {
|
|
13
|
-
CORE_ERROR_CODES,
|
|
14
14
|
describeErrorCode,
|
|
15
15
|
ERROR_DOCS_URL,
|
|
16
16
|
errorCodeSnapshot,
|
package/src/exports/secrets.ts
CHANGED
package/src/generation-fence.ts
CHANGED
|
@@ -51,7 +51,14 @@ function superseded(subject: string, issued: number, current: number): UltimateE
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Whether a caught value is a supersession — this fence's refusal, or `X_CLIENT_SCOPE_CHANGED`,
|
|
56
|
+
* the principal fence's (`client-scope.ts`). One reader for both, because a caller does the same
|
|
57
|
+
* thing with either: drop the answer and render nothing. Never `error.code`.
|
|
58
|
+
*/
|
|
55
59
|
export function isSuperseded(error: unknown): boolean {
|
|
56
|
-
return
|
|
60
|
+
return (
|
|
61
|
+
isUltimateError(error) &&
|
|
62
|
+
(error.code === 'X_SUPERSEDED' || error.code === 'X_CLIENT_SCOPE_CHANGED')
|
|
63
|
+
);
|
|
57
64
|
}
|