@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.
Files changed (243) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agent/index.d.ts +907 -0
  3. package/dist/agent/index.js +20 -0
  4. package/dist/agent/index.js.map +1 -0
  5. package/dist/authed-call/index.d.ts +119 -0
  6. package/dist/authed-call/index.js +148 -0
  7. package/dist/authed-call/index.js.map +1 -0
  8. package/dist/await-terminal/index.d.ts +99 -0
  9. package/dist/await-terminal/index.js +75 -0
  10. package/dist/await-terminal/index.js.map +1 -0
  11. package/dist/batch-window/index.d.ts +259 -0
  12. package/dist/batch-window/index.js +43 -0
  13. package/dist/batch-window/index.js.map +1 -0
  14. package/dist/cache/index.d.ts +2 -2
  15. package/dist/cache/index.js +2 -50
  16. package/dist/cache/index.js.map +1 -1
  17. package/dist/chunk-27N2SPTN.js +222 -0
  18. package/dist/chunk-27N2SPTN.js.map +1 -0
  19. package/dist/chunk-2YOE5PIS.js +45 -0
  20. package/dist/chunk-2YOE5PIS.js.map +1 -0
  21. package/dist/chunk-3545BOYL.js +65 -0
  22. package/dist/chunk-3545BOYL.js.map +1 -0
  23. package/dist/chunk-3XJWC7VW.js +61 -0
  24. package/dist/chunk-3XJWC7VW.js.map +1 -0
  25. package/dist/chunk-5DEQ5I3U.js +132 -0
  26. package/dist/chunk-5DEQ5I3U.js.map +1 -0
  27. package/dist/chunk-5VNWZADP.js +49 -0
  28. package/dist/chunk-5VNWZADP.js.map +1 -0
  29. package/dist/chunk-6RGBKODV.js +268 -0
  30. package/dist/chunk-6RGBKODV.js.map +1 -0
  31. package/dist/{chunk-ZRUST6BK.js → chunk-7FYQGDMF.js} +2 -2
  32. package/dist/chunk-7FYQGDMF.js.map +1 -0
  33. package/dist/chunk-7W4LMPUW.js +186 -0
  34. package/dist/chunk-7W4LMPUW.js.map +1 -0
  35. package/dist/chunk-BYQUGDD3.js +16 -0
  36. package/dist/chunk-BYQUGDD3.js.map +1 -0
  37. package/dist/{chunk-PYBQREFU.js → chunk-DILMKOZ7.js} +22 -26
  38. package/dist/chunk-DILMKOZ7.js.map +1 -0
  39. package/dist/chunk-EHIX7NYZ.js +93 -0
  40. package/dist/chunk-EHIX7NYZ.js.map +1 -0
  41. package/dist/chunk-HCEVHFJ7.js +54 -0
  42. package/dist/chunk-HCEVHFJ7.js.map +1 -0
  43. package/dist/chunk-HJO6VNQI.js +15 -0
  44. package/dist/chunk-HJO6VNQI.js.map +1 -0
  45. package/dist/chunk-IN33Q4VJ.js +52 -0
  46. package/dist/chunk-IN33Q4VJ.js.map +1 -0
  47. package/dist/chunk-J6527VRQ.js +56 -0
  48. package/dist/chunk-J6527VRQ.js.map +1 -0
  49. package/dist/chunk-JELSZN25.js +47 -0
  50. package/dist/chunk-JELSZN25.js.map +1 -0
  51. package/dist/chunk-K575PT4S.js +58 -0
  52. package/dist/chunk-K575PT4S.js.map +1 -0
  53. package/dist/chunk-K7Q23EQ4.js +20 -0
  54. package/dist/chunk-K7Q23EQ4.js.map +1 -0
  55. package/dist/chunk-LEC4U6CX.js +91 -0
  56. package/dist/chunk-LEC4U6CX.js.map +1 -0
  57. package/dist/{chunk-PYOCUBQJ.js → chunk-LP7VQBRG.js} +22 -15
  58. package/dist/chunk-LP7VQBRG.js.map +1 -0
  59. package/dist/{chunk-CMG2KMWX.js → chunk-MMH7CW5K.js} +220 -189
  60. package/dist/chunk-MMH7CW5K.js.map +1 -0
  61. package/dist/{chunk-Y5JM3UUH.js → chunk-NSDFA6M6.js} +75 -3
  62. package/dist/chunk-NSDFA6M6.js.map +1 -0
  63. package/dist/chunk-ON24RKSE.js +60 -0
  64. package/dist/chunk-ON24RKSE.js.map +1 -0
  65. package/dist/chunk-PS23H6KQ.js +87 -0
  66. package/dist/chunk-PS23H6KQ.js.map +1 -0
  67. package/dist/chunk-QLA76GIT.js +162 -0
  68. package/dist/chunk-QLA76GIT.js.map +1 -0
  69. package/dist/chunk-S3FQN4SL.js +506 -0
  70. package/dist/chunk-S3FQN4SL.js.map +1 -0
  71. package/dist/chunk-SC3FAFZN.js +53 -0
  72. package/dist/chunk-SC3FAFZN.js.map +1 -0
  73. package/dist/chunk-SINXE47Y.js +55 -0
  74. package/dist/chunk-SINXE47Y.js.map +1 -0
  75. package/dist/chunk-SQTCS44W.js +33 -0
  76. package/dist/chunk-SQTCS44W.js.map +1 -0
  77. package/dist/chunk-TGG26XMK.js +52 -0
  78. package/dist/chunk-TGG26XMK.js.map +1 -0
  79. package/dist/chunk-UV6UWGEO.js +147 -0
  80. package/dist/chunk-UV6UWGEO.js.map +1 -0
  81. package/dist/chunk-UYWUVEP6.js +158 -0
  82. package/dist/chunk-UYWUVEP6.js.map +1 -0
  83. package/dist/chunk-VLGARN6H.js +202 -0
  84. package/dist/chunk-VLGARN6H.js.map +1 -0
  85. package/dist/circuit-breaker/index.js +1 -51
  86. package/dist/circuit-breaker/index.js.map +1 -1
  87. package/dist/core-BoB6t7dB.d.ts +188 -0
  88. package/dist/deadline/index.d.ts +24 -5
  89. package/dist/deadline/index.js +4 -18
  90. package/dist/deadline/index.js.map +1 -1
  91. package/dist/debounce/index.d.ts +6 -0
  92. package/dist/debounce/index.js +1 -54
  93. package/dist/debounce/index.js.map +1 -1
  94. package/dist/devtools/index.d.ts +1 -1
  95. package/dist/devtools/index.js +18 -6
  96. package/dist/devtools/index.js.map +1 -1
  97. package/dist/do/index.d.ts +1723 -92
  98. package/dist/do/index.js +673 -31
  99. package/dist/do/index.js.map +1 -1
  100. package/dist/durable-effects-CO9Uh971.d.ts +180 -0
  101. package/dist/extension/index.d.ts +31 -13
  102. package/dist/extension/index.js +5 -3
  103. package/dist/extension/index.js.map +1 -1
  104. package/dist/extension/react.js +8 -3
  105. package/dist/extension/react.js.map +1 -1
  106. package/dist/extension/subs/index.d.ts +2 -2
  107. package/dist/extension/subs/index.js.map +1 -1
  108. package/dist/extension/test-utils.js +11 -3
  109. package/dist/extension/test-utils.js.map +1 -1
  110. package/dist/fan-out/index.d.ts +239 -0
  111. package/dist/fan-out/index.js +5 -0
  112. package/dist/fan-out/index.js.map +1 -0
  113. package/dist/idempotency/adapter.d.ts +62 -0
  114. package/dist/idempotency/adapter.js +4 -0
  115. package/dist/idempotency/adapter.js.map +1 -0
  116. package/dist/idempotency/index.js +1 -56
  117. package/dist/idempotency/index.js.map +1 -1
  118. package/dist/idempotent-intake/index.d.ts +186 -0
  119. package/dist/idempotent-intake/index.js +7 -0
  120. package/dist/idempotent-intake/index.js.map +1 -0
  121. package/dist/index.d.ts +341 -191
  122. package/dist/index.js +2 -1
  123. package/dist/llm-call/index.d.ts +309 -0
  124. package/dist/llm-call/index.js +14 -0
  125. package/dist/llm-call/index.js.map +1 -0
  126. package/dist/machine-viz/index.d.ts +4 -4
  127. package/dist/machine-viz/index.js +5 -9
  128. package/dist/machine-viz/index.js.map +1 -1
  129. package/dist/mem/index.d.ts +1 -0
  130. package/dist/monitored-run/index.d.ts +253 -0
  131. package/dist/monitored-run/index.js +10 -0
  132. package/dist/monitored-run/index.js.map +1 -0
  133. package/dist/node/index.d.ts +5 -5
  134. package/dist/node/index.js.map +1 -1
  135. package/dist/paginated-walk/index.d.ts +234 -0
  136. package/dist/paginated-walk/index.js +15 -0
  137. package/dist/paginated-walk/index.js.map +1 -0
  138. package/dist/paginator/index.d.ts +271 -0
  139. package/dist/paginator/index.js +3 -0
  140. package/dist/paginator/index.js.map +1 -0
  141. package/dist/parity/index.d.ts +92 -0
  142. package/dist/parity/index.js +78 -0
  143. package/dist/parity/index.js.map +1 -0
  144. package/dist/pbt/arbitraries/index.d.ts +1 -1
  145. package/dist/pbt/arbitraries/index.js +1 -1
  146. package/dist/pbt/index.d.ts +15 -8
  147. package/dist/pbt/index.js +6 -11
  148. package/dist/pbt/index.js.map +1 -1
  149. package/dist/pbt/runners/index.d.ts +1 -1
  150. package/dist/pbt/runners/index.js +3 -2
  151. package/dist/poller/index.d.ts +288 -0
  152. package/dist/poller/index.js +79 -0
  153. package/dist/poller/index.js.map +1 -0
  154. package/dist/prediction/index.d.ts +130 -0
  155. package/dist/prediction/index.js +4 -0
  156. package/dist/prediction/index.js.map +1 -0
  157. package/dist/protocol-B5YmJr0d.d.ts +45 -0
  158. package/dist/pure/index.d.ts +2 -0
  159. package/dist/pure/index.js +4 -0
  160. package/dist/pure/index.js.map +1 -0
  161. package/dist/raft/demo.d.ts +105 -0
  162. package/dist/raft/demo.js +172 -0
  163. package/dist/raft/demo.js.map +1 -0
  164. package/dist/raft/index.d.ts +548 -0
  165. package/dist/raft/index.js +7 -0
  166. package/dist/raft/index.js.map +1 -0
  167. package/dist/raft/sim.d.ts +222 -0
  168. package/dist/raft/sim.js +8 -0
  169. package/dist/raft/sim.js.map +1 -0
  170. package/dist/raft/timeline.d.ts +46 -0
  171. package/dist/raft/timeline.js +62 -0
  172. package/dist/raft/timeline.js.map +1 -0
  173. package/dist/rate-limit/index.js +1 -43
  174. package/dist/rate-limit/index.js.map +1 -1
  175. package/dist/react/index.d.ts +2 -1
  176. package/dist/react/index.js +43 -20
  177. package/dist/react/index.js.map +1 -1
  178. package/dist/reconciler/index.d.ts +192 -0
  179. package/dist/reconciler/index.js +166 -0
  180. package/dist/reconciler/index.js.map +1 -0
  181. package/dist/recorder/index.d.ts +7 -3
  182. package/dist/recorder/index.js +1 -155
  183. package/dist/recorder/index.js.map +1 -1
  184. package/dist/resilient-call/index.d.ts +294 -0
  185. package/dist/resilient-call/index.js +13 -0
  186. package/dist/resilient-call/index.js.map +1 -0
  187. package/dist/retry-backoff/index.d.ts +42 -5
  188. package/dist/retry-backoff/index.js +1 -35
  189. package/dist/retry-backoff/index.js.map +1 -1
  190. package/dist/retry-to-success/index.d.ts +109 -0
  191. package/dist/retry-to-success/index.js +35 -0
  192. package/dist/retry-to-success/index.js.map +1 -0
  193. package/dist/saga/index.d.ts +238 -0
  194. package/dist/saga/index.js +150 -0
  195. package/dist/saga/index.js.map +1 -0
  196. package/dist/snapshot/index.d.ts +334 -0
  197. package/dist/snapshot/index.js +5 -0
  198. package/dist/snapshot/index.js.map +1 -0
  199. package/dist/subs/index.d.ts +48 -10
  200. package/dist/subs/index.js +1 -1
  201. package/dist/testing/index.d.ts +107 -2
  202. package/dist/testing/index.js +204 -6
  203. package/dist/testing/index.js.map +1 -1
  204. package/dist/throttle/index.d.ts +9 -0
  205. package/dist/throttle/index.js +1 -52
  206. package/dist/throttle/index.js.map +1 -1
  207. package/dist/throttled-input/index.d.ts +380 -0
  208. package/dist/throttled-input/index.js +88 -0
  209. package/dist/throttled-input/index.js.map +1 -0
  210. package/dist/token-refresh/index.d.ts +215 -0
  211. package/dist/token-refresh/index.js +6 -0
  212. package/dist/token-refresh/index.js.map +1 -0
  213. package/dist/trace-replay/index.d.ts +11 -2
  214. package/dist/trace-replay/index.js +3 -55
  215. package/dist/trace-replay/index.js.map +1 -1
  216. package/dist/{types-CwBC5wMn.d.ts → types-B-8bcAp8.d.ts} +1 -1
  217. package/dist/with-deadline/index.d.ts +196 -0
  218. package/dist/with-deadline/index.js +111 -0
  219. package/dist/with-deadline/index.js.map +1 -0
  220. package/dist/with-resilience/index.d.ts +205 -0
  221. package/dist/with-resilience/index.js +251 -0
  222. package/dist/with-resilience/index.js.map +1 -0
  223. package/dist/with-telemetry/index.d.ts +149 -0
  224. package/dist/with-telemetry/index.js +70 -0
  225. package/dist/with-telemetry/index.js.map +1 -0
  226. package/dist/work-queue/adapter.d.ts +82 -0
  227. package/dist/work-queue/adapter.js +4 -0
  228. package/dist/work-queue/adapter.js.map +1 -0
  229. package/dist/work-queue/index.d.ts +25 -11
  230. package/dist/work-queue/index.js +1 -46
  231. package/dist/work-queue/index.js.map +1 -1
  232. package/dist/work-queue/ops.d.ts +69 -0
  233. package/dist/work-queue/ops.js +3 -0
  234. package/dist/work-queue/ops.js.map +1 -0
  235. package/dist/workflow/index.d.ts +450 -0
  236. package/dist/workflow/index.js +225 -0
  237. package/dist/workflow/index.js.map +1 -0
  238. package/package.json +168 -25
  239. package/dist/chunk-CMG2KMWX.js.map +0 -1
  240. package/dist/chunk-PYBQREFU.js.map +0 -1
  241. package/dist/chunk-PYOCUBQJ.js.map +0 -1
  242. package/dist/chunk-Y5JM3UUH.js.map +0 -1
  243. package/dist/chunk-ZRUST6BK.js.map +0 -1
@@ -0,0 +1,222 @@
1
+ import { NodeId, RaftCmd, RaftState, RaftConfig } from './index.js';
2
+ export { LogEntry } from './index.js';
3
+ import '../deadline/index.js';
4
+ import '../core-BoB6t7dB.js';
5
+ import '../types-B-8bcAp8.js';
6
+
7
+ /**
8
+ * @demlik/tea/raft — a deterministic, in-memory multi-node Raft simulation
9
+ * driver. Slice #121 (the safety-property half) of the consensus epic (#117):
10
+ * the headline payoff is consensus verified by **deterministic replay**, not by
11
+ * flaky wall-clock timing.
12
+ *
13
+ * This module owns NO wall clock, NO real timers, and NO networking. Time and
14
+ * message ordering are entirely the *schedule* — a finite list of {@link SimEvent}s
15
+ * the caller (a fast-check generator or an explicit fixture) hands the driver.
16
+ * The driver folds the schedule one event at a time over a cluster of pure
17
+ * {@link createRaftNode}s, routing every emitted `Cmd` into a pending-message
18
+ * pool and recording the full `(event → resulting cluster states)` trace.
19
+ *
20
+ * Because the underlying reducer is pure (no ambient clock — `at` rides on the
21
+ * Msg; no ambient RNG — `rng` injected once per node), the same schedule re-run
22
+ * yields **byte-identical** cluster state. That replay identity is the property
23
+ * the safety suite (`safety.test.ts`) leans on: a violated invariant shrinks to
24
+ * a *replayable schedule*, not an irreproducible timing fluke (errors are data).
25
+ *
26
+ * ## The schedule model
27
+ *
28
+ * The cluster is N nodes plus a pending-message pool. Each {@link SimEvent} is
29
+ * one of two moves:
30
+ *
31
+ * - `{ kind: "timer", node, timer }` — fire node's election OR heartbeat timer.
32
+ * Drives an `onElectionTimeout` / `onHeartbeat` verb on that node. The
33
+ * firing `at` is the driver's monotonic logical clock (one tick per event),
34
+ * so no verb reads a real clock.
35
+ * - `{ kind: "deliver", index }` — deliver the in-flight message at `index` in
36
+ * the pending pool to its target node, as the matching inbound `RaftMsg`.
37
+ * An out-of-range `index` is taken modulo the pool size (so a fast-check
38
+ * integer always names *some* deliverable message when the pool is
39
+ * non-empty); a `deliver` against an empty pool is a no-op event (recorded,
40
+ * advances the clock, changes nothing) — the schedule stays total.
41
+ *
42
+ * Delivering a message removes it from the pool and feeds it to the target's
43
+ * verb; any Cmds that verb emits are appended to the pool (each Cmd carries its
44
+ * own `to`, so routing is just a lookup). This is the in-memory transport: a
45
+ * `SendRequestVote{to}` delivered becomes a `RequestVoteRequest` on `to`; the
46
+ * `SendRequestVoteReply{to}` it emits becomes a `RequestVoteReply` on the
47
+ * original candidate; and so on around the RPC pairs.
48
+ *
49
+ * ## Record / replay
50
+ *
51
+ * {@link runSchedule} returns a {@link SimTrace}: the ordered list of steps, each
52
+ * the event applied and the resulting per-node {@link RaftState} snapshot, plus
53
+ * the final cluster. {@link replaySchedule} re-runs the identical schedule from a
54
+ * fresh cluster; {@link clusterStatesEqual} compares two cluster snapshots
55
+ * structurally. The safety suite asserts `runSchedule(s)` and
56
+ * `replaySchedule(s)` agree byte-for-byte.
57
+ *
58
+ * NOT a substrate primitive and NOT shipped in the node's public verb surface:
59
+ * it depends only on `./index` (the pure node) read-only, and lives beside the
60
+ * node as a test/replay harness.
61
+ */
62
+
63
+ /** Which of a node's two timers a `timer` event fires. */
64
+ type TimerKind = "election" | "heartbeat";
65
+ /**
66
+ * One move in a schedule. The whole simulation is a fold of these — there is no
67
+ * other source of time or ordering. `_tag`-style `kind` discriminant matches the
68
+ * engine's tagged-union convention.
69
+ */
70
+ type SimEvent = {
71
+ /** Fire a node's timer (drives `onElectionTimeout` / `onHeartbeat`). */
72
+ readonly kind: "timer";
73
+ /** The node whose timer fires. */
74
+ readonly node: NodeId;
75
+ /** Which timer. */
76
+ readonly timer: TimerKind;
77
+ } | {
78
+ /**
79
+ * Deliver an in-flight message from the pending pool to its target. The
80
+ * index is taken modulo the pool size, so any integer names a deliverable
81
+ * message when the pool is non-empty; a deliver against an empty pool is a
82
+ * recorded no-op (the clock still advances).
83
+ */
84
+ readonly kind: "deliver";
85
+ /** Which pending message to deliver (modulo pool size). */
86
+ readonly index: number;
87
+ } | {
88
+ /**
89
+ * A client submits a command to a node (Figure 2 §5.3). On the leader it
90
+ * appends + replicates; on a non-leader it is a no-op. This is how the
91
+ * replicated log grows in a simulation.
92
+ */
93
+ readonly kind: "client";
94
+ /** The node the client contacts. */
95
+ readonly node: NodeId;
96
+ /** The opaque command to submit. */
97
+ readonly command: number;
98
+ } | {
99
+ /**
100
+ * Drain the pending pool to quiescence: deliver messages FIFO (each new
101
+ * Cmd a delivery emits is appended and itself delivered) until the pool is
102
+ * empty or `bound` deliveries have run. This is the "let the network
103
+ * settle" move — it lets a started election or a submitted client command
104
+ * run its RPC round(s) to completion, which is what actually produces
105
+ * leaders and committed entries. Without it, a purely-random `deliver`
106
+ * index almost never threads the exact sequence an election + replication
107
+ * needs (measured: 0 commits in 2000 random schedules vs. 529 with settle),
108
+ * so the safety properties would pass *vacuously* over empty logs.
109
+ *
110
+ * The `bound` caps the drain so a pathological re-emit loop cannot diverge;
111
+ * every step is still recorded (one trace step per delivery), so a settle
112
+ * is a deterministic, replayable macro — not a black box.
113
+ */
114
+ readonly kind: "settle";
115
+ /** Max deliveries to run (the drain is FIFO; stops early when empty). */
116
+ readonly bound: number;
117
+ } | {
118
+ /**
119
+ * Set the cluster's *partitioned* node set (a network fault, NOT a state
120
+ * change). A node in `down` is isolated: the transport DROPS every message
121
+ * to OR from it (both directions, as a real partition does) while its
122
+ * timers still fire — so a crashed/partitioned leader stops being able to
123
+ * heartbeat and the surviving majority elects a new leader. This is the
124
+ * "kill the leader" / "heal the partition" move the failover demo needs.
125
+ *
126
+ * It names the WHOLE down-set absolutely (not a toggle): `{ down: [n0] }`
127
+ * isolates n0; a later `{ down: [] }` heals every partition. Recorded as
128
+ * one step (clock advances, no message moves), so it replays deterministically.
129
+ */
130
+ readonly kind: "partition";
131
+ /** The nodes that are currently isolated (drop all their traffic). */
132
+ readonly down: readonly NodeId[];
133
+ };
134
+ /** A whole schedule: the finite event stream the driver folds. */
135
+ type Schedule = readonly SimEvent[];
136
+ /**
137
+ * A Cmd a node emitted, parked until a `deliver` event routes it. We keep the
138
+ * raw {@link RaftCmd} (it already carries `to`) plus the sender, so a delivery
139
+ * can build the correct inbound {@link RaftMsg} on the target. Commands over a
140
+ * numeric payload (the simulation's `C = number`).
141
+ */
142
+ interface InFlight {
143
+ /** The node that emitted this Cmd (the inbound Msg's `from` / sender). */
144
+ readonly from: NodeId;
145
+ /** The emitted Cmd, addressed to `cmd.to`. */
146
+ readonly cmd: RaftCmd<number>;
147
+ }
148
+ /** A snapshot of every node's state, keyed by id. */
149
+ type ClusterStates = Readonly<Record<NodeId, RaftState<number>>>;
150
+ /**
151
+ * A read-off of the message a `deliver` step actually pulled off the pending
152
+ * pool — sender, target, and the {@link RaftCmd} discriminant (`cmd.type`). This
153
+ * is a pure projection of what the fold *already did* (the InFlight that
154
+ * {@link deliverOne} consumed), recorded so a renderer can draw the RPC flow.
155
+ * It is NOT a new input and changes NO simulation behavior. Recorded even when
156
+ * the packet is dropped by a partition (the RPC was still attempted), so the
157
+ * arrow shows the intent while the greyed node shows the drop.
158
+ */
159
+ interface DeliveredMessage {
160
+ /** The node that emitted the delivered Cmd (the inbound Msg's sender). */
161
+ readonly from: NodeId;
162
+ /** The node the Cmd was addressed to (`cmd.to`). */
163
+ readonly to: NodeId;
164
+ /** The delivered Cmd's discriminant (`RaftCmd["type"]`). */
165
+ readonly kind: RaftCmd<number>["type"];
166
+ }
167
+ /** One recorded step: the event applied and the cluster state it produced. */
168
+ interface SimStep {
169
+ /** The event applied at this step. */
170
+ readonly event: SimEvent;
171
+ /** The pending-pool size AFTER applying the event. */
172
+ readonly pending: number;
173
+ /** Every node's state after the event. */
174
+ readonly states: ClusterStates;
175
+ /**
176
+ * On a `deliver` step (incl. each delivery a `settle` drains), the message
177
+ * that was actually pulled off the pool. ABSENT on every other step kind and
178
+ * on a `deliver` against an empty pool — additive + backward-compatible, so a
179
+ * trace recorded before this field still folds and renders identically (field
180
+ * absent ⇒ no arrow). Purely a read-off of {@link deliverOne}'s work.
181
+ */
182
+ readonly delivered?: DeliveredMessage;
183
+ }
184
+ /**
185
+ * The full record of a run: the per-node configs, the ordered steps, and the
186
+ * final cluster. Replaying the same schedule reproduces this byte-for-byte.
187
+ */
188
+ interface SimTrace {
189
+ /** The cluster's node ids, in config order. */
190
+ readonly nodes: readonly NodeId[];
191
+ /** One step per event in the schedule (in order). */
192
+ readonly steps: readonly SimStep[];
193
+ /** The final per-node states (== `steps.at(-1)?.states` when non-empty). */
194
+ readonly final: ClusterStates;
195
+ }
196
+ /**
197
+ * Build N node configs sharing one election-timeout window + heartbeat. Each
198
+ * node's `peers` is every OTHER id. Ids are `n0..n{N-1}`.
199
+ */
200
+ declare function clusterConfigs(n: number, over?: Partial<Omit<RaftConfig, "self" | "peers">>): readonly RaftConfig[];
201
+ /**
202
+ * Run a schedule against a fresh N-node cluster, recording the full trace. Pure
203
+ * given `(configs, schedule)`: no clock, no RNG, no IO — the only "time" is the
204
+ * driver's logical clock, advanced one tick per event. Each node gets a
205
+ * fixed-jitter rng derived from its index (distinct but deterministic timeouts).
206
+ */
207
+ declare function runSchedule(configs: readonly RaftConfig[], schedule: Schedule): SimTrace;
208
+ /**
209
+ * Replay a schedule from a fresh cluster. Identical to {@link runSchedule} (it
210
+ * IS `runSchedule`) — exposed under a name that documents intent at the call
211
+ * site: a replay is meant to reproduce a prior run's trace byte-for-byte.
212
+ */
213
+ declare function replaySchedule(configs: readonly RaftConfig[], schedule: Schedule): SimTrace;
214
+ /**
215
+ * Structural equality of two cluster snapshots. Used by the determinism
216
+ * property: a run and its replay must produce identical per-node state for every
217
+ * node. (Tests use vitest `toEqual` for the same comparison; this is the
218
+ * dependency-free version the driver can expose for ad-hoc checks.)
219
+ */
220
+ declare function clusterStatesEqual(a: ClusterStates, b: ClusterStates): boolean;
221
+
222
+ export { type ClusterStates, type DeliveredMessage, type InFlight, NodeId, RaftConfig, RaftState, type Schedule, type SimEvent, type SimStep, type SimTrace, type TimerKind, clusterConfigs, clusterStatesEqual, replaySchedule, runSchedule };
@@ -0,0 +1,8 @@
1
+ export { clusterConfigs, clusterStatesEqual, replaySchedule, runSchedule } from '../chunk-VLGARN6H.js';
2
+ import '../chunk-6RGBKODV.js';
3
+ import '../chunk-K7Q23EQ4.js';
4
+ import '../chunk-2YFLOCWX.js';
5
+ import '../chunk-MMH7CW5K.js';
6
+ import '../chunk-QLA76GIT.js';
7
+ //# sourceMappingURL=sim.js.map
8
+ //# sourceMappingURL=sim.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"sim.js"}
@@ -0,0 +1,46 @@
1
+ import { SimTrace } from './sim.js';
2
+ import './index.js';
3
+ import '../deadline/index.js';
4
+ import '../core-BoB6t7dB.js';
5
+ import '../types-B-8bcAp8.js';
6
+
7
+ /**
8
+ * @demlik/tea/raft — an ASCII step-timeline renderer for a {@link SimTrace}.
9
+ *
10
+ * PURE. `renderTimeline(trace)` folds a recorded trace into a deterministic,
11
+ * paced-friendly string: a header, then one block per step — the {@link SimEvent}
12
+ * that drove it, followed by one row per node (role · term · commit · committed
13
+ * log), with the current leader marked and partitioned nodes flagged.
14
+ *
15
+ * It is a *projection of the trace*, not an instrumentation of the engine: the
16
+ * trace already carries every node's full {@link RaftState} at every step
17
+ * (`step.states`), so this reads that data and never touches the reducer. The
18
+ * same renderer works for ANY trace `runSchedule` produces — the canned
19
+ * {@link runDemo} failover, or an author's own schedule.
20
+ *
21
+ * Plain ASCII + a little box-drawing, no ANSI — so it renders identically in a
22
+ * terminal, a captured asciinema cast, and a golden test assertion (the
23
+ * deterministic-output property the rest of the raft module leans on).
24
+ *
25
+ * The partition set is not on `SimStep` (it lives in the sim's internal world),
26
+ * so the renderer RECONSTRUCTS it by folding the `partition` events as it walks
27
+ * the steps — a `{ kind: "partition", down }` sets the down-set absolutely,
28
+ * `{ down: [] }` heals (exactly the harness's own semantics).
29
+ */
30
+
31
+ /** Options for {@link renderTimeline}. */
32
+ interface TimelineOptions {
33
+ /**
34
+ * Drop the header banner (the `@demlik/tea/raft …` title + the column key).
35
+ * Default `false` — the banner is shown. A cast that frames its own title
36
+ * passes `true`.
37
+ */
38
+ readonly noHeader?: boolean;
39
+ }
40
+ /**
41
+ * Render a whole {@link SimTrace} as an ASCII step timeline. PURE +
42
+ * deterministic — the same trace renders byte-identically every time.
43
+ */
44
+ declare function renderTimeline(trace: SimTrace, opts?: TimelineOptions): string;
45
+
46
+ export { type TimelineOptions, renderTimeline };
@@ -0,0 +1,62 @@
1
+ // src/raft/timeline.ts
2
+ function committedCommands(state) {
3
+ return state.log.slice(0, state.commitIndex).map((entry) => entry.command);
4
+ }
5
+ function summarizeEvent(event) {
6
+ switch (event.kind) {
7
+ case "timer":
8
+ return `timer \xB7 ${event.node} ${event.timer}`;
9
+ case "deliver":
10
+ return `deliver \xB7 in-flight msg #${event.index}`;
11
+ case "client":
12
+ return `client \xB7 ${event.node} \u27F5 cmd ${event.command}`;
13
+ case "settle":
14
+ return `settle \xB7 drain the network (\u2264 ${event.bound})`;
15
+ case "partition":
16
+ return event.down.length === 0 ? "partition \xB7 HEAL \u2014 every node reachable" : `partition \xB7 DOWN [${[...event.down].join(", ")}] (traffic dropped)`;
17
+ }
18
+ }
19
+ function pad(s, width) {
20
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
21
+ }
22
+ function renderNodeRow(id, state, down) {
23
+ const isLeader = state.role._tag === "leader";
24
+ const isDown = down.has(id);
25
+ const marker = isDown ? "\u2573" : isLeader ? "\u25B8" : " ";
26
+ const committed = committedCommands(state);
27
+ const logSize = state.log.length;
28
+ const logCell = `[${committed.join(", ")}]${logSize > committed.length ? ` (+${logSize - committed.length} uncommitted)` : ""}`;
29
+ return ` ${marker} ${pad(id, 4)} ${pad(state.role._tag, 10)} term ${pad(String(state.currentTerm), 3)} commit ${pad(String(state.commitIndex), 3)} log ${logCell}${isDown ? " (down)" : ""}`;
30
+ }
31
+ function renderStep(index, event, states, nodes, down) {
32
+ const header = `step ${pad(String(index), 3)}\u2502 ${summarizeEvent(event)}`;
33
+ const rows = nodes.map((id) => {
34
+ const state = states[id];
35
+ return state === void 0 ? ` \xB7 ${pad(id, 4)} (absent)` : renderNodeRow(id, state, down);
36
+ });
37
+ return [header, ...rows].join("\n");
38
+ }
39
+ function renderTimeline(trace, opts = {}) {
40
+ const blocks = [];
41
+ if (!opts.noHeader) {
42
+ const majority = Math.floor(trace.nodes.length / 2) + 1;
43
+ blocks.push(
44
+ "\u2550".repeat(64),
45
+ ` @demlik/tea/raft \u2014 consensus timeline (${trace.nodes.length} nodes, majority ${majority})`,
46
+ ` \u25B8 leader \u2573 partitioned \xB7 log = [committed]`,
47
+ "\u2550".repeat(64)
48
+ );
49
+ }
50
+ let down = /* @__PURE__ */ new Set();
51
+ trace.steps.forEach((step, i) => {
52
+ if (step.event.kind === "partition") {
53
+ down = new Set(step.event.down);
54
+ }
55
+ blocks.push(renderStep(i + 1, step.event, step.states, trace.nodes, down));
56
+ });
57
+ return blocks.join("\n\n");
58
+ }
59
+
60
+ export { renderTimeline };
61
+ //# sourceMappingURL=timeline.js.map
62
+ //# sourceMappingURL=timeline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/raft/timeline.ts"],"names":[],"mappings":"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","file":"timeline.js","sourcesContent":["/**\n * @demlik/tea/raft — an ASCII step-timeline renderer for a {@link SimTrace}.\n *\n * PURE. `renderTimeline(trace)` folds a recorded trace into a deterministic,\n * paced-friendly string: a header, then one block per step — the {@link SimEvent}\n * that drove it, followed by one row per node (role · term · commit · committed\n * log), with the current leader marked and partitioned nodes flagged.\n *\n * It is a *projection of the trace*, not an instrumentation of the engine: the\n * trace already carries every node's full {@link RaftState} at every step\n * (`step.states`), so this reads that data and never touches the reducer. The\n * same renderer works for ANY trace `runSchedule` produces — the canned\n * {@link runDemo} failover, or an author's own schedule.\n *\n * Plain ASCII + a little box-drawing, no ANSI — so it renders identically in a\n * terminal, a captured asciinema cast, and a golden test assertion (the\n * deterministic-output property the rest of the raft module leans on).\n *\n * The partition set is not on `SimStep` (it lives in the sim's internal world),\n * so the renderer RECONSTRUCTS it by folding the `partition` events as it walks\n * the steps — a `{ kind: \"partition\", down }` sets the down-set absolutely,\n * `{ down: [] }` heals (exactly the harness's own semantics).\n */\n\nimport type { NodeId, RaftState } from \"./index\";\nimport type { ClusterStates, SimEvent, SimTrace } from \"./sim\";\n\n/** Options for {@link renderTimeline}. */\nexport interface TimelineOptions {\n /**\n * Drop the header banner (the `@demlik/tea/raft …` title + the column key).\n * Default `false` — the banner is shown. A cast that frames its own title\n * passes `true`.\n */\n readonly noHeader?: boolean;\n}\n\n/** The committed commands of a node — `log[0 .. commitIndex)`, in order. */\nfunction committedCommands(state: RaftState<number>): number[] {\n return state.log.slice(0, state.commitIndex).map((entry) => entry.command);\n}\n\n/** One-line summary of the move a step applied. */\nfunction summarizeEvent(event: SimEvent): string {\n switch (event.kind) {\n case \"timer\":\n return `timer · ${event.node} ${event.timer}`;\n case \"deliver\":\n return `deliver · in-flight msg #${event.index}`;\n case \"client\":\n return `client · ${event.node} ⟵ cmd ${event.command}`;\n case \"settle\":\n return `settle · drain the network (≤ ${event.bound})`;\n case \"partition\":\n return event.down.length === 0\n ? \"partition · HEAL — every node reachable\"\n : `partition · DOWN [${[...event.down].join(\", \")}] (traffic dropped)`;\n }\n}\n\n/** Pad `s` to `width` with trailing spaces (left-aligned column). */\nfunction pad(s: string, width: number): string {\n return s.length >= width ? s : s + \" \".repeat(width - s.length);\n}\n\n/**\n * Render one node's row. The leader of the cluster is marked with `▸`; a\n * partitioned (down) node is dimmed with `╳` and a `(down)` tag — it keeps\n * showing its last-known state because, isolated, it cannot learn otherwise.\n */\nfunction renderNodeRow(\n id: NodeId,\n state: RaftState<number>,\n down: ReadonlySet<NodeId>,\n): string {\n const isLeader = state.role._tag === \"leader\";\n const isDown = down.has(id);\n const marker = isDown ? \"╳\" : isLeader ? \"▸\" : \" \";\n const committed = committedCommands(state);\n const logSize = state.log.length;\n // committed/total so a row shows replication progress (committed ≤ total).\n const logCell = `[${committed.join(\", \")}]${\n logSize > committed.length\n ? ` (+${logSize - committed.length} uncommitted)`\n : \"\"\n }`;\n return (\n ` ${marker} ${pad(id, 4)} ${pad(state.role._tag, 10)} ` +\n `term ${pad(String(state.currentTerm), 3)} ` +\n `commit ${pad(String(state.commitIndex), 3)} ` +\n `log ${logCell}${isDown ? \" (down)\" : \"\"}`\n );\n}\n\n/** Render one step block: the event header + every node's row. */\nfunction renderStep(\n index: number,\n event: SimEvent,\n states: ClusterStates,\n nodes: readonly NodeId[],\n down: ReadonlySet<NodeId>,\n): string {\n const header = `step ${pad(String(index), 3)}│ ${summarizeEvent(event)}`;\n const rows = nodes.map((id) => {\n const state = states[id];\n return state === undefined\n ? ` · ${pad(id, 4)} (absent)`\n : renderNodeRow(id, state, down);\n });\n return [header, ...rows].join(\"\\n\");\n}\n\n/**\n * Render a whole {@link SimTrace} as an ASCII step timeline. PURE +\n * deterministic — the same trace renders byte-identically every time.\n */\nexport function renderTimeline(\n trace: SimTrace,\n opts: TimelineOptions = {},\n): string {\n const blocks: string[] = [];\n if (!opts.noHeader) {\n const majority = Math.floor(trace.nodes.length / 2) + 1;\n blocks.push(\n \"═\".repeat(64),\n ` @demlik/tea/raft — consensus timeline` +\n ` (${trace.nodes.length} nodes, majority ${majority})`,\n ` ▸ leader ╳ partitioned · log = [committed]`,\n \"═\".repeat(64),\n );\n }\n // Fold the partition set as we walk — a `partition` event sets it absolutely.\n let down: ReadonlySet<NodeId> = new Set();\n trace.steps.forEach((step, i) => {\n if (step.event.kind === \"partition\") {\n down = new Set(step.event.down);\n }\n blocks.push(renderStep(i + 1, step.event, step.states, trace.nodes, down));\n });\n return blocks.join(\"\\n\\n\");\n}\n"]}
@@ -1,45 +1,3 @@
1
- // src/rate-limit/index.ts
2
- function initBucket(capacity, refillPerSec, nowMs) {
3
- return {
4
- tokens: capacity,
5
- capacity,
6
- refillPerSec,
7
- lastRefillMs: nowMs
8
- };
9
- }
10
- function refill(bucket, nowMs) {
11
- const elapsedSec = Math.max(0, nowMs - bucket.lastRefillMs) / 1e3;
12
- const replenished = bucket.tokens + elapsedSec * bucket.refillPerSec;
13
- return {
14
- ...bucket,
15
- tokens: Math.min(bucket.capacity, replenished),
16
- lastRefillMs: nowMs
17
- };
18
- }
19
- function tryConsume(bucket, nowMs, n = 1) {
20
- const refilled = refill(bucket, nowMs);
21
- if (refilled.tokens >= n) {
22
- return [{ ...refilled, tokens: refilled.tokens - n }, true];
23
- }
24
- return [refilled, false];
25
- }
26
- function initWindow(windowMs, limit) {
27
- return { hits: [], windowMs, limit };
28
- }
29
- function record(window, nowMs) {
30
- const cutoff = nowMs - window.windowMs;
31
- const pruned = window.hits.filter((t) => t > cutoff);
32
- if (pruned.length < window.limit) {
33
- return [{ ...window, hits: [...pruned, nowMs] }, true];
34
- }
35
- return [{ ...window, hits: pruned }, false];
36
- }
37
- function remaining(window, nowMs) {
38
- const cutoff = nowMs - window.windowMs;
39
- const live = window.hits.filter((t) => t > cutoff).length;
40
- return Math.max(0, window.limit - live);
41
- }
42
-
43
- export { initBucket, initWindow, record, refill, remaining, tryConsume };
1
+ export { initBucket, initWindow, record, refill, remaining, tryConsume } from '../chunk-2YOE5PIS.js';
44
2
  //# sourceMappingURL=index.js.map
45
3
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/rate-limit/index.ts"],"names":[],"mappings":";AAsDO,SAAS,UAAA,CAAW,QAAA,EAAkB,YAAA,EAAsB,KAAA,EAA4B;AAC7F,EAAA,OAAO;AAAA,IACL,MAAA,EAAQ,QAAA;AAAA,IACR,QAAA;AAAA,IACA,YAAA;AAAA,IACA,YAAA,EAAc;AAAA,GAChB;AACF;AAcO,SAAS,MAAA,CAAO,QAAqB,KAAA,EAA4B;AACtE,EAAA,MAAM,aAAa,IAAA,CAAK,GAAA,CAAI,GAAG,KAAA,GAAQ,MAAA,CAAO,YAAY,CAAA,GAAI,GAAA;AAC9D,EAAA,MAAM,WAAA,GAAc,MAAA,CAAO,MAAA,GAAS,UAAA,GAAa,MAAA,CAAO,YAAA;AACxD,EAAA,OAAO;AAAA,IACL,GAAG,MAAA;AAAA,IACH,MAAA,EAAQ,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,UAAU,WAAW,CAAA;AAAA,IAC7C,YAAA,EAAc;AAAA,GAChB;AACF;AAeO,SAAS,UAAA,CACd,MAAA,EACA,KAAA,EACA,CAAA,GAAI,CAAA,EAC6B;AACjC,EAAA,MAAM,QAAA,GAAW,MAAA,CAAO,MAAA,EAAQ,KAAK,CAAA;AACrC,EAAA,IAAI,QAAA,CAAS,UAAU,CAAA,EAAG;AACxB,IAAA,OAAO,CAAC,EAAE,GAAG,QAAA,EAAU,QAAQ,QAAA,CAAS,MAAA,GAAS,CAAA,EAAE,EAAG,IAAI,CAAA;AAAA,EAC5D;AACA,EAAA,OAAO,CAAC,UAAU,KAAK,CAAA;AACzB;AAqBO,SAAS,UAAA,CAAW,UAAkB,KAAA,EAA8B;AACzE,EAAA,OAAO,EAAE,IAAA,EAAM,EAAC,EAAG,UAAU,KAAA,EAAM;AACrC;AAgBO,SAAS,MAAA,CAAO,QAAuB,KAAA,EAAkD;AAC9F,EAAA,MAAM,MAAA,GAAS,QAAQ,MAAA,CAAO,QAAA;AAC9B,EAAA,MAAM,SAAS,MAAA,CAAO,IAAA,CAAK,OAAO,CAAC,CAAA,KAAM,IAAI,MAAM,CAAA;AACnD,EAAA,IAAI,MAAA,CAAO,MAAA,GAAS,MAAA,CAAO,KAAA,EAAO;AAChC,IAAA,OAAO,CAAC,EAAE,GAAG,MAAA,EAAQ,IAAA,EAAM,CAAC,GAAG,MAAA,EAAQ,KAAK,CAAA,EAAE,EAAG,IAAI,CAAA;AAAA,EACvD;AACA,EAAA,OAAO,CAAC,EAAE,GAAG,QAAQ,IAAA,EAAM,MAAA,IAAU,KAAK,CAAA;AAC5C;AAQO,SAAS,SAAA,CAAU,QAAuB,KAAA,EAAuB;AACtE,EAAA,MAAM,MAAA,GAAS,QAAQ,MAAA,CAAO,QAAA;AAC9B,EAAA,MAAM,IAAA,GAAO,OAAO,IAAA,CAAK,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,GAAI,MAAM,CAAA,CAAE,MAAA;AACnD,EAAA,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,MAAA,CAAO,QAAQ,IAAI,CAAA;AACxC","file":"index.js","sourcesContent":["/**\n * @demlik/tea/rate-limit — two rate limiters as pure state + ops.\n *\n * Same shape as `@demlik/tea/work-queue`'s `ops.ts`: a state type plus pure\n * transition functions. Host-agnostic by construction — no `Date.now()`, no\n * timers, no I/O. The caller injects the clock (`nowMs`) and wires the result\n * into its own reducer / interpret. This keeps the limiters inside TEA's\n * invariant 2 (transitions are pure: same inputs → same outputs, no `Date.now`)\n * so a consumer can fold them through `update` and exercise them with `replay`\n * / the pbt fold without a fake clock.\n *\n * Every op returns a NEW state value and never mutates its input (invariant 1 —\n * state is a value, not a place). The `tryConsume` / `record` ops return a\n * `[next, ok]` tuple so the caller branches on the decision while threading the\n * advanced state forward; `refill` / `remaining` are read-shaped helpers.\n *\n * Two complementary algorithms, picked by the cost the caller wants to pay:\n *\n * - **Token bucket** — O(1) state (four numbers), smooths bursts against a\n * steady refill rate. Allows a burst up to `capacity`, then throttles to\n * `refillPerSec`. Cheapest; the default choice.\n * - **Sliding window log** — O(limit) state (one timestamp per in-window\n * hit), exact \"no more than `limit` events per `windowMs`\". Pay the memory\n * for precise window edges when a token bucket's continuous refill would\n * let through too many at a boundary.\n *\n * NOT exported from the package root — reached via the `@demlik/tea/rate-limit`\n * subpath, same one-shape-per-package rule as `work-queue` and `do`.\n */\n\n// ===========================================================================\n// Token bucket\n// ===========================================================================\n\n/**\n * A token bucket: `tokens` of `capacity` available now, replenished at\n * `refillPerSec` tokens per second. `lastRefillMs` is the clock reading at\n * which `tokens` was last computed — `refill` advances it. All four fields are\n * plain numbers so the whole bucket is trivially JSON-serializable into a\n * consumer's `Store<S>`.\n */\nexport interface TokenBucket {\n readonly tokens: number;\n readonly capacity: number;\n readonly refillPerSec: number;\n readonly lastRefillMs: number;\n}\n\n/**\n * Create a full bucket. Starts at `capacity` tokens (the conventional \"a fresh\n * limiter lets the first burst through\" behavior) and stamps `lastRefillMs`\n * with the caller's clock so the first `refill` measures elapsed time from\n * boot, not from epoch 0.\n */\nexport function initBucket(capacity: number, refillPerSec: number, nowMs: number): TokenBucket {\n return {\n tokens: capacity,\n capacity,\n refillPerSec,\n lastRefillMs: nowMs,\n };\n}\n\n/**\n * Add the tokens accrued since `lastRefillMs`, clamped to `capacity`, and\n * advance `lastRefillMs` to `nowMs`. PURE — returns a new bucket; the input is\n * untouched.\n *\n * A `nowMs` at or before `lastRefillMs` (clock not advanced, or skewed\n * backwards) adds a non-positive amount; `Math.max(0, …)` floors the elapsed\n * term so a backwards clock can never *remove* tokens. `lastRefillMs` still\n * advances to `nowMs` so the bucket tracks the latest reading the caller\n * presented — the next forward step measures from there, not from a stale\n * future timestamp.\n */\nexport function refill(bucket: TokenBucket, nowMs: number): TokenBucket {\n const elapsedSec = Math.max(0, nowMs - bucket.lastRefillMs) / 1000;\n const replenished = bucket.tokens + elapsedSec * bucket.refillPerSec;\n return {\n ...bucket,\n tokens: Math.min(bucket.capacity, replenished),\n lastRefillMs: nowMs,\n };\n}\n\n/**\n * Refill against `nowMs`, then attempt to take `n` tokens (default 1).\n *\n * On success returns `[next, true]` with `n` subtracted; on failure returns\n * `[refilled, false]` — the refilled bucket (so the caller keeps the advanced\n * clock + accrued tokens) with no tokens spent. Branch on the boolean, thread\n * the bucket forward regardless:\n *\n * const [b1, ok] = tryConsume(bucket, nowMs);\n * return ok ? [{ ...state, bucket: b1 }, [doWork]] : [{ ...state, bucket: b1 }, []];\n *\n * PURE — input bucket is never mutated.\n */\nexport function tryConsume(\n bucket: TokenBucket,\n nowMs: number,\n n = 1,\n): readonly [TokenBucket, boolean] {\n const refilled = refill(bucket, nowMs);\n if (refilled.tokens >= n) {\n return [{ ...refilled, tokens: refilled.tokens - n }, true];\n }\n return [refilled, false];\n}\n\n// ===========================================================================\n// Sliding window log\n// ===========================================================================\n\n/**\n * A sliding-window log: the timestamps of every hit still inside the trailing\n * `windowMs`, capped at `limit` events per window. `hits` is `readonly` and\n * kept oldest-first (pruning slices the stale prefix off the front, recording\n * appends to the back), so the array is its own sorted index. Exact at the\n * window edge — unlike a token bucket's continuous refill, a hit leaves the\n * count the instant it ages past `windowMs`.\n */\nexport interface SlidingWindow {\n readonly hits: readonly number[];\n readonly windowMs: number;\n readonly limit: number;\n}\n\n/** Create an empty window. No hits recorded yet. */\nexport function initWindow(windowMs: number, limit: number): SlidingWindow {\n return { hits: [], windowMs, limit };\n}\n\n/**\n * Prune hits older than `nowMs - windowMs`, then record `nowMs` if there is\n * room. Returns `[next, true]` with the hit appended when `hits.length < limit`\n * after pruning, else `[pruned, false]` — the pruned window (capacity recovers\n * as old hits age out) with nothing appended.\n *\n * The cutoff is half-open: a hit exactly `windowMs` old is pruned (`<= cutoff`\n * drops it), so a hit recorded at `t` no longer counts at `t + windowMs`. That\n * makes the window's capacity fully recover `windowMs` after the last hit, the\n * behavior tests pin.\n *\n * PURE — input window is never mutated; `hits` is rebuilt by `filter` +\n * spread.\n */\nexport function record(window: SlidingWindow, nowMs: number): readonly [SlidingWindow, boolean] {\n const cutoff = nowMs - window.windowMs;\n const pruned = window.hits.filter((t) => t > cutoff);\n if (pruned.length < window.limit) {\n return [{ ...window, hits: [...pruned, nowMs] }, true];\n }\n return [{ ...window, hits: pruned }, false];\n}\n\n/**\n * How many more hits `record` would accept at `nowMs` without blocking —\n * `limit` minus the live (post-prune) hit count, floored at 0. Read-only: it\n * prunes a local copy to answer but returns no new window. Use it to render a\n * \"N requests remaining\" affordance without committing a hit.\n */\nexport function remaining(window: SlidingWindow, nowMs: number): number {\n const cutoff = nowMs - window.windowMs;\n const live = window.hits.filter((t) => t > cutoff).length;\n return Math.max(0, window.limit - live);\n}\n"]}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
@@ -1,4 +1,5 @@
1
- import { Store, Cmd, Sub, Machine, Runtime } from '../index.js';
1
+ import { Store, Runtime } from '../index.js';
2
+ import { C as Cmd, S as Sub, M as Machine } from '../core-BoB6t7dB.js';
2
3
 
3
4
  /**
4
5
  * @demlik/tea/react — React host adapter for `@demlik/tea`.
@@ -1,30 +1,53 @@
1
- import { run } from '../chunk-CMG2KMWX.js';
2
- import { useMemo, useSyncExternalStore, useEffect } from 'react';
1
+ import { run } from '../chunk-MMH7CW5K.js';
2
+ import '../chunk-QLA76GIT.js';
3
+ import { useMemo, useRef, useReducer, useSyncExternalStore, useEffect } from 'react';
3
4
 
4
5
  function useMachine(machine, opts) {
5
6
  const ctx = opts.ctx;
6
7
  const store = opts.store;
7
- const runtime = useMemo(() => run(machine, { ctx, store }), [machine, ctx, store]);
8
- const preliminaryState = useMemo(() => machine.init(null, ctx)[0], [machine, ctx]);
9
- const getSnapshot = () => {
10
- try {
11
- return runtime.getState();
12
- } catch {
13
- return preliminaryState;
14
- }
15
- };
16
- const state = useSyncExternalStore(runtime.subscribe, getSnapshot, getSnapshot);
17
- useEffect(
18
- () => () => {
19
- runtime.stop().catch(() => {
20
- });
21
- },
22
- [runtime]
8
+ const booting = useMemo(
9
+ () => run(machine, { ctx, store }),
10
+ [machine, ctx, store]
23
11
  );
24
- return [state, runtime.dispatch];
12
+ const readyRef = useRef(null);
13
+ const [, markBooted] = useReducer((tick) => tick + 1, 0);
14
+ const preliminaryState = useMemo(
15
+ () => machine.init(null, ctx)[0],
16
+ [machine, ctx]
17
+ );
18
+ const getSnapshot = () => readyRef.current !== null ? readyRef.current.getState() : preliminaryState;
19
+ const state = useSyncExternalStore(
20
+ booting.subscribe,
21
+ getSnapshot,
22
+ getSnapshot
23
+ );
24
+ useEffect(() => {
25
+ let live = true;
26
+ booting.ready.then(
27
+ (r) => {
28
+ if (live) {
29
+ readyRef.current = r;
30
+ markBooted();
31
+ }
32
+ },
33
+ () => {
34
+ }
35
+ );
36
+ return () => {
37
+ live = false;
38
+ readyRef.current = null;
39
+ booting.stop().catch(() => {
40
+ });
41
+ };
42
+ }, [booting]);
43
+ return [state, booting.dispatch];
25
44
  }
26
45
  function useRuntime(runtime) {
27
- const state = useSyncExternalStore(runtime.subscribe, runtime.getState, runtime.getState);
46
+ const state = useSyncExternalStore(
47
+ runtime.subscribe,
48
+ runtime.getState,
49
+ runtime.getState
50
+ );
28
51
  return [state, runtime.dispatch];
29
52
  }
30
53
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/react/index.ts"],"names":[],"mappings":";;;AAuDO,SAAS,UAAA,CACd,SACA,IAAA,EACgC;AAMhC,EAAA,MAAM,MAAM,IAAA,CAAK,GAAA;AACjB,EAAA,MAAM,QAAQ,IAAA,CAAK,KAAA;AACnB,EAAA,MAAM,OAAA,GAAU,OAAA,CAAuB,MAAM,GAAA,CAAI,SAAS,EAAE,GAAA,EAAK,KAAA,EAAO,CAAA,EAAG,CAAC,OAAA,EAAS,GAAA,EAAK,KAAK,CAAC,CAAA;AAahG,EAAA,MAAM,gBAAA,GAAmB,OAAA,CAAW,MAAM,OAAA,CAAQ,IAAA,CAAK,IAAA,EAAM,GAAG,CAAA,CAAE,CAAC,CAAA,EAAG,CAAC,OAAA,EAAS,GAAG,CAAC,CAAA;AAMpF,EAAA,MAAM,cAAc,MAAS;AAC3B,IAAA,IAAI;AACF,MAAA,OAAO,QAAQ,QAAA,EAAS;AAAA,IAC1B,CAAA,CAAA,MAAQ;AACN,MAAA,OAAO,gBAAA;AAAA,IACT;AAAA,EACF,CAAA;AAEA,EAAA,MAAM,KAAA,GAAQ,oBAAA,CAAqB,OAAA,CAAQ,SAAA,EAAW,aAAa,WAAW,CAAA;AAC9E,EAAA,SAAA;AAAA,IACE,MAAM,MAAM;AAIV,MAAA,OAAA,CAAQ,IAAA,EAAK,CAAE,KAAA,CAAM,MAAM;AAAA,MAAC,CAAC,CAAA;AAAA,IAC/B,CAAA;AAAA,IACA,CAAC,OAAO;AAAA,GACV;AACA,EAAA,OAAO,CAAC,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAA;AACjC;AAeO,SAAS,WACd,OAAA,EACgC;AAChC,EAAA,MAAM,QAAQ,oBAAA,CAAqB,OAAA,CAAQ,WAAW,OAAA,CAAQ,QAAA,EAAU,QAAQ,QAAQ,CAAA;AACxF,EAAA,OAAO,CAAC,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAA;AACjC","file":"index.js","sourcesContent":["/**\n * @demlik/tea/react — React host adapter for `@demlik/tea`.\n *\n * The thinnest possible adapter that makes a tea machine consumable from a\n * React component. Three rules (PRD §\"React adapter\"):\n *\n * 1. The runtime is the source of truth, NOT React state. We subscribe via\n * `useSyncExternalStore(runtime.subscribe, runtime.getState,\n * runtime.getState)`. React renders are driven by runtime state changes;\n * React never owns the state.\n * 2. The runtime is memoized per component-mount, not per render. The deps\n * array is `[machine, opts.ctx, opts.store]` — IDENTITY, not value. See\n * `README.md` for the ctx-identity footgun + fix.\n * 3. Concurrent React is the design target. `useSyncExternalStore` is the\n * React-blessed primitive for tearing-free external state.\n *\n * Client-only in v1 — components that call `useMachine` MUST be `\"use client\"`.\n * Server Components consume data via Relay/RSC as today; tea machines own\n * client-side stateful flows. No RSC streaming, no server-side state\n * hydration. Crossing this boundary is a v2 concern.\n */\n\n\"use client\";\n\nimport { type Cmd, type Machine, type Runtime, run, type Store, type Sub } from \"../index\";\nimport { useEffect, useMemo, useSyncExternalStore } from \"react\";\n\n/**\n * Options passed to `useMachine`. The shape is intentionally minimal — `ctx`\n * is required (every machine has one), `store` is optional (omit it for\n * volatile-state machines).\n *\n * **Identity matters.** The runtime is memoized on `[machine, opts.ctx,\n * opts.store]`. A new `ctx` reference rebuilds the runtime. Always `useMemo`\n * (or otherwise stabilize) the ctx at the call site. See README.\n */\nexport interface UseMachineOpts<S, Ctx> {\n ctx: Ctx;\n store?: Store<S>;\n}\n\n/**\n * Build and own a `Runtime<S, M>` for the lifetime of the component mount.\n *\n * - Builds via `useMemo(() => run(machine, opts), [machine, opts.ctx,\n * opts.store])` — runtime is recreated when ANY of the three identities\n * change.\n * - Subscribes via `useSyncExternalStore(runtime.subscribe, runtime.getState,\n * runtime.getState)` — tearing-free under React 18 concurrent rendering.\n * - On unmount (or any dep change), `useEffect` cleanup calls\n * `runtime.stop()` — drains the queue, runs every active sub cleanup,\n * flushes state.\n *\n * Returns `[state, dispatch]` shaped like `useReducer`.\n */\nexport function useMachine<S, M extends { type: string }, C extends Cmd, U extends Sub, Ctx>(\n machine: Machine<S, M, C, U, Ctx>,\n opts: UseMachineOpts<S, Ctx>,\n): [S, (msg: M) => Promise<void>] {\n // Deps are literal: machine identity, ctx identity, store identity. NOT\n // `[opts]` (would rebuild every render — callers pass fresh objects).\n // We pass explicit fields to `run()` so the closure captures only the\n // deps in the array — keeps biome's `useExhaustiveDependencies` happy\n // without lying about what the memo actually depends on.\n const ctx = opts.ctx;\n const store = opts.store;\n const runtime = useMemo<Runtime<S, M>>(() => run(machine, { ctx, store }), [machine, ctx, store]);\n\n // Preliminary state computed sync from `machine.init(null, ctx)`. This is\n // the snapshot React sees BEFORE the runtime finishes booting (matters\n // only when `opts.store` is present — without a store, the substrate\n // already sets state synchronously inside `run()`). Once the runtime\n // boots, `runtime.getState()` returns the real (possibly loaded) state.\n //\n // We deliberately recompute on each render but cache by `[machine,\n // opts.ctx]` — `init` is pure by convention, so cost is negligible. The\n // cached snapshot is also identity-stable across rerenders when the deps\n // don't change, which `useSyncExternalStore` rewards (no spurious\n // re-renders).\n const preliminaryState = useMemo<S>(() => machine.init(null, ctx)[0], [machine, ctx]);\n\n // `getSnapshot` reads the runtime if it has booted, else falls back to\n // the preliminary state. The runtime's `getState` throws pre-boot when a\n // store is configured — we catch that and substitute the preliminary\n // value.\n const getSnapshot = (): S => {\n try {\n return runtime.getState();\n } catch {\n return preliminaryState;\n }\n };\n\n const state = useSyncExternalStore(runtime.subscribe, getSnapshot, getSnapshot);\n useEffect(\n () => () => {\n // Fire-and-forget — `stop()` is async but `useEffect` cleanup is sync.\n // The runtime swallows in-flight rejections internally, so this never\n // rejects in practice; we still attach `.catch` for safety.\n runtime.stop().catch(() => {});\n },\n [runtime],\n );\n return [state, runtime.dispatch];\n}\n\n/**\n * Lower-level escape hatch: consume an externally-built `Runtime<S, M>`.\n *\n * Use this when the runtime is owned by something OTHER than the component —\n * a parent component, a test harness, a singleton. **The component does NOT\n * call `runtime.stop()` on unmount** — the caller owns the lifecycle.\n *\n * Primary use case in this repo: component tests. Build a runtime with\n * `@demlik/tea/mem` and pre-seed it via `replay`, then mount the component with\n * `useRuntime(testRuntime)` to render in any phase.\n *\n * Returns `[state, dispatch]` — same shape as `useMachine`.\n */\nexport function useRuntime<S, M extends { type: string }>(\n runtime: Runtime<S, M>,\n): [S, (msg: M) => Promise<void>] {\n const state = useSyncExternalStore(runtime.subscribe, runtime.getState, runtime.getState);\n return [state, runtime.dispatch];\n}\n"]}
1
+ {"version":3,"sources":["../../src/react/index.ts"],"names":[],"mappings":";;;;AAqEO,SAAS,UAAA,CAOd,SACA,IAAA,EACgC;AAMhC,EAAA,MAAM,MAAM,IAAA,CAAK,GAAA;AACjB,EAAA,MAAM,QAAQ,IAAA,CAAK,KAAA;AAMnB,EAAA,MAAM,OAAA,GAAU,OAAA;AAAA,IACd,MAAM,GAAA,CAAI,OAAA,EAAS,EAAE,GAAA,EAAK,OAAO,CAAA;AAAA,IACjC,CAAC,OAAA,EAAS,GAAA,EAAK,KAAK;AAAA,GACtB;AAeA,EAAA,MAAM,QAAA,GAAW,OAA6B,IAAI,CAAA;AAClD,EAAA,MAAM,GAAG,UAAU,CAAA,GAAI,WAAW,CAAC,IAAA,KAAiB,IAAA,GAAO,CAAA,EAAG,CAAC,CAAA;AAa/D,EAAA,MAAM,gBAAA,GAAmB,OAAA;AAAA,IACvB,MAAM,OAAA,CAAQ,IAAA,CAAK,IAAA,EAAM,GAAG,EAAE,CAAC,CAAA;AAAA,IAC/B,CAAC,SAAS,GAAG;AAAA,GACf;AAMA,EAAA,MAAM,WAAA,GAAc,MAClB,QAAA,CAAS,OAAA,KAAY,OAAO,QAAA,CAAS,OAAA,CAAQ,UAAS,GAAI,gBAAA;AAE5D,EAAA,MAAM,KAAA,GAAQ,oBAAA;AAAA,IACZ,OAAA,CAAQ,SAAA;AAAA,IACR,WAAA;AAAA,IACA;AAAA,GACF;AAEA,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,IAAI,IAAA,GAAO,IAAA;AAMX,IAAA,OAAA,CAAQ,KAAA,CAAM,IAAA;AAAA,MACZ,CAAC,CAAA,KAAM;AACL,QAAA,IAAI,IAAA,EAAM;AACR,UAAA,QAAA,CAAS,OAAA,GAAU,CAAA;AACnB,UAAA,UAAA,EAAW;AAAA,QACb;AAAA,MACF,CAAA;AAAA,MACA,MAAM;AAAA,MAAC;AAAA,KACT;AACA,IAAA,OAAO,MAAM;AACX,MAAA,IAAA,GAAO,KAAA;AACP,MAAA,QAAA,CAAS,OAAA,GAAU,IAAA;AAInB,MAAA,OAAA,CAAQ,IAAA,EAAK,CAAE,KAAA,CAAM,MAAM;AAAA,MAAC,CAAC,CAAA;AAAA,IAC/B,CAAA;AAAA,EACF,CAAA,EAAG,CAAC,OAAO,CAAC,CAAA;AAEZ,EAAA,OAAO,CAAC,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAA;AACjC;AAeO,SAAS,WACd,OAAA,EACgC;AAChC,EAAA,MAAM,KAAA,GAAQ,oBAAA;AAAA,IACZ,OAAA,CAAQ,SAAA;AAAA,IACR,OAAA,CAAQ,QAAA;AAAA,IACR,OAAA,CAAQ;AAAA,GACV;AACA,EAAA,OAAO,CAAC,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAA;AACjC","file":"index.js","sourcesContent":["/**\n * @demlik/tea/react — React host adapter for `@demlik/tea`.\n *\n * The thinnest possible adapter that makes a tea machine consumable from a\n * React component. Three rules (PRD §\"React adapter\"):\n *\n * 1. The runtime is the source of truth, NOT React state. We subscribe via\n * `useSyncExternalStore(runtime.subscribe, runtime.getState,\n * runtime.getState)`. React renders are driven by runtime state changes;\n * React never owns the state.\n * 2. The runtime is memoized per component-mount, not per render. The deps\n * array is `[machine, opts.ctx, opts.store]` — IDENTITY, not value. See\n * `README.md` for the ctx-identity footgun + fix.\n * 3. Concurrent React is the design target. `useSyncExternalStore` is the\n * React-blessed primitive for tearing-free external state.\n *\n * Client-only in v1 — components that call `useMachine` MUST be `\"use client\"`.\n * Server Components consume data via Relay/RSC as today; tea machines own\n * client-side stateful flows. No RSC streaming, no server-side state\n * hydration. Crossing this boundary is a v2 concern.\n */\n\n\"use client\";\n\nimport {\n useEffect,\n useMemo,\n useReducer,\n useRef,\n useSyncExternalStore,\n} from \"react\";\nimport {\n type BootingRuntime,\n type Cmd,\n type Machine,\n type Runtime,\n run,\n type Store,\n type Sub,\n} from \"../index\";\n\n/**\n * Options passed to `useMachine`. The shape is intentionally minimal — `ctx`\n * is required (every machine has one), `store` is optional (omit it for\n * volatile-state machines).\n *\n * **Identity matters.** The runtime is memoized on `[machine, opts.ctx,\n * opts.store]`. A new `ctx` reference rebuilds the runtime. Always `useMemo`\n * (or otherwise stabilize) the ctx at the call site. See README.\n */\nexport interface UseMachineOpts<S, Ctx> {\n ctx: Ctx;\n store?: Store<S>;\n}\n\n/**\n * Build and own a `Runtime<S, M>` for the lifetime of the component mount.\n *\n * - Builds via `useMemo(() => run(machine, opts), [machine, opts.ctx,\n * opts.store])` — runtime is recreated when ANY of the three identities\n * change.\n * - Subscribes via `useSyncExternalStore(runtime.subscribe, runtime.getState,\n * runtime.getState)` — tearing-free under React 18 concurrent rendering.\n * - On unmount (or any dep change), `useEffect` cleanup calls\n * `runtime.stop()` — drains the queue, runs every active sub cleanup,\n * flushes state.\n *\n * Returns `[state, dispatch]` shaped like `useReducer`.\n */\nexport function useMachine<\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 opts: UseMachineOpts<S, Ctx>,\n): [S, (msg: M) => Promise<void>] {\n // Deps are literal: machine identity, ctx identity, store identity. NOT\n // `[opts]` (would rebuild every render — callers pass fresh objects).\n // We pass explicit fields to `run()` so the closure captures only the\n // deps in the array — keeps biome's `useExhaustiveDependencies` happy\n // without lying about what the memo actually depends on.\n const ctx = opts.ctx;\n const store = opts.store;\n // `run()` returns a `BootingRuntime<S, M>` SYNCHRONOUSLY (issue #45) — exactly\n // what `useMemo` needs. We never `await` here: awaiting would force an async\n // memo, a null first-commit state, and a resolved-flag dance. Instead we hold\n // the booting handle and capture the booted `Runtime` (the only thing with a\n // total `getState`) once `ready` resolves.\n const booting = useMemo<BootingRuntime<S, M>>(\n () => run(machine, { ctx, store }),\n [machine, ctx, store],\n );\n\n // Captures the booted `Runtime` once `ready` resolves. Until then it is\n // `null` and `getSnapshot` serves `preliminaryState`. Reset whenever the\n // booting handle identity changes (a new machine/ctx/store mount).\n //\n // The boot race the paired `bootedTick` bump exists for: the substrate's\n // boot fanout fires BEFORE `ready` resolves (fireListeners runs inside the\n // boot step; `ready` chains off the boot promise), so at notification time\n // this ref is still null and `getSnapshot` returns the same\n // `preliminaryState` reference — React sees an unchanged snapshot and\n // skips the re-render. Nothing re-notifies after the ref is set, so a\n // store-backed mount would stay stuck on the preliminary state until the\n // next unrelated transition. Bumping a reducer when we capture the booted\n // runtime forces the render that swaps preliminary → real state.\n const readyRef = useRef<Runtime<S, M> | null>(null);\n const [, markBooted] = useReducer((tick: number) => tick + 1, 0);\n\n // Preliminary state computed sync from `machine.init(null, ctx)`. This is\n // the snapshot React sees BEFORE the runtime finishes booting (matters\n // only when `opts.store` is present — without a store, the substrate\n // already sets state synchronously inside `run()`, so the boot fanout that\n // captures `readyRef` lands on the same microtask as the first commit).\n //\n // We deliberately recompute on each render but cache by `[machine,\n // opts.ctx]` — `init` is pure by convention, so cost is negligible. The\n // cached snapshot is also identity-stable across rerenders when the deps\n // don't change, which `useSyncExternalStore` rewards (no spurious\n // re-renders).\n const preliminaryState = useMemo<S>(\n () => machine.init(null, ctx)[0],\n [machine, ctx],\n );\n\n // `getSnapshot` reads the booted runtime if `ready` has resolved (the ref is\n // populated), else falls back to the preliminary state. No try/catch dance\n // anymore — pre-boot there is simply no booted `Runtime` to read from, so\n // the fallback is structural, not exception-driven.\n const getSnapshot = (): S =>\n readyRef.current !== null ? readyRef.current.getState() : preliminaryState;\n\n const state = useSyncExternalStore(\n booting.subscribe,\n getSnapshot,\n getSnapshot,\n );\n\n useEffect(() => {\n let live = true;\n // Capture the booted runtime when `ready` resolves, then force the\n // render that swaps preliminary → real state (see the boot-race note on\n // `readyRef` — the boot fanout fires too early to do it for us). Boot\n // rejections are swallowed: a failed boot surfaces on every `dispatch`,\n // which is where the consumer handles it.\n booting.ready.then(\n (r) => {\n if (live) {\n readyRef.current = r;\n markBooted();\n }\n },\n () => {},\n );\n return () => {\n live = false;\n readyRef.current = null;\n // Fire-and-forget — `stop()` is async but `useEffect` cleanup is sync.\n // The runtime swallows in-flight rejections internally, so this never\n // rejects in practice; we still attach `.catch` for safety.\n booting.stop().catch(() => {});\n };\n }, [booting]);\n\n return [state, booting.dispatch];\n}\n\n/**\n * Lower-level escape hatch: consume an externally-built `Runtime<S, M>`.\n *\n * Use this when the runtime is owned by something OTHER than the component —\n * a parent component, a test harness, a singleton. **The component does NOT\n * call `runtime.stop()` on unmount** — the caller owns the lifecycle.\n *\n * Primary use case in this repo: component tests. Build a runtime with\n * `@demlik/tea/mem` and pre-seed it via `replay`, then mount the component with\n * `useRuntime(testRuntime)` to render in any phase.\n *\n * Returns `[state, dispatch]` — same shape as `useMachine`.\n */\nexport function useRuntime<S, M extends { type: string }>(\n runtime: Runtime<S, M>,\n): [S, (msg: M) => Promise<void>] {\n const state = useSyncExternalStore(\n runtime.subscribe,\n runtime.getState,\n runtime.getState,\n );\n return [state, runtime.dispatch];\n}\n"]}