@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 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/token-refresh/index.ts"],"names":[],"mappings":";;;;AAkJO,SAAS,gBAAA,GAA+B;AAC7C,EAAA,OAAO,EAAE,KAAA,EAAO,IAAA,EAAM,KAAA,EAAO,KAAA,EAAM;AACrC;AAYO,SAAS,eAAA,GAAmC;AACjD,EAAA,OAAO,EAAE,MAAM,eAAA,EAAgB;AACjC;AAaO,SAAS,kBAAkB,KAAA,EAAiC;AACjE,EAAA,OAAO,EAAE,IAAA,EAAM,OAAA,CAAQ,cAAA,EAAgB,KAAA,EAAM;AAC/C;AAgBO,SAAS,sBAAsB,KAAA,EAAuC;AAC3E,EAAA,OAAO,EAAE,IAAA,EAAM,OAAA,CAAQ,kBAAA,EAAoB,KAAA,EAAM;AACnD;AA0BO,SAAS,kBAAA,CAAmB,MAAA,GAA6B,EAAC,EAAG;AAIlE,EAAA,MAAM,MAAA,GACJ,OAAO,MAAA,KAAW,MAAA,IAAa,OAAO,MAAA,GAAS,CAAA,GAAI,OAAO,MAAA,GAAS,CAAA;AAsBrE,EAAA,SAAS,YAAA,CAAa,OAAmB,EAAA,EAAqB;AAC5D,IAAA,IAAI,KAAA,CAAM,KAAA,KAAU,IAAA,EAAM,OAAO,IAAA;AACjC,IAAA,IAAI,KAAA,CAAM,OAAO,OAAO,IAAA;AAKxB,IAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,MAAM,KAAA,CAAM,SAAS,GAAG,OAAO,IAAA;AACpD,IAAA,OAAO,EAAA,IAAM,KAAA,CAAM,KAAA,CAAM,SAAA,GAAY,MAAA;AAAA,EACvC;AAcA,EAAA,SAAS,MAAM,KAAA,EAA+B;AAC5C,IAAA,IAAI,KAAA,CAAM,OAAO,OAAO,KAAA;AACxB,IAAA,OAAO,EAAE,GAAG,KAAA,EAAO,KAAA,EAAO,IAAA,EAAK;AAAA,EACjC;AAmBA,EAAA,SAAS,SAAA,CAAU,OAAmB,KAAA,EAA0B;AAC9D,IAAA,MAAM,UAAA,GAAoB,MAAA,CAAO,EAAA,CAAG,KAAA,CAAM,SAAA,EAAW,EAAE,CAAA,GACnD,EAAE,GAAG,KAAA,EAAO,SAAA,EAAW,CAAA,EAAE,GACzB,KAAA;AACJ,IAAA,OAAO,EAAE,GAAG,KAAA,EAAO,KAAA,EAAO,UAAA,EAAY,OAAO,KAAA,EAAM;AAAA,EACrD;AAgBA,EAAA,SAAS,WAAA,CACP,OACA,EAAA,EACuC;AACvC,IAAA,IAAI,YAAA,CAAa,KAAA,EAAO,EAAE,CAAA,EAAG;AAC3B,MAAA,OAAO,CAAC,KAAA,EAAO,CAAC,eAAA,EAAiB,CAAC,CAAA;AAAA,IACpC;AACA,IAAA,OAAO,CAAC,KAAA,EAAO,EAAE,CAAA;AAAA,EACnB;AAgBA,EAAA,SAAS,SACP,KAAA,EACsD;AACtD,IAAA,OAAO;AAAA,MACL,aAAA,EAAe,YAAA;AAAA,QAMb,MAAM,MAAM,OAAA,EAAQ;AAAA,QACpB,CAAC,KAAA,KAAU,iBAAA,CAAkB,KAAK,CAAA;AAAA,QAClC,CAAC,KAAA,KAAU,qBAAA,CAAsB,KAAK;AAAA;AACxC,KACF;AAAA,EACF;AAEA,EAAA,OAAO;AAAA;AAAA,IAEL,IAAA,EAAM,gBAAA;AAAA,IACN,YAAA;AAAA,IACA,KAAA;AAAA,IACA,SAAA;AAAA,IACA,WAAA;AAAA,IACA;AAAA,GACF;AACF","file":"chunk-3XJWC7VW.js","sourcesContent":["/**\n * @demlik/tea/token-refresh — a credential's lifecycle as pure state + verbs,\n * with the actual token fetch deferred to a single injected port.\n *\n * This is the L1 brick `authed-call` (#10 in the composition specs) stacks on:\n * a token slice `{ token, stale }` plus pure transitions that decide *when* a\n * credential must be re-minted, and one `handlers(ports)` splice that performs\n * the re-mint. It follows the uniform knob contract every composition wears —\n * `createTokenRefresh(config) → { init, <verbs>, handlers }` — so a consumer\n * spreads ~3 hooks into their machine instead of hand-wiring the refresh dance.\n *\n * It is a LEAF module in the same mold as `@demlik/tea/retry-backoff`: it\n * depends only on the core (`../index`), never on a sibling brick. The two\n * non-negotiables of every knob hold here by construction:\n *\n * - **Durable** — the token slice is plain JSON-serializable data, so it\n * survives a Durable Object eviction / page reload. A rehydrated machine\n * resumes knowing exactly which token it holds and whether it is stale.\n * - **Replayable** — every transition is a pure verb returning\n * `readonly [State, Cmd[]]`. No verb reads the wall clock or performs I/O,\n * so `replay` reconstructs the slice exactly. Time enters ONLY as an `at`\n * parameter the caller stamps; the network fetch enters ONLY at the\n * `handlers` boundary. This keeps the substrate's purity invariants intact\n * (invariant 2: pure transitions; invariant 4: I/O is an effect, not a\n * reducer body).\n *\n * ## The staleness model — two independent reasons to refresh\n *\n * A token becomes unusable for two unrelated reasons, and the slice tracks both\n * without conflating them:\n *\n * 1. **Expiry** — the token has a known `expiresAt` (epoch ms). It is \"near\n * expiry\" once `at >= expiresAt - skewMs`. The `skewMs` skew refreshes\n * *ahead* of the hard deadline so an in-flight call never races a token\n * that expires mid-request. This is the proactive, time-driven reason and\n * is computed purely from the injected `at` — no clock in the verb.\n *\n * 2. **Server rejection (401)** — the server rejected a request the client\n * believed was still valid (clock skew, a server-side revocation, an early\n * rotation). `on401` flips a `stale` flag regardless of `expiresAt`. This\n * is the reactive reason: the client's belief about validity was wrong, so\n * the next `needsRefresh` returns true even though the clock says the\n * token is fine.\n *\n * `needsRefresh` is the union of the two (plus \"no token at all\"). One source\n * of truth for \"should I fetch a fresh credential before the next call?\", read\n * by the consumer's reducer at the call boundary.\n *\n * Typical wiring (inside a consumer machine):\n *\n * const tr = createTokenRefresh({ skewMs: 30_000 });\n *\n * // in init:\n * init: (loaded) => [loaded ?? { auth: tr.init() }, Cmd.none],\n *\n * // in a reducer cell that is about to make an authed call:\n * before_call: (s, msg) => {\n * const [auth, cmds] = tr.ensureFresh(s.auth, msg.at);\n * return [{ ...s, auth }, cmds]; // emits a refresh Cmd iff a refresh is due\n * },\n *\n * // on a 401 from a guarded call:\n * got_401: (s) => [{ ...s, auth: tr.on401(s.auth) }, []],\n *\n * // when the refresh port resolves:\n * token_refreshed: (s, msg) => [{ ...s, auth: tr.refreshed(s.auth, msg.token) }, []],\n *\n * // splice the I/O:\n * interpret: { ...tr.handlers({ refresh: () => mySdk.mintToken() }) },\n *\n * NOT a substrate primitive: from `../index` it reuses only the `Cmd` /\n * `Interpret` types and the `tryInterpret` Railway helper (a runtime function,\n * not a type) that every effectful brick uses — and no sibling brick. It is a\n * leaf utility consumers reach via the `@demlik/tea/token-refresh` subpath.\n */\n\nimport { type Cmd, type Interpret, tryInterpret } from \"../index\";\nimport { MsgType } from \"../protocol\";\n\n/**\n * A minted credential: the opaque `value` to send on the wire, and the absolute\n * `expiresAt` (epoch milliseconds — the `Date.now()` scale) the issuer stamped\n * it with. Both fields are plain data so a `Token` is trivially serializable\n * into a consumer's `Store<S>`.\n *\n * `value` is `string` because that is the universal shape of a bearer token /\n * API key / session cookie on the wire. A consumer whose refresh port yields a\n * richer object can JSON-encode it into `value`, or keep this module's slice as\n * the \"is it fresh?\" gate and hold the richer object elsewhere in their Model.\n */\nexport interface Token {\n /** The opaque credential to present on each request (bearer token, API key). */\n readonly value: string;\n /** Absolute expiry instant, epoch milliseconds. The `Date.now()` scale. */\n readonly expiresAt: number;\n}\n\n/**\n * Pure configuration — the knob's only field. Carries no mutable state;\n * `TokenState` does. One config is shared across all the refreshes of one\n * logical credential (often a module-scope const).\n */\nexport interface TokenRefreshConfig {\n /**\n * Refresh-ahead skew, in milliseconds. A token is treated as needing a\n * refresh once `at >= expiresAt - skewMs`, i.e. `skewMs` BEFORE its hard\n * expiry. This buys headroom so a call that starts just under the wire never\n * races a token that lapses mid-flight, and absorbs small client/server clock\n * disagreement. `0` means \"refresh exactly at expiry\"; a negative value is\n * clamped to `0` (a skew that pushes the trigger past expiry is meaningless).\n *\n * @default 0\n */\n readonly skewMs?: number;\n}\n\n/**\n * The token slice this knob owns — the Model field a consumer spreads in via\n * `init()`. A two-field record rather than a phase union because the two\n * dimensions are orthogonal: a token can be present-and-fresh, present-but-stale\n * (a 401 came back), absent, or absent-and-stale (cleared after a failed\n * refresh). Every combination is meaningful, so neither field implies the\n * other's value and a discriminated union would only manufacture impossible\n * arms.\n *\n * - `token` — the currently held credential, or `null` when none has been\n * minted yet (fresh boot) or the last refresh failed.\n * - `stale` — set by `on401` when the server rejected a request the client\n * believed valid. Forces the next `needsRefresh` to true regardless of\n * `expiresAt`; cleared by `refreshed` once a new credential lands.\n *\n * Both fields `readonly` so the whole slice threads safely through a pure\n * reducer and serializes into a `Store<S>`.\n */\nexport interface TokenState {\n /** The held credential, or `null` if none is available (boot / failed refresh). */\n readonly token: Token | null;\n /** Whether a 401 has marked the held token unusable ahead of its `expiresAt`. */\n readonly stale: boolean;\n}\n\n/**\n * The starting slice: no token held, not stale. The first `needsRefresh` after\n * this returns `true` (no token), so a consumer's first guarded call mints a\n * credential before going out.\n */\nexport function initTokenRefresh(): TokenState {\n return { token: null, stale: false };\n}\n\n/**\n * The Cmd this knob emits to ask the runtime to mint a fresh token. Plain data\n * (no closure — invariant 1) carrying nothing: the refresh port takes no input,\n * and the slice the result installs is read off the follow-up Msg, not this\n * Cmd. The consumer unions this into their machine's Cmd type and lets\n * `handlers` interpret it.\n */\nexport interface RefreshTokenCmd extends Cmd<\"refresh_token\"> {}\n\n/** Construct a `refresh_token` Cmd. */\nexport function refreshTokenCmd(): RefreshTokenCmd {\n return { type: \"refresh_token\" };\n}\n\n/**\n * The Msg the refresh port dispatches on success — carries the freshly minted\n * `Token`. The consumer unions this into their Msg type and handles it by\n * folding `refreshed(state, msg.token)` into the slice.\n */\nexport interface TokenRefreshedMsg {\n readonly type: typeof MsgType.TokenRefreshed;\n readonly token: Token;\n}\n\n/** Construct a `token_refreshed` Msg carrying the new token. */\nexport function tokenRefreshedMsg(token: Token): TokenRefreshedMsg {\n return { type: MsgType.TokenRefreshed, token };\n}\n\n/**\n * The Msg the refresh port dispatches on failure — carries the rejection so the\n * consumer's reducer can decide whether to back off, surface an error, or give\n * up (errors are data: it is dispatched, never swallowed). The slice itself is\n * left untouched by a failed refresh — the held token (if any) stays held and\n * stale stays set, so the consumer can retry; clearing it is the consumer's\n * policy, expressed in their reducer.\n */\nexport interface TokenRefreshFailedMsg {\n readonly type: typeof MsgType.TokenRefreshFailed;\n readonly error: unknown;\n}\n\n/** Construct a `token_refresh_failed` Msg carrying the rejection. */\nexport function tokenRefreshFailedMsg(error: unknown): TokenRefreshFailedMsg {\n return { type: MsgType.TokenRefreshFailed, error };\n}\n\n/** The two Msgs the refresh port can produce. Union for a consumer's Msg type. */\nexport type TokenRefreshMsg = TokenRefreshedMsg | TokenRefreshFailedMsg;\n\n/** The ports this knob's `handlers` splice needs — the single refresh DI seam. */\nexport interface TokenRefreshPorts {\n /**\n * Mint a fresh credential. Called ONLY from the `refresh_token` interpret\n * handler — never from a verb — so the network I/O and any clock the issuer\n * reads stay at the effect boundary. May reject; the rejection is routed to\n * `token_refresh_failed` (errors are data), never thrown into the runtime.\n */\n readonly refresh: () => Promise<Token>;\n}\n\n/**\n * The knob: hand it a config, get back the slice initializer, the pure verbs,\n * and the `handlers` splice. Mirrors every L2 composition's uniform contract so\n * the call sites read identically across bricks.\n *\n * @example\n * const tr = createTokenRefresh({ skewMs: 30_000 });\n * const s0 = tr.init();\n * const [s1, cmds] = tr.ensureFresh(s0, Date.now()); // cmds = [refreshTokenCmd()]\n */\nexport function createTokenRefresh(config: TokenRefreshConfig = {}) {\n // Clamp once at construction: a negative skew would push the refresh trigger\n // PAST expiry, which inverts the knob's intent (\"refresh ahead\"). NaN (a\n // miswired config) collapses to 0 so `needsRefresh`'s comparison stays total.\n const skewMs =\n config.skewMs !== undefined && config.skewMs > 0 ? config.skewMs : 0;\n\n /**\n * Whether the held token must be refreshed before the next guarded call, at\n * the injected instant `at` (epoch ms). PURE — reads only, no clock, no\n * mutation. True iff ANY of:\n *\n * - no token is held (`token === null`) — nothing to present;\n * - the token was marked `stale` by a 401 — the server disagrees with the\n * client's belief that it is valid;\n * - the token's `expiresAt` is not a finite number (`NaN` / `±Infinity`) —\n * a miswired issuer or a corrupted slice; we cannot reason about when it\n * lapses, so we fail SAFE toward minting a fresh one rather than letting\n * `at >= NaN` (always `false`) or `at >= Infinity` (always `false`) mask\n * a broken expiry as \"fresh forever\";\n * - the token is at or past its skewed expiry (`at >= expiresAt - skewMs`)\n * — the proactive refresh-ahead window has opened.\n *\n * The expiry cutoff is half-open (`>=`), matching the window edges in\n * `rate-limit` / `cache` / `circuit-breaker`: a token minted to expire at `t`\n * with zero skew is considered due at exactly `t`.\n */\n function needsRefresh(state: TokenState, at: number): boolean {\n if (state.token === null) return true;\n if (state.stale) return true;\n // Non-finite expiry (NaN/±Infinity) can never be compared into a \"due\"\n // verdict — `at >= NaN` and `at >= Infinity` are both `false`, which would\n // pin a broken token as fresh forever. Fail safe: treat it as needing a\n // refresh so the next call mints a credential with a real deadline.\n if (!Number.isFinite(state.token.expiresAt)) return true;\n return at >= state.token.expiresAt - skewMs;\n }\n\n /**\n * Mark the held token unusable because a request the client believed valid\n * came back 401. PURE — returns a new slice; the input is never mutated.\n * Sets `stale` regardless of `expiresAt`, so the next `needsRefresh` returns\n * true even when the clock still says the token is fine. The token VALUE is\n * kept (not nulled) — it is the consumer's last-known credential until a\n * refresh replaces it, and keeping it lets observability show what was\n * rejected.\n *\n * Idempotent: a second 401 on an already-stale slice returns the same shape.\n * A 401 with no token held is a no-op (already needs a refresh).\n */\n function on401(state: TokenState): TokenState {\n if (state.stale) return state;\n return { ...state, stale: true };\n }\n\n /**\n * Install a freshly minted `token`, clearing `stale`. PURE — returns a new\n * slice; the input is never mutated. This is the fold for the\n * `token_refreshed` Msg: after it, `needsRefresh(state, at)` is false for any\n * `at` below the new skewed expiry, and the stale flag is gone (the new\n * credential supersedes whatever the 401 rejected).\n *\n * This is the slice's WRITE boundary for a credential, so it is where the\n * durability invariant is paid for: it normalizes a negative-zero `expiresAt`\n * to `+0` before the value enters the slice. `-0` is the one finite double\n * JSON cannot round-trip — `JSON.stringify(-0)` is `\"0\"`, which parses back as\n * `+0`, so a slice carrying `-0` would diverge from its persisted form across\n * a Durable Object eviction / page reload (invariant: the boundary parses, the\n * core trusts). `-0` and `+0` denote the same epoch-ms instant, so collapsing\n * them loses no information and makes the un-round-trippable state\n * unrepresentable in the slice by construction.\n */\n function refreshed(state: TokenState, token: Token): TokenState {\n const normalized: Token = Object.is(token.expiresAt, -0)\n ? { ...token, expiresAt: 0 }\n : token;\n return { ...state, token: normalized, stale: false };\n }\n\n /**\n * The verb a consumer calls at the call boundary: \"make sure I have a fresh\n * credential before the next request.\" PURE — returns `[next, cmds]`. When a\n * refresh is due (`needsRefresh(state, at)`), emits a single `refresh_token`\n * Cmd; otherwise emits none. The slice is returned UNCHANGED in both branches\n * — the refresh itself is async and lands later via `refreshed`, so there is\n * no in-flight phase to record here. (A consumer that wants single-flight —\n * \"don't fire a second refresh while one is pending\" — layers that in their\n * own reducer, e.g. by tracking a pending flag; this brick stays minimal and\n * lets the consumer own that policy.)\n *\n * The `at` is the caller's stamped clock reading (epoch ms), never read here —\n * keeping the verb pure and `replay`-able.\n */\n function ensureFresh(\n state: TokenState,\n at: number,\n ): readonly [TokenState, readonly Cmd[]] {\n if (needsRefresh(state, at)) {\n return [state, [refreshTokenCmd()]];\n }\n return [state, []];\n }\n\n /**\n * The interpret splice: wraps the injected `refresh` port and routes its\n * Ok/Err to `token_refreshed` / `token_refresh_failed` Msgs via the core's\n * `tryInterpret` Railway helper. Spread the result into a consumer's\n * `interpret` map:\n *\n * interpret: { ...other, ...tr.handlers({ refresh: () => sdk.mintToken() }) }\n *\n * The port is the ONLY place the network (and any clock the issuer reads) is\n * touched — every verb above stays pure. `tryInterpret` guarantees the\n * handler never rejects: a port rejection becomes a `token_refresh_failed`\n * Msg (errors are data), so the runtime's dispatch loop is never poisoned by\n * an unhandled rejection.\n */\n function handlers(\n ports: TokenRefreshPorts,\n ): Interpret<TokenRefreshMsg, RefreshTokenCmd, unknown> {\n return {\n refresh_token: tryInterpret<\n RefreshTokenCmd,\n Token,\n TokenRefreshMsg,\n unknown\n >(\n () => ports.refresh(),\n (token) => tokenRefreshedMsg(token),\n (error) => tokenRefreshFailedMsg(error),\n ),\n };\n }\n\n return {\n /** The slice initializer — spread into the consumer's `init`. */\n init: initTokenRefresh,\n needsRefresh,\n on401,\n refreshed,\n ensureFresh,\n handlers,\n };\n}\n\n/** The shape `createTokenRefresh` returns — useful for typing a held knob. */\nexport type TokenRefresh = ReturnType<typeof createTokenRefresh>;\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/token-refresh/index.ts"],"names":[],"mappings":";;;;AAkJO,SAAS,gBAAA,GAA+B;AAC7C,EAAA,OAAO,EAAE,KAAA,EAAO,IAAA,EAAM,KAAA,EAAO,KAAA,EAAM;AACrC;AAYO,SAAS,eAAA,GAAmC;AACjD,EAAA,OAAO,EAAE,MAAM,eAAA,EAAgB;AACjC;AAaO,SAAS,kBAAkB,KAAA,EAAiC;AACjE,EAAA,OAAO,EAAE,IAAA,EAAM,OAAA,CAAQ,cAAA,EAAgB,KAAA,EAAM;AAC/C;AAgBO,SAAS,sBAAsB,KAAA,EAAuC;AAC3E,EAAA,OAAO,EAAE,IAAA,EAAM,OAAA,CAAQ,kBAAA,EAAoB,KAAA,EAAM;AACnD;AA0BO,SAAS,kBAAA,CAAmB,MAAA,GAA6B,EAAC,EAAG;AAIlE,EAAA,MAAM,MAAA,GACJ,OAAO,MAAA,KAAW,MAAA,IAAa,OAAO,MAAA,GAAS,CAAA,GAAI,OAAO,MAAA,GAAS,CAAA;AAsBrE,EAAA,SAAS,YAAA,CAAa,OAAmB,EAAA,EAAqB;AAC5D,IAAA,IAAI,KAAA,CAAM,KAAA,KAAU,IAAA,EAAM,OAAO,IAAA;AACjC,IAAA,IAAI,KAAA,CAAM,OAAO,OAAO,IAAA;AAKxB,IAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,MAAM,KAAA,CAAM,SAAS,GAAG,OAAO,IAAA;AACpD,IAAA,OAAO,EAAA,IAAM,KAAA,CAAM,KAAA,CAAM,SAAA,GAAY,MAAA;AAAA,EACvC;AAcA,EAAA,SAAS,MAAM,KAAA,EAA+B;AAC5C,IAAA,IAAI,KAAA,CAAM,OAAO,OAAO,KAAA;AACxB,IAAA,OAAO,EAAE,GAAG,KAAA,EAAO,KAAA,EAAO,IAAA,EAAK;AAAA,EACjC;AAmBA,EAAA,SAAS,SAAA,CAAU,OAAmB,KAAA,EAA0B;AAC9D,IAAA,MAAM,UAAA,GAAoB,MAAA,CAAO,EAAA,CAAG,KAAA,CAAM,SAAA,EAAW,EAAE,CAAA,GACnD,EAAE,GAAG,KAAA,EAAO,SAAA,EAAW,CAAA,EAAE,GACzB,KAAA;AACJ,IAAA,OAAO,EAAE,GAAG,KAAA,EAAO,KAAA,EAAO,UAAA,EAAY,OAAO,KAAA,EAAM;AAAA,EACrD;AAgBA,EAAA,SAAS,WAAA,CACP,OACA,EAAA,EACuC;AACvC,IAAA,IAAI,YAAA,CAAa,KAAA,EAAO,EAAE,CAAA,EAAG;AAC3B,MAAA,OAAO,CAAC,KAAA,EAAO,CAAC,eAAA,EAAiB,CAAC,CAAA;AAAA,IACpC;AACA,IAAA,OAAO,CAAC,KAAA,EAAO,EAAE,CAAA;AAAA,EACnB;AAgBA,EAAA,SAAS,SACP,KAAA,EACsD;AACtD,IAAA,OAAO;AAAA,MACL,aAAA,EAAe,YAAA;AAAA,QAMb,MAAM,MAAM,OAAA,EAAQ;AAAA,QACpB,CAAC,KAAA,KAAU,iBAAA,CAAkB,KAAK,CAAA;AAAA,QAClC,CAAC,KAAA,KAAU,qBAAA,CAAsB,KAAK;AAAA;AACxC,KACF;AAAA,EACF;AAEA,EAAA,OAAO;AAAA;AAAA,IAEL,IAAA,EAAM,gBAAA;AAAA,IACN,YAAA;AAAA,IACA,KAAA;AAAA,IACA,SAAA;AAAA,IACA,WAAA;AAAA,IACA;AAAA,GACF;AACF","file":"chunk-SK5JGUY7.js","sourcesContent":["/**\n * @demlik/tea/token-refresh — a credential's lifecycle as pure state + verbs,\n * with the actual token fetch deferred to a single injected port.\n *\n * This is the L1 brick `authed-call` (#10 in the composition specs) stacks on:\n * a token slice `{ token, stale }` plus pure transitions that decide *when* a\n * credential must be re-minted, and one `handlers(ports)` splice that performs\n * the re-mint. It follows the uniform knob contract every composition wears —\n * `createTokenRefresh(config) → { init, <verbs>, handlers }` — so a consumer\n * spreads ~3 hooks into their machine instead of hand-wiring the refresh dance.\n *\n * It is a LEAF module in the same mold as `@demlik/tea/retry-backoff`: it\n * depends only on the core (`../index`), never on a sibling brick. The two\n * non-negotiables of every knob hold here by construction:\n *\n * - **Durable** — the token slice is plain JSON-serializable data, so it\n * survives a Durable Object eviction / page reload. A rehydrated machine\n * resumes knowing exactly which token it holds and whether it is stale.\n * - **Replayable** — every transition is a pure verb returning\n * `readonly [State, Cmd[]]`. No verb reads the wall clock or performs I/O,\n * so `replay` reconstructs the slice exactly. Time enters ONLY as an `at`\n * parameter the caller stamps; the network fetch enters ONLY at the\n * `handlers` boundary. This keeps the substrate's purity invariants intact\n * (invariant 2: pure transitions; invariant 4: I/O is an effect, not a\n * reducer body).\n *\n * ## The staleness model — two independent reasons to refresh\n *\n * A token becomes unusable for two unrelated reasons, and the slice tracks both\n * without conflating them:\n *\n * 1. **Expiry** — the token has a known `expiresAt` (epoch ms). It is \"near\n * expiry\" once `at >= expiresAt - skewMs`. The `skewMs` skew refreshes\n * *ahead* of the hard deadline so an in-flight call never races a token\n * that expires mid-request. This is the proactive, time-driven reason and\n * is computed purely from the injected `at` — no clock in the verb.\n *\n * 2. **Server rejection (401)** — the server rejected a request the client\n * believed was still valid (clock skew, a server-side revocation, an early\n * rotation). `on401` flips a `stale` flag regardless of `expiresAt`. This\n * is the reactive reason: the client's belief about validity was wrong, so\n * the next `needsRefresh` returns true even though the clock says the\n * token is fine.\n *\n * `needsRefresh` is the union of the two (plus \"no token at all\"). One source\n * of truth for \"should I fetch a fresh credential before the next call?\", read\n * by the consumer's reducer at the call boundary.\n *\n * Typical wiring (inside a consumer machine):\n *\n * const tr = createTokenRefresh({ skewMs: 30_000 });\n *\n * // in init:\n * init: (loaded) => [loaded ?? { auth: tr.init() }, Cmd.none],\n *\n * // in a reducer cell that is about to make an authed call:\n * before_call: (s, msg) => {\n * const [auth, cmds] = tr.ensureFresh(s.auth, msg.at);\n * return [{ ...s, auth }, cmds]; // emits a refresh Cmd iff a refresh is due\n * },\n *\n * // on a 401 from a guarded call:\n * got_401: (s) => [{ ...s, auth: tr.on401(s.auth) }, []],\n *\n * // when the refresh port resolves:\n * token_refreshed: (s, msg) => [{ ...s, auth: tr.refreshed(s.auth, msg.token) }, []],\n *\n * // splice the I/O:\n * interpret: { ...tr.handlers({ refresh: () => mySdk.mintToken() }) },\n *\n * NOT a substrate primitive: from `../index` it reuses only the `Cmd` /\n * `Interpret` types and the `tryInterpret` Railway helper (a runtime function,\n * not a type) that every effectful brick uses — and no sibling brick. It is a\n * leaf utility consumers reach via the `@demlik/tea/token-refresh` subpath.\n */\n\nimport { type Cmd, type Interpret, tryInterpret } from \"../index\";\nimport { MsgType } from \"../protocol\";\n\n/**\n * A minted credential: the opaque `value` to send on the wire, and the absolute\n * `expiresAt` (epoch milliseconds — the `Date.now()` scale) the issuer stamped\n * it with. Both fields are plain data so a `Token` is trivially serializable\n * into a consumer's `Store<S>`.\n *\n * `value` is `string` because that is the universal shape of a bearer token /\n * API key / session cookie on the wire. A consumer whose refresh port yields a\n * richer object can JSON-encode it into `value`, or keep this module's slice as\n * the \"is it fresh?\" gate and hold the richer object elsewhere in their Model.\n */\nexport interface Token {\n /** The opaque credential to present on each request (bearer token, API key). */\n readonly value: string;\n /** Absolute expiry instant, epoch milliseconds. The `Date.now()` scale. */\n readonly expiresAt: number;\n}\n\n/**\n * Pure configuration — the knob's only field. Carries no mutable state;\n * `TokenState` does. One config is shared across all the refreshes of one\n * logical credential (often a module-scope const).\n */\nexport interface TokenRefreshConfig {\n /**\n * Refresh-ahead skew, in milliseconds. A token is treated as needing a\n * refresh once `at >= expiresAt - skewMs`, i.e. `skewMs` BEFORE its hard\n * expiry. This buys headroom so a call that starts just under the wire never\n * races a token that lapses mid-flight, and absorbs small client/server clock\n * disagreement. `0` means \"refresh exactly at expiry\"; a negative value is\n * clamped to `0` (a skew that pushes the trigger past expiry is meaningless).\n *\n * @default 0\n */\n readonly skewMs?: number;\n}\n\n/**\n * The token slice this knob owns — the Model field a consumer spreads in via\n * `init()`. A two-field record rather than a phase union because the two\n * dimensions are orthogonal: a token can be present-and-fresh, present-but-stale\n * (a 401 came back), absent, or absent-and-stale (cleared after a failed\n * refresh). Every combination is meaningful, so neither field implies the\n * other's value and a discriminated union would only manufacture impossible\n * arms.\n *\n * - `token` — the currently held credential, or `null` when none has been\n * minted yet (fresh boot) or the last refresh failed.\n * - `stale` — set by `on401` when the server rejected a request the client\n * believed valid. Forces the next `needsRefresh` to true regardless of\n * `expiresAt`; cleared by `refreshed` once a new credential lands.\n *\n * Both fields `readonly` so the whole slice threads safely through a pure\n * reducer and serializes into a `Store<S>`.\n */\nexport interface TokenState {\n /** The held credential, or `null` if none is available (boot / failed refresh). */\n readonly token: Token | null;\n /** Whether a 401 has marked the held token unusable ahead of its `expiresAt`. */\n readonly stale: boolean;\n}\n\n/**\n * The starting slice: no token held, not stale. The first `needsRefresh` after\n * this returns `true` (no token), so a consumer's first guarded call mints a\n * credential before going out.\n */\nexport function initTokenRefresh(): TokenState {\n return { token: null, stale: false };\n}\n\n/**\n * The Cmd this knob emits to ask the runtime to mint a fresh token. Plain data\n * (no closure — invariant 1) carrying nothing: the refresh port takes no input,\n * and the slice the result installs is read off the follow-up Msg, not this\n * Cmd. The consumer unions this into their machine's Cmd type and lets\n * `handlers` interpret it.\n */\nexport interface RefreshTokenCmd extends Cmd<\"refresh_token\"> {}\n\n/** Construct a `refresh_token` Cmd. */\nexport function refreshTokenCmd(): RefreshTokenCmd {\n return { type: \"refresh_token\" };\n}\n\n/**\n * The Msg the refresh port dispatches on success — carries the freshly minted\n * `Token`. The consumer unions this into their Msg type and handles it by\n * folding `refreshed(state, msg.token)` into the slice.\n */\nexport interface TokenRefreshedMsg {\n readonly type: typeof MsgType.TokenRefreshed;\n readonly token: Token;\n}\n\n/** Construct a `token_refreshed` Msg carrying the new token. */\nexport function tokenRefreshedMsg(token: Token): TokenRefreshedMsg {\n return { type: MsgType.TokenRefreshed, token };\n}\n\n/**\n * The Msg the refresh port dispatches on failure — carries the rejection so the\n * consumer's reducer can decide whether to back off, surface an error, or give\n * up (errors are data: it is dispatched, never swallowed). The slice itself is\n * left untouched by a failed refresh — the held token (if any) stays held and\n * stale stays set, so the consumer can retry; clearing it is the consumer's\n * policy, expressed in their reducer.\n */\nexport interface TokenRefreshFailedMsg {\n readonly type: typeof MsgType.TokenRefreshFailed;\n readonly error: unknown;\n}\n\n/** Construct a `token_refresh_failed` Msg carrying the rejection. */\nexport function tokenRefreshFailedMsg(error: unknown): TokenRefreshFailedMsg {\n return { type: MsgType.TokenRefreshFailed, error };\n}\n\n/** The two Msgs the refresh port can produce. Union for a consumer's Msg type. */\nexport type TokenRefreshMsg = TokenRefreshedMsg | TokenRefreshFailedMsg;\n\n/** The ports this knob's `handlers` splice needs — the single refresh DI seam. */\nexport interface TokenRefreshPorts {\n /**\n * Mint a fresh credential. Called ONLY from the `refresh_token` interpret\n * handler — never from a verb — so the network I/O and any clock the issuer\n * reads stay at the effect boundary. May reject; the rejection is routed to\n * `token_refresh_failed` (errors are data), never thrown into the runtime.\n */\n readonly refresh: () => Promise<Token>;\n}\n\n/**\n * The knob: hand it a config, get back the slice initializer, the pure verbs,\n * and the `handlers` splice. Mirrors every L2 composition's uniform contract so\n * the call sites read identically across bricks.\n *\n * @example\n * const tr = createTokenRefresh({ skewMs: 30_000 });\n * const s0 = tr.init();\n * const [s1, cmds] = tr.ensureFresh(s0, Date.now()); // cmds = [refreshTokenCmd()]\n */\nexport function createTokenRefresh(config: TokenRefreshConfig = {}) {\n // Clamp once at construction: a negative skew would push the refresh trigger\n // PAST expiry, which inverts the knob's intent (\"refresh ahead\"). NaN (a\n // miswired config) collapses to 0 so `needsRefresh`'s comparison stays total.\n const skewMs =\n config.skewMs !== undefined && config.skewMs > 0 ? config.skewMs : 0;\n\n /**\n * Whether the held token must be refreshed before the next guarded call, at\n * the injected instant `at` (epoch ms). PURE — reads only, no clock, no\n * mutation. True iff ANY of:\n *\n * - no token is held (`token === null`) — nothing to present;\n * - the token was marked `stale` by a 401 — the server disagrees with the\n * client's belief that it is valid;\n * - the token's `expiresAt` is not a finite number (`NaN` / `±Infinity`) —\n * a miswired issuer or a corrupted slice; we cannot reason about when it\n * lapses, so we fail SAFE toward minting a fresh one rather than letting\n * `at >= NaN` (always `false`) or `at >= Infinity` (always `false`) mask\n * a broken expiry as \"fresh forever\";\n * - the token is at or past its skewed expiry (`at >= expiresAt - skewMs`)\n * — the proactive refresh-ahead window has opened.\n *\n * The expiry cutoff is half-open (`>=`), matching the window edges in\n * `rate-limit` / `cache` / `circuit-breaker`: a token minted to expire at `t`\n * with zero skew is considered due at exactly `t`.\n */\n function needsRefresh(state: TokenState, at: number): boolean {\n if (state.token === null) return true;\n if (state.stale) return true;\n // Non-finite expiry (NaN/±Infinity) can never be compared into a \"due\"\n // verdict — `at >= NaN` and `at >= Infinity` are both `false`, which would\n // pin a broken token as fresh forever. Fail safe: treat it as needing a\n // refresh so the next call mints a credential with a real deadline.\n if (!Number.isFinite(state.token.expiresAt)) return true;\n return at >= state.token.expiresAt - skewMs;\n }\n\n /**\n * Mark the held token unusable because a request the client believed valid\n * came back 401. PURE — returns a new slice; the input is never mutated.\n * Sets `stale` regardless of `expiresAt`, so the next `needsRefresh` returns\n * true even when the clock still says the token is fine. The token VALUE is\n * kept (not nulled) — it is the consumer's last-known credential until a\n * refresh replaces it, and keeping it lets observability show what was\n * rejected.\n *\n * Idempotent: a second 401 on an already-stale slice returns the same shape.\n * A 401 with no token held is a no-op (already needs a refresh).\n */\n function on401(state: TokenState): TokenState {\n if (state.stale) return state;\n return { ...state, stale: true };\n }\n\n /**\n * Install a freshly minted `token`, clearing `stale`. PURE — returns a new\n * slice; the input is never mutated. This is the fold for the\n * `token_refreshed` Msg: after it, `needsRefresh(state, at)` is false for any\n * `at` below the new skewed expiry, and the stale flag is gone (the new\n * credential supersedes whatever the 401 rejected).\n *\n * This is the slice's WRITE boundary for a credential, so it is where the\n * durability invariant is paid for: it normalizes a negative-zero `expiresAt`\n * to `+0` before the value enters the slice. `-0` is the one finite double\n * JSON cannot round-trip — `JSON.stringify(-0)` is `\"0\"`, which parses back as\n * `+0`, so a slice carrying `-0` would diverge from its persisted form across\n * a Durable Object eviction / page reload (invariant: the boundary parses, the\n * core trusts). `-0` and `+0` denote the same epoch-ms instant, so collapsing\n * them loses no information and makes the un-round-trippable state\n * unrepresentable in the slice by construction.\n */\n function refreshed(state: TokenState, token: Token): TokenState {\n const normalized: Token = Object.is(token.expiresAt, -0)\n ? { ...token, expiresAt: 0 }\n : token;\n return { ...state, token: normalized, stale: false };\n }\n\n /**\n * The verb a consumer calls at the call boundary: \"make sure I have a fresh\n * credential before the next request.\" PURE — returns `[next, cmds]`. When a\n * refresh is due (`needsRefresh(state, at)`), emits a single `refresh_token`\n * Cmd; otherwise emits none. The slice is returned UNCHANGED in both branches\n * — the refresh itself is async and lands later via `refreshed`, so there is\n * no in-flight phase to record here. (A consumer that wants single-flight —\n * \"don't fire a second refresh while one is pending\" — layers that in their\n * own reducer, e.g. by tracking a pending flag; this brick stays minimal and\n * lets the consumer own that policy.)\n *\n * The `at` is the caller's stamped clock reading (epoch ms), never read here —\n * keeping the verb pure and `replay`-able.\n */\n function ensureFresh(\n state: TokenState,\n at: number,\n ): readonly [TokenState, readonly Cmd[]] {\n if (needsRefresh(state, at)) {\n return [state, [refreshTokenCmd()]];\n }\n return [state, []];\n }\n\n /**\n * The interpret splice: wraps the injected `refresh` port and routes its\n * Ok/Err to `token_refreshed` / `token_refresh_failed` Msgs via the core's\n * `tryInterpret` Railway helper. Spread the result into a consumer's\n * `interpret` map:\n *\n * interpret: { ...other, ...tr.handlers({ refresh: () => sdk.mintToken() }) }\n *\n * The port is the ONLY place the network (and any clock the issuer reads) is\n * touched — every verb above stays pure. `tryInterpret` guarantees the\n * handler never rejects: a port rejection becomes a `token_refresh_failed`\n * Msg (errors are data), so the runtime's dispatch loop is never poisoned by\n * an unhandled rejection.\n */\n function handlers(\n ports: TokenRefreshPorts,\n ): Interpret<TokenRefreshMsg, RefreshTokenCmd, unknown> {\n return {\n refresh_token: tryInterpret<\n RefreshTokenCmd,\n Token,\n TokenRefreshMsg,\n unknown\n >(\n () => ports.refresh(),\n (token) => tokenRefreshedMsg(token),\n (error) => tokenRefreshFailedMsg(error),\n ),\n };\n }\n\n return {\n /** The slice initializer — spread into the consumer's `init`. */\n init: initTokenRefresh,\n needsRefresh,\n on401,\n refreshed,\n ensureFresh,\n handlers,\n };\n}\n\n/** The shape `createTokenRefresh` returns — useful for typing a held knob. */\nexport type TokenRefresh = ReturnType<typeof createTokenRefresh>;\n"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { initPaginator, start, recordPage, drain, resume, isDone } from './chunk-SINXE47Y.js';
|
|
2
|
-
import { createResilientCall } from './chunk-
|
|
2
|
+
import { createResilientCall } from './chunk-IXBKFQH5.js';
|
|
3
3
|
import { MsgType } from './chunk-JELSZN25.js';
|
|
4
4
|
|
|
5
5
|
// src/paginated-walk/index.ts
|
|
@@ -31,13 +31,15 @@ function createPaginatedWalk(config, rng = Math.random) {
|
|
|
31
31
|
const [resilience, cmds] = rc.attempt(s.resilience, PAGE_KEY, cursor, at);
|
|
32
32
|
return [withResilience(s, resilience), cmds];
|
|
33
33
|
}
|
|
34
|
-
function
|
|
35
|
-
const walk = start(s.walk, paginatorPolicy);
|
|
34
|
+
function armAndFetch(s, walk, at) {
|
|
36
35
|
if (walk === s.walk || walk.phase !== "fetching") {
|
|
37
36
|
return [s, []];
|
|
38
37
|
}
|
|
39
38
|
return fetch({ ...s, walk }, walk.cursor, at);
|
|
40
39
|
}
|
|
40
|
+
function start2(s, at) {
|
|
41
|
+
return armAndFetch(s, start(s.walk, paginatorPolicy), at);
|
|
42
|
+
}
|
|
41
43
|
function pageOk(s, page, at) {
|
|
42
44
|
const [resilience] = rc.succeed(s.resilience, PAGE_KEY, {
|
|
43
45
|
type: MsgType.ResilientOk,
|
|
@@ -86,11 +88,7 @@ function createPaginatedWalk(config, rng = Math.random) {
|
|
|
86
88
|
return [{ ...s, walk }, []];
|
|
87
89
|
}
|
|
88
90
|
function resume2(s, at) {
|
|
89
|
-
|
|
90
|
-
if (walk === s.walk || walk.phase !== "fetching") {
|
|
91
|
-
return [s, []];
|
|
92
|
-
}
|
|
93
|
-
return fetch({ ...s, walk }, walk.cursor, at);
|
|
91
|
+
return armAndFetch(s, resume(s.walk, paginatorPolicy), at);
|
|
94
92
|
}
|
|
95
93
|
function isComplete(s) {
|
|
96
94
|
return isDone(s.walk);
|
|
@@ -128,5 +126,5 @@ function liftWalk(state, [slice, cmds]) {
|
|
|
128
126
|
}
|
|
129
127
|
|
|
130
128
|
export { PAGE_KEY, createPaginatedWalk, liftWalk };
|
|
131
|
-
//# sourceMappingURL=chunk-
|
|
132
|
-
//# sourceMappingURL=chunk-
|
|
129
|
+
//# sourceMappingURL=chunk-TPGASTFF.js.map
|
|
130
|
+
//# sourceMappingURL=chunk-TPGASTFF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/paginated-walk/index.ts"],"names":["start","drain","resume"],"mappings":"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","file":"chunk-TPGASTFF.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 /**\n * Issue the page fetch for a freshly-armed paginator transition, else a pure\n * no-op. `walk` is the result of a paginator verb (`startWalk` / `resumeWalk`)\n * applied to `s.walk`; this states the arm-and-fetch contract ONCE for the two\n * verbs that share it (`start`, `resume`).\n *\n * A paginator verb is a pure no-op on any phase it does not act on — it returns\n * the SAME state object. Reference identity (not phase) is the honest \"did the\n * verb arm a fresh fetch?\" 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 the verb produced a fresh state. The\n * `phase === \"fetching\"` narrow that follows is therefore always true here\n * (both verbs only ever produce a `fetching` arm), and also narrows `.cursor`\n * for the type checker. PURE — `at` threads into the fetch gate.\n */\n function armAndFetch(\n s: PaginatedWalkState<Cursor, Page>,\n walk: PaginatorState<Cursor>,\n at: number,\n ): readonly [PaginatedWalkState<Cursor, Page>, readonly OutCmd[]] {\n if (walk === s.walk || walk.phase !== \"fetching\") {\n return [s, []];\n }\n return fetch({ ...s, walk }, walk.cursor, at);\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 // `startWalk` is a pure no-op on any non-`idle` phase (same reference); a\n // fresh `fetching(firstCursor)` arm → issue the first page fetch.\n return armAndFetch(s, startWalk(s.walk, paginatorPolicy), 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 // `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 return armAndFetch(s, resumeWalk(s.walk, paginatorPolicy), 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"]}
|
|
@@ -49,5 +49,5 @@ function canPass(state, policy, nowMs) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export { canPass, defaultCircuitPolicy, initCircuit, onFailure, onSuccess };
|
|
52
|
-
//# sourceMappingURL=chunk-
|
|
53
|
-
//# sourceMappingURL=chunk-
|
|
52
|
+
//# sourceMappingURL=chunk-UF4MZSLT.js.map
|
|
53
|
+
//# sourceMappingURL=chunk-UF4MZSLT.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/circuit-breaker/index.ts"],"names":[],"mappings":";AAyHO,IAAM,oBAAA,GAAsC;AAAA,EACjD,gBAAA,EAAkB,CAAA;AAAA,EAClB,UAAA,EAAY,GAAA;AAAA,EACZ,iBAAA,EAAmB;AACrB;AAoBO,SAAS,WAAA,GAA4B;AAC1C,EAAA,OAAO,EAAE,KAAA,EAAO,QAAA,EAAU,QAAA,EAAU,CAAA,EAAE;AACxC;AAYO,SAAS,SAAA,CACd,OACA,OAAA,EACc;AACd,EAAA,QAAQ,MAAM,KAAA;AAAO,IACnB,KAAK,QAAA;AAGH,MAAA,OAAO,KAAA,CAAM,aAAa,CAAA,GAAI,KAAA,GAAQ,EAAE,KAAA,EAAO,QAAA,EAAU,UAAU,CAAA,EAAE;AAAA,IACvE,KAAK,WAAA;AACH,MAAA,OAAO,EAAE,KAAA,EAAO,QAAA,EAAU,QAAA,EAAU,CAAA,EAAE;AAAA,IACxC,KAAK,MAAA;AACH,MAAA,OAAO,KAAA;AAAA;AAEb;AAgBO,SAAS,SAAA,CACd,KAAA,EACA,MAAA,EACA,KAAA,EACc;AACd,EAAA,QAAQ,MAAM,KAAA;AAAO,IACnB,KAAK,QAAA,EAAU;AACb,MAAA,MAAM,QAAA,GAAW,MAAM,QAAA,GAAW,CAAA;AAGlC,MAAA,OAAO,QAAA,IAAY,MAAA,CAAO,gBAAA,GACtB,EAAE,KAAA,EAAO,MAAA,EAAQ,UAAA,EAAY,KAAA,EAAM,GACnC,EAAE,KAAA,EAAO,QAAA,EAAU,QAAA,EAAS;AAAA,IAClC;AAAA,IACA,KAAK,WAAA;AAEH,MAAA,OAAO,EAAE,KAAA,EAAO,MAAA,EAAQ,UAAA,EAAY,KAAA,EAAM;AAAA,IAC5C,KAAK,MAAA;AACH,MAAA,OAAO,KAAA;AAAA;AAEb;AA0BO,SAAS,OAAA,CACd,KAAA,EACA,MAAA,EACA,KAAA,EACkC;AAClC,EAAA,QAAQ,MAAM,KAAA;AAAO,IACnB,KAAK,QAAA;AACH,MAAA,OAAO,CAAC,OAAO,IAAI,CAAA;AAAA,IACrB,KAAK,MAAA,EAAQ;AACX,MAAA,MAAM,OAAA,GAAU,QAAQ,KAAA,CAAM,UAAA;AAG9B,MAAA,IAAI,OAAA,IAAW,OAAO,UAAA,EAAY;AAChC,QAAA,OAAO,CAAC,EAAE,KAAA,EAAO,aAAa,MAAA,EAAQ,CAAA,IAAK,IAAI,CAAA;AAAA,MACjD;AACA,MAAA,OAAO,CAAC,OAAO,KAAK,CAAA;AAAA,IACtB;AAAA,IACA,KAAK,WAAA;AACH,MAAA,IAAI,KAAA,CAAM,MAAA,GAAS,MAAA,CAAO,iBAAA,EAAmB;AAC3C,QAAA,OAAO,CAAC,EAAE,KAAA,EAAO,WAAA,EAAa,QAAQ,KAAA,CAAM,MAAA,GAAS,CAAA,EAAE,EAAG,IAAI,CAAA;AAAA,MAChE;AACA,MAAA,OAAO,CAAC,OAAO,KAAK,CAAA;AAAA;AAE1B","file":"chunk-UF4MZSLT.js","sourcesContent":["/**\n * @packageDocumentation\n * @demlik/tea/circuit-breaker — per-target failure tracking as pure state + ops.\n *\n * Same shape as `@demlik/tea/work-queue`'s `ops.ts`, `@demlik/tea/rate-limit`,\n * and `@demlik/tea/idempotency`: a state type plus pure transition functions.\n * Host-agnostic by construction — no `Date.now()`, no timers, no I/O. The\n * caller injects the clock (`nowMs`) and wires the result into its own reducer\n * / interpret. This keeps the breaker inside TEA's invariant 2 (transitions are\n * pure: same inputs → same outputs, no `Date.now`) so a consumer can fold it\n * through `update` and exercise it with `replay` / the pbt fold without a fake\n * clock.\n *\n * Every op returns a NEW state value and never mutates its input (invariant 1 —\n * state is a value, not a place). `canPass` returns a `[next, allowed]` tuple so\n * the caller branches on the decision while threading the advanced state forward\n * — same convention as `rate-limit`'s `tryConsume` / `record`. `onSuccess` /\n * `onFailure` are the outcome-recording transitions the caller folds in once the\n * guarded call resolves.\n *\n * ## The phase machine\n *\n * A circuit breaker guards a fallible downstream target (an HTTP backend, a DB,\n * a flaky tool call). Instead of hammering a target that is already failing, it\n * trips OPEN after enough failures and fast-fails subsequent calls for a\n * cooldown, then probes with a few trial calls before fully closing again.\n * Three phases, each carrying ONLY its own data so impossible states are\n * unrepresentable (pattern 11):\n *\n * - **closed** — the healthy steady state. Calls pass through. Carries\n * `failures`: the count of *consecutive* failures since the last success.\n * A success resets it to 0; the `failureThreshold`-th failure trips the\n * breaker OPEN. Only `closed` has a failure counter — `open` and\n * `half_open` don't, because in those phases the count is irrelevant\n * (the breaker has already tripped / is already probing).\n *\n * - **open** — the tripped state. All calls fast-fail (no downstream load).\n * Carries `openedAtMs`: the clock reading at which the breaker tripped.\n * `canPass` compares `nowMs - openedAtMs` against `cooldownMs` to decide\n * when to start probing. Only `open` carries a timestamp — neither other\n * phase is timing anything.\n *\n * - **half_open** — the probing state. A limited number of trial calls are\n * allowed through to test whether the target has recovered. Carries\n * `probes`: the count of probes admitted so far this probing round.\n * `canPass` admits while `probes < halfOpenMaxProbes`, then fast-fails the\n * rest (so a slow target can't be flooded by every queued caller at once).\n * A probe success closes the breaker; a probe failure re-opens it (and\n * resets the cooldown clock). Only `half_open` counts in-flight probes.\n *\n * ### Transition table\n *\n * Rows are the current phase; columns are the event. `→` denotes the next\n * phase; `self` means unchanged. `nowMs` is the injected clock.\n *\n * | from \\ event | onSuccess | onFailure | canPass (→ allowed?) |\n * | ------------ | -------------- | -------------------------------------- | ------------------------------------------------------------------------------------ |\n * | closed | closed(0) | failures+1; trip → open(nowMs) at thr. | self → true (always) |\n * | open | self *(¹)* | self | elapsed ≥ cooldown → half_open(1), true; else self → false |\n * | half_open | → closed(0) | → open(nowMs) (probe failed) | probes < max → half_open(probes+1), true; else self → false |\n *\n * *(¹)* `onSuccess` on `open` \"shouldn't happen\": no call was admitted while\n * open, so no success can be reported for one. We leave the state unchanged\n * rather than throw — the breaker is a coordination aid, not a correctness\n * gate, and a spurious success report (e.g. a late-arriving result from before\n * the trip) must not silently un-trip the breaker. `onFailure` on `open` is\n * likewise a no-op: the breaker is already tripped; the `open` clock is anchored\n * at the trip, not bumped by late failures.\n *\n * ### The probe-admission asymmetry\n *\n * Entering `half_open` happens inside `canPass`, not `onSuccess` / `onFailure`,\n * because the cooldown is a *time* condition and only `canPass` reads the clock\n * against `openedAtMs`. The first `canPass` after the cooldown elapses both\n * transitions the phase AND admits the probe (`[half_open(1), true]`) — one\n * caller drives the recovery attempt. Subsequent `canPass` calls in the same\n * round admit up to `halfOpenMaxProbes` total, then fast-fail until a probe\n * resolves and `onSuccess` / `onFailure` moves the phase out of `half_open`.\n *\n * This module is a textbook three-state machine — a clean `/machine-viz` demo.\n *\n * NOT exported from the package root — reached via the\n * `@demlik/tea/circuit-breaker` subpath, same one-shape-per-package rule as\n * `work-queue`, `rate-limit`, and `idempotency`.\n */\n\n/**\n * Circuit-breaker policy — pure configuration, no mutable state. One policy is\n * shared across all the calls guarded by one logical breaker (often a\n * module-scope const). `CircuitState` carries the mutable counts; this carries\n * the thresholds those counts are compared against.\n */\nexport interface CircuitPolicy {\n /**\n * Consecutive failures in `closed` that trip the breaker `open`. The\n * `failureThreshold`-th failure is the one that trips — with a threshold of\n * 5, the breaker survives 4 failures and opens on the 5th.\n */\n readonly failureThreshold: number;\n /**\n * How long the breaker stays `open` before `canPass` starts admitting\n * probes, in milliseconds. Measured from `openedAtMs`; the cutoff is\n * half-open (`elapsed >= cooldownMs` admits), matching `rate-limit` /\n * `idempotency` window edges — a breaker opened at `t` first probes at\n * `t + cooldownMs`.\n */\n readonly cooldownMs: number;\n /**\n * How many probe calls `half_open` admits before fast-failing the rest of\n * the round. Caps the load on a recovering target — a single slow probe\n * can't be joined by every queued caller. The round ends when a probe\n * resolves: success closes the breaker, failure re-opens it.\n */\n readonly halfOpenMaxProbes: number;\n}\n\n/**\n * Sensible defaults: trip after 5 consecutive failures, cool down for 30s,\n * admit a single probe before deciding. Tuned for guarding a network backend;\n * override per call site rather than mutating this shared const.\n */\nexport const defaultCircuitPolicy: CircuitPolicy = {\n failureThreshold: 5,\n cooldownMs: 30_000,\n halfOpenMaxProbes: 1,\n};\n\n/**\n * The breaker's phase. A discriminated union on `phase` so each phase carries\n * ONLY its own data and impossible combinations are unrepresentable (pattern\n * 11): there is no state with both a failure count AND an open timestamp,\n * because such a state is meaningless. `phase` is the discriminant; every field\n * is `readonly` so the whole value is trivially JSON-serializable into a\n * consumer's `Store<S>` and safe to thread through a pure reducer.\n *\n * - `closed` carries `failures` — consecutive failures since last success.\n * - `open` carries `openedAtMs` — clock reading when it tripped.\n * - `half_open` carries `probes` — probes admitted this round.\n */\nexport type CircuitState =\n | { readonly phase: \"closed\"; readonly failures: number }\n | { readonly phase: \"open\"; readonly openedAtMs: number }\n | { readonly phase: \"half_open\"; readonly probes: number };\n\n/** The starting state: closed with zero recorded failures. */\nexport function initCircuit(): CircuitState {\n return { phase: \"closed\", failures: 0 };\n}\n\n/**\n * Record a successful guarded call. PURE — returns a new state; the input is\n * never mutated.\n *\n * - `closed` → reset `failures` to 0 (a success breaks the consecutive run).\n * - `half_open` → the probe succeeded; the target has recovered → `closed`.\n * - `open` → unchanged. A success \"shouldn't happen\" while open (no call\n * was admitted to produce one); a late/spurious success must\n * not silently un-trip the breaker. See module header note ¹.\n */\nexport function onSuccess(\n state: CircuitState,\n _policy: CircuitPolicy,\n): CircuitState {\n switch (state.phase) {\n case \"closed\":\n // Already closed at 0 → return the same shape (cheap; the consumer's\n // store only re-persists on a real change if it dedupes).\n return state.failures === 0 ? state : { phase: \"closed\", failures: 0 };\n case \"half_open\":\n return { phase: \"closed\", failures: 0 };\n case \"open\":\n return state;\n }\n}\n\n/**\n * Record a failed guarded call. PURE — returns a new state; the input is never\n * mutated. `nowMs` is the injected clock, stamped onto the `open` phase when the\n * breaker trips so `canPass` can measure the cooldown later.\n *\n * - `closed` → `failures + 1`; if that reaches `failureThreshold`, trip to\n * `open(nowMs)`. The threshold-th failure is the one that trips.\n * - `half_open` → the probe failed; the target is still unhealthy →\n * `open(nowMs)`, restarting the cooldown clock so a fresh\n * cooldown elapses before the next probe round.\n * - `open` → unchanged. The breaker is already tripped; the `open` clock\n * is anchored at the original trip, not bumped by late\n * failures. See module header note ¹.\n */\nexport function onFailure(\n state: CircuitState,\n policy: CircuitPolicy,\n nowMs: number,\n): CircuitState {\n switch (state.phase) {\n case \"closed\": {\n const failures = state.failures + 1;\n // `>=` not `===`: a threshold of 0 (or any already-exceeded count from a\n // policy change) trips immediately rather than over-counting forever.\n return failures >= policy.failureThreshold\n ? { phase: \"open\", openedAtMs: nowMs }\n : { phase: \"closed\", failures };\n }\n case \"half_open\":\n // A failed probe re-opens and resets the cooldown clock to nowMs.\n return { phase: \"open\", openedAtMs: nowMs };\n case \"open\":\n return state;\n }\n}\n\n/**\n * Decide whether a call may pass, advancing the phase as a side effect of the\n * decision. PURE — returns `[next, allowed]`; the input is never mutated.\n * Branch on the boolean, thread the state forward regardless:\n *\n * const [c1, allowed] = canPass(state.circuit, policy, nowMs);\n * if (!allowed) return [{ ...state, circuit: c1 }, [fastFailCmd]];\n * return [{ ...state, circuit: c1 }, [doGuardedCallCmd]];\n *\n * - `closed` → `[state, true]` — always passes; failures are recorded by\n * `onFailure` once the call resolves, not gated here.\n * - `open` → if `nowMs - openedAtMs >= cooldownMs` the cooldown has\n * elapsed → enter `half_open` and admit this caller as the\n * first probe: `[half_open(1), true]`. Otherwise still cooling\n * down → `[state, false]` (fast-fail, no downstream load).\n * - `half_open` → admit while `probes < halfOpenMaxProbes`, incrementing the\n * count: `[half_open(probes+1), true]`. Once the cap is hit →\n * `[state, false]` until a probe resolves and `onSuccess` /\n * `onFailure` moves the phase out of `half_open`.\n *\n * Entering `half_open` lives here (not in `onSuccess` / `onFailure`) because the\n * cooldown is a time condition and only `canPass` reads `nowMs` against\n * `openedAtMs`. See the module header's probe-admission note.\n */\nexport function canPass(\n state: CircuitState,\n policy: CircuitPolicy,\n nowMs: number,\n): readonly [CircuitState, boolean] {\n switch (state.phase) {\n case \"closed\":\n return [state, true];\n case \"open\": {\n const elapsed = nowMs - state.openedAtMs;\n // Half-open cutoff: elapsed exactly == cooldownMs admits the first probe,\n // matching the >= edge `rate-limit` / `idempotency` use for their windows.\n if (elapsed >= policy.cooldownMs) {\n return [{ phase: \"half_open\", probes: 1 }, true];\n }\n return [state, false];\n }\n case \"half_open\":\n if (state.probes < policy.halfOpenMaxProbes) {\n return [{ phase: \"half_open\", probes: state.probes + 1 }, true];\n }\n return [state, false];\n }\n}\n"]}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { createResilientCall, liftResilience } from './chunk-
|
|
1
|
+
import { createResilientCall, liftResilience } from './chunk-IXBKFQH5.js';
|
|
2
2
|
import { MsgType } from './chunk-JELSZN25.js';
|
|
3
|
+
import { describeError } from './chunk-G5E74IJB.js';
|
|
3
4
|
|
|
4
5
|
// src/llm-call/index.ts
|
|
5
6
|
function createLlmCall(config, rng = Math.random) {
|
|
@@ -40,13 +41,13 @@ function createLlmCall(config, rng = Math.random) {
|
|
|
40
41
|
async function settleOf(cmd) {
|
|
41
42
|
const settle = await resilientRunHandler(cmd, {});
|
|
42
43
|
if (settle.type === MsgType.ResilientOk) {
|
|
43
|
-
return settle;
|
|
44
|
+
return { ...settle, result: { ...settle.result, key: cmd.key } };
|
|
44
45
|
}
|
|
45
46
|
const rawError = settle.error;
|
|
46
47
|
const err = {
|
|
47
48
|
key: cmd.key,
|
|
48
49
|
purpose: cmd.input.purpose,
|
|
49
|
-
reason:
|
|
50
|
+
reason: describeError(rawError),
|
|
50
51
|
error: rawError
|
|
51
52
|
};
|
|
52
53
|
return {
|
|
@@ -83,5 +84,5 @@ function liftLlmCall(state, result) {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export { createLlmCall, liftLlmCall };
|
|
86
|
-
//# sourceMappingURL=chunk-
|
|
87
|
-
//# sourceMappingURL=chunk-
|
|
87
|
+
//# sourceMappingURL=chunk-VBUCHVCV.js.map
|
|
88
|
+
//# sourceMappingURL=chunk-VBUCHVCV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/llm-call/index.ts"],"names":["msg"],"mappings":"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","file":"chunk-VBUCHVCV.js","sourcesContent":["/**\n * @packageDocumentation\n * @demlik/tea/llm-call — `resilient-call` + structured-output parse + a typed\n * failure variant, around a purpose-discriminated LLM invocation.\n *\n * This is the ~60-line `call_llm` handler from the audit-agent seed\n * (`interpret.ts` / `effects.ts`) collapsed into the uniform L2 knob: a config\n * object, the resilient-call slice it inherits, and one interpret handler that\n * assembles messages, binds the structured-output schema for the call's\n * `purpose`, invokes the model (retry composed from `../resilient-call`, NOT\n * reinvented), parses the output, and RETURNS the enriched resilient settle Msg\n * (`resilient_ok` / `resilient_err`) so it re-enters the host reducer and drives\n * the inherited succeed/fail → backoff → onTimer loop.\n *\n * ## What it adds over `../resilient-call`\n *\n * - **Two DI ports** (the seed's two seams):\n * 1. `model: (modelId) => LLM` — the model factory. Tests pass a fake so\n * the handler runs end-to-end without touching a real provider; the\n * production worker wires `createChatModel(env, …)`.\n * 2. `loadMessages: Loader` — the SDK / message loader. The seed lazy-\n * imports `@langchain/core/messages` (a top-level `import type` blows\n * up the workers test runner); the loader keeps that import lazy and\n * stubbable. It produces the `BaseMessage[]` for a given call.\n * - **Structured-output parse** — each `purpose` maps to a `Schema` in\n * `config.schemas`. The handler binds `model.withStructuredOutput(schema)`\n * so the invoke resolves to a typed object; a `Schema.parse` that throws\n * becomes a `resilient_err` carrying an `LlmErr` (a parse failure is a\n * failure, not a stall — \"errors are data\"). `withStructuredOutput` is the\n * seed's brain-only path; the fake model in tests returns the typed object\n * directly.\n * - **A typed failure variant** — every failure (model throw, timeout-via-\n * retry-exhaustion, schema parse) is surfaced as the `LlmErr` carried on the\n * enriched `resilient_err` settle Msg, tagged with the `purpose` so the\n * consumer's reducer routes per-stage. The seed dispatched\n * `llm_failed{purpose}`; the consumer rebuilds that shape in its reducer arm\n * from `m.error: LlmErr`.\n *\n * ## Inheriting resilient-call by composition (not reinvention)\n *\n * The retry / backoff lives in `../resilient-call`: `createLlmCall` builds a\n * `createResilientCall` knob over the model-invoke port and DELEGATES `init`,\n * `attempt`, `succeed`, `fail`, `onTimer`, `subs` to it verbatim — the slice is\n * literally resilient-call's slice (the spec's \"retry lives here\"). There is no\n * second backoff implementation here. The handler runs the invoke through the\n * resilient-call handler so a transient model failure backs off exactly as a\n * resilient HTTP call would; the `purpose`-branch + structured parse wrap the\n * port the resilient handler drives.\n *\n * ## The two non-negotiables (canon, inherited)\n *\n * - **Durable** — the slice is resilient-call's plain-data slice (a Model\n * field). Input carried on the `resilient_run` Cmd is the plain\n * `LlmCall` request (purpose + modelId + payload) — no closures.\n * - **Replayable** — every transition is a resilient-call verb; the handler\n * is the only impurity, and the one clock read is `Date.now()` at the\n * effect boundary (inside the handler), exactly as the seed stamped its\n * `llm_responded` / `llm_failed` Msgs.\n *\n * ## Typical wiring\n *\n * const llm = createLlmCall<MyPurpose, MyOutputs>({\n * model: (id) => createChatModel(env, id),\n * schemas: { plan: planSchema, report: reportSchema },\n * retry: defaultRetryPolicy,\n * loadMessages: defaultMessagesLoader,\n * });\n *\n * // in the machine:\n * init: () => [{ resilience: llm.init() }, []],\n * update: {\n * call_llm: (s, m) => lift(s, llm.attempt(s.resilience, m.input, m.at)),\n * // `resilient_ok` / `resilient_err` RE-ENTER from `handlers` (the settle\n * // Msg the interpret handler returns). Run the inherited verb FIRST so the\n * // succeed/fail → backoff → onTimer loop advances, THEN fold the enriched\n * // payload (`m.result: LlmOk` / `m.error: LlmErr`) into the host's own\n * // state — the enrichment lives in the reducer arm, not the handler.\n * resilient_ok: (s, m) => {\n * const [slice, cmds] = llm.succeed(s.resilience, m.key, m);\n * return [{ ...s, resilience: slice, output: m.result.output }, cmds];\n * },\n * resilient_err: (s, m) => {\n * const [slice, cmds] = llm.fail(s.resilience, m.key, m);\n * return [{ ...s, resilience: slice, failure: m.error }, cmds];\n * },\n * deadline_exceeded: (s, m) => lift(s, llm.onTimer(s.resilience, m)),\n * },\n * subscriptions: (s) => llm.subs(s.resilience),\n * subscribe: { deadline: subscribeDeadline },\n * interpret: llm.handlers(),\n */\n\nimport { describeError } from \"../describe-error\";\nimport type { Cmd } from \"../index\";\nimport { MsgType } from \"../protocol\";\nimport {\n createResilientCall,\n type DeadlineExceeded,\n type DeadlineSub,\n deadlineSub,\n type FailMsg,\n liftResilience,\n type ResilientConfig,\n type ResilientState,\n type RunCmd,\n type SucceedMsg,\n subscribeDeadline,\n} from \"../resilient-call\";\nimport type { RetryPolicy } from \"../retry-backoff\";\n\n// ===========================================================================\n// The DI port surfaces — model factory + structured output + message loader.\n// ===========================================================================\n\n/**\n * The minimal structured-output schema contract: `parse(unknown) => T`, the\n * zod-style call the handler uses to validate the model's output before it\n * settles `resilient_ok`. A throwing `parse` (the zod contract on invalid\n * input) propagates out of `invokeOne` and is caught by the resilient handler,\n * which settles `resilient_err` (enriched to `LlmErr`) — a parse failure is a\n * failure, never a silent pass. Structural so a real `z.ZodType<T>` satisfies\n * it without an import.\n */\nexport interface Schema<T> {\n /** Validate + narrow `value` to `T`, or throw on mismatch (the zod contract). */\n parse(value: unknown): T;\n}\n\n/**\n * The minimal chat-model contract every model the handler talks to must\n * satisfy — the seed's `InjectableChatModel`, trimmed to the one operation\n * llm-call drives for brain-only stages:\n *\n * `withStructuredOutput(schema)` → a runnable whose `invoke(messages)`\n * resolves to a typed object matching `schema`.\n *\n * `BaseChatModel` from `@langchain/core` is the runtime type; this surface\n * names only what llm-call calls so a test fake can ignore the rest. Generic\n * over the message shape `Msg` so a consumer's loader and model agree on it\n * without llm-call inspecting messages.\n */\nexport interface Llm<Msg> {\n withStructuredOutput<T>(schema: Schema<T>): {\n invoke(messages: readonly Msg[]): Promise<T>;\n };\n}\n\n/**\n * Build the `Msg[]` the handler hands to the bound model for a given call. The\n * SDK / message-assembly seam — the seed's `buildBaseMessages` + the lazy\n * `loadMessages` loader rolled into one injected port. Async because the seed\n * lazy-imports the SDK (a top-level `import type` of the langchain messages\n * package explodes the workers test runner). Receives the full `LlmCall` so it\n * can branch on `purpose` exactly as the seed's `buildBaseMessages` did.\n */\nexport type MessageLoader<P extends string, Msg> = (\n call: LlmCall<P>,\n) => Promise<readonly Msg[]>;\n\n/**\n * The model factory — the first DI port. `(modelId) => Llm`. Tests pass a fake\n * builder; production wires `createChatModel(env, getModelConfig(id))`. `null`\n * means \"the host's default model\" (the seed's `string | null`).\n */\nexport type ModelFactory<Msg> = (modelId: string | null) => Llm<Msg>;\n\n// ===========================================================================\n// Config — the knob. `model` + `schemas` required (the two DI ports + the\n// per-purpose parse target); `retry` + `loadMessages` optional.\n// ===========================================================================\n\n/**\n * The llm-call knob. `model` and `schemas` are the load-bearing pair — the\n * model factory DI port and the per-purpose structured-output targets; the\n * rest is optional, exactly the resilient-call \"omit a brick → omit its gate\"\n * story for `retry`:\n *\n * - `model` — DI port 1: the model factory.\n * - `schemas` — one `Schema` per `Purpose`. The handler binds\n * `schemas[call.purpose]` as the structured-output target.\n * - `retry` — backoff policy, composed straight into `../resilient-\n * call`. Omit it and a model failure is terminal (no\n * backoff), exactly as resilient-call with no retry brick.\n * - `loadMessages` — DI port 2: the SDK / message loader. Omit it and the\n * handler invokes the bound model with `[]` (a degenerate\n * but valid call) — provide it to assemble real messages.\n *\n * `P` is the purpose union (e.g. `\"plan\" | \"report\"`); `O` maps each purpose to\n * its parsed output type; `Msg` is the model's message shape (threaded through\n * the loader + model so llm-call never inspects a message).\n */\nexport interface LlmCallConfig<\n P extends string,\n O extends Record<P, unknown>,\n Msg = unknown,\n> {\n /** DI port 1 — the model factory. `(modelId) => Llm`. */\n readonly model: ModelFactory<Msg>;\n /** One structured-output schema per purpose; the parse target the handler binds. */\n readonly schemas: { readonly [K in P]: Schema<O[K]> };\n /** Backoff policy, composed into `../resilient-call`. Omit → no backoff. */\n readonly retry?: RetryPolicy;\n /** DI port 2 — the SDK / message loader. Omit → the handler invokes with `[]`. */\n readonly loadMessages?: MessageLoader<P, Msg>;\n}\n\n// ===========================================================================\n// The request / response shapes the knob speaks.\n// ===========================================================================\n\n/**\n * One LLM call request — the resilient-call `input` for this knob, carried on\n * the `resilient_run` Cmd as plain data (no closures, invariant 3). Mirrors the\n * seed's purpose-discriminated `CallLlmCmd`: a `purpose` selecting the schema +\n * prompt assembly, the `model` id, and the opaque per-purpose `payload` the\n * loader consumes.\n *\n * `key` defaults to `purpose` when the consumer calls `attempt(s, purpose, …)`,\n * so one in-flight call per purpose is tracked under the resilient-call slice —\n * the common single-call-per-stage shape. A consumer that fans out many calls\n * of one purpose passes a distinct `key`.\n */\nexport interface LlmCall<P extends string> {\n /** The stage / schema selector — drives both `schemas[purpose]` and message assembly. */\n readonly purpose: P;\n /** The model id to invoke; `null` = the host's default model. */\n readonly model: string | null;\n /** The per-purpose prompt payload the `MessageLoader` consumes. Opaque to the knob. */\n readonly payload: unknown;\n}\n\n/** The parsed, typed success carried on the `resilient_ok` settle Msg, tagged with its purpose. */\nexport interface LlmOk<P extends string, O extends Record<P, unknown>> {\n readonly key: string;\n readonly purpose: P;\n readonly output: O[P];\n}\n\n/** The typed failure variant — every failure path surfaces this, tagged by purpose. */\nexport interface LlmErr<P extends string> {\n readonly key: string;\n readonly purpose: P;\n /** A human-readable cause (model throw, retry-exhaustion, or schema parse). */\n readonly reason: string;\n /** The original error, carried untouched for the consumer to inspect. */\n readonly error: unknown;\n}\n\n/**\n * The settle Msgs llm-call's handler RETURNS from `interpret` so the substrate\n * enqueues them as follow-up Msgs (re-entry) into the host reducer — exactly as\n * `../resilient-call` does. They are the resilient-call settle Msgs with the\n * payloads ENRICHED to llm-call's typed variants: `result` is the parsed,\n * purpose-tagged `LlmOk`; `error` is the typed `LlmErr` (purpose + reason +\n * raw). The host wires `resilient_ok` → `succeed` and `resilient_err` → `fail`\n * reducer arms (the doc-comment wiring), and folds the enriched payload into its\n * own state there — llm-call never forces a Msg vocabulary on the host, and the\n * settle Msg drives the inherited succeed/fail → backoff → onTimer loop instead\n * of bypassing it.\n */\n//\n// The success Msg is `../resilient-call`'s `SucceedMsg` specialized to the parsed\n// `LlmOk` result — NOT a re-declared shape — so the verb returns (`succeed`'s\n// argument, the handler's resolve) thread through with no cast. The failure Msg\n// is the resilient `FailMsg` with its `error: unknown` NARROWED to the typed\n// `LlmErr`: every value the handler enriches is an `LlmErr`, and `LlmErr` is\n// assignable to `unknown`, so `LlmFailMsg` flows into `rc.fail` (which takes the\n// wide `FailMsg`) directly while the host reducer reads the narrow `error` type.\nexport type LlmSucceedMsg<\n P extends string,\n O extends Record<P, unknown>,\n> = SucceedMsg<LlmOk<P, O>>;\nexport type LlmFailMsg<P extends string> = Omit<FailMsg, \"error\"> & {\n readonly error: LlmErr<P>;\n};\n\n/** The retry / deadline timer Msg — `DeadlineExceeded`, inherited from resilient-call. */\nexport type LlmTimerMsg = DeadlineExceeded;\n\n/**\n * The ports the LEGACY detached `handlers(ports)` form takes. Two outbound Msg\n * builders the handler hands the typed Ok / Err; the consumer returns its own\n * Msg (or `undefined`). KEPT only for `../agent`, which still inherits the\n * detached shape; that path does NOT drive the retry loop (it dispatches the\n * consumer's Msg directly and never re-enters the resilient settle Msg). New\n * consumers use the no-arg `handlers()` form, which RETURNS the settle Msg for\n * re-entry and drives the inherited succeed/fail → backoff → onTimer loop.\n * `../agent` carries the same retry-loop gap and is fixed separately.\n *\n * The builders return `M | undefined` — `undefined` is the \"dispatch nothing\"\n * sentinel the detached form checks (`!== undefined`), spelled as `undefined`\n * rather than `void` so the union is unambiguous (a `void` member of a union is\n * confusing and reads as \"may return anything\").\n */\nexport interface LlmCallPorts<\n P extends string,\n O extends Record<P, unknown>,\n M,\n> {\n /** Build the Msg dispatched on a parsed success. Return `undefined` to dispatch nothing. */\n readonly onOk: (ok: LlmOk<P, O>) => M | undefined;\n /** Build the Msg dispatched on any failure (throw / retry-exhausted / parse). */\n readonly onErr: (err: LlmErr<P>) => M | undefined;\n}\n\n/**\n * The effect Cmd the knob emits: run the LLM call for `key` with `input`. The\n * `input` is the plain `LlmCall` request — the handler reads `purpose` /\n * `model` / `payload` off it (invariant 3: Cmds are data).\n *\n * It is `../resilient-call`'s `RunCmd` specialized to the `LlmCall` input — NOT a\n * re-declared shape — so the resilient verbs' return tuples (`[State, RunCmd[]]`)\n * are this exact type and thread through `attempt` / `succeed` / `fail` /\n * `onTimer` with no cast.\n */\nexport type LlmRunCmd<P extends string> = RunCmd<LlmCall<P>>;\n\n// ===========================================================================\n// The knob factory.\n// ===========================================================================\n\n/**\n * Build an llm-call knob from `config`. `rng` is injected for the inherited\n * retry jitter (pass a fixed `() => 0.5` in tests to pin backoff; defaults to\n * `Math.random`, read only at the resilient-call verb boundary).\n *\n * Returns the uniform L2 knob contract. The slice + verbs (`init`, `attempt`,\n * `succeed`, `fail`, `onTimer`, `subs`) are DELEGATED to a `../resilient-call`\n * knob built over the structured-output model-invoke port — no second backoff\n * here. `handlers` is the one new piece: the purpose-branching, schema-parsing\n * wrapper that returns the enriched resilient settle Msg for re-entry.\n *\n * `P` is the purpose union, `O` the purpose→output map, `Msg` the model's\n * message shape.\n */\nexport function createLlmCall<\n P extends string,\n O extends Record<P, unknown>,\n Msg = unknown,\n>(config: LlmCallConfig<P, O, Msg>, rng: () => number = Math.random) {\n // ---- The composed resilient-call knob ----------------------------------\n //\n // The resilient-call input is the full `LlmCall` request; its result is the\n // PARSED, typed `LlmOk`. Only the `retry` brick is forwarded — llm-call does\n // not expose circuit / rate-limit / cache / deadline knobs (a stage call is\n // a single brain invocation, not a keyed downstream target). Omitting those\n // bricks omits their gates, exactly as resilient-call documents.\n const resilientConfig: ResilientConfig = {\n ...(config.retry === undefined ? {} : { retry: config.retry }),\n };\n const rc = createResilientCall<LlmCall<P>, LlmOk<P, O>>(resilientConfig, rng);\n\n /** The slice this knob owns — resilient-call's slice verbatim. */\n type State = ResilientState<LlmCall<P>, LlmOk<P, O>>;\n\n /** The starting slice — resilient-call's. */\n function init(): State {\n return rc.init();\n }\n\n /**\n * Start (or restart) an LLM call. `keyOrPurpose` defaults the resilient-call\n * `key` to the call's `purpose` so one in-flight call per stage is tracked\n * under the slice (the common shape); pass a distinct key to fan out. PURE —\n * delegates straight to resilient-call's gate.\n */\n function attempt(\n s: State,\n input: LlmCall<P>,\n at: number,\n key: string = input.purpose,\n ): readonly [State, readonly LlmRunCmd<P>[]] {\n return rc.attempt(s, key, input, at);\n }\n\n /** Record a parsed success for `key`. PURE — resilient-call's `succeed`. */\n function succeed(\n s: State,\n key: string,\n msg: LlmSucceedMsg<P, O>,\n ): readonly [State, readonly LlmRunCmd<P>[]] {\n return rc.succeed(s, key, msg);\n }\n\n /**\n * Record a failure for `key`: back off via the inherited retry, or settle\n * `failed`. PURE — resilient-call's `fail`. The `error` is the typed\n * `LlmErr`, carried on the call's `failed` phase for the consumer to read.\n */\n function fail(\n s: State,\n key: string,\n msg: LlmFailMsg<P>,\n ): readonly [State, readonly LlmRunCmd<P>[]] {\n return rc.fail(s, key, msg);\n }\n\n /** A retry / deadline timer fired. PURE — resilient-call's `onTimer`. */\n function onTimer(\n s: State,\n msg: LlmTimerMsg,\n ): readonly [State, readonly LlmRunCmd<P>[]] {\n return rc.onTimer(s, msg);\n }\n\n /** Pre-wired subs — resilient-call's retry-timer subscriptions. */\n function subs(s: State): readonly DeadlineSub[] {\n return rc.subs(s);\n }\n\n // ---- The model-invoke port the resilient handler drives ----------------\n\n /**\n * Run ONE LLM call: assemble messages via the loader, bind the purpose's\n * structured-output schema, invoke, and parse. Returns the typed `LlmOk` on\n * success; throws on any failure (model throw OR schema parse) so the\n * resilient-call handler routes it to a failure Msg + backoff. This is the\n * single port resilient-call wraps — the retry / timeout machinery lives in\n * resilient-call, never here (\"do NOT reinvent backoff\").\n *\n * The seed's two paths collapse to one: every brain-only purpose goes through\n * `withStructuredOutput(schema)`. The `parse` re-validates the output (a fake\n * model might skip validation, and a real provider can drift) so a malformed\n * structured response is a failure, not a corrupt success.\n */\n async function invokeOne(input: LlmCall<P>): Promise<LlmOk<P, O>> {\n const model = config.model(input.model);\n const messages = config.loadMessages\n ? await config.loadMessages(input)\n : [];\n const schema = config.schemas[input.purpose];\n const structured = model.withStructuredOutput(schema);\n const raw = await structured.invoke(messages);\n // Re-validate at the boundary — a parse throw becomes a `resilient_err`\n // (enriched to `LlmErr`) settle Msg upstream, never a corrupt success.\n const output = schema.parse(raw);\n return { key: input.purpose, purpose: input.purpose, output };\n }\n\n // ---- Handlers — the one new piece, composed onto resilient-call --------\n\n /**\n * Pre-wired interpret handler for `resilient_run`. Composes\n * `../resilient-call`'s `resilient_run` handler over the `invokeOne` port and\n * RETURNS the resilient settle Msg — it does NOT dispatch the consumer's Msg\n * itself. Returning the settle Msg is the whole point: the substrate enqueues\n * an interpret handler's returned Msg as a FOLLOW-UP (re-entry) onto the\n * dispatch tail, so the host reducer's `resilient_ok` / `resilient_err` arms\n * run `succeed` / `fail`, which advances the inherited retry loop\n * (succeed/fail → backoff → onTimer → re-issue). A handler that instead\n * `dispatch`ed an enriched Msg directly would settle ONE invoke and bypass\n * that loop entirely — the slice would never leave `running`, the breaker\n * would never trip/close, and the retry counter would never reset.\n *\n * Resilience is composed, not reimplemented: the body delegates to the\n * resilient-call handler's Railway-routed invoke (Ok → `resilient_ok`, Err →\n * `resilient_err`), so a transient model failure backs off via the inherited\n * retry exactly as resilient-call decides. The ONE enrichment llm-call adds is\n * mapping the settle PAYLOADS into its typed variants:\n *\n * - success → `result` is already the parsed `LlmOk` (`invokeOne` returns\n * it), so the resilient `resilient_ok` Msg is the llm-call `LlmSucceedMsg`\n * as-is.\n * - failure → the resilient `resilient_err` carries the RAW throw (a model\n * 503 or a `schema.parse` throw). We rebuild it into the typed `LlmErr`\n * (purpose + reason + raw) so the host reducer routes per-stage.\n *\n * The host folds the enriched payload into its own state in the reducer arm\n * (the doc-comment wiring), NOT here — the handler stays the one impurity\n * (the `invokeOne` await + the `Date.now()` stamp the resilient handler makes)\n * and the reducer stays pure.\n */\n // The resilient-call `resilient_run` handler over `invokeOne` — the shared\n // Railway-routed invoke (Ok → resilient_ok Msg, Err → resilient_err Msg). Both\n // handler forms below build on it; the settle shape is composed, not reinvented.\n const resilientRunHandler = rc.handlers({\n run: (input) => invokeOne(input),\n }).resilient_run;\n\n /**\n * Await one composed invoke and resolve to the ENRICHED resilient settle Msg\n * (`resilient_ok` carrying the parsed `LlmOk`, or `resilient_err` carrying the\n * typed `LlmErr`). The shared core both handler forms reuse.\n */\n async function settleOf(\n cmd: LlmRunCmd<P>,\n ): Promise<LlmSucceedMsg<P, O> | LlmFailMsg<P>> {\n // `resilientRunHandler` never rejects (tryInterpret contract) — it resolves\n // to a `resilient_ok` / `resilient_err` settle Msg stamped with `Date.now()`.\n // Its `ctx` slot is `NoCtx` (the resilient-call work fn reads no ctx — a\n // DELIBERATE context-free seam, not accidental `unknown`), so the empty\n // record satisfies it without a cast.\n const settle = await resilientRunHandler(cmd, {});\n if (settle.type === MsgType.ResilientOk) {\n // `result` is the parsed `LlmOk` from `invokeOne`, which hardcoded its\n // `key` to `purpose` (the port receives no key). Re-key it to `cmd.key` —\n // the fan-out key `attempt` threaded in — so a fanned-out success carries\n // the SAME key its failure would (`settleOf`'s err path below uses\n // `cmd.key`), never the collapsed purpose. `settle.key` is already\n // `cmd.key`; this aligns the enriched `LlmOk.key` inside it.\n return { ...settle, result: { ...settle.result, key: cmd.key } };\n }\n // resilient_err — enrich the raw throw into the typed `LlmErr`, keeping the\n // resilient Msg's `key` / `at` so the host reducer's `fail` verb re-issues\n // from the remembered input and backs off on schedule.\n const rawError: unknown = settle.error;\n const err: LlmErr<P> = {\n key: cmd.key,\n purpose: cmd.input.purpose,\n reason: describeError(rawError),\n error: rawError,\n };\n return {\n type: MsgType.ResilientErr,\n key: settle.key,\n error: err,\n at: settle.at,\n };\n }\n\n /**\n * Pre-wired interpret handler for `resilient_run`. TWO forms:\n *\n * - `handlers()` (no args, the FIXED primary) — RETURNS the enriched\n * resilient settle Msg. The substrate enqueues an interpret handler's\n * returned Msg as a FOLLOW-UP (re-entry) onto the dispatch tail, so the\n * host reducer's `resilient_ok` / `resilient_err` arms run `succeed` /\n * `fail`, advancing the inherited retry loop (succeed/fail → backoff →\n * onTimer → re-issue). This is the correct wiring; the module doc shows it.\n *\n * - `handlers(ports)` (LEGACY, detached) — runs the invoke inside\n * `ctx.waitUntil` and dispatches the consumer's `onOk` / `onErr` Msg\n * directly. KEPT only for `../agent`, which still inherits this shape. It\n * does NOT drive the retry loop (it never re-enters the settle Msg, so\n * `succeed` / `fail` never run) — `../agent` carries the same gap and is\n * fixed in its own pass. New consumers MUST use the no-arg form.\n *\n * Resilience is composed, not reimplemented: both forms delegate to the\n * resilient-call handler's Railway-routed invoke and add only the typed\n * `LlmOk` / `LlmErr` enrichment. The ONE impurity is the `invokeOne` await +\n * the `Date.now()` stamp the resilient handler makes; the reducer stays pure.\n */\n function handlers(): {\n resilient_run: (\n cmd: LlmRunCmd<P>,\n ) => Promise<LlmSucceedMsg<P, O> | LlmFailMsg<P>>;\n };\n function handlers<M>(ports: LlmCallPorts<P, O, M>): {\n resilient_run: (\n cmd: LlmRunCmd<P>,\n ctx: { waitUntil(p: Promise<unknown>): void; dispatch(msg: M): unknown },\n ) => void;\n };\n function handlers<M>(ports?: LlmCallPorts<P, O, M>) {\n if (ports === undefined) {\n // FIXED primary: return the settle Msg for re-entry — drives the loop.\n return { resilient_run: (cmd: LlmRunCmd<P>) => settleOf(cmd) };\n }\n // LEGACY detached form — kept for `../agent` (see doc above). Dispatches the\n // consumer's Msg directly; does not re-enter the settle Msg.\n return {\n resilient_run: (\n cmd: LlmRunCmd<P>,\n ctx: {\n waitUntil(p: Promise<unknown>): void;\n dispatch(msg: M): unknown;\n },\n ): void => {\n const fired = (async () => {\n const settle = await settleOf(cmd);\n if (settle.type === MsgType.ResilientOk) {\n const msg = ports.onOk(settle.result);\n if (msg !== undefined) await ctx.dispatch(msg);\n return;\n }\n const msg = ports.onErr(settle.error);\n if (msg !== undefined) await ctx.dispatch(msg);\n })();\n ctx.waitUntil(fired);\n },\n };\n }\n\n return { init, attempt, succeed, fail, onTimer, subs, handlers, invokeOne };\n}\n\n/**\n * Lift a knob result `[slice, cmds]` into a host `[State, cmds]` where the\n * slice lives at `state.resilience` — the same convenience `../resilient-call`\n * ships, re-typed for the llm-call slice so consumers wire one import. Pure.\n */\nexport function liftLlmCall<\n S extends { resilience: ResilientState<LlmCall<P>, LlmOk<P, O>> },\n P extends string,\n O extends Record<P, unknown>,\n C extends Cmd,\n>(\n state: S,\n result: readonly [ResilientState<LlmCall<P>, LlmOk<P, O>>, readonly C[]],\n): readonly [S, readonly C[]] {\n return liftResilience(state, result);\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 `subs` emits.\n */\nexport { subscribeDeadline, deadlineSub };\nexport type { DeadlineSub, DeadlineExceeded, ResilientState };\n"]}
|