@demlik/tea 0.1.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.
Files changed (96) hide show
  1. package/dist/cache/index.d.ts +151 -0
  2. package/dist/cache/index.js +53 -0
  3. package/dist/cache/index.js.map +1 -0
  4. package/dist/chunk-2YFLOCWX.js +15 -0
  5. package/dist/chunk-2YFLOCWX.js.map +1 -0
  6. package/dist/chunk-CMG2KMWX.js +458 -0
  7. package/dist/chunk-CMG2KMWX.js.map +1 -0
  8. package/dist/chunk-PYBQREFU.js +95 -0
  9. package/dist/chunk-PYBQREFU.js.map +1 -0
  10. package/dist/chunk-PYOCUBQJ.js +196 -0
  11. package/dist/chunk-PYOCUBQJ.js.map +1 -0
  12. package/dist/chunk-Y5JM3UUH.js +126 -0
  13. package/dist/chunk-Y5JM3UUH.js.map +1 -0
  14. package/dist/chunk-ZRUST6BK.js +62 -0
  15. package/dist/chunk-ZRUST6BK.js.map +1 -0
  16. package/dist/circuit-breaker/index.d.ts +196 -0
  17. package/dist/circuit-breaker/index.js +53 -0
  18. package/dist/circuit-breaker/index.js.map +1 -0
  19. package/dist/deadline/index.d.ts +113 -0
  20. package/dist/deadline/index.js +20 -0
  21. package/dist/deadline/index.js.map +1 -0
  22. package/dist/debounce/index.d.ts +114 -0
  23. package/dist/debounce/index.js +56 -0
  24. package/dist/debounce/index.js.map +1 -0
  25. package/dist/devtools/index.d.ts +137 -0
  26. package/dist/devtools/index.js +217 -0
  27. package/dist/devtools/index.js.map +1 -0
  28. package/dist/devtools/styles.css +218 -0
  29. package/dist/do/index.d.ts +135 -0
  30. package/dist/do/index.js +54 -0
  31. package/dist/do/index.js.map +1 -0
  32. package/dist/extension/index.d.ts +207 -0
  33. package/dist/extension/index.js +26 -0
  34. package/dist/extension/index.js.map +1 -0
  35. package/dist/extension/react.d.ts +121 -0
  36. package/dist/extension/react.js +69 -0
  37. package/dist/extension/react.js.map +1 -0
  38. package/dist/extension/subs/index.d.ts +44 -0
  39. package/dist/extension/subs/index.js +83 -0
  40. package/dist/extension/subs/index.js.map +1 -0
  41. package/dist/extension/test-utils.d.ts +128 -0
  42. package/dist/extension/test-utils.js +269 -0
  43. package/dist/extension/test-utils.js.map +1 -0
  44. package/dist/idempotency/index.d.ts +131 -0
  45. package/dist/idempotency/index.js +58 -0
  46. package/dist/idempotency/index.js.map +1 -0
  47. package/dist/index.d.ts +364 -0
  48. package/dist/index.js +3 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/machine-viz/index.d.ts +78 -0
  51. package/dist/machine-viz/index.js +139 -0
  52. package/dist/machine-viz/index.js.map +1 -0
  53. package/dist/mem/index.d.ts +45 -0
  54. package/dist/mem/index.js +19 -0
  55. package/dist/mem/index.js.map +1 -0
  56. package/dist/node/index.d.ts +134 -0
  57. package/dist/node/index.js +89 -0
  58. package/dist/node/index.js.map +1 -0
  59. package/dist/pbt/arbitraries/index.d.ts +135 -0
  60. package/dist/pbt/arbitraries/index.js +3 -0
  61. package/dist/pbt/arbitraries/index.js.map +1 -0
  62. package/dist/pbt/index.d.ts +28 -0
  63. package/dist/pbt/index.js +18 -0
  64. package/dist/pbt/index.js.map +1 -0
  65. package/dist/pbt/runners/index.d.ts +117 -0
  66. package/dist/pbt/runners/index.js +4 -0
  67. package/dist/pbt/runners/index.js.map +1 -0
  68. package/dist/rate-limit/index.d.ts +115 -0
  69. package/dist/rate-limit/index.js +45 -0
  70. package/dist/rate-limit/index.js.map +1 -0
  71. package/dist/react/index.d.ts +72 -0
  72. package/dist/react/index.js +33 -0
  73. package/dist/react/index.js.map +1 -0
  74. package/dist/recorder/index.d.ts +287 -0
  75. package/dist/recorder/index.js +157 -0
  76. package/dist/recorder/index.js.map +1 -0
  77. package/dist/retry-backoff/index.d.ts +131 -0
  78. package/dist/retry-backoff/index.js +37 -0
  79. package/dist/retry-backoff/index.js.map +1 -0
  80. package/dist/subs/index.d.ts +57 -0
  81. package/dist/subs/index.js +4 -0
  82. package/dist/subs/index.js.map +1 -0
  83. package/dist/testing/index.d.ts +220 -0
  84. package/dist/testing/index.js +105 -0
  85. package/dist/testing/index.js.map +1 -0
  86. package/dist/throttle/index.d.ts +125 -0
  87. package/dist/throttle/index.js +54 -0
  88. package/dist/throttle/index.js.map +1 -0
  89. package/dist/trace-replay/index.d.ts +52 -0
  90. package/dist/trace-replay/index.js +57 -0
  91. package/dist/trace-replay/index.js.map +1 -0
  92. package/dist/types-CwBC5wMn.d.ts +5 -0
  93. package/dist/work-queue/index.d.ts +56 -0
  94. package/dist/work-queue/index.js +143 -0
  95. package/dist/work-queue/index.js.map +1 -0
  96. package/package.json +184 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/pbt/arbitraries/ctx.ts","../src/pbt/arbitraries/msg.ts","../src/pbt/arbitraries/sequence.ts"],"names":["fc2"],"mappings":";;;AAyCO,SAAS,oBAAA,GAAiC;AAC/C,EAAA,MAAM,OAAA,GAAgC;AAAA,IACpC,GAAA,CAAI,SAAS,IAAA,EAAgC;AAK3C,MAAA,IAAI,OAAO,IAAA,KAAS,QAAA,EAAU,OAAO,MAAA;AAKrC,MAAA,IAAI,SAAS,QAAA,IAAY,IAAA,KAAS,cAAc,IAAA,KAAS,SAAA,IAAa,SAAS,MAAA,EAAQ;AACrF,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,MAAM,IAAI,KAAA;AAAA,QACR,oFACgB,IAAI,CAAA,wEAAA;AAAA,OAEtB;AAAA,IACF,CAAA;AAAA,IACA,GAAA,GAAe;AACb,MAAA,OAAO,KAAA;AAAA,IACT,CAAA;AAAA,IACA,OAAA,GAAsC;AACpC,MAAA,OAAO,EAAC;AAAA,IACV;AAAA,GACF;AACA,EAAA,OAAO,IAAI,KAAA,CAAM,EAAC,EAAG,OAAO,CAAA;AAC9B;ACVO,SAAS,MAAA,CACd,OACA,IAAA,EACiB;AACjB,EAAA,MAAM,UAAU,IAAA,EAAM,OAAA;AACtB,EAAA,MAAM,OAAA,GAAU,MAAA,CAAO,OAAA,CAAQ,KAAK,CAAA;AACpC,EAAA,IAAI,OAAA,CAAQ,WAAW,CAAA,EAAG;AACxB,IAAA,MAAM,IAAI,MAAM,wCAAwC,CAAA;AAAA,EAC1D;AACA,EAAA,MAAM,WAAW,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAC,GAAA,EAAK,GAAG,CAAA,MAAO;AAAA,IAC5C,SAAA,EAAW,GAAA;AAAA,IACX,MAAA,EAAQ,OAAA,GAAU,GAAG,CAAA,IAAK;AAAA,GAC5B,CAAE,CAAA;AACF,EAAA,OAAU,EAAA,CAAA,KAAA,CAAM,GAAG,QAAQ,CAAA;AAC7B;AASO,SAAS,eAAiC,IAAA,EAAoC;AACnF,EAAA,OAAU,EAAA,CAAA,QAAA,CAAS,EAAE,IAAA,EAAM,CAAA;AAC7B;AAWO,SAAS,YAAA,CACd,MACA,MAAA,EAC+B;AAa/B,EAAA,MAAM,iBAAA,GAA2D;AAAA,IAC/D,IAAA,EAAS,YAAS,IAAI,CAAA;AAAA,IACtB,GAAI;AAAA,GACN;AACA,EAAA,OAAU,UAAO,iBAAiB,CAAA;AACpC;ACnFO,SAAS,cAAA,CACd,QACA,IAAA,EAK4B;AAC5B,EAAA,MAAM,SAAA,GAAY,MAAM,SAAA,IAAa,CAAA;AACrC,EAAA,MAAM,SAAA,GAAY,MAAM,SAAA,IAAa,EAAA;AACrC,EAAA,MAAM,MAAA,GAAS,IAAA,EAAM,MAAA,IAAU,EAAC;AAChC,EAAA,MAAM,OAAUA,EAAA,CAAA,KAAA,CAAM,MAAA,EAAQ,EAAE,SAAA,EAAW,WAAW,CAAA;AACtD,EAAA,IAAI,MAAA,CAAO,MAAA,KAAW,CAAA,EAAG,OAAO,IAAA;AAChC,EAAA,OAAO,IAAA,CAAK,IAAI,CAAC,GAAA,KAAQ,CAAC,GAAG,MAAA,EAAQ,GAAG,GAAG,CAAC,CAAA;AAC9C;AAWO,SAAS,iBAAA,CACd,QAAA,EACA,IAAA,EACA,MAAA,EAGA,KAAA,EAC4B;AAC5B,EAAA,MAAM,IAAI,KAAA;AAAA,IACR;AAAA,GAIF;AACF","file":"chunk-ZRUST6BK.js","sourcesContent":["// ---------------------------------------------------------------------------\n// `stubCtxThrowingProxy<Ctx>()` — a Proxy that satisfies the Ctx type-shape\n// at compile time but throws on every property access at runtime.\n//\n// Why this exists: PBT runs the pure reducer surface ONLY — `replay` never\n// invokes `interpret` and never starts a Sub, so Ctx should never be\n// dereferenced during property generation. But the substrate's `replay`\n// signature requires a `ctx: Ctx` value at compile time (it forwards ctx\n// into `init`, which on some machines IS pure but on others reads ctx). A\n// hand-rolled `{} as Ctx` would silently pass through any accidental ctx\n// access — exactly the silent footgun this proxy catches.\n//\n// Open question 2 (design doc): we ship this as the default. Tests that\n// genuinely need a working ctx (because their `init` reads from it) can\n// pass a real ctx value instead — the proxy is opt-in, not load-bearing.\n//\n// Strengthens invariant 6 (no silent failures — accidental ctx reads\n// produce a useful error, not a `undefined.foo` confusion later).\n// ---------------------------------------------------------------------------\n\n/**\n * Build a Ctx value whose every property access throws. Use as the `ctx`\n * argument to PBT runners when the reducer under test is pure (does not\n * read from ctx). Accidental ctx access during property generation\n * surfaces as a labelled error instead of a silent `undefined` or\n * downstream type-error.\n *\n * Returns a typed `Ctx` (via `as unknown as Ctx`) — the proxy satisfies the\n * type-shape requirement at the call site; the runtime contract is\n * \"throw on first read.\" The `as unknown` cast is the one boundary cast\n * this helper requires; the proxy is structurally a `Ctx` for every\n * property accessor.\n *\n * @example\n * import { stubCtxThrowingProxy } from \"../index\";\n * const ctx = stubCtxThrowingProxy<MyCtx>();\n * propertyTerminates(machine, ctx, ...);\n * // If anything reads ctx.someField during the run, an Error throws:\n * // \"@demlik/tea/pbt: ctx access denied (PBT operates on the pure\n * // reducer; ctx field 'someField' was read)\"\n */\nexport function stubCtxThrowingProxy<Ctx>(): Ctx {\n const handler: ProxyHandler<object> = {\n get(_target, prop: string | symbol): unknown {\n // Allow vitest / fast-check internals to introspect via Symbol-keyed\n // checks (e.g. Symbol.toStringTag, Symbol.iterator). Returning\n // undefined on symbol keys keeps property-tree walks cheap; the\n // throw fires on real domain field reads.\n if (typeof prop === \"symbol\") return undefined;\n // Some shrinkers / pretty-printers in fast-check probe a small set\n // of conventional field names (`toJSON`, `toString`, `valueOf`,\n // `then` — the last for thenable-check). Return undefined for those\n // so the proxy doesn't fail a probe before the property body runs.\n if (prop === \"toJSON\" || prop === \"toString\" || prop === \"valueOf\" || prop === \"then\") {\n return undefined;\n }\n throw new Error(\n `@demlik/tea/pbt: ctx access denied (PBT operates on the pure reducer; ` +\n `ctx field '${prop}' was read). Pass a real ctx value if the reducer's ` +\n `init reads from ctx.`,\n );\n },\n has(): boolean {\n return false;\n },\n ownKeys(): ArrayLike<string | symbol> {\n return [];\n },\n };\n return new Proxy({}, handler) as unknown as Ctx;\n}\n","// ---------------------------------------------------------------------------\n// Msg arbitrary builders.\n//\n// `MsgArbitraryTable<M>` is a strict mapped type — every Msg variant must\n// have an arbitrary entry, keyed by `M[\"type\"]`, narrowing the arbitrary's\n// `T` to `Extract<M, { type: K }>`. This mirrors the load-bearing discipline\n// of `Reducer<S, M, C>` and `Transitions<S, M, C>` in the substrate: adding\n// a Msg variant without an arbitrary fails to compile, not at runtime.\n//\n// `arbMsg(table, opts?)` collapses the table into a single `fc.Arbitrary<M>`\n// via `fc.oneof`. Optional per-variant weighting for sequence-skew tests.\n//\n// `arbConstantMsg(type)` / `arbRecordMsg(type, fields)` are sugar over\n// `fc.constant({ type })` / `fc.record({ type: fc.constant(type), ...fields })`.\n// They exist because every Msg variant arbitrary either (a) carries no\n// payload — `arbConstantMsg(\"stop\")` — or (b) carries a record of fields —\n// `arbRecordMsg(\"tick\", { now: fc.integer() })`. Naming the two shapes\n// prevents callers from hand-rolling `fc.record({ type: fc.constant(\"...\") })`\n// inconsistently across test files.\n//\n// Open question 1 from the design doc (strict vs Partial table): strict by\n// default. The mapped type catches drift at compile time; `arbMsgPartial`\n// sugar is a future addition iff friction becomes real.\n// ---------------------------------------------------------------------------\n\nimport * as fc from \"fast-check\";\n\n/**\n * Strict per-variant Msg arbitrary table. Mapped type: every key in\n * `M[\"type\"]` MUST have an arbitrary entry whose `T` is the narrowed\n * variant. Adding a new Msg variant without an entry is a compile error\n * (same load-bearing discipline as `Reducer<S, M, C>` in the substrate).\n *\n * @example\n * type Msg = { type: \"start\" } | { type: \"tick\"; now: number };\n * const table: MsgArbitraryTable<Msg> = {\n * start: arbConstantMsg(\"start\"),\n * tick: arbRecordMsg(\"tick\", { now: fc.integer({ min: 0 }) }),\n * };\n */\nexport type MsgArbitraryTable<M extends { type: string }> = {\n [K in M[\"type\"]]: fc.Arbitrary<Extract<M, { type: K }>>;\n};\n\n/**\n * Build a single `fc.Arbitrary<M>` from a `MsgArbitraryTable<M>`. Picks one\n * variant per generation via `fc.oneof`; optional per-variant `weights`\n * bias the distribution (default uniform).\n *\n * @param table Per-variant arbitrary table (strict — every variant required).\n * @param opts Optional bias: `weights` is a `Partial<Record<M[\"type\"], number>>`.\n * Variants without an explicit weight default to 1.\n *\n * @example\n * const msgArb = arbMsg<Msg>({\n * start: arbConstantMsg(\"start\"),\n * tick: arbRecordMsg(\"tick\", { now: fc.integer() }),\n * stop: arbConstantMsg(\"stop\"),\n * }, { weights: { tick: 5, start: 1, stop: 1 } });\n */\nexport function arbMsg<M extends { type: string }>(\n table: MsgArbitraryTable<M>,\n opts?: { weights?: Partial<Record<M[\"type\"], number>> },\n): fc.Arbitrary<M> {\n const weights = opts?.weights;\n const entries = Object.entries(table) as [M[\"type\"], fc.Arbitrary<M>][];\n if (entries.length === 0) {\n throw new Error(\"@demlik/tea/pbt: arbMsg table is empty\");\n }\n const weighted = entries.map(([key, arb]) => ({\n arbitrary: arb,\n weight: weights?.[key] ?? 1,\n }));\n return fc.oneof(...weighted);\n}\n\n/**\n * Build a payload-free Msg variant arbitrary. Sugar for\n * `fc.constant({ type })` that narrows the result to `{ type: T }` literal.\n *\n * @example\n * arbConstantMsg(\"stop\") // fc.Arbitrary<{ type: \"stop\" }>\n */\nexport function arbConstantMsg<T extends string>(type: T): fc.Arbitrary<{ type: T }> {\n return fc.constant({ type });\n}\n\n/**\n * Build a record-shaped Msg variant arbitrary. Sugar for\n * `fc.record({ type: fc.constant(type), ...fields })` that stamps the\n * discriminator literal and combines with the per-field arbitraries.\n *\n * @example\n * arbRecordMsg(\"tick\", { now: fc.integer({ min: 0 }) })\n * // fc.Arbitrary<{ type: \"tick\"; now: number }>\n */\nexport function arbRecordMsg<T extends string, R>(\n type: T,\n fields: { [K in keyof R]: fc.Arbitrary<R[K]> },\n): fc.Arbitrary<{ type: T } & R> {\n // `fc.record` builds an arbitrary of the merged-shape record. The\n // `type: fc.constant(type)` field stamps the literal discriminator;\n // the spread of `fields` provides every payload field's arbitrary.\n //\n // The intermediate `as Record<string, fc.Arbitrary<unknown>>` is the\n // single load-bearing cast — fc.record's parameter type is invariant in\n // its inferred field shape, and the merged literal-and-generic spread\n // doesn't unify with the strict `{ [K in keyof R]: Arbitrary<R[K]> }`\n // shape TS computes for us. The outer cast to `fc.Arbitrary<{ type: T }\n // & R>` restates the result shape we know to be correct at this\n // boundary. Mirrors the same discipline `fc.record` callers use\n // throughout the prior-art branch.\n const recordArbitraries: Record<string, fc.Arbitrary<unknown>> = {\n type: fc.constant(type),\n ...(fields as Record<string, fc.Arbitrary<unknown>>),\n };\n return fc.record(recordArbitraries) as unknown as fc.Arbitrary<{ type: T } & R>;\n}\n","// ---------------------------------------------------------------------------\n// Msg-sequence arbitraries.\n//\n// `arbMsgSequence(msgArb, opts)` produces `fc.Arbitrary<readonly M[]>` —\n// fast-check's default-shrinker handles the array; the per-element shrinker\n// inside `msgArb` shrinks each variant. Optional `prefix` is prepended\n// verbatim (used by Test A: \"the bug-triggering Msg leads the sequence\").\n//\n// `arbGuidedSequence` is the state-aware sequence builder. In v1 it throws\n// — Phase 6 of the design doc lands the `fc.commands`-backed implementation.\n// The export exists today so the surface is forward-compatible: consumers\n// that need state-aware generation get a useful error pointing at the\n// design doc instead of \"is not a function.\"\n// ---------------------------------------------------------------------------\n\nimport type { Cmd, Machine, Sub } from \"../../index\";\nimport * as fc from \"fast-check\";\n\n/**\n * Build an arbitrary of Msg sequences over a per-Msg arbitrary. Optionally\n * bounded in length and / or prefixed with a fixed sequence.\n *\n * - `minLength` (default 0) and `maxLength` (default 30) bound the random\n * array length. `prefix` is prepended AFTER the random tail is built; the\n * final length is `prefix.length + tail.length`.\n * - `prefix` is the load-bearing escape hatch for \"the first msg is the\n * one that exercises the cell I care about\" (e.g. Test A's\n * `[{ type: \"ws:client:hello\" }]` prefix to land in the\n * `auditing.ws:client:hello` cell on the first transition).\n *\n * @example\n * arbMsgSequence(msgArb, { minLength: 1, maxLength: 30 });\n * arbMsgSequence(msgArb, { prefix: [{ type: \"ws:client:hello\" }], maxLength: 20 });\n */\nexport function arbMsgSequence<M extends { type: string }>(\n msgArb: fc.Arbitrary<M>,\n opts?: {\n readonly minLength?: number;\n readonly maxLength?: number;\n readonly prefix?: readonly NoInfer<M>[];\n },\n): fc.Arbitrary<readonly M[]> {\n const minLength = opts?.minLength ?? 0;\n const maxLength = opts?.maxLength ?? 30;\n const prefix = opts?.prefix ?? [];\n const tail = fc.array(msgArb, { minLength, maxLength });\n if (prefix.length === 0) return tail;\n return tail.map((arr) => [...prefix, ...arr]);\n}\n\n/**\n * State-aware Msg sequence arbitrary — generates only msgs whose\n * precondition holds in the current state. Backed by `fc.commands`\n * internally (Phase 6 of `.patterns/tea/tea-pbt-design.md`).\n *\n * **NOT IMPLEMENTED IN v1.** The export exists today so the surface is\n * forward-compatible; the design doc §7 ships this in Phase 6 alongside\n * shrinkers. Calling it throws.\n */\nexport function arbGuidedSequence<S, M extends { type: string }, C extends Cmd, U extends Sub, Ctx>(\n _machine: Machine<S, M, C, U, Ctx>,\n _ctx: Ctx,\n _cells: {\n [K in M[\"type\"]]?: (state: S) => fc.Arbitrary<Extract<M, { type: K }>> | null;\n },\n _opts?: { maxLength?: number },\n): fc.Arbitrary<readonly M[]> {\n throw new Error(\n \"@demlik/tea/pbt: arbGuidedSequence is not implemented in v1 — see \" +\n \".patterns/tea/tea-pbt-design.md §7 (Phase 6). Use arbMsgSequence with \" +\n \"an unguided arbMsg table for now; states that don't accept a given \" +\n \"msg should noop in the reducer (mapped-type discipline).\",\n );\n}\n"]}
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @demlik/tea/circuit-breaker — per-target failure tracking as pure state + ops.
3
+ *
4
+ * Same shape as `@demlik/tea/work-queue`'s `ops.ts`, `@demlik/tea/rate-limit`,
5
+ * and `@demlik/tea/idempotency`: a state type plus pure transition functions.
6
+ * Host-agnostic by construction — no `Date.now()`, no timers, no I/O. The
7
+ * caller injects the clock (`nowMs`) and wires the result into its own reducer
8
+ * / interpret. This keeps the breaker inside TEA's invariant 2 (transitions are
9
+ * pure: same inputs → same outputs, no `Date.now`) so a consumer can fold it
10
+ * through `update` and exercise it with `replay` / the pbt fold without a fake
11
+ * clock.
12
+ *
13
+ * Every op returns a NEW state value and never mutates its input (invariant 1 —
14
+ * state is a value, not a place). `canPass` returns a `[next, allowed]` tuple so
15
+ * the caller branches on the decision while threading the advanced state forward
16
+ * — same convention as `rate-limit`'s `tryConsume` / `record`. `onSuccess` /
17
+ * `onFailure` are the outcome-recording transitions the caller folds in once the
18
+ * guarded call resolves.
19
+ *
20
+ * ## The phase machine
21
+ *
22
+ * A circuit breaker guards a fallible downstream target (an HTTP backend, a DB,
23
+ * a flaky tool call). Instead of hammering a target that is already failing, it
24
+ * trips OPEN after enough failures and fast-fails subsequent calls for a
25
+ * cooldown, then probes with a few trial calls before fully closing again.
26
+ * Three phases, each carrying ONLY its own data so impossible states are
27
+ * unrepresentable (pattern 11):
28
+ *
29
+ * - **closed** — the healthy steady state. Calls pass through. Carries
30
+ * `failures`: the count of *consecutive* failures since the last success.
31
+ * A success resets it to 0; the `failureThreshold`-th failure trips the
32
+ * breaker OPEN. Only `closed` has a failure counter — `open` and
33
+ * `half_open` don't, because in those phases the count is irrelevant
34
+ * (the breaker has already tripped / is already probing).
35
+ *
36
+ * - **open** — the tripped state. All calls fast-fail (no downstream load).
37
+ * Carries `openedAtMs`: the clock reading at which the breaker tripped.
38
+ * `canPass` compares `nowMs - openedAtMs` against `cooldownMs` to decide
39
+ * when to start probing. Only `open` carries a timestamp — neither other
40
+ * phase is timing anything.
41
+ *
42
+ * - **half_open** — the probing state. A limited number of trial calls are
43
+ * allowed through to test whether the target has recovered. Carries
44
+ * `probes`: the count of probes admitted so far this probing round.
45
+ * `canPass` admits while `probes < halfOpenMaxProbes`, then fast-fails the
46
+ * rest (so a slow target can't be flooded by every queued caller at once).
47
+ * A probe success closes the breaker; a probe failure re-opens it (and
48
+ * resets the cooldown clock). Only `half_open` counts in-flight probes.
49
+ *
50
+ * ### Transition table
51
+ *
52
+ * Rows are the current phase; columns are the event. `→` denotes the next
53
+ * phase; `self` means unchanged. `nowMs` is the injected clock.
54
+ *
55
+ * | from \ event | onSuccess | onFailure | canPass (→ allowed?) |
56
+ * | ------------ | -------------- | -------------------------------------- | ------------------------------------------------------------------------------------ |
57
+ * | closed | closed(0) | failures+1; trip → open(nowMs) at thr. | self → true (always) |
58
+ * | open | self *(¹)* | self | elapsed ≥ cooldown → half_open(1), true; else self → false |
59
+ * | half_open | → closed(0) | → open(nowMs) (probe failed) | probes < max → half_open(probes+1), true; else self → false |
60
+ *
61
+ * *(¹)* `onSuccess` on `open` "shouldn't happen": no call was admitted while
62
+ * open, so no success can be reported for one. We leave the state unchanged
63
+ * rather than throw — the breaker is a coordination aid, not a correctness
64
+ * gate, and a spurious success report (e.g. a late-arriving result from before
65
+ * the trip) must not silently un-trip the breaker. `onFailure` on `open` is
66
+ * likewise a no-op: the breaker is already tripped; the `open` clock is anchored
67
+ * at the trip, not bumped by late failures.
68
+ *
69
+ * ### The probe-admission asymmetry
70
+ *
71
+ * Entering `half_open` happens inside `canPass`, not `onSuccess` / `onFailure`,
72
+ * because the cooldown is a *time* condition and only `canPass` reads the clock
73
+ * against `openedAtMs`. The first `canPass` after the cooldown elapses both
74
+ * transitions the phase AND admits the probe (`[half_open(1), true]`) — one
75
+ * caller drives the recovery attempt. Subsequent `canPass` calls in the same
76
+ * round admit up to `halfOpenMaxProbes` total, then fast-fail until a probe
77
+ * resolves and `onSuccess` / `onFailure` moves the phase out of `half_open`.
78
+ *
79
+ * This module is a textbook three-state machine — a clean `/machine-viz` demo.
80
+ *
81
+ * NOT exported from the package root — reached via the
82
+ * `@demlik/tea/circuit-breaker` subpath, same one-shape-per-package rule as
83
+ * `work-queue`, `rate-limit`, and `idempotency`.
84
+ */
85
+ /**
86
+ * Circuit-breaker policy — pure configuration, no mutable state. One policy is
87
+ * shared across all the calls guarded by one logical breaker (often a
88
+ * module-scope const). `CircuitState` carries the mutable counts; this carries
89
+ * the thresholds those counts are compared against.
90
+ */
91
+ interface CircuitPolicy {
92
+ /**
93
+ * Consecutive failures in `closed` that trip the breaker `open`. The
94
+ * `failureThreshold`-th failure is the one that trips — with a threshold of
95
+ * 5, the breaker survives 4 failures and opens on the 5th.
96
+ */
97
+ readonly failureThreshold: number;
98
+ /**
99
+ * How long the breaker stays `open` before `canPass` starts admitting
100
+ * probes, in milliseconds. Measured from `openedAtMs`; the cutoff is
101
+ * half-open (`elapsed >= cooldownMs` admits), matching `rate-limit` /
102
+ * `idempotency` window edges — a breaker opened at `t` first probes at
103
+ * `t + cooldownMs`.
104
+ */
105
+ readonly cooldownMs: number;
106
+ /**
107
+ * How many probe calls `half_open` admits before fast-failing the rest of
108
+ * the round. Caps the load on a recovering target — a single slow probe
109
+ * can't be joined by every queued caller. The round ends when a probe
110
+ * resolves: success closes the breaker, failure re-opens it.
111
+ */
112
+ readonly halfOpenMaxProbes: number;
113
+ }
114
+ /**
115
+ * Sensible defaults: trip after 5 consecutive failures, cool down for 30s,
116
+ * admit a single probe before deciding. Tuned for guarding a network backend;
117
+ * override per call site rather than mutating this shared const.
118
+ */
119
+ declare const defaultCircuitPolicy: CircuitPolicy;
120
+ /**
121
+ * The breaker's phase. A discriminated union on `phase` so each phase carries
122
+ * ONLY its own data and impossible combinations are unrepresentable (pattern
123
+ * 11): there is no state with both a failure count AND an open timestamp,
124
+ * because such a state is meaningless. `phase` is the discriminant; every field
125
+ * is `readonly` so the whole value is trivially JSON-serializable into a
126
+ * consumer's `Store<S>` and safe to thread through a pure reducer.
127
+ *
128
+ * - `closed` carries `failures` — consecutive failures since last success.
129
+ * - `open` carries `openedAtMs` — clock reading when it tripped.
130
+ * - `half_open` carries `probes` — probes admitted this round.
131
+ */
132
+ type CircuitState = {
133
+ readonly phase: "closed";
134
+ readonly failures: number;
135
+ } | {
136
+ readonly phase: "open";
137
+ readonly openedAtMs: number;
138
+ } | {
139
+ readonly phase: "half_open";
140
+ readonly probes: number;
141
+ };
142
+ /** The starting state: closed with zero recorded failures. */
143
+ declare function initCircuit(): CircuitState;
144
+ /**
145
+ * Record a successful guarded call. PURE — returns a new state; the input is
146
+ * never mutated.
147
+ *
148
+ * - `closed` → reset `failures` to 0 (a success breaks the consecutive run).
149
+ * - `half_open` → the probe succeeded; the target has recovered → `closed`.
150
+ * - `open` → unchanged. A success "shouldn't happen" while open (no call
151
+ * was admitted to produce one); a late/spurious success must
152
+ * not silently un-trip the breaker. See module header note ¹.
153
+ */
154
+ declare function onSuccess(state: CircuitState, _policy: CircuitPolicy): CircuitState;
155
+ /**
156
+ * Record a failed guarded call. PURE — returns a new state; the input is never
157
+ * mutated. `nowMs` is the injected clock, stamped onto the `open` phase when the
158
+ * breaker trips so `canPass` can measure the cooldown later.
159
+ *
160
+ * - `closed` → `failures + 1`; if that reaches `failureThreshold`, trip to
161
+ * `open(nowMs)`. The threshold-th failure is the one that trips.
162
+ * - `half_open` → the probe failed; the target is still unhealthy →
163
+ * `open(nowMs)`, restarting the cooldown clock so a fresh
164
+ * cooldown elapses before the next probe round.
165
+ * - `open` → unchanged. The breaker is already tripped; the `open` clock
166
+ * is anchored at the original trip, not bumped by late
167
+ * failures. See module header note ¹.
168
+ */
169
+ declare function onFailure(state: CircuitState, policy: CircuitPolicy, nowMs: number): CircuitState;
170
+ /**
171
+ * Decide whether a call may pass, advancing the phase as a side effect of the
172
+ * decision. PURE — returns `[next, allowed]`; the input is never mutated.
173
+ * Branch on the boolean, thread the state forward regardless:
174
+ *
175
+ * const [c1, allowed] = canPass(state.circuit, policy, nowMs);
176
+ * if (!allowed) return [{ ...state, circuit: c1 }, [fastFailCmd]];
177
+ * return [{ ...state, circuit: c1 }, [doGuardedCallCmd]];
178
+ *
179
+ * - `closed` → `[state, true]` — always passes; failures are recorded by
180
+ * `onFailure` once the call resolves, not gated here.
181
+ * - `open` → if `nowMs - openedAtMs >= cooldownMs` the cooldown has
182
+ * elapsed → enter `half_open` and admit this caller as the
183
+ * first probe: `[half_open(1), true]`. Otherwise still cooling
184
+ * down → `[state, false]` (fast-fail, no downstream load).
185
+ * - `half_open` → admit while `probes < halfOpenMaxProbes`, incrementing the
186
+ * count: `[half_open(probes+1), true]`. Once the cap is hit →
187
+ * `[state, false]` until a probe resolves and `onSuccess` /
188
+ * `onFailure` moves the phase out of `half_open`.
189
+ *
190
+ * Entering `half_open` lives here (not in `onSuccess` / `onFailure`) because the
191
+ * cooldown is a time condition and only `canPass` reads `nowMs` against
192
+ * `openedAtMs`. See the module header's probe-admission note.
193
+ */
194
+ declare function canPass(state: CircuitState, policy: CircuitPolicy, nowMs: number): readonly [CircuitState, boolean];
195
+
196
+ export { type CircuitPolicy, type CircuitState, canPass, defaultCircuitPolicy, initCircuit, onFailure, onSuccess };
@@ -0,0 +1,53 @@
1
+ // src/circuit-breaker/index.ts
2
+ var defaultCircuitPolicy = {
3
+ failureThreshold: 5,
4
+ cooldownMs: 3e4,
5
+ halfOpenMaxProbes: 1
6
+ };
7
+ function initCircuit() {
8
+ return { phase: "closed", failures: 0 };
9
+ }
10
+ function onSuccess(state, _policy) {
11
+ switch (state.phase) {
12
+ case "closed":
13
+ return state.failures === 0 ? state : { phase: "closed", failures: 0 };
14
+ case "half_open":
15
+ return { phase: "closed", failures: 0 };
16
+ case "open":
17
+ return state;
18
+ }
19
+ }
20
+ function onFailure(state, policy, nowMs) {
21
+ switch (state.phase) {
22
+ case "closed": {
23
+ const failures = state.failures + 1;
24
+ return failures >= policy.failureThreshold ? { phase: "open", openedAtMs: nowMs } : { phase: "closed", failures };
25
+ }
26
+ case "half_open":
27
+ return { phase: "open", openedAtMs: nowMs };
28
+ case "open":
29
+ return state;
30
+ }
31
+ }
32
+ function canPass(state, policy, nowMs) {
33
+ switch (state.phase) {
34
+ case "closed":
35
+ return [state, true];
36
+ case "open": {
37
+ const elapsed = nowMs - state.openedAtMs;
38
+ if (elapsed >= policy.cooldownMs) {
39
+ return [{ phase: "half_open", probes: 1 }, true];
40
+ }
41
+ return [state, false];
42
+ }
43
+ case "half_open":
44
+ if (state.probes < policy.halfOpenMaxProbes) {
45
+ return [{ phase: "half_open", probes: state.probes + 1 }, true];
46
+ }
47
+ return [state, false];
48
+ }
49
+ }
50
+
51
+ export { canPass, defaultCircuitPolicy, initCircuit, onFailure, onSuccess };
52
+ //# sourceMappingURL=index.js.map
53
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/circuit-breaker/index.ts"],"names":[],"mappings":";AAwHO,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":"index.js","sourcesContent":["/**\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"]}
@@ -0,0 +1,113 @@
1
+ import { Sub } from '../index.js';
2
+ import { S as SubscribeHandler } from '../types-CwBC5wMn.js';
3
+
4
+ /**
5
+ * @demlik/tea/deadline — a one-shot Sub that fires when an ABSOLUTE wall-clock
6
+ * deadline is reached.
7
+ *
8
+ * Generalizes the recurring "auto-fail a machine at T+N" / "15-minute stale
9
+ * guard" pattern: a Sub whose `subscriptions(state)` returns it only while the
10
+ * deadline should be armed, and which dispatches a single `DeadlineExceeded`
11
+ * Msg when the wall clock crosses `atMs`.
12
+ *
13
+ * Difference from `fromTimeout` (relative — "fire after N ms"): a deadline is
14
+ * an ABSOLUTE target. The delay is computed at subscribe time as
15
+ * `max(0, atMs - Date.now())`. The consequence that matters: subscribing LATE —
16
+ * e.g. after a `Store` rehydrate that resumes a machine whose deadline was set
17
+ * before the page reloaded — still fires at the correct absolute moment,
18
+ * because the remaining delay is recomputed from the current clock, not stored
19
+ * in the Sub. A deadline already in the past at subscribe time yields delay `0`,
20
+ * so `setTimeout(fn, 0)` fires on the NEXT tick (never synchronously inside the
21
+ * reconcile pass — the substrate must finish wiring all subs before any Msg
22
+ * lands).
23
+ *
24
+ * Composition over reinvention: the timer lifecycle is `fromTimeout`'s, not
25
+ * redrawn here. `deadlineSub` produces the Sub literal carrying `atMs`; the
26
+ * `subscribe` cell (`subscribeDeadline`) translates `atMs` → a relative delay
27
+ * and delegates the `setTimeout` / `clearTimeout` to `fromTimeout`. The clock
28
+ * read (`Date.now()`) lives only in the subscribe handler — never in a reducer
29
+ * (invariant 2) — and is exercised in tests via vitest fake timers, matching
30
+ * the no-injection convention of the `fromTimeout` / `fromInterval` exemplars.
31
+ *
32
+ * Strengthens invariant 4 (external time is a subscription — the deadline is a
33
+ * `Sub` the runtime reconciles, never a `setTimeout` leaked into a reducer) and
34
+ * invariant 7 (identity is explicit — the Sub carries a stable `SubId`, so the
35
+ * reconcile pass leaves it running across transitions instead of churning it).
36
+ */
37
+
38
+ /**
39
+ * The Sub variant a deadline produces. `atMs` is the absolute target — epoch
40
+ * milliseconds (the `Date.now()` / `Date.parse(...)` scale), NOT a relative
41
+ * delay. It rides on the Sub so the reconcile pass keys identity by `id`
42
+ * alone: the same `id` across transitions means the same armed timer, even if
43
+ * a later `subscriptions(state)` recomputes the literal.
44
+ *
45
+ * Lowercase `"deadline"` discriminant per the Sub naming convention (a source
46
+ * noun that doubles as the SubId family); same shape family as the
47
+ * `TimeoutSubData` the `fromTimeout` factory consumes.
48
+ */
49
+ type DeadlineSub = Sub<"deadline"> & {
50
+ readonly atMs: number;
51
+ };
52
+ /**
53
+ * The Msg the deadline dispatches when the wall clock crosses `atMs`. Exported
54
+ * as a tagged shape so consumers can union it into their machine's Msg type and
55
+ * handle it in a reducer cell. `id` lets a reducer disambiguate WHICH deadline
56
+ * fired when a machine arms several at once; `atMs` echoes the target the timer
57
+ * was scheduled against (useful for assertions / observability).
58
+ *
59
+ * Snake-shaped `"deadline_exceeded"` matches the tagged-Msg style this module's
60
+ * orchestrator requested; the actor-in-name PascalCase convention
61
+ * (`TimerFiredDeadline`) is the consumer's to apply at their own boundary if
62
+ * they prefer — `deadlineExceeded(...)` only fixes the wire shape, not the
63
+ * consumer's local dialect.
64
+ */
65
+ type DeadlineExceeded = {
66
+ readonly type: "deadline_exceeded";
67
+ readonly id: string;
68
+ readonly atMs: number;
69
+ };
70
+ /**
71
+ * Build a deadline Sub literal. Pure data — no clock read, no timer; the timer
72
+ * is armed later by the `subscribe` cell. `id` is branded via `subId(...)` so
73
+ * accidental raw-string drift fails at the type level (invariant 7).
74
+ *
75
+ * Return the result directly from `subscriptions(state)` while the deadline
76
+ * should be armed; drop it (return `[]`, or stop returning this id) to disarm —
77
+ * the reconcile pass calls the cleanup, clearing the pending timer before it
78
+ * fires. That is the "cancel on state exit" lifecycle, identical to
79
+ * `fromTimeout`'s.
80
+ *
81
+ * @param id Stable identity for this deadline. Same `id` across transitions =
82
+ * same armed timer (no churn). Use distinct ids for distinct
83
+ * deadlines on the same machine.
84
+ * @param atMs Absolute target in epoch milliseconds (e.g. `Date.now() + 900_000`
85
+ * for a 15-minute guard, or a persisted `expiresAt`).
86
+ */
87
+ declare function deadlineSub(id: string, atMs: number): DeadlineSub;
88
+ /**
89
+ * The `subscribe["deadline"]` cell. Arms a one-shot timer for the remaining
90
+ * delay `max(0, atMs - Date.now())` and dispatches `deadlineExceeded(...)` when
91
+ * it fires; returns a cleanup that clears the pending timer.
92
+ *
93
+ * Composes on `fromTimeout`: it adapts the absolute `atMs` on the Sub to the
94
+ * relative `delayMs` that `fromTimeout` schedules against, then hands off the
95
+ * `setTimeout` / `clearTimeout` lifecycle wholesale. A past deadline collapses
96
+ * to `delayMs: 0`, so `fromTimeout` fires on the next tick — asynchronously,
97
+ * never inside the reconcile pass.
98
+ *
99
+ * Assign directly to a `Subscribe` cell:
100
+ *
101
+ * subscribe: {
102
+ * deadline: subscribeDeadline,
103
+ * }
104
+ */
105
+ declare const subscribeDeadline: SubscribeHandler<DeadlineSub, DeadlineExceeded, unknown>;
106
+ /**
107
+ * Construct the Msg the deadline dispatches. Exported so consumers can build /
108
+ * assert the same shape (and so the subscribe cell and tests share one
109
+ * constructor rather than two literals that can drift).
110
+ */
111
+ declare function deadlineExceeded(id: string, atMs: number): DeadlineExceeded;
112
+
113
+ export { type DeadlineExceeded, type DeadlineSub, deadlineExceeded, deadlineSub, subscribeDeadline };
@@ -0,0 +1,20 @@
1
+ import { fromTimeout } from '../chunk-2YFLOCWX.js';
2
+ import { subId } from '../chunk-CMG2KMWX.js';
3
+
4
+ // src/deadline/index.ts
5
+ function deadlineSub(id, atMs) {
6
+ return { id: subId(id), type: "deadline", atMs };
7
+ }
8
+ var subscribeDeadline = (sub, ctx, dispatch) => {
9
+ const remainingMs = Math.max(0, sub.atMs - Date.now());
10
+ return fromTimeout(
11
+ (armed) => deadlineExceeded(armed.id, armed.atMs)
12
+ )({ ...sub, delayMs: remainingMs }, ctx, dispatch);
13
+ };
14
+ function deadlineExceeded(id, atMs) {
15
+ return { type: "deadline_exceeded", id, atMs };
16
+ }
17
+
18
+ export { deadlineExceeded, deadlineSub, subscribeDeadline };
19
+ //# sourceMappingURL=index.js.map
20
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/deadline/index.ts"],"names":[],"mappings":";;;;AAuFO,SAAS,WAAA,CAAY,IAAY,IAAA,EAA2B;AACjE,EAAA,OAAO,EAAE,EAAA,EAAI,KAAA,CAAM,EAAE,CAAA,EAAG,IAAA,EAAM,YAAY,IAAA,EAAK;AACjD;AAmBO,IAAM,iBAAA,GAA8E,CACzF,GAAA,EACA,GAAA,EACA,QAAA,KACG;AAKH,EAAA,MAAM,WAAA,GAAc,KAAK,GAAA,CAAI,CAAA,EAAG,IAAI,IAAA,GAAO,IAAA,CAAK,KAAK,CAAA;AAMrD,EAAA,OAAO,WAAA;AAAA,IAAiE,CAAC,KAAA,KACvE,gBAAA,CAAiB,KAAA,CAAM,EAAA,EAAI,MAAM,IAAI;AAAA,GACvC,CAAE,EAAE,GAAG,GAAA,EAAK,SAAS,WAAA,EAAY,EAAG,KAAK,QAAQ,CAAA;AACnD;AAOO,SAAS,gBAAA,CAAiB,IAAY,IAAA,EAAgC;AAC3E,EAAA,OAAO,EAAE,IAAA,EAAM,mBAAA,EAAqB,EAAA,EAAI,IAAA,EAAK;AAC/C","file":"index.js","sourcesContent":["/**\n * @demlik/tea/deadline — a one-shot Sub that fires when an ABSOLUTE wall-clock\n * deadline is reached.\n *\n * Generalizes the recurring \"auto-fail a machine at T+N\" / \"15-minute stale\n * guard\" pattern: a Sub whose `subscriptions(state)` returns it only while the\n * deadline should be armed, and which dispatches a single `DeadlineExceeded`\n * Msg when the wall clock crosses `atMs`.\n *\n * Difference from `fromTimeout` (relative — \"fire after N ms\"): a deadline is\n * an ABSOLUTE target. The delay is computed at subscribe time as\n * `max(0, atMs - Date.now())`. The consequence that matters: subscribing LATE —\n * e.g. after a `Store` rehydrate that resumes a machine whose deadline was set\n * before the page reloaded — still fires at the correct absolute moment,\n * because the remaining delay is recomputed from the current clock, not stored\n * in the Sub. A deadline already in the past at subscribe time yields delay `0`,\n * so `setTimeout(fn, 0)` fires on the NEXT tick (never synchronously inside the\n * reconcile pass — the substrate must finish wiring all subs before any Msg\n * lands).\n *\n * Composition over reinvention: the timer lifecycle is `fromTimeout`'s, not\n * redrawn here. `deadlineSub` produces the Sub literal carrying `atMs`; the\n * `subscribe` cell (`subscribeDeadline`) translates `atMs` → a relative delay\n * and delegates the `setTimeout` / `clearTimeout` to `fromTimeout`. The clock\n * read (`Date.now()`) lives only in the subscribe handler — never in a reducer\n * (invariant 2) — and is exercised in tests via vitest fake timers, matching\n * the no-injection convention of the `fromTimeout` / `fromInterval` exemplars.\n *\n * Strengthens invariant 4 (external time is a subscription — the deadline is a\n * `Sub` the runtime reconciles, never a `setTimeout` leaked into a reducer) and\n * invariant 7 (identity is explicit — the Sub carries a stable `SubId`, so the\n * reconcile pass leaves it running across transitions instead of churning it).\n */\n\nimport { type Sub, subId } from \"../index\";\nimport { fromTimeout } from \"../subs/from-timeout\";\nimport type { SubscribeHandler } from \"../subs/types\";\n\n/**\n * The Sub variant a deadline produces. `atMs` is the absolute target — epoch\n * milliseconds (the `Date.now()` / `Date.parse(...)` scale), NOT a relative\n * delay. It rides on the Sub so the reconcile pass keys identity by `id`\n * alone: the same `id` across transitions means the same armed timer, even if\n * a later `subscriptions(state)` recomputes the literal.\n *\n * Lowercase `\"deadline\"` discriminant per the Sub naming convention (a source\n * noun that doubles as the SubId family); same shape family as the\n * `TimeoutSubData` the `fromTimeout` factory consumes.\n */\nexport type DeadlineSub = Sub<\"deadline\"> & { readonly atMs: number };\n\n/**\n * The Msg the deadline dispatches when the wall clock crosses `atMs`. Exported\n * as a tagged shape so consumers can union it into their machine's Msg type and\n * handle it in a reducer cell. `id` lets a reducer disambiguate WHICH deadline\n * fired when a machine arms several at once; `atMs` echoes the target the timer\n * was scheduled against (useful for assertions / observability).\n *\n * Snake-shaped `\"deadline_exceeded\"` matches the tagged-Msg style this module's\n * orchestrator requested; the actor-in-name PascalCase convention\n * (`TimerFiredDeadline`) is the consumer's to apply at their own boundary if\n * they prefer — `deadlineExceeded(...)` only fixes the wire shape, not the\n * consumer's local dialect.\n */\nexport type DeadlineExceeded = {\n readonly type: \"deadline_exceeded\";\n readonly id: string;\n readonly atMs: number;\n};\n\n/**\n * Build a deadline Sub literal. Pure data — no clock read, no timer; the timer\n * is armed later by the `subscribe` cell. `id` is branded via `subId(...)` so\n * accidental raw-string drift fails at the type level (invariant 7).\n *\n * Return the result directly from `subscriptions(state)` while the deadline\n * should be armed; drop it (return `[]`, or stop returning this id) to disarm —\n * the reconcile pass calls the cleanup, clearing the pending timer before it\n * fires. That is the \"cancel on state exit\" lifecycle, identical to\n * `fromTimeout`'s.\n *\n * @param id Stable identity for this deadline. Same `id` across transitions =\n * same armed timer (no churn). Use distinct ids for distinct\n * deadlines on the same machine.\n * @param atMs Absolute target in epoch milliseconds (e.g. `Date.now() + 900_000`\n * for a 15-minute guard, or a persisted `expiresAt`).\n */\nexport function deadlineSub(id: string, atMs: number): DeadlineSub {\n return { id: subId(id), type: \"deadline\", atMs };\n}\n\n/**\n * The `subscribe[\"deadline\"]` cell. Arms a one-shot timer for the remaining\n * delay `max(0, atMs - Date.now())` and dispatches `deadlineExceeded(...)` when\n * it fires; returns a cleanup that clears the pending timer.\n *\n * Composes on `fromTimeout`: it adapts the absolute `atMs` on the Sub to the\n * relative `delayMs` that `fromTimeout` schedules against, then hands off the\n * `setTimeout` / `clearTimeout` lifecycle wholesale. A past deadline collapses\n * to `delayMs: 0`, so `fromTimeout` fires on the next tick — asynchronously,\n * never inside the reconcile pass.\n *\n * Assign directly to a `Subscribe` cell:\n *\n * subscribe: {\n * deadline: subscribeDeadline,\n * }\n */\nexport const subscribeDeadline: SubscribeHandler<DeadlineSub, DeadlineExceeded, unknown> = (\n sub,\n ctx,\n dispatch,\n) => {\n // Recompute the remaining delay from the CURRENT clock so a late subscribe\n // (post-rehydrate) still targets the correct absolute moment. `max(0, …)`\n // keeps a past deadline at delay 0 → setTimeout(fn, 0) → fires next tick, not\n // synchronously.\n const remainingMs = Math.max(0, sub.atMs - Date.now());\n\n // Delegate the timer lifecycle to fromTimeout: build the relative-delay Sub\n // shape it consumes (`{ ...sub, delayMs }`) and let it own setTimeout /\n // clearTimeout. The msgFn closes over the deadline's identity + target so the\n // dispatched Msg carries both.\n return fromTimeout<DeadlineSub & { delayMs: number }, DeadlineExceeded>((armed) =>\n deadlineExceeded(armed.id, armed.atMs),\n )({ ...sub, delayMs: remainingMs }, ctx, dispatch);\n};\n\n/**\n * Construct the Msg the deadline dispatches. Exported so consumers can build /\n * assert the same shape (and so the subscribe cell and tests share one\n * constructor rather than two literals that can drift).\n */\nexport function deadlineExceeded(id: string, atMs: number): DeadlineExceeded {\n return { type: \"deadline_exceeded\", id, atMs };\n}\n"]}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @demlik/tea/debounce — a timer-based call transformer that coalesces a burst
3
+ * of calls into a single fire.
4
+ *
5
+ * Sibling of `@demlik/tea/throttle` — the two share a SYMMETRIC surface
6
+ * (`Debounced<A>` / `Throttled<A>`, both callable + `.cancel()` + `.flush()`,
7
+ * both `{ leading?, trailing? }`) so they read as a pair. They are kept in
8
+ * separate files with NO cross-import: each is a self-contained timer, and the
9
+ * coalesce-vs-drop policies are different enough that sharing code would hide
10
+ * the distinction. Read one to understand the other; do not couple them.
11
+ *
12
+ * WHAT THIS IS NOT — and why it lives outside `update`:
13
+ *
14
+ * - NOT a reducer / Transitions cell. Reducers are PURE (invariant 2 — same
15
+ * inputs → same outputs, no `Date.now`, no timers). A debounce holds a live
16
+ * `setTimeout` handle and mutable pending-args state; folding it into
17
+ * `update` would smuggle a timer into the pure core.
18
+ * - NOT a Sub. A Sub is a continuous SOURCE of Msgs the substrate reconciles
19
+ * by id every transition (invariant 4). A debounce is a SINK transformer:
20
+ * it wraps a `void`-returning handler (the canonical one is
21
+ * `Runtime.dispatch`) so bursty HOST events coalesce BEFORE they ever
22
+ * become a Msg.
23
+ *
24
+ * It is a higher-order function at the host boundary (pattern 14 — TEA owns
25
+ * state, JS owns timing). The host attaches the wrapped function to a noisy
26
+ * event source (`scroll`, `resize`, `input`, a ResizeObserver) and the
27
+ * substrate downstream only ever sees the coalesced Msg:
28
+ *
29
+ * const onResized = debounce(
30
+ * () => runtime.dispatch({ type: "BrowserResizedWindow" }),
31
+ * 150,
32
+ * );
33
+ * window.addEventListener("resize", onResized);
34
+ * // cleanup: onResized.cancel(); window.removeEventListener("resize", onResized);
35
+ *
36
+ * NOT exported from the package root — reached via the `@demlik/tea/debounce`
37
+ * subpath, same one-shape-per-package rule as `rate-limit`, `deadline`, and
38
+ * `work-queue`.
39
+ *
40
+ * Uses the universal `setTimeout` / `clearTimeout` timer idiom (same discipline
41
+ * as `subs/from-timeout.ts`): exactly one live handle at a time, every arm
42
+ * clears the previous, every fire / cancel clears the handle and nulls it so a
43
+ * stale callback can never run.
44
+ */
45
+ /**
46
+ * A debounced wrapper around `fn`. Callable with the SAME argument tuple as
47
+ * `fn` — `(...args: A) => void` — plus two control methods that share names and
48
+ * shapes with `Throttled<A>` so the two modules read as a symmetric pair.
49
+ */
50
+ interface Debounced<A extends readonly unknown[]> {
51
+ /** Schedule (or re-schedule) a fire of the wrapped `fn`. See `debounce`. */
52
+ (...args: A): void;
53
+ /**
54
+ * Drop any pending trailing fire WITHOUT invoking `fn`. Clears the timer and
55
+ * forgets the captured args. Idempotent — calling it with nothing pending is
56
+ * a no-op. The leading-edge latch (if `leading` is enabled) also resets, so
57
+ * the next call after `cancel` is treated as a fresh burst.
58
+ *
59
+ * The host-cleanup partner of `removeEventListener`: cancel the pending fire
60
+ * when the component unmounts / the listener detaches, so a queued
61
+ * `dispatch` can't land after teardown.
62
+ */
63
+ cancel(): void;
64
+ /**
65
+ * Fire any pending trailing call IMMEDIATELY with its captured args, then
66
+ * clear the timer. No-op when nothing is pending. Use to force the last
67
+ * coalesced call out early — e.g. flush a debounced save on `beforeunload`,
68
+ * or flush a debounced search when the user presses Enter.
69
+ */
70
+ flush(): void;
71
+ }
72
+ /**
73
+ * Wrap `fn` so a BURST of calls collapses to a single invocation.
74
+ *
75
+ * Trailing edge (the default): each call (re)starts a `ms` timer; `fn` fires
76
+ * once with the LAST args after `ms` of quiet. Every call within `ms` of the
77
+ * previous resets the timer — the classic "wait until the user stops typing"
78
+ * behavior.
79
+ *
80
+ * @param fn The handler to coalesce. Typically `(...) => runtime.dispatch(...)`,
81
+ * but any `(...args: A) => void` works. The return value is
82
+ * ignored — debounce is a fire transformer, not a value pipe.
83
+ * @param ms Quiet window in milliseconds. Calls closer together than `ms`
84
+ * coalesce; a gap of at least `ms` lets the trailing fire through.
85
+ * @param opts.leading Fire on the FIRST call of a burst (the leading edge),
86
+ * with that first call's args. Default `false`.
87
+ * @param opts.trailing Fire on the trailing edge with the LAST call's args.
88
+ * Default `true`.
89
+ *
90
+ * Edge combinations (matching lodash's settled semantics, the de-facto
91
+ * standard the ecosystem rediscovered):
92
+ *
93
+ * - `{ trailing: true }` (default): one fire, after the burst, last args.
94
+ * - `{ leading: true, trailing: false }`: one fire, at the burst START,
95
+ * first args. Subsequent calls within the window are swallowed; the timer
96
+ * only re-opens the "can lead again" latch after `ms` of quiet.
97
+ * - `{ leading: true, trailing: true }`: fires at the start AND end of a
98
+ * burst — BUT the trailing fire is SUPPRESSED for a burst of exactly ONE
99
+ * call (the leading fire already covered it, so a lone call doesn't
100
+ * double-fire). This matches lodash and is the behavior tests pin.
101
+ * - `{ leading: false, trailing: false }`: never fires. Degenerate but legal;
102
+ * we don't throw — the caller asked for a no-op transformer.
103
+ *
104
+ * WHY a default of trailing-only: a debounce's whole job is "act after the
105
+ * activity settles." The trailing edge IS that semantic — fire with the final
106
+ * state of the burst (the last keystroke, the final scroll position). Leading
107
+ * is the opt-in for "respond instantly, then go quiet."
108
+ */
109
+ declare function debounce<A extends readonly unknown[]>(fn: (...args: A) => void, ms: number, opts?: {
110
+ leading?: boolean;
111
+ trailing?: boolean;
112
+ }): Debounced<A>;
113
+
114
+ export { type Debounced, debounce };
@@ -0,0 +1,56 @@
1
+ // src/debounce/index.ts
2
+ function debounce(fn, ms, opts) {
3
+ const leading = opts?.leading ?? false;
4
+ const trailing = opts?.trailing ?? true;
5
+ let handle = null;
6
+ let pendingArgs = null;
7
+ let leadingFired = false;
8
+ function clearHandle() {
9
+ if (handle !== null) {
10
+ clearTimeout(handle);
11
+ handle = null;
12
+ }
13
+ }
14
+ function onTimerExpired() {
15
+ handle = null;
16
+ if (trailing && pendingArgs !== null) {
17
+ const args = pendingArgs;
18
+ pendingArgs = null;
19
+ fn(...args);
20
+ }
21
+ leadingFired = false;
22
+ }
23
+ const debounced = (...args) => {
24
+ const isBurstStart = handle === null;
25
+ if (leading && isBurstStart && !leadingFired) {
26
+ leadingFired = true;
27
+ fn(...args);
28
+ } else if (trailing) {
29
+ pendingArgs = args;
30
+ }
31
+ clearHandle();
32
+ handle = setTimeout(onTimerExpired, ms);
33
+ };
34
+ debounced.cancel = () => {
35
+ clearHandle();
36
+ pendingArgs = null;
37
+ leadingFired = false;
38
+ };
39
+ debounced.flush = () => {
40
+ if (pendingArgs !== null) {
41
+ const args = pendingArgs;
42
+ pendingArgs = null;
43
+ clearHandle();
44
+ leadingFired = false;
45
+ fn(...args);
46
+ } else {
47
+ clearHandle();
48
+ leadingFired = false;
49
+ }
50
+ };
51
+ return debounced;
52
+ }
53
+
54
+ export { debounce };
55
+ //# sourceMappingURL=index.js.map
56
+ //# sourceMappingURL=index.js.map