@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.
- package/dist/cache/index.d.ts +151 -0
- package/dist/cache/index.js +53 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/chunk-2YFLOCWX.js +15 -0
- package/dist/chunk-2YFLOCWX.js.map +1 -0
- package/dist/chunk-CMG2KMWX.js +458 -0
- package/dist/chunk-CMG2KMWX.js.map +1 -0
- package/dist/chunk-PYBQREFU.js +95 -0
- package/dist/chunk-PYBQREFU.js.map +1 -0
- package/dist/chunk-PYOCUBQJ.js +196 -0
- package/dist/chunk-PYOCUBQJ.js.map +1 -0
- package/dist/chunk-Y5JM3UUH.js +126 -0
- package/dist/chunk-Y5JM3UUH.js.map +1 -0
- package/dist/chunk-ZRUST6BK.js +62 -0
- package/dist/chunk-ZRUST6BK.js.map +1 -0
- package/dist/circuit-breaker/index.d.ts +196 -0
- package/dist/circuit-breaker/index.js +53 -0
- package/dist/circuit-breaker/index.js.map +1 -0
- package/dist/deadline/index.d.ts +113 -0
- package/dist/deadline/index.js +20 -0
- package/dist/deadline/index.js.map +1 -0
- package/dist/debounce/index.d.ts +114 -0
- package/dist/debounce/index.js +56 -0
- package/dist/debounce/index.js.map +1 -0
- package/dist/devtools/index.d.ts +137 -0
- package/dist/devtools/index.js +217 -0
- package/dist/devtools/index.js.map +1 -0
- package/dist/devtools/styles.css +218 -0
- package/dist/do/index.d.ts +135 -0
- package/dist/do/index.js +54 -0
- package/dist/do/index.js.map +1 -0
- package/dist/extension/index.d.ts +207 -0
- package/dist/extension/index.js +26 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/react.d.ts +121 -0
- package/dist/extension/react.js +69 -0
- package/dist/extension/react.js.map +1 -0
- package/dist/extension/subs/index.d.ts +44 -0
- package/dist/extension/subs/index.js +83 -0
- package/dist/extension/subs/index.js.map +1 -0
- package/dist/extension/test-utils.d.ts +128 -0
- package/dist/extension/test-utils.js +269 -0
- package/dist/extension/test-utils.js.map +1 -0
- package/dist/idempotency/index.d.ts +131 -0
- package/dist/idempotency/index.js +58 -0
- package/dist/idempotency/index.js.map +1 -0
- package/dist/index.d.ts +364 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/machine-viz/index.d.ts +78 -0
- package/dist/machine-viz/index.js +139 -0
- package/dist/machine-viz/index.js.map +1 -0
- package/dist/mem/index.d.ts +45 -0
- package/dist/mem/index.js +19 -0
- package/dist/mem/index.js.map +1 -0
- package/dist/node/index.d.ts +134 -0
- package/dist/node/index.js +89 -0
- package/dist/node/index.js.map +1 -0
- package/dist/pbt/arbitraries/index.d.ts +135 -0
- package/dist/pbt/arbitraries/index.js +3 -0
- package/dist/pbt/arbitraries/index.js.map +1 -0
- package/dist/pbt/index.d.ts +28 -0
- package/dist/pbt/index.js +18 -0
- package/dist/pbt/index.js.map +1 -0
- package/dist/pbt/runners/index.d.ts +117 -0
- package/dist/pbt/runners/index.js +4 -0
- package/dist/pbt/runners/index.js.map +1 -0
- package/dist/rate-limit/index.d.ts +115 -0
- package/dist/rate-limit/index.js +45 -0
- package/dist/rate-limit/index.js.map +1 -0
- package/dist/react/index.d.ts +72 -0
- package/dist/react/index.js +33 -0
- package/dist/react/index.js.map +1 -0
- package/dist/recorder/index.d.ts +287 -0
- package/dist/recorder/index.js +157 -0
- package/dist/recorder/index.js.map +1 -0
- package/dist/retry-backoff/index.d.ts +131 -0
- package/dist/retry-backoff/index.js +37 -0
- package/dist/retry-backoff/index.js.map +1 -0
- package/dist/subs/index.d.ts +57 -0
- package/dist/subs/index.js +4 -0
- package/dist/subs/index.js.map +1 -0
- package/dist/testing/index.d.ts +220 -0
- package/dist/testing/index.js +105 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/throttle/index.d.ts +125 -0
- package/dist/throttle/index.js +54 -0
- package/dist/throttle/index.js.map +1 -0
- package/dist/trace-replay/index.d.ts +52 -0
- package/dist/trace-replay/index.js +57 -0
- package/dist/trace-replay/index.js.map +1 -0
- package/dist/types-CwBC5wMn.d.ts +5 -0
- package/dist/work-queue/index.d.ts +56 -0
- package/dist/work-queue/index.js +143 -0
- package/dist/work-queue/index.js.map +1 -0
- 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
|