@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 +1 -0
- package/package.json +1 -1
- package/src/client-wire.ts +26 -10
- package/src/context.ts +17 -0
- package/src/env-example.ts +1 -1
- package/src/index.ts +5 -0
- package/src/request-budget.ts +44 -0
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
package/src/client-wire.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a typed client puts on the wire and reads back off it: the
|
|
3
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
});
|
package/src/env-example.ts
CHANGED
|
@@ -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.
|
|
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
|
+
};
|