@demlik/tea 0.1.0 → 0.1.2
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/LICENSE +21 -0
- package/dist/agent/index.d.ts +907 -0
- package/dist/agent/index.js +20 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/authed-call/index.d.ts +119 -0
- package/dist/authed-call/index.js +148 -0
- package/dist/authed-call/index.js.map +1 -0
- package/dist/await-terminal/index.d.ts +99 -0
- package/dist/await-terminal/index.js +75 -0
- package/dist/await-terminal/index.js.map +1 -0
- package/dist/batch-window/index.d.ts +259 -0
- package/dist/batch-window/index.js +43 -0
- package/dist/batch-window/index.js.map +1 -0
- package/dist/cache/index.d.ts +2 -2
- package/dist/cache/index.js +2 -50
- package/dist/cache/index.js.map +1 -1
- package/dist/chunk-27N2SPTN.js +222 -0
- package/dist/chunk-27N2SPTN.js.map +1 -0
- package/dist/chunk-2YOE5PIS.js +45 -0
- package/dist/chunk-2YOE5PIS.js.map +1 -0
- package/dist/chunk-3545BOYL.js +65 -0
- package/dist/chunk-3545BOYL.js.map +1 -0
- package/dist/chunk-3XJWC7VW.js +61 -0
- package/dist/chunk-3XJWC7VW.js.map +1 -0
- package/dist/chunk-5DEQ5I3U.js +132 -0
- package/dist/chunk-5DEQ5I3U.js.map +1 -0
- package/dist/chunk-5VNWZADP.js +49 -0
- package/dist/chunk-5VNWZADP.js.map +1 -0
- package/dist/chunk-6RGBKODV.js +268 -0
- package/dist/chunk-6RGBKODV.js.map +1 -0
- package/dist/{chunk-ZRUST6BK.js → chunk-7FYQGDMF.js} +2 -2
- package/dist/chunk-7FYQGDMF.js.map +1 -0
- package/dist/chunk-7W4LMPUW.js +186 -0
- package/dist/chunk-7W4LMPUW.js.map +1 -0
- package/dist/chunk-BYQUGDD3.js +16 -0
- package/dist/chunk-BYQUGDD3.js.map +1 -0
- package/dist/{chunk-PYBQREFU.js → chunk-DILMKOZ7.js} +22 -26
- package/dist/chunk-DILMKOZ7.js.map +1 -0
- package/dist/chunk-EHIX7NYZ.js +93 -0
- package/dist/chunk-EHIX7NYZ.js.map +1 -0
- package/dist/chunk-HCEVHFJ7.js +54 -0
- package/dist/chunk-HCEVHFJ7.js.map +1 -0
- package/dist/chunk-HJO6VNQI.js +15 -0
- package/dist/chunk-HJO6VNQI.js.map +1 -0
- package/dist/chunk-IN33Q4VJ.js +52 -0
- package/dist/chunk-IN33Q4VJ.js.map +1 -0
- package/dist/chunk-J6527VRQ.js +56 -0
- package/dist/chunk-J6527VRQ.js.map +1 -0
- package/dist/chunk-JELSZN25.js +47 -0
- package/dist/chunk-JELSZN25.js.map +1 -0
- package/dist/chunk-K575PT4S.js +58 -0
- package/dist/chunk-K575PT4S.js.map +1 -0
- package/dist/chunk-K7Q23EQ4.js +20 -0
- package/dist/chunk-K7Q23EQ4.js.map +1 -0
- package/dist/chunk-LEC4U6CX.js +91 -0
- package/dist/chunk-LEC4U6CX.js.map +1 -0
- package/dist/{chunk-PYOCUBQJ.js → chunk-LP7VQBRG.js} +22 -15
- package/dist/chunk-LP7VQBRG.js.map +1 -0
- package/dist/{chunk-CMG2KMWX.js → chunk-MMH7CW5K.js} +220 -189
- package/dist/chunk-MMH7CW5K.js.map +1 -0
- package/dist/{chunk-Y5JM3UUH.js → chunk-NSDFA6M6.js} +75 -3
- package/dist/chunk-NSDFA6M6.js.map +1 -0
- package/dist/chunk-ON24RKSE.js +60 -0
- package/dist/chunk-ON24RKSE.js.map +1 -0
- package/dist/chunk-PS23H6KQ.js +87 -0
- package/dist/chunk-PS23H6KQ.js.map +1 -0
- package/dist/chunk-QLA76GIT.js +162 -0
- package/dist/chunk-QLA76GIT.js.map +1 -0
- package/dist/chunk-S3FQN4SL.js +506 -0
- package/dist/chunk-S3FQN4SL.js.map +1 -0
- package/dist/chunk-SC3FAFZN.js +53 -0
- package/dist/chunk-SC3FAFZN.js.map +1 -0
- package/dist/chunk-SINXE47Y.js +55 -0
- package/dist/chunk-SINXE47Y.js.map +1 -0
- package/dist/chunk-SQTCS44W.js +33 -0
- package/dist/chunk-SQTCS44W.js.map +1 -0
- package/dist/chunk-TGG26XMK.js +52 -0
- package/dist/chunk-TGG26XMK.js.map +1 -0
- package/dist/chunk-UV6UWGEO.js +147 -0
- package/dist/chunk-UV6UWGEO.js.map +1 -0
- package/dist/chunk-UYWUVEP6.js +158 -0
- package/dist/chunk-UYWUVEP6.js.map +1 -0
- package/dist/chunk-VLGARN6H.js +202 -0
- package/dist/chunk-VLGARN6H.js.map +1 -0
- package/dist/circuit-breaker/index.js +1 -51
- package/dist/circuit-breaker/index.js.map +1 -1
- package/dist/core-BoB6t7dB.d.ts +188 -0
- package/dist/deadline/index.d.ts +24 -5
- package/dist/deadline/index.js +4 -18
- package/dist/deadline/index.js.map +1 -1
- package/dist/debounce/index.d.ts +6 -0
- package/dist/debounce/index.js +1 -54
- package/dist/debounce/index.js.map +1 -1
- package/dist/devtools/index.d.ts +1 -1
- package/dist/devtools/index.js +18 -6
- package/dist/devtools/index.js.map +1 -1
- package/dist/do/index.d.ts +1723 -92
- package/dist/do/index.js +673 -31
- package/dist/do/index.js.map +1 -1
- package/dist/durable-effects-CO9Uh971.d.ts +180 -0
- package/dist/extension/index.d.ts +31 -13
- package/dist/extension/index.js +5 -3
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/react.js +8 -3
- package/dist/extension/react.js.map +1 -1
- package/dist/extension/subs/index.d.ts +2 -2
- package/dist/extension/subs/index.js.map +1 -1
- package/dist/extension/test-utils.js +11 -3
- package/dist/extension/test-utils.js.map +1 -1
- package/dist/fan-out/index.d.ts +239 -0
- package/dist/fan-out/index.js +5 -0
- package/dist/fan-out/index.js.map +1 -0
- package/dist/idempotency/adapter.d.ts +62 -0
- package/dist/idempotency/adapter.js +4 -0
- package/dist/idempotency/adapter.js.map +1 -0
- package/dist/idempotency/index.js +1 -56
- package/dist/idempotency/index.js.map +1 -1
- package/dist/idempotent-intake/index.d.ts +186 -0
- package/dist/idempotent-intake/index.js +7 -0
- package/dist/idempotent-intake/index.js.map +1 -0
- package/dist/index.d.ts +341 -191
- package/dist/index.js +2 -1
- package/dist/llm-call/index.d.ts +309 -0
- package/dist/llm-call/index.js +14 -0
- package/dist/llm-call/index.js.map +1 -0
- package/dist/machine-viz/index.d.ts +4 -4
- package/dist/machine-viz/index.js +5 -9
- package/dist/machine-viz/index.js.map +1 -1
- package/dist/mem/index.d.ts +1 -0
- package/dist/monitored-run/index.d.ts +253 -0
- package/dist/monitored-run/index.js +10 -0
- package/dist/monitored-run/index.js.map +1 -0
- package/dist/node/index.d.ts +5 -5
- package/dist/node/index.js.map +1 -1
- package/dist/paginated-walk/index.d.ts +234 -0
- package/dist/paginated-walk/index.js +15 -0
- package/dist/paginated-walk/index.js.map +1 -0
- package/dist/paginator/index.d.ts +271 -0
- package/dist/paginator/index.js +3 -0
- package/dist/paginator/index.js.map +1 -0
- package/dist/parity/index.d.ts +92 -0
- package/dist/parity/index.js +78 -0
- package/dist/parity/index.js.map +1 -0
- package/dist/pbt/arbitraries/index.d.ts +1 -1
- package/dist/pbt/arbitraries/index.js +1 -1
- package/dist/pbt/index.d.ts +15 -8
- package/dist/pbt/index.js +6 -11
- package/dist/pbt/index.js.map +1 -1
- package/dist/pbt/runners/index.d.ts +1 -1
- package/dist/pbt/runners/index.js +3 -2
- package/dist/poller/index.d.ts +288 -0
- package/dist/poller/index.js +79 -0
- package/dist/poller/index.js.map +1 -0
- package/dist/prediction/index.d.ts +130 -0
- package/dist/prediction/index.js +4 -0
- package/dist/prediction/index.js.map +1 -0
- package/dist/protocol-B5YmJr0d.d.ts +45 -0
- package/dist/pure/index.d.ts +2 -0
- package/dist/pure/index.js +4 -0
- package/dist/pure/index.js.map +1 -0
- package/dist/raft/demo.d.ts +105 -0
- package/dist/raft/demo.js +172 -0
- package/dist/raft/demo.js.map +1 -0
- package/dist/raft/index.d.ts +548 -0
- package/dist/raft/index.js +7 -0
- package/dist/raft/index.js.map +1 -0
- package/dist/raft/sim.d.ts +222 -0
- package/dist/raft/sim.js +8 -0
- package/dist/raft/sim.js.map +1 -0
- package/dist/raft/timeline.d.ts +46 -0
- package/dist/raft/timeline.js +62 -0
- package/dist/raft/timeline.js.map +1 -0
- package/dist/rate-limit/index.js +1 -43
- package/dist/rate-limit/index.js.map +1 -1
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +43 -20
- package/dist/react/index.js.map +1 -1
- package/dist/reconciler/index.d.ts +192 -0
- package/dist/reconciler/index.js +166 -0
- package/dist/reconciler/index.js.map +1 -0
- package/dist/recorder/index.d.ts +7 -3
- package/dist/recorder/index.js +1 -155
- package/dist/recorder/index.js.map +1 -1
- package/dist/resilient-call/index.d.ts +294 -0
- package/dist/resilient-call/index.js +13 -0
- package/dist/resilient-call/index.js.map +1 -0
- package/dist/retry-backoff/index.d.ts +42 -5
- package/dist/retry-backoff/index.js +1 -35
- package/dist/retry-backoff/index.js.map +1 -1
- package/dist/retry-to-success/index.d.ts +109 -0
- package/dist/retry-to-success/index.js +35 -0
- package/dist/retry-to-success/index.js.map +1 -0
- package/dist/saga/index.d.ts +238 -0
- package/dist/saga/index.js +150 -0
- package/dist/saga/index.js.map +1 -0
- package/dist/snapshot/index.d.ts +334 -0
- package/dist/snapshot/index.js +5 -0
- package/dist/snapshot/index.js.map +1 -0
- package/dist/subs/index.d.ts +48 -10
- package/dist/subs/index.js +1 -1
- package/dist/testing/index.d.ts +107 -2
- package/dist/testing/index.js +204 -6
- package/dist/testing/index.js.map +1 -1
- package/dist/throttle/index.d.ts +9 -0
- package/dist/throttle/index.js +1 -52
- package/dist/throttle/index.js.map +1 -1
- package/dist/throttled-input/index.d.ts +380 -0
- package/dist/throttled-input/index.js +88 -0
- package/dist/throttled-input/index.js.map +1 -0
- package/dist/token-refresh/index.d.ts +215 -0
- package/dist/token-refresh/index.js +6 -0
- package/dist/token-refresh/index.js.map +1 -0
- package/dist/trace-replay/index.d.ts +11 -2
- package/dist/trace-replay/index.js +3 -55
- package/dist/trace-replay/index.js.map +1 -1
- package/dist/{types-CwBC5wMn.d.ts → types-B-8bcAp8.d.ts} +1 -1
- package/dist/with-deadline/index.d.ts +196 -0
- package/dist/with-deadline/index.js +111 -0
- package/dist/with-deadline/index.js.map +1 -0
- package/dist/with-resilience/index.d.ts +205 -0
- package/dist/with-resilience/index.js +251 -0
- package/dist/with-resilience/index.js.map +1 -0
- package/dist/with-telemetry/index.d.ts +149 -0
- package/dist/with-telemetry/index.js +70 -0
- package/dist/with-telemetry/index.js.map +1 -0
- package/dist/work-queue/adapter.d.ts +82 -0
- package/dist/work-queue/adapter.js +4 -0
- package/dist/work-queue/adapter.js.map +1 -0
- package/dist/work-queue/index.d.ts +25 -11
- package/dist/work-queue/index.js +1 -46
- package/dist/work-queue/index.js.map +1 -1
- package/dist/work-queue/ops.d.ts +69 -0
- package/dist/work-queue/ops.js +3 -0
- package/dist/work-queue/ops.js.map +1 -0
- package/dist/workflow/index.d.ts +450 -0
- package/dist/workflow/index.js +225 -0
- package/dist/workflow/index.js.map +1 -0
- package/package.json +168 -25
- package/dist/chunk-CMG2KMWX.js.map +0 -1
- package/dist/chunk-PYBQREFU.js.map +0 -1
- package/dist/chunk-PYOCUBQJ.js.map +0 -1
- package/dist/chunk-Y5JM3UUH.js.map +0 -1
- package/dist/chunk-ZRUST6BK.js.map +0 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { COMPACTION_PURPOSE, agentBootMsg, agentEvents, agentTurnSchema, compactionSummarySchema, createAgent, isAgentTurn, isCompactionSummary, liftAgent, mergeInterpret, status } from '../chunk-S3FQN4SL.js';
|
|
2
|
+
import '../chunk-7W4LMPUW.js';
|
|
3
|
+
import '../chunk-PS23H6KQ.js';
|
|
4
|
+
import '../chunk-EHIX7NYZ.js';
|
|
5
|
+
import '../chunk-27N2SPTN.js';
|
|
6
|
+
import '../chunk-JELSZN25.js';
|
|
7
|
+
import '../chunk-UV6UWGEO.js';
|
|
8
|
+
export { deadlineSub, subscribeDeadline } from '../chunk-K7Q23EQ4.js';
|
|
9
|
+
import '../chunk-5VNWZADP.js';
|
|
10
|
+
import '../chunk-2YOE5PIS.js';
|
|
11
|
+
import '../chunk-SC3FAFZN.js';
|
|
12
|
+
import '../chunk-IN33Q4VJ.js';
|
|
13
|
+
import '../chunk-HJO6VNQI.js';
|
|
14
|
+
import '../chunk-TGG26XMK.js';
|
|
15
|
+
import '../chunk-NSDFA6M6.js';
|
|
16
|
+
import '../chunk-2YFLOCWX.js';
|
|
17
|
+
import '../chunk-MMH7CW5K.js';
|
|
18
|
+
import '../chunk-QLA76GIT.js';
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { c as PortEmitter, C as Cmd } from '../core-BoB6t7dB.js';
|
|
2
|
+
import { M as MsgType } from '../protocol-B5YmJr0d.js';
|
|
3
|
+
import { ResilientConfig, ResilientState, RunCmd, ResilientPorts, SucceedMsg, FailMsg, ResilientTimerMsg } from '../resilient-call/index.js';
|
|
4
|
+
import { TokenRefreshConfig, Token, TokenState, RefreshTokenCmd, TokenRefreshMsg } from '../token-refresh/index.js';
|
|
5
|
+
import { DeadlineSub } from '../deadline/index.js';
|
|
6
|
+
export { DeadlineExceeded, deadlineSub, subscribeDeadline } from '../deadline/index.js';
|
|
7
|
+
import '../cache/index.js';
|
|
8
|
+
import '../types-B-8bcAp8.js';
|
|
9
|
+
import '../circuit-breaker/index.js';
|
|
10
|
+
import '../rate-limit/index.js';
|
|
11
|
+
import '../retry-backoff/index.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The authed-call knob. It is `ResilientConfig` widened by the token dimension:
|
|
15
|
+
*
|
|
16
|
+
* - `refresh` — the ONLY required field: the port that mints a fresh `Token`.
|
|
17
|
+
* Identical to token-refresh's `TokenRefreshPorts.refresh`; it is hoisted
|
|
18
|
+
* onto the config (not the ports) so the consumer spells the credential
|
|
19
|
+
* source once, the same way `rng` is curried onto the factory.
|
|
20
|
+
* - `skewMs` — token-refresh's refresh-ahead skew (optional, defaults to 0).
|
|
21
|
+
* - every `ResilientConfig` field (`retry` / `circuit` / `rateLimit` /
|
|
22
|
+
* `cache` / `deadline`) — passed straight through to the resilient-call
|
|
23
|
+
* knob, each still optional, each still "omit a brick → omit its gate".
|
|
24
|
+
*/
|
|
25
|
+
interface AuthedConfig extends ResilientConfig, TokenRefreshConfig {
|
|
26
|
+
/** Mint a fresh credential. The single DI seam the auth dimension adds. */
|
|
27
|
+
readonly refresh: () => Promise<Token>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The composed slice. Three plain-data fields, no closures:
|
|
31
|
+
*
|
|
32
|
+
* - `resilience` — resilient-call's entire slice (circuit, bucket, retry,
|
|
33
|
+
* cache, calls). Owned and mutated only via resilient-call's verbs.
|
|
34
|
+
* - `auth` — token-refresh's `{ token, stale }` slice. Owned and mutated only
|
|
35
|
+
* via token-refresh's verbs.
|
|
36
|
+
* - `authRetry` — per-key count of auth-retries (401 → refresh → retry) spent
|
|
37
|
+
* on a call. Caps the dance at one retry per call so a credential the server
|
|
38
|
+
* keeps rejecting cannot spin forever. A key absent from the record has
|
|
39
|
+
* spent zero. Cleared when a call settles or a fresh `attempt` starts.
|
|
40
|
+
*
|
|
41
|
+
* `pendingAuthRetry` carries the inputs of calls parked waiting for the refresh
|
|
42
|
+
* to land, keyed so `onRefreshed` can re-issue exactly those calls. Plain data —
|
|
43
|
+
* `input` is the same payload the call was issued with.
|
|
44
|
+
*/
|
|
45
|
+
interface AuthedState<I, R> {
|
|
46
|
+
readonly resilience: ResilientState<I, R>;
|
|
47
|
+
readonly auth: TokenState;
|
|
48
|
+
readonly authRetry: Readonly<Record<string, number>>;
|
|
49
|
+
readonly pendingAuthRetry: Readonly<Record<string, I>>;
|
|
50
|
+
}
|
|
51
|
+
/** The two effects this knob emits: resilient-call's run, token-refresh's mint. */
|
|
52
|
+
type AuthedCmd<I> = RunCmd<I> | RefreshTokenCmd;
|
|
53
|
+
/**
|
|
54
|
+
* Every Msg the knob's verbs fold. Resilient-call's settle + timer Msgs, token-
|
|
55
|
+
* refresh's refresh-result Msgs, and the 401 trigger the consumer dispatches
|
|
56
|
+
* when a guarded call comes back unauthorized.
|
|
57
|
+
*/
|
|
58
|
+
type Unauthorized = {
|
|
59
|
+
readonly type: typeof MsgType.Unauthorized;
|
|
60
|
+
readonly key: string;
|
|
61
|
+
readonly at: number;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The plain-data error a 401 we cannot fix settles with. A `{_tag, ...}`
|
|
65
|
+
* sentinel — NEVER a `new Error(...)` — so the slice stays JSON-serializable
|
|
66
|
+
* (an Error object round-trips to `{}` under JSON and carries a stack trace,
|
|
67
|
+
* both of which break the slice's durability invariant once it is persisted
|
|
68
|
+
* into a `Store<S>` and reloaded). Mirrors resilient-call's `DeadlineExceededError`
|
|
69
|
+
* sentinel discipline. `key` echoes the call that was rejected so a consumer can
|
|
70
|
+
* attribute the failure.
|
|
71
|
+
*/
|
|
72
|
+
type UnauthorizedError = {
|
|
73
|
+
readonly _tag: "unauthorized";
|
|
74
|
+
readonly key: string;
|
|
75
|
+
};
|
|
76
|
+
/** Ports `handlers` needs — resilient-call's run port AND token-refresh's refresh port. */
|
|
77
|
+
interface AuthedPorts<I, R> extends ResilientPorts<I, R> {
|
|
78
|
+
/** Mint a fresh credential. Mirrors `AuthedConfig.refresh` for symmetry at the splice site. */
|
|
79
|
+
readonly refresh: () => Promise<Token>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build an authed-call knob from `config`. `rng` is injected for retry jitter
|
|
83
|
+
* and threaded straight into the underlying resilient-call knob — pass a fixed
|
|
84
|
+
* `() => 0.5` in tests to pin backoff; defaults to `Math.random` (read only at
|
|
85
|
+
* the verb boundary inside resilient-call, never in a brick op).
|
|
86
|
+
*
|
|
87
|
+
* Returns the uniform L2 knob contract, widened by the auth verbs (`on401`,
|
|
88
|
+
* `onRefreshed`, `installToken`). `I` is the port input type, `R` the result.
|
|
89
|
+
*/
|
|
90
|
+
declare function createAuthedCall<I, R>(config: AuthedConfig, rng?: () => number): {
|
|
91
|
+
init: () => AuthedState<I, R>;
|
|
92
|
+
attempt: (s: AuthedState<I, R>, key: string, input: I, at: number) => readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]];
|
|
93
|
+
succeed: (s: AuthedState<I, R>, key: string, msg: SucceedMsg<R>) => readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]];
|
|
94
|
+
fail: (s: AuthedState<I, R>, key: string, msg: FailMsg) => readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]];
|
|
95
|
+
on401: (s: AuthedState<I, R>, key: string, at: number) => readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]];
|
|
96
|
+
installToken: (s: AuthedState<I, R>, token: Token) => AuthedState<I, R>;
|
|
97
|
+
onRefreshed: (s: AuthedState<I, R>, at: number) => readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]];
|
|
98
|
+
onTimer: (s: AuthedState<I, R>, msg: ResilientTimerMsg) => readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]];
|
|
99
|
+
/** token-refresh's call-boundary verb, re-exposed for proactive expiry refresh. */
|
|
100
|
+
needsRefresh: (s: AuthedState<I, R>, at: number) => boolean;
|
|
101
|
+
subs: (s: AuthedState<I, R>) => readonly DeadlineSub[];
|
|
102
|
+
handlers: (ports: AuthedPorts<I, R>) => {
|
|
103
|
+
refresh_token: (cmd: RefreshTokenCmd, ctx: PortEmitter) => Promise<void | TokenRefreshMsg>;
|
|
104
|
+
resilient_run: (cmd: RunCmd<I>, ctx: Readonly<Record<never, never>>) => Promise<FailMsg | SucceedMsg<R>>;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
/** The shape `createAuthedCall` returns — useful for typing a held knob. */
|
|
108
|
+
type AuthedCall<I, R> = ReturnType<typeof createAuthedCall<I, R>>;
|
|
109
|
+
/**
|
|
110
|
+
* Lift a knob result `[slice, cmds]` into a host `[State, cmds]` where the slice
|
|
111
|
+
* lives at `state.authed`. A convenience for the common single-slice host;
|
|
112
|
+
* consumers with a differently-named field spread by hand. Pure — a thin record
|
|
113
|
+
* rebuild, no clock / RNG.
|
|
114
|
+
*/
|
|
115
|
+
declare function liftAuthed<S extends {
|
|
116
|
+
authed: AuthedState<I, R>;
|
|
117
|
+
}, I, R, C extends Cmd>(state: S, [slice, cmds]: readonly [AuthedState<I, R>, readonly C[]]): readonly [S, readonly C[]];
|
|
118
|
+
|
|
119
|
+
export { type AuthedCall, type AuthedCmd, type AuthedConfig, type AuthedPorts, type AuthedState, DeadlineSub, FailMsg, ResilientState, ResilientTimerMsg, RunCmd, SucceedMsg, Token, TokenRefreshMsg, TokenState, type Unauthorized, type UnauthorizedError, createAuthedCall, liftAuthed };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createTokenRefresh, refreshTokenCmd } from '../chunk-3XJWC7VW.js';
|
|
2
|
+
import { createResilientCall } from '../chunk-27N2SPTN.js';
|
|
3
|
+
import '../chunk-JELSZN25.js';
|
|
4
|
+
export { deadlineSub, subscribeDeadline } from '../chunk-K7Q23EQ4.js';
|
|
5
|
+
import '../chunk-5VNWZADP.js';
|
|
6
|
+
import '../chunk-2YOE5PIS.js';
|
|
7
|
+
import '../chunk-SC3FAFZN.js';
|
|
8
|
+
import '../chunk-IN33Q4VJ.js';
|
|
9
|
+
import '../chunk-NSDFA6M6.js';
|
|
10
|
+
import '../chunk-2YFLOCWX.js';
|
|
11
|
+
import '../chunk-MMH7CW5K.js';
|
|
12
|
+
import '../chunk-QLA76GIT.js';
|
|
13
|
+
|
|
14
|
+
// src/authed-call/index.ts
|
|
15
|
+
function createAuthedCall(config, rng = Math.random) {
|
|
16
|
+
const rc = createResilientCall(config, rng);
|
|
17
|
+
const tr = createTokenRefresh({ skewMs: config.skewMs });
|
|
18
|
+
function init() {
|
|
19
|
+
return {
|
|
20
|
+
resilience: rc.init(),
|
|
21
|
+
auth: tr.init(),
|
|
22
|
+
authRetry: {},
|
|
23
|
+
pendingAuthRetry: {}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function liftResilient(s, [resilience, cmds]) {
|
|
27
|
+
return [{ ...s, resilience }, cmds];
|
|
28
|
+
}
|
|
29
|
+
function without(rec, key) {
|
|
30
|
+
const { [key]: _dropped, ...rest } = rec;
|
|
31
|
+
return rest;
|
|
32
|
+
}
|
|
33
|
+
function attempt(s, key, input, at) {
|
|
34
|
+
const cleared = {
|
|
35
|
+
...s,
|
|
36
|
+
authRetry: without(s.authRetry, key),
|
|
37
|
+
pendingAuthRetry: without(s.pendingAuthRetry, key)
|
|
38
|
+
};
|
|
39
|
+
return liftResilient(
|
|
40
|
+
cleared,
|
|
41
|
+
rc.attempt(cleared.resilience, key, input, at)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
function succeed(s, key, msg) {
|
|
45
|
+
const settled = {
|
|
46
|
+
...s,
|
|
47
|
+
authRetry: without(s.authRetry, key),
|
|
48
|
+
pendingAuthRetry: without(s.pendingAuthRetry, key)
|
|
49
|
+
};
|
|
50
|
+
return liftResilient(settled, rc.succeed(settled.resilience, key, msg));
|
|
51
|
+
}
|
|
52
|
+
function fail(s, key, msg) {
|
|
53
|
+
const [resilience, cmds] = rc.fail(s.resilience, key, msg);
|
|
54
|
+
const next = settleAuthIfDone({ ...s, resilience }, key, resilience);
|
|
55
|
+
return [next, cmds];
|
|
56
|
+
}
|
|
57
|
+
function on401(s, key, at) {
|
|
58
|
+
const spent = s.authRetry[key] ?? 0;
|
|
59
|
+
const call = s.resilience.calls[key];
|
|
60
|
+
const input = call?.phase === "running" || call?.phase === "waiting_retry" ? call.input : void 0;
|
|
61
|
+
if (spent >= 1 || input === void 0) {
|
|
62
|
+
const error = { _tag: "unauthorized", key };
|
|
63
|
+
const [settled] = rc.settleFailed(s.resilience, key, error);
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
...s,
|
|
67
|
+
resilience: settled,
|
|
68
|
+
authRetry: without(s.authRetry, key),
|
|
69
|
+
pendingAuthRetry: without(s.pendingAuthRetry, key)
|
|
70
|
+
},
|
|
71
|
+
[]
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
...s,
|
|
77
|
+
auth: tr.on401(s.auth),
|
|
78
|
+
authRetry: { ...s.authRetry, [key]: spent + 1 },
|
|
79
|
+
pendingAuthRetry: { ...s.pendingAuthRetry, [key]: input }
|
|
80
|
+
},
|
|
81
|
+
[refreshTokenCmd()]
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
function installToken(s, token) {
|
|
85
|
+
return { ...s, auth: tr.refreshed(s.auth, token) };
|
|
86
|
+
}
|
|
87
|
+
function onRefreshed(s, at) {
|
|
88
|
+
const parked = Object.entries(s.pendingAuthRetry);
|
|
89
|
+
if (parked.length === 0) return [s, []];
|
|
90
|
+
let resilience = s.resilience;
|
|
91
|
+
const cmds = [];
|
|
92
|
+
for (const [key, input] of parked) {
|
|
93
|
+
const [next, runCmds] = rc.attempt(resilience, key, input, at);
|
|
94
|
+
resilience = next;
|
|
95
|
+
cmds.push(...runCmds);
|
|
96
|
+
}
|
|
97
|
+
return [{ ...s, resilience, pendingAuthRetry: {} }, cmds];
|
|
98
|
+
}
|
|
99
|
+
function onTimer(s, msg) {
|
|
100
|
+
const [resilience, cmds] = rc.onTimer(s.resilience, msg);
|
|
101
|
+
const next = settleAuthIfDone({ ...s, resilience }, void 0, resilience);
|
|
102
|
+
return [next, cmds];
|
|
103
|
+
}
|
|
104
|
+
function settleAuthIfDone(s, onlyKey, resilience) {
|
|
105
|
+
let authRetry = s.authRetry;
|
|
106
|
+
let pendingAuthRetry = s.pendingAuthRetry;
|
|
107
|
+
const keys = onlyKey !== void 0 ? [onlyKey] : Object.keys(authRetry);
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
const phase = resilience.calls[key]?.phase;
|
|
110
|
+
if (phase === "succeeded" || phase === "failed") {
|
|
111
|
+
authRetry = without(authRetry, key);
|
|
112
|
+
pendingAuthRetry = without(pendingAuthRetry, key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { ...s, authRetry, pendingAuthRetry };
|
|
116
|
+
}
|
|
117
|
+
function subs(s) {
|
|
118
|
+
return rc.subs(s.resilience);
|
|
119
|
+
}
|
|
120
|
+
function handlers(ports) {
|
|
121
|
+
const trPorts = { refresh: ports.refresh };
|
|
122
|
+
return {
|
|
123
|
+
...rc.handlers({ run: ports.run }),
|
|
124
|
+
...tr.handlers(trPorts)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
init,
|
|
129
|
+
attempt,
|
|
130
|
+
succeed,
|
|
131
|
+
fail,
|
|
132
|
+
on401,
|
|
133
|
+
installToken,
|
|
134
|
+
onRefreshed,
|
|
135
|
+
onTimer,
|
|
136
|
+
/** token-refresh's call-boundary verb, re-exposed for proactive expiry refresh. */
|
|
137
|
+
needsRefresh: (s, at) => tr.needsRefresh(s.auth, at),
|
|
138
|
+
subs,
|
|
139
|
+
handlers
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function liftAuthed(state, [slice, cmds]) {
|
|
143
|
+
return [{ ...state, authed: slice }, cmds];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { createAuthedCall, liftAuthed };
|
|
147
|
+
//# sourceMappingURL=index.js.map
|
|
148
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/authed-call/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @demlik/tea/authed-call — resilient-call with one extra dimension: a bearer\n * credential that must be minted before a call goes out and re-minted when the\n * server says it is no longer good (a 401). It is composition #10 in the specs:\n * *extends resilient-call; needs the L1 `token-refresh` brick*.\n *\n * The recipe — two existing bricks, stacked, nothing hand-wired:\n *\n * - `../resilient-call` owns ALL the call resilience: cache → circuit-breaker\n * → rate-limit → exponential-backoff retry, plus the per-call deadline. This\n * knob does not re-implement a single line of it. The `attempt` / `succeed`\n * / `fail` / `onTimer` verbs here are thin delegations that thread the\n * composed slice's `resilience` field through the resilient-call verb of the\n * same name. Whatever resilience config a consumer passes flows straight\n * down — every `ResilientConfig` field is still optional, still \"omit a\n * brick → omit its gate\".\n *\n * - `../token-refresh` owns the credential lifecycle: the `{ token, stale }`\n * slice and the decisions about *when* a token must be refreshed (expiry\n * skew, or a reactive 401). This knob adds the one verb resilient-call has\n * no concept of — `on401` — and routes the refresh I/O through token-\n * refresh's `refresh_token` handler.\n *\n * So the slice is exactly *resilient-call's slice + token-refresh's slice*:\n *\n * { resilience: ResilientState<I,R>, auth: TokenState }\n *\n * and the verb set is *resilient-call's four verbs + `on401`*, precisely the\n * spec's \"resilient-call's + `on401(s)` (refresh → retry once)\".\n *\n * ## The 401 dance — \"refresh → retry once\"\n *\n * A 401 is special: it is not a transient failure (backing off and retrying the\n * SAME credential just earns another 401), and it is not a terminal failure (a\n * fresh token usually fixes it). So `on401(s, key, at)` does three things:\n *\n * 1. marks the held token `stale` (via token-refresh's `on401`) and emits a\n * `refresh_token` Cmd to mint a new one;\n * 2. parks the in-flight call in a per-key `authRetry` budget so that, *once*,\n * the refresh landing re-issues that call with the new credential;\n * 3. caps that to a SINGLE auth-retry per call. A second 401 on the same call\n * after we already refreshed and retried does NOT loop — it settles the\n * call `failed` (errors are data: the failure is recorded, never swallowed\n * or spun on forever). The budget resets on the next fresh `attempt`.\n *\n * When the refresh resolves, the consumer folds `refreshed` into the auth slice\n * and calls `onRefreshed(s, at)`, which re-attempts every parked call through\n * the resilient gate with the new token in hand.\n *\n * ## The two non-negotiables (canon — inherited from both bricks)\n *\n * - **Durable** — the slice is `ResilientState` (plain data) + `TokenState`\n * (plain data) + a plain `Record<string, number>` retry budget. All JSON-\n * serializable, so it survives a Durable Object eviction / reload.\n * - **Replayable** — every verb returns `readonly [State, Cmd[]]` and never\n * reads the wall clock or RNG. Time arrives as an `at` parameter; the RNG\n * for retry jitter is injected once at `createAuthedCall(config, rng)` and\n * handed straight to the resilient-call knob. The only clock / network reads\n * live in `handlers` (the effect boundary) — resilient-call's `run` port and\n * token-refresh's `refresh` port.\n *\n * ## Typical wiring\n *\n * const ac = createAuthedCall<string, Resp>({\n * refresh: () => sdk.mintToken(),\n * retry: defaultRetryPolicy,\n * circuit: { threshold: 5, cooldownMs: 30_000 },\n * deadline: { ms: 5_000 },\n * skewMs: 30_000,\n * });\n *\n * // in the machine:\n * init: () => [ac.init(), []],\n * update: {\n * fetch: (s, m) => ac.attempt(s, m.key, m.input, m.at),\n * call_ok: (s, m) => ac.succeed(s, m.key, m),\n * call_err:(s, m) => ac.fail(s, m.key, m),\n * got_401: (s, m) => ac.on401(s, m.key, m.at),\n * token_refreshed: (s, m) => ac.onRefreshed(ac.installToken(s, m.token), m.at),\n * retry_due: (s, m) => ac.onTimer(s, m),\n * },\n * subscriptions: (s) => ac.subs(s),\n * subscribe: { deadline: subscribeDeadline },\n * interpret: ac.handlers({ run: ctx.call, refresh: () => sdk.mintToken() }),\n */\n\nimport type { Cmd } from \"../index\";\nimport type { MsgType } from \"../protocol\";\nimport {\n createResilientCall,\n type DeadlineExceeded,\n type DeadlineSub,\n deadlineSub,\n type FailMsg,\n type ResilientConfig,\n type ResilientPorts,\n type ResilientState,\n type ResilientTimerMsg,\n type RunCmd,\n type SucceedMsg,\n subscribeDeadline,\n} from \"../resilient-call\";\nimport {\n createTokenRefresh,\n type RefreshTokenCmd,\n refreshTokenCmd,\n type Token,\n type TokenRefreshConfig,\n type TokenRefreshMsg,\n type TokenRefreshPorts,\n type TokenState,\n} from \"../token-refresh\";\n\n// ===========================================================================\n// Config — the knob. The resilient-call config PLUS the two token-refresh\n// fields. Every field optional except `refresh` (the credential is the whole\n// point: without a mint port there is nothing to authenticate with).\n// ===========================================================================\n\n/**\n * The authed-call knob. It is `ResilientConfig` widened by the token dimension:\n *\n * - `refresh` — the ONLY required field: the port that mints a fresh `Token`.\n * Identical to token-refresh's `TokenRefreshPorts.refresh`; it is hoisted\n * onto the config (not the ports) so the consumer spells the credential\n * source once, the same way `rng` is curried onto the factory.\n * - `skewMs` — token-refresh's refresh-ahead skew (optional, defaults to 0).\n * - every `ResilientConfig` field (`retry` / `circuit` / `rateLimit` /\n * `cache` / `deadline`) — passed straight through to the resilient-call\n * knob, each still optional, each still \"omit a brick → omit its gate\".\n */\nexport interface AuthedConfig extends ResilientConfig, TokenRefreshConfig {\n /** Mint a fresh credential. The single DI seam the auth dimension adds. */\n readonly refresh: () => Promise<Token>;\n}\n\n// ===========================================================================\n// Slice — resilient-call's slice + token-refresh's slice + the auth-retry\n// budget. A flat record, every field plain data.\n// ===========================================================================\n\n/**\n * The composed slice. Three plain-data fields, no closures:\n *\n * - `resilience` — resilient-call's entire slice (circuit, bucket, retry,\n * cache, calls). Owned and mutated only via resilient-call's verbs.\n * - `auth` — token-refresh's `{ token, stale }` slice. Owned and mutated only\n * via token-refresh's verbs.\n * - `authRetry` — per-key count of auth-retries (401 → refresh → retry) spent\n * on a call. Caps the dance at one retry per call so a credential the server\n * keeps rejecting cannot spin forever. A key absent from the record has\n * spent zero. Cleared when a call settles or a fresh `attempt` starts.\n *\n * `pendingAuthRetry` carries the inputs of calls parked waiting for the refresh\n * to land, keyed so `onRefreshed` can re-issue exactly those calls. Plain data —\n * `input` is the same payload the call was issued with.\n */\nexport interface AuthedState<I, R> {\n readonly resilience: ResilientState<I, R>;\n readonly auth: TokenState;\n readonly authRetry: Readonly<Record<string, number>>;\n readonly pendingAuthRetry: Readonly<Record<string, I>>;\n}\n\n// ===========================================================================\n// Cmds + Msgs. The union of both bricks' Cmds/Msgs plus a 401 trigger Msg.\n// ===========================================================================\n\n/** The two effects this knob emits: resilient-call's run, token-refresh's mint. */\nexport type AuthedCmd<I> = RunCmd<I> | RefreshTokenCmd;\n\n/**\n * Every Msg the knob's verbs fold. Resilient-call's settle + timer Msgs, token-\n * refresh's refresh-result Msgs, and the 401 trigger the consumer dispatches\n * when a guarded call comes back unauthorized.\n */\nexport type Unauthorized = {\n readonly type: typeof MsgType.Unauthorized;\n readonly key: string;\n readonly at: number;\n};\n\n/**\n * The plain-data error a 401 we cannot fix settles with. A `{_tag, ...}`\n * sentinel — NEVER a `new Error(...)` — so the slice stays JSON-serializable\n * (an Error object round-trips to `{}` under JSON and carries a stack trace,\n * both of which break the slice's durability invariant once it is persisted\n * into a `Store<S>` and reloaded). Mirrors resilient-call's `DeadlineExceededError`\n * sentinel discipline. `key` echoes the call that was rejected so a consumer can\n * attribute the failure.\n */\nexport type UnauthorizedError = {\n readonly _tag: \"unauthorized\";\n readonly key: string;\n};\n\n/** Ports `handlers` needs — resilient-call's run port AND token-refresh's refresh port. */\nexport interface AuthedPorts<I, R> extends ResilientPorts<I, R> {\n /** Mint a fresh credential. Mirrors `AuthedConfig.refresh` for symmetry at the splice site. */\n readonly refresh: () => Promise<Token>;\n}\n\n// ===========================================================================\n// The knob factory.\n// ===========================================================================\n\n/**\n * Build an authed-call knob from `config`. `rng` is injected for retry jitter\n * and threaded straight into the underlying resilient-call knob — pass a fixed\n * `() => 0.5` in tests to pin backoff; defaults to `Math.random` (read only at\n * the verb boundary inside resilient-call, never in a brick op).\n *\n * Returns the uniform L2 knob contract, widened by the auth verbs (`on401`,\n * `onRefreshed`, `installToken`). `I` is the port input type, `R` the result.\n */\nexport function createAuthedCall<I, R>(\n config: AuthedConfig,\n rng: () => number = Math.random,\n) {\n // Delegate ALL call resilience to the root composition. The resilient config\n // is exactly `config` minus the two auth-only fields — but since\n // `ResilientConfig` only reads the brick fields it knows, passing the wider\n // `config` is harmless. We pass it directly so future resilient bricks flow\n // through without a re-spread.\n const rc = createResilientCall<I, R>(config, rng);\n // Delegate the credential lifecycle to the L1 token brick.\n const tr = createTokenRefresh({ skewMs: config.skewMs });\n\n /** The starting slice — both bricks' inits plus empty auth-retry bookkeeping. */\n function init(): AuthedState<I, R> {\n return {\n resilience: rc.init(),\n auth: tr.init(),\n authRetry: {},\n pendingAuthRetry: {},\n };\n }\n\n // Rebuild the host record after a resilient-call verb, preserving the auth\n // dimension. The single seam where a `[ResilientState, Cmd[]]` becomes a\n // `[AuthedState, Cmd[]]`.\n function liftResilient(\n s: AuthedState<I, R>,\n [resilience, cmds]: readonly [ResilientState<I, R>, readonly RunCmd<I>[]],\n ): readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]] {\n return [{ ...s, resilience }, cmds];\n }\n\n // Drop a key from a `Record` immutably.\n function without<V>(\n rec: Readonly<Record<string, V>>,\n key: string,\n ): Readonly<Record<string, V>> {\n const { [key]: _dropped, ...rest } = rec;\n return rest;\n }\n\n // === Verb: attempt =======================================================\n\n /**\n * Start (or restart) the call for `key`. Delegates the gate entirely to\n * resilient-call. A fresh attempt resets this key's auth-retry budget (a new\n * logical call gets its own single 401 retry) and drops any stale parked\n * input. PURE — `at` is the only clock.\n *\n * Note this knob does NOT proactively refresh on expiry inside `attempt`: the\n * expiry-driven refresh is the consumer's call-boundary concern (token-refresh\n * exposes `ensureFresh` for exactly that, re-exported below). `attempt` owns\n * the *reactive* path — it issues the call, and a 401 coming back triggers the\n * refresh. Keeping the two seams separate means a consumer who wants only\n * reactive auth (no proactive refresh) wires nothing extra.\n */\n function attempt(\n s: AuthedState<I, R>,\n key: string,\n input: I,\n at: number,\n ): readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]] {\n const cleared: AuthedState<I, R> = {\n ...s,\n authRetry: without(s.authRetry, key),\n pendingAuthRetry: without(s.pendingAuthRetry, key),\n };\n return liftResilient(\n cleared,\n rc.attempt(cleared.resilience, key, input, at),\n );\n }\n\n // === Verb: succeed =======================================================\n\n /**\n * Record a success for `key`: delegate to resilient-call (closes the breaker,\n * fills the cache, resets retry) and clear this key's auth-retry bookkeeping —\n * the call settled, so its 401 budget and any parked input are forgotten.\n * PURE.\n */\n function succeed(\n s: AuthedState<I, R>,\n key: string,\n msg: SucceedMsg<R>,\n ): readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]] {\n const settled: AuthedState<I, R> = {\n ...s,\n authRetry: without(s.authRetry, key),\n pendingAuthRetry: without(s.pendingAuthRetry, key),\n };\n return liftResilient(settled, rc.succeed(settled.resilience, key, msg));\n }\n\n // === Verb: fail ==========================================================\n\n /**\n * Record a NON-auth failure for `key` (a 5xx, a network drop — anything that\n * is not a 401). Delegate to resilient-call's `fail`, which trips the breaker\n * and backs off / settles per the retry policy. The auth dimension is\n * untouched: a generic failure says nothing about the credential. PURE.\n *\n * A 401 is NOT routed here — the consumer dispatches `unauthorized` and the\n * machine calls `on401` instead. Routing a 401 through `fail` would burn the\n * resilient retry budget on a credential that a plain re-issue cannot fix.\n */\n function fail(\n s: AuthedState<I, R>,\n key: string,\n msg: FailMsg,\n ): readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]] {\n // If the resilient verb settles this call, forget its auth bookkeeping too.\n const [resilience, cmds] = rc.fail(s.resilience, key, msg);\n const next = settleAuthIfDone({ ...s, resilience }, key, resilience);\n return [next, cmds];\n }\n\n // === Verb: on401 =========================================================\n\n /**\n * The auth dimension's one verb: the server rejected `key`'s call as\n * unauthorized. \"Refresh → retry once\":\n *\n * - First 401 on this call → mark the token stale, emit a `refresh_token`\n * Cmd, and PARK the call's input so the refresh landing re-issues it. The\n * resilient call phase is left as-is (still `running`, or `waiting_retry`\n * if a transient failure had already backed it off); the in-flight\n * resilient effect is abandoned in favour of the post-refresh re-issue.\n * - Second 401 (the budget is already spent) → settle the call `failed`\n * with the unauthorized error. No second refresh, no loop. Errors are\n * data: the failure is recorded on the resilient slice, visible to the\n * consumer.\n *\n * A 401 is NOT a downstream-health signal — a credential the server rejected\n * says nothing about the backend's health. So the terminal path routes through\n * resilient-call's `settleFailed` (which settles `failed` WITHOUT tripping the\n * shared breaker or advancing this key's retry counter), never `fail` (which\n * would punish a healthy target and burn the retry budget on a request that a\n * plain re-issue cannot fix).\n *\n * PURE — `at` is carried for symmetry with the other verbs (the refresh Cmd\n * itself carries no time; the refresh port stamps its own result Msg).\n */\n function on401(\n s: AuthedState<I, R>,\n key: string,\n at: number,\n ): readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]] {\n void at; // carried for symmetry; the async refresh's result Msg stamps the clock.\n const spent = s.authRetry[key] ?? 0;\n const call = s.resilience.calls[key];\n // A 401 can arrive while the call is `running` OR after a transient failure\n // backed it off to `waiting_retry`; both hold the input needed to re-issue.\n // Reading only `running` would drop a 401 that races a backoff — the call\n // would never refresh.\n const input =\n call?.phase === \"running\" || call?.phase === \"waiting_retry\"\n ? call.input\n : undefined;\n\n // Budget exhausted (already refreshed + retried once), or nothing to\n // re-issue: settle the call failed with the unauthorized error. Through\n // `settleFailed`, NOT `fail` — a 401 is not a breaker / retry signal.\n if (spent >= 1 || input === undefined) {\n const error: UnauthorizedError = { _tag: \"unauthorized\", key };\n const [settled] = rc.settleFailed(s.resilience, key, error);\n return [\n {\n ...s,\n resilience: settled,\n authRetry: without(s.authRetry, key),\n pendingAuthRetry: without(s.pendingAuthRetry, key),\n },\n [],\n ];\n }\n\n // First 401: mark stale, park the call, ask for a fresh token.\n return [\n {\n ...s,\n auth: tr.on401(s.auth),\n authRetry: { ...s.authRetry, [key]: spent + 1 },\n pendingAuthRetry: { ...s.pendingAuthRetry, [key]: input },\n },\n [refreshTokenCmd()],\n ];\n }\n\n // === Verb: installToken ==================================================\n\n /**\n * Fold a freshly minted `token` into the auth slice (delegates to token-\n * refresh's `refreshed`: installs the token, clears `stale`). PURE. Call this\n * from the `token_refreshed` reducer cell, then chain `onRefreshed` to re-fire\n * the parked calls. Split from `onRefreshed` so a consumer can install a token\n * without auto-retrying (e.g. a proactive expiry refresh with nothing parked).\n */\n function installToken(s: AuthedState<I, R>, token: Token): AuthedState<I, R> {\n return { ...s, auth: tr.refreshed(s.auth, token) };\n }\n\n // === Verb: onRefreshed ===================================================\n\n /**\n * Re-issue every call parked by a 401 now that a fresh token has landed. Runs\n * each parked input back through resilient-call's `attempt` gate (so the\n * circuit / rate-limit / cache are all re-checked against the new `at`),\n * clears the parked set, and accumulates the run Cmds. The auth-retry COUNT is\n * preserved (each re-issued call has already spent one auth-retry, so a second\n * 401 on it settles failed). PURE — `at` is the gate's clock.\n *\n * No parked calls → a no-op that returns the slice unchanged by reference, so\n * a proactive (expiry) refresh with nothing in flight costs nothing.\n */\n function onRefreshed(\n s: AuthedState<I, R>,\n at: number,\n ): readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]] {\n const parked = Object.entries(s.pendingAuthRetry);\n if (parked.length === 0) return [s, []];\n\n let resilience = s.resilience;\n const cmds: AuthedCmd<I>[] = [];\n for (const [key, input] of parked) {\n const [next, runCmds] = rc.attempt(resilience, key, input, at);\n resilience = next;\n cmds.push(...runCmds);\n }\n return [{ ...s, resilience, pendingAuthRetry: {} }, cmds];\n }\n\n // === Verb: onTimer =======================================================\n\n /**\n * A retry / deadline timer fired — delegate straight to resilient-call. If the\n * delegated verb settled the call, forget its auth bookkeeping. PURE.\n */\n function onTimer(\n s: AuthedState<I, R>,\n msg: ResilientTimerMsg,\n ): readonly [AuthedState<I, R>, readonly AuthedCmd<I>[]] {\n const [resilience, cmds] = rc.onTimer(s.resilience, msg);\n const next = settleAuthIfDone({ ...s, resilience }, undefined, resilience);\n return [next, cmds];\n }\n\n // --- helpers --------------------------------------------------------------\n\n // After a resilient verb, drop auth bookkeeping for any key the resilient\n // slice now reports as settled (succeeded / failed). `onlyKey` narrows the\n // scan to one key when the verb targeted one (fail); `undefined` scans all\n // (onTimer can settle a deadline on any key).\n function settleAuthIfDone(\n s: AuthedState<I, R>,\n onlyKey: string | undefined,\n resilience: ResilientState<I, R>,\n ): AuthedState<I, R> {\n let authRetry = s.authRetry;\n let pendingAuthRetry = s.pendingAuthRetry;\n const keys = onlyKey !== undefined ? [onlyKey] : Object.keys(authRetry);\n for (const key of keys) {\n const phase = resilience.calls[key]?.phase;\n if (phase === \"succeeded\" || phase === \"failed\") {\n authRetry = without(authRetry, key);\n pendingAuthRetry = without(pendingAuthRetry, key);\n }\n }\n return { ...s, authRetry, pendingAuthRetry };\n }\n\n // === Subs ================================================================\n\n /**\n * Pre-wired subscriptions — exactly resilient-call's (retry + deadline timers\n * keyed per call). The auth dimension arms NO timers: a refresh is a one-shot\n * Cmd, not a recurring sub, and the parked-call re-issue is driven by the\n * `token_refreshed` Msg, not a timer. Wire `subscribe: { deadline:\n * subscribeDeadline }`.\n */\n function subs(s: AuthedState<I, R>): readonly DeadlineSub[] {\n return rc.subs(s.resilience);\n }\n\n // === Handlers ============================================================\n\n /**\n * Pre-wired interpret handlers: resilient-call's `resilient_run` (wraps the\n * `run` port) AND token-refresh's `refresh_token` (wraps the `refresh` port).\n * Both route Ok/Err through `tryInterpret`, stamping `Date.now()` at the\n * effect boundary — the only clock reads in the whole module. Assign to the\n * machine's `interpret`:\n *\n * interpret: ac.handlers({ run: ctx.call, refresh: () => sdk.mintToken() })\n */\n function handlers(ports: AuthedPorts<I, R>) {\n const trPorts: TokenRefreshPorts = { refresh: ports.refresh };\n return {\n ...rc.handlers({ run: ports.run }),\n ...tr.handlers(trPorts),\n };\n }\n\n return {\n init,\n attempt,\n succeed,\n fail,\n on401,\n installToken,\n onRefreshed,\n onTimer,\n /** token-refresh's call-boundary verb, re-exposed for proactive expiry refresh. */\n needsRefresh: (s: AuthedState<I, R>, at: number) =>\n tr.needsRefresh(s.auth, at),\n subs,\n handlers,\n };\n}\n\n/** The shape `createAuthedCall` returns — useful for typing a held knob. */\nexport type AuthedCall<I, R> = ReturnType<typeof createAuthedCall<I, R>>;\n\n/**\n * Lift a knob result `[slice, cmds]` into a host `[State, cmds]` where the slice\n * lives at `state.authed`. A convenience for the common single-slice host;\n * consumers with a differently-named field spread by hand. Pure — a thin record\n * rebuild, no clock / RNG.\n */\nexport function liftAuthed<\n S extends { authed: AuthedState<I, R> },\n I,\n R,\n C extends Cmd,\n>(\n state: S,\n [slice, cmds]: readonly [AuthedState<I, R>, readonly C[]],\n): readonly [S, readonly C[]] {\n return [{ ...state, authed: slice }, cmds];\n}\n\n/**\n * Re-export the deadline Sub primitives (this knob's `subs` emits `DeadlineSub`s,\n * inherited from resilient-call) and the token-refresh Msg constructors a\n * consumer wires into their reducer, so the whole composition imports from one\n * subpath.\n */\nexport { subscribeDeadline, deadlineSub };\nexport type {\n DeadlineSub,\n DeadlineExceeded,\n ResilientState,\n TokenState,\n Token,\n TokenRefreshMsg,\n SucceedMsg,\n FailMsg,\n ResilientTimerMsg,\n RunCmd,\n};\n"]}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Runtime, CtxArg } from '../index.js';
|
|
2
|
+
import { C as Cmd, S as Sub, M as Machine } from '../core-BoB6t7dB.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @demlik/tea/await-terminal — run a ONE-SHOT / decision-procedure machine to a
|
|
6
|
+
* terminal state and hand the caller a `Promise<State>` that resolves the moment
|
|
7
|
+
* the machine FIRST enters a terminal state.
|
|
8
|
+
*
|
|
9
|
+
* This is the ~10 lines both audit suitability probes hand-wired at their
|
|
10
|
+
* integration seam: a one-shot decision machine (a credibility check, an auth
|
|
11
|
+
* mint, a validation pass — run to a terminal state, then throw-or-proceed at the
|
|
12
|
+
* caller boundary) has no built-in "await the terminal", so each consumer re-wires
|
|
13
|
+
* a promise off `observe` that resolves when the state is terminal. This helper
|
|
14
|
+
* lifts that boilerplate into `@demlik/tea` once.
|
|
15
|
+
*
|
|
16
|
+
* It rides the EXISTING observation surface (`packages/tea/src/index.ts`) and adds
|
|
17
|
+
* NO new runtime capability:
|
|
18
|
+
* - `runtime.getState()` (total on a booted `Runtime`) covers the
|
|
19
|
+
* already-terminal-at-attach case — resolve synchronously rather than wait for
|
|
20
|
+
* a transition that may never come.
|
|
21
|
+
* - `runtime.observe((msg, state) => void): () => void` fires on EVERY transition
|
|
22
|
+
* and returns an unsubscribe — the "first terminal transition" signal, detached
|
|
23
|
+
* the instant it resolves (no leak, no busy-wait / polling loop).
|
|
24
|
+
* - `runtime.stop()` is the teardown the fire-and-await `runToTerminal` owns.
|
|
25
|
+
*
|
|
26
|
+
* `isTerminal` is caller-supplied — tea has no built-in terminal-set concept and
|
|
27
|
+
* this helper does NOT invent one; the caller names their own terminal states.
|
|
28
|
+
*
|
|
29
|
+
* The optional `timeoutMs` is a plain caller-boundary timer (not an in-loop
|
|
30
|
+
* progress deadline — see `../deadline` / `../with-deadline` for that shape): it
|
|
31
|
+
* clears on resolve and REJECTS with the `instanceof`-checkable
|
|
32
|
+
* {@link TerminalTimeoutError} if the deadline elapses first, never silently
|
|
33
|
+
* resolving.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Raised when `awaitTerminal` / `runToTerminal` is wired with a `timeoutMs` and
|
|
38
|
+
* the deadline elapses before any terminal state is reached. Follows the tea
|
|
39
|
+
* error dialect (`override readonly name`) and carries a `_tag` discriminant, so
|
|
40
|
+
* a caller can branch on it either by `instanceof` or on the tag — the timeout is
|
|
41
|
+
* a distinct, checkable rejection, never a silent resolve.
|
|
42
|
+
*/
|
|
43
|
+
declare class TerminalTimeoutError extends Error {
|
|
44
|
+
readonly timeoutMs: number;
|
|
45
|
+
readonly name = "TerminalTimeoutError";
|
|
46
|
+
readonly _tag: "TerminalTimeoutError";
|
|
47
|
+
constructor(timeoutMs: number);
|
|
48
|
+
}
|
|
49
|
+
/** Options shared by `awaitTerminal` and `runToTerminal`. */
|
|
50
|
+
interface AwaitTerminalOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Optional bounded deadline in milliseconds. When set, the returned promise
|
|
53
|
+
* REJECTS with {@link TerminalTimeoutError} if no terminal state is reached
|
|
54
|
+
* within the window; the timer is cleared on the resolve path so a resolved
|
|
55
|
+
* await never later rejects or leaves a dangling timer. Omit for an unbounded
|
|
56
|
+
* await (never rejects for time).
|
|
57
|
+
*/
|
|
58
|
+
readonly timeoutMs?: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Attach to an ALREADY-RUNNING `Runtime<S, M, E>` and resolve with the terminal
|
|
62
|
+
* `S` on the first transition for which `isTerminal(state)` is true.
|
|
63
|
+
*
|
|
64
|
+
* If the runtime is ALREADY terminal at attach time (`isTerminal(getState())`),
|
|
65
|
+
* resolves immediately — it never hangs waiting for a transition that will not
|
|
66
|
+
* come. Otherwise it subscribes via `observe` and detaches that subscription the
|
|
67
|
+
* instant it resolves (or times out), so there is no observer leak and no
|
|
68
|
+
* further callbacks fire.
|
|
69
|
+
*
|
|
70
|
+
* @param runtime a booted `Runtime` (from `await run(machine, opts).ready`).
|
|
71
|
+
* @param isTerminal caller-supplied terminal predicate over the machine's state.
|
|
72
|
+
* @param opts optional bounded `timeoutMs` deadline (see {@link AwaitTerminalOptions}).
|
|
73
|
+
*/
|
|
74
|
+
declare function awaitTerminal<S, M extends {
|
|
75
|
+
type: string;
|
|
76
|
+
}, E extends {
|
|
77
|
+
type: string;
|
|
78
|
+
} = never>(runtime: Runtime<S, M, E>, isTerminal: (state: S) => boolean, opts?: AwaitTerminalOptions): Promise<S>;
|
|
79
|
+
/**
|
|
80
|
+
* Fire-and-await convenience: `run()` the machine, dispatch the seed `msgs`, and
|
|
81
|
+
* resolve with the terminal state — then tear the runtime down (`runtime.stop()`)
|
|
82
|
+
* on BOTH the resolve and reject paths. Wraps {@link awaitTerminal}.
|
|
83
|
+
*
|
|
84
|
+
* The seed carries the machine's `ctx` (conditionally optional, exactly as
|
|
85
|
+
* `run`'s `ctx` — a pure machine omits it) and the `msgs` to dispatch once
|
|
86
|
+
* booted. A `timeoutMs` in `opts` bounds the await identically to `awaitTerminal`.
|
|
87
|
+
*
|
|
88
|
+
* @param machine the machine to boot.
|
|
89
|
+
* @param seed `{ ctx?, msgs }` — the boot context and the seed messages.
|
|
90
|
+
* @param isTerminal caller-supplied terminal predicate over the machine's state.
|
|
91
|
+
* @param opts optional bounded `timeoutMs` deadline (see {@link AwaitTerminalOptions}).
|
|
92
|
+
*/
|
|
93
|
+
declare function runToTerminal<S, M extends {
|
|
94
|
+
type: string;
|
|
95
|
+
}, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, seed: CtxArg<Ctx> & {
|
|
96
|
+
readonly msgs: readonly M[];
|
|
97
|
+
}, isTerminal: (state: S) => boolean, opts?: AwaitTerminalOptions): Promise<S>;
|
|
98
|
+
|
|
99
|
+
export { type AwaitTerminalOptions, TerminalTimeoutError, awaitTerminal, runToTerminal };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { run } from '../chunk-MMH7CW5K.js';
|
|
2
|
+
import '../chunk-QLA76GIT.js';
|
|
3
|
+
|
|
4
|
+
// src/await-terminal/index.ts
|
|
5
|
+
var TerminalTimeoutError = class extends Error {
|
|
6
|
+
constructor(timeoutMs) {
|
|
7
|
+
super(
|
|
8
|
+
`@demlik/tea: awaitTerminal did not reach a terminal state within ${timeoutMs}ms \u2014 no transition satisfied isTerminal before the deadline.`
|
|
9
|
+
);
|
|
10
|
+
this.timeoutMs = timeoutMs;
|
|
11
|
+
}
|
|
12
|
+
name = "TerminalTimeoutError";
|
|
13
|
+
_tag = "TerminalTimeoutError";
|
|
14
|
+
};
|
|
15
|
+
function awaitTerminal(runtime, isTerminal, opts = {}) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const current = runtime.getState();
|
|
18
|
+
if (isTerminal(current)) {
|
|
19
|
+
resolve(current);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
let settled = false;
|
|
23
|
+
let timer;
|
|
24
|
+
let detach;
|
|
25
|
+
const cleanup = () => {
|
|
26
|
+
if (timer !== void 0) {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
timer = void 0;
|
|
29
|
+
}
|
|
30
|
+
if (detach !== void 0) {
|
|
31
|
+
detach();
|
|
32
|
+
detach = void 0;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
detach = runtime.observe((_msg, state) => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
if (isTerminal(state)) {
|
|
38
|
+
settled = true;
|
|
39
|
+
cleanup();
|
|
40
|
+
resolve(state);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
const timeoutMs = opts.timeoutMs;
|
|
44
|
+
if (timeoutMs !== void 0) {
|
|
45
|
+
timer = setTimeout(() => {
|
|
46
|
+
if (settled) return;
|
|
47
|
+
settled = true;
|
|
48
|
+
cleanup();
|
|
49
|
+
reject(new TerminalTimeoutError(timeoutMs));
|
|
50
|
+
}, timeoutMs);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async function runToTerminal(machine, seed, isTerminal, opts = {}) {
|
|
55
|
+
const { msgs, ...ctxArg } = seed;
|
|
56
|
+
const runtime = await run(
|
|
57
|
+
machine,
|
|
58
|
+
ctxArg
|
|
59
|
+
).ready;
|
|
60
|
+
try {
|
|
61
|
+
const terminal = awaitTerminal(runtime, isTerminal, opts);
|
|
62
|
+
const seeding = Promise.all(msgs.map((msg) => runtime.dispatch(msg)));
|
|
63
|
+
const state = await Promise.race([terminal, seeding.then(() => terminal)]);
|
|
64
|
+
await runtime.stop();
|
|
65
|
+
return state;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
await runtime.stop().catch(() => {
|
|
68
|
+
});
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { TerminalTimeoutError, awaitTerminal, runToTerminal };
|
|
74
|
+
//# sourceMappingURL=index.js.map
|
|
75
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/await-terminal/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @demlik/tea/await-terminal — run a ONE-SHOT / decision-procedure machine to a\n * terminal state and hand the caller a `Promise<State>` that resolves the moment\n * the machine FIRST enters a terminal state.\n *\n * This is the ~10 lines both audit suitability probes hand-wired at their\n * integration seam: a one-shot decision machine (a credibility check, an auth\n * mint, a validation pass — run to a terminal state, then throw-or-proceed at the\n * caller boundary) has no built-in \"await the terminal\", so each consumer re-wires\n * a promise off `observe` that resolves when the state is terminal. This helper\n * lifts that boilerplate into `@demlik/tea` once.\n *\n * It rides the EXISTING observation surface (`packages/tea/src/index.ts`) and adds\n * NO new runtime capability:\n * - `runtime.getState()` (total on a booted `Runtime`) covers the\n * already-terminal-at-attach case — resolve synchronously rather than wait for\n * a transition that may never come.\n * - `runtime.observe((msg, state) => void): () => void` fires on EVERY transition\n * and returns an unsubscribe — the \"first terminal transition\" signal, detached\n * the instant it resolves (no leak, no busy-wait / polling loop).\n * - `runtime.stop()` is the teardown the fire-and-await `runToTerminal` owns.\n *\n * `isTerminal` is caller-supplied — tea has no built-in terminal-set concept and\n * this helper does NOT invent one; the caller names their own terminal states.\n *\n * The optional `timeoutMs` is a plain caller-boundary timer (not an in-loop\n * progress deadline — see `../deadline` / `../with-deadline` for that shape): it\n * clears on resolve and REJECTS with the `instanceof`-checkable\n * {@link TerminalTimeoutError} if the deadline elapses first, never silently\n * resolving.\n */\n\nimport {\n type Cmd,\n type CtxArg,\n type Machine,\n type Runtime,\n run,\n type Sub,\n} from \"../index\";\n\n/**\n * Raised when `awaitTerminal` / `runToTerminal` is wired with a `timeoutMs` and\n * the deadline elapses before any terminal state is reached. Follows the tea\n * error dialect (`override readonly name`) and carries a `_tag` discriminant, so\n * a caller can branch on it either by `instanceof` or on the tag — the timeout is\n * a distinct, checkable rejection, never a silent resolve.\n */\nexport class TerminalTimeoutError extends Error {\n override readonly name = \"TerminalTimeoutError\";\n readonly _tag = \"TerminalTimeoutError\" as const;\n constructor(public readonly timeoutMs: number) {\n super(\n `@demlik/tea: awaitTerminal did not reach a terminal state within ` +\n `${timeoutMs}ms — no transition satisfied isTerminal before the deadline.`,\n );\n }\n}\n\n/** Options shared by `awaitTerminal` and `runToTerminal`. */\nexport interface AwaitTerminalOptions {\n /**\n * Optional bounded deadline in milliseconds. When set, the returned promise\n * REJECTS with {@link TerminalTimeoutError} if no terminal state is reached\n * within the window; the timer is cleared on the resolve path so a resolved\n * await never later rejects or leaves a dangling timer. Omit for an unbounded\n * await (never rejects for time).\n */\n readonly timeoutMs?: number;\n}\n\n/**\n * Attach to an ALREADY-RUNNING `Runtime<S, M, E>` and resolve with the terminal\n * `S` on the first transition for which `isTerminal(state)` is true.\n *\n * If the runtime is ALREADY terminal at attach time (`isTerminal(getState())`),\n * resolves immediately — it never hangs waiting for a transition that will not\n * come. Otherwise it subscribes via `observe` and detaches that subscription the\n * instant it resolves (or times out), so there is no observer leak and no\n * further callbacks fire.\n *\n * @param runtime a booted `Runtime` (from `await run(machine, opts).ready`).\n * @param isTerminal caller-supplied terminal predicate over the machine's state.\n * @param opts optional bounded `timeoutMs` deadline (see {@link AwaitTerminalOptions}).\n */\nexport function awaitTerminal<\n S,\n M extends { type: string },\n E extends { type: string } = never,\n>(\n runtime: Runtime<S, M, E>,\n isTerminal: (state: S) => boolean,\n opts: AwaitTerminalOptions = {},\n): Promise<S> {\n return new Promise<S>((resolve, reject) => {\n // Already-terminal-at-attach: resolve now. Returning here attaches neither an\n // observer nor a timer, so the immediate case cannot leak either.\n const current = runtime.getState();\n if (isTerminal(current)) {\n resolve(current);\n return;\n }\n\n let settled = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n let detach: (() => void) | undefined;\n\n const cleanup = (): void => {\n if (timer !== undefined) {\n clearTimeout(timer);\n timer = undefined;\n }\n if (detach !== undefined) {\n detach();\n detach = undefined;\n }\n };\n\n detach = runtime.observe((_msg, state) => {\n if (settled) return;\n if (isTerminal(state)) {\n settled = true;\n cleanup();\n resolve(state);\n }\n });\n\n // Narrow into a const so the closure keeps the `number` narrowing (the field\n // itself is `number | undefined`).\n const timeoutMs = opts.timeoutMs;\n if (timeoutMs !== undefined) {\n timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n cleanup();\n reject(new TerminalTimeoutError(timeoutMs));\n }, timeoutMs);\n }\n });\n}\n\n/**\n * Fire-and-await convenience: `run()` the machine, dispatch the seed `msgs`, and\n * resolve with the terminal state — then tear the runtime down (`runtime.stop()`)\n * on BOTH the resolve and reject paths. Wraps {@link awaitTerminal}.\n *\n * The seed carries the machine's `ctx` (conditionally optional, exactly as\n * `run`'s `ctx` — a pure machine omits it) and the `msgs` to dispatch once\n * booted. A `timeoutMs` in `opts` bounds the await identically to `awaitTerminal`.\n *\n * @param machine the machine to boot.\n * @param seed `{ ctx?, msgs }` — the boot context and the seed messages.\n * @param isTerminal caller-supplied terminal predicate over the machine's state.\n * @param opts optional bounded `timeoutMs` deadline (see {@link AwaitTerminalOptions}).\n */\nexport async function runToTerminal<\n S,\n M extends { type: string },\n C extends Cmd,\n U extends Sub,\n Ctx,\n>(\n machine: Machine<S, M, C, U, Ctx>,\n seed: CtxArg<Ctx> & { readonly msgs: readonly M[] },\n isTerminal: (state: S) => boolean,\n opts: AwaitTerminalOptions = {},\n): Promise<S> {\n // Split the seed messages out from the boot context: `run`'s opts don't carry\n // `msgs`, so what remains is exactly the conditionally-optional `ctx` arg. The\n // cast is because `CtxArg<Ctx>` is a deferred generic conditional TS won't\n // verify against `run`'s all-optional opts (its weak-type check) — the value\n // is structurally valid; `run` defaults an absent `ctx` to `{}`.\n const { msgs, ...ctxArg } = seed;\n const runtime = await run<S, M, C, U, Ctx>(\n machine,\n ctxArg as Parameters<typeof run<S, M, C, U, Ctx>>[1],\n ).ready;\n try {\n // Attach the terminal await BEFORE dispatching so a terminal transition that\n // lands during a seed dispatch is caught (and the immediate-terminal check\n // inside awaitTerminal covers a machine that boots already terminal).\n const terminal = awaitTerminal(runtime, isTerminal, opts);\n // Dispatch runs serially on the runtime's tail; fire all seeds and let a\n // dispatch failure (a reducer/interpret throw) surface by racing it against\n // the terminal await rather than floating as an unhandled rejection.\n const seeding = Promise.all(msgs.map((msg) => runtime.dispatch(msg)));\n const state = await Promise.race([terminal, seeding.then(() => terminal)]);\n await runtime.stop();\n return state;\n } catch (err) {\n // Tear down on the reject path too (timeout, dispatch failure) — a rejected\n // runToTerminal never leaves the runtime running.\n await runtime.stop().catch(() => {});\n throw err;\n }\n}\n"]}
|