@ultimat3/core 11.2.0 → 12.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 CHANGED
@@ -121,6 +121,7 @@ shape against a locally declared sample interface for exactly that reason.
121
121
  | how many at once | `flight-gate.ts` | hand-over on release, refusal past `maxQueued`, injectable `overflow:` refusal |
122
122
  | whether an answer still applies | `generation-fence.ts` | `X_SUPERSEDED` / `isSuperseded`; nothing else in the tree had one |
123
123
  | which HTTP statuses are worth repeating | `retryable-status.ts` | `>= 500` plus 408, 409, 425, 429 — the two byte-identical copies' set |
124
+ | how long this request has left | `request-budget.ts` (`Ctx.deadlineAt`, `REQUEST_TIMEOUT_HEADER`) | `@ultimat3/http`'s `startDeadline` is the one production writer of the instant; `traceHeaders()` is the one writer of the header. It lives here because the READER is tier 2 and the WRITER is a typed client in tier 0, and a second literal for the header name is a propagation that stops working the day either string is edited. A spent budget sends nothing rather than `0` — the far side ignores anything under 1ms and falls back to its own, which is the failure the header exists to prevent, one hop later |
124
125
  | the five above, composed into one typed-client call | `client-flight.ts` + `client-wire.ts` | `@ultimat3/action` and `@ultimat3/query` both project a typed client and are both tier 3, so neither could import the other: it shipped as a byte-identical 288-line + 85-line copy in each, policed by a `client-twin.test.ts` in both. Both packages re-export these names, so their public surface is unchanged. Declares NO code of its own — `X_SUPERSEDED`, `X_TIMEOUT` and `X_FLIGHT_GATE_OVERLOADED` are already here |
125
126
  | is this `unknown` a keyed record? | `json-object.ts` | `isJsonObject`, which was the same three terms in both those packages' `stable.ts`. A `Date` and a class instance PASS: it narrows a shape, it does not certify provenance |
126
127
  | a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one way out, on purpose greppable |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "11.2.0",
3
+ "version": "12.0.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  /**
2
- * What a typed client puts on the wire and reads back off it: the W3C trace header, the
3
- * problem+json body, and the immutable answer one dispatch hands to every caller sharing it.
2
+ * What a typed client puts on the wire and reads back off it: the ambient call context as
3
+ * headers the W3C trace and the remaining request budget the problem+json body, and the
4
+ * immutable answer one dispatch hands to every caller sharing it.
4
5
  *
5
6
  * Tier 0 because `@ultimat3/action` and `@ultimat3/query` need this identical file and are both
6
7
  * tier 3, so neither may import the other — the shape `canonical-json.ts` is already here for.
@@ -11,6 +12,7 @@
11
12
  import type { ErrorRetry } from './error-retry';
12
13
  import { declaredErrorRetry } from './error-retry';
13
14
  import { isJsonObject } from './json-object';
15
+ import { budgetHeaders } from './request-budget';
14
16
  import { isRetryableStatus } from './retryable-status';
15
17
  import { currentSpanContext, traceparent } from './telemetry';
16
18
 
@@ -30,17 +32,31 @@ const TRACE_ID = /^[0-9a-f]{32}$/;
30
32
  const SPAN_ID = /^[0-9a-f]{16}$/;
31
33
 
32
34
  /**
33
- * The current trace, as the W3C header or nothing at all. `currentSpanContext()` answers with
34
- * an empty `spanId` when a request context exists but no span is active, and `00-<trace>--01` is
35
- * a header every collector drops, so an incomplete context sends none. In a browser there is no
36
- * ambient context and this is always empty, which is also what keeps a cross-origin call from
37
- * acquiring a CORS preflight it did not have.
35
+ * Everything this process knows about the call in flight, as headers: the W3C trace, and how much
36
+ * of the request budget is left. Both callers (`@ultimat3/action`'s `postOnce` and
37
+ * `@ultimat3/query`'s reader) spread it BEFORE the caller's own headers, so an explicit value
38
+ * still wins.
39
+ *
40
+ * The trace half: `currentSpanContext()` answers with an empty `spanId` when a request context
41
+ * exists but no span is active, and `00-<trace>--01` is a header every collector drops, so an
42
+ * incomplete context sends none. In a browser there is no ambient context and this is always
43
+ * empty, which is also what keeps a cross-origin call from acquiring a CORS preflight it did not
44
+ * have.
45
+ *
46
+ * The budget half is independent of it, and that is deliberate: a deadline must propagate through
47
+ * a hop that is not being traced. `budgetHeaders()` answers `{}` outside a request and for a
48
+ * context with no deadline, so a browser and a job are unchanged — the header only appears where
49
+ * something really is waiting on a socket.
50
+ *
51
+ * The name is the trace half's alone for one reason: renaming it means editing
52
+ * `packages/{action,query}/src/client.ts`, and both spell it as a value import.
38
53
  */
39
54
  export function traceHeaders(): Record<string, string> {
55
+ const budget = budgetHeaders();
40
56
  const context = currentSpanContext();
41
- if (context === undefined) return {};
42
- if (!TRACE_ID.test(context.traceId) || !SPAN_ID.test(context.spanId)) return {};
43
- return { traceparent: traceparent(context) };
57
+ if (context === undefined) return budget;
58
+ if (!TRACE_ID.test(context.traceId) || !SPAN_ID.test(context.spanId)) return budget;
59
+ return { ...budget, traceparent: traceparent(context) };
44
60
  }
45
61
 
46
62
  /**
package/src/context.ts CHANGED
@@ -52,6 +52,16 @@ export interface Ctx extends CtxServices {
52
52
  now(): Date;
53
53
  readonly logger: Logger;
54
54
  readonly signal: AbortSignal;
55
+ /**
56
+ * Epoch milliseconds this request's budget runs out at, or `null` when nothing set one.
57
+ *
58
+ * The value BEHIND `signal`, published as a number because a signal can only say "already over"
59
+ * — and every outbound hop needs to say how much is LEFT. Without it a service called at t=29 of
60
+ * a 30s budget started a fresh 30s of its own: real work, holding a pool slot and a vendor
61
+ * connection, for half a minute after the caller's socket was answered `X_TIMEOUT`. `null` for a
62
+ * job, a task and a CLI command, none of which has a caller waiting on a socket.
63
+ */
64
+ readonly deadlineAt: number | null;
55
65
  /** Late-bound services, for anything not worth a type augmentation. */
56
66
  readonly services: ServiceBag;
57
67
  }
@@ -67,6 +77,8 @@ export interface CtxInit {
67
77
  readonly clock?: Clock | undefined;
68
78
  readonly logger?: Logger | undefined;
69
79
  readonly signal?: AbortSignal | undefined;
80
+ /** Epoch ms. `@ultimat3/http`'s `startDeadline` is the one production writer. */
81
+ readonly deadlineAt?: number | undefined;
70
82
  readonly services?: ServiceBag | undefined;
71
83
  }
72
84
 
@@ -112,6 +124,7 @@ export function createContext(init: CtxInit = {}): Ctx {
112
124
  now: () => clock.now(),
113
125
  logger: base.child({ requestId, traceId: trace }),
114
126
  signal: init.signal ?? neverAborted,
127
+ deadlineAt: init.deadlineAt ?? null,
115
128
  };
116
129
  // A registered service (`defineService`) closes over the ctx it is built for — actor, clock,
117
130
  // tz — so it has to run HERE, against this exact call's fields, rather than once at boot and
@@ -191,6 +204,10 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
191
204
  role: patch.role ?? parent.role,
192
205
  clock: patch.clock ?? parent.clock,
193
206
  logger: patch.logger ?? parent.logger,
207
+ // One request, one budget: a child scope inherits the deadline for the same reason it
208
+ // inherits `requestId`. A patch may SHORTEN it (a step with its own budget); nothing here
209
+ // lengthens it, because the socket the parent is answering does not move.
210
+ deadlineAt: patch.deadlineAt ?? parent.deadlineAt ?? undefined,
194
211
  signal: patch.signal ?? parent.signal,
195
212
  services: { ...carried, ...(patch.services ?? {}) },
196
213
  });
@@ -18,7 +18,7 @@ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
18
  export const ENV_EXAMPLE_PATH = '.env.example';
19
19
 
20
20
  /**
21
- * What Bun loads by itself, lowest precedence first — measured against Bun 1.3, not assumed.
21
+ * What Bun loads by itself, lowest precedence first — measured against Bun 1.4, not assumed.
22
22
  *
23
23
  * The mode is NOT `ULTIMATE_ENV` and not even `NODE_ENV` verbatim: Bun reads `.env.production`
24
24
  * for `NODE_ENV=production`, `.env.test` for `test`, and `.env.development` for **everything
package/src/index.ts CHANGED
@@ -509,6 +509,11 @@ export {
509
509
  registerPrimitiveRegistrar,
510
510
  resetPrimitiveRegistrars,
511
511
  } from './registrar';
512
+ export {
513
+ budgetHeaders,
514
+ REQUEST_TIMEOUT_HEADER,
515
+ remainingBudgetMs,
516
+ } from './request-budget';
512
517
  export type { Err, Ok, Result } from './result';
513
518
  export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result';
514
519
  export type { RetryDecision, RetryDeps, RetryPolicy, RetryStopReason } from './retry';
@@ -0,0 +1,44 @@
1
+ // How much of THIS request's budget is left, and the one header that carries it to the next hop.
2
+ // The header name lives here rather than in `@ultimat3/http` because both ends need it and only
3
+ // one of them is tier 2: the reader is http's `resolveTimeoutMs`, the writer is a typed client in
4
+ // tier 0, and a second literal in the writer is a propagation that silently stops working the day
5
+ // either string is edited.
6
+
7
+ import type { Ctx } from './context';
8
+ import { tryUseContext } from './context';
9
+
10
+ /**
11
+ * A caller may SHORTEN the hop it is calling, never lengthen it — `@ultimat3/http`'s
12
+ * `resolveTimeoutMs` takes the minimum of its own configured budget and this. It had ONE reader
13
+ * and zero writers anywhere in the tree, so a 30s gateway budget that had already been spent to
14
+ * t=29 handed the next service a fresh 30s: work still running, still holding a pool slot and a
15
+ * vendor connection, 30 seconds after the caller's socket was answered `X_TIMEOUT`.
16
+ */
17
+ export const REQUEST_TIMEOUT_HEADER = 'x-request-timeout-ms';
18
+
19
+ /**
20
+ * Milliseconds left before this context's deadline, or `undefined` when there is no deadline or
21
+ * it has already passed.
22
+ *
23
+ * `undefined` for a spent budget rather than `0`, and that is not a nicety: the far side ignores
24
+ * anything under 1ms and falls back to its OWN configured budget, so a `0` on the wire reads as
25
+ * "the caller asked for nothing" — the exact failure this header exists to prevent, one hop later.
26
+ * Whether to make a call whose budget is gone is the caller's decision (`throwIfAborted`), not
27
+ * this function's.
28
+ */
29
+ export const remainingBudgetMs = (ctx: Ctx): number | undefined => {
30
+ if (ctx.deadlineAt === null) return undefined;
31
+ const left = Math.floor(ctx.deadlineAt - ctx.now().getTime());
32
+ return left >= 1 ? left : undefined;
33
+ };
34
+
35
+ /**
36
+ * The budget header for the ambient request, or nothing. Rounded DOWN by `remainingBudgetMs`:
37
+ * a hop must never be told it has more time than the caller will wait.
38
+ */
39
+ export const budgetHeaders = (): Record<string, string> => {
40
+ const ctx = tryUseContext();
41
+ if (ctx === undefined) return {};
42
+ const left = remainingBudgetMs(ctx);
43
+ return left === undefined ? {} : { [REQUEST_TIMEOUT_HEADER]: String(left) };
44
+ };