@demlik/tea 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/agent/index.d.ts +16 -770
- package/dist/agent/index.js +18 -16
- package/dist/authed-call/index.d.ts +3 -3
- package/dist/authed-call/index.js +22 -36
- package/dist/authed-call/index.js.map +1 -1
- package/dist/await-terminal/index.d.ts +2 -2
- package/dist/await-terminal/index.js +2 -2
- package/dist/batch-window/index.d.ts +3 -3
- package/dist/batch-window/index.js +3 -3
- package/dist/cache/index.d.ts +2 -2
- package/dist/cache/index.js +5 -2
- package/dist/{chunk-3545BOYL.js → chunk-25LBDCBC.js} +6 -5
- package/dist/chunk-25LBDCBC.js.map +1 -0
- package/dist/{chunk-IN33Q4VJ.js → chunk-2KNLKQVN.js} +3 -3
- package/dist/{chunk-IN33Q4VJ.js.map → chunk-2KNLKQVN.js.map} +1 -1
- package/dist/{chunk-MMH7CW5K.js → chunk-BP2R7TPP.js} +216 -157
- package/dist/chunk-BP2R7TPP.js.map +1 -0
- package/dist/chunk-C6QC273X.js +71 -0
- package/dist/chunk-C6QC273X.js.map +1 -0
- package/dist/{chunk-7W4LMPUW.js → chunk-CAXMQJJ3.js} +17 -24
- package/dist/chunk-CAXMQJJ3.js.map +1 -0
- package/dist/{chunk-UV6UWGEO.js → chunk-D736QUGF.js} +6 -5
- package/dist/chunk-D736QUGF.js.map +1 -0
- package/dist/chunk-EWJP3YYP.js +21 -0
- package/dist/chunk-EWJP3YYP.js.map +1 -0
- package/dist/{chunk-TGG26XMK.js → chunk-FV3Z5MBE.js} +10 -5
- package/dist/chunk-FV3Z5MBE.js.map +1 -0
- package/dist/chunk-G5E74IJB.js +8 -0
- package/dist/chunk-G5E74IJB.js.map +1 -0
- package/dist/{chunk-27N2SPTN.js → chunk-IXBKFQH5.js} +12 -11
- package/dist/chunk-IXBKFQH5.js.map +1 -0
- package/dist/{chunk-SQTCS44W.js → chunk-JFFVBEDI.js} +3 -3
- package/dist/{chunk-SQTCS44W.js.map → chunk-JFFVBEDI.js.map} +1 -1
- package/dist/chunk-LYLDCIPH.js +365 -0
- package/dist/chunk-LYLDCIPH.js.map +1 -0
- package/dist/{chunk-HJO6VNQI.js → chunk-MU6MCIAC.js} +3 -3
- package/dist/{chunk-HJO6VNQI.js.map → chunk-MU6MCIAC.js.map} +1 -1
- package/dist/{chunk-UYWUVEP6.js → chunk-MUUROGIH.js} +2 -2
- package/dist/chunk-MUUROGIH.js.map +1 -0
- package/dist/{chunk-EHIX7NYZ.js → chunk-MVY5DAMF.js} +4 -4
- package/dist/{chunk-EHIX7NYZ.js.map → chunk-MVY5DAMF.js.map} +1 -1
- package/dist/{chunk-LP7VQBRG.js → chunk-NPERUUUE.js} +76 -95
- package/dist/chunk-NPERUUUE.js.map +1 -0
- package/dist/{chunk-QLA76GIT.js → chunk-PBDYW2TU.js} +68 -9
- package/dist/chunk-PBDYW2TU.js.map +1 -0
- package/dist/{chunk-5VNWZADP.js → chunk-Q2UM7BXU.js} +18 -7
- package/dist/chunk-Q2UM7BXU.js.map +1 -0
- package/dist/chunk-RMX4EOQK.js +26 -0
- package/dist/chunk-RMX4EOQK.js.map +1 -0
- package/dist/{chunk-2YOE5PIS.js → chunk-S2EGRPCM.js} +8 -6
- package/dist/chunk-S2EGRPCM.js.map +1 -0
- package/dist/{chunk-ON24RKSE.js → chunk-S4O26D7B.js} +7 -14
- package/dist/chunk-S4O26D7B.js.map +1 -0
- package/dist/{chunk-3XJWC7VW.js → chunk-SK5JGUY7.js} +3 -3
- package/dist/{chunk-3XJWC7VW.js.map → chunk-SK5JGUY7.js.map} +1 -1
- package/dist/{chunk-5DEQ5I3U.js → chunk-TPGASTFF.js} +8 -10
- package/dist/chunk-TPGASTFF.js.map +1 -0
- package/dist/{chunk-SC3FAFZN.js → chunk-UF4MZSLT.js} +2 -2
- package/dist/chunk-UF4MZSLT.js.map +1 -0
- package/dist/{chunk-PS23H6KQ.js → chunk-VBUCHVCV.js} +6 -5
- package/dist/chunk-VBUCHVCV.js.map +1 -0
- package/dist/{chunk-S3FQN4SL.js → chunk-WGGAMW7Y.js} +191 -178
- package/dist/chunk-WGGAMW7Y.js.map +1 -0
- package/dist/chunk-ZJ5AOYBH.js +8 -0
- package/dist/chunk-ZJ5AOYBH.js.map +1 -0
- package/dist/{chunk-LEC4U6CX.js → chunk-ZVJZ72HY.js} +3 -3
- package/dist/{chunk-LEC4U6CX.js.map → chunk-ZVJZ72HY.js.map} +1 -1
- package/dist/circuit-breaker/index.d.ts +1 -0
- package/dist/circuit-breaker/index.js +1 -1
- package/dist/{core-BoB6t7dB.d.ts → core-42kapW0n.d.ts} +105 -6
- package/dist/deadline/index.d.ts +77 -11
- package/dist/deadline/index.js +3 -3
- package/dist/devtools/index.js +8 -28
- package/dist/devtools/index.js.map +1 -1
- package/dist/do/index.d.ts +483 -339
- package/dist/do/index.js +244 -214
- package/dist/do/index.js.map +1 -1
- package/dist/{durable-effects-CO9Uh971.d.ts → durable-effects-CFRngTNA.d.ts} +3 -3
- package/dist/extension/index.d.ts +25 -7
- package/dist/extension/index.js +4 -3
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/react.js +4 -3
- package/dist/extension/react.js.map +1 -1
- package/dist/extension/subs/index.d.ts +2 -2
- package/dist/extension/subs/index.js +7 -10
- package/dist/extension/subs/index.js.map +1 -1
- package/dist/fan-out/index.d.ts +10 -10
- package/dist/fan-out/index.js +3 -3
- package/dist/idempotent-intake/index.d.ts +2 -2
- package/dist/idempotent-intake/index.js +3 -3
- package/dist/index.d.ts +50 -472
- package/dist/index.js +2 -2
- package/dist/llm-call/index.d.ts +3 -2
- package/dist/llm-call/index.js +12 -10
- package/dist/machine-B_BF4jij.d.ts +804 -0
- package/dist/machine-viz/index.d.ts +1 -1
- package/dist/machine-viz/index.js +2 -2
- package/dist/mem/index.d.ts +3 -2
- package/dist/mem/index.js.map +1 -1
- package/dist/monitored-run/index.d.ts +54 -30
- package/dist/monitored-run/index.js +7 -7
- package/dist/node/index.d.ts +3 -2
- package/dist/node/index.js +2 -3
- package/dist/node/index.js.map +1 -1
- package/dist/paginated-walk/index.d.ts +2 -2
- package/dist/paginated-walk/index.js +11 -10
- package/dist/parity/index.d.ts +6 -5
- package/dist/parity/index.js +5 -4
- package/dist/parity/index.js.map +1 -1
- package/dist/pbt/arbitraries/index.d.ts +1 -1
- package/dist/pbt/index.d.ts +1 -1
- package/dist/pbt/index.js +3 -3
- package/dist/pbt/index.js.map +1 -1
- package/dist/pbt/runners/index.d.ts +1 -1
- package/dist/pbt/runners/index.js +3 -3
- package/dist/poller/index.d.ts +59 -17
- package/dist/poller/index.js +56 -20
- package/dist/poller/index.js.map +1 -1
- package/dist/prediction/index.d.ts +1 -1
- package/dist/prediction/index.js +2 -2
- package/dist/pure/index.d.ts +1 -1
- package/dist/pure/index.js +2 -2
- package/dist/rate-limit/index.d.ts +2 -2
- package/dist/rate-limit/index.js +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +2 -2
- package/dist/react/index.js.map +1 -1
- package/dist/reconciler/index.d.ts +2 -2
- package/dist/reconciler/index.js +13 -15
- package/dist/reconciler/index.js.map +1 -1
- package/dist/recorder/index.d.ts +20 -20
- package/dist/recorder/index.js +1 -1
- package/dist/resilient-call/index.d.ts +31 -6
- package/dist/resilient-call/index.js +10 -9
- package/dist/retry-backoff/index.d.ts +191 -14
- package/dist/retry-backoff/index.js +1 -1
- package/dist/retry-to-success/index.js +1 -1
- package/dist/runtime-types-gsog27b6.d.ts +337 -0
- package/dist/saga/index.d.ts +3 -2
- package/dist/saga/index.js +2 -2
- package/dist/saga/index.js.map +1 -1
- package/dist/snapshot/index.d.ts +1 -1
- package/dist/snapshot/index.js +3 -3
- package/dist/subs/index.d.ts +372 -4
- package/dist/subs/index.js +4 -1
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +2 -2
- package/dist/throttled-input/index.d.ts +2 -2
- package/dist/throttled-input/index.js +14 -13
- package/dist/throttled-input/index.js.map +1 -1
- package/dist/token-refresh/index.d.ts +1 -1
- package/dist/token-refresh/index.js +3 -3
- package/dist/trace-replay/index.d.ts +12 -7
- package/dist/trace-replay/index.js +4 -3
- package/dist/{types-B-8bcAp8.d.ts → types-KbL3W1mj.d.ts} +1 -1
- package/dist/with-deadline/index.d.ts +1 -1
- package/dist/with-deadline/index.js +2 -2
- package/dist/with-resilience/index.d.ts +21 -2
- package/dist/with-resilience/index.js +12 -23
- package/dist/with-resilience/index.js.map +1 -1
- package/dist/with-telemetry/index.d.ts +1 -1
- package/dist/with-telemetry/index.js +2 -2
- package/dist/work-queue/adapter.d.ts +2 -2
- package/dist/work-queue/adapter.js +2 -2
- package/dist/work-queue/index.d.ts +3 -2
- package/dist/work-queue/index.js +1 -1
- package/dist/work-queue/index.js.map +1 -1
- package/dist/work-queue/ops.d.ts +2 -2
- package/dist/work-queue/ops.js +1 -1
- package/dist/workflow/index.d.ts +51 -19
- package/dist/workflow/index.js +71 -52
- package/dist/workflow/index.js.map +1 -1
- package/package.json +4 -19
- package/dist/chunk-27N2SPTN.js.map +0 -1
- package/dist/chunk-2YOE5PIS.js.map +0 -1
- package/dist/chunk-3545BOYL.js.map +0 -1
- package/dist/chunk-5DEQ5I3U.js.map +0 -1
- package/dist/chunk-5VNWZADP.js.map +0 -1
- package/dist/chunk-6RGBKODV.js +0 -268
- package/dist/chunk-6RGBKODV.js.map +0 -1
- package/dist/chunk-7W4LMPUW.js.map +0 -1
- package/dist/chunk-DILMKOZ7.js +0 -91
- package/dist/chunk-DILMKOZ7.js.map +0 -1
- package/dist/chunk-K7Q23EQ4.js +0 -20
- package/dist/chunk-K7Q23EQ4.js.map +0 -1
- package/dist/chunk-LP7VQBRG.js.map +0 -1
- package/dist/chunk-MMH7CW5K.js.map +0 -1
- package/dist/chunk-NSDFA6M6.js +0 -198
- package/dist/chunk-NSDFA6M6.js.map +0 -1
- package/dist/chunk-ON24RKSE.js.map +0 -1
- package/dist/chunk-PS23H6KQ.js.map +0 -1
- package/dist/chunk-QLA76GIT.js.map +0 -1
- package/dist/chunk-S3FQN4SL.js.map +0 -1
- package/dist/chunk-SC3FAFZN.js.map +0 -1
- package/dist/chunk-TGG26XMK.js.map +0 -1
- package/dist/chunk-UV6UWGEO.js.map +0 -1
- package/dist/chunk-UYWUVEP6.js.map +0 -1
- package/dist/chunk-VLGARN6H.js +0 -202
- package/dist/chunk-VLGARN6H.js.map +0 -1
- package/dist/raft/demo.d.ts +0 -105
- package/dist/raft/demo.js +0 -172
- package/dist/raft/demo.js.map +0 -1
- package/dist/raft/index.d.ts +0 -548
- package/dist/raft/index.js +0 -7
- package/dist/raft/index.js.map +0 -1
- package/dist/raft/sim.d.ts +0 -222
- package/dist/raft/sim.js +0 -8
- package/dist/raft/sim.js.map +0 -1
- package/dist/raft/timeline.d.ts +0 -46
- package/dist/raft/timeline.js +0 -62
- package/dist/raft/timeline.js.map +0 -1
package/dist/chunk-VLGARN6H.js
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import { createRaftNode } from './chunk-6RGBKODV.js';
|
|
2
|
-
|
|
3
|
-
// src/raft/sim.ts
|
|
4
|
-
function clusterConfigs(n, over) {
|
|
5
|
-
const ids = Array.from({ length: n }, (_, i) => `n${i}`);
|
|
6
|
-
return ids.map((self) => ({
|
|
7
|
-
self,
|
|
8
|
-
peers: ids.filter((id) => id !== self),
|
|
9
|
-
electionTimeout: over?.electionTimeout ?? { minMs: 150, maxMs: 300 },
|
|
10
|
-
heartbeatMs: over?.heartbeatMs ?? 50
|
|
11
|
-
}));
|
|
12
|
-
}
|
|
13
|
-
function fixedRng(index, count) {
|
|
14
|
-
const value = count > 0 ? index / count : 0;
|
|
15
|
-
return () => value;
|
|
16
|
-
}
|
|
17
|
-
function cmdToInbound(sender, cmd) {
|
|
18
|
-
switch (cmd.type) {
|
|
19
|
-
case "raft:send_request_vote": {
|
|
20
|
-
const msg = {
|
|
21
|
-
_tag: "request_vote_request",
|
|
22
|
-
term: cmd.term,
|
|
23
|
-
candidateId: cmd.candidateId,
|
|
24
|
-
lastLogIndex: cmd.lastLogIndex,
|
|
25
|
-
lastLogTerm: cmd.lastLogTerm
|
|
26
|
-
};
|
|
27
|
-
return msg;
|
|
28
|
-
}
|
|
29
|
-
case "raft:send_request_vote_reply": {
|
|
30
|
-
const msg = {
|
|
31
|
-
_tag: "request_vote_reply",
|
|
32
|
-
term: cmd.term,
|
|
33
|
-
from: sender,
|
|
34
|
-
voteGranted: cmd.voteGranted
|
|
35
|
-
};
|
|
36
|
-
return msg;
|
|
37
|
-
}
|
|
38
|
-
case "raft:send_append_entries": {
|
|
39
|
-
const msg = {
|
|
40
|
-
_tag: "append_entries_request",
|
|
41
|
-
term: cmd.term,
|
|
42
|
-
leaderId: cmd.leaderId,
|
|
43
|
-
prevLogIndex: cmd.prevLogIndex,
|
|
44
|
-
prevLogTerm: cmd.prevLogTerm,
|
|
45
|
-
entries: cmd.entries,
|
|
46
|
-
leaderCommit: cmd.leaderCommit
|
|
47
|
-
};
|
|
48
|
-
return msg;
|
|
49
|
-
}
|
|
50
|
-
case "raft:send_append_entries_reply": {
|
|
51
|
-
const msg = {
|
|
52
|
-
_tag: "append_entries_reply",
|
|
53
|
-
term: cmd.term,
|
|
54
|
-
from: sender,
|
|
55
|
-
success: cmd.success,
|
|
56
|
-
matchIndex: cmd.matchIndex
|
|
57
|
-
};
|
|
58
|
-
return msg;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
function applyInbound(node, state, msg, at) {
|
|
63
|
-
switch (msg._tag) {
|
|
64
|
-
case "election_timeout_fired":
|
|
65
|
-
return node.onElectionTimeout(state, at);
|
|
66
|
-
case "heartbeat_fired":
|
|
67
|
-
return node.onHeartbeat(state, at);
|
|
68
|
-
case "request_vote_request":
|
|
69
|
-
return node.onRequestVote(state, msg);
|
|
70
|
-
case "request_vote_reply":
|
|
71
|
-
return node.onRequestVoteReply(state, msg);
|
|
72
|
-
case "append_entries_request":
|
|
73
|
-
return node.onAppendEntries(state, msg);
|
|
74
|
-
case "append_entries_reply":
|
|
75
|
-
return node.onAppendEntriesReply(state, msg);
|
|
76
|
-
case "client_command":
|
|
77
|
-
return node.onClientCommand(state, msg);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
function park(world, actor, emitted) {
|
|
81
|
-
for (const cmd of emitted) {
|
|
82
|
-
world.pending.push({ from: actor, cmd });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
function deliverOne(world, index) {
|
|
86
|
-
if (world.pending.length === 0) {
|
|
87
|
-
world.clock += 1;
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
world.clock += 1;
|
|
91
|
-
const at = world.clock;
|
|
92
|
-
const i = (index % world.pending.length + world.pending.length) % world.pending.length;
|
|
93
|
-
const [inFlight] = world.pending.splice(i, 1);
|
|
94
|
-
if (!inFlight) return null;
|
|
95
|
-
const target = inFlight.cmd.to;
|
|
96
|
-
if (world.down.has(target) || world.down.has(inFlight.from)) {
|
|
97
|
-
return inFlight;
|
|
98
|
-
}
|
|
99
|
-
const node = world.handles.get(target);
|
|
100
|
-
const state = world.states[target];
|
|
101
|
-
if (node && state) {
|
|
102
|
-
const inbound = cmdToInbound(inFlight.from, inFlight.cmd);
|
|
103
|
-
const [next, cmds] = applyInbound(node, state, inbound, at);
|
|
104
|
-
world.states[target] = next;
|
|
105
|
-
park(world, target, cmds);
|
|
106
|
-
}
|
|
107
|
-
return inFlight;
|
|
108
|
-
}
|
|
109
|
-
function deliveredOf(inFlight) {
|
|
110
|
-
return { from: inFlight.from, to: inFlight.cmd.to, kind: inFlight.cmd.type };
|
|
111
|
-
}
|
|
112
|
-
function applyEvent(world, event, steps) {
|
|
113
|
-
if (event.kind === "partition") {
|
|
114
|
-
world.clock += 1;
|
|
115
|
-
world.down = new Set(event.down);
|
|
116
|
-
steps.push({
|
|
117
|
-
event,
|
|
118
|
-
pending: world.pending.length,
|
|
119
|
-
states: snapshot(world.states)
|
|
120
|
-
});
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (event.kind === "settle") {
|
|
124
|
-
const bound = Math.max(0, event.bound);
|
|
125
|
-
for (let n = 0; n < bound && world.pending.length > 0; n++) {
|
|
126
|
-
const inFlight = deliverOne(world, 0);
|
|
127
|
-
steps.push({
|
|
128
|
-
event: { kind: "deliver", index: 0 },
|
|
129
|
-
pending: world.pending.length,
|
|
130
|
-
states: snapshot(world.states),
|
|
131
|
-
// Each drained delivery is its OWN step, so record that step's own
|
|
132
|
-
// delivered message (not "the last of the settle"). The pool was
|
|
133
|
-
// non-empty (loop guard), so `inFlight` is never null here.
|
|
134
|
-
...inFlight ? { delivered: deliveredOf(inFlight) } : {}
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
let delivered = null;
|
|
140
|
-
if (event.kind === "deliver") {
|
|
141
|
-
delivered = deliverOne(world, event.index);
|
|
142
|
-
} else {
|
|
143
|
-
world.clock += 1;
|
|
144
|
-
const at = world.clock;
|
|
145
|
-
const actor = event.node;
|
|
146
|
-
const node = world.handles.get(actor);
|
|
147
|
-
const state = world.states[actor];
|
|
148
|
-
if (node && state) {
|
|
149
|
-
const inbound = event.kind === "timer" ? event.timer === "election" ? { _tag: "election_timeout_fired", at } : { _tag: "heartbeat_fired", at } : { _tag: "client_command", command: event.command };
|
|
150
|
-
const [next, cmds] = applyInbound(node, state, inbound, at);
|
|
151
|
-
world.states[actor] = next;
|
|
152
|
-
park(world, actor, cmds);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
steps.push({
|
|
156
|
-
event,
|
|
157
|
-
pending: world.pending.length,
|
|
158
|
-
states: snapshot(world.states),
|
|
159
|
-
// Additive read-off: present only when a `deliver` actually consumed a
|
|
160
|
-
// message. Absent (key omitted) for timer/client and empty-pool delivers,
|
|
161
|
-
// so the step is structurally identical to a pre-#144 trace.
|
|
162
|
-
...delivered ? { delivered: deliveredOf(delivered) } : {}
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
function snapshot(states) {
|
|
166
|
-
return { ...states };
|
|
167
|
-
}
|
|
168
|
-
function runSchedule(configs, schedule) {
|
|
169
|
-
const handles = /* @__PURE__ */ new Map();
|
|
170
|
-
const states = {};
|
|
171
|
-
configs.forEach((config, i) => {
|
|
172
|
-
const node = createRaftNode(config, fixedRng(i, configs.length));
|
|
173
|
-
handles.set(config.self, node);
|
|
174
|
-
states[config.self] = node.init();
|
|
175
|
-
});
|
|
176
|
-
const world = {
|
|
177
|
-
handles,
|
|
178
|
-
states,
|
|
179
|
-
pending: [],
|
|
180
|
-
clock: 0,
|
|
181
|
-
down: /* @__PURE__ */ new Set()
|
|
182
|
-
};
|
|
183
|
-
const steps = [];
|
|
184
|
-
for (const event of schedule) {
|
|
185
|
-
applyEvent(world, event, steps);
|
|
186
|
-
}
|
|
187
|
-
return {
|
|
188
|
-
nodes: configs.map((c) => c.self),
|
|
189
|
-
steps,
|
|
190
|
-
final: snapshot(world.states)
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
function replaySchedule(configs, schedule) {
|
|
194
|
-
return runSchedule(configs, schedule);
|
|
195
|
-
}
|
|
196
|
-
function clusterStatesEqual(a, b) {
|
|
197
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export { clusterConfigs, clusterStatesEqual, replaySchedule, runSchedule };
|
|
201
|
-
//# sourceMappingURL=chunk-VLGARN6H.js.map
|
|
202
|
-
//# sourceMappingURL=chunk-VLGARN6H.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/raft/sim.ts"],"names":[],"mappings":";;;AA6OO,SAAS,cAAA,CACd,GACA,IAAA,EACuB;AACvB,EAAA,MAAM,GAAA,GAAM,KAAA,CAAM,IAAA,CAAK,EAAE,MAAA,EAAQ,CAAA,EAAE,EAAG,CAAC,CAAA,EAAG,CAAA,KAAM,CAAA,CAAA,EAAI,CAAC,CAAA,CAAE,CAAA;AACvD,EAAA,OAAO,GAAA,CAAI,GAAA,CAAI,CAAC,IAAA,MAAU;AAAA,IACxB,IAAA;AAAA,IACA,OAAO,GAAA,CAAI,MAAA,CAAO,CAAC,EAAA,KAAO,OAAO,IAAI,CAAA;AAAA,IACrC,iBAAiB,IAAA,EAAM,eAAA,IAAmB,EAAE,KAAA,EAAO,GAAA,EAAK,OAAO,GAAA,EAAI;AAAA,IACnE,WAAA,EAAa,MAAM,WAAA,IAAe;AAAA,GACpC,CAAE,CAAA;AACJ;AAQA,SAAS,QAAA,CAAS,OAAe,KAAA,EAA6B;AAE5D,EAAA,MAAM,KAAA,GAAQ,KAAA,GAAQ,CAAA,GAAI,KAAA,GAAQ,KAAA,GAAQ,CAAA;AAC1C,EAAA,OAAO,MAAM,KAAA;AACf;AA+BA,SAAS,YAAA,CAAa,QAAgB,GAAA,EAAuC;AAC3E,EAAA,QAAQ,IAAI,IAAA;AAAM,IAChB,KAAK,wBAAA,EAA0B;AAC7B,MAAA,MAAM,GAAA,GAA0B;AAAA,QAC9B,IAAA,EAAM,sBAAA;AAAA,QACN,MAAM,GAAA,CAAI,IAAA;AAAA,QACV,aAAa,GAAA,CAAI,WAAA;AAAA,QACjB,cAAc,GAAA,CAAI,YAAA;AAAA,QAClB,aAAa,GAAA,CAAI;AAAA,OACnB;AACA,MAAA,OAAO,GAAA;AAAA,IACT;AAAA,IACA,KAAK,8BAAA,EAAgC;AACnC,MAAA,MAAM,GAAA,GAAwB;AAAA,QAC5B,IAAA,EAAM,oBAAA;AAAA,QACN,MAAM,GAAA,CAAI,IAAA;AAAA,QACV,IAAA,EAAM,MAAA;AAAA,QACN,aAAa,GAAA,CAAI;AAAA,OACnB;AACA,MAAA,OAAO,GAAA;AAAA,IACT;AAAA,IACA,KAAK,0BAAA,EAA4B;AAC/B,MAAA,MAAM,GAAA,GAAoC;AAAA,QACxC,IAAA,EAAM,wBAAA;AAAA,QACN,MAAM,GAAA,CAAI,IAAA;AAAA,QACV,UAAU,GAAA,CAAI,QAAA;AAAA,QACd,cAAc,GAAA,CAAI,YAAA;AAAA,QAClB,aAAa,GAAA,CAAI,WAAA;AAAA,QACjB,SAAS,GAAA,CAAI,OAAA;AAAA,QACb,cAAc,GAAA,CAAI;AAAA,OACpB;AACA,MAAA,OAAO,GAAA;AAAA,IACT;AAAA,IACA,KAAK,gCAAA,EAAkC;AACrC,MAAA,MAAM,GAAA,GAA0B;AAAA,QAC9B,IAAA,EAAM,sBAAA;AAAA,QACN,MAAM,GAAA,CAAI,IAAA;AAAA,QACV,IAAA,EAAM,MAAA;AAAA,QACN,SAAS,GAAA,CAAI,OAAA;AAAA,QACb,YAAY,GAAA,CAAI;AAAA,OAClB;AACA,MAAA,OAAO,GAAA;AAAA,IACT;AAAA;AAEJ;AAGA,SAAS,YAAA,CACP,IAAA,EACA,KAAA,EACA,GAAA,EACA,EAAA,EACA;AACA,EAAA,QAAQ,IAAI,IAAA;AAAM,IAChB,KAAK,wBAAA;AACH,MAAA,OAAO,IAAA,CAAK,iBAAA,CAAkB,KAAA,EAAO,EAAE,CAAA;AAAA,IACzC,KAAK,iBAAA;AACH,MAAA,OAAO,IAAA,CAAK,WAAA,CAAY,KAAA,EAAO,EAAE,CAAA;AAAA,IACnC,KAAK,sBAAA;AACH,MAAA,OAAO,IAAA,CAAK,aAAA,CAAc,KAAA,EAAO,GAAG,CAAA;AAAA,IACtC,KAAK,oBAAA;AACH,MAAA,OAAO,IAAA,CAAK,kBAAA,CAAmB,KAAA,EAAO,GAAG,CAAA;AAAA,IAC3C,KAAK,wBAAA;AACH,MAAA,OAAO,IAAA,CAAK,eAAA,CAAgB,KAAA,EAAO,GAAG,CAAA;AAAA,IACxC,KAAK,sBAAA;AACH,MAAA,OAAO,IAAA,CAAK,oBAAA,CAAqB,KAAA,EAAO,GAAG,CAAA;AAAA,IAC7C,KAAK,gBAAA;AACH,MAAA,OAAO,IAAA,CAAK,eAAA,CAAgB,KAAA,EAAO,GAAG,CAAA;AAAA;AAE5C;AAGA,SAAS,IAAA,CACP,KAAA,EACA,KAAA,EACA,OAAA,EACM;AACN,EAAA,KAAA,MAAW,OAAO,OAAA,EAAS;AACzB,IAAA,KAAA,CAAM,QAAQ,IAAA,CAAK,EAAE,IAAA,EAAM,KAAA,EAAO,KAAK,CAAA;AAAA,EACzC;AACF;AAcA,SAAS,UAAA,CAAW,OAAiB,KAAA,EAAgC;AACnE,EAAA,IAAI,KAAA,CAAM,OAAA,CAAQ,MAAA,KAAW,CAAA,EAAG;AAC9B,IAAA,KAAA,CAAM,KAAA,IAAS,CAAA;AACf,IAAA,OAAO,IAAA;AAAA,EACT;AACA,EAAA,KAAA,CAAM,KAAA,IAAS,CAAA;AACf,EAAA,MAAM,KAAK,KAAA,CAAM,KAAA;AACjB,EAAA,MAAM,CAAA,GAAA,CACF,QAAQ,KAAA,CAAM,OAAA,CAAQ,SAAU,KAAA,CAAM,OAAA,CAAQ,MAAA,IAChD,KAAA,CAAM,OAAA,CAAQ,MAAA;AAChB,EAAA,MAAM,CAAC,QAAQ,CAAA,GAAI,MAAM,OAAA,CAAQ,MAAA,CAAO,GAAG,CAAC,CAAA;AAC5C,EAAA,IAAI,CAAC,UAAU,OAAO,IAAA;AACtB,EAAA,MAAM,MAAA,GAAS,SAAS,GAAA,CAAI,EAAA;AAI5B,EAAA,IAAI,KAAA,CAAM,IAAA,CAAK,GAAA,CAAI,MAAM,CAAA,IAAK,MAAM,IAAA,CAAK,GAAA,CAAI,QAAA,CAAS,IAAI,CAAA,EAAG;AAC3D,IAAA,OAAO,QAAA;AAAA,EACT;AACA,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,OAAA,CAAQ,GAAA,CAAI,MAAM,CAAA;AACrC,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,MAAA,CAAO,MAAM,CAAA;AACjC,EAAA,IAAI,QAAQ,KAAA,EAAO;AACjB,IAAA,MAAM,OAAA,GAAU,YAAA,CAAa,QAAA,CAAS,IAAA,EAAM,SAAS,GAAG,CAAA;AACxD,IAAA,MAAM,CAAC,MAAM,IAAI,CAAA,GAAI,aAAa,IAAA,EAAM,KAAA,EAAO,SAAS,EAAE,CAAA;AAC1D,IAAA,KAAA,CAAM,MAAA,CAAO,MAAM,CAAA,GAAI,IAAA;AACvB,IAAA,IAAA,CAAK,KAAA,EAAO,QAAQ,IAAI,CAAA;AAAA,EAC1B;AACA,EAAA,OAAO,QAAA;AACT;AAGA,SAAS,YAAY,QAAA,EAAsC;AACzD,EAAA,OAAO,EAAE,IAAA,EAAM,QAAA,CAAS,IAAA,EAAM,EAAA,EAAI,QAAA,CAAS,GAAA,CAAI,EAAA,EAAI,IAAA,EAAM,QAAA,CAAS,GAAA,CAAI,IAAA,EAAK;AAC7E;AASA,SAAS,UAAA,CAAW,KAAA,EAAiB,KAAA,EAAiB,KAAA,EAAwB;AAC5E,EAAA,IAAI,KAAA,CAAM,SAAS,WAAA,EAAa;AAI9B,IAAA,KAAA,CAAM,KAAA,IAAS,CAAA;AACf,IAAA,KAAA,CAAM,IAAA,GAAO,IAAI,GAAA,CAAI,KAAA,CAAM,IAAI,CAAA;AAC/B,IAAA,KAAA,CAAM,IAAA,CAAK;AAAA,MACT,KAAA;AAAA,MACA,OAAA,EAAS,MAAM,OAAA,CAAQ,MAAA;AAAA,MACvB,MAAA,EAAQ,QAAA,CAAS,KAAA,CAAM,MAAM;AAAA,KAC9B,CAAA;AACD,IAAA;AAAA,EACF;AAEA,EAAA,IAAI,KAAA,CAAM,SAAS,QAAA,EAAU;AAG3B,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,MAAM,KAAK,CAAA;AACrC,IAAA,KAAA,IAAS,CAAA,GAAI,GAAG,CAAA,GAAI,KAAA,IAAS,MAAM,OAAA,CAAQ,MAAA,GAAS,GAAG,CAAA,EAAA,EAAK;AAC1D,MAAA,MAAM,QAAA,GAAW,UAAA,CAAW,KAAA,EAAO,CAAC,CAAA;AACpC,MAAA,KAAA,CAAM,IAAA,CAAK;AAAA,QACT,KAAA,EAAO,EAAE,IAAA,EAAM,SAAA,EAAW,OAAO,CAAA,EAAE;AAAA,QACnC,OAAA,EAAS,MAAM,OAAA,CAAQ,MAAA;AAAA,QACvB,MAAA,EAAQ,QAAA,CAAS,KAAA,CAAM,MAAM,CAAA;AAAA;AAAA;AAAA;AAAA,QAI7B,GAAI,WAAW,EAAE,SAAA,EAAW,YAAY,QAAQ,CAAA,KAAM;AAAC,OACxD,CAAA;AAAA,IACH;AACA,IAAA;AAAA,EACF;AAKA,EAAA,IAAI,SAAA,GAA6B,IAAA;AACjC,EAAA,IAAI,KAAA,CAAM,SAAS,SAAA,EAAW;AAC5B,IAAA,SAAA,GAAY,UAAA,CAAW,KAAA,EAAO,KAAA,CAAM,KAAK,CAAA;AAAA,EAC3C,CAAA,MAAO;AAEL,IAAA,KAAA,CAAM,KAAA,IAAS,CAAA;AACf,IAAA,MAAM,KAAK,KAAA,CAAM,KAAA;AACjB,IAAA,MAAM,QAAQ,KAAA,CAAM,IAAA;AACpB,IAAA,MAAM,IAAA,GAAO,KAAA,CAAM,OAAA,CAAQ,GAAA,CAAI,KAAK,CAAA;AACpC,IAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,MAAA,CAAO,KAAK,CAAA;AAChC,IAAA,IAAI,QAAQ,KAAA,EAAO;AACjB,MAAA,MAAM,OAAA,GACJ,MAAM,IAAA,KAAS,OAAA,GACX,MAAM,KAAA,KAAU,UAAA,GACd,EAAE,IAAA,EAAM,wBAAA,EAA0B,EAAA,KAClC,EAAE,IAAA,EAAM,mBAAmB,EAAA,EAAG,GAChC,EAAE,IAAA,EAAM,gBAAA,EAAkB,OAAA,EAAS,KAAA,CAAM,OAAA,EAAQ;AACvD,MAAA,MAAM,CAAC,MAAM,IAAI,CAAA,GAAI,aAAa,IAAA,EAAM,KAAA,EAAO,SAAS,EAAE,CAAA;AAC1D,MAAA,KAAA,CAAM,MAAA,CAAO,KAAK,CAAA,GAAI,IAAA;AACtB,MAAA,IAAA,CAAK,KAAA,EAAO,OAAO,IAAI,CAAA;AAAA,IACzB;AAAA,EACF;AAEA,EAAA,KAAA,CAAM,IAAA,CAAK;AAAA,IACT,KAAA;AAAA,IACA,OAAA,EAAS,MAAM,OAAA,CAAQ,MAAA;AAAA,IACvB,MAAA,EAAQ,QAAA,CAAS,KAAA,CAAM,MAAM,CAAA;AAAA;AAAA;AAAA;AAAA,IAI7B,GAAI,YAAY,EAAE,SAAA,EAAW,YAAY,SAAS,CAAA,KAAM;AAAC,GAC1D,CAAA;AACH;AAGA,SAAS,SAAS,MAAA,EAA0D;AAC1E,EAAA,OAAO,EAAE,GAAG,MAAA,EAAO;AACrB;AAQO,SAAS,WAAA,CACd,SACA,QAAA,EACU;AACV,EAAA,MAAM,OAAA,uBAAc,GAAA,EAA8B;AAClD,EAAA,MAAM,SAA4C,EAAC;AACnD,EAAA,OAAA,CAAQ,OAAA,CAAQ,CAAC,MAAA,EAAQ,CAAA,KAAM;AAC7B,IAAA,MAAM,OAAO,cAAA,CAAuB,MAAA,EAAQ,SAAS,CAAA,EAAG,OAAA,CAAQ,MAAM,CAAC,CAAA;AACvE,IAAA,OAAA,CAAQ,GAAA,CAAI,MAAA,CAAO,IAAA,EAAM,IAAI,CAAA;AAC7B,IAAA,MAAA,CAAO,MAAA,CAAO,IAAI,CAAA,GAAI,IAAA,CAAK,IAAA,EAAK;AAAA,EAClC,CAAC,CAAA;AAED,EAAA,MAAM,KAAA,GAAkB;AAAA,IACtB,OAAA;AAAA,IACA,MAAA;AAAA,IACA,SAAS,EAAC;AAAA,IACV,KAAA,EAAO,CAAA;AAAA,IACP,IAAA,sBAAU,GAAA;AAAI,GAChB;AACA,EAAA,MAAM,QAAmB,EAAC;AAE1B,EAAA,KAAA,MAAW,SAAS,QAAA,EAAU;AAC5B,IAAA,UAAA,CAAW,KAAA,EAAO,OAAO,KAAK,CAAA;AAAA,EAChC;AAEA,EAAA,OAAO;AAAA,IACL,OAAO,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,KAAM,EAAE,IAAI,CAAA;AAAA,IAChC,KAAA;AAAA,IACA,KAAA,EAAO,QAAA,CAAS,KAAA,CAAM,MAAM;AAAA,GAC9B;AACF;AAOO,SAAS,cAAA,CACd,SACA,QAAA,EACU;AACV,EAAA,OAAO,WAAA,CAAY,SAAS,QAAQ,CAAA;AACtC;AAYO,SAAS,kBAAA,CACd,GACA,CAAA,EACS;AACT,EAAA,OAAO,KAAK,SAAA,CAAU,CAAC,CAAA,KAAM,IAAA,CAAK,UAAU,CAAC,CAAA;AAC/C","file":"chunk-VLGARN6H.js","sourcesContent":["/**\n * @demlik/tea/raft — a deterministic, in-memory multi-node Raft simulation\n * driver. Slice #121 (the safety-property half) of the consensus epic (#117):\n * the headline payoff is consensus verified by **deterministic replay**, not by\n * flaky wall-clock timing.\n *\n * This module owns NO wall clock, NO real timers, and NO networking. Time and\n * message ordering are entirely the *schedule* — a finite list of {@link SimEvent}s\n * the caller (a fast-check generator or an explicit fixture) hands the driver.\n * The driver folds the schedule one event at a time over a cluster of pure\n * {@link createRaftNode}s, routing every emitted `Cmd` into a pending-message\n * pool and recording the full `(event → resulting cluster states)` trace.\n *\n * Because the underlying reducer is pure (no ambient clock — `at` rides on the\n * Msg; no ambient RNG — `rng` injected once per node), the same schedule re-run\n * yields **byte-identical** cluster state. That replay identity is the property\n * the safety suite (`safety.test.ts`) leans on: a violated invariant shrinks to\n * a *replayable schedule*, not an irreproducible timing fluke (errors are data).\n *\n * ## The schedule model\n *\n * The cluster is N nodes plus a pending-message pool. Each {@link SimEvent} is\n * one of two moves:\n *\n * - `{ kind: \"timer\", node, timer }` — fire node's election OR heartbeat timer.\n * Drives an `onElectionTimeout` / `onHeartbeat` verb on that node. The\n * firing `at` is the driver's monotonic logical clock (one tick per event),\n * so no verb reads a real clock.\n * - `{ kind: \"deliver\", index }` — deliver the in-flight message at `index` in\n * the pending pool to its target node, as the matching inbound `RaftMsg`.\n * An out-of-range `index` is taken modulo the pool size (so a fast-check\n * integer always names *some* deliverable message when the pool is\n * non-empty); a `deliver` against an empty pool is a no-op event (recorded,\n * advances the clock, changes nothing) — the schedule stays total.\n *\n * Delivering a message removes it from the pool and feeds it to the target's\n * verb; any Cmds that verb emits are appended to the pool (each Cmd carries its\n * own `to`, so routing is just a lookup). This is the in-memory transport: a\n * `SendRequestVote{to}` delivered becomes a `RequestVoteRequest` on `to`; the\n * `SendRequestVoteReply{to}` it emits becomes a `RequestVoteReply` on the\n * original candidate; and so on around the RPC pairs.\n *\n * ## Record / replay\n *\n * {@link runSchedule} returns a {@link SimTrace}: the ordered list of steps, each\n * the event applied and the resulting per-node {@link RaftState} snapshot, plus\n * the final cluster. {@link replaySchedule} re-runs the identical schedule from a\n * fresh cluster; {@link clusterStatesEqual} compares two cluster snapshots\n * structurally. The safety suite asserts `runSchedule(s)` and\n * `replaySchedule(s)` agree byte-for-byte.\n *\n * NOT a substrate primitive and NOT shipped in the node's public verb surface:\n * it depends only on `./index` (the pure node) read-only, and lives beside the\n * node as a test/replay harness.\n */\n\nimport {\n type AppendEntriesReply,\n type AppendEntriesRequest,\n createRaftNode,\n type LogEntry,\n type NodeId,\n type RaftCmd,\n type RaftConfig,\n type RaftMsg,\n type RaftNode,\n type RaftState,\n type RequestVoteReply,\n type RequestVoteRequest,\n} from \"./index\";\n\n// ===========================================================================\n// Schedule — the controllable, wall-clock-free event stream\n// ===========================================================================\n\n/** Which of a node's two timers a `timer` event fires. */\nexport type TimerKind = \"election\" | \"heartbeat\";\n\n/**\n * One move in a schedule. The whole simulation is a fold of these — there is no\n * other source of time or ordering. `_tag`-style `kind` discriminant matches the\n * engine's tagged-union convention.\n */\nexport type SimEvent =\n | {\n /** Fire a node's timer (drives `onElectionTimeout` / `onHeartbeat`). */\n readonly kind: \"timer\";\n /** The node whose timer fires. */\n readonly node: NodeId;\n /** Which timer. */\n readonly timer: TimerKind;\n }\n | {\n /**\n * Deliver an in-flight message from the pending pool to its target. The\n * index is taken modulo the pool size, so any integer names a deliverable\n * message when the pool is non-empty; a deliver against an empty pool is a\n * recorded no-op (the clock still advances).\n */\n readonly kind: \"deliver\";\n /** Which pending message to deliver (modulo pool size). */\n readonly index: number;\n }\n | {\n /**\n * A client submits a command to a node (Figure 2 §5.3). On the leader it\n * appends + replicates; on a non-leader it is a no-op. This is how the\n * replicated log grows in a simulation.\n */\n readonly kind: \"client\";\n /** The node the client contacts. */\n readonly node: NodeId;\n /** The opaque command to submit. */\n readonly command: number;\n }\n | {\n /**\n * Drain the pending pool to quiescence: deliver messages FIFO (each new\n * Cmd a delivery emits is appended and itself delivered) until the pool is\n * empty or `bound` deliveries have run. This is the \"let the network\n * settle\" move — it lets a started election or a submitted client command\n * run its RPC round(s) to completion, which is what actually produces\n * leaders and committed entries. Without it, a purely-random `deliver`\n * index almost never threads the exact sequence an election + replication\n * needs (measured: 0 commits in 2000 random schedules vs. 529 with settle),\n * so the safety properties would pass *vacuously* over empty logs.\n *\n * The `bound` caps the drain so a pathological re-emit loop cannot diverge;\n * every step is still recorded (one trace step per delivery), so a settle\n * is a deterministic, replayable macro — not a black box.\n */\n readonly kind: \"settle\";\n /** Max deliveries to run (the drain is FIFO; stops early when empty). */\n readonly bound: number;\n }\n | {\n /**\n * Set the cluster's *partitioned* node set (a network fault, NOT a state\n * change). A node in `down` is isolated: the transport DROPS every message\n * to OR from it (both directions, as a real partition does) while its\n * timers still fire — so a crashed/partitioned leader stops being able to\n * heartbeat and the surviving majority elects a new leader. This is the\n * \"kill the leader\" / \"heal the partition\" move the failover demo needs.\n *\n * It names the WHOLE down-set absolutely (not a toggle): `{ down: [n0] }`\n * isolates n0; a later `{ down: [] }` heals every partition. Recorded as\n * one step (clock advances, no message moves), so it replays deterministically.\n */\n readonly kind: \"partition\";\n /** The nodes that are currently isolated (drop all their traffic). */\n readonly down: readonly NodeId[];\n };\n\n/** A whole schedule: the finite event stream the driver folds. */\nexport type Schedule = readonly SimEvent[];\n\n// ===========================================================================\n// In-flight messages — emitted Cmds parked in the pending pool\n// ===========================================================================\n\n/**\n * A Cmd a node emitted, parked until a `deliver` event routes it. We keep the\n * raw {@link RaftCmd} (it already carries `to`) plus the sender, so a delivery\n * can build the correct inbound {@link RaftMsg} on the target. Commands over a\n * numeric payload (the simulation's `C = number`).\n */\nexport interface InFlight {\n /** The node that emitted this Cmd (the inbound Msg's `from` / sender). */\n readonly from: NodeId;\n /** The emitted Cmd, addressed to `cmd.to`. */\n readonly cmd: RaftCmd<number>;\n}\n\n// ===========================================================================\n// Cluster + trace\n// ===========================================================================\n\n/** A snapshot of every node's state, keyed by id. */\nexport type ClusterStates = Readonly<Record<NodeId, RaftState<number>>>;\n\n/**\n * A read-off of the message a `deliver` step actually pulled off the pending\n * pool — sender, target, and the {@link RaftCmd} discriminant (`cmd.type`). This\n * is a pure projection of what the fold *already did* (the InFlight that\n * {@link deliverOne} consumed), recorded so a renderer can draw the RPC flow.\n * It is NOT a new input and changes NO simulation behavior. Recorded even when\n * the packet is dropped by a partition (the RPC was still attempted), so the\n * arrow shows the intent while the greyed node shows the drop.\n */\nexport interface DeliveredMessage {\n /** The node that emitted the delivered Cmd (the inbound Msg's sender). */\n readonly from: NodeId;\n /** The node the Cmd was addressed to (`cmd.to`). */\n readonly to: NodeId;\n /** The delivered Cmd's discriminant (`RaftCmd[\"type\"]`). */\n readonly kind: RaftCmd<number>[\"type\"];\n}\n\n/** One recorded step: the event applied and the cluster state it produced. */\nexport interface SimStep {\n /** The event applied at this step. */\n readonly event: SimEvent;\n /** The pending-pool size AFTER applying the event. */\n readonly pending: number;\n /** Every node's state after the event. */\n readonly states: ClusterStates;\n /**\n * On a `deliver` step (incl. each delivery a `settle` drains), the message\n * that was actually pulled off the pool. ABSENT on every other step kind and\n * on a `deliver` against an empty pool — additive + backward-compatible, so a\n * trace recorded before this field still folds and renders identically (field\n * absent ⇒ no arrow). Purely a read-off of {@link deliverOne}'s work.\n */\n readonly delivered?: DeliveredMessage;\n}\n\n/**\n * The full record of a run: the per-node configs, the ordered steps, and the\n * final cluster. Replaying the same schedule reproduces this byte-for-byte.\n */\nexport interface SimTrace {\n /** The cluster's node ids, in config order. */\n readonly nodes: readonly NodeId[];\n /** One step per event in the schedule (in order). */\n readonly steps: readonly SimStep[];\n /** The final per-node states (== `steps.at(-1)?.states` when non-empty). */\n readonly final: ClusterStates;\n}\n\n// ===========================================================================\n// Building the cluster\n// ===========================================================================\n\n/**\n * Build N node configs sharing one election-timeout window + heartbeat. Each\n * node's `peers` is every OTHER id. Ids are `n0..n{N-1}`.\n */\nexport function clusterConfigs(\n n: number,\n over?: Partial<Omit<RaftConfig, \"self\" | \"peers\">>,\n): readonly RaftConfig[] {\n const ids = Array.from({ length: n }, (_, i) => `n${i}`);\n return ids.map((self) => ({\n self,\n peers: ids.filter((id) => id !== self),\n electionTimeout: over?.electionTimeout ?? { minMs: 150, maxMs: 300 },\n heartbeatMs: over?.heartbeatMs ?? 50,\n }));\n}\n\n/**\n * A fixed-jitter rng for a node, derived from its index so that distinct nodes\n * draw *distinct but deterministic* election timeouts (the real-cluster effect\n * that breaks split votes), while a replay with the same configs reproduces them\n * exactly. Pure function of the index — no ambient randomness.\n */\nfunction fixedRng(index: number, count: number): () => number {\n // Spread the [0,1) jitter evenly across the cluster: node i draws i/count.\n const value = count > 0 ? index / count : 0;\n return () => value;\n}\n\n// ===========================================================================\n// The driver\n// ===========================================================================\n\n/**\n * The mutable bag the fold threads. Kept local to {@link runSchedule}; never\n * escapes. `clock` is the monotonic logical clock — incremented once per event\n * and used as the `at` for timer firings, so the reducer never reads a real\n * clock and a replay reproduces every `at`.\n */\ninterface SimWorld {\n readonly handles: ReadonlyMap<NodeId, RaftNode<number>>;\n states: Record<NodeId, RaftState<number>>;\n pending: InFlight[];\n clock: number;\n /**\n * The currently-partitioned nodes (a `partition` event sets this absolutely).\n * The transport drops any in-flight message to OR from a down node — its\n * timers still fire, but its RPCs never land and it never hears the cluster.\n */\n down: Set<NodeId>;\n}\n\n/**\n * Translate one emitted {@link RaftCmd} into the inbound {@link RaftMsg} the\n * target node should receive. Pure mapping over the RPC pairs (Figure 2 wire\n * shapes); `sender` becomes the Msg's `from` / candidateId / leaderId as\n * appropriate. The target is always `cmd.to`.\n */\nfunction cmdToInbound(sender: NodeId, cmd: RaftCmd<number>): RaftMsg<number> {\n switch (cmd.type) {\n case \"raft:send_request_vote\": {\n const msg: RequestVoteRequest = {\n _tag: \"request_vote_request\",\n term: cmd.term,\n candidateId: cmd.candidateId,\n lastLogIndex: cmd.lastLogIndex,\n lastLogTerm: cmd.lastLogTerm,\n };\n return msg;\n }\n case \"raft:send_request_vote_reply\": {\n const msg: RequestVoteReply = {\n _tag: \"request_vote_reply\",\n term: cmd.term,\n from: sender,\n voteGranted: cmd.voteGranted,\n };\n return msg;\n }\n case \"raft:send_append_entries\": {\n const msg: AppendEntriesRequest<number> = {\n _tag: \"append_entries_request\",\n term: cmd.term,\n leaderId: cmd.leaderId,\n prevLogIndex: cmd.prevLogIndex,\n prevLogTerm: cmd.prevLogTerm,\n entries: cmd.entries,\n leaderCommit: cmd.leaderCommit,\n };\n return msg;\n }\n case \"raft:send_append_entries_reply\": {\n const msg: AppendEntriesReply = {\n _tag: \"append_entries_reply\",\n term: cmd.term,\n from: sender,\n success: cmd.success,\n matchIndex: cmd.matchIndex,\n };\n return msg;\n }\n }\n}\n\n/** Run one node verb for the inbound Msg, returning `[next, cmds]`. */\nfunction applyInbound(\n node: RaftNode<number>,\n state: RaftState<number>,\n msg: RaftMsg<number>,\n at: number,\n) {\n switch (msg._tag) {\n case \"election_timeout_fired\":\n return node.onElectionTimeout(state, at);\n case \"heartbeat_fired\":\n return node.onHeartbeat(state, at);\n case \"request_vote_request\":\n return node.onRequestVote(state, msg);\n case \"request_vote_reply\":\n return node.onRequestVoteReply(state, msg);\n case \"append_entries_request\":\n return node.onAppendEntries(state, msg);\n case \"append_entries_reply\":\n return node.onAppendEntriesReply(state, msg);\n case \"client_command\":\n return node.onClientCommand(state, msg);\n }\n}\n\n/** Park the Cmds an acting node emitted into the pending pool (FIFO append). */\nfunction park(\n world: SimWorld,\n actor: NodeId,\n emitted: readonly RaftCmd<number>[],\n): void {\n for (const cmd of emitted) {\n world.pending.push({ from: actor, cmd });\n }\n}\n\n/**\n * Deliver the pending message at `index` (modulo the pool size) to its target,\n * parking whatever it emits. Advances the clock and returns the {@link InFlight}\n * it consumed from the pool (so a caller can record what was delivered),\n * or `null` when the pool was empty (a recorded no-op). A single atomic unit of\n * simulated work — one trace step.\n *\n * The return value is a PURE read-off of work the function already does (the\n * splice was always there); returning it instead of a `boolean` adds no\n * behavior — every existing call-site path (apply, partition-drop, empty-pool)\n * is byte-for-byte unchanged.\n */\nfunction deliverOne(world: SimWorld, index: number): InFlight | null {\n if (world.pending.length === 0) {\n world.clock += 1;\n return null;\n }\n world.clock += 1;\n const at = world.clock;\n const i =\n ((index % world.pending.length) + world.pending.length) %\n world.pending.length;\n const [inFlight] = world.pending.splice(i, 1);\n if (!inFlight) return null;\n const target = inFlight.cmd.to;\n // Partition: a message to OR from an isolated node is dropped — consumed from\n // the pool (so a `settle` still terminates) but never applied (the packet was\n // lost on the wire). Both directions, as a real partition severs.\n if (world.down.has(target) || world.down.has(inFlight.from)) {\n return inFlight;\n }\n const node = world.handles.get(target);\n const state = world.states[target];\n if (node && state) {\n const inbound = cmdToInbound(inFlight.from, inFlight.cmd);\n const [next, cmds] = applyInbound(node, state, inbound, at);\n world.states[target] = next;\n park(world, target, cmds);\n }\n return inFlight;\n}\n\n/** Project a consumed {@link InFlight} into the additive {@link DeliveredMessage} read-off. */\nfunction deliveredOf(inFlight: InFlight): DeliveredMessage {\n return { from: inFlight.from, to: inFlight.cmd.to, kind: inFlight.cmd.type };\n}\n\n/**\n * Apply one {@link SimEvent} to the world, pushing one trace step PER unit of\n * work onto `steps`. A `timer` / `client` / `deliver` produces exactly one step;\n * a `settle` produces one step per drained delivery (so the macro stays a\n * transparent, replayable sequence — not a black box). Mutates the world's\n * `states` / `pending` / `clock` in place (the local fold bag).\n */\nfunction applyEvent(world: SimWorld, event: SimEvent, steps: SimStep[]): void {\n if (event.kind === \"partition\") {\n // A pure network-fault move: replace the down-set, advance the clock, record\n // a step. No message moves and no node state changes — the effect surfaces\n // later when `deliverOne` drops traffic to/from a down node.\n world.clock += 1;\n world.down = new Set(event.down);\n steps.push({\n event,\n pending: world.pending.length,\n states: snapshot(world.states),\n });\n return;\n }\n\n if (event.kind === \"settle\") {\n // Drain FIFO (index 0) until empty or the bound is hit. Each delivery is its\n // own recorded step, tagged as the deliver-index-0 it actually is.\n const bound = Math.max(0, event.bound);\n for (let n = 0; n < bound && world.pending.length > 0; n++) {\n const inFlight = deliverOne(world, 0);\n steps.push({\n event: { kind: \"deliver\", index: 0 },\n pending: world.pending.length,\n states: snapshot(world.states),\n // Each drained delivery is its OWN step, so record that step's own\n // delivered message (not \"the last of the settle\"). The pool was\n // non-empty (loop guard), so `inFlight` is never null here.\n ...(inFlight ? { delivered: deliveredOf(inFlight) } : {}),\n });\n }\n return;\n }\n\n // The message a top-level `deliver` consumed (null on an empty-pool no-op);\n // read off below into the step's additive `delivered` field. `undefined` for\n // every non-deliver event kind.\n let delivered: InFlight | null = null;\n if (event.kind === \"deliver\") {\n delivered = deliverOne(world, event.index);\n } else {\n // timer / client: drive the acting node's verb directly.\n world.clock += 1;\n const at = world.clock;\n const actor = event.node;\n const node = world.handles.get(actor);\n const state = world.states[actor];\n if (node && state) {\n const inbound: RaftMsg<number> =\n event.kind === \"timer\"\n ? event.timer === \"election\"\n ? { _tag: \"election_timeout_fired\", at }\n : { _tag: \"heartbeat_fired\", at }\n : { _tag: \"client_command\", command: event.command };\n const [next, cmds] = applyInbound(node, state, inbound, at);\n world.states[actor] = next;\n park(world, actor, cmds);\n }\n }\n\n steps.push({\n event,\n pending: world.pending.length,\n states: snapshot(world.states),\n // Additive read-off: present only when a `deliver` actually consumed a\n // message. Absent (key omitted) for timer/client and empty-pool delivers,\n // so the step is structurally identical to a pre-#144 trace.\n ...(delivered ? { delivered: deliveredOf(delivered) } : {}),\n });\n}\n\n/** Deep-freeze-free structural copy of a cluster snapshot (states are already immutable values). */\nfunction snapshot(states: Record<NodeId, RaftState<number>>): ClusterStates {\n return { ...states };\n}\n\n/**\n * Run a schedule against a fresh N-node cluster, recording the full trace. Pure\n * given `(configs, schedule)`: no clock, no RNG, no IO — the only \"time\" is the\n * driver's logical clock, advanced one tick per event. Each node gets a\n * fixed-jitter rng derived from its index (distinct but deterministic timeouts).\n */\nexport function runSchedule(\n configs: readonly RaftConfig[],\n schedule: Schedule,\n): SimTrace {\n const handles = new Map<NodeId, RaftNode<number>>();\n const states: Record<NodeId, RaftState<number>> = {};\n configs.forEach((config, i) => {\n const node = createRaftNode<number>(config, fixedRng(i, configs.length));\n handles.set(config.self, node);\n states[config.self] = node.init();\n });\n\n const world: SimWorld = {\n handles,\n states,\n pending: [],\n clock: 0,\n down: new Set(),\n };\n const steps: SimStep[] = [];\n\n for (const event of schedule) {\n applyEvent(world, event, steps);\n }\n\n return {\n nodes: configs.map((c) => c.self),\n steps,\n final: snapshot(world.states),\n };\n}\n\n/**\n * Replay a schedule from a fresh cluster. Identical to {@link runSchedule} (it\n * IS `runSchedule`) — exposed under a name that documents intent at the call\n * site: a replay is meant to reproduce a prior run's trace byte-for-byte.\n */\nexport function replaySchedule(\n configs: readonly RaftConfig[],\n schedule: Schedule,\n): SimTrace {\n return runSchedule(configs, schedule);\n}\n\n// ===========================================================================\n// Comparison helpers (for the determinism property + counterexample reports)\n// ===========================================================================\n\n/**\n * Structural equality of two cluster snapshots. Used by the determinism\n * property: a run and its replay must produce identical per-node state for every\n * node. (Tests use vitest `toEqual` for the same comparison; this is the\n * dependency-free version the driver can expose for ad-hoc checks.)\n */\nexport function clusterStatesEqual(\n a: ClusterStates,\n b: ClusterStates,\n): boolean {\n return JSON.stringify(a) === JSON.stringify(b);\n}\n\n// ===========================================================================\n// Re-exports the safety suite reads (so it imports the harness, not ./index)\n// ===========================================================================\n\nexport type { LogEntry, NodeId, RaftConfig, RaftState };\n"]}
|
package/dist/raft/demo.d.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { NodeId, RaftState, RaftConfig } from './index.js';
|
|
2
|
-
import { SimTrace, Schedule } from './sim.js';
|
|
3
|
-
import '../deadline/index.js';
|
|
4
|
-
import '../core-BoB6t7dB.js';
|
|
5
|
-
import '../types-B-8bcAp8.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 };
|
package/dist/raft/demo.js
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { clusterConfigs, runSchedule, replaySchedule } 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
|
-
|
|
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
|
package/dist/raft/demo.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
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"]}
|