@demlik/tea 0.1.0 → 0.1.1

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 (238) 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-CMG2KMWX.js → chunk-2WZHS4P2.js} +220 -177
  18. package/dist/chunk-2WZHS4P2.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-5VNWZADP.js +49 -0
  24. package/dist/chunk-5VNWZADP.js.map +1 -0
  25. package/dist/{chunk-ZRUST6BK.js → chunk-7FYQGDMF.js} +2 -2
  26. package/dist/chunk-7FYQGDMF.js.map +1 -0
  27. package/dist/chunk-7VEG4KHB.js +202 -0
  28. package/dist/chunk-7VEG4KHB.js.map +1 -0
  29. package/dist/{chunk-PYBQREFU.js → chunk-A56GNPI7.js} +20 -6
  30. package/dist/chunk-A56GNPI7.js.map +1 -0
  31. package/dist/chunk-BNJYE4AU.js +157 -0
  32. package/dist/chunk-BNJYE4AU.js.map +1 -0
  33. package/dist/chunk-BYQUGDD3.js +16 -0
  34. package/dist/chunk-BYQUGDD3.js.map +1 -0
  35. package/dist/chunk-D5EQDLDV.js +186 -0
  36. package/dist/chunk-D5EQDLDV.js.map +1 -0
  37. package/dist/chunk-EDGDR5XX.js +60 -0
  38. package/dist/chunk-EDGDR5XX.js.map +1 -0
  39. package/dist/chunk-EIZJTYKR.js +93 -0
  40. package/dist/chunk-EIZJTYKR.js.map +1 -0
  41. package/dist/chunk-EQRUAP2A.js +56 -0
  42. package/dist/chunk-EQRUAP2A.js.map +1 -0
  43. package/dist/chunk-FH5RUIIL.js +61 -0
  44. package/dist/chunk-FH5RUIIL.js.map +1 -0
  45. package/dist/chunk-HJO6VNQI.js +15 -0
  46. package/dist/chunk-HJO6VNQI.js.map +1 -0
  47. package/dist/chunk-IFBQJYGI.js +132 -0
  48. package/dist/chunk-IFBQJYGI.js.map +1 -0
  49. package/dist/chunk-IN33Q4VJ.js +52 -0
  50. package/dist/chunk-IN33Q4VJ.js.map +1 -0
  51. package/dist/chunk-JELSZN25.js +47 -0
  52. package/dist/chunk-JELSZN25.js.map +1 -0
  53. package/dist/chunk-K575PT4S.js +58 -0
  54. package/dist/chunk-K575PT4S.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-Y5JM3UUH.js → chunk-NSDFA6M6.js} +75 -3
  58. package/dist/chunk-NSDFA6M6.js.map +1 -0
  59. package/dist/chunk-QGZW4W3E.js +20 -0
  60. package/dist/chunk-QGZW4W3E.js.map +1 -0
  61. package/dist/{chunk-PYOCUBQJ.js → chunk-RGTPPF4U.js} +22 -15
  62. package/dist/chunk-RGTPPF4U.js.map +1 -0
  63. package/dist/chunk-RVJFGRGK.js +222 -0
  64. package/dist/chunk-RVJFGRGK.js.map +1 -0
  65. package/dist/chunk-RVU3RBE6.js +31 -0
  66. package/dist/chunk-RVU3RBE6.js.map +1 -0
  67. package/dist/chunk-SC3FAFZN.js +53 -0
  68. package/dist/chunk-SC3FAFZN.js.map +1 -0
  69. package/dist/chunk-SINXE47Y.js +55 -0
  70. package/dist/chunk-SINXE47Y.js.map +1 -0
  71. package/dist/chunk-TAO4YOLO.js +87 -0
  72. package/dist/chunk-TAO4YOLO.js.map +1 -0
  73. package/dist/chunk-TC4HWABA.js +506 -0
  74. package/dist/chunk-TC4HWABA.js.map +1 -0
  75. package/dist/chunk-TGG26XMK.js +52 -0
  76. package/dist/chunk-TGG26XMK.js.map +1 -0
  77. package/dist/chunk-UV6UWGEO.js +147 -0
  78. package/dist/chunk-UV6UWGEO.js.map +1 -0
  79. package/dist/chunk-UYWUVEP6.js +158 -0
  80. package/dist/chunk-UYWUVEP6.js.map +1 -0
  81. package/dist/chunk-WUV3PSCZ.js +54 -0
  82. package/dist/chunk-WUV3PSCZ.js.map +1 -0
  83. package/dist/chunk-XRXKH7KA.js +268 -0
  84. package/dist/chunk-XRXKH7KA.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-CymXZCeY.d.ts +178 -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.js +1 -54
  92. package/dist/debounce/index.js.map +1 -1
  93. package/dist/devtools/index.d.ts +1 -1
  94. package/dist/devtools/index.js +18 -6
  95. package/dist/devtools/index.js.map +1 -1
  96. package/dist/do/index.d.ts +1732 -92
  97. package/dist/do/index.js +677 -31
  98. package/dist/do/index.js.map +1 -1
  99. package/dist/durable-effects-2MDxDs45.d.ts +180 -0
  100. package/dist/extension/index.d.ts +31 -13
  101. package/dist/extension/index.js +5 -3
  102. package/dist/extension/index.js.map +1 -1
  103. package/dist/extension/react.js +8 -3
  104. package/dist/extension/react.js.map +1 -1
  105. package/dist/extension/subs/index.d.ts +2 -2
  106. package/dist/extension/subs/index.js.map +1 -1
  107. package/dist/extension/test-utils.js +11 -3
  108. package/dist/extension/test-utils.js.map +1 -1
  109. package/dist/fan-out/index.d.ts +239 -0
  110. package/dist/fan-out/index.js +5 -0
  111. package/dist/fan-out/index.js.map +1 -0
  112. package/dist/idempotency/adapter.d.ts +62 -0
  113. package/dist/idempotency/adapter.js +4 -0
  114. package/dist/idempotency/adapter.js.map +1 -0
  115. package/dist/idempotency/index.js +1 -56
  116. package/dist/idempotency/index.js.map +1 -1
  117. package/dist/idempotent-intake/index.d.ts +186 -0
  118. package/dist/idempotent-intake/index.js +7 -0
  119. package/dist/idempotent-intake/index.js.map +1 -0
  120. package/dist/index.d.ts +341 -191
  121. package/dist/index.js +2 -1
  122. package/dist/llm-call/index.d.ts +309 -0
  123. package/dist/llm-call/index.js +14 -0
  124. package/dist/llm-call/index.js.map +1 -0
  125. package/dist/machine-viz/index.d.ts +1 -1
  126. package/dist/machine-viz/index.js +1 -1
  127. package/dist/machine-viz/index.js.map +1 -1
  128. package/dist/mem/index.d.ts +1 -0
  129. package/dist/monitored-run/index.d.ts +253 -0
  130. package/dist/monitored-run/index.js +10 -0
  131. package/dist/monitored-run/index.js.map +1 -0
  132. package/dist/node/index.d.ts +5 -5
  133. package/dist/node/index.js.map +1 -1
  134. package/dist/paginated-walk/index.d.ts +234 -0
  135. package/dist/paginated-walk/index.js +15 -0
  136. package/dist/paginated-walk/index.js.map +1 -0
  137. package/dist/paginator/index.d.ts +271 -0
  138. package/dist/paginator/index.js +3 -0
  139. package/dist/paginator/index.js.map +1 -0
  140. package/dist/parity/index.d.ts +86 -0
  141. package/dist/parity/index.js +77 -0
  142. package/dist/parity/index.js.map +1 -0
  143. package/dist/pbt/arbitraries/index.d.ts +1 -1
  144. package/dist/pbt/arbitraries/index.js +1 -1
  145. package/dist/pbt/index.d.ts +1 -1
  146. package/dist/pbt/index.js +5 -4
  147. package/dist/pbt/index.js.map +1 -1
  148. package/dist/pbt/runners/index.d.ts +1 -1
  149. package/dist/pbt/runners/index.js +3 -2
  150. package/dist/poller/index.d.ts +288 -0
  151. package/dist/poller/index.js +79 -0
  152. package/dist/poller/index.js.map +1 -0
  153. package/dist/prediction/index.d.ts +109 -0
  154. package/dist/prediction/index.js +4 -0
  155. package/dist/prediction/index.js.map +1 -0
  156. package/dist/protocol-B5YmJr0d.d.ts +45 -0
  157. package/dist/pure/index.d.ts +2 -0
  158. package/dist/pure/index.js +4 -0
  159. package/dist/pure/index.js.map +1 -0
  160. package/dist/raft/demo.d.ts +105 -0
  161. package/dist/raft/demo.js +172 -0
  162. package/dist/raft/demo.js.map +1 -0
  163. package/dist/raft/index.d.ts +548 -0
  164. package/dist/raft/index.js +7 -0
  165. package/dist/raft/index.js.map +1 -0
  166. package/dist/raft/sim.d.ts +222 -0
  167. package/dist/raft/sim.js +8 -0
  168. package/dist/raft/sim.js.map +1 -0
  169. package/dist/raft/timeline.d.ts +46 -0
  170. package/dist/raft/timeline.js +62 -0
  171. package/dist/raft/timeline.js.map +1 -0
  172. package/dist/rate-limit/index.js +1 -43
  173. package/dist/rate-limit/index.js.map +1 -1
  174. package/dist/react/index.d.ts +2 -1
  175. package/dist/react/index.js +39 -20
  176. package/dist/react/index.js.map +1 -1
  177. package/dist/reconciler/index.d.ts +192 -0
  178. package/dist/reconciler/index.js +166 -0
  179. package/dist/reconciler/index.js.map +1 -0
  180. package/dist/recorder/index.d.ts +7 -3
  181. package/dist/recorder/index.js +1 -155
  182. package/dist/recorder/index.js.map +1 -1
  183. package/dist/resilient-call/index.d.ts +251 -0
  184. package/dist/resilient-call/index.js +13 -0
  185. package/dist/resilient-call/index.js.map +1 -0
  186. package/dist/retry-backoff/index.d.ts +42 -5
  187. package/dist/retry-backoff/index.js +1 -35
  188. package/dist/retry-backoff/index.js.map +1 -1
  189. package/dist/saga/index.d.ts +225 -0
  190. package/dist/saga/index.js +150 -0
  191. package/dist/saga/index.js.map +1 -0
  192. package/dist/snapshot/index.d.ts +334 -0
  193. package/dist/snapshot/index.js +5 -0
  194. package/dist/snapshot/index.js.map +1 -0
  195. package/dist/subs/index.d.ts +48 -10
  196. package/dist/subs/index.js +1 -1
  197. package/dist/testing/index.d.ts +107 -2
  198. package/dist/testing/index.js +204 -6
  199. package/dist/testing/index.js.map +1 -1
  200. package/dist/throttle/index.js +1 -52
  201. package/dist/throttle/index.js.map +1 -1
  202. package/dist/throttled-input/index.d.ts +372 -0
  203. package/dist/throttled-input/index.js +88 -0
  204. package/dist/throttled-input/index.js.map +1 -0
  205. package/dist/token-refresh/index.d.ts +215 -0
  206. package/dist/token-refresh/index.js +6 -0
  207. package/dist/token-refresh/index.js.map +1 -0
  208. package/dist/trace-replay/index.d.ts +11 -2
  209. package/dist/trace-replay/index.js +3 -55
  210. package/dist/trace-replay/index.js.map +1 -1
  211. package/dist/{types-CwBC5wMn.d.ts → types-N9LZbdnr.d.ts} +1 -1
  212. package/dist/with-deadline/index.d.ts +196 -0
  213. package/dist/with-deadline/index.js +130 -0
  214. package/dist/with-deadline/index.js.map +1 -0
  215. package/dist/with-resilience/index.d.ts +205 -0
  216. package/dist/with-resilience/index.js +274 -0
  217. package/dist/with-resilience/index.js.map +1 -0
  218. package/dist/with-telemetry/index.d.ts +149 -0
  219. package/dist/with-telemetry/index.js +95 -0
  220. package/dist/with-telemetry/index.js.map +1 -0
  221. package/dist/work-queue/adapter.d.ts +82 -0
  222. package/dist/work-queue/adapter.js +4 -0
  223. package/dist/work-queue/adapter.js.map +1 -0
  224. package/dist/work-queue/index.d.ts +25 -11
  225. package/dist/work-queue/index.js +1 -46
  226. package/dist/work-queue/index.js.map +1 -1
  227. package/dist/work-queue/ops.d.ts +69 -0
  228. package/dist/work-queue/ops.js +3 -0
  229. package/dist/work-queue/ops.js.map +1 -0
  230. package/dist/workflow/index.d.ts +444 -0
  231. package/dist/workflow/index.js +225 -0
  232. package/dist/workflow/index.js.map +1 -0
  233. package/package.json +162 -25
  234. package/dist/chunk-CMG2KMWX.js.map +0 -1
  235. package/dist/chunk-PYBQREFU.js.map +0 -1
  236. package/dist/chunk-PYOCUBQJ.js.map +0 -1
  237. package/dist/chunk-Y5JM3UUH.js.map +0 -1
  238. package/dist/chunk-ZRUST6BK.js.map +0 -1
@@ -0,0 +1,105 @@
1
+ import { NodeId, RaftState, RaftConfig } from './index.js';
2
+ import { SimTrace, Schedule } from './sim.js';
3
+ import '../deadline/index.js';
4
+ import '../core-CymXZCeY.js';
5
+ import '../types-N9LZbdnr.js';
6
+
7
+ /**
8
+ * @demlik/tea/raft — a runnable, deterministic multi-node Raft demo (#123,
9
+ * stories 7+8 of epic #117). The live demo the docs reference: it boots a
10
+ * 3-node cluster and drives the full consensus arc through {@link runSchedule}
11
+ * — elect a leader, replicate + commit a client command to a majority, KILL the
12
+ * leader (a network partition), watch the surviving majority elect a NEW leader,
13
+ * and show the committed log converged across the survivors.
14
+ *
15
+ * It is a thin, readable composition over the existing simulation harness
16
+ * (`./sim`): every phase is just a slice of one fixed {@link Schedule}, so the
17
+ * whole run is pure, replayable, and byte-identical on re-run (the determinism
18
+ * the safety suite already enforces — here turned into a watchable narrative).
19
+ * It owns NO clock, NO RNG, NO IO beyond an optional narration printer.
20
+ *
21
+ * Two entry points:
22
+ * - {@link runDemo} — fold the scripted schedule, return a structured
23
+ * {@link DemoResult} (the phases, the per-phase cluster summary, the final
24
+ * trace). Pure: it asserts nothing and prints nothing — the test and the
25
+ * CLI both read its result.
26
+ * - {@link narrateDemo} — render a {@link DemoResult} as readable lines (the
27
+ * `pnpm demo:raft` output). Pure string-building; the runner does the IO.
28
+ *
29
+ * Run it: `pnpm --filter @demlik/tea demo:raft`
30
+ * (see `src/raft/README.md`).
31
+ */
32
+
33
+ /** A compact, JSON-friendly read of one node's state (what the demo reports). */
34
+ interface NodeSummary {
35
+ /** The node's id. */
36
+ readonly id: NodeId;
37
+ /** Its role this snapshot: follower / candidate / leader. */
38
+ readonly role: RaftState["role"]["_tag"];
39
+ /** Its current term. */
40
+ readonly term: number;
41
+ /** Its commit index (entries at or below this are committed). */
42
+ readonly commitIndex: number;
43
+ /** The COMMITTED commands, in log order (entries `1..commitIndex`). */
44
+ readonly committed: readonly number[];
45
+ }
46
+ /** Read one node's committed commands (the prefix of its log up to commitIndex). */
47
+ declare function committedCommands(state: RaftState<number>): readonly number[];
48
+ /** A snapshot of one phase's outcome: its headline + the cluster after it ran. */
49
+ interface PhaseOutcome {
50
+ /** The phase headline. */
51
+ readonly title: string;
52
+ /** The phase's one-line note. */
53
+ readonly note: string;
54
+ /** The single leader after this phase, or `null`. */
55
+ readonly leader: NodeId | null;
56
+ /** Every node's summary after this phase. */
57
+ readonly cluster: readonly NodeSummary[];
58
+ }
59
+ /** The structured outcome of a full demo run — pure data, asserts nothing. */
60
+ interface DemoResult {
61
+ /** The cluster node ids, in config order. */
62
+ readonly nodes: readonly NodeId[];
63
+ /** The nodes partitioned off by the end of the run (the killed leader). */
64
+ readonly partitioned: readonly NodeId[];
65
+ /** The leader elected in phase 1 (before the kill). */
66
+ readonly firstLeader: NodeId | null;
67
+ /** The leader elected in phase 4 (after the kill) — a DIFFERENT node. */
68
+ readonly secondLeader: NodeId | null;
69
+ /** The commands committed across the SURVIVING majority by the end. */
70
+ readonly convergedLog: readonly number[];
71
+ /** Per-phase outcomes, in order. */
72
+ readonly phases: readonly PhaseOutcome[];
73
+ /** The full underlying simulation trace (for replay / deep inspection). */
74
+ readonly trace: SimTrace;
75
+ }
76
+ /** The demo cluster: 3 nodes, default election-timeout window + heartbeat. */
77
+ declare function demoCluster(): readonly RaftConfig[];
78
+ /** The full scripted schedule (every phase's events concatenated, in order). */
79
+ declare function demoSchedule(): Schedule;
80
+ /**
81
+ * The nodes that survive the partition (everyone NOT in the down-set). The demo
82
+ * checks convergence over THESE — the partitioned leader is, by construction,
83
+ * stuck at its pre-kill log.
84
+ */
85
+ declare function survivors(nodes: readonly NodeId[], partitioned: readonly NodeId[]): readonly NodeId[];
86
+ /**
87
+ * Run the scripted demo against a fresh 3-node cluster and return a structured
88
+ * {@link DemoResult}. Pure: folds {@link demoSchedule} through {@link runSchedule},
89
+ * then reads the cluster at each phase boundary. Deterministic — the same
90
+ * schedule yields a byte-identical trace, so {@link runDemo}() === runDemo().
91
+ */
92
+ declare function runDemo(): DemoResult;
93
+ /**
94
+ * Render a {@link DemoResult} as a readable, deterministic narrative — the
95
+ * `pnpm demo:raft` output. Pure: returns the lines; the runner prints them.
96
+ */
97
+ declare function narrateDemo(result: DemoResult): string;
98
+ /**
99
+ * Re-run the demo's schedule from a fresh cluster and report whether the trace
100
+ * is byte-identical to the original run (the determinism contract). Used by the
101
+ * test and surfaced in the CLI footer.
102
+ */
103
+ declare function demoIsReproducible(result: DemoResult): boolean;
104
+
105
+ export { type DemoResult, type NodeSummary, type PhaseOutcome, committedCommands, demoCluster, demoIsReproducible, demoSchedule, narrateDemo, runDemo, survivors };
@@ -0,0 +1,172 @@
1
+ import { clusterConfigs, runSchedule, replaySchedule } from '../chunk-7VEG4KHB.js';
2
+ import '../chunk-XRXKH7KA.js';
3
+ import '../chunk-QGZW4W3E.js';
4
+ import '../chunk-2YFLOCWX.js';
5
+ import '../chunk-2WZHS4P2.js';
6
+ import '../chunk-BNJYE4AU.js';
7
+
8
+ // src/raft/demo.ts
9
+ var N0 = "n0";
10
+ var N1 = "n1";
11
+ var FIRST_COMMAND = 42;
12
+ var SECOND_COMMAND = 77;
13
+ var PHASES = [
14
+ {
15
+ title: "1. Elect a leader",
16
+ note: "n0 times out, runs an election, and wins a majority (term 1).",
17
+ events: [
18
+ { kind: "timer", node: N0, timer: "election" },
19
+ { kind: "settle", bound: 30 }
20
+ ]
21
+ },
22
+ {
23
+ title: "2. Replicate + commit a command",
24
+ note: `client sends ${FIRST_COMMAND} to the leader; it replicates to a majority, commits, and the next heartbeat carries the commit to the followers.`,
25
+ events: [
26
+ { kind: "client", node: N0, command: FIRST_COMMAND },
27
+ { kind: "settle", bound: 30 },
28
+ // The leader's commitIndex advances on the success replies, but followers
29
+ // only learn it on the NEXT AppendEntries (Figure 2 `leaderCommit`). One
30
+ // heartbeat round propagates the commit so the cluster converges.
31
+ { kind: "timer", node: N0, timer: "heartbeat" },
32
+ { kind: "settle", bound: 30 }
33
+ ]
34
+ },
35
+ {
36
+ title: "3. Kill the leader",
37
+ note: "partition isolates n0 \u2014 the transport drops all traffic to/from it.",
38
+ events: [{ kind: "partition", down: [N0] }]
39
+ },
40
+ {
41
+ title: "4. A new leader is elected",
42
+ note: "a surviving follower (n1) times out and wins the surviving majority {n1,n2} in a higher term.",
43
+ events: [
44
+ { kind: "timer", node: N1, timer: "election" },
45
+ { kind: "settle", bound: 30 }
46
+ ]
47
+ },
48
+ {
49
+ title: "5. Replicate on the new leader \u2192 logs converged",
50
+ note: `client sends ${SECOND_COMMAND} to the new leader; it commits on the surviving majority, and a heartbeat converges the survivors' committed logs.`,
51
+ events: [
52
+ { kind: "client", node: N1, command: SECOND_COMMAND },
53
+ { kind: "settle", bound: 30 },
54
+ // Same as phase 2: a heartbeat round carries the new commitIndex to the
55
+ // surviving follower (n2) so every survivor's committed log converges.
56
+ { kind: "timer", node: N1, timer: "heartbeat" },
57
+ { kind: "settle", bound: 30 }
58
+ ]
59
+ }
60
+ ];
61
+ function committedCommands(state) {
62
+ return state.log.slice(0, state.commitIndex).map((e) => e.command);
63
+ }
64
+ function summarizeNode(id, states) {
65
+ const state = states[id];
66
+ if (!state) {
67
+ return { id, role: "follower", term: 0, commitIndex: 0, committed: [] };
68
+ }
69
+ return {
70
+ id,
71
+ role: state.role._tag,
72
+ term: state.currentTerm,
73
+ commitIndex: state.commitIndex,
74
+ committed: committedCommands(state)
75
+ };
76
+ }
77
+ function summarizeCluster(nodes, states) {
78
+ return nodes.map((id) => summarizeNode(id, states));
79
+ }
80
+ function leaderOf(nodes, states) {
81
+ const leaders = nodes.filter((id) => states[id]?.role._tag === "leader");
82
+ if (leaders.length === 0) return null;
83
+ const topTerm = Math.max(
84
+ ...leaders.map((id) => states[id]?.currentTerm ?? 0)
85
+ );
86
+ const top = leaders.filter((id) => states[id]?.currentTerm === topTerm);
87
+ return top.length === 1 ? top[0] ?? null : null;
88
+ }
89
+ function demoCluster() {
90
+ return clusterConfigs(3);
91
+ }
92
+ function demoSchedule() {
93
+ return PHASES.flatMap((phase) => phase.events);
94
+ }
95
+ function survivors(nodes, partitioned) {
96
+ const down = new Set(partitioned);
97
+ return nodes.filter((id) => !down.has(id));
98
+ }
99
+ function runDemo() {
100
+ const configs = demoCluster();
101
+ const nodes = configs.map((c) => c.self);
102
+ const schedule = demoSchedule();
103
+ const trace = runSchedule(configs, schedule);
104
+ const phases = [];
105
+ for (let i = 0; i < PHASES.length; i++) {
106
+ const phase = PHASES[i];
107
+ if (!phase) continue;
108
+ const prefix = PHASES.slice(0, i + 1).flatMap((p) => p.events);
109
+ const states = runSchedule(configs, prefix).final;
110
+ phases.push({
111
+ title: phase.title,
112
+ note: phase.note,
113
+ leader: leaderOf(nodes, states),
114
+ cluster: summarizeCluster(nodes, states)
115
+ });
116
+ }
117
+ const partitioned = [N0];
118
+ const firstLeader = phases[1]?.leader ?? null;
119
+ const secondLeader = phases.at(-1)?.leader ?? null;
120
+ const leaderState = secondLeader ? trace.final[secondLeader] : void 0;
121
+ const convergedLog = leaderState ? committedCommands(leaderState) : [];
122
+ return {
123
+ nodes,
124
+ partitioned,
125
+ firstLeader,
126
+ secondLeader,
127
+ convergedLog,
128
+ phases,
129
+ trace
130
+ };
131
+ }
132
+ function nodeLine(n) {
133
+ const role = n.role.padEnd(9);
134
+ return ` ${n.id} ${role} term=${n.term} commit=${n.commitIndex} committed=[${n.committed.join(",")}]`;
135
+ }
136
+ function narrateDemo(result) {
137
+ const lines = [];
138
+ lines.push("=".repeat(72));
139
+ lines.push(" @demlik/tea/raft \u2014 multi-node consensus demo (deterministic)");
140
+ lines.push(` cluster: ${result.nodes.join(", ")} (majority = 2 of 3)`);
141
+ lines.push("=".repeat(72));
142
+ for (const phase of result.phases) {
143
+ lines.push("");
144
+ lines.push(phase.title);
145
+ lines.push(` ${phase.note}`);
146
+ lines.push(` leader: ${phase.leader ?? "(none \u2014 election in progress)"}`);
147
+ for (const node of phase.cluster) {
148
+ lines.push(nodeLine(node));
149
+ }
150
+ }
151
+ lines.push("");
152
+ lines.push("-".repeat(72));
153
+ lines.push(" Summary");
154
+ lines.push(` first leader (pre-kill): ${result.firstLeader ?? "none"}`);
155
+ lines.push(` killed (partitioned): ${result.partitioned.join(", ")}`);
156
+ lines.push(` new leader (post-kill): ${result.secondLeader ?? "none"}`);
157
+ lines.push(` converged committed log: [${result.convergedLog.join(", ")}]`);
158
+ const failedOver = result.firstLeader !== null && result.secondLeader !== null && result.firstLeader !== result.secondLeader;
159
+ lines.push(
160
+ ` failover: ${failedOver ? "OK \u2014 a NEW leader took over and the log converged" : "NOT observed"}`
161
+ );
162
+ lines.push("-".repeat(72));
163
+ return lines.join("\n");
164
+ }
165
+ function demoIsReproducible(result) {
166
+ const replay = replaySchedule(demoCluster(), demoSchedule());
167
+ return JSON.stringify(replay.final) === JSON.stringify(result.trace.final) && JSON.stringify(replay.steps) === JSON.stringify(result.trace.steps);
168
+ }
169
+
170
+ export { committedCommands, demoCluster, demoIsReproducible, demoSchedule, narrateDemo, runDemo, survivors };
171
+ //# sourceMappingURL=demo.js.map
172
+ //# sourceMappingURL=demo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/raft/demo.ts"],"names":[],"mappings":"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","file":"demo.js","sourcesContent":["/**\n * @demlik/tea/raft — a runnable, deterministic multi-node Raft demo (#123,\n * stories 7+8 of epic #117). The live demo the docs reference: it boots a\n * 3-node cluster and drives the full consensus arc through {@link runSchedule}\n * — elect a leader, replicate + commit a client command to a majority, KILL the\n * leader (a network partition), watch the surviving majority elect a NEW leader,\n * and show the committed log converged across the survivors.\n *\n * It is a thin, readable composition over the existing simulation harness\n * (`./sim`): every phase is just a slice of one fixed {@link Schedule}, so the\n * whole run is pure, replayable, and byte-identical on re-run (the determinism\n * the safety suite already enforces — here turned into a watchable narrative).\n * It owns NO clock, NO RNG, NO IO beyond an optional narration printer.\n *\n * Two entry points:\n * - {@link runDemo} — fold the scripted schedule, return a structured\n * {@link DemoResult} (the phases, the per-phase cluster summary, the final\n * trace). Pure: it asserts nothing and prints nothing — the test and the\n * CLI both read its result.\n * - {@link narrateDemo} — render a {@link DemoResult} as readable lines (the\n * `pnpm demo:raft` output). Pure string-building; the runner does the IO.\n *\n * Run it: `pnpm --filter @demlik/tea demo:raft`\n * (see `src/raft/README.md`).\n */\n\nimport type { NodeId, RaftState } from \"./index\";\nimport {\n type ClusterStates,\n clusterConfigs,\n type RaftConfig,\n replaySchedule,\n runSchedule,\n type Schedule,\n type SimTrace,\n} from \"./sim\";\n\n// ===========================================================================\n// The scripted scenario — one fixed schedule, sliced into named phases\n// ===========================================================================\n\n// The demo cluster is the 3 default nodes n0/n1/n2 (see {@link clusterConfigs}).\n// Only the two nodes the script names explicitly need constants; the rest of the\n// cluster is addressed via `nodes`.\nconst N0 = \"n0\";\nconst N1 = \"n1\";\n\n/** The two client commands the demo replicates (opaque numeric payloads). */\nconst FIRST_COMMAND = 42;\nconst SECOND_COMMAND = 77;\n\n/**\n * One phase of the demo: a human-readable headline plus the slice of the\n * schedule that drives it. Concatenating every phase's `events` in order yields\n * the full {@link Schedule} {@link runDemo} folds — so the phase boundaries are\n * exactly the trace-step boundaries, and narration lines up with the trace.\n */\ninterface DemoPhase {\n /** What this phase demonstrates (printed as the section headline). */\n readonly title: string;\n /** A one-line plain-language description of the move. */\n readonly note: string;\n /** The schedule events this phase contributes (in order). */\n readonly events: Schedule;\n}\n\n/**\n * The scripted arc. Each phase is a deterministic macro over the harness:\n *\n * 1. n0's election timer fires; `settle` drains the RequestVote round → n0\n * wins a majority (n0+n1, or n0+n2) and becomes leader of term 1, then its\n * first heartbeat round drains.\n * 2. A client command hits the leader; `settle` runs the AppendEntries\n * replication + success replies → the entry commits on a majority.\n * 3. `partition` isolates n0 (the leader): the transport now drops every\n * message to/from n0. n0 can no longer heartbeat the cluster.\n * 4. A surviving follower (n1) times out and starts an election; `settle`\n * drains it across the survivors {n1,n2} (a majority of 3) → n1 becomes\n * leader of a HIGHER term.\n * 5. A client command hits the NEW leader; `settle` replicates it → it commits\n * on the surviving majority. The survivors' committed logs have converged.\n *\n * The `bound`s are generous caps (the FIFO drain stops early when the pool is\n * empty); they only guard against a pathological re-emit loop.\n */\nconst PHASES: readonly DemoPhase[] = [\n {\n title: \"1. Elect a leader\",\n note: \"n0 times out, runs an election, and wins a majority (term 1).\",\n events: [\n { kind: \"timer\", node: N0, timer: \"election\" },\n { kind: \"settle\", bound: 30 },\n ],\n },\n {\n title: \"2. Replicate + commit a command\",\n note: `client sends ${FIRST_COMMAND} to the leader; it replicates to a majority, commits, and the next heartbeat carries the commit to the followers.`,\n events: [\n { kind: \"client\", node: N0, command: FIRST_COMMAND },\n { kind: \"settle\", bound: 30 },\n // The leader's commitIndex advances on the success replies, but followers\n // only learn it on the NEXT AppendEntries (Figure 2 `leaderCommit`). One\n // heartbeat round propagates the commit so the cluster converges.\n { kind: \"timer\", node: N0, timer: \"heartbeat\" },\n { kind: \"settle\", bound: 30 },\n ],\n },\n {\n title: \"3. Kill the leader\",\n note: \"partition isolates n0 — the transport drops all traffic to/from it.\",\n events: [{ kind: \"partition\", down: [N0] }],\n },\n {\n title: \"4. A new leader is elected\",\n note: \"a surviving follower (n1) times out and wins the surviving majority {n1,n2} in a higher term.\",\n events: [\n { kind: \"timer\", node: N1, timer: \"election\" },\n { kind: \"settle\", bound: 30 },\n ],\n },\n {\n title: \"5. Replicate on the new leader → logs converged\",\n note: `client sends ${SECOND_COMMAND} to the new leader; it commits on the surviving majority, and a heartbeat converges the survivors' committed logs.`,\n events: [\n { kind: \"client\", node: N1, command: SECOND_COMMAND },\n { kind: \"settle\", bound: 30 },\n // Same as phase 2: a heartbeat round carries the new commitIndex to the\n // surviving follower (n2) so every survivor's committed log converges.\n { kind: \"timer\", node: N1, timer: \"heartbeat\" },\n { kind: \"settle\", bound: 30 },\n ],\n },\n] as const;\n\n// ===========================================================================\n// Cluster-state reading — the demo's view of \"who leads / what committed\"\n// ===========================================================================\n\n/** A compact, JSON-friendly read of one node's state (what the demo reports). */\nexport interface NodeSummary {\n /** The node's id. */\n readonly id: NodeId;\n /** Its role this snapshot: follower / candidate / leader. */\n readonly role: RaftState[\"role\"][\"_tag\"];\n /** Its current term. */\n readonly term: number;\n /** Its commit index (entries at or below this are committed). */\n readonly commitIndex: number;\n /** The COMMITTED commands, in log order (entries `1..commitIndex`). */\n readonly committed: readonly number[];\n}\n\n/** Read one node's committed commands (the prefix of its log up to commitIndex). */\nexport function committedCommands(state: RaftState<number>): readonly number[] {\n return state.log.slice(0, state.commitIndex).map((e) => e.command);\n}\n\n/** Summarize one node from a cluster snapshot. */\nfunction summarizeNode(id: NodeId, states: ClusterStates): NodeSummary {\n const state = states[id];\n if (!state) {\n return { id, role: \"follower\", term: 0, commitIndex: 0, committed: [] };\n }\n return {\n id,\n role: state.role._tag,\n term: state.currentTerm,\n commitIndex: state.commitIndex,\n committed: committedCommands(state),\n };\n}\n\n/** Summarize every node in a cluster snapshot, in the given order. */\nfunction summarizeCluster(\n nodes: readonly NodeId[],\n states: ClusterStates,\n): readonly NodeSummary[] {\n return nodes.map((id) => summarizeNode(id, states));\n}\n\n/**\n * The cluster's *current* leader in a snapshot: the leader of the HIGHEST term,\n * or `null` if no node leads (mid-election) or two nodes tie for the top term (a\n * genuine split). A partitioned OLD leader keeps believing it leads — it cannot\n * learn it was deposed while its traffic is dropped — but it sits at a strictly\n * LOWER term than whoever the surviving majority elected, so the highest-term\n * leader is the legitimate one. (Election Safety guarantees ≤1 leader per term,\n * so a single top-term leader is unambiguous.)\n */\nfunction leaderOf(\n nodes: readonly NodeId[],\n states: ClusterStates,\n): NodeId | null {\n const leaders = nodes.filter((id) => states[id]?.role._tag === \"leader\");\n if (leaders.length === 0) return null;\n const topTerm = Math.max(\n ...leaders.map((id) => states[id]?.currentTerm ?? 0),\n );\n const top = leaders.filter((id) => states[id]?.currentTerm === topTerm);\n return top.length === 1 ? (top[0] ?? null) : null;\n}\n\n// ===========================================================================\n// The demo result — what runDemo returns (the test + the CLI both read this)\n// ===========================================================================\n\n/** A snapshot of one phase's outcome: its headline + the cluster after it ran. */\nexport interface PhaseOutcome {\n /** The phase headline. */\n readonly title: string;\n /** The phase's one-line note. */\n readonly note: string;\n /** The single leader after this phase, or `null`. */\n readonly leader: NodeId | null;\n /** Every node's summary after this phase. */\n readonly cluster: readonly NodeSummary[];\n}\n\n/** The structured outcome of a full demo run — pure data, asserts nothing. */\nexport interface DemoResult {\n /** The cluster node ids, in config order. */\n readonly nodes: readonly NodeId[];\n /** The nodes partitioned off by the end of the run (the killed leader). */\n readonly partitioned: readonly NodeId[];\n /** The leader elected in phase 1 (before the kill). */\n readonly firstLeader: NodeId | null;\n /** The leader elected in phase 4 (after the kill) — a DIFFERENT node. */\n readonly secondLeader: NodeId | null;\n /** The commands committed across the SURVIVING majority by the end. */\n readonly convergedLog: readonly number[];\n /** Per-phase outcomes, in order. */\n readonly phases: readonly PhaseOutcome[];\n /** The full underlying simulation trace (for replay / deep inspection). */\n readonly trace: SimTrace;\n}\n\n// ===========================================================================\n// Running the demo\n// ===========================================================================\n\n/** The demo cluster: 3 nodes, default election-timeout window + heartbeat. */\nexport function demoCluster(): readonly RaftConfig[] {\n return clusterConfigs(3);\n}\n\n/** The full scripted schedule (every phase's events concatenated, in order). */\nexport function demoSchedule(): Schedule {\n return PHASES.flatMap((phase) => phase.events);\n}\n\n/**\n * The nodes that survive the partition (everyone NOT in the down-set). The demo\n * checks convergence over THESE — the partitioned leader is, by construction,\n * stuck at its pre-kill log.\n */\nexport function survivors(\n nodes: readonly NodeId[],\n partitioned: readonly NodeId[],\n): readonly NodeId[] {\n const down = new Set(partitioned);\n return nodes.filter((id) => !down.has(id));\n}\n\n/**\n * Run the scripted demo against a fresh 3-node cluster and return a structured\n * {@link DemoResult}. Pure: folds {@link demoSchedule} through {@link runSchedule},\n * then reads the cluster at each phase boundary. Deterministic — the same\n * schedule yields a byte-identical trace, so {@link runDemo}() === runDemo().\n */\nexport function runDemo(): DemoResult {\n const configs = demoCluster();\n const nodes = configs.map((c) => c.self);\n const schedule = demoSchedule();\n const trace = runSchedule(configs, schedule);\n\n // Walk the trace one phase at a time: each phase consumes as many trace steps\n // as it contributed events — except `settle`, which expands to one step per\n // drained delivery. So we slice by counting steps until the phase's last\n // non-settle event is consumed. Simpler + robust: re-fold each cumulative\n // prefix of phases and read the resulting cluster (the schedule is tiny).\n const phases: PhaseOutcome[] = [];\n for (let i = 0; i < PHASES.length; i++) {\n const phase = PHASES[i];\n if (!phase) continue;\n const prefix = PHASES.slice(0, i + 1).flatMap((p) => p.events);\n const states = runSchedule(configs, prefix).final;\n phases.push({\n title: phase.title,\n note: phase.note,\n leader: leaderOf(nodes, states),\n cluster: summarizeCluster(nodes, states),\n });\n }\n\n const partitioned: readonly NodeId[] = [N0];\n const firstLeader = phases[1]?.leader ?? null;\n const secondLeader = phases.at(-1)?.leader ?? null;\n\n // Converged log = the committed commands the surviving majority agree on. We\n // read the new leader's committed prefix; the test asserts every survivor\n // matches it once the final settle drains.\n const leaderState = secondLeader ? trace.final[secondLeader] : undefined;\n const convergedLog = leaderState ? committedCommands(leaderState) : [];\n\n return {\n nodes,\n partitioned,\n firstLeader,\n secondLeader,\n convergedLog,\n phases,\n trace,\n };\n}\n\n// ===========================================================================\n// Narration — render a DemoResult as readable lines (the CLI output)\n// ===========================================================================\n\n/** One node's summary as a compact line, e.g. `n0 leader term=2 commit=2 [42,77]`. */\nfunction nodeLine(n: NodeSummary): string {\n const role = n.role.padEnd(9);\n return ` ${n.id} ${role} term=${n.term} commit=${n.commitIndex} committed=[${n.committed.join(\",\")}]`;\n}\n\n/**\n * Render a {@link DemoResult} as a readable, deterministic narrative — the\n * `pnpm demo:raft` output. Pure: returns the lines; the runner prints them.\n */\nexport function narrateDemo(result: DemoResult): string {\n const lines: string[] = [];\n lines.push(\"=\".repeat(72));\n lines.push(\" @demlik/tea/raft — multi-node consensus demo (deterministic)\");\n lines.push(` cluster: ${result.nodes.join(\", \")} (majority = 2 of 3)`);\n lines.push(\"=\".repeat(72));\n\n for (const phase of result.phases) {\n lines.push(\"\");\n lines.push(phase.title);\n lines.push(` ${phase.note}`);\n lines.push(` leader: ${phase.leader ?? \"(none — election in progress)\"}`);\n for (const node of phase.cluster) {\n lines.push(nodeLine(node));\n }\n }\n\n lines.push(\"\");\n lines.push(\"-\".repeat(72));\n lines.push(\" Summary\");\n lines.push(` first leader (pre-kill): ${result.firstLeader ?? \"none\"}`);\n lines.push(` killed (partitioned): ${result.partitioned.join(\", \")}`);\n lines.push(` new leader (post-kill): ${result.secondLeader ?? \"none\"}`);\n lines.push(` converged committed log: [${result.convergedLog.join(\", \")}]`);\n const failedOver =\n result.firstLeader !== null &&\n result.secondLeader !== null &&\n result.firstLeader !== result.secondLeader;\n lines.push(\n ` failover: ${failedOver ? \"OK — a NEW leader took over and the log converged\" : \"NOT observed\"}`,\n );\n lines.push(\"-\".repeat(72));\n return lines.join(\"\\n\");\n}\n\n// ===========================================================================\n// Replay helper — prove the demo is byte-identically reproducible\n// ===========================================================================\n\n/**\n * Re-run the demo's schedule from a fresh cluster and report whether the trace\n * is byte-identical to the original run (the determinism contract). Used by the\n * test and surfaced in the CLI footer.\n */\nexport function demoIsReproducible(result: DemoResult): boolean {\n const replay = replaySchedule(demoCluster(), demoSchedule());\n return (\n JSON.stringify(replay.final) === JSON.stringify(result.trace.final) &&\n JSON.stringify(replay.steps) === JSON.stringify(result.trace.steps)\n );\n}\n"]}