@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
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/paginated-walk/index.ts"],"names":["start","drain","resume"],"mappings":";;;;;AAiPO,IAAM,QAAA,GAAW;AAiBjB,SAAS,mBAAA,CACd,MAAA,EACA,GAAA,GAAoB,IAAA,CAAK,MAAA,EACzB;AAGA,EAAA,MAAM,EAAA,GAAK,mBAAA;AAAA,IACT;AAAA,MACE,OAAO,MAAA,CAAO,KAAA;AAAA,MACd,SAAS,MAAA,CAAO,OAAA;AAAA,MAChB,WAAW,MAAA,CAAO,SAAA;AAAA,MAClB,UAAU,MAAA,CAAO;AAAA,KACnB;AAAA,IACA;AAAA,GACF;AAIA,EAAA,MAAM,eAAA,GAA2C;AAAA,IAC/C,aAAa,MAAA,CAAO,SAAA;AAAA,IACpB,aAAA,EAAe,OAAO,aAAA,IAAiB;AAAA,GACzC;AAOA,EAAA,SAAS,cAAA,CACP,GACA,UAAA,EACkC;AAClC,IAAA,OAAO,EAAE,GAAG,CAAA,EAAG,UAAA,EAAW;AAAA,EAC5B;AAGA,EAAA,SAAS,IAAA,GAAyC;AAChD,IAAA,OAAO;AAAA,MACL,MAAM,aAAA,EAAsB;AAAA,MAC5B,UAAA,EAAY,GAAG,IAAA;AAAK,KACtB;AAAA,EACF;AASA,EAAA,SAAS,KAAA,CACP,CAAA,EACA,MAAA,EACA,EAAA,EAIA;AACA,IAAA,MAAM,CAAC,UAAA,EAAY,IAAI,CAAA,GAAI,EAAA,CAAG,QAAQ,CAAA,CAAE,UAAA,EAAY,QAAA,EAAU,MAAA,EAAQ,EAAE,CAAA;AACxE,IAAA,OAAO,CAAC,cAAA,CAAe,CAAA,EAAG,UAAU,GAAG,IAAI,CAAA;AAAA,EAC7C;AAWA,EAAA,SAASA,MAAAA,CACP,GACA,EAAA,EACgE;AAChE,IAAA,MAAM,IAAA,GAAO,KAAA,CAAU,CAAA,CAAE,IAAA,EAAM,eAAe,CAAA;AAS9C,IAAA,IAAI,IAAA,KAAS,CAAA,CAAE,IAAA,IAAQ,IAAA,CAAK,UAAU,UAAA,EAAY;AAChD,MAAA,OAAO,CAAC,CAAA,EAAG,EAAE,CAAA;AAAA,IACf;AAEA,IAAA,OAAO,KAAA,CAAM,EAAE,GAAG,CAAA,EAAG,MAAK,EAAG,IAAA,CAAK,QAAQ,EAAE,CAAA;AAAA,EAC9C;AAwBA,EAAA,SAAS,MAAA,CACP,CAAA,EACA,IAAA,EACA,EAAA,EACgE;AAEhE,IAAA,MAAM,CAAC,UAAU,CAAA,GAAI,GAAG,OAAA,CAAQ,CAAA,CAAE,YAAY,QAAA,EAAU;AAAA,MACtD,MAAM,OAAA,CAAQ,WAAA;AAAA,MACd,GAAA,EAAK,QAAA;AAAA,MACL,MAAA,EAAQ,IAAA;AAAA,MACR;AAAA,KACD,CAAA;AAGD,IAAA,MAAM,QAAQ,MAAA,CAAO,QAAA,GAAW,MAAA,CAAO,QAAA,CAAS,IAAI,CAAA,GAAI,CAAA;AACxD,IAAA,MAAM,IAAA,GAAO,MAAA,CAAO,UAAA,CAAW,IAAI,CAAA;AACnC,IAAA,MAAM,OAAO,UAAA,CAAW,CAAA,CAAE,IAAA,EAAM,KAAA,EAAO,MAAM,eAAe,CAAA;AAE5D,IAAA,MAAM,QAAA,GAA6C,EAAE,IAAA,EAAM,UAAA,EAAW;AAGtE,IAAA,MAAM,QAAA,GAAkC,MAAA,CAAO,MAAA,CAAO,IAAI,CAAA;AAG1D,IAAA,IAAI,IAAA,CAAK,UAAU,UAAA,EAAY;AAC7B,MAAA,MAAM,CAAC,WAAW,SAAS,CAAA,GAAI,MAAM,QAAA,EAAU,IAAA,CAAK,QAAQ,EAAE,CAAA;AAC9D,MAAA,OAAO,CAAC,SAAA,EAAW,CAAC,GAAG,QAAA,EAAU,GAAG,SAAS,CAAC,CAAA;AAAA,IAChD;AAGA,IAAA,OAAO,CAAC,UAAU,QAAQ,CAAA;AAAA,EAC5B;AA2BA,EAAA,SAAS,OAAA,CACP,CAAA,EACA,KAAA,EACA,EAAA,EACgE;AAIhE,IAAA,IAAI,CAAA,CAAE,IAAA,CAAK,KAAA,KAAU,UAAA,EAAY;AAC/B,MAAA,OAAO,CAAC,CAAA,EAAG,EAAE,CAAA;AAAA,IACf;AACA,IAAA,MAAM,CAAC,YAAY,IAAI,CAAA,GAAI,GAAG,IAAA,CAAK,CAAA,CAAE,YAAY,QAAA,EAAU;AAAA,MACzD,MAAM,OAAA,CAAQ,YAAA;AAAA,MACd,GAAA,EAAK,QAAA;AAAA,MACL,KAAA;AAAA,MACA;AAAA,KACD,CAAA;AACD,IAAA,OAAO,CAAC,cAAA,CAAe,CAAA,EAAG,UAAU,GAAG,IAAI,CAAA;AAAA,EAC7C;AAmBA,EAAA,SAAS,OAAA,CACP,GACA,GAAA,EACgE;AAChE,IAAA,IAAI,CAAA,CAAE,IAAA,CAAK,KAAA,KAAU,UAAA,EAAY;AAC/B,MAAA,OAAO,CAAC,CAAA,EAAG,EAAE,CAAA;AAAA,IACf;AACA,IAAA,MAAM,CAAC,YAAY,IAAI,CAAA,GAAI,GAAG,OAAA,CAAQ,CAAA,CAAE,YAAY,GAAG,CAAA;AACvD,IAAA,IAAI,UAAA,KAAe,EAAE,UAAA,EAAY;AAE/B,MAAA,OAAO,CAAC,CAAA,EAAG,EAAE,CAAA;AAAA,IACf;AACA,IAAA,OAAO,CAAC,cAAA,CAAe,CAAA,EAAG,UAAU,GAAG,IAAI,CAAA;AAAA,EAC7C;AAgBA,EAAA,SAASC,MAAAA,CACP,GACA,CAAA,EACgE;AAChE,IAAA,MAAM,IAAA,GAAO,KAAA,CAAU,CAAA,CAAE,IAAA,EAAM,CAAC,CAAA;AAChC,IAAA,IAAI,IAAA,KAAS,EAAE,IAAA,EAAM;AAEnB,MAAA,OAAO,CAAC,CAAA,EAAG,EAAE,CAAA;AAAA,IACf;AACA,IAAA,OAAO,CAAC,EAAE,GAAG,GAAG,IAAA,EAAK,EAAG,EAAE,CAAA;AAAA,EAC5B;AAoBA,EAAA,SAASC,OAAAA,CACP,GACA,EAAA,EACgE;AAChE,IAAA,MAAM,IAAA,GAAO,MAAA,CAAW,CAAA,CAAE,IAAA,EAAM,eAAe,CAAA;AAI/C,IAAA,IAAI,IAAA,KAAS,CAAA,CAAE,IAAA,IAAQ,IAAA,CAAK,UAAU,UAAA,EAAY;AAChD,MAAA,OAAO,CAAC,CAAA,EAAG,EAAE,CAAA;AAAA,IACf;AACA,IAAA,OAAO,KAAA,CAAM,EAAE,GAAG,CAAA,EAAG,MAAK,EAAG,IAAA,CAAK,QAAQ,EAAE,CAAA;AAAA,EAC9C;AAUA,EAAA,SAAS,WAAW,CAAA,EAA8C;AAChE,IAAA,OAAO,MAAA,CAAO,EAAE,IAAI,CAAA;AAAA,EACtB;AAaA,EAAA,SAAS,QAAQ,CAAA,EAA8C;AAC7D,IAAA,MAAM,IAAA,GAAO,CAAA,CAAE,UAAA,CAAW,KAAA,CAAM,QAAQ,CAAA;AACxC,IAAA,OAAO,IAAA,EAAM,KAAA,KAAU,QAAA,GAAW,IAAA,CAAK,KAAA,GAAQ,MAAA;AAAA,EACjD;AAcA,EAAA,SAAS,QAAQ,CAAA,EAA8C;AAC7D,IAAA,OAAO,CAAA,CAAE,UAAA,CAAW,KAAA,CAAM,QAAQ,CAAA,EAAG,UAAU,QAAA,IAAY,CAAC,MAAA,CAAO,CAAA,CAAE,IAAI,CAAA;AAAA,EAC3E;AAWA,EAAA,SAAS,KAAK,CAAA,EAA6D;AACzE,IAAA,OAAO,EAAA,CAAG,IAAA,CAAK,CAAA,CAAE,UAAU,CAAA;AAAA,EAC7B;AAgBA,EAAA,SAAS,SAAS,KAAA,EAAyC;AACzD,IAAA,OAAO,EAAA,CAAG,SAAS,KAAK,CAAA;AAAA,EAC1B;AAEA,EAAA,OAAO;AAAA,IACL,IAAA;AAAA,IACA,KAAA,EAAAF,MAAAA;AAAA,IACA,MAAA;AAAA,IACA,OAAA;AAAA,IACA,OAAA;AAAA,IACA,KAAA,EAAAC,MAAAA;AAAA,IACA,MAAA,EAAAC,OAAAA;AAAA,IACA,UAAA;AAAA,IACA,OAAA;AAAA,IACA,OAAA;AAAA,IACA,IAAA;AAAA,IACA;AAAA,GACF;AACF;AAQO,SAAS,QAAA,CAMd,KAAA,EACA,CAAC,KAAA,EAAO,IAAI,CAAA,EACgB;AAC5B,EAAA,OAAO,CAAC,EAAE,GAAG,OAAO,IAAA,EAAM,KAAA,IAAS,IAAI,CAAA;AACzC","file":"chunk-5DEQ5I3U.js","sourcesContent":["/**\n * @demlik/tea/paginated-walk — traverse a paginated API / sitemap end to end\n * WITHOUT a fake clock, without 429s, and resumable across a Durable-Object\n * eviction. The L2 composition that bolts `../resilient-call`'s resilience onto\n * `../paginator`'s cursor walk: each page is fetched as ONE keyed resilient\n * call (retry + backoff + rate-limit gate), and the cursor advances only once a\n * page settles `ok`.\n *\n * ## The recipe (the two siblings it composes)\n *\n * - **`../paginator`** owns the walk loop: the opaque `Cursor`, the `seen` /\n * `pages` counters, and the `idle → fetching → … → done` phase machine.\n * `paginated-walk` never re-implements cursor advancement — it folds each\n * settled page through `recordPage`, letting the paginator decide\n * `done` / `fetching(next)` / `paused`. Backpressure is a TWO-way valve:\n * `recordPage` parks the walk in `paused` once `seen >= highWaterMark`, and\n * the `drain` / `resume` verbs (delegating to the paginator's own\n * `drain` / `resume`) lower the gauge and re-open the parked fetch — without\n * them `highWaterMark` would be a one-way valve that strands a paused walk.\n * - **`../resilient-call`** owns the page fetch: the single `resilient_run`\n * effect per page is gated through that knob's cache → circuit → rate-limit\n * → retry pipeline. A transient page failure backs off and re-issues the\n * SAME cursor (idempotent retry); the walk does not advance until the page\n * genuinely settles.\n *\n * Those are the only two siblings this module calls into. The resilience knobs\n * (`retry` / `circuit` / `rateLimit` / `deadline`) are NOT separate bricks\n * `paginated-walk` composes — they belong to `resilient-call`, which owns the\n * rate-limit gate internally. This module merely passes them through to\n * `createResilientCall` and re-uses two config TYPES at the boundary:\n * `RateLimitConfig` (re-exported by `resilient-call`) and `RetryPolicy` (from\n * `../retry-backoff`). Pass a `rateLimit` and `resilient-call`'s token bucket\n * throttles page fetches (\"don't 429 the upstream\"); omit it and the walk runs\n * as fast as the upstream answers.\n *\n * Because the page-fetch resilience IS a `resilient-call` slice, this knob\n * **inherits that knob's contract verbatim**: the slice is a flat record, every\n * verb returns `readonly [State, Cmd[]]`, time is always an `at` parameter, and\n * the jitter RNG is injected once at `createPaginatedWalk(config, rng)` — never\n * read inside a verb (per the spec, `paginated-walk` *extends* `resilient-call`).\n *\n * ## The two non-negotiables (canon)\n *\n * - **Durable** — the slice is plain data: the paginator phase + counters, and\n * the resilient-call brick states (all JSON-serializable). The cursor lives\n * in the Model, not a closure, so a walk survives eviction mid-traversal and\n * resumes from the exact page it was fetching.\n * - **Replayable** — every transition is a verb returning new state + Cmds.\n * `nextCursor` / `onPage` are pure config functions applied at the verb\n * boundary; the Cmds they yield are plain data. `replay` reconstructs the\n * whole walk exactly.\n *\n * ## Where the clock / RNG live\n *\n * Inside the verbs: nowhere. `at` arrives on `start` / `pageOk` / `pageErr` /\n * `onTimer` and is threaded straight into the underlying resilient-call gate (so\n * a rate-limited or retried page measures backoff from that `at`). The jitter\n * RNG is injected at construction. The only clock read is `Date.now()` inside\n * the resilient-call `handlers` port (the effect boundary), inherited unchanged.\n *\n * ## The fixed fetch key\n *\n * A walk fetches one page at a time, so the resilient-call layer tracks exactly\n * one logical call under the constant key `PAGE_KEY`. The cursor being fetched\n * is the resilient call's `input`, so a post-eviction resume re-issues the same\n * page request. (`resilient-call` is keyed to support N concurrent calls; a walk\n * deliberately uses a single key — the paginator is the sequencer.)\n *\n * ## Typical wiring\n *\n * const walk = createPaginatedWalk<PageToken, ApiPage>({\n * firstPage: null, // the API's \"page one\" sentinel\n * nextCursor: (page) => page.nextToken ?? null,\n * onPage: (page) => [{ type: \"index_rows\", rows: page.items }],\n * rateLimit: { capacity: 10, refillPerSec: 5 },\n * retry: defaultRetryPolicy,\n * });\n *\n * // in the machine:\n * init: () => [{ walk: walk.init() }, []],\n * update: {\n * start: (s, m) => lift(s, walk.start(s.walk, m.at)),\n * page_ok: (s, m) => lift(s, walk.pageOk(s.walk, m.result, m.at)),\n * page_err: (s, m) => lift(s, walk.pageErr(s.walk, m.error, m.at)),\n * retry_due:(s, m) => lift(s, walk.onTimer(s.walk, m)),\n * drained: (s, m) => lift(s, walk.drain(s.walk, m.n)),\n * resume: (s, m) => lift(s, walk.resume(s.walk, m.at)),\n * },\n * subscriptions: (s) => walk.subs(s.walk),\n * subscribe: { deadline: subscribeDeadline },\n * interpret: walk.handlers({ run: (cursor) => api.fetchPage(cursor) }),\n */\n\nimport type { Cmd } from \"../index\";\nimport {\n drain as drainWalk,\n initPaginator,\n isDone,\n type PaginatorPolicy,\n type PaginatorState,\n recordPage,\n resume as resumeWalk,\n start as startWalk,\n} from \"../paginator\";\nimport { MsgType } from \"../protocol\";\nimport {\n type CircuitConfig,\n createResilientCall,\n type DeadlineConfig,\n type DeadlineSub,\n deadlineSub,\n type FailMsg,\n type RateLimitConfig,\n type ResilientPorts,\n type ResilientState,\n type ResilientTimerMsg,\n type RunCmd,\n type SucceedMsg,\n subscribeDeadline,\n} from \"../resilient-call\";\nimport type { RetryPolicy } from \"../retry-backoff\";\n\n// ===========================================================================\n// Config — the knob. `firstPage` / `nextCursor` / `onPage` describe the walk;\n// the resilience bricks are inherited from resilient-call (every one optional —\n// omit a brick, omit its gate).\n// ===========================================================================\n\n/**\n * The paginated-walk knob. Generic in `Cursor` (the opaque page handle the\n * upstream API hands back — offset, page token, \"next\" URL, …) and `Page` (the\n * shape your fetch port resolves with).\n *\n * `nextCursor` and `onPage` are PURE config functions applied at the verb\n * boundary, never stored in the slice or in a Cmd (invariant 3 — Cmds are\n * plain data, not closures). `nextCursor(page)` extracts the API's next-page\n * handle (or `null` at the end); `onPage(page)` yields the Cmds the consumer\n * wants emitted for each successfully fetched page (index rows, write to R2,\n * dispatch a downstream Msg — whatever; an empty array is fine).\n *\n * The resilience bricks (`retry` / `circuit` / `rateLimit` / `deadline`) are\n * the SAME optional knobs `resilient-call` exposes — paginated-walk extends it.\n * `rateLimit` is the brick that keeps the walk from 429-ing the upstream.\n */\nexport interface PaginatedWalkConfig<Cursor, Page, EmittedCmd extends Cmd> {\n /**\n * The cursor the walk fetches first. For an offset API this is typically `0`;\n * for a token API, whatever sentinel the API treats as \"page one\" (often\n * `null` or `\"\"`). Threaded into `paginator`'s `firstCursor`.\n */\n readonly firstPage: Cursor;\n /**\n * Extract the next cursor from a just-fetched page. Return a `Cursor` to keep\n * walking, or `null` when the API signalled the end (the walk finishes). PURE\n * + consumer-specific (only the consumer knows the API's pagination shape).\n */\n readonly nextCursor: (page: Page) => Cursor | null;\n /**\n * The Cmds to emit for each successfully fetched page — the consumer's work\n * per page (index, persist, fan out, …). Returns plain-data Cmds; `[]` is a\n * valid \"I process the page synchronously elsewhere\" answer. PURE.\n */\n readonly onPage: (page: Page) => readonly EmittedCmd[];\n /**\n * How many items a page contributes to the paginator's backpressure gauge\n * (`seen`). Defaults to `1` per page (count pages, not items) when omitted; a\n * consumer that wants per-item backpressure returns `page.items.length`. PURE.\n */\n readonly pageSize?: (page: Page) => number;\n /**\n * Backpressure threshold handed to the paginator. `seen >= highWaterMark`\n * parks the walk in `paused` instead of fetching the next page. Non-positive\n * (the default `0`) disables backpressure — the walk runs flat out until the\n * API exhausts. See `../paginator`'s `highWaterMark` docs.\n */\n readonly highWaterMark?: number;\n\n // --- resilience bricks, inherited from resilient-call (all optional) ---\n /** Exponential-backoff retry policy for a transient page-fetch failure. */\n readonly retry?: RetryPolicy;\n /** Circuit breaker around the page fetch (one upstream → one breaker). */\n readonly circuit?: CircuitConfig;\n /** Token-bucket rate limit — the \"don't 429 the upstream\" knob. */\n readonly rateLimit?: RateLimitConfig;\n /** Overall wall-clock deadline per individual page fetch. */\n readonly deadline?: DeadlineConfig;\n}\n\n// ===========================================================================\n// Slice — the Model field this knob owns. The paginator walk state + the\n// resilient-call slice for the page fetch. Both are flat plain data.\n// ===========================================================================\n\n/**\n * The slice. `walk` is the cursor/phase/counters from `../paginator`;\n * `resilience` is the `../resilient-call` slice that gates the page fetch.\n * Every field is JSON-serializable, so the whole walk survives a reload.\n *\n * The two move in lockstep: `walk` decides WHICH cursor to fetch next, while\n * `resilience` decides HOW that fetch is attempted (gated, retried, backed\n * off). `pageOk` advances `walk`; `pageErr` defers to `resilience` and leaves\n * `walk` parked on the same cursor until the page truly settles.\n */\nexport interface PaginatedWalkState<Cursor, Page> {\n readonly walk: PaginatorState<Cursor>;\n readonly resilience: ResilientState<Cursor, Page>;\n}\n\n// ===========================================================================\n// Cmds + Msgs the knob speaks.\n// ===========================================================================\n\n/**\n * The page-fetch effect this knob emits: the inherited `resilient_run` Cmd from\n * resilient-call, whose `input` is the `Cursor` to fetch and whose `key` is the\n * fixed `PAGE_KEY`. The consumer's `handlers(ports)` interprets it.\n */\nexport type FetchPageCmd<Cursor> = RunCmd<Cursor>;\n\n/** Page-settled Msgs the `handlers` port dispatches back (inherited verbatim). */\nexport type PageOkMsg<Page> = SucceedMsg<Page>;\nexport type PageErrMsg = FailMsg;\n\n/** The retry / deadline timer Msg — inherited from resilient-call. */\nexport type PaginatedWalkTimerMsg = ResilientTimerMsg;\n\n/**\n * Ports the consumer supplies to `handlers`. `run(cursor)` fetches ONE page for\n * the given cursor; throwing routes to `pageErr` (and thus the resilience\n * backoff). Same shape as resilient-call's port, specialized to the page fetch.\n */\nexport interface PaginatedWalkPorts<Cursor, Page>\n extends ResilientPorts<Cursor, Page> {}\n\n// ===========================================================================\n// The fixed key. A walk fetches one page at a time → exactly one logical\n// resilient call. The paginator is the sequencer; resilient-call's keying is\n// pinned to this single key.\n// ===========================================================================\n\n/** The single resilient-call key every page fetch runs under. */\nexport const PAGE_KEY = \"page\";\n\n// ===========================================================================\n// The knob factory.\n// ===========================================================================\n\n/**\n * Build a paginated-walk knob from `config`. `rng` is injected for the\n * underlying retry jitter — pass a fixed `() => 0` in tests to pin backoff;\n * defaults to `Math.random` (read at the verb boundary inside resilient-call,\n * never in a `paginated-walk` verb). Inherited straight from resilient-call.\n *\n * Returns the uniform L2 knob contract: `init()`, the verbs `start` / `pageOk`\n * / `pageErr` / `onTimer`, `subs(state)`, and `handlers(ports)`. `Cursor` is\n * the page handle, `Page` the fetch result, `EmittedCmd` the consumer's\n * per-page Cmds plus the inherited `FetchPageCmd`.\n */\nexport function createPaginatedWalk<Cursor, Page, EmittedCmd extends Cmd = Cmd>(\n config: PaginatedWalkConfig<Cursor, Page, EmittedCmd>,\n rng: () => number = Math.random,\n) {\n // The page fetch is a resilient call keyed by Cursor input → Page result.\n // We feed it only the resilience bricks; the walk-loop knobs are ours.\n const rc = createResilientCall<Cursor, Page>(\n {\n retry: config.retry,\n circuit: config.circuit,\n rateLimit: config.rateLimit,\n deadline: config.deadline,\n },\n rng,\n );\n\n // The paginator policy: our firstPage is the paginator's firstCursor; the\n // high-water mark gates backpressure (0 / omitted → unbounded).\n const paginatorPolicy: PaginatorPolicy<Cursor> = {\n firstCursor: config.firstPage,\n highWaterMark: config.highWaterMark ?? 0,\n };\n\n // The combined Cmd type the verbs can emit: the inherited page-fetch effect\n // plus whatever `onPage` yields.\n type OutCmd = FetchPageCmd<Cursor> | EmittedCmd;\n\n /** Replace the resilience slice, leaving `walk` untouched. */\n function withResilience(\n s: PaginatedWalkState<Cursor, Page>,\n resilience: ResilientState<Cursor, Page>,\n ): PaginatedWalkState<Cursor, Page> {\n return { ...s, resilience };\n }\n\n /** The starting slice: an idle paginator + a fresh resilient-call slice. */\n function init(): PaginatedWalkState<Cursor, Page> {\n return {\n walk: initPaginator<Cursor>(),\n resilience: rc.init(),\n };\n }\n\n /**\n * Issue a resilient page fetch for `cursor` at `at`. The cursor is the\n * resilient call's `input` (so a retry / resume re-fetches the same page) and\n * `PAGE_KEY` is the fixed key. Returns the advanced resilience slice + the\n * `resilient_run` Cmd (or none, if the gate cached / fast-failed / backed off).\n * PURE — `at` is the only clock. Shared by `start` and `pageOk`'s advance.\n */\n function fetch(\n s: PaginatedWalkState<Cursor, Page>,\n cursor: Cursor,\n at: number,\n ): readonly [\n PaginatedWalkState<Cursor, Page>,\n readonly FetchPageCmd<Cursor>[],\n ] {\n const [resilience, cmds] = rc.attempt(s.resilience, PAGE_KEY, cursor, at);\n return [withResilience(s, resilience), cmds];\n }\n\n // === Verb: start =========================================================\n\n /**\n * Begin the walk: arm the paginator's first cursor and issue the first page\n * fetch (gated through resilience). On any non-`idle` paginator phase this is\n * a no-op — re-starting a walk in flight would skip back to page one and\n * double-count, so a stray `start` after launch is absorbed (the paginator's\n * own `start` guard). PURE — `at` threads into the fetch gate.\n */\n function start(\n s: PaginatedWalkState<Cursor, Page>,\n at: number,\n ): readonly [PaginatedWalkState<Cursor, Page>, readonly OutCmd[]] {\n const walk = startWalk(s.walk, paginatorPolicy);\n // `startWalk` is a pure no-op on any non-`idle` phase: it returns the SAME\n // state object. Reference identity (not phase) is the honest \"did anything\n // arm?\" check — a walk already `fetching` would also satisfy\n // `phase === \"fetching\"` yet must NOT re-issue a fetch (page-one skip /\n // double-fetch). So we proceed ONLY when start produced a fresh state. The\n // `phase === \"fetching\"` narrow that follows is therefore always true here\n // (start only ever produces a `fetching` arm), and also narrows `.cursor`\n // for the type checker.\n if (walk === s.walk || walk.phase !== \"fetching\") {\n return [s, []];\n }\n // Armed `fetching(firstCursor)` → issue the first page fetch.\n return fetch({ ...s, walk }, walk.cursor, at);\n }\n\n // === Verb: pageOk ========================================================\n\n /**\n * Record a successfully fetched `page` and decide what comes next. PURE —\n * `at` is the cache / next-fetch clock.\n *\n * 1. Settle the resilient call OK (closes the breaker, fills the cache,\n * resets the page-fetch retry counter) — inherited from resilient-call.\n * 2. Fold the page through the paginator: add `pageSize(page)` to `seen`,\n * bump `pages`, and branch on `nextCursor(page)`:\n * - `null` → the API is exhausted → paginator goes `done`.\n * - a cursor → advance, then either fetch it (backpressure room) or\n * park in `paused` (high-water mark reached).\n * 3. Emit the consumer's `onPage(page)` Cmds either way, PLUS the next\n * page-fetch effect when the paginator stays `fetching`.\n *\n * A `pageOk` that arrives when the paginator is not `fetching` (a late\n * duplicate, a double-dispatch) is absorbed by `recordPage` — the cursor does\n * not advance and `seen` is not double-counted; only the resilient settle +\n * `onPage` Cmds happen. The walk is a coordination aid, not a correctness\n * gate.\n */\n function pageOk(\n s: PaginatedWalkState<Cursor, Page>,\n page: Page,\n at: number,\n ): readonly [PaginatedWalkState<Cursor, Page>, readonly OutCmd[]] {\n // 1) Settle the underlying resilient call as a success.\n const [resilience] = rc.succeed(s.resilience, PAGE_KEY, {\n type: MsgType.ResilientOk,\n key: PAGE_KEY,\n result: page,\n at,\n });\n\n // 2) Advance the paginator with the page's contribution + the next cursor.\n const count = config.pageSize ? config.pageSize(page) : 1;\n const next = config.nextCursor(page);\n const walk = recordPage(s.walk, count, next, paginatorPolicy);\n\n const advanced: PaginatedWalkState<Cursor, Page> = { walk, resilience };\n\n // 3) The consumer's per-page work, always emitted on a successful page.\n const pageCmds: readonly EmittedCmd[] = config.onPage(page);\n\n // If the paginator stayed `fetching`, issue the next page fetch (gated).\n if (walk.phase === \"fetching\") {\n const [withFetch, fetchCmds] = fetch(advanced, walk.cursor, at);\n return [withFetch, [...pageCmds, ...fetchCmds]];\n }\n\n // `done` (exhausted) or `paused` (backpressure) → no further fetch now.\n return [advanced, pageCmds];\n }\n\n // === Verb: pageErr =======================================================\n\n /**\n * Record a failed page fetch and back off via resilience. PURE — `at` stamps\n * the breaker trip + the retry-delay base.\n *\n * Defers to the resilient-call `fail` verb: it trips the breaker and either\n * schedules a retry (paginator stays parked on the SAME cursor — no advance)\n * or settles the resilient call `failed` once retries are exhausted. The\n * paginator is deliberately untouched: a failed fetch must NOT advance the\n * cursor or skip a page. When the retry timer later fires, `onTimer` re-issues\n * the same page fetch.\n *\n * GUARDED by the paginator phase, exactly as `pageOk` is guarded by\n * `recordPage`'s `fetching`-only check. A `pageErr` only makes sense while a\n * page fetch is outstanding (`walk.phase === \"fetching\"`). A stray `pageErr`\n * that arrives after the walk has settled — `done` (exhausted), `paused`\n * (backpressure), or `idle` (not started) — has NO outstanding fetch to fail.\n * Without this guard it would still call `rc.fail`, which trips the SHARED\n * circuit breaker (dinging a healthy upstream) and overwrites the settled\n * `PAGE_KEY` resilient slot with `failed` (clobbering the succeeded result a\n * resume would otherwise observe). So a stray `pageErr` is absorbed as a pure\n * no-op — identity preserved — matching `pageOk`'s \"the walk is a coordination\n * aid, not a correctness gate\" stance.\n */\n function pageErr(\n s: PaginatedWalkState<Cursor, Page>,\n error: unknown,\n at: number,\n ): readonly [PaginatedWalkState<Cursor, Page>, readonly OutCmd[]] {\n // Only a `fetching` paginator has an outstanding page fetch to fail. On any\n // other phase the fail would corrupt shared resilience state for a fetch\n // that is not in flight — absorb it as a pure no-op.\n if (s.walk.phase !== \"fetching\") {\n return [s, []];\n }\n const [resilience, cmds] = rc.fail(s.resilience, PAGE_KEY, {\n type: MsgType.ResilientErr,\n key: PAGE_KEY,\n error,\n at,\n });\n return [withResilience(s, resilience), cmds];\n }\n\n // === Verb: onTimer =======================================================\n\n /**\n * A retry / deadline timer fired. Defers to resilient-call's `onTimer`: a\n * retry timer re-runs the gate for the page-fetch's remembered cursor (re-\n * issuing the SAME page); a deadline timer settles the resilient call failed.\n * The paginator stays parked on its cursor throughout — only `pageOk` advances\n * it. A stale fire is a no-op (inherited). PURE.\n *\n * GUARDED by the paginator phase, for the same reason as `pageErr`. A timer is\n * only meaningful while a page fetch is outstanding (`walk.phase ===\n * \"fetching\"`). A stray timer that fires after the walk has settled (`done` /\n * `paused` / `idle`) would otherwise re-enter `rc.onTimer` — re-issuing a fetch\n * the paginator no longer wants (a deadline timer could even overwrite the\n * settled `PAGE_KEY` slot with a deadline `failed`). On any non-`fetching`\n * phase the timer is absorbed as a pure no-op.\n */\n function onTimer(\n s: PaginatedWalkState<Cursor, Page>,\n msg: PaginatedWalkTimerMsg,\n ): readonly [PaginatedWalkState<Cursor, Page>, readonly OutCmd[]] {\n if (s.walk.phase !== \"fetching\") {\n return [s, []];\n }\n const [resilience, cmds] = rc.onTimer(s.resilience, msg);\n if (resilience === s.resilience) {\n // Inherited no-op identity → keep our slice identity too (pure no-op).\n return [s, []];\n }\n return [withResilience(s, resilience), cmds];\n }\n\n // === Verb: drain =========================================================\n\n /**\n * Acknowledge that the consumer finished processing `n` items, lowering the\n * paginator's backpressure gauge (`seen`). PURE — no clock. Delegates to\n * `paginator.drain`: subtracts `n` (floored at 0), leaves the phase untouched.\n *\n * The OTHER half of the backpressure valve `highWaterMark` opens: `recordPage`\n * parks the walk in `paused` once `seen >= highWaterMark`, and `drain` is how a\n * consumer lowers `seen` back below the mark so `resume` can re-open the fetch.\n * Without it `highWaterMark` would be a one-way valve — a walk could pause but\n * never continue. Emits no Cmds: draining only frees headroom; `resume` is the\n * separate, explicit \"continue now\" decision.\n */\n function drain(\n s: PaginatedWalkState<Cursor, Page>,\n n: number,\n ): readonly [PaginatedWalkState<Cursor, Page>, readonly OutCmd[]] {\n const walk = drainWalk(s.walk, n);\n if (walk === s.walk) {\n // Pure no-op identity (n <= 0, or seen already at floor).\n return [s, []];\n }\n return [{ ...s, walk }, []];\n }\n\n // === Verb: resume ========================================================\n\n /**\n * Re-open the backpressure valve after a `drain`: if the walk is `paused` and\n * `seen` has dropped below `highWaterMark`, re-arm the parked cursor and issue\n * its page fetch (gated through resilience). PURE — `at` threads into the fetch\n * gate. Delegates the phase decision to `paginator.resume`:\n *\n * - `paused` AND `seen < highWaterMark` → `fetching(cursor)` → emit the fetch.\n * - `paused` but still over the mark → no-op (the valve stays shut until a\n * further `drain` frees real headroom).\n * - any other phase → no-op (nothing to resume).\n *\n * Together with `drain` this completes the valve: `recordPage` shuts it\n * (`paused`), `drain` lowers the gauge, `resume` re-opens it and fires the next\n * page fetch — the same `fetch` helper `start` / `pageOk` use, so a resumed\n * fetch is gated identically (cache / circuit / rate-limit / retry).\n */\n function resume(\n s: PaginatedWalkState<Cursor, Page>,\n at: number,\n ): readonly [PaginatedWalkState<Cursor, Page>, readonly OutCmd[]] {\n const walk = resumeWalk(s.walk, paginatorPolicy);\n // `resumeWalk` is a pure no-op (same reference) on any phase but `paused`, or\n // when `seen` is still at/over the mark. Only a fresh `fetching` arm means\n // the valve actually re-opened — issue the page fetch only then.\n if (walk === s.walk || walk.phase !== \"fetching\") {\n return [s, []];\n }\n return fetch({ ...s, walk }, walk.cursor, at);\n }\n\n // === Derived predicates ==================================================\n\n /**\n * Whether the walk has finished — the API returned a null next cursor and the\n * paginator is `done`. PURE, read-only. Lets a consumer's reducer fire a\n * \"walk complete\" Cmd (`if (walk.isDone(s.walk)) …`) without re-checking the\n * paginator phase by hand.\n */\n function isComplete(s: PaginatedWalkState<Cursor, Page>): boolean {\n return isDone(s.walk);\n }\n\n /**\n * The terminal page-fetch failure that stranded the walk, or `undefined` if no\n * page has terminally failed. PURE, read-only. Errors are data: a page that\n * exhausts its retries (or trips the deadline) settles the `PAGE_KEY` resilient\n * slot to `failed` while the paginator stays parked on its cursor — the walk is\n * stuck and will never advance on its own. Without a way to read that phase the\n * failure is unobservable: the walk simply stops emitting fetches with no\n * signal. `failure(s)` surfaces the settled error (the same plain-data sentinel\n * resilient-call records) so a consumer can fire a \"walk dead\" Cmd, alert, or\n * re-`start` a fresh walk.\n */\n function failure(s: PaginatedWalkState<Cursor, Page>): unknown {\n const call = s.resilience.calls[PAGE_KEY];\n return call?.phase === \"failed\" ? call.error : undefined;\n }\n\n /**\n * Whether the walk is STUCK: a page fetch has terminally `failed` yet the\n * paginator is still parked on a cursor (not `done`), so no further fetch will\n * ever be issued without intervention. PURE, read-only.\n *\n * This is the dead-walk predicate the consumer polls (`if (walk.isStuck(s)) …`)\n * to distinguish a healthy finish (`isComplete`) from a silent death. A walk is\n * stuck precisely when the resilient slot settled `failed` AND the paginator\n * has NOT reached `done` — a terminal failure on the final page that also\n * finished the walk is `isComplete`, not stuck. Pairs with `failure(s)`, which\n * hands back the error that stuck it.\n */\n function isStuck(s: PaginatedWalkState<Cursor, Page>): boolean {\n return s.resilience.calls[PAGE_KEY]?.phase === \"failed\" && !isDone(s.walk);\n }\n\n // === Subs ================================================================\n\n /**\n * Pre-wired subscriptions: exactly the resilient-call subs for the page\n * fetch — a retry timer while the fetch is `waiting_retry`, and (with the\n * `deadline` brick) a per-fetch deadline timer while it is active. Reconciled\n * by id, so a phase change cancels the matching timer automatically. Wire\n * `subscribe: { deadline: subscribeDeadline }` (re-exported below).\n */\n function subs(s: PaginatedWalkState<Cursor, Page>): readonly DeadlineSub[] {\n return rc.subs(s.resilience);\n }\n\n // === Handlers ============================================================\n\n /**\n * Pre-wired interpret handler for the page-fetch effect. Inherited from\n * resilient-call (`resilient_run`): wraps the consumer's `run(cursor)` port\n * via `tryInterpret` (Railway) — success → `resilient_ok` (handled by\n * `pageOk`), failure → `resilient_err` (handled by `pageErr`), each stamped\n * with `Date.now()` at the effect boundary (the ONE permitted clock read).\n *\n * interpret: walk.handlers({ run: (cursor) => api.fetchPage(cursor) })\n *\n * The consumer maps the inherited Msg types to their `pageOk` / `pageErr`\n * reducer cells (the wire shape is `resilient_ok` / `resilient_err`).\n */\n function handlers(ports: PaginatedWalkPorts<Cursor, Page>) {\n return rc.handlers(ports);\n }\n\n return {\n init,\n start,\n pageOk,\n pageErr,\n onTimer,\n drain,\n resume,\n isComplete,\n failure,\n isStuck,\n subs,\n handlers,\n };\n}\n\n/**\n * Lift a knob result `[slice, cmds]` into a host `[State, cmds]` where the slice\n * lives at `state.walk`. A convenience for the common single-slice host;\n * consumers with a differently-named field spread by hand. Pure — a thin record\n * rebuild, no clock / RNG.\n */\nexport function liftWalk<\n S extends { walk: PaginatedWalkState<Cursor, Page> },\n Cursor,\n Page,\n C extends Cmd,\n>(\n state: S,\n [slice, cmds]: readonly [PaginatedWalkState<Cursor, Page>, readonly C[]],\n): readonly [S, readonly C[]] {\n return [{ ...state, walk: slice }, cmds];\n}\n\n/**\n * Re-export the deadline Sub primitives (inherited from resilient-call) so\n * consumers wire one import: `subscribeDeadline` is the `subscribe` cell,\n * `deadlineSub` builds the Sub literal this knob's `subs` emits.\n */\nexport { subscribeDeadline, deadlineSub };\nexport type { DeadlineSub };\n"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/retry-backoff/index.ts"],"names":[],"mappings":";AA8EO,SAAS,KAAA,CAAM,EAAA,EAAkB,KAAA,GAAQ,KAAA,EAAY;AAC1D,EAAA,IAAI,KAAA,EAAO;AACT,IAAA,MAAM,SAAS,EAAA,EAAG;AAClB,IAAA,IAAI,EAAE,MAAA,IAAU,CAAA,IAAK,MAAA,GAAS,CAAA,CAAA,EAAI;AAChC,MAAA,MAAM,IAAI,UAAA;AAAA,QACR,6BAA6B,MAAM,CAAA,6BAAA;AAAA,OACrC;AAAA,IACF;AAAA,EACF;AACA,EAAA,OAAO,EAAA;AACT;AAOO,IAAM,aAAkB,IAAA,CAAK;AA4B7B,IAAM,kBAAA,GAAkC;AAAA,EAC7C,MAAA,EAAQ,GAAA;AAAA,EACR,MAAA,EAAQ,CAAA;AAAA,EACR,KAAA,EAAO,GAAA;AAAA,EACP,WAAA,EAAa,CAAA;AAAA,EACb,MAAA,EAAQ;AACV;AAuBO,SAAS,YAAA,CACd,OAAA,EACA,MAAA,EACA,GAAA,GAAW,UAAA,EACH;AAIR,EAAA,MAAM,WAAA,GAAc,OAAA,GAAU,CAAA,GAAI,CAAA,GAAI,OAAA;AACtC,EAAA,MAAM,WAAA,GAAc,MAAA,CAAO,MAAA,GAAS,MAAA,CAAO,MAAA,IAAU,WAAA;AAMrD,EAAA,MAAM,CAAA,GAAI,MAAA,CAAO,KAAA,CAAM,WAAW,CAAA,GAAI,IAAI,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,KAAA,EAAO,WAAW,CAAA;AAE5E,EAAA,QAAQ,OAAO,MAAA;AAAQ,IACrB,KAAK,MAAA;AACH,MAAA,OAAO,CAAA;AAAA,IACT,KAAK,MAAA;AAKH,MAAA,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,GAAA,KAAQ,CAAC,CAAA;AAAA,IAC9B,KAAK,OAAA;AAGH,MAAA,OAAO,IAAA,CAAK,IAAI,CAAA,EAAG,CAAA,GAAI,IAAI,GAAA,EAAI,IAAK,IAAI,CAAA,CAAE,CAAA;AAAA;AAEhD;AAgBO,SAAS,SAAA,GAAwB;AACtC,EAAA,OAAO,EAAE,SAAS,CAAA,EAAE;AACtB;AAMO,SAAS,aAAA,CAAc,OAAmB,KAAA,EAA4B;AAC3E,EAAA,OAAO,EAAE,OAAA,EAAS,KAAA,CAAM,OAAA,GAAU,CAAA,EAAG,WAAW,KAAA,EAAM;AACxD;AASO,SAAS,WAAA,CAAY,OAAmB,MAAA,EAA8B;AAC3E,EAAA,OAAO,KAAA,CAAM,UAAU,MAAA,CAAO,WAAA;AAChC;AASO,SAAS,WAAA,CACd,KAAA,EACA,MAAA,EACA,GAAA,GAAW,UAAA,EACH;AACR,EAAA,OAAO,YAAA,CAAa,KAAA,CAAM,OAAA,EAAS,MAAA,EAAQ,GAAG,CAAA;AAChD","file":"chunk-5VNWZADP.js","sourcesContent":["/**\n * @demlik/tea/retry-backoff — exponential backoff with jitter + cap, and the\n * retry-attempt state every fallible `interpret` handler folds over.\n *\n * This is a \"pure state + ops\" module in the same mold as\n * `@demlik/tea/work-queue`: a state type plus pure transition functions,\n * storage- and host-agnostic, that a consumer wires into their own reducer /\n * interpret. Nothing here reads the clock or the RNG on its own behalf —\n * randomness is *injected* (`rng`) and time is *computed by the caller* from\n * the millisecond delays this module returns. That keeps every function\n * deterministic and unit-testable, and keeps the substrate's purity invariants\n * intact when the result is threaded through `update` (invariant 2) and the\n * delay is realized as a `Cmd` the runtime performs (invariant 3).\n *\n * Typical wiring:\n *\n * // in `update`, on a failure Msg:\n * const retry = recordFailure(state.retry, msg.error);\n * if (!shouldRetry(retry, policy)) return [toGaveUp(state), []];\n * const delayMs = nextDelayMs(retry, policy); // pure: rng injected at call site\n * return [{ ...state, retry }, [{ type: \"ScheduleRetry\", delayMs, into: TimerFiredRetry }]];\n *\n * The reducer stays pure: `nextDelayMs` is called with the runtime's RNG only\n * at the emit boundary (or the caller defers the whole delay computation to the\n * `interpret` handler and passes `rng` there). Either way the substrate never\n * sees `Math.random()` inside a transition body.\n *\n * NOT a substrate primitive: it depends on nothing from `../index`. It is a\n * leaf utility consumers reach via the `@demlik/tea/retry-backoff` subpath.\n */\n\n/**\n * Jitter strategy applied to the computed backoff delay.\n *\n * Names follow the AWS \"Exponential Backoff And Jitter\" taxonomy (Brooker,\n * 2015), the same vocabulary used by the AWS SDKs and most retry libraries:\n *\n * - `\"none\"` — no jitter; the delay is the raw capped exponential `d`.\n * Deterministic, but synchronizes retries across clients\n * (thundering herd). Use only when callers are already\n * de-correlated by other means.\n * - `\"full\"` — uniform random in `[0, d]`. Maximum spread; best herd\n * avoidance, highest variance in latency.\n * - `\"equal\"` — `d/2 + random(0, d/2)`, i.e. uniform in `[d/2, d]`. Half the\n * delay is fixed, half is jittered: keeps a latency floor while\n * still de-correlating clients. A common default.\n */\nexport type Jitter = \"none\" | \"full\" | \"equal\";\n\ndeclare const RngBrand: unique symbol;\n\n/**\n * A source of uniform randomness in `[0, 1)` — the `Math.random` contract.\n *\n * Nominally branded so the `[0, 1)` range is enforced at *construction*, not\n * by a comment. A raw `() => number` does NOT satisfy `Rng`: a stray `() => 5`\n * or `() => -1` is rejected by the type before it can ever feed the jitter\n * math and breach the `0 <= delay <= capMs` postcondition. The only way to\n * mint an `Rng` is {@link asRng} (or the {@link defaultRng} const), so the\n * range obligation lives at the boundary where randomness enters the module —\n * \"parse, don't validate\" applied to a function value.\n */\nexport type Rng = (() => number) & { readonly [RngBrand]: true };\n\n/**\n * Brand a `[0, 1)` generator as an {@link Rng}. The single entry point for\n * randomness into this module: callers (production wiring and tests alike)\n * pass their raw generator through here, which optionally asserts the contract\n * and tags the value. The probe is a cheap guard against the most common\n * misuse (returning a non-unit value); it does not — and cannot — prove\n * uniformity.\n *\n * PURE with respect to the module: it neither reads the clock nor calls the\n * generator on the module's behalf unless `probe` is set. The probe is opt-in\n * (default off) so wrapping a hot generator stays allocation- and call-free.\n *\n * @throws RangeError if `probe` is enabled and a sampled value is outside `[0, 1)`.\n */\nexport function asRng(fn: () => number, probe = false): Rng {\n if (probe) {\n const sample = fn();\n if (!(sample >= 0 && sample < 1)) {\n throw new RangeError(\n `asRng: generator returned ${sample}, outside the [0, 1) contract`,\n );\n }\n }\n return fn as Rng;\n}\n\n/**\n * The production default: `Math.random`, branded. Used wherever an `Rng` is\n * required but the caller omits one — at the effect boundary, never inside a\n * reducer.\n */\nexport const defaultRng: Rng = Math.random as Rng;\n\n/**\n * A retry policy is pure configuration — the knobs of the backoff curve. It\n * carries no mutable state; `RetryState` does. One policy is shared across all\n * the attempts of one logical operation (often a module-scope const).\n */\nexport interface RetryPolicy {\n /** Delay for attempt 0, in milliseconds, before any jitter or cap. */\n readonly baseMs: number;\n /** Geometric growth factor. `2` doubles the delay each attempt. */\n readonly factor: number;\n /** Hard ceiling, in milliseconds. The capped exponential never exceeds this. */\n readonly capMs: number;\n /**\n * Maximum number of attempts. `shouldRetry` allows attempts `0 .. maxAttempts-1`,\n * so `maxAttempts: 3` means \"try once, then retry at most twice\" (attempts 0, 1, 2).\n */\n readonly maxAttempts: number;\n /** Jitter strategy applied to the capped exponential delay. */\n readonly jitter: Jitter;\n}\n\n/**\n * Sensible defaults: 100ms base, doubling, capped at 30s, up to 5 attempts,\n * full jitter (the herd-avoidance default). Tuned for network calls; override\n * per call site rather than mutating this shared const.\n */\nexport const defaultRetryPolicy: RetryPolicy = {\n baseMs: 100,\n factor: 2,\n capMs: 30_000,\n maxAttempts: 5,\n jitter: \"full\",\n};\n\n/**\n * Compute the delay for a given 0-based `attempt` under `policy`.\n *\n * PURE. The exponential is `baseMs * factor ** attempt`, clamped to `capMs`,\n * then jittered. `rng` is injected so the result is deterministic in tests —\n * pass an `rng` returning a fixed value to pin the jitter; the production\n * default reads `Math.random` only when the caller omits it (at the effect\n * boundary, never inside a reducer).\n *\n * `rng` is an {@link Rng} — a `[0, 1)` generator branded at construction via\n * {@link asRng}, so a raw `() => 5` can't be passed here. The returned delay\n * is always in `[0, capMs]` regardless of jitter strategy:\n * - `\"none\"` → exactly the capped exponential `d`.\n * - `\"full\"` → `rng() * d`, uniform in `[0, d]`.\n * - `\"equal\"` → `d/2 + rng() * d/2`, uniform in `[d/2, d]`.\n *\n * `attempt` is treated as `>= 0`; a negative attempt is clamped to 0 so a\n * caller's off-by-one never produces a fractional exponent or a delay below\n * `baseMs`. `capMs` is also the floor of the clamp, so a misconfigured\n * `capMs < baseMs` still yields a delay within `[0, capMs]`.\n */\nexport function backoffDelay(\n attempt: number,\n policy: RetryPolicy,\n rng: Rng = defaultRng,\n): number {\n // Clamp the attempt to >= 0 so factor ** attempt is never a fractional /\n // negative exponent. The exponential can overflow to Infinity for large\n // attempts; Math.min with capMs collapses that back to the ceiling.\n const safeAttempt = attempt < 0 ? 0 : attempt;\n const exponential = policy.baseMs * policy.factor ** safeAttempt;\n // `factor ** attempt` overflows to Infinity for large attempts. With\n // `baseMs > 0` that's `Infinity`, which Math.min collapses back to `capMs`.\n // With `baseMs === 0` it's `0 * Infinity === NaN`; a zero base means zero\n // delay, so treat NaN as 0 (otherwise NaN poisons the comparison and breaks\n // the `0 <= delay <= capMs` postcondition).\n const d = Number.isNaN(exponential) ? 0 : Math.min(policy.capMs, exponential);\n\n switch (policy.jitter) {\n case \"none\":\n return d;\n case \"full\":\n // Uniform in [0, d]. Math.min re-pins the upper bound: at subnormal /\n // extreme-precision `d`, `rng() * d` can round one ULP above `d`, which\n // would breach the documented `delay <= capMs` postcondition. Clamping to\n // `d` (already <= capMs) makes the invariant hold by construction.\n return Math.min(d, rng() * d);\n case \"equal\":\n // Fixed half + jittered half → uniform in [d/2, d]. Same FP-rounding clamp\n // as \"full\": `d/2 + rng()*(d/2)` can round above `d` for subnormal `d`.\n return Math.min(d, d / 2 + rng() * (d / 2));\n }\n}\n\n/**\n * Per-operation retry state: how many attempts have failed so far, and the most\n * recent error. `lastError` is `unknown` because the failure can be anything a\n * boundary surfaces (an `Error`, a tagged result, a string) — this module never\n * inspects it, it only carries it forward for the consumer's reducer to read.\n */\nexport interface RetryState {\n /** Count of failures recorded so far. Also the 0-based index of the next attempt. */\n readonly attempt: number;\n /** The error from the most recent failure, if any. Carried, never interpreted. */\n readonly lastError?: unknown;\n}\n\n/** The starting state: zero attempts, no error yet. */\nexport function initRetry(): RetryState {\n return { attempt: 0 };\n}\n\n/**\n * Record a failed attempt. PURE — returns a *new* state with `attempt`\n * incremented and `lastError` set; the input `state` is never mutated.\n */\nexport function recordFailure(state: RetryState, error: unknown): RetryState {\n return { attempt: state.attempt + 1, lastError: error };\n}\n\n/**\n * Whether another attempt is permitted under `policy`. PURE.\n *\n * `attempt` is the number of failures recorded so far (= the index of the next\n * attempt). We may retry while that index is below `maxAttempts`, i.e. attempts\n * `0 .. maxAttempts-1` are allowed and the `maxAttempts`-th is refused.\n */\nexport function shouldRetry(state: RetryState, policy: RetryPolicy): boolean {\n return state.attempt < policy.maxAttempts;\n}\n\n/**\n * The delay to wait before the next attempt, given the current state. PURE.\n *\n * Bridges `RetryState` → `backoffDelay` by using `state.attempt` as the 0-based\n * attempt index, so the delay grows with each recorded failure. `rng` is\n * injected for determinism, same contract as `backoffDelay`.\n */\nexport function nextDelayMs(\n state: RetryState,\n policy: RetryPolicy,\n rng: Rng = defaultRng,\n): number {\n return backoffDelay(state.attempt, policy, rng);\n}\n"]}
|
package/dist/chunk-6RGBKODV.js
DELETED
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
import { deadlineSub } from './chunk-K7Q23EQ4.js';
|
|
2
|
-
|
|
3
|
-
// src/raft/index.ts
|
|
4
|
-
function electionSubId(self) {
|
|
5
|
-
return `raft:election:${self}`;
|
|
6
|
-
}
|
|
7
|
-
function heartbeatSubId(self) {
|
|
8
|
-
return `raft:heartbeat:${self}`;
|
|
9
|
-
}
|
|
10
|
-
function toRaftMsg(self, fired) {
|
|
11
|
-
if (fired.id === electionSubId(self)) {
|
|
12
|
-
return { _tag: "election_timeout_fired", at: fired.atMs };
|
|
13
|
-
}
|
|
14
|
-
if (fired.id === heartbeatSubId(self)) {
|
|
15
|
-
return { _tag: "heartbeat_fired", at: fired.atMs };
|
|
16
|
-
}
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
function logIsUpToDate(log, candidateLastLogIndex, candidateLastLogTerm) {
|
|
20
|
-
const last = log.at(-1);
|
|
21
|
-
const ourLastTerm = last?.term ?? 0;
|
|
22
|
-
const ourLastIndex = last?.index ?? 0;
|
|
23
|
-
if (candidateLastLogTerm !== ourLastTerm) {
|
|
24
|
-
return candidateLastLogTerm > ourLastTerm;
|
|
25
|
-
}
|
|
26
|
-
return candidateLastLogIndex >= ourLastIndex;
|
|
27
|
-
}
|
|
28
|
-
function majority(peerCount) {
|
|
29
|
-
const clusterSize = peerCount + 1;
|
|
30
|
-
return Math.floor(clusterSize / 2) + 1;
|
|
31
|
-
}
|
|
32
|
-
function createRaftNode(config, rng = Math.random) {
|
|
33
|
-
const { self, peers, electionTimeout, heartbeatMs } = config;
|
|
34
|
-
const quorum = majority(peers.length);
|
|
35
|
-
function lastIndex(state) {
|
|
36
|
-
return state.log.at(-1)?.index ?? 0;
|
|
37
|
-
}
|
|
38
|
-
function lastTerm(state) {
|
|
39
|
-
return state.log.at(-1)?.term ?? 0;
|
|
40
|
-
}
|
|
41
|
-
function adoptHigherTerm(state, term) {
|
|
42
|
-
if (term <= state.currentTerm) return state;
|
|
43
|
-
return {
|
|
44
|
-
...state,
|
|
45
|
-
currentTerm: term,
|
|
46
|
-
votedFor: null,
|
|
47
|
-
role: { _tag: "follower" }
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
function startElection(state) {
|
|
51
|
-
const newTerm = state.currentTerm + 1;
|
|
52
|
-
const lastLogIndex = lastIndex(state);
|
|
53
|
-
const lastLogTerm = lastTerm(state);
|
|
54
|
-
const next = {
|
|
55
|
-
...state,
|
|
56
|
-
currentTerm: newTerm,
|
|
57
|
-
votedFor: self,
|
|
58
|
-
role: { _tag: "candidate", votesGranted: [self] }
|
|
59
|
-
};
|
|
60
|
-
const requests = peers.map((peer) => ({
|
|
61
|
-
type: "raft:send_request_vote",
|
|
62
|
-
to: peer,
|
|
63
|
-
term: newTerm,
|
|
64
|
-
candidateId: self,
|
|
65
|
-
lastLogIndex,
|
|
66
|
-
lastLogTerm
|
|
67
|
-
}));
|
|
68
|
-
if (next.role._tag === "candidate" && next.role.votesGranted.length >= quorum) {
|
|
69
|
-
return becomeLeader(next);
|
|
70
|
-
}
|
|
71
|
-
return [next, requests];
|
|
72
|
-
}
|
|
73
|
-
function becomeLeader(state) {
|
|
74
|
-
const nextIdx = lastIndex(state) + 1;
|
|
75
|
-
const nextIndex = {};
|
|
76
|
-
const matchIndex = {};
|
|
77
|
-
for (const peer of peers) {
|
|
78
|
-
nextIndex[peer] = nextIdx;
|
|
79
|
-
matchIndex[peer] = 0;
|
|
80
|
-
}
|
|
81
|
-
const leader = {
|
|
82
|
-
...state,
|
|
83
|
-
role: { _tag: "leader", nextIndex, matchIndex }
|
|
84
|
-
};
|
|
85
|
-
return [leader, replicateAll(leader)];
|
|
86
|
-
}
|
|
87
|
-
function appendEntriesFor(state, peer, next) {
|
|
88
|
-
const prevLogIndex = next - 1;
|
|
89
|
-
const prevLogTerm = prevLogIndex > 0 ? state.log.at(prevLogIndex - 1)?.term ?? 0 : 0;
|
|
90
|
-
const entries = state.log.slice(prevLogIndex);
|
|
91
|
-
return {
|
|
92
|
-
type: "raft:send_append_entries",
|
|
93
|
-
to: peer,
|
|
94
|
-
term: state.currentTerm,
|
|
95
|
-
leaderId: self,
|
|
96
|
-
prevLogIndex,
|
|
97
|
-
prevLogTerm,
|
|
98
|
-
entries,
|
|
99
|
-
leaderCommit: state.commitIndex
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
function replicateAll(state) {
|
|
103
|
-
if (state.role._tag !== "leader") return [];
|
|
104
|
-
const { nextIndex } = state.role;
|
|
105
|
-
return peers.map(
|
|
106
|
-
(peer) => appendEntriesFor(state, peer, nextIndex[peer] ?? 1)
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
function advanceCommitIndex(state) {
|
|
110
|
-
if (state.role._tag !== "leader") return state.commitIndex;
|
|
111
|
-
const { matchIndex } = state.role;
|
|
112
|
-
const lastLogIndex = lastIndex(state);
|
|
113
|
-
let commit = state.commitIndex;
|
|
114
|
-
for (let n = state.commitIndex + 1; n <= lastLogIndex; n++) {
|
|
115
|
-
if (state.log.at(n - 1)?.term !== state.currentTerm) continue;
|
|
116
|
-
let replicas = 1;
|
|
117
|
-
for (const peer of peers) {
|
|
118
|
-
if ((matchIndex[peer] ?? 0) >= n) replicas++;
|
|
119
|
-
}
|
|
120
|
-
if (replicas >= quorum) commit = n;
|
|
121
|
-
}
|
|
122
|
-
return commit;
|
|
123
|
-
}
|
|
124
|
-
function init() {
|
|
125
|
-
return {
|
|
126
|
-
currentTerm: 0,
|
|
127
|
-
votedFor: null,
|
|
128
|
-
log: [],
|
|
129
|
-
commitIndex: 0,
|
|
130
|
-
role: { _tag: "follower" }
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
function onElectionTimeout(state, _at) {
|
|
134
|
-
if (state.role._tag === "leader") return [state, []];
|
|
135
|
-
return startElection(state);
|
|
136
|
-
}
|
|
137
|
-
function onRequestVote(state, req) {
|
|
138
|
-
const base = adoptHigherTerm(state, req.term);
|
|
139
|
-
if (req.term < base.currentTerm) {
|
|
140
|
-
return [base, [voteReply(req.candidateId, base.currentTerm, false)]];
|
|
141
|
-
}
|
|
142
|
-
const free = base.votedFor === null || base.votedFor === req.candidateId;
|
|
143
|
-
const upToDate = logIsUpToDate(base.log, req.lastLogIndex, req.lastLogTerm);
|
|
144
|
-
const grant = free && upToDate;
|
|
145
|
-
if (!grant) {
|
|
146
|
-
return [base, [voteReply(req.candidateId, base.currentTerm, false)]];
|
|
147
|
-
}
|
|
148
|
-
const granted = {
|
|
149
|
-
...base,
|
|
150
|
-
votedFor: req.candidateId,
|
|
151
|
-
role: { _tag: "follower" }
|
|
152
|
-
};
|
|
153
|
-
return [granted, [voteReply(req.candidateId, base.currentTerm, true)]];
|
|
154
|
-
}
|
|
155
|
-
function onRequestVoteReply(state, reply) {
|
|
156
|
-
if (reply.term > state.currentTerm) {
|
|
157
|
-
return [adoptHigherTerm(state, reply.term), []];
|
|
158
|
-
}
|
|
159
|
-
if (reply.term < state.currentTerm) return [state, []];
|
|
160
|
-
if (state.role._tag !== "candidate") return [state, []];
|
|
161
|
-
if (!reply.voteGranted) return [state, []];
|
|
162
|
-
if (state.role.votesGranted.includes(reply.from)) return [state, []];
|
|
163
|
-
const votesGranted = [...state.role.votesGranted, reply.from];
|
|
164
|
-
const tallied = {
|
|
165
|
-
...state,
|
|
166
|
-
role: { _tag: "candidate", votesGranted }
|
|
167
|
-
};
|
|
168
|
-
if (votesGranted.length >= quorum) return becomeLeader(tallied);
|
|
169
|
-
return [tallied, []];
|
|
170
|
-
}
|
|
171
|
-
function onAppendEntries(state, req) {
|
|
172
|
-
if (req.term < state.currentTerm) {
|
|
173
|
-
return [state, [appendReply(req.leaderId, state.currentTerm, false, 0)]];
|
|
174
|
-
}
|
|
175
|
-
const adopted = adoptHigherTerm(state, req.term);
|
|
176
|
-
const base = adopted.role._tag === "follower" ? adopted : { ...adopted, role: { _tag: "follower" } };
|
|
177
|
-
const prevOk = req.prevLogIndex === 0 || base.log[req.prevLogIndex - 1]?.term === req.prevLogTerm;
|
|
178
|
-
if (!prevOk) {
|
|
179
|
-
return [base, [appendReply(req.leaderId, base.currentTerm, false, 0)]];
|
|
180
|
-
}
|
|
181
|
-
const kept = base.log.slice(0, req.prevLogIndex);
|
|
182
|
-
const nextLog = [...kept, ...req.entries];
|
|
183
|
-
const lastNew = req.prevLogIndex + req.entries.length;
|
|
184
|
-
const commitIndex = req.leaderCommit > base.commitIndex ? Math.min(req.leaderCommit, lastNew) : base.commitIndex;
|
|
185
|
-
const next = { ...base, log: nextLog, commitIndex };
|
|
186
|
-
return [next, [appendReply(req.leaderId, base.currentTerm, true, lastNew)]];
|
|
187
|
-
}
|
|
188
|
-
function onAppendEntriesReply(state, reply) {
|
|
189
|
-
if (reply.term > state.currentTerm) {
|
|
190
|
-
return [adoptHigherTerm(state, reply.term), []];
|
|
191
|
-
}
|
|
192
|
-
if (state.role._tag !== "leader") return [state, []];
|
|
193
|
-
if (reply.term < state.currentTerm) return [state, []];
|
|
194
|
-
const { nextIndex, matchIndex } = state.role;
|
|
195
|
-
if (!reply.success) {
|
|
196
|
-
const backed = Math.max(1, (nextIndex[reply.from] ?? 1) - 1);
|
|
197
|
-
const retried = {
|
|
198
|
-
...state,
|
|
199
|
-
role: {
|
|
200
|
-
_tag: "leader",
|
|
201
|
-
nextIndex: { ...nextIndex, [reply.from]: backed },
|
|
202
|
-
matchIndex
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
return [retried, [appendEntriesFor(retried, reply.from, backed)]];
|
|
206
|
-
}
|
|
207
|
-
const newMatch = Math.max(matchIndex[reply.from] ?? 0, reply.matchIndex);
|
|
208
|
-
const advanced = {
|
|
209
|
-
...state,
|
|
210
|
-
role: {
|
|
211
|
-
_tag: "leader",
|
|
212
|
-
nextIndex: { ...nextIndex, [reply.from]: newMatch + 1 },
|
|
213
|
-
matchIndex: { ...matchIndex, [reply.from]: newMatch }
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
const commitIndex = advanceCommitIndex(advanced);
|
|
217
|
-
return [{ ...advanced, commitIndex }, []];
|
|
218
|
-
}
|
|
219
|
-
function onClientCommand(state, msg) {
|
|
220
|
-
if (state.role._tag !== "leader") return [state, []];
|
|
221
|
-
const entry = {
|
|
222
|
-
term: state.currentTerm,
|
|
223
|
-
index: lastIndex(state) + 1,
|
|
224
|
-
command: msg.command
|
|
225
|
-
};
|
|
226
|
-
const appended = { ...state, log: [...state.log, entry] };
|
|
227
|
-
return [appended, replicateAll(appended)];
|
|
228
|
-
}
|
|
229
|
-
function onHeartbeat(state, _at) {
|
|
230
|
-
if (state.role._tag !== "leader") return [state, []];
|
|
231
|
-
return [state, replicateAll(state)];
|
|
232
|
-
}
|
|
233
|
-
function subs(state, now) {
|
|
234
|
-
if (state.role._tag === "leader") {
|
|
235
|
-
return [deadlineSub(heartbeatSubId(self), now + heartbeatMs)];
|
|
236
|
-
}
|
|
237
|
-
const span = electionTimeout.maxMs - electionTimeout.minMs;
|
|
238
|
-
const timeout = electionTimeout.minMs + Math.floor(rng() * (span + 1));
|
|
239
|
-
return [deadlineSub(electionSubId(self), now + timeout)];
|
|
240
|
-
}
|
|
241
|
-
function voteReply(to, term, voteGranted) {
|
|
242
|
-
return { type: "raft:send_request_vote_reply", to, term, voteGranted };
|
|
243
|
-
}
|
|
244
|
-
function appendReply(to, term, success, matchIndex) {
|
|
245
|
-
return {
|
|
246
|
-
type: "raft:send_append_entries_reply",
|
|
247
|
-
to,
|
|
248
|
-
term,
|
|
249
|
-
success,
|
|
250
|
-
matchIndex
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
return {
|
|
254
|
-
init,
|
|
255
|
-
onElectionTimeout,
|
|
256
|
-
onRequestVote,
|
|
257
|
-
onRequestVoteReply,
|
|
258
|
-
onAppendEntries,
|
|
259
|
-
onAppendEntriesReply,
|
|
260
|
-
onClientCommand,
|
|
261
|
-
onHeartbeat,
|
|
262
|
-
subs
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export { createRaftNode, electionSubId, heartbeatSubId, logIsUpToDate, toRaftMsg };
|
|
267
|
-
//# sourceMappingURL=chunk-6RGBKODV.js.map
|
|
268
|
-
//# sourceMappingURL=chunk-6RGBKODV.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/raft/index.ts"],"names":[],"mappings":"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","file":"chunk-6RGBKODV.js","sourcesContent":["/**\n * @demlik/tea/raft — Raft (Figure 2) as a pure TEA reducer. Slices #119 (the\n * election half) + #120 (the log-replication half) of the consensus epic\n * (#117): the role state machine (`follower → candidate → leader`), election +\n * heartbeat timers as `DeadlineSub`s, `RequestVote` request/reply handling, and\n * — added in #120 — the real replicated log with the Figure-2 `AppendEntries`\n * consistency check, leader `nextIndex`/`matchIndex` bookkeeping, and\n * `commitIndex` advancement under the Figure-8 current-term safety rule.\n * Persistence/cold-wake (#122) and the safety-property suite + multi-node\n * harness (#121/#123) are LATER siblings — out of scope here.\n *\n * The Raft paper (\"In Search of an Understandable Consensus Algorithm\",\n * raft.github.io) maps onto TEA almost 1:1, and this module is the proof of\n * that mapping:\n *\n * - **Reducer** — the Raft transition function over `RaftState`. PURE: it\n * reads no ambient clock and no ambient RNG. Wall time arrives as the `at`\n * parameter every verb takes (the firing Msg carries it, same discipline\n * as `../poller`); election-timeout jitter comes from the `rng` injected\n * ONCE at `createRaftNode` (default `Math.random` at the effect boundary, a\n * fixed value in tests). No verb body names the global RNG or `Date.now`,\n * so a node driven by a recorded `(timer × message)` schedule re-decides\n * bit-for-bit — the headline replay-testing payoff of the epic.\n * - **Sub** — the election-timeout and heartbeat timers are `DeadlineSub`s\n * (`../deadline`, the same shape `../poller` / the agent layer arm). A\n * fired timer is a `Msg` the consumer routes back into a verb; the verb\n * never owns a `setTimeout`.\n * - **Cmd** — the RPCs (`RequestVote` request + reply, `AppendEntries`\n * request + reply) are `Cmd`s routed through the consumer's `interpret`\n * over an injected transport port. This module emits the Cmd literals; it\n * performs NO networking. Tests assert against the emitted Cmds and fake\n * the transport.\n *\n * Invalid states are unrepresentable: the leader-only volatile state\n * (`nextIndex` / `matchIndex`) lives ONLY on the `leader` variant of the role\n * union, so a follower or candidate cannot carry — or be asked for — replication\n * bookkeeping it has no business holding (#119 acceptance criterion).\n *\n * **Persistence seam (#120 → #122).** The replicated log *is* the event log\n * (epic Approach). The verbs here keep `log` a pure value computed by folding —\n * a client command or a follower-side `AppendEntries` append/truncate is a\n * deterministic function of `(state, msg)`, with no clock/RNG and no mutation.\n * That is exactly the port the event-sourced store under `../do`\n * (`doEventSourcedStore`) folds over: its `replay(machine, { loaded, msgs })`\n * re-runs this same reducer to rebuild `log` byte-identically. #120 therefore\n * routes the log through the pure-reducer seam and defers the *effectful* host\n * wiring (async persist-before-respond, snapshot retention, cold-wake replay)\n * to #122 — see {@link RaftState.log} and {@link RaftNode.onClientCommand}.\n *\n * Generic over the command payload `C` (`unknown` by default): a `LogEntry`\n * carries an opaque `command` the consumer's state machine interprets on\n * commit. Raft itself never inspects it.\n *\n * NOT a substrate primitive: it depends only on sibling subpaths (`../deadline`)\n * and the core `Cmd` / `Sub` types. Consumers reach it via the\n * `@demlik/tea/raft` subpath.\n */\n\nimport { type DeadlineSub, deadlineSub } from \"../deadline\";\nimport { type Cmd, type Sub, subId } from \"../index\";\n\n// ===========================================================================\n// Identity & config\n// ===========================================================================\n\n/**\n * A Raft node identity. Opaque string; the cluster's `peers` set lists every\n * OTHER node (self excluded). Branded-by-convention only — kept a plain string\n * so transport adapters can use it as an address key without unwrapping.\n */\nexport type NodeId = string;\n\n/**\n * A Raft term — a monotonically increasing logical clock. Starts at 0 (no\n * leader elected yet) and bumps by one each time a node starts an election.\n */\nexport type Term = number;\n\n/**\n * The election-timeout window, in milliseconds. Raft randomizes each node's\n * timeout uniformly in `[minMs, maxMs]` so split votes resolve quickly — the\n * single source of liveness in leader election. The concrete duration is drawn\n * from the injected `rng` (never `Math.random` ambiently), so a fixed `rng`\n * pins the timeout and the run replays identically.\n */\nexport interface ElectionTimeout {\n /** Lower bound of the randomized election timeout, inclusive. */\n readonly minMs: number;\n /** Upper bound of the randomized election timeout, inclusive. */\n readonly maxMs: number;\n}\n\n/**\n * Static configuration for one Raft node, injected at construction. Pure data:\n * no node reads its own id or peer set from any ambient source.\n */\nexport interface RaftConfig {\n /** This node's identity. */\n readonly self: NodeId;\n /**\n * Every OTHER node in the cluster (self excluded). Cluster size is\n * `peers.length + 1`; a majority is `floor((peers.length + 1) / 2) + 1`.\n */\n readonly peers: readonly NodeId[];\n /** The randomized election-timeout window (see {@link ElectionTimeout}). */\n readonly electionTimeout: ElectionTimeout;\n /**\n * The leader's heartbeat interval, in milliseconds. A leader re-arms an empty\n * `AppendEntries` to every peer this often; it MUST be comfortably below\n * `electionTimeout.minMs` so followers never time out under a live leader.\n */\n readonly heartbeatMs: number;\n}\n\n// ===========================================================================\n// Log entry — the real { term, index, command } (#120)\n// ===========================================================================\n\n/**\n * One entry in the replicated log (Figure 2). Generic over the command payload\n * `C` (`unknown` by default): Raft itself never inspects `command` — it\n * replicates and orders the opaque blob, and the consumer's state machine\n * applies it once the entry commits.\n *\n * `index` is 1-based (Raft convention: index 0 means \"before the log\"; the\n * first real entry is index 1), so an empty log has `lastLogIndex` 0. It is\n * stored on the entry rather than derived from array position so the\n * consistency check and the leader's per-peer bookkeeping read it directly,\n * and so a persisted/replayed log carries its own indices.\n */\nexport interface LogEntry<C = unknown> {\n /** The term in which the leader created this entry. */\n readonly term: Term;\n /** This entry's 1-based position in the log. */\n readonly index: number;\n /** The opaque command this entry replicates; applied by the consumer on commit. */\n readonly command: C;\n}\n\n// ===========================================================================\n// Role union — invalid states unrepresentable\n// ===========================================================================\n\n/**\n * The role discriminated union. `_tag` is the discriminant (engine convention).\n *\n * The crux of \"make invalid states unrepresentable\": the leader-only volatile\n * state — `nextIndex` / `matchIndex`, the per-peer replication bookkeeping —\n * exists ONLY on the `leader` variant. A follower or candidate value has no\n * such field to read or mis-set; the type makes \"a follower with a matchIndex\n * map\" unspellable rather than merely discouraged (#119 acceptance criterion).\n *\n * `candidate` carries the per-election vote tally (`votesGranted`, including\n * self) so the reducer can decide majority without re-deriving it.\n */\nexport type Role =\n | { readonly _tag: \"follower\" }\n | {\n readonly _tag: \"candidate\";\n /**\n * The set of nodes (self + peers) that have granted this candidate a vote\n * THIS term. A `Set`-by-id semantic kept as a readonly array of unique\n * ids; the reducer dedups on insert so a duplicate reply never\n * double-counts toward the majority.\n */\n readonly votesGranted: readonly NodeId[];\n }\n | {\n readonly _tag: \"leader\";\n /**\n * For each peer, the index of the next log entry to send (Figure 2,\n * leader volatile state). Initialized to the leader's last-log index + 1\n * on election; #120 advances it on `AppendEntries`. Keyed by `NodeId`.\n */\n readonly nextIndex: Readonly<Record<NodeId, number>>;\n /**\n * For each peer, the highest log index known to be replicated (Figure 2,\n * leader volatile state). Initialized to 0; #120 advances it. Keyed by\n * `NodeId`.\n */\n readonly matchIndex: Readonly<Record<NodeId, number>>;\n };\n\n// ===========================================================================\n// RaftState — persistent + volatile, role-discriminated\n// ===========================================================================\n\n/**\n * The whole state of one Raft node, generic over the command payload `C`.\n * Persistent state (`currentTerm`, `votedFor`, `log`) survives crashes in a\n * real deployment (#122); volatile state (`commitIndex`, the leader's\n * `nextIndex`/`matchIndex`) is rebuilt on restart.\n */\nexport interface RaftState<C = unknown> {\n /**\n * Latest term this node has seen (Figure 2, persistent). Init 0; increases\n * monotonically — every higher-term message observed bumps it (and steps the\n * node down to follower).\n */\n readonly currentTerm: Term;\n /**\n * The candidate this node voted for in `currentTerm`, or `null` if it has not\n * voted this term (Figure 2, persistent). Reset to `null` whenever\n * `currentTerm` advances.\n */\n readonly votedFor: NodeId | null;\n /**\n * The replicated log (Figure 2, persistent). 1-based: `log[i]` has\n * `index === i + 1`; `lastLogIndex` is `log.length`. The log *is* the event\n * log (epic Approach): every mutation here — a leader appending a client\n * command, a follower truncating-and-appending on `AppendEntries` — is a pure\n * function of `(state, msg)`, so the event-sourced store under `../do` rebuilds\n * it by folding this same reducer over the recorded Msgs (#122 wires the host;\n * see the module header's persistence-seam note).\n */\n readonly log: readonly LogEntry<C>[];\n /**\n * Highest log index known to be committed (Figure 2, volatile). Init 0. A\n * follower advances it to `min(leaderCommit, lastNewEntryIndex)` on a matching\n * `AppendEntries`; a leader advances it under the Figure-8 current-term\n * majority rule (see {@link RaftNode.onAppendEntriesReply}). Entries up to and\n * including `commitIndex` are durably agreed and safe for the consumer to apply.\n */\n readonly commitIndex: number;\n /** The current role + its role-specific state (see {@link Role}). */\n readonly role: Role;\n}\n\n// ===========================================================================\n// Messages — fired timers + inbound RPCs, the verbs' inputs\n// ===========================================================================\n\n/**\n * Tagged wire shapes the consumer unions into its machine's Msg type and routes\n * into the matching verb. `_tag` is the discriminant. Each carries the sender's\n * `term` (Figure 2: every RPC carries the sender's `currentTerm`) so the\n * \"rules for all servers\" higher-term step-down applies uniformly.\n */\n\n/** Inbound `RequestVote` RPC — a candidate asking this node for its vote. */\nexport interface RequestVoteRequest {\n readonly _tag: \"request_vote_request\";\n /** The candidate's term. */\n readonly term: Term;\n /** The candidate requesting the vote. */\n readonly candidateId: NodeId;\n /**\n * Index of the candidate's last log entry (Figure 2). Read by the up-to-date\n * check — STUBBED in #119 (see {@link logIsUpToDate}); real comparison in #120.\n */\n readonly lastLogIndex: number;\n /** Term of the candidate's last log entry (Figure 2). Stub-read in #119. */\n readonly lastLogTerm: Term;\n}\n\n/** Inbound `RequestVote` reply — a peer answering this node's vote request. */\nexport interface RequestVoteReply {\n readonly _tag: \"request_vote_reply\";\n /** The replier's `currentTerm`, for the candidate to update itself. */\n readonly term: Term;\n /** The peer that replied (so the candidate can dedup its tally). */\n readonly from: NodeId;\n /** Whether the peer granted its vote. */\n readonly voteGranted: boolean;\n}\n\n/**\n * Inbound `AppendEntries` RPC (Figure 2). An empty `entries` array is a\n * heartbeat; a non-empty one replicates log entries. The `prevLog*` pair anchors\n * the consistency check, and `leaderCommit` carries the leader's commit index so\n * the follower can advance its own.\n */\nexport interface AppendEntriesRequest<C = unknown> {\n readonly _tag: \"append_entries_request\";\n /** The leader's term. */\n readonly term: Term;\n /** The leader sending this (so a follower can track the leader). */\n readonly leaderId: NodeId;\n /**\n * Index of the log entry immediately preceding `entries` (0 if `entries`\n * starts at the head of the log). The follower must already hold an entry at\n * this index whose term is `prevLogTerm`, or it rejects (consistency check).\n */\n readonly prevLogIndex: number;\n /** Term of the entry at `prevLogIndex` (0 when `prevLogIndex` is 0). */\n readonly prevLogTerm: Term;\n /** The new entries to store (empty for a heartbeat). Already index-stamped. */\n readonly entries: readonly LogEntry<C>[];\n /** The leader's `commitIndex`, so the follower can advance its own. */\n readonly leaderCommit: number;\n}\n\n/**\n * Inbound `AppendEntries` reply — a follower answering this leader's\n * replication RPC (Figure 2 reply shape). `matchIndex` is the index of the last\n * entry the follower now agrees on (0 if none), used by the leader to advance\n * `matchIndex`/`nextIndex` on success without re-deriving it.\n */\nexport interface AppendEntriesReply {\n readonly _tag: \"append_entries_reply\";\n /** The replier's `currentTerm`, for the leader to update itself / step down. */\n readonly term: Term;\n /** The follower that replied (so the leader can update its per-peer bookkeeping). */\n readonly from: NodeId;\n /** Whether the consistency check passed and the entries were stored. */\n readonly success: boolean;\n /**\n * On success, the index of the follower's last entry covered by this RPC\n * (`prevLogIndex + entries.length`) — the leader's new `matchIndex` for this\n * peer. Meaningless (0) on rejection.\n */\n readonly matchIndex: number;\n}\n\n/**\n * A client command submitted to this node (Figure 2 §5.3: clients send commands\n * to the leader). The leader appends it to its log and replicates; a non-leader\n * rejects it (the consumer would redirect the client to the known leader).\n */\nexport interface ClientCommand<C = unknown> {\n readonly _tag: \"client_command\";\n /** The opaque command to replicate (the consumer's state-machine input). */\n readonly command: C;\n}\n\n/**\n * The two fired-timer Msgs, dispatched by the `DeadlineSub`s this module arms.\n * The consumer maps a `deadline_exceeded` whose id matches the election /\n * heartbeat sub id onto these (`toRaftMsg` does the mapping), then routes them\n * into `onElectionTimeout` / `onHeartbeat`. Each carries the firing `at` (the\n * deadline's `atMs`) so the verb re-arms the next absolute target without\n * reading the clock.\n */\nexport interface ElectionTimeoutFired {\n readonly _tag: \"election_timeout_fired\";\n /** Absolute wall-clock ms the timer fired at (the deadline's target). */\n readonly at: number;\n}\nexport interface HeartbeatFired {\n readonly _tag: \"heartbeat_fired\";\n /** Absolute wall-clock ms the timer fired at (the deadline's target). */\n readonly at: number;\n}\n\n/** The full inbound-Msg union a Raft node's `update` handles. */\nexport type RaftMsg<C = unknown> =\n | RequestVoteRequest\n | RequestVoteReply\n | AppendEntriesRequest<C>\n | AppendEntriesReply\n | ClientCommand<C>\n | ElectionTimeoutFired\n | HeartbeatFired;\n\n// ===========================================================================\n// Cmds — outbound RPCs routed through the injected transport port\n// ===========================================================================\n\n/**\n * Outbound RPCs as `Cmd`s. The consumer's `interpret` performs each over the\n * injected transport port (a fetch binding, an in-memory bus in tests); this\n * module only emits the literals. `to` addresses a peer; a reply addresses the\n * original caller.\n */\n\n/** Send a `RequestVote` request to one peer (fanned out, one Cmd per peer). */\nexport interface SendRequestVote extends Cmd<\"raft:send_request_vote\"> {\n /** The peer to ask. */\n readonly to: NodeId;\n /** This candidate's term. */\n readonly term: Term;\n /** This candidate's id. */\n readonly candidateId: NodeId;\n /** This candidate's last-log index (stub payload until #120). */\n readonly lastLogIndex: number;\n /** This candidate's last-log term (stub payload until #120). */\n readonly lastLogTerm: Term;\n}\n\n/** Reply to a `RequestVote` request the original candidate awaits. */\nexport interface SendRequestVoteReply\n extends Cmd<\"raft:send_request_vote_reply\"> {\n /** The candidate that asked. */\n readonly to: NodeId;\n /** This node's `currentTerm`. */\n readonly term: Term;\n /** Whether this node granted its vote. */\n readonly voteGranted: boolean;\n}\n\n/**\n * Send an `AppendEntries` to one peer. Empty `entries` is a heartbeat; a\n * non-empty one replicates from the leader's `nextIndex` for that peer. Carries\n * the full Figure-2 payload so the follower can run its consistency check.\n */\nexport interface SendAppendEntries<C = unknown>\n extends Cmd<\"raft:send_append_entries\"> {\n /** The peer to send to. */\n readonly to: NodeId;\n /** The leader's term. */\n readonly term: Term;\n /** The leader's id. */\n readonly leaderId: NodeId;\n /** Index of the entry preceding `entries` (0 at the head of the log). */\n readonly prevLogIndex: number;\n /** Term of the entry at `prevLogIndex` (0 when `prevLogIndex` is 0). */\n readonly prevLogTerm: Term;\n /** The entries to replicate (empty for a heartbeat). */\n readonly entries: readonly LogEntry<C>[];\n /** The leader's `commitIndex`. */\n readonly leaderCommit: number;\n}\n\n/** Reply to an `AppendEntries` the original leader awaits. */\nexport interface SendAppendEntriesReply\n extends Cmd<\"raft:send_append_entries_reply\"> {\n /** The leader that asked. */\n readonly to: NodeId;\n /** This node's `currentTerm`. */\n readonly term: Term;\n /** Whether the consistency check passed and the entries were stored. */\n readonly success: boolean;\n /** On success, this node's new last-agreed index (the leader's `matchIndex`). */\n readonly matchIndex: number;\n}\n\n/** The full outbound-Cmd union a Raft node emits. */\nexport type RaftCmd<C = unknown> =\n | SendRequestVote\n | SendRequestVoteReply\n | SendAppendEntries<C>\n | SendAppendEntriesReply;\n\n// ===========================================================================\n// Subs — election + heartbeat DeadlineSubs\n// ===========================================================================\n\n/** Stable sub id for this node's election-timeout deadline. */\nfunction electionSubId(self: NodeId): string {\n return `raft:election:${self}`;\n}\n/** Stable sub id for this node's heartbeat deadline. */\nfunction heartbeatSubId(self: NodeId): string {\n return `raft:heartbeat:${self}`;\n}\n\n/**\n * Map a fired `deadline_exceeded` (from `../deadline`'s subscribe cell) onto the\n * Raft timer Msg it represents, by matching the sub id. Returns `null` if the\n * id belongs to neither Raft timer — so a consumer wiring several deadline\n * families can route ours without a brittle string-prefix check at the call\n * site. The deadline's `atMs` becomes the Msg's `at` (no clock read).\n */\nexport function toRaftMsg(\n self: NodeId,\n fired: { readonly id: string; readonly atMs: number },\n): ElectionTimeoutFired | HeartbeatFired | null {\n if (fired.id === electionSubId(self)) {\n return { _tag: \"election_timeout_fired\", at: fired.atMs };\n }\n if (fired.id === heartbeatSubId(self)) {\n return { _tag: \"heartbeat_fired\", at: fired.atMs };\n }\n return null;\n}\n\n// ===========================================================================\n// The node handle — pure verbs + subs, rng injected once\n// ===========================================================================\n\n/**\n * Result tuple every verb returns: the next state and the Cmds to emit. Matches\n * the `(state, cmds)` shape the substrate's reducer cells return, so a consumer\n * threads these straight through their `update`.\n */\nexport type RaftStep<C = unknown> = readonly [\n RaftState<C>,\n readonly RaftCmd<C>[],\n];\n\n/**\n * The node handle returned by {@link createRaftNode}. Spread its hooks into a\n * machine: `init()` seeds a fresh follower, the verbs (`onElectionTimeout`,\n * `onRequestVote`, `onRequestVoteReply`, `onAppendEntries`, `onHeartbeat`) are\n * the reducer cells, and `subs(state, now)` returns the live `DeadlineSub`s.\n *\n * Every verb is PURE given the injected `rng`: no clock read (time arrives as\n * `at`), no ambient randomness, no mutation, no throw.\n */\nexport interface RaftNode<C = unknown> {\n /** Seed a fresh node: term 0, no vote, empty log, follower. */\n init(): RaftState<C>;\n\n /**\n * The election timeout fired without a heartbeat / granted vote. Convert\n * follower OR candidate → candidate and start a NEW election: bump the term,\n * vote for self, reset the tally to `{self}`, and emit `RequestVote` to every\n * peer (carrying the real last-log index/term). A leader ignores its own\n * election timeout (it has a live mandate).\n *\n * `at` is the firing wall-clock (carried by the Msg) — used only so the\n * caller can re-arm the next election deadline at `at + <jittered timeout>`\n * via `subs`; the verb itself reads no clock.\n *\n * A single-node cluster (`peers` empty) wins the election immediately:\n * one vote (self) is already a majority, so the node converts straight to\n * leader and emits the first heartbeat round (empty here — no peers).\n */\n onElectionTimeout(state: RaftState<C>, at: number): RaftStep<C>;\n\n /**\n * Handle an inbound `RequestVote` request (Figure 2 receiver rule).\n *\n * - Rules for all servers: if `req.term > currentTerm`, adopt the term, clear\n * `votedFor`, and step down to follower BEFORE evaluating the grant.\n * - Reply `false` if `req.term < currentTerm` (stale candidate).\n * - Otherwise grant iff (`votedFor` is null OR already this candidate) AND the\n * candidate's log is at least as up-to-date ({@link logIsUpToDate} — the\n * real last-log term/index comparison, #120). Granting sets\n * `votedFor = candidateId`.\n *\n * Granting a vote (or stepping down) counts as \"heard from a valid leader /\n * candidate\", so the consumer re-arms the election timer off the resulting\n * state (`subs`).\n */\n onRequestVote(state: RaftState<C>, req: RequestVoteRequest): RaftStep<C>;\n\n /**\n * Handle an inbound `RequestVote` reply (the candidate tallying votes).\n *\n * - Rules for all servers: a higher-term reply steps the node down to follower\n * (it lost the election implicitly).\n * - A stale-term reply (`reply.term < currentTerm`) is dropped.\n * - A grant from a peer not yet counted adds to the tally; on reaching a\n * majority the candidate converts to leader (initializing `nextIndex` /\n * `matchIndex`) and emits the first `AppendEntries` round to every peer.\n * - A reply that arrives when the node is no longer a candidate (already\n * leader / stepped down) is a no-op.\n */\n onRequestVoteReply(state: RaftState<C>, reply: RequestVoteReply): RaftStep<C>;\n\n /**\n * Handle an inbound `AppendEntries` — heartbeat or replication (Figure 2\n * receiver rules). Always emits an `AppendEntries` reply Cmd unless the term\n * is stale-and-dropped without a state change.\n *\n * - Reply-side rule: `req.term < currentTerm` is rejected — a stale leader;\n * the node holds its role and replies `success: false`.\n * - `req.term >= currentTerm`: a valid leader for this term. Adopt the term if\n * higher and revert candidate → follower (a candidate that hears from a\n * leader of term ≥ its own steps down). Then run the **consistency check**:\n * if the follower's log has no entry at `prevLogIndex` with term\n * `prevLogTerm`, reply `success: false` (the leader backs off `nextIndex`\n * and retries). On a match, delete any conflicting suffix and append the new\n * `entries`, then advance `commitIndex` to\n * `min(leaderCommit, lastNewEntryIndex)`, and reply `success: true` with the\n * follower's new last-agreed index as `matchIndex`.\n *\n * Hearing a valid `AppendEntries` resets the election timer (the consumer\n * re-arms off the resulting state).\n */\n onAppendEntries(\n state: RaftState<C>,\n req: AppendEntriesRequest<C>,\n ): RaftStep<C>;\n\n /**\n * Handle an inbound `AppendEntries` reply (the leader updating its per-peer\n * bookkeeping, Figure 2 leader rules).\n *\n * - Rules for all servers: a higher-term reply steps the leader down to\n * follower.\n * - A reply received when no longer leader, or a stale-term reply, is a no-op.\n * - On `success`: set `matchIndex[from]` to the reply's `matchIndex` and\n * `nextIndex[from]` to `matchIndex + 1`, then advance `commitIndex` under the\n * **Figure-8 safety rule** — to the highest N such that N is on a majority of\n * `matchIndex`es AND `log[N].term === currentTerm` (a leader NEVER commits a\n * prior-term entry by counting replicas; it commits it only transitively once\n * a current-term entry above it commits).\n * - On rejection: decrement `nextIndex[from]` (floored at 1) and re-emit an\n * `AppendEntries` to that peer from the backed-off index (the retry).\n */\n onAppendEntriesReply(\n state: RaftState<C>,\n reply: AppendEntriesReply,\n ): RaftStep<C>;\n\n /**\n * A client submitted a command. On the leader: append a new entry\n * `{ term: currentTerm, index: lastLogIndex + 1, command }` to the log and\n * emit an `AppendEntries` round replicating it to every peer. On a non-leader:\n * a no-op (no append, no Cmds) — the consumer redirects the client to the\n * leader. This is the entry point that grows the replicated/event log.\n */\n onClientCommand(state: RaftState<C>, msg: ClientCommand<C>): RaftStep<C>;\n\n /**\n * The leader's heartbeat timer fired: emit an `AppendEntries` to every peer\n * (entries beyond that peer's `nextIndex`, empty when the peer is caught up).\n * A no-op (no Cmds) on a non-leader — a stale heartbeat fire racing a\n * step-down the reconcile pass has not yet retired.\n */\n onHeartbeat(state: RaftState<C>, at: number): RaftStep<C>;\n\n /**\n * The live `DeadlineSub`s for `state`, computed against the current clock\n * `now` (the ONLY clock read — at subscribe time, never in a verb; same seam\n * as `../poller`'s `subs`). The election-timeout target is `now + <jittered\n * timeout>` drawn from the injected `rng`; the heartbeat target is\n * `now + heartbeatMs`.\n *\n * - follower / candidate → arm ONLY the election timeout.\n * - leader → arm ONLY the heartbeat (a leader does not time itself out).\n */\n subs(state: RaftState<C>, now: number): readonly DeadlineSub[];\n}\n\n/**\n * The candidate \"log at least as up-to-date\" check (Figure 2 receiver rule,\n * §5.4.1 election restriction). Compares the candidate's last-log\n * (term, index) against ours: term dominates, index breaks a tie.\n *\n * Up-to-date iff the candidate's last-log term is GREATER than ours, OR the\n * terms are EQUAL and the candidate's last-log index is ≥ ours. (Our own\n * last-log term/index are read from the tail of `log`; an empty log is\n * (term 0, index 0), so any candidate is at least as up-to-date as an empty\n * log.) This is the property that keeps a leader's log a superset of every\n * committed entry — a node missing a committed entry can never gather a\n * majority, so it cannot win.\n */\nexport function logIsUpToDate(\n log: readonly LogEntry[],\n candidateLastLogIndex: number,\n candidateLastLogTerm: Term,\n): boolean {\n const last = log.at(-1);\n const ourLastTerm = last?.term ?? 0;\n const ourLastIndex = last?.index ?? 0;\n if (candidateLastLogTerm !== ourLastTerm) {\n return candidateLastLogTerm > ourLastTerm;\n }\n return candidateLastLogIndex >= ourLastIndex;\n}\n\n/**\n * The number of votes that constitutes a majority of the cluster. Cluster size\n * is `peers + self`; a strict majority is `floor(size / 2) + 1`. A single-node\n * cluster (size 1) needs 1 vote — its own.\n */\nfunction majority(peerCount: number): number {\n const clusterSize = peerCount + 1;\n return Math.floor(clusterSize / 2) + 1;\n}\n\n/**\n * Build a Raft node from `config`. Returns the hook bag to spread into a\n * machine. Pure factory — no clock read, no timer; everything happens when the\n * consumer calls the verbs / `subs` with an injected `at` / `now`.\n *\n * `rng` is injected ONCE here (default `Math.random` at the effect boundary, a\n * fixed value in tests) and read ONLY by `subs` to jitter the election timeout.\n * No verb body names the global RNG, so a node driven by a recorded schedule\n * replays bit-for-bit (the epic's headline payoff).\n */\nexport function createRaftNode<C = unknown>(\n config: RaftConfig,\n rng: () => number = Math.random,\n): RaftNode<C> {\n const { self, peers, electionTimeout, heartbeatMs } = config;\n const quorum = majority(peers.length);\n\n /** This node's last-log index (1-based; 0 for an empty log). */\n function lastIndex(state: RaftState<C>): number {\n return state.log.at(-1)?.index ?? 0;\n }\n /** Term of this node's last log entry (0 for an empty log). */\n function lastTerm(state: RaftState<C>): number {\n return state.log.at(-1)?.term ?? 0;\n }\n\n /**\n * Adopt a strictly-higher term: bump `currentTerm`, clear `votedFor`, and\n * step down to follower (Figure 2 \"rules for all servers\"). Pure; the caller\n * folds the rest of its decision on top of the returned state. Returns the\n * input unchanged when `term` is not higher (so callers can apply it\n * unconditionally before evaluating their own rule).\n */\n function adoptHigherTerm(state: RaftState<C>, term: Term): RaftState<C> {\n if (term <= state.currentTerm) return state;\n return {\n ...state,\n currentTerm: term,\n votedFor: null,\n role: { _tag: \"follower\" },\n };\n }\n\n /**\n * Convert to candidate and start a fresh election. Bumps the term, votes for\n * self, resets the tally to `{self}`, and emits `RequestVote` to every peer\n * carrying the real last-log index/term (the §5.4.1 election restriction the\n * receiver enforces via {@link logIsUpToDate}). Shared by `onElectionTimeout`\n * for both follower→candidate and candidate→candidate.\n */\n function startElection(state: RaftState<C>): RaftStep<C> {\n const newTerm = state.currentTerm + 1;\n const lastLogIndex = lastIndex(state);\n const lastLogTerm = lastTerm(state);\n\n const next: RaftState<C> = {\n ...state,\n currentTerm: newTerm,\n votedFor: self,\n role: { _tag: \"candidate\", votesGranted: [self] },\n };\n\n const requests: RaftCmd<C>[] = peers.map((peer) => ({\n type: \"raft:send_request_vote\",\n to: peer,\n term: newTerm,\n candidateId: self,\n lastLogIndex,\n lastLogTerm,\n }));\n\n // A single-node cluster reaches majority with its own vote alone — convert\n // straight to leader and emit the (empty, no-peer) first heartbeat round.\n if (\n next.role._tag === \"candidate\" &&\n next.role.votesGranted.length >= quorum\n ) {\n return becomeLeader(next);\n }\n\n return [next, requests];\n }\n\n /**\n * Convert a winning candidate to leader: initialize per-peer `nextIndex`\n * (leader's last-log index + 1) and `matchIndex` (0), then emit the first\n * `AppendEntries` round to every peer. Figure 2 leader-on-election rules.\n * `nextIndex` / `matchIndex` live only on the leader variant — built here,\n * nowhere else.\n */\n function becomeLeader(state: RaftState<C>): RaftStep<C> {\n const nextIdx = lastIndex(state) + 1;\n const nextIndex: Record<NodeId, number> = {};\n const matchIndex: Record<NodeId, number> = {};\n for (const peer of peers) {\n nextIndex[peer] = nextIdx;\n matchIndex[peer] = 0;\n }\n\n const leader: RaftState<C> = {\n ...state,\n role: { _tag: \"leader\", nextIndex, matchIndex },\n };\n\n return [leader, replicateAll(leader)];\n }\n\n /**\n * Build the `AppendEntries` Cmd for one peer from the leader's `nextIndex`\n * for it: `prevLogIndex` is `nextIndex - 1`, `entries` is everything from\n * `nextIndex` onward (empty when the peer is caught up → a heartbeat). Pure\n * read of the leader's log + per-peer cursor. Caller guarantees `state` is a\n * leader.\n */\n function appendEntriesFor(\n state: RaftState<C>,\n peer: NodeId,\n next: number,\n ): SendAppendEntries<C> {\n const prevLogIndex = next - 1;\n // log is 1-based and contiguous, so index i lives at log[i - 1]; `.at`\n // keeps the access total (0 term at the head / off the end).\n const prevLogTerm =\n prevLogIndex > 0 ? (state.log.at(prevLogIndex - 1)?.term ?? 0) : 0;\n const entries = state.log.slice(prevLogIndex);\n return {\n type: \"raft:send_append_entries\",\n to: peer,\n term: state.currentTerm,\n leaderId: self,\n prevLogIndex,\n prevLogTerm,\n entries,\n leaderCommit: state.commitIndex,\n };\n }\n\n /** The full `AppendEntries` fanout a leader sends (one Cmd per peer). */\n function replicateAll(state: RaftState<C>): RaftCmd<C>[] {\n if (state.role._tag !== \"leader\") return [];\n const { nextIndex } = state.role;\n // Every peer is a key of nextIndex (built in becomeLeader); `?? 1` keeps\n // the read total for the type-checker and floors a missing cursor at the\n // log head.\n return peers.map((peer) =>\n appendEntriesFor(state, peer, nextIndex[peer] ?? 1),\n );\n }\n\n /**\n * Advance a leader's `commitIndex` under the Figure-8 safety rule. Returns the\n * highest N > commitIndex such that (a) a MAJORITY of the cluster — counting\n * the leader's own log plus the peers whose `matchIndex >= N` — has the entry,\n * AND (b) `log[N].term === currentTerm`. Clause (b) is the subtle correctness\n * point: a leader must NOT commit an entry from a PRIOR term merely because it\n * sits on a majority of logs (Figure 8 shows such an entry can still be\n * overwritten). A prior-term entry commits only transitively, once a\n * current-term entry above it reaches a majority. Returns the unchanged\n * `commitIndex` when no such N exists.\n */\n function advanceCommitIndex(state: RaftState<C>): number {\n if (state.role._tag !== \"leader\") return state.commitIndex;\n const { matchIndex } = state.role;\n const lastLogIndex = lastIndex(state);\n let commit = state.commitIndex;\n // Walk candidate indices above the current commit; the highest qualifying N\n // wins (each higher N implies the lower ones, so we keep the max).\n for (let n = state.commitIndex + 1; n <= lastLogIndex; n++) {\n // Figure-8: only an entry from the current term is committable by count.\n if (state.log.at(n - 1)?.term !== state.currentTerm) continue;\n // The leader itself holds entry n (n <= lastLogIndex), so start the count\n // at 1, then add every peer that has replicated up to at least n.\n let replicas = 1;\n for (const peer of peers) {\n if ((matchIndex[peer] ?? 0) >= n) replicas++;\n }\n if (replicas >= quorum) commit = n;\n }\n return commit;\n }\n\n function init(): RaftState<C> {\n return {\n currentTerm: 0,\n votedFor: null,\n log: [],\n commitIndex: 0,\n role: { _tag: \"follower\" },\n };\n }\n\n function onElectionTimeout(state: RaftState<C>, _at: number): RaftStep<C> {\n // A leader has a live mandate; its own election timer is never armed (see\n // `subs`), but a stale fire racing a step-up must be a no-op regardless.\n if (state.role._tag === \"leader\") return [state, []];\n return startElection(state);\n }\n\n function onRequestVote(\n state: RaftState<C>,\n req: RequestVoteRequest,\n ): RaftStep<C> {\n // Rules for all servers: a higher term steps us down BEFORE we decide.\n const base = adoptHigherTerm(state, req.term);\n\n // Stale candidate — reject. Use `base.currentTerm` (already adopted) so the\n // reply carries our up-to-date term.\n if (req.term < base.currentTerm) {\n return [base, [voteReply(req.candidateId, base.currentTerm, false)]];\n }\n\n const free = base.votedFor === null || base.votedFor === req.candidateId;\n const upToDate = logIsUpToDate(base.log, req.lastLogIndex, req.lastLogTerm);\n const grant = free && upToDate;\n\n if (!grant) {\n return [base, [voteReply(req.candidateId, base.currentTerm, false)]];\n }\n\n // Granting records the vote (and resets the election timer via `subs`, off\n // the returned follower state).\n const granted: RaftState<C> = {\n ...base,\n votedFor: req.candidateId,\n role: { _tag: \"follower\" },\n };\n return [granted, [voteReply(req.candidateId, base.currentTerm, true)]];\n }\n\n function onRequestVoteReply(\n state: RaftState<C>,\n reply: RequestVoteReply,\n ): RaftStep<C> {\n // Rules for all servers: a higher-term reply steps us down (we lost).\n if (reply.term > state.currentTerm) {\n return [adoptHigherTerm(state, reply.term), []];\n }\n\n // Drop stale-term replies and replies for a role that is no longer\n // tallying (already leader / stepped down).\n if (reply.term < state.currentTerm) return [state, []];\n if (state.role._tag !== \"candidate\") return [state, []];\n if (!reply.voteGranted) return [state, []];\n\n // Dedup the granter so a duplicate reply never double-counts.\n if (state.role.votesGranted.includes(reply.from)) return [state, []];\n\n const votesGranted = [...state.role.votesGranted, reply.from];\n const tallied: RaftState<C> = {\n ...state,\n role: { _tag: \"candidate\", votesGranted },\n };\n\n if (votesGranted.length >= quorum) return becomeLeader(tallied);\n return [tallied, []];\n }\n\n function onAppendEntries(\n state: RaftState<C>,\n req: AppendEntriesRequest<C>,\n ): RaftStep<C> {\n // Stale leader — reject. Reply with our (higher) term so the sender steps\n // down; no state change.\n if (req.term < state.currentTerm) {\n return [state, [appendReply(req.leaderId, state.currentTerm, false, 0)]];\n }\n\n // Valid leader for term ≥ ours. Adopt a higher term (clears votedFor, steps\n // down); then ensure we are a follower for this term — a candidate that\n // hears from a leader of term ≥ its own reverts (Figure 2). Hearing a valid\n // AppendEntries resets the election timer (consumer re-arms off this state).\n const adopted = adoptHigherTerm(state, req.term);\n const base: RaftState<C> =\n adopted.role._tag === \"follower\"\n ? adopted\n : { ...adopted, role: { _tag: \"follower\" } };\n\n // Consistency check (Figure 2): the follower must hold an entry at\n // `prevLogIndex` whose term is `prevLogTerm` (prevLogIndex 0 always matches\n // — it anchors at the head). Otherwise reject; the leader backs off.\n const prevOk =\n req.prevLogIndex === 0 ||\n base.log[req.prevLogIndex - 1]?.term === req.prevLogTerm;\n if (!prevOk) {\n return [base, [appendReply(req.leaderId, base.currentTerm, false, 0)]];\n }\n\n // Match: keep the log up to prevLogIndex, then delete any conflicting suffix\n // and append the new entries. Splicing at prevLogIndex and concatenating is\n // the truncate-and-append in one step (an entry already present and\n // identical is simply overwritten with itself — idempotent on a retry).\n const kept = base.log.slice(0, req.prevLogIndex);\n const nextLog = [...kept, ...req.entries];\n const lastNew = req.prevLogIndex + req.entries.length;\n\n // Advance commitIndex to min(leaderCommit, index of last new entry). Never\n // run ahead of what this follower actually holds.\n const commitIndex =\n req.leaderCommit > base.commitIndex\n ? Math.min(req.leaderCommit, lastNew)\n : base.commitIndex;\n\n const next: RaftState<C> = { ...base, log: nextLog, commitIndex };\n return [next, [appendReply(req.leaderId, base.currentTerm, true, lastNew)]];\n }\n\n function onAppendEntriesReply(\n state: RaftState<C>,\n reply: AppendEntriesReply,\n ): RaftStep<C> {\n // Rules for all servers: a higher-term reply steps us down.\n if (reply.term > state.currentTerm) {\n return [adoptHigherTerm(state, reply.term), []];\n }\n // Only a leader of the matching term acts on replies; anything else is a\n // stale/misrouted reply and a no-op.\n if (state.role._tag !== \"leader\") return [state, []];\n if (reply.term < state.currentTerm) return [state, []];\n\n const { nextIndex, matchIndex } = state.role;\n\n if (!reply.success) {\n // Consistency check failed at the follower: back off nextIndex (floored\n // at 1) and retry from the lower bound. matchIndex is untouched.\n const backed = Math.max(1, (nextIndex[reply.from] ?? 1) - 1);\n const retried: RaftState<C> = {\n ...state,\n role: {\n _tag: \"leader\",\n nextIndex: { ...nextIndex, [reply.from]: backed },\n matchIndex,\n },\n };\n return [retried, [appendEntriesFor(retried, reply.from, backed)]];\n }\n\n // Success: this peer now agrees up to reply.matchIndex. Advance its cursors\n // monotonically (a stale/duplicated success must never pull them backward),\n // then re-evaluate the commit index under the Figure-8 rule.\n const newMatch = Math.max(matchIndex[reply.from] ?? 0, reply.matchIndex);\n const advanced: RaftState<C> = {\n ...state,\n role: {\n _tag: \"leader\",\n nextIndex: { ...nextIndex, [reply.from]: newMatch + 1 },\n matchIndex: { ...matchIndex, [reply.from]: newMatch },\n },\n };\n const commitIndex = advanceCommitIndex(advanced);\n return [{ ...advanced, commitIndex }, []];\n }\n\n function onClientCommand(\n state: RaftState<C>,\n msg: ClientCommand<C>,\n ): RaftStep<C> {\n // Only the leader accepts client commands; a non-leader rejects (the\n // consumer redirects to the known leader). No append, no Cmds.\n if (state.role._tag !== \"leader\") return [state, []];\n\n const entry: LogEntry<C> = {\n term: state.currentTerm,\n index: lastIndex(state) + 1,\n command: msg.command,\n };\n const appended: RaftState<C> = { ...state, log: [...state.log, entry] };\n return [appended, replicateAll(appended)];\n }\n\n function onHeartbeat(state: RaftState<C>, _at: number): RaftStep<C> {\n // Only a leader heartbeats. A stale fire racing a step-down is a no-op.\n if (state.role._tag !== \"leader\") return [state, []];\n return [state, replicateAll(state)];\n }\n\n function subs(state: RaftState<C>, now: number): readonly DeadlineSub[] {\n if (state.role._tag === \"leader\") {\n // A leader arms ONLY the heartbeat; it never times itself out.\n return [deadlineSub(heartbeatSubId(self), now + heartbeatMs)];\n }\n // Follower / candidate arm ONLY the election timeout, jittered in\n // [minMs, maxMs] from the injected rng (never Math.random ambiently).\n const span = electionTimeout.maxMs - electionTimeout.minMs;\n const timeout = electionTimeout.minMs + Math.floor(rng() * (span + 1));\n return [deadlineSub(electionSubId(self), now + timeout)];\n }\n\n /** Build a `RequestVote` reply Cmd addressed to the asking candidate. */\n function voteReply(\n to: NodeId,\n term: Term,\n voteGranted: boolean,\n ): SendRequestVoteReply {\n return { type: \"raft:send_request_vote_reply\", to, term, voteGranted };\n }\n\n /** Build an `AppendEntries` reply Cmd addressed to the asking leader. */\n function appendReply(\n to: NodeId,\n term: Term,\n success: boolean,\n matchIndex: number,\n ): SendAppendEntriesReply {\n return {\n type: \"raft:send_append_entries_reply\",\n to,\n term,\n success,\n matchIndex,\n };\n }\n\n return {\n init,\n onElectionTimeout,\n onRequestVote,\n onRequestVoteReply,\n onAppendEntries,\n onAppendEntriesReply,\n onClientCommand,\n onHeartbeat,\n subs,\n };\n}\n\n// Re-export the sub-id builders' identities for consumers wiring `subscribe`\n// cells / asserting against the armed sub ids without re-deriving the strings.\nexport { electionSubId, heartbeatSubId };\n\n// `Sub` / `subId` are imported for the `DeadlineSub` return type and id\n// branding inside `../deadline`; re-exported here so a consumer wiring the Raft\n// subs into a machine's `subscribe` map need not also reach into the core.\nexport type { Sub };\nexport { subId };\n"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/monitored-run/index.ts"],"names":[],"mappings":"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","file":"chunk-7W4LMPUW.js","sourcesContent":["/**\n * @demlik/tea/monitored-run — a long-running operation that is BOTH staged and\n * watched: an ordered stage pipeline whose POSITION survives eviction, wrapped\n * by a no-progress safety deadline and (optionally) a periodic durable\n * checkpoint.\n *\n * This is the merge of two field-guide concerns the audit-agent machine\n * hand-wired together (`seed/audit-agent-machine`): the OUTER pipeline position\n * (`RunStage` — planning → testing → summarizing → reporting, replayed on cold\n * wake by re-emitting the one outstanding effect) and the no-progress safety\n * alarm (`subs.ts`, keyed on `progressSeq` so every progress event retires the\n * old alarm and arms a fresh one). `monitored-run` lifts both into one config-\n * driven knob with the uniform L2 contract `resilient-call` established:\n * `init()`, the verbs (`start` / `advance` / `progress` / `onDeadline` /\n * `boot`), `subs(state)`, and `handlers(ports)`.\n *\n * - It is **staged, optionally**. Omit `stages` and the run is single-shot:\n * `start` enters `running`, `advance` finishes it to `done`. Provide\n * `stages` and it becomes an ordered pipeline — each `advance` retires the\n * current stage and claims the next, finishing to `done` only when the last\n * stage is retired. The pipeline POSITION lives in the Model (a\n * `work-queue` of stage items), so an eviction mid-pipeline resumes at the\n * same stage via `boot` — the audit machine's \"pipeline position survives\n * eviction\" property, generalized.\n * - It is **watched**. While `running`, a no-progress deadline is armed at\n * `lastProgressAt + deadlineMs` and keyed on `progressSeq`. Every\n * `progress` / `advance` bumps the seq, so the substrate retires the old\n * alarm and arms a new one — a self-rearming watchdog. If the alarm fires\n * (`onDeadline`) the run goes `failed` with a deadline reason. Omit\n * `deadlineMs` → no watchdog (the `stale` phase is never reachable).\n * - It **checkpoints, optionally**. Provide `snapshotEvery` and every\n * progress unit is accounted to a `../snapshot` slice; once the cadence is\n * reached a checkpoint Cmd is emitted (interpreted by the spliced snapshot\n * `handlers`), so a crashed run restarts from the last durable checkpoint\n * rather than from zero. Omit it → no checkpointing.\n *\n * ## Why the run lifecycle is INLINE (run-tracker not extracted)\n *\n * The phase union (`running` / `done` / `failed` / `stale`) and its\n * bookkeeping (`runId`, `startedAt`, `lastProgressAt`, `progressSeq`) live\n * directly on this slice rather than behind a `run-tracker` sub-knob.\n * Extraction earns its keep when two independent axes vary; here the lifecycle\n * has exactly one anchoring axis (this run), so a separate module would be a\n * premature seam. The two genuinely independent axes — the safety DEADLINE and\n * the periodic SNAPSHOT — ARE extracted (`../deadline`, `../snapshot`), and the\n * stage list is a `../work-queue` slice (`QueueItem<Stage>[]`) whose every\n * status flip is DELEGATED to the blessed `../work-queue/ops` (`enqueueOp` /\n * `claimNextOp` / `markDoneOp`) — never re-rolled here. The lifecycle is the\n * glue between them.\n *\n * ## The two non-negotiables (canon)\n *\n * - **Durable** — the slice is plain data (the stage queue is a\n * JSON-serializable `QueueItem<Stage>[]`, the snapshot slice is plain\n * numbers), so it survives DO eviction / reload.\n * - **Replayable** — every transition is a pure verb returning new state +\n * Cmds; nothing here reads the clock or RNG. Time arrives as an `at` / `atMs`\n * parameter; the only clock read is `Date.now()` inside the snapshot\n * `handlers` port (the effect boundary), exactly as `../snapshot` stamps it.\n *\n * ## Typical wiring\n *\n * const run = createMonitoredRun<MyStage, MyState>({\n * stages: [\"plan\", \"test\", \"report\"],\n * deadlineMs: 10 * 60_000,\n * snapshotEvery: 25,\n * });\n *\n * init: (loaded) => loaded ? [loaded, []] : [{ run: run.init() }, []],\n * update: {\n * begin: (s, m) => lift(s, run.start(s.run, m.runId, m.at)),\n * advance: (s, m) => lift(s, run.advance(s.run, checkpoint(s), m.result, m.at)),\n * progress: (s, m) => lift(s, run.progress(s.run, checkpoint(s), m.at)),\n * run_deadline: (s, m) => lift(s, run.onDeadline(s.run, m)),\n * boot: (s, m) => lift(s, run.boot(s.run, m.at)),\n * },\n * subscriptions: (s) => run.subs(s.run),\n * subscribe: { deadline: subscribeDeadline },\n * interpret: run.handlers({ store: r2 }),\n */\n\nimport {\n type DeadlineExceeded,\n type DeadlineSub,\n deadlineSub,\n subscribeDeadline,\n} from \"../deadline\";\nimport type { Cmd, Interpret } from \"../index\";\nimport {\n createSnapshot,\n type SnapshotFailedMsg,\n type SnapshotLoadCmd,\n type SnapshotLoadedMsg,\n type SnapshotLoadFailedMsg,\n type SnapshotSavedMsg,\n type SnapshotState,\n type SnapshotStore,\n type SnapshotWriteCmd,\n} from \"../snapshot\";\nimport type { QueueItem } from \"../work-queue\";\nimport { queueAdapter } from \"../work-queue/adapter\";\n\n// ===========================================================================\n// Config — the knob. Every field optional; omit a field → omit its gate.\n// ===========================================================================\n\n/**\n * The monitored-run knob. EVERY field is optional:\n * - omit `stages` → single-shot run (no pipeline; one `advance` finishes).\n * - omit `deadlineMs` → no no-progress watchdog (the run is never auto-failed).\n * - omit `snapshotEvery`→ no periodic checkpoint (no `snapshot_write` Cmds).\n *\n * An empty config `{}` is a valid single-shot, unwatched, uncheckpointed run.\n *\n * `Stage` is the consumer's stage identifier — any JSON-serializable value\n * (a string id, a `{ kind, ... }` record). It rides through the stage queue\n * unchanged so the consumer's reducer can branch on the current stage to emit\n * that stage's outstanding effect (the audit machine's `outstandingEffect`).\n */\nexport interface MonitoredRunConfig<Stage> {\n /**\n * The ordered stages of the pipeline. Position survives eviction (lives in\n * the Model's stage queue). Omit for a single-shot run. An empty array is\n * treated as single-shot too — there is nothing to pipeline through.\n */\n readonly stages?: readonly Stage[];\n /**\n * No-progress watchdog budget, in ms. The safety deadline is armed at\n * `lastProgressAt + deadlineMs` and re-armed on every progress event. Omit\n * for no watchdog.\n */\n readonly deadlineMs?: number;\n /**\n * Checkpoint cadence — write a durable snapshot once this many progress\n * units have accumulated. Threaded straight into `../snapshot`'s `every`.\n * Omit for no checkpointing.\n */\n readonly snapshotEvery?: number;\n /**\n * Store key the rolling checkpoint is written under. Forwarded to\n * `../snapshot`'s `key`; defaults to that module's `@@snapshot`. Ignored\n * when `snapshotEvery` is omitted.\n */\n readonly snapshotKey?: string;\n}\n\n// ===========================================================================\n// Slice — the Model field this knob owns. Run lifecycle INLINE + composed bricks.\n// ===========================================================================\n\n/**\n * Why a run terminated as `failed`. Discriminated so each reason carries only\n * its own data (pattern 11):\n * - `deadline` — the no-progress watchdog fired (`onDeadline`).\n * - `stage` — the consumer reported a stage failure via `advance(..., fail)`.\n *\n * `stale` is a SEPARATE phase (not a fail reason): a run is `stale` only by an\n * explicit consumer signal that progress has wedged WITHOUT the deadline having\n * fired — a soft, recoverable observation. The deadline firing is a hard\n * terminal (`failed { reason: \"deadline\" }`).\n *\n * The `stage` variant carries the FAILING STAGE VALUE (the `Stage` itself), not\n * a positional index: done stages leave the queue (`markDoneOp` filters them),\n * so a residual index drifts as the pipeline advances. The stage value is the\n * stable, meaningful identifier — `undefined` for a single-shot run (no stage).\n */\nexport type RunFailure<Stage> =\n | { readonly reason: \"deadline\"; readonly at: number }\n | {\n readonly reason: \"stage\";\n readonly stage: Stage | undefined;\n readonly error: unknown;\n };\n\n/**\n * Run lifecycle phase. Discriminated on `phase`:\n *\n * - `running` — the run is live. Carries the current pipeline position\n * (`stepStates` is the stage queue; the single `running` item in it is the\n * current stage, `undefined` for a single-shot run) and the watchdog\n * bookkeeping (`lastProgressAt` / `progressSeq` keying the safety alarm).\n * - `stale` — a soft \"no progress observed\" mark the consumer can set and\n * clear; the run is still resumable (a `progress` clears it back to\n * `running`). Distinct from a fired deadline, which is terminal.\n * - `done` — every stage retired (or the single-shot `advance` landed).\n * - `failed` — terminal failure; `failure` carries why.\n */\nexport type RunPhase = \"running\" | \"stale\" | \"done\" | \"failed\";\n\n/**\n * The slice. Plain data end to end:\n * - `phase` — the lifecycle discriminant.\n * - `runId` — host-minted run identity, stamped at `start` (the\n * machine never mints — see the audit seed's\n * \"run identity is host-minted\").\n * - `stepStates` — the stage pipeline as a `work-queue` of stage items.\n * Empty for a single-shot run. The ONE `running` item is\n * the current stage; `done` items are retired stages\n * (they leave the queue via `markDoneOp`, so the queue\n * shrinks as the pipeline advances and is empty at\n * `done`). POSITION survives eviction because this list\n * is durable.\n * - `startedAt` — `at` of `start`. Observability; never compared in a verb.\n * - `lastProgressAt` — `at` of the most recent progress event; the watchdog\n * base. Re-armed by `progress` / `advance`.\n * - `progressSeq` — monotonic progress counter; keys the safety alarm Sub\n * id so every bump retires the old alarm and arms a new\n * one (the audit subs' `safety:{progressSeq}` discipline).\n * - `failure` — present iff `phase === \"failed\"`; the typed reason.\n * - `snapshot` — the `../snapshot` slice (cadence counter + durable\n * watermark). Always present (uniform shape); the gate\n * is simply never driven when `snapshotEvery` is omitted.\n */\nexport interface MonitoredRunState<Stage> {\n readonly phase: RunPhase;\n readonly runId: string;\n readonly stepStates: readonly QueueItem<Stage>[];\n readonly startedAt: number;\n readonly lastProgressAt: number;\n readonly progressSeq: number;\n readonly failure: RunFailure<Stage> | null;\n readonly snapshot: SnapshotState;\n}\n\n/**\n * The outcome a consumer reports to `advance`: the current stage either\n * succeeded (retire it, claim the next) or failed (terminate the run). Plain\n * data — the consumer parses its stage result at its own boundary and hands a\n * settled outcome here.\n */\nexport type StageResult =\n | { readonly kind: \"ok\" }\n | { readonly kind: \"fail\"; readonly error: unknown };\n\n// ===========================================================================\n// Cmd + Msg — re-exported from the composed bricks so consumers wire one import.\n// ===========================================================================\n\n/** The checkpoint-write Cmd, generic over the consumer's checkpoint value `V`. */\nexport type MonitoredRunCmd<V> = SnapshotWriteCmd<V>;\n\n/** The deadline Msg the safety alarm dispatches when the watchdog fires. */\nexport type MonitoredRunTimerMsg = DeadlineExceeded;\n\n/** Ports the consumer supplies to `handlers` — just the checkpoint store. */\nexport interface MonitoredRunPorts<V> {\n /** Where periodic checkpoints are written (R2 / KV / DO sub-key / Map). */\n readonly store: SnapshotStore<V>;\n}\n\n// ===========================================================================\n// Sub-id family. The safety alarm is keyed on `progressSeq` so a progress\n// event retires the old alarm and arms a new one (single-shot, re-armed by id\n// bump — the audit subs discipline).\n// ===========================================================================\n\nfunction safetyTimerId(runId: string, progressSeq: number): string {\n return `monitored:safety:${runId}:${progressSeq}`;\n}\n\n// ===========================================================================\n// The knob factory.\n// ===========================================================================\n\n/**\n * Build a monitored-run knob from `config`. `Stage` is the consumer's stage\n * identifier type; `V` is the consumer's checkpoint value type (what\n * `snapshotEvery` checkpoints). Returns the uniform L2 knob contract.\n *\n * No `rng` parameter: unlike `resilient-call`, nothing here jitters — the only\n * external inputs are time (an `at` / `atMs` parameter) and the consumer's\n * stage outcomes. The clock is read only inside the snapshot `handlers` port.\n */\nexport function createMonitoredRun<Stage, V = unknown>(\n config: MonitoredRunConfig<Stage> = {},\n) {\n const stages = config.stages ?? [];\n const isPipeline = stages.length > 0;\n\n // The stage-queue seam, bound once. Every stage status flip delegates to the\n // `QueueAdapter` verbs (`wq.enqueue` / `wq.claim` / `wq.markDone`), so the\n // `QueueItem<Stage>` shape stays insulated behind them.\n const wq = queueAdapter<Stage>();\n\n // The snapshot sub-knob is built only when `snapshotEvery` is configured;\n // absence is `null` so the progress gate short-circuits to \"no checkpoint\".\n // When present, the slice still carries a default SnapshotState so the shape\n // is uniform across configs — the gate simply never records into it.\n const snap =\n config.snapshotEvery !== undefined\n ? createSnapshot<V>({\n every: config.snapshotEvery,\n ...(config.snapshotKey !== undefined\n ? { key: config.snapshotKey }\n : {}),\n })\n : null;\n\n /** The starting slice for a never-started run. `runId` is filled at `start`. */\n function init(): MonitoredRunState<Stage> {\n return {\n phase: \"running\",\n runId: \"\",\n stepStates: [],\n startedAt: 0,\n lastProgressAt: 0,\n progressSeq: 0,\n failure: null,\n // A default snapshot slice even without the brick → uniform shape. When\n // `snap` is null the slice is created with a throwaway cadence; its\n // `record` is never called, so the cadence value is inert.\n snapshot: (snap ?? createSnapshot<V>({ every: 1 })).init(),\n };\n }\n\n /**\n * Seed the stage queue from the configured stages: every stage enqueued\n * `pending`, then the first claimed `running`. `at` stamps the claim. PURE —\n * the work-queue ops are pure array transforms; ids are positional (`stage:i`)\n * so they are deterministic and replayable (no `crypto`, no `Math.random`).\n */\n function seedStages(at: number): readonly QueueItem<Stage>[] {\n let queue: readonly QueueItem<Stage>[] = [];\n stages.forEach((stage, i) => {\n queue = wq.enqueue(queue, stage, at, `stage:${i}`).next;\n });\n // Claim the first stage as the running position. A non-empty pipeline always\n // has a pending head here, so the claim succeeds.\n const claimed = wq.claim(queue, at);\n return claimed ? claimed.next : queue;\n }\n\n /** The current (running) stage item, or `undefined` for a single-shot run. */\n function currentStage(\n s: MonitoredRunState<Stage>,\n ): QueueItem<Stage> | undefined {\n return s.stepStates.find((i) => i.status === \"running\");\n }\n\n // === Verb: start =========================================================\n\n /**\n * Start (or restart) the run for `runId` at time `at`. Seeds the stage queue\n * (if a pipeline) and enters `running` with the watchdog clock primed. Stamps\n * `runId` from the host — the machine never mints (invariant: identity is\n * host-minted, parsed at the boundary). PURE.\n */\n function start(\n s: MonitoredRunState<Stage>,\n runId: string,\n at: number,\n ): readonly [MonitoredRunState<Stage>, readonly MonitoredRunCmd<V>[]] {\n const next: MonitoredRunState<Stage> = {\n ...s,\n phase: \"running\",\n runId,\n stepStates: isPipeline ? seedStages(at) : [],\n startedAt: at,\n lastProgressAt: at,\n progressSeq: 0,\n failure: null,\n };\n return [next, []];\n }\n\n /**\n * Bump the watchdog clock: set `lastProgressAt`/`progressSeq` so the safety\n * alarm Sub id changes and the substrate re-arms a fresh deadline. Shared by\n * `progress` and `advance`. PURE.\n */\n function bumpProgress(\n s: MonitoredRunState<Stage>,\n at: number,\n ): MonitoredRunState<Stage> {\n return { ...s, lastProgressAt: at, progressSeq: s.progressSeq + 1 };\n }\n\n /**\n * Account one progress unit to the snapshot slice (when the brick is\n * configured), returning the new slice + any checkpoint Cmd. `payload` is the\n * consumer's checkpoint value to persist. PURE — `snap.record` is pure, the\n * write is realized only when the runtime performs the emitted Cmd.\n */\n function recordSnapshot(\n s: MonitoredRunState<Stage>,\n payload: V,\n at: number,\n ): readonly [MonitoredRunState<Stage>, readonly MonitoredRunCmd<V>[]] {\n if (snap === null) return [s, []];\n const [snapshot, cmds] = snap.record(s.snapshot, payload, at);\n // `snap.record` only ever emits `SnapshotWriteCmd<V>` (its sole Cmd), but\n // its signature widens to `readonly Cmd[]`. Narrow back to this knob's\n // precise Cmd union so a consumer's host Cmd type can stay exact.\n return [{ ...s, snapshot }, cmds as readonly MonitoredRunCmd<V>[]];\n }\n\n // === Verb: progress ======================================================\n\n /**\n * Record forward progress: bump the watchdog (re-arming the safety alarm),\n * clear any `stale` mark back to `running`, and account a snapshot unit\n * (emitting a checkpoint Cmd on a cadence hit). A no-op on a settled run\n * (`done` / `failed`) — a late progress event after the run ended is ignored.\n * PURE.\n */\n function progress(\n s: MonitoredRunState<Stage>,\n payload: V,\n at: number,\n ): readonly [MonitoredRunState<Stage>, readonly MonitoredRunCmd<V>[]] {\n if (s.phase === \"done\" || s.phase === \"failed\") return [s, []];\n const bumped: MonitoredRunState<Stage> = {\n ...bumpProgress(s, at),\n phase: \"running\",\n };\n return recordSnapshot(bumped, payload, at);\n }\n\n // === Verb: markStale =====================================================\n\n /**\n * Mark the run `stale` — a soft \"no progress observed\" signal the consumer\n * raises WITHOUT the deadline having fired (e.g. an upstream heartbeat gap).\n * Recoverable: the next `progress` flips it back to `running`. A no-op unless\n * the run is currently `running` (a settled run cannot go stale). Does NOT\n * touch `progressSeq` — going stale is not progress, so the armed deadline\n * keeps counting toward the hard terminal. PURE.\n */\n function markStale(\n s: MonitoredRunState<Stage>,\n ): readonly [MonitoredRunState<Stage>, readonly MonitoredRunCmd<V>[]] {\n if (s.phase !== \"running\") return [s, []];\n return [{ ...s, phase: \"stale\" }, []];\n }\n\n // === Verb: advance =======================================================\n\n /**\n * Advance the pipeline on a stage outcome:\n *\n * - `result.kind === \"fail\"` → terminate `failed { reason: \"stage\" }`,\n * carrying the failing stage index + error. No further stages run.\n * - `result.kind === \"ok\"`:\n * - single-shot run → finish to `done` (and force a final checkpoint\n * when checkpointing, so the last durable snapshot is the terminal\n * state).\n * - pipeline → retire the current stage (`markDoneOp`) and claim the\n * next (`claimNextOp`). If none remain, finish to `done`. The new\n * position survives eviction.\n *\n * An advance is itself a progress event, so it bumps the watchdog (re-arming\n * the alarm at the new stage) and accounts a snapshot unit. A no-op on a\n * settled run. PURE — `at` is the only clock, ids are positional.\n */\n function advance(\n s: MonitoredRunState<Stage>,\n payload: V,\n result: StageResult,\n at: number,\n ): readonly [MonitoredRunState<Stage>, readonly MonitoredRunCmd<V>[]] {\n if (s.phase === \"done\" || s.phase === \"failed\") return [s, []];\n\n // Stage failure → hard terminal. Carry the failing stage VALUE (stable),\n // `undefined` for a single-shot run with no stage.\n if (result.kind === \"fail\") {\n const failed: MonitoredRunState<Stage> = {\n ...s,\n phase: \"failed\",\n failure: {\n reason: \"stage\",\n stage: currentStage(s)?.input,\n error: result.error,\n },\n };\n return [failed, []];\n }\n\n // Success: bump the watchdog first (an advance is progress), then move the\n // pipeline position.\n const bumped = bumpProgress(s, at);\n\n // Single-shot: one advance finishes the run.\n if (!isPipeline) {\n const done: MonitoredRunState<Stage> = { ...bumped, phase: \"done\" };\n // Force a terminal checkpoint so the last durable snapshot is `done`,\n // not whatever the cadence happened to land on.\n return finishWithSnapshot(done, payload, at);\n }\n\n // Pipeline: retire the running stage, claim the next.\n const current = currentStage(s);\n const afterDone =\n current !== undefined\n ? wq.markDone(s.stepStates, current.id).next\n : s.stepStates;\n const claimed = wq.claim(afterDone, at);\n\n if (claimed === null) {\n // No more stages — the pipeline is complete.\n const done: MonitoredRunState<Stage> = {\n ...bumped,\n phase: \"done\",\n stepStates: afterDone,\n };\n return finishWithSnapshot(done, payload, at);\n }\n\n // Advance to the next stage — POSITION now points at the claimed item.\n const moved: MonitoredRunState<Stage> = {\n ...bumped,\n stepStates: claimed.next,\n };\n return recordSnapshot(moved, payload, at);\n }\n\n /**\n * Finish helper: force a terminal checkpoint when checkpointing is enabled\n * (so the durable snapshot is the terminal state), else just settle. Shared\n * by single-shot finish and pipeline completion. PURE.\n */\n function finishWithSnapshot(\n s: MonitoredRunState<Stage>,\n payload: V,\n at: number,\n ): readonly [MonitoredRunState<Stage>, readonly MonitoredRunCmd<V>[]] {\n if (snap === null) return [s, []];\n const [snapshot, cmds] = snap.force(s.snapshot, payload, at);\n // Same narrowing as `recordSnapshot`: `snap.force` emits only the write Cmd.\n return [{ ...s, snapshot }, cmds as readonly MonitoredRunCmd<V>[]];\n }\n\n // === Verb: onDeadline ====================================================\n\n /**\n * The safety alarm fired. Two outcomes by Sub-id match:\n *\n * - the alarm matches the CURRENT `progressSeq` (no progress since it was\n * armed) → the run is wedged → terminate `failed { reason: \"deadline\" }`.\n * - the alarm matches an OLDER seq → a stale fire racing a progress event\n * that already re-armed the alarm → no-op (the substrate retired this id;\n * tolerate a fire still in flight defensively).\n *\n * A no-op on a settled run (its alarm was reconciled away; tolerate a stale\n * fire) and on a NEVER-STARTED run (empty `runId` → `subs` armed nothing, so\n * any alarm reaching it is a rogue fire that must not un-start it). PURE —\n * `msg.atMs` stamps the failure.\n */\n function onDeadline(\n s: MonitoredRunState<Stage>,\n msg: MonitoredRunTimerMsg,\n ): readonly [MonitoredRunState<Stage>, readonly MonitoredRunCmd<V>[]] {\n if (s.phase === \"done\" || s.phase === \"failed\") return [s, []];\n // A never-started run has no host-minted runId, so `subs` never armed a\n // watchdog for it. Any alarm reaching this slice is a born-live/rogue fire;\n // refuse to auto-fail a run that was never started.\n if (s.runId === \"\") return [s, []];\n // Only the alarm armed against the current progressSeq is live. A bumped\n // seq means progress re-armed it; an older fire is stale.\n if (msg.id !== safetyTimerId(s.runId, s.progressSeq)) return [s, []];\n const failed: MonitoredRunState<Stage> = {\n ...s,\n phase: \"failed\",\n failure: { reason: \"deadline\", at: msg.atMs },\n };\n return [failed, []];\n }\n\n // === Verb: boot ==========================================================\n\n /**\n * Resume after a reload (cold wake). Re-seeds the watchdog clock to `at` so\n * the safety alarm re-arms from NOW (the pre-crash `lastProgressAt` would\n * immediately fire a deadline that already elapsed during downtime — a cold\n * wake is not a no-progress wedge). Bumps `progressSeq` so the alarm gets a\n * fresh id, and resets the snapshot cadence counter (un-checkpointed\n * pre-crash progress is moot — recovery resumes from the last durable\n * checkpoint). The pipeline POSITION (`stepStates`) is preserved untouched —\n * that is the whole point of staging: resume at the same stage.\n *\n * A no-op on a settled run. Emits NO Cmd — re-emitting the current stage's\n * outstanding effect is the CONSUMER's job (it knows the per-stage Cmd), the\n * audit machine's `outstandingEffect(stage)` pattern. PURE.\n */\n function boot(\n s: MonitoredRunState<Stage>,\n at: number,\n ): readonly [MonitoredRunState<Stage>, readonly MonitoredRunCmd<V>[]] {\n if (s.phase === \"done\" || s.phase === \"failed\") return [s, []];\n // `snap.boot` returns the reducer-cell shape `[SnapshotState, Cmd[]]`\n // (uniform across every sibling knob); a boot emits no Cmd, so take only the\n // slice. Assigning the tuple itself would put an ARRAY where a SnapshotState\n // belongs — a type error AND a non-round-trippable slice.\n const [bootedSnapshot] = (snap ?? createSnapshot<V>({ every: 1 })).boot(\n s.snapshot,\n );\n const reseeded: MonitoredRunState<Stage> = {\n ...s,\n phase: \"running\",\n lastProgressAt: at,\n progressSeq: s.progressSeq + 1,\n snapshot: bootedSnapshot,\n // Any stage left `running` mid-flight stays the running position; the\n // queue is already correct (a single in-flight stage). resetRunningOp is\n // NOT applied — unlike a work pool, a monitored run has exactly one\n // in-flight stage and resuming it (not re-queuing it) is the correct\n // cold-wake behavior.\n };\n return [reseeded, []];\n }\n\n // === Subs ================================================================\n\n /**\n * Pre-wired subscriptions: the no-progress safety deadline, armed only while\n * the run is live (`running` / `stale`) AND a `deadlineMs` is configured. The\n * Sub id is keyed on `progressSeq`, so every progress event retires the old\n * alarm and the substrate arms a fresh one at the new `lastProgressAt +\n * deadlineMs` — a self-rearming watchdog with no manual `clearTimeout`. A\n * settled run desires no subs. Wire `subscribe: { deadline: subscribeDeadline }`.\n *\n * A NEVER-STARTED slice (`init()`) is `running` with an empty `runId` and a\n * zero `lastProgressAt`. Without the `runId` gate it would arm a deadline at\n * `0 + deadlineMs` — already in the past — which fires immediately and\n * auto-fails a run that never began (the \"born live\" defect). `start` is the\n * only verb that stamps a host-minted `runId`, so `runId !== \"\"` is the exact\n * \"has this run actually been started?\" predicate: a pre-start slice arms\n * nothing, and the watchdog only exists once a real run is in flight.\n */\n function subs(s: MonitoredRunState<Stage>): readonly DeadlineSub[] {\n if (config.deadlineMs === undefined) return [];\n if (s.runId === \"\") return [];\n if (s.phase !== \"running\" && s.phase !== \"stale\") return [];\n return [\n deadlineSub(\n safetyTimerId(s.runId, s.progressSeq),\n s.lastProgressAt + config.deadlineMs,\n ),\n ];\n }\n\n // === Handlers ============================================================\n\n /**\n * Pre-wired interpret handler for the checkpoint write. Delegates wholesale to\n * `../snapshot`'s `handlers` (which performs the `put` and routes Ok →\n * `snapshot_saved` / Err → `snapshot_failed` via Railway — the write never\n * rejects into the runtime). When checkpointing is disabled (`snapshotEvery`\n * omitted), no `snapshot_write` Cmd is ever emitted, so the returned map is an\n * inert no-op handler — kept for a uniform `handlers(ports)` call shape.\n *\n * Fold the `snapshot_saved` Msg back through this knob's `confirmSnapshot`\n * verb to advance the durable watermark.\n */\n function handlers(\n ports: MonitoredRunPorts<V>,\n ): Interpret<\n | SnapshotSavedMsg\n | SnapshotFailedMsg\n | SnapshotLoadedMsg<V>\n | SnapshotLoadFailedMsg,\n SnapshotWriteCmd<V> | SnapshotLoadCmd,\n unknown\n > {\n const active = snap ?? createSnapshot<V>({ every: 1 });\n return active.handlers({ store: ports.store });\n }\n\n /**\n * Fold a confirmed checkpoint write into the slice — advances the snapshot\n * watermark (forward-only). A no-op pass-through when checkpointing is\n * disabled. PURE. Fold the `snapshot_saved` Msg here.\n */\n function confirmSnapshot(\n s: MonitoredRunState<Stage>,\n msg: SnapshotSavedMsg,\n ): MonitoredRunState<Stage> {\n if (snap === null) return s;\n // `snap.confirm` returns the reducer-cell shape `[SnapshotState, Cmd[]]`\n // (an ack emits nothing). Take only the slice — folding the whole tuple into\n // `snapshot` would store an ARRAY where a SnapshotState belongs (a type error\n // and a non-round-trippable slice).\n const [confirmed] = snap.confirm(s.snapshot, msg);\n return { ...s, snapshot: confirmed };\n }\n\n return {\n init,\n start,\n progress,\n markStale,\n advance,\n onDeadline,\n boot,\n confirmSnapshot,\n subs,\n handlers,\n };\n}\n\n/**\n * Lift a knob result `[slice, cmds]` into a host `[State, cmds]` where the\n * slice lives at `state.run`. Convenience for the common single-slice host;\n * hosts with a differently-named field spread by hand. PURE — a thin record\n * rebuild, no clock / RNG.\n */\nexport function liftRun<\n S extends { run: MonitoredRunState<Stage> },\n Stage,\n C extends Cmd,\n>(\n state: S,\n [slice, cmds]: readonly [MonitoredRunState<Stage>, readonly C[]],\n): readonly [S, readonly C[]] {\n return [{ ...state, run: slice }, cmds];\n}\n\n/**\n * Re-export the deadline Sub primitives so consumers (and tests) wire one\n * import: `subscribeDeadline` is the `subscribe` cell, `deadlineSub` builds the\n * Sub literal this knob's `subs` emits.\n */\nexport { subscribeDeadline, deadlineSub };\nexport type {\n DeadlineSub,\n DeadlineExceeded,\n SnapshotSavedMsg,\n SnapshotWriteCmd,\n};\n"]}
|