@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,37 @@
1
+ // src/retry-backoff/index.ts
2
+ var defaultRetryPolicy = {
3
+ baseMs: 100,
4
+ factor: 2,
5
+ capMs: 3e4,
6
+ maxAttempts: 5,
7
+ jitter: "full"
8
+ };
9
+ function backoffDelay(attempt, policy, rng = Math.random) {
10
+ const safeAttempt = attempt < 0 ? 0 : attempt;
11
+ const exponential = policy.baseMs * policy.factor ** safeAttempt;
12
+ const d = Number.isNaN(exponential) ? 0 : Math.min(policy.capMs, exponential);
13
+ switch (policy.jitter) {
14
+ case "none":
15
+ return d;
16
+ case "full":
17
+ return Math.min(d, rng() * d);
18
+ case "equal":
19
+ return Math.min(d, d / 2 + rng() * (d / 2));
20
+ }
21
+ }
22
+ function initRetry() {
23
+ return { attempt: 0 };
24
+ }
25
+ function recordFailure(state, error) {
26
+ return { attempt: state.attempt + 1, lastError: error };
27
+ }
28
+ function shouldRetry(state, policy) {
29
+ return state.attempt < policy.maxAttempts;
30
+ }
31
+ function nextDelayMs(state, policy, rng = Math.random) {
32
+ return backoffDelay(state.attempt, policy, rng);
33
+ }
34
+
35
+ export { backoffDelay, defaultRetryPolicy, initRetry, nextDelayMs, recordFailure, shouldRetry };
36
+ //# sourceMappingURL=index.js.map
37
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/retry-backoff/index.ts"],"names":[],"mappings":";AA2EO,IAAM,kBAAA,GAAkC;AAAA,EAC7C,MAAA,EAAQ,GAAA;AAAA,EACR,MAAA,EAAQ,CAAA;AAAA,EACR,KAAA,EAAO,GAAA;AAAA,EACP,WAAA,EAAa,CAAA;AAAA,EACb,MAAA,EAAQ;AACV;AAsBO,SAAS,YAAA,CACd,OAAA,EACA,MAAA,EACA,GAAA,GAAoB,KAAK,MAAA,EACjB;AAIR,EAAA,MAAM,WAAA,GAAc,OAAA,GAAU,CAAA,GAAI,CAAA,GAAI,OAAA;AACtC,EAAA,MAAM,WAAA,GAAc,MAAA,CAAO,MAAA,GAAS,MAAA,CAAO,MAAA,IAAU,WAAA;AAMrD,EAAA,MAAM,CAAA,GAAI,MAAA,CAAO,KAAA,CAAM,WAAW,CAAA,GAAI,IAAI,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,KAAA,EAAO,WAAW,CAAA;AAE5E,EAAA,QAAQ,OAAO,MAAA;AAAQ,IACrB,KAAK,MAAA;AACH,MAAA,OAAO,CAAA;AAAA,IACT,KAAK,MAAA;AAKH,MAAA,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,GAAA,KAAQ,CAAC,CAAA;AAAA,IAC9B,KAAK,OAAA;AAGH,MAAA,OAAO,IAAA,CAAK,IAAI,CAAA,EAAG,CAAA,GAAI,IAAI,GAAA,EAAI,IAAK,IAAI,CAAA,CAAE,CAAA;AAAA;AAEhD;AAgBO,SAAS,SAAA,GAAwB;AACtC,EAAA,OAAO,EAAE,SAAS,CAAA,EAAE;AACtB;AAMO,SAAS,aAAA,CAAc,OAAmB,KAAA,EAA4B;AAC3E,EAAA,OAAO,EAAE,OAAA,EAAS,KAAA,CAAM,OAAA,GAAU,CAAA,EAAG,WAAW,KAAA,EAAM;AACxD;AASO,SAAS,WAAA,CAAY,OAAmB,MAAA,EAA8B;AAC3E,EAAA,OAAO,KAAA,CAAM,UAAU,MAAA,CAAO,WAAA;AAChC;AASO,SAAS,WAAA,CACd,KAAA,EACA,MAAA,EACA,GAAA,GAAoB,KAAK,MAAA,EACjB;AACR,EAAA,OAAO,YAAA,CAAa,KAAA,CAAM,OAAA,EAAS,MAAA,EAAQ,GAAG,CAAA;AAChD","file":"index.js","sourcesContent":["/**\n * @demlik/tea/retry-backoff — exponential backoff with jitter + cap, and the\n * retry-attempt state every fallible `interpret` handler folds over.\n *\n * This is a \"pure state + ops\" module in the same mold as\n * `@demlik/tea/work-queue`: a state type plus pure transition functions,\n * storage- and host-agnostic, that a consumer wires into their own reducer /\n * interpret. Nothing here reads the clock or the RNG on its own behalf —\n * randomness is *injected* (`rng`) and time is *computed by the caller* from\n * the millisecond delays this module returns. That keeps every function\n * deterministic and unit-testable, and keeps the substrate's purity invariants\n * intact when the result is threaded through `update` (invariant 2) and the\n * delay is realized as a `Cmd` the runtime performs (invariant 3).\n *\n * Typical wiring:\n *\n * // in `update`, on a failure Msg:\n * const retry = recordFailure(state.retry, msg.error);\n * if (!shouldRetry(retry, policy)) return [toGaveUp(state), []];\n * const delayMs = nextDelayMs(retry, policy); // pure: rng injected at call site\n * return [{ ...state, retry }, [{ type: \"ScheduleRetry\", delayMs, into: TimerFiredRetry }]];\n *\n * The reducer stays pure: `nextDelayMs` is called with the runtime's RNG only\n * at the emit boundary (or the caller defers the whole delay computation to the\n * `interpret` handler and passes `rng` there). Either way the substrate never\n * sees `Math.random()` inside a transition body.\n *\n * NOT a substrate primitive: it depends on nothing from `../index`. It is a\n * leaf utility consumers reach via the `@demlik/tea/retry-backoff` subpath.\n */\n\n/**\n * Jitter strategy applied to the computed backoff delay.\n *\n * Names follow the AWS \"Exponential Backoff And Jitter\" taxonomy (Brooker,\n * 2015), the same vocabulary used by the AWS SDKs and most retry libraries:\n *\n * - `\"none\"` — no jitter; the delay is the raw capped exponential `d`.\n * Deterministic, but synchronizes retries across clients\n * (thundering herd). Use only when callers are already\n * de-correlated by other means.\n * - `\"full\"` — uniform random in `[0, d]`. Maximum spread; best herd\n * avoidance, highest variance in latency.\n * - `\"equal\"` — `d/2 + random(0, d/2)`, i.e. uniform in `[d/2, d]`. Half the\n * delay is fixed, half is jittered: keeps a latency floor while\n * still de-correlating clients. A common default.\n */\nexport type Jitter = \"none\" | \"full\" | \"equal\";\n\n/**\n * A retry policy is pure configuration — the knobs of the backoff curve. It\n * carries no mutable state; `RetryState` does. One policy is shared across all\n * the attempts of one logical operation (often a module-scope const).\n */\nexport interface RetryPolicy {\n /** Delay for attempt 0, in milliseconds, before any jitter or cap. */\n readonly baseMs: number;\n /** Geometric growth factor. `2` doubles the delay each attempt. */\n readonly factor: number;\n /** Hard ceiling, in milliseconds. The capped exponential never exceeds this. */\n readonly capMs: number;\n /**\n * Maximum number of attempts. `shouldRetry` allows attempts `0 .. maxAttempts-1`,\n * so `maxAttempts: 3` means \"try once, then retry at most twice\" (attempts 0, 1, 2).\n */\n readonly maxAttempts: number;\n /** Jitter strategy applied to the capped exponential delay. */\n readonly jitter: Jitter;\n}\n\n/**\n * Sensible defaults: 100ms base, doubling, capped at 30s, up to 5 attempts,\n * full jitter (the herd-avoidance default). Tuned for network calls; override\n * per call site rather than mutating this shared const.\n */\nexport const defaultRetryPolicy: RetryPolicy = {\n baseMs: 100,\n factor: 2,\n capMs: 30_000,\n maxAttempts: 5,\n jitter: \"full\",\n};\n\n/**\n * Compute the delay for a given 0-based `attempt` under `policy`.\n *\n * PURE. The exponential is `baseMs * factor ** attempt`, clamped to `capMs`,\n * then jittered. `rng` is injected so the result is deterministic in tests —\n * pass an `rng` returning a fixed value to pin the jitter; the production\n * default reads `Math.random` only when the caller omits it (at the effect\n * boundary, never inside a reducer).\n *\n * `rng` must return a value in `[0, 1)` (the `Math.random` contract). The\n * returned delay is always in `[0, capMs]` regardless of jitter strategy:\n * - `\"none\"` → exactly the capped exponential `d`.\n * - `\"full\"` → `rng() * d`, uniform in `[0, d]`.\n * - `\"equal\"` → `d/2 + rng() * d/2`, uniform in `[d/2, d]`.\n *\n * `attempt` is treated as `>= 0`; a negative attempt is clamped to 0 so a\n * caller's off-by-one never produces a fractional exponent or a delay below\n * `baseMs`. `capMs` is also the floor of the clamp, so a misconfigured\n * `capMs < baseMs` still yields a delay within `[0, capMs]`.\n */\nexport function backoffDelay(\n attempt: number,\n policy: RetryPolicy,\n rng: () => number = Math.random,\n): number {\n // Clamp the attempt to >= 0 so factor ** attempt is never a fractional /\n // negative exponent. The exponential can overflow to Infinity for large\n // attempts; Math.min with capMs collapses that back to the ceiling.\n const safeAttempt = attempt < 0 ? 0 : attempt;\n const exponential = policy.baseMs * policy.factor ** safeAttempt;\n // `factor ** attempt` overflows to Infinity for large attempts. With\n // `baseMs > 0` that's `Infinity`, which Math.min collapses back to `capMs`.\n // With `baseMs === 0` it's `0 * Infinity === NaN`; a zero base means zero\n // delay, so treat NaN as 0 (otherwise NaN poisons the comparison and breaks\n // the `0 <= delay <= capMs` postcondition).\n const d = Number.isNaN(exponential) ? 0 : Math.min(policy.capMs, exponential);\n\n switch (policy.jitter) {\n case \"none\":\n return d;\n case \"full\":\n // Uniform in [0, d]. Math.min re-pins the upper bound: at subnormal /\n // extreme-precision `d`, `rng() * d` can round one ULP above `d`, which\n // would breach the documented `delay <= capMs` postcondition. Clamping to\n // `d` (already <= capMs) makes the invariant hold by construction.\n return Math.min(d, rng() * d);\n case \"equal\":\n // Fixed half + jittered half → uniform in [d/2, d]. Same FP-rounding clamp\n // as \"full\": `d/2 + rng()*(d/2)` can round above `d` for subnormal `d`.\n return Math.min(d, d / 2 + rng() * (d / 2));\n }\n}\n\n/**\n * Per-operation retry state: how many attempts have failed so far, and the most\n * recent error. `lastError` is `unknown` because the failure can be anything a\n * boundary surfaces (an `Error`, a tagged result, a string) — this module never\n * inspects it, it only carries it forward for the consumer's reducer to read.\n */\nexport interface RetryState {\n /** Count of failures recorded so far. Also the 0-based index of the next attempt. */\n readonly attempt: number;\n /** The error from the most recent failure, if any. Carried, never interpreted. */\n readonly lastError?: unknown;\n}\n\n/** The starting state: zero attempts, no error yet. */\nexport function initRetry(): RetryState {\n return { attempt: 0 };\n}\n\n/**\n * Record a failed attempt. PURE — returns a *new* state with `attempt`\n * incremented and `lastError` set; the input `state` is never mutated.\n */\nexport function recordFailure(state: RetryState, error: unknown): RetryState {\n return { attempt: state.attempt + 1, lastError: error };\n}\n\n/**\n * Whether another attempt is permitted under `policy`. PURE.\n *\n * `attempt` is the number of failures recorded so far (= the index of the next\n * attempt). We may retry while that index is below `maxAttempts`, i.e. attempts\n * `0 .. maxAttempts-1` are allowed and the `maxAttempts`-th is refused.\n */\nexport function shouldRetry(state: RetryState, policy: RetryPolicy): boolean {\n return state.attempt < policy.maxAttempts;\n}\n\n/**\n * The delay to wait before the next attempt, given the current state. PURE.\n *\n * Bridges `RetryState` → `backoffDelay` by using `state.attempt` as the 0-based\n * attempt index, so the delay grows with each recorded failure. `rng` is\n * injected for determinism, same contract as `backoffDelay`.\n */\nexport function nextDelayMs(\n state: RetryState,\n policy: RetryPolicy,\n rng: () => number = Math.random,\n): number {\n return backoffDelay(state.attempt, policy, rng);\n}\n"]}
@@ -0,0 +1,57 @@
1
+ import { Sub, Runtime, Port } from '../index.js';
2
+ import { S as SubscribeHandler } from '../types-CwBC5wMn.js';
3
+
4
+ interface MinimalEvent {
5
+ readonly type: string;
6
+ }
7
+ interface MinimalMessageEvent extends MinimalEvent {
8
+ readonly data: unknown;
9
+ }
10
+ interface MinimalEventTarget {
11
+ addEventListener(type: string, listener: (event: MinimalEvent) => void): void;
12
+ removeEventListener(type: string, listener: (event: MinimalEvent) => void): void;
13
+ }
14
+
15
+ type BroadcastSubData = {
16
+ channelName: string;
17
+ };
18
+ declare function fromBroadcastChannel<S extends Sub & BroadcastSubData, M>(msgFn: (event: MinimalMessageEvent, sub: S) => M | null): SubscribeHandler<S, M, unknown>;
19
+
20
+ type EventSourceSubData = {
21
+ url: string;
22
+ };
23
+ interface EventSourceFactoryOpts<S, M> {
24
+ onMessage: (data: string, sub: S) => M | null;
25
+ onError?: (event: MinimalEvent, sub: S) => M | null;
26
+ onOpen?: (sub: S) => M | null;
27
+ }
28
+ declare function fromEventSource<S extends Sub & EventSourceSubData, M>(opts: EventSourceFactoryOpts<S, M>): SubscribeHandler<S, M, unknown>;
29
+
30
+ declare function fromEventTarget<S extends Sub, M>(getTarget: () => MinimalEventTarget, eventName: string, msgFn: (event: MinimalEvent, sub: S) => M | null): SubscribeHandler<S, M, unknown>;
31
+
32
+ type IntervalSubData = {
33
+ intervalMs: number;
34
+ };
35
+ declare function fromInterval<S extends Sub & IntervalSubData, M>(msgFn: (sub: S) => M): SubscribeHandler<S, M, unknown>;
36
+
37
+ declare function fromPort<S extends Sub, M, Ctx, T>(selectRuntime: (ctx: Ctx) => Runtime<unknown, {
38
+ type: string;
39
+ }>, port: Port<T>, msgFn: (value: T, sub: S) => M | null): SubscribeHandler<S, M, Ctx>;
40
+
41
+ type TimeoutSubData = {
42
+ delayMs: number;
43
+ };
44
+ declare function fromTimeout<S extends Sub & TimeoutSubData, M>(msgFn: (sub: S) => M): SubscribeHandler<S, M, unknown>;
45
+
46
+ type WebSocketSubData = {
47
+ wsUrl: string;
48
+ };
49
+ interface WebSocketFactoryOpts<S, M> {
50
+ onMessage: (data: unknown, sub: S) => M | null;
51
+ onOpen?: (sub: S) => M | null;
52
+ onError?: (event: MinimalEvent, sub: S) => M | null;
53
+ onClose?: (code: number, reason: string, sub: S) => M | null;
54
+ }
55
+ declare function fromWebSocket<S extends Sub & WebSocketSubData, M>(opts: WebSocketFactoryOpts<S, M>): SubscribeHandler<S, M, unknown>;
56
+
57
+ export { type EventSourceFactoryOpts, SubscribeHandler, type WebSocketFactoryOpts, type WebSocketSubData, fromBroadcastChannel, fromEventSource, fromEventTarget, fromInterval, fromPort, fromTimeout, fromWebSocket };
@@ -0,0 +1,4 @@
1
+ export { fromBroadcastChannel, fromEventSource, fromEventTarget, fromInterval, fromPort, fromWebSocket } from '../chunk-Y5JM3UUH.js';
2
+ export { fromTimeout } from '../chunk-2YFLOCWX.js';
3
+ //# sourceMappingURL=index.js.map
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
@@ -0,0 +1,220 @@
1
+ import { Cmd, Sub, Machine, Runtime } from '../index.js';
2
+
3
+ /**
4
+ * Shared options shape for every test assertion below. Mirrors the
5
+ * second argument of `replay(machine, opts)` — `msgs` is the sequence to
6
+ * fold over (may be empty for init-only assertions), `ctx` is the
7
+ * substrate-required ctx value, `loaded` is the optional pre-`init`
8
+ * snapshot (omit / pass `null` for fresh boot).
9
+ */
10
+ interface ReplayOpts<S, M, Ctx> {
11
+ readonly msgs: readonly M[];
12
+ readonly ctx: Ctx;
13
+ readonly loaded?: S | null;
14
+ }
15
+ /**
16
+ * Assert the final state after replaying `opts.msgs` equals `expected`.
17
+ * Replaces the boilerplate:
18
+ *
19
+ * const { state } = replay(machine, opts);
20
+ * expect(state).toEqual(expected);
21
+ *
22
+ * Uses `toEqual` (deep structural). For type-narrowing assertions that
23
+ * branch on `state.type` afterwards, fall back to plain `replay` —
24
+ * `expectFinalState` is the "exact match" tool.
25
+ */
26
+ declare function expectFinalState<S, M extends {
27
+ type: string;
28
+ }, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, opts: NoInfer<ReplayOpts<S, M, Ctx>>, expected: NoInfer<S>): void;
29
+ /**
30
+ * Assert that `cmd` appears at least once in the cmds array produced by
31
+ * replaying `opts.msgs`. Uses `toContainEqual` (deep structural). Order
32
+ * and surrounding cmds are not asserted — use `expectCmdSequence` for
33
+ * exact ordered match.
34
+ *
35
+ * Replaces:
36
+ *
37
+ * const { cmds } = replay(machine, opts);
38
+ * expect(cmds).toContainEqual(cmd);
39
+ */
40
+ declare function expectCmdEmitted<S, M extends {
41
+ type: string;
42
+ }, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, opts: NoInfer<ReplayOpts<S, M, Ctx>>, cmd: NoInfer<C>): void;
43
+ /**
44
+ * Assert the exact ordered sequence of cmds emitted by replaying
45
+ * `opts.msgs`. Uses `toEqual` against the full cmds array — extra cmds
46
+ * fail the assertion, missing cmds fail the assertion, wrong order
47
+ * fails the assertion.
48
+ *
49
+ * Replaces:
50
+ *
51
+ * const { cmds } = replay(machine, opts);
52
+ * expect(cmds).toEqual(expected);
53
+ */
54
+ declare function expectCmdSequence<S, M extends {
55
+ type: string;
56
+ }, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, opts: NoInfer<ReplayOpts<S, M, Ctx>>, expected: NoInfer<readonly C[]>): void;
57
+ /**
58
+ * Assert the exact set of subs desired at the final state after
59
+ * replaying `opts.msgs`. Uses `toEqual` against the full subs array.
60
+ *
61
+ * Replaces:
62
+ *
63
+ * const { subs } = replay(machine, opts);
64
+ * expect(subs).toEqual(expected);
65
+ */
66
+ declare function expectActiveSubs<S, M extends {
67
+ type: string;
68
+ }, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, opts: NoInfer<ReplayOpts<S, M, Ctx>>, expected: NoInfer<readonly U[]>): void;
69
+ /**
70
+ * Single-msg step helper — feeds `loaded → msg → next state + cmds emitted by
71
+ * that msg`. Returns a readonly tuple in the shape consumers used before the
72
+ * substrate (`[state, cmds]`), so existing tests that destructure with
73
+ * `[next, cmds]` can swap `reduce(state, msg)` for `step(machine, state, msg,
74
+ * ctx)` without changing the assertion code.
75
+ *
76
+ * Internally a `replay` call with `loaded: prevState` and a single-element
77
+ * `msgs` array. The cmds returned are exactly the cmds emitted by THIS msg
78
+ * (init emits no cmds when `loaded` is provided, so the slice is the entire
79
+ * array).
80
+ *
81
+ * Use when consumers want to walk a machine one msg at a time and assert per
82
+ * step. For one-shot "final state after N msgs" assertions, prefer
83
+ * `expectFinalState`.
84
+ */
85
+ declare function step<S, M extends {
86
+ type: string;
87
+ }, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, loaded: NoInfer<S>, msg: NoInfer<M>, ctx: NoInfer<Ctx>): readonly [S, readonly C[]];
88
+
89
+ /**
90
+ * Replay options shape used by every bound method. `ctx` is captured by
91
+ * `bindMachine`; the per-call opts only need `msgs` and an optional
92
+ * `loaded` snapshot. (Compare `ReplayOpts` in `./assertions.ts`, which
93
+ * still takes `ctx` because the free helpers cannot capture it.)
94
+ */
95
+ interface BoundOpts<S, M> {
96
+ readonly msgs: readonly M[];
97
+ readonly loaded?: S | null;
98
+ }
99
+ /**
100
+ * The bound testing surface. Every method is 2-arg max — `ctx` and
101
+ * `machine` are captured by `bindMachine`.
102
+ *
103
+ * `NoInfer<...>` on `expected` / `cmd` arguments mirrors the discipline
104
+ * the free helpers use (see `./assertions.ts`): inference flows purely
105
+ * FROM the machine, so a `[]`-shaped expected does not drag `C` down to
106
+ * `never` and reject the machine.
107
+ */
108
+ interface BoundMachine<S, M extends {
109
+ type: string;
110
+ }, C extends Cmd, U extends Sub, Ctx> {
111
+ /**
112
+ * Single-msg step — feeds `loaded → msg → [next state, cmds emitted by
113
+ * that msg]`. Mirrors the free `step(machine, loaded, msg, ctx)`.
114
+ */
115
+ step(loaded: S, msg: M): readonly [S, readonly C[]];
116
+ /**
117
+ * Assert the final state after replaying `opts.msgs` deep-equals
118
+ * `expected`. Mirrors the free `expectFinalState(machine, opts, expected)`.
119
+ */
120
+ expectFinalState(opts: BoundOpts<S, M>, expected: NoInfer<S>): void;
121
+ /**
122
+ * Assert `cmd` appears at least once in the emitted cmd list. Order is
123
+ * not asserted. Mirrors the free `expectCmdEmitted(machine, opts, cmd)`.
124
+ */
125
+ expectCmdEmitted(opts: BoundOpts<S, M>, cmd: NoInfer<C>): void;
126
+ /**
127
+ * Assert the exact ordered sequence of cmds emitted by replaying
128
+ * `opts.msgs`. Mirrors the free `expectCmdSequence(machine, opts, expected)`.
129
+ */
130
+ expectCmdSequence(opts: BoundOpts<S, M>, expected: readonly NoInfer<C>[]): void;
131
+ /**
132
+ * Assert the exact set of subs desired at the final state. Mirrors the
133
+ * free `expectActiveSubs(machine, opts, expected)`.
134
+ */
135
+ expectActiveSubs(opts: BoundOpts<S, M>, expected: readonly NoInfer<U>[]): void;
136
+ /**
137
+ * Bound `replay` — returns `{ state, cmds, subs }` for the given opts.
138
+ * Use for the "narrow-then-assert" pattern where the test inspects a
139
+ * specific field after `state.type === "..."` discrimination.
140
+ */
141
+ replay(opts: BoundOpts<S, M>): {
142
+ state: S;
143
+ cmds: readonly C[];
144
+ subs: readonly U[];
145
+ };
146
+ }
147
+ /**
148
+ * Bind a machine + ctx into a small bag of 2-arg helpers. Each method
149
+ * delegates to `replay(machine, { ...opts, ctx })` and pipes the result
150
+ * through a single `expect(...)` matcher (or returns the raw tuple, for
151
+ * `step` and `replay`).
152
+ *
153
+ * Use one bind per `describe` block (or per file) when many assertions
154
+ * share the same machine + ctx. For one-off cross-machine calls, stick
155
+ * with the free helpers from `./assertions.ts`.
156
+ */
157
+ declare function bindMachine<S, M extends {
158
+ type: string;
159
+ }, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, ctx: Ctx): BoundMachine<S, M, C, U, Ctx>;
160
+
161
+ /**
162
+ * Construct an inert `Runtime<S, M>` value. Use at ctx sites that require
163
+ * a Runtime reference but where the test never invokes `interpret` (so
164
+ * the runtime is never dereferenced in practice).
165
+ *
166
+ * @param opts.initialState Optional value returned by `getState()`. Omit
167
+ * when the test never calls `getState()` on the no-op runtime — most
168
+ * tests only need the type-level slot filled.
169
+ */
170
+ declare function noopRuntime<S, M extends {
171
+ type: string;
172
+ }>(opts?: {
173
+ initialState?: S;
174
+ }): Runtime<S, M>;
175
+
176
+ /** A factory-constructed phase: `S` narrowed to the arm whose discriminator equals `K`. */
177
+ type StateOf<S, K> = Extract<S, {
178
+ type: K;
179
+ }>;
180
+ /** A factory-constructed phase with the `type` discriminator removed — the "fields the caller cares about" half. */
181
+ type RequiredFields<T> = Omit<T, "type">;
182
+ /**
183
+ * Defaults shape passed to `stateFactory`. Mapped type over the discriminant
184
+ * set: every phase MUST have an entry, and that entry MUST match the phase's
185
+ * required fields minus the `type` discriminator.
186
+ */
187
+ type StateFactoryDefaults<S extends {
188
+ type: string;
189
+ }> = {
190
+ readonly [K in S["type"]]: RequiredFields<StateOf<S, K>>;
191
+ };
192
+ /**
193
+ * The API returned by `stateFactory`. One method per phase; calling it with
194
+ * no args returns the default snapshot, calling it with a partial overrides
195
+ * object merges those overrides in (with undefineds stripped — see
196
+ * stripUndefined below).
197
+ */
198
+ type StateFactoryAPI<S extends {
199
+ type: string;
200
+ }> = {
201
+ readonly [K in S["type"]]: (overrides?: Partial<RequiredFields<StateOf<S, K>>>) => StateOf<S, K>;
202
+ };
203
+ /**
204
+ * Build a typed phase-constructor API from per-phase defaults. The defaults
205
+ * object MUST have one entry per `S["type"]` discriminant; the returned API
206
+ * has one method per phase. Each method merges the defaults with optional
207
+ * overrides (undefineds stripped), then stamps the `type` discriminator.
208
+ *
209
+ * @example
210
+ * type S = { type: "a"; n: number } | { type: "b"; s: string };
211
+ * const f = stateFactory<S>({ a: { n: 0 }, b: { s: "" } });
212
+ * f.a(); // { type: "a", n: 0 }
213
+ * f.a({ n: 42 }); // { type: "a", n: 42 }
214
+ * f.b({ s: "hi" }); // { type: "b", s: "hi" }
215
+ */
216
+ declare function stateFactory<S extends {
217
+ type: string;
218
+ }>(defaults: StateFactoryDefaults<S>): StateFactoryAPI<S>;
219
+
220
+ export { type BoundMachine, type ReplayOpts, type StateFactoryAPI, type StateFactoryDefaults, bindMachine, expectActiveSubs, expectCmdEmitted, expectCmdSequence, expectFinalState, noopRuntime, stateFactory, step };
@@ -0,0 +1,105 @@
1
+ import { replay } from '../chunk-CMG2KMWX.js';
2
+ import { expect } from 'vitest';
3
+
4
+ function expectFinalState(machine, opts, expected) {
5
+ const { state } = replay(machine, opts);
6
+ expect(state).toEqual(expected);
7
+ }
8
+ function expectCmdEmitted(machine, opts, cmd) {
9
+ const { cmds } = replay(machine, opts);
10
+ expect(cmds).toContainEqual(cmd);
11
+ }
12
+ function expectCmdSequence(machine, opts, expected) {
13
+ const { cmds } = replay(machine, opts);
14
+ expect(cmds).toEqual(expected);
15
+ }
16
+ function expectActiveSubs(machine, opts, expected) {
17
+ const { subs } = replay(machine, opts);
18
+ expect(subs).toEqual(expected);
19
+ }
20
+ function step(machine, loaded, msg, ctx) {
21
+ const { state, cmds } = replay(machine, { msgs: [msg], ctx, loaded });
22
+ return [state, cmds];
23
+ }
24
+ function bindMachine(machine, ctx) {
25
+ return {
26
+ step(loaded, msg) {
27
+ const { state, cmds } = replay(machine, { msgs: [msg], ctx, loaded });
28
+ return [state, cmds];
29
+ },
30
+ expectFinalState(opts, expected) {
31
+ const { state } = replay(machine, { ...opts, ctx });
32
+ expect(state).toEqual(expected);
33
+ },
34
+ expectCmdEmitted(opts, cmd) {
35
+ const { cmds } = replay(machine, { ...opts, ctx });
36
+ expect(cmds).toContainEqual(cmd);
37
+ },
38
+ expectCmdSequence(opts, expected) {
39
+ const { cmds } = replay(machine, { ...opts, ctx });
40
+ expect(cmds).toEqual(expected);
41
+ },
42
+ expectActiveSubs(opts, expected) {
43
+ const { subs } = replay(machine, { ...opts, ctx });
44
+ expect(subs).toEqual(expected);
45
+ },
46
+ replay(opts) {
47
+ return replay(machine, { ...opts, ctx });
48
+ }
49
+ };
50
+ }
51
+
52
+ // src/testing/noop-runtime.ts
53
+ function noopRuntime(opts) {
54
+ const initialState = opts?.initialState;
55
+ const noopUnsubscribe = () => {
56
+ };
57
+ return {
58
+ async dispatch(_msg) {
59
+ },
60
+ getState() {
61
+ return initialState;
62
+ },
63
+ subscribe(_listener) {
64
+ return noopUnsubscribe;
65
+ },
66
+ observe(_observer) {
67
+ return noopUnsubscribe;
68
+ },
69
+ subscribePort(_port, _listener) {
70
+ return noopUnsubscribe;
71
+ },
72
+ emitPort(_port, _value) {
73
+ },
74
+ ready: Promise.resolve(),
75
+ async stop() {
76
+ }
77
+ };
78
+ }
79
+
80
+ // src/testing/state-factory.ts
81
+ function stripUndefined(obj) {
82
+ const out = {};
83
+ for (const key of Object.keys(obj)) {
84
+ const value = obj[key];
85
+ if (value !== void 0) {
86
+ out[key] = value;
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ function stateFactory(defaults) {
92
+ const api = {};
93
+ for (const key of Object.keys(defaults)) {
94
+ const phaseDefaults = defaults[key];
95
+ api[key] = (overrides) => {
96
+ const cleanedOverrides = overrides ? stripUndefined(overrides) : {};
97
+ return { type: key, ...phaseDefaults, ...cleanedOverrides };
98
+ };
99
+ }
100
+ return api;
101
+ }
102
+
103
+ export { bindMachine, expectActiveSubs, expectCmdEmitted, expectCmdSequence, expectFinalState, noopRuntime, stateFactory, step };
104
+ //# sourceMappingURL=index.js.map
105
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/testing/assertions.ts","../../src/testing/bind-machine.ts","../../src/testing/noop-runtime.ts","../../src/testing/state-factory.ts"],"names":["expect"],"mappings":"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","file":"index.js","sourcesContent":["// ---------------------------------------------------------------------------\n// Test-side assertions over `replay`. Each helper composes the replay call\n// with a single `expect(...)` so consumers stop hand-rolling the 5-line\n// \"destructure { state, cmds } → expect.toBe / toEqual / toContainEqual\"\n// boilerplate at every test site.\n//\n// Each helper is a thin wrapper that:\n// 1. Calls `replay(machine, opts)` once.\n// 2. Pipes the result into a single vitest `expect(...)` matcher.\n//\n// All helpers return `void` — assertions throw on failure (vitest contract).\n// No mutation of the machine or opts. Pure test ergonomics.\n//\n// Type-inference note: each helper anchors its generic parameters to the\n// `machine` argument. The `expected` (and `opts.msgs`) arguments are wrapped\n// in `NoInfer<...>` so they do NOT participate in inference — otherwise a\n// `[]`-shaped `expected` would force `C = never` and reject a machine whose\n// real Cmd union is non-empty. Anchoring inference to the machine is the\n// same discipline `replay` uses (it has nothing but the machine and opts).\n//\n// Strengthens invariant 9 (the testing surface is named, small, and forks\n// nothing of the substrate's purity guarantees).\n// ---------------------------------------------------------------------------\n\nimport { expect } from \"vitest\";\nimport { type Cmd, type Machine, replay, type Sub } from \"../index\";\n\n// `NoInfer<T>` is the built-in TypeScript 5.4+ intrinsic that blocks\n// inference from a generic parameter at a position. We use it on `expected`\n// and `opts` so inference flows purely FROM the machine — `[]`-shaped\n// `expected` no longer drags `C` down to `never` and rejects the machine.\n\n/**\n * Shared options shape for every test assertion below. Mirrors the\n * second argument of `replay(machine, opts)` — `msgs` is the sequence to\n * fold over (may be empty for init-only assertions), `ctx` is the\n * substrate-required ctx value, `loaded` is the optional pre-`init`\n * snapshot (omit / pass `null` for fresh boot).\n */\nexport interface ReplayOpts<S, M, Ctx> {\n readonly msgs: readonly M[];\n readonly ctx: Ctx;\n readonly loaded?: S | null;\n}\n\n/**\n * Assert the final state after replaying `opts.msgs` equals `expected`.\n * Replaces the boilerplate:\n *\n * const { state } = replay(machine, opts);\n * expect(state).toEqual(expected);\n *\n * Uses `toEqual` (deep structural). For type-narrowing assertions that\n * branch on `state.type` afterwards, fall back to plain `replay` —\n * `expectFinalState` is the \"exact match\" tool.\n */\nexport function expectFinalState<S, M extends { type: string }, C extends Cmd, U extends Sub, Ctx>(\n machine: Machine<S, M, C, U, Ctx>,\n opts: NoInfer<ReplayOpts<S, M, Ctx>>,\n expected: NoInfer<S>,\n): void {\n const { state } = replay(machine, opts);\n expect(state).toEqual(expected);\n}\n\n/**\n * Assert that `cmd` appears at least once in the cmds array produced by\n * replaying `opts.msgs`. Uses `toContainEqual` (deep structural). Order\n * and surrounding cmds are not asserted — use `expectCmdSequence` for\n * exact ordered match.\n *\n * Replaces:\n *\n * const { cmds } = replay(machine, opts);\n * expect(cmds).toContainEqual(cmd);\n */\nexport function expectCmdEmitted<S, M extends { type: string }, C extends Cmd, U extends Sub, Ctx>(\n machine: Machine<S, M, C, U, Ctx>,\n opts: NoInfer<ReplayOpts<S, M, Ctx>>,\n cmd: NoInfer<C>,\n): void {\n const { cmds } = replay(machine, opts);\n expect(cmds).toContainEqual(cmd);\n}\n\n/**\n * Assert the exact ordered sequence of cmds emitted by replaying\n * `opts.msgs`. Uses `toEqual` against the full cmds array — extra cmds\n * fail the assertion, missing cmds fail the assertion, wrong order\n * fails the assertion.\n *\n * Replaces:\n *\n * const { cmds } = replay(machine, opts);\n * expect(cmds).toEqual(expected);\n */\nexport function expectCmdSequence<S, M extends { type: string }, C extends Cmd, U extends Sub, Ctx>(\n machine: Machine<S, M, C, U, Ctx>,\n opts: NoInfer<ReplayOpts<S, M, Ctx>>,\n expected: NoInfer<readonly C[]>,\n): void {\n const { cmds } = replay(machine, opts);\n expect(cmds).toEqual(expected);\n}\n\n/**\n * Assert the exact set of subs desired at the final state after\n * replaying `opts.msgs`. Uses `toEqual` against the full subs array.\n *\n * Replaces:\n *\n * const { subs } = replay(machine, opts);\n * expect(subs).toEqual(expected);\n */\nexport function expectActiveSubs<S, M extends { type: string }, C extends Cmd, U extends Sub, Ctx>(\n machine: Machine<S, M, C, U, Ctx>,\n opts: NoInfer<ReplayOpts<S, M, Ctx>>,\n expected: NoInfer<readonly U[]>,\n): void {\n const { subs } = replay(machine, opts);\n expect(subs).toEqual(expected);\n}\n\n/**\n * Single-msg step helper — feeds `loaded → msg → next state + cmds emitted by\n * that msg`. Returns a readonly tuple in the shape consumers used before the\n * substrate (`[state, cmds]`), so existing tests that destructure with\n * `[next, cmds]` can swap `reduce(state, msg)` for `step(machine, state, msg,\n * ctx)` without changing the assertion code.\n *\n * Internally a `replay` call with `loaded: prevState` and a single-element\n * `msgs` array. The cmds returned are exactly the cmds emitted by THIS msg\n * (init emits no cmds when `loaded` is provided, so the slice is the entire\n * array).\n *\n * Use when consumers want to walk a machine one msg at a time and assert per\n * step. For one-shot \"final state after N msgs\" assertions, prefer\n * `expectFinalState`.\n */\nexport function step<S, M extends { type: string }, C extends Cmd, U extends Sub, Ctx>(\n machine: Machine<S, M, C, U, Ctx>,\n loaded: NoInfer<S>,\n msg: NoInfer<M>,\n ctx: NoInfer<Ctx>,\n): readonly [S, readonly C[]] {\n const { state, cmds } = replay(machine, { msgs: [msg], ctx, loaded });\n return [state, cmds] as const;\n}\n","// ---------------------------------------------------------------------------\n// `bindMachine(machine, ctx)` — bound test harness over the free-function\n// assertions in `./assertions.ts`. Closes over the `machine` and `ctx` once\n// so per-test callsites stop repeating both at every invocation.\n//\n// Why this exists: every consumer test file ends up hand-rolling a local\n// two-arg `step(state, msg)` wrapper around `step(machine, state, msg, ctx)`,\n// and the four `expect*` assertions repeat `machine` + `ctx` at every\n// callsite. The bound form pulls both into one place and gives every\n// per-test call its tightest possible shape (2-arg max).\n//\n// `bindMachine` is built ON TOP of the free helpers — it does not replace\n// them. Both forms coexist; pick the one that fits the call site:\n//\n// - Free form → one-off call inside a helper that isn't bound to one\n// machine (e.g. cross-machine integration test).\n// - Bound form → a `describe` block runs many assertions against one\n// machine + ctx. The bound form is the common case.\n//\n// Strengthens invariant 9 (the testing surface is named, small, and forks\n// nothing of the substrate's purity guarantees).\n// ---------------------------------------------------------------------------\n\nimport { expect } from \"vitest\";\nimport { type Cmd, type Machine, replay, type Sub } from \"../index\";\n\n/**\n * Replay options shape used by every bound method. `ctx` is captured by\n * `bindMachine`; the per-call opts only need `msgs` and an optional\n * `loaded` snapshot. (Compare `ReplayOpts` in `./assertions.ts`, which\n * still takes `ctx` because the free helpers cannot capture it.)\n */\ninterface BoundOpts<S, M> {\n readonly msgs: readonly M[];\n readonly loaded?: S | null;\n}\n\n/**\n * The bound testing surface. Every method is 2-arg max — `ctx` and\n * `machine` are captured by `bindMachine`.\n *\n * `NoInfer<...>` on `expected` / `cmd` arguments mirrors the discipline\n * the free helpers use (see `./assertions.ts`): inference flows purely\n * FROM the machine, so a `[]`-shaped expected does not drag `C` down to\n * `never` and reject the machine.\n */\nexport interface BoundMachine<\n S,\n M extends { type: string },\n C extends Cmd,\n U extends Sub,\n // biome-ignore lint/correctness/noUnusedVariables: Ctx is a phantom-style\n // parameter — present on the interface for consumers that want to spell\n // `BoundMachine<S, M, C, U, Ctx>` explicitly, mirroring `Machine`.\n Ctx,\n> {\n /**\n * Single-msg step — feeds `loaded → msg → [next state, cmds emitted by\n * that msg]`. Mirrors the free `step(machine, loaded, msg, ctx)`.\n */\n step(loaded: S, msg: M): readonly [S, readonly C[]];\n /**\n * Assert the final state after replaying `opts.msgs` deep-equals\n * `expected`. Mirrors the free `expectFinalState(machine, opts, expected)`.\n */\n expectFinalState(opts: BoundOpts<S, M>, expected: NoInfer<S>): void;\n /**\n * Assert `cmd` appears at least once in the emitted cmd list. Order is\n * not asserted. Mirrors the free `expectCmdEmitted(machine, opts, cmd)`.\n */\n expectCmdEmitted(opts: BoundOpts<S, M>, cmd: NoInfer<C>): void;\n /**\n * Assert the exact ordered sequence of cmds emitted by replaying\n * `opts.msgs`. Mirrors the free `expectCmdSequence(machine, opts, expected)`.\n */\n expectCmdSequence(opts: BoundOpts<S, M>, expected: readonly NoInfer<C>[]): void;\n /**\n * Assert the exact set of subs desired at the final state. Mirrors the\n * free `expectActiveSubs(machine, opts, expected)`.\n */\n expectActiveSubs(opts: BoundOpts<S, M>, expected: readonly NoInfer<U>[]): void;\n /**\n * Bound `replay` — returns `{ state, cmds, subs }` for the given opts.\n * Use for the \"narrow-then-assert\" pattern where the test inspects a\n * specific field after `state.type === \"...\"` discrimination.\n */\n replay(opts: BoundOpts<S, M>): { state: S; cmds: readonly C[]; subs: readonly U[] };\n}\n\n/**\n * Bind a machine + ctx into a small bag of 2-arg helpers. Each method\n * delegates to `replay(machine, { ...opts, ctx })` and pipes the result\n * through a single `expect(...)` matcher (or returns the raw tuple, for\n * `step` and `replay`).\n *\n * Use one bind per `describe` block (or per file) when many assertions\n * share the same machine + ctx. For one-off cross-machine calls, stick\n * with the free helpers from `./assertions.ts`.\n */\nexport function bindMachine<S, M extends { type: string }, C extends Cmd, U extends Sub, Ctx>(\n machine: Machine<S, M, C, U, Ctx>,\n ctx: Ctx,\n): BoundMachine<S, M, C, U, Ctx> {\n return {\n step(loaded, msg) {\n const { state, cmds } = replay(machine, { msgs: [msg], ctx, loaded });\n return [state, cmds] as const;\n },\n expectFinalState(opts, expected) {\n const { state } = replay(machine, { ...opts, ctx });\n expect(state).toEqual(expected);\n },\n expectCmdEmitted(opts, cmd) {\n const { cmds } = replay(machine, { ...opts, ctx });\n expect(cmds).toContainEqual(cmd);\n },\n expectCmdSequence(opts, expected) {\n const { cmds } = replay(machine, { ...opts, ctx });\n expect(cmds).toEqual(expected);\n },\n expectActiveSubs(opts, expected) {\n const { subs } = replay(machine, { ...opts, ctx });\n expect(subs).toEqual(expected);\n },\n replay(opts) {\n return replay(machine, { ...opts, ctx });\n },\n };\n}\n","// ---------------------------------------------------------------------------\n// noopRuntime — a Runtime<S, M>-shaped value whose every method is inert.\n//\n// The motivating callsite: a machine's `ctx` requires a sibling Runtime<S,\n// M> reference (e.g. the queue runtime in extension/lib/queue-machine\n// reaches into an `auditRuntime: Runtime<...>` slot on its ctx so its\n// interpret handlers can dispatch into the audit machine). When the test\n// replays such a machine, `replay` never actually invokes `interpret`, so\n// the runtime reference is never dereferenced — but the type system\n// still requires a value of `Runtime<S, M>` shape at the ctx slot. Tests\n// today work around this with a hand-rolled 12-line NOOP_AUDIT_RUNTIME\n// const cast through `as unknown as Runtime<...>`.\n//\n// `noopRuntime<S, M>()` is that hand-roll, named once. It returns a real\n// Runtime<S, M> value whose every method is a structurally-correct no-op:\n//\n// - `dispatch`: resolves immediately, never advances state.\n// - `getState`: returns the optional `initialState` argument (or a\n// deliberate `undefined` cast as S — the function lies about its\n// return type when no initial state is provided; this is the trade-off\n// we accept for an ergonomic test substrate).\n// - `subscribe` / `observe` / `subscribePort`: register the listener,\n// return a no-op unsubscribe. The listener is never fired because the\n// state never changes.\n// - `emitPort`: no-op.\n// - `ready`: resolved Promise<void>.\n// - `stop`: resolved Promise<void>.\n//\n// Strengthens invariant 9 (the testing surface is named and small).\n// ---------------------------------------------------------------------------\n\nimport type { Port, Runtime } from \"../index\";\n\n/**\n * Construct an inert `Runtime<S, M>` value. Use at ctx sites that require\n * a Runtime reference but where the test never invokes `interpret` (so\n * the runtime is never dereferenced in practice).\n *\n * @param opts.initialState Optional value returned by `getState()`. Omit\n * when the test never calls `getState()` on the no-op runtime — most\n * tests only need the type-level slot filled.\n */\nexport function noopRuntime<S, M extends { type: string }>(opts?: {\n initialState?: S;\n}): Runtime<S, M> {\n const initialState = opts?.initialState;\n // Single source of truth for the \"do-nothing\" unsubscribe so multiple\n // subscribe call sites share the same function instance — purely cosmetic\n // (the function would be a no-op either way).\n const noopUnsubscribe = (): void => {};\n return {\n async dispatch(_msg: M): Promise<void> {\n // Resolves immediately. State never advances; listeners never fire.\n },\n getState(): S {\n // Lie if no initial state was provided. The caller's contract is\n // \"you accept the lie because you know replay never invokes\n // interpret and therefore never reads this.\" When the caller DOES\n // pass an initialState the lie collapses to truth.\n return initialState as S;\n },\n subscribe(_listener: () => void): () => void {\n return noopUnsubscribe;\n },\n observe(_observer: (msg: M | null, state: S) => void): () => void {\n return noopUnsubscribe;\n },\n subscribePort<T>(_port: Port<T>, _listener: (value: T) => void): () => void {\n return noopUnsubscribe;\n },\n emitPort<T>(_port: Port<T>, _value: T): void {\n // No-op. No subscribers to fan out to; nothing to do.\n },\n ready: Promise.resolve(),\n async stop(): Promise<void> {\n // Resolves immediately. No in-flight tail to drain, no subs to clean.\n },\n };\n}\n","// ---------------------------------------------------------------------------\n// stateFactory — typed phase-constructor API for a discriminated-union State.\n//\n// The motivating callsite: every reducer test today opens with ~60–80 lines\n// of hand-rolled factory functions, one per phase, each one's defaults\n// duplicated across files:\n//\n// function idleState() { return { type: \"idle\" }; }\n// function creatingWindowState(overrides?: Partial<...>) {\n// return { type: \"creating_window\", auditId: ..., wsUrl: ..., ...overrides };\n// }\n// ...\n//\n// `stateFactory<BackgroundState>({...})` collapses the whole block to one\n// declaration: pass each phase's REQUIRED defaults once (the type system\n// enforces every phase has an entry), and the helper builds an API of\n// `factory.idle()`, `factory.creating_window({...})`, etc.\n//\n// Two type-level guarantees:\n//\n// 1. Every phase has a defaults entry — `StateFactoryDefaults<S>` is a\n// mapped type keyed by every `S[\"type\"]`. Missing an entry is a\n// compile error (the @ts-expect-error in testing.spec.ts pins this).\n//\n// 2. Each phase's defaults match that phase's required shape minus the\n// `type` discriminator. Passing wrong fields fails to compile.\n//\n// The runtime is pure data:\n//\n// api[k] = (overrides) => ({\n// type: k,\n// ...defaults[k],\n// ...stripUndefined(overrides ?? {}),\n// })\n//\n// The `stripUndefined` pass is what preserves `exactOptionalPropertyTypes`\n// assignability — without it, calling `factory.creating_window({\n// queueItemId: undefined })` would land an explicit `queueItemId: undefined`\n// on the object, which is NOT assignable to `queueItemId?: string` under\n// `exactOptionalPropertyTypes`. Stripping undefineds before spread keeps\n// the boundary clean.\n//\n// `stripUndefined` is inlined (not imported from a shared stdlib) so this\n// package can stay dep-free of stdlib.\n// ---------------------------------------------------------------------------\n\n/** A factory-constructed phase: `S` narrowed to the arm whose discriminator equals `K`. */\ntype StateOf<S, K> = Extract<S, { type: K }>;\n\n/** A factory-constructed phase with the `type` discriminator removed — the \"fields the caller cares about\" half. */\ntype RequiredFields<T> = Omit<T, \"type\">;\n\n/**\n * Defaults shape passed to `stateFactory`. Mapped type over the discriminant\n * set: every phase MUST have an entry, and that entry MUST match the phase's\n * required fields minus the `type` discriminator.\n */\nexport type StateFactoryDefaults<S extends { type: string }> = {\n readonly [K in S[\"type\"]]: RequiredFields<StateOf<S, K>>;\n};\n\n/**\n * The API returned by `stateFactory`. One method per phase; calling it with\n * no args returns the default snapshot, calling it with a partial overrides\n * object merges those overrides in (with undefineds stripped — see\n * stripUndefined below).\n */\nexport type StateFactoryAPI<S extends { type: string }> = {\n readonly [K in S[\"type\"]]: (overrides?: Partial<RequiredFields<StateOf<S, K>>>) => StateOf<S, K>;\n};\n\n/**\n * Strip own-enumerable properties whose value is `undefined`. Used inside\n * the factory's spread chain so callers can pass `{ queueItemId: undefined }`\n * without the explicit-undefined landing on the result object (which would\n * fail to assign to a `queueItemId?: string` field under\n * `exactOptionalPropertyTypes`).\n *\n * Inlined (not imported from `a shared stdlib`) so `@demlik/tea/testing` can stay\n * dependency-free of stdlib. The 8-line cost of duplication is cheaper than\n * a package-graph edge.\n */\nfunction stripUndefined<T extends object>(obj: T): Partial<T> {\n const out: Partial<T> = {};\n for (const key of Object.keys(obj) as (keyof T)[]) {\n const value = obj[key];\n if (value !== undefined) {\n out[key] = value;\n }\n }\n return out;\n}\n\n/**\n * Build a typed phase-constructor API from per-phase defaults. The defaults\n * object MUST have one entry per `S[\"type\"]` discriminant; the returned API\n * has one method per phase. Each method merges the defaults with optional\n * overrides (undefineds stripped), then stamps the `type` discriminator.\n *\n * @example\n * type S = { type: \"a\"; n: number } | { type: \"b\"; s: string };\n * const f = stateFactory<S>({ a: { n: 0 }, b: { s: \"\" } });\n * f.a(); // { type: \"a\", n: 0 }\n * f.a({ n: 42 }); // { type: \"a\", n: 42 }\n * f.b({ s: \"hi\" }); // { type: \"b\", s: \"hi\" }\n */\nexport function stateFactory<S extends { type: string }>(\n defaults: StateFactoryDefaults<S>,\n): StateFactoryAPI<S> {\n // Build the API by walking the defaults' own keys. Each key corresponds\n // to one phase discriminator; the constructor closes over both the key\n // (for the `type` stamp) and that phase's defaults snapshot.\n const api = {} as { [k: string]: (overrides?: object) => unknown };\n for (const key of Object.keys(defaults) as (keyof StateFactoryDefaults<S>)[]) {\n const phaseDefaults = defaults[key];\n api[key as string] = (overrides?: object) => {\n // The strip-undefined pass lives here, not at the caller. See the\n // module header for the `exactOptionalPropertyTypes` rationale.\n const cleanedOverrides = overrides ? stripUndefined(overrides) : {};\n return { type: key, ...phaseDefaults, ...cleanedOverrides };\n };\n }\n return api as StateFactoryAPI<S>;\n}\n"]}
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @demlik/tea/throttle — a timer-based call transformer that caps invocation
3
+ * to at most once per `ms` window.
4
+ *
5
+ * Sibling of `@demlik/tea/debounce` — the two share a SYMMETRIC surface
6
+ * (`Throttled<A>` / `Debounced<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 differ enough that sharing code would hide the
10
+ * distinction. The contrast IS the lesson:
11
+ *
12
+ * - **debounce** waits for quiet — fires once AFTER a burst settles.
13
+ * - **throttle** enforces a rate — fires AT MOST once per `ms` while the
14
+ * burst is ongoing, dropping the calls in between.
15
+ *
16
+ * WHAT THIS IS NOT — and why it lives outside `update`:
17
+ *
18
+ * - NOT a reducer / Transitions cell. Reducers are PURE (invariant 2 — same
19
+ * inputs → same outputs, no `Date.now`, no timers). A throttle holds a live
20
+ * `setTimeout` handle and a window cursor; folding it into `update` would
21
+ * smuggle a timer into the pure core.
22
+ * - NOT a Sub. A Sub is a continuous SOURCE of Msgs the substrate reconciles
23
+ * by id every transition (invariant 4). A throttle is a SINK transformer:
24
+ * it wraps a `void`-returning handler (the canonical one is
25
+ * `Runtime.dispatch`) so a high-frequency HOST event is rate-limited BEFORE
26
+ * it ever becomes a Msg.
27
+ *
28
+ * It is a higher-order function at the host boundary (pattern 14 — TEA owns
29
+ * state, JS owns timing). The host attaches the wrapped function to a
30
+ * high-frequency event source (`mousemove`, `scroll`, `pointermove`, a game
31
+ * loop) and the substrate downstream only ever sees the rate-limited Msg:
32
+ *
33
+ * const onMoved = throttle(
34
+ * (e: PointerEvent) =>
35
+ * runtime.dispatch({ type: "UserMovedCursor", x: e.clientX, y: e.clientY }),
36
+ * 16, // ~60fps cap
37
+ * );
38
+ * window.addEventListener("pointermove", onMoved);
39
+ * // cleanup: onMoved.cancel(); window.removeEventListener("pointermove", onMoved);
40
+ *
41
+ * NOT exported from the package root — reached via the `@demlik/tea/throttle`
42
+ * subpath, same one-shape-per-package rule as `rate-limit`, `deadline`, and
43
+ * `work-queue`.
44
+ *
45
+ * Uses the universal `setTimeout` / `clearTimeout` timer idiom (same discipline
46
+ * as `subs/from-timeout.ts`): exactly one live handle at a time, the window
47
+ * timer clears itself on expiry, `cancel` / `flush` clear and null it so a
48
+ * stale callback can never run.
49
+ */
50
+ /**
51
+ * A throttled wrapper around `fn`. Callable with the SAME argument tuple as
52
+ * `fn` — `(...args: A) => void` — plus two control methods that share names and
53
+ * shapes with `Debounced<A>` so the two modules read as a symmetric pair.
54
+ */
55
+ interface Throttled<A extends readonly unknown[]> {
56
+ /** Invoke `fn` if the window is open, else record args for the trailing fire. */
57
+ (...args: A): void;
58
+ /**
59
+ * Drop any pending trailing fire WITHOUT invoking `fn`, and close the active
60
+ * window. Clears the timer, forgets the captured trailing args, and resets
61
+ * the cursor so the next call fires immediately on the leading edge (if
62
+ * `leading` is enabled). Idempotent.
63
+ *
64
+ * The host-cleanup partner of `removeEventListener`: cancel the pending fire
65
+ * when the component unmounts / the listener detaches, so a queued
66
+ * `dispatch` can't land after teardown.
67
+ */
68
+ cancel(): void;
69
+ /**
70
+ * Fire any pending trailing call IMMEDIATELY with its latest captured args,
71
+ * then close the window. No-op when nothing is pending. Use to force the last
72
+ * dropped call out early — e.g. flush the final cursor position when a drag
73
+ * ends, so the rest state isn't a stale mid-drag sample.
74
+ */
75
+ flush(): void;
76
+ }
77
+ /**
78
+ * Wrap `fn` so it runs AT MOST once per `ms` window, no matter how often it is
79
+ * called.
80
+ *
81
+ * Leading + trailing (the default): the first call fires `fn` IMMEDIATELY (the
82
+ * leading edge) and opens a `ms` window. Calls during the window are dropped
83
+ * from immediate firing but their args are remembered; when the window closes,
84
+ * `fn` fires ONCE more with the LATEST dropped args (the trailing edge) and a
85
+ * new window opens. A call after the window has already closed leads a fresh
86
+ * window immediately. Spacing every call at least `ms` apart therefore fires
87
+ * each one on its own leading edge.
88
+ *
89
+ * @param fn The handler to rate-limit. Typically `(...) => runtime.dispatch(...)`,
90
+ * but any `(...args: A) => void` works. The return value is
91
+ * ignored — throttle is a fire transformer, not a value pipe.
92
+ * @param ms Window length in milliseconds. `fn` fires at most once per `ms`.
93
+ * @param opts.leading Fire on the FIRST call of a window (the leading edge),
94
+ * immediately. Default `true`.
95
+ * @param opts.trailing Fire once at window END with the latest args dropped
96
+ * during the window. Default `true`.
97
+ *
98
+ * Edge combinations (matching lodash's settled semantics, the de-facto
99
+ * standard the ecosystem rediscovered):
100
+ *
101
+ * - `{ leading: true, trailing: true }` (default): immediate fire + a
102
+ * trailing fire per window while calls keep arriving. The trailing fire is
103
+ * SUPPRESSED when no extra call arrived during the window (a lone call
104
+ * already fired on the leading edge — no stale trailing double-fire).
105
+ * - `{ leading: true, trailing: false }`: only the immediate fire; everything
106
+ * in the window is dropped with no catch-up at the end.
107
+ * - `{ leading: false, trailing: true }`: no immediate fire; the FIRST call
108
+ * opens a window and `fn` fires at the window's end with the latest args.
109
+ * Steady "sample every `ms`" behavior with no instant response.
110
+ * - `{ leading: false, trailing: false }`: never fires. Degenerate but legal;
111
+ * we don't throw — the caller asked for a no-op transformer.
112
+ *
113
+ * WHY a default of leading + trailing both `true`: a throttle's job is "respond
114
+ * now, then keep responding at a steady rate." Leading gives the instant
115
+ * response (the first `mousemove` updates immediately); trailing guarantees the
116
+ * FINAL sample of a window isn't lost (the cursor ends where the user actually
117
+ * left it, not one window-width behind). Dropping either loses one of those two
118
+ * guarantees, so both default on.
119
+ */
120
+ declare function throttle<A extends readonly unknown[]>(fn: (...args: A) => void, ms: number, opts?: {
121
+ leading?: boolean;
122
+ trailing?: boolean;
123
+ }): Throttled<A>;
124
+
125
+ export { type Throttled, throttle };