@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":[],"names":[],"mappings":"","file":"index.js"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { MsgArbitraryTable, arbConstantMsg, arbGuidedSequence, arbMsg, arbMsgSequence, arbRecordMsg, stubCtxThrowingProxy } from './arbitraries/index.js';
|
|
2
|
+
export { Step, foldEvents, propertyInvariant, propertyTerminates, propertyTrace } from './runners/index.js';
|
|
3
|
+
import { Cmd, Reducer, Transitions } from '../index.js';
|
|
4
|
+
import 'fast-check';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extract the Msg discriminant set at runtime from a Reducer record or a
|
|
8
|
+
* Transitions table. Useful for asserting that a user-supplied
|
|
9
|
+
* `MsgArbitraryTable<M>` covers every variant the reducer can handle.
|
|
10
|
+
*
|
|
11
|
+
* - **Reducer form** — keys of the top-level record ARE the Msg variants.
|
|
12
|
+
* - **Transitions form** — keys of any single state's inner record. Every
|
|
13
|
+
* state's inner record must cover every Msg variant (mapped-type
|
|
14
|
+
* discipline), so reading the first state's keys is equivalent to reading
|
|
15
|
+
* the union.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import { auditBackgroundMachine } from "./reducer";
|
|
19
|
+
* msgTypeKeys(auditBackgroundMachine.update);
|
|
20
|
+
* // → ["start_audit", "stop_audit", "window_created", ...]
|
|
21
|
+
*/
|
|
22
|
+
declare function msgTypeKeys<S, M extends {
|
|
23
|
+
type: string;
|
|
24
|
+
}, C extends Cmd>(update: Reducer<S, M, C> | ([S] extends [{
|
|
25
|
+
type: string;
|
|
26
|
+
}] ? Transitions<S, M, C> : never)): readonly string[];
|
|
27
|
+
|
|
28
|
+
export { msgTypeKeys };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { arbConstantMsg, arbGuidedSequence, arbMsg, arbMsgSequence, arbRecordMsg, stubCtxThrowingProxy } from '../chunk-ZRUST6BK.js';
|
|
2
|
+
export { foldEvents, propertyInvariant, propertyTerminates, propertyTrace } from '../chunk-PYBQREFU.js';
|
|
3
|
+
import '../chunk-CMG2KMWX.js';
|
|
4
|
+
|
|
5
|
+
// src/pbt/util/msg-keys.ts
|
|
6
|
+
function msgTypeKeys(update) {
|
|
7
|
+
const keys = Object.keys(update);
|
|
8
|
+
if (keys.length === 0) return [];
|
|
9
|
+
const firstValue = update[keys[0]];
|
|
10
|
+
if (typeof firstValue === "function") {
|
|
11
|
+
return keys;
|
|
12
|
+
}
|
|
13
|
+
return Object.keys(firstValue);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { msgTypeKeys };
|
|
17
|
+
//# sourceMappingURL=index.js.map
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/pbt/util/msg-keys.ts"],"names":[],"mappings":";;;;;AAiCO,SAAS,YACd,MAAA,EACmB;AACnB,EAAA,MAAM,IAAA,GAAO,MAAA,CAAO,IAAA,CAAK,MAAgB,CAAA;AACzC,EAAA,IAAI,IAAA,CAAK,MAAA,KAAW,CAAA,EAAG,OAAO,EAAC;AAI/B,EAAA,MAAM,UAAA,GAAc,MAAA,CAAmC,IAAA,CAAK,CAAC,CAAE,CAAA;AAC/D,EAAA,IAAI,OAAO,eAAe,UAAA,EAAY;AACpC,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,OAAO,MAAA,CAAO,KAAK,UAAoB,CAAA;AACzC","file":"index.js","sourcesContent":["// ---------------------------------------------------------------------------\n// msgTypeKeys — read the Msg discriminant set at runtime from a Reducer\n// record or a Transitions table.\n//\n// TS Msg unions only exist at the type level; the discriminant strings are\n// erased at runtime. The reducer record / transitions table is the load-\n// bearing artifact that DOES carry the discriminants at runtime — its keys\n// (for Reducer) or any one cell's inner keys (for Transitions) are exactly\n// the set we need to build an `arbMsg` table without hand-listing them.\n//\n// Used by `arbMsg` to assert (in dev) that the user-supplied table covers\n// every Msg variant the reducer claims to handle. Strict drift detection\n// without runtime introspection of the type system.\n// ---------------------------------------------------------------------------\n\nimport type { Cmd, Reducer, Transitions } from \"../../index\";\n\n/**\n * Extract the Msg discriminant set at runtime from a Reducer record or a\n * Transitions table. Useful for asserting that a user-supplied\n * `MsgArbitraryTable<M>` covers every variant the reducer can handle.\n *\n * - **Reducer form** — keys of the top-level record ARE the Msg variants.\n * - **Transitions form** — keys of any single state's inner record. Every\n * state's inner record must cover every Msg variant (mapped-type\n * discipline), so reading the first state's keys is equivalent to reading\n * the union.\n *\n * @example\n * import { auditBackgroundMachine } from \"./reducer\";\n * msgTypeKeys(auditBackgroundMachine.update);\n * // → [\"start_audit\", \"stop_audit\", \"window_created\", ...]\n */\nexport function msgTypeKeys<S, M extends { type: string }, C extends Cmd>(\n update: Reducer<S, M, C> | ([S] extends [{ type: string }] ? Transitions<S, M, C> : never),\n): readonly string[] {\n const keys = Object.keys(update as object);\n if (keys.length === 0) return [];\n // Reducer form: every top-level value is a function (the cell).\n // Transitions form: every top-level value is an object (the inner record).\n // `keys[0]` is present — the length guard above rules out the empty record.\n const firstValue = (update as Record<string, unknown>)[keys[0]!];\n if (typeof firstValue === \"function\") {\n return keys;\n }\n // Transitions form — read inner record.\n return Object.keys(firstValue as object);\n}\n"]}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Cmd, Sub, Machine } from '../../index.js';
|
|
2
|
+
import * as fc from 'fast-check';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One unit in a fold trace — the four pieces of data a property
|
|
6
|
+
* invariant typically asserts on. `prev` is the state before the msg
|
|
7
|
+
* applied; `next` is the state after; `cmds` is the cmds emitted by this
|
|
8
|
+
* single step (NOT the running total).
|
|
9
|
+
*/
|
|
10
|
+
interface Step<S, M, C> {
|
|
11
|
+
readonly prev: S;
|
|
12
|
+
readonly msg: M;
|
|
13
|
+
readonly next: S;
|
|
14
|
+
readonly cmds: readonly C[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Fold a Msg sequence through a machine's pure reducer, returning the
|
|
18
|
+
* per-step trace, every state in order, and the final state. Initial
|
|
19
|
+
* state comes from `replay(machine, { msgs: [], ctx, loaded })` — i.e.
|
|
20
|
+
* `machine.init(loaded ?? null, ctx)` faithfully. Init's emitted cmds
|
|
21
|
+
* are NOT included in any `Step` (they're the boot moment, not a Msg).
|
|
22
|
+
*
|
|
23
|
+
* @param machine The machine under test.
|
|
24
|
+
* @param ctx The Ctx — typically `stubCtxThrowingProxy<Ctx>()` when
|
|
25
|
+
* init is pure. Pass a real ctx if init reads from it.
|
|
26
|
+
* @param loaded Optional pre-init snapshot (the `loaded` arg to
|
|
27
|
+
* `machine.init`). Defaults to `null` (fresh boot).
|
|
28
|
+
* @param msgs The Msg sequence to fold.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const { states, steps, finalState } = foldEvents(machine, ctx, null, [
|
|
32
|
+
* { type: "start" }, { type: "tick" }, { type: "stop" },
|
|
33
|
+
* ]);
|
|
34
|
+
* // steps[1] = { prev: { type: "counting", n: 0 }, msg: { type: "tick" }, ... }
|
|
35
|
+
*/
|
|
36
|
+
declare function foldEvents<S, M extends {
|
|
37
|
+
type: string;
|
|
38
|
+
}, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, ctx: NoInfer<Ctx>, loaded: NoInfer<S> | null, msgs: readonly NoInfer<M>[]): {
|
|
39
|
+
readonly states: readonly S[];
|
|
40
|
+
readonly steps: readonly Step<S, M, C>[];
|
|
41
|
+
readonly finalState: S;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Common options shared by every property runner. */
|
|
45
|
+
interface PropertyOpts<S> {
|
|
46
|
+
/** Pre-init snapshot — passed to `machine.init(loaded, ctx)`. Default `null`. */
|
|
47
|
+
readonly loaded?: S | null;
|
|
48
|
+
/** Number of generated sequences to test. Forwarded to `fc.assert`. */
|
|
49
|
+
readonly numRuns?: number;
|
|
50
|
+
/** Optional fast-check seed for reproducibility. */
|
|
51
|
+
readonly seed?: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Assert every generated Msg sequence ends in a terminal state. `terminal`
|
|
55
|
+
* is the predicate that names the acceptable final states (often
|
|
56
|
+
* `(s) => s.type === "done" || s.type === "idle"`).
|
|
57
|
+
*
|
|
58
|
+
* The "every sequence" property catches stuck states (machine gets to a
|
|
59
|
+
* non-terminal state and no further msg can escape) AND silent loops
|
|
60
|
+
* (every msg variant is a noop from a given state).
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* propertyTerminates(
|
|
64
|
+
* auditBackgroundMachine,
|
|
65
|
+
* stubCtxThrowingProxy<BackgroundCtx>(),
|
|
66
|
+
* arbMsgSequence(msgArb, { minLength: 1, maxLength: 30 }),
|
|
67
|
+
* (s) => s.type === "done" || s.type === "idle",
|
|
68
|
+
* { loaded: auditingState, numRuns: 500 },
|
|
69
|
+
* );
|
|
70
|
+
*/
|
|
71
|
+
declare function propertyTerminates<S, M extends {
|
|
72
|
+
type: string;
|
|
73
|
+
}, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, ctx: NoInfer<Ctx>, seqArb: fc.Arbitrary<readonly NoInfer<M>[]>, terminal: (state: NoInfer<S>) => boolean, opts?: NoInfer<PropertyOpts<S>>): void;
|
|
74
|
+
/**
|
|
75
|
+
* Assert a predicate holds on EVERY transition step in a generated
|
|
76
|
+
* sequence. The predicate sees `{ prev, msg, next, cmds }` per step;
|
|
77
|
+
* return true to accept, false (or throw) to fail.
|
|
78
|
+
*
|
|
79
|
+
* Use to pin a "when X happens in state Y, cmd Z must be emitted" rule.
|
|
80
|
+
* The shrinker minimizes the offending sequence to the smallest Msg list
|
|
81
|
+
* that still violates the predicate.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* // Pin the resume-handshake fix: `ws:client:hello` in auditing must
|
|
85
|
+
* // emit `send_ws { client:ready }`.
|
|
86
|
+
* propertyInvariant(
|
|
87
|
+
* auditBackgroundMachine, ctx,
|
|
88
|
+
* arbMsgSequence(msgArb, { prefix: [{ type: "ws:client:hello" }] }),
|
|
89
|
+
* ({ prev, msg, cmds }) =>
|
|
90
|
+
* !(prev.type === "auditing" && msg.type === "ws:client:hello") ||
|
|
91
|
+
* cmds.some((c) => c.type === "send_ws" && c.msg.type === "client:ready"),
|
|
92
|
+
* { loaded: auditingState, numRuns: 200 },
|
|
93
|
+
* );
|
|
94
|
+
*/
|
|
95
|
+
declare function propertyInvariant<S, M extends {
|
|
96
|
+
type: string;
|
|
97
|
+
}, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, ctx: NoInfer<Ctx>, seqArb: fc.Arbitrary<readonly NoInfer<M>[]>, invariant: (step: Step<NoInfer<S>, NoInfer<M>, NoInfer<C>>) => boolean, opts?: NoInfer<PropertyOpts<S>>): void;
|
|
98
|
+
/**
|
|
99
|
+
* Assert a predicate over the full trace plus the final state. Use when
|
|
100
|
+
* the property is "some Msg appeared in the trace before reaching the
|
|
101
|
+
* final state" or "no two consecutive steps had the same state.type" —
|
|
102
|
+
* predicates that need the full sequence, not a single step.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* propertyTrace(
|
|
106
|
+
* machine, ctx, seqArb,
|
|
107
|
+
* (steps, final) =>
|
|
108
|
+
* final.type !== "done" ||
|
|
109
|
+
* steps.some((step) => step.msg.type === "ws:audit:done"),
|
|
110
|
+
* { loaded: auditingState, numRuns: 500 },
|
|
111
|
+
* );
|
|
112
|
+
*/
|
|
113
|
+
declare function propertyTrace<S, M extends {
|
|
114
|
+
type: string;
|
|
115
|
+
}, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, ctx: NoInfer<Ctx>, seqArb: fc.Arbitrary<readonly NoInfer<M>[]>, predicate: (steps: readonly Step<NoInfer<S>, NoInfer<M>, NoInfer<C>>[], finalState: NoInfer<S>) => boolean, opts?: NoInfer<PropertyOpts<S>>): void;
|
|
116
|
+
|
|
117
|
+
export { type Step, foldEvents, propertyInvariant, propertyTerminates, propertyTrace };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @demlik/tea/rate-limit — two rate limiters as pure state + ops.
|
|
3
|
+
*
|
|
4
|
+
* Same shape as `@demlik/tea/work-queue`'s `ops.ts`: a state type plus pure
|
|
5
|
+
* transition functions. Host-agnostic by construction — no `Date.now()`, no
|
|
6
|
+
* timers, no I/O. The caller injects the clock (`nowMs`) and wires the result
|
|
7
|
+
* into its own reducer / interpret. This keeps the limiters inside TEA's
|
|
8
|
+
* invariant 2 (transitions are pure: same inputs → same outputs, no `Date.now`)
|
|
9
|
+
* so a consumer can fold them through `update` and exercise them with `replay`
|
|
10
|
+
* / the pbt fold without a fake clock.
|
|
11
|
+
*
|
|
12
|
+
* Every op returns a NEW state value and never mutates its input (invariant 1 —
|
|
13
|
+
* state is a value, not a place). The `tryConsume` / `record` ops return a
|
|
14
|
+
* `[next, ok]` tuple so the caller branches on the decision while threading the
|
|
15
|
+
* advanced state forward; `refill` / `remaining` are read-shaped helpers.
|
|
16
|
+
*
|
|
17
|
+
* Two complementary algorithms, picked by the cost the caller wants to pay:
|
|
18
|
+
*
|
|
19
|
+
* - **Token bucket** — O(1) state (four numbers), smooths bursts against a
|
|
20
|
+
* steady refill rate. Allows a burst up to `capacity`, then throttles to
|
|
21
|
+
* `refillPerSec`. Cheapest; the default choice.
|
|
22
|
+
* - **Sliding window log** — O(limit) state (one timestamp per in-window
|
|
23
|
+
* hit), exact "no more than `limit` events per `windowMs`". Pay the memory
|
|
24
|
+
* for precise window edges when a token bucket's continuous refill would
|
|
25
|
+
* let through too many at a boundary.
|
|
26
|
+
*
|
|
27
|
+
* NOT exported from the package root — reached via the `@demlik/tea/rate-limit`
|
|
28
|
+
* subpath, same one-shape-per-package rule as `work-queue` and `do`.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* A token bucket: `tokens` of `capacity` available now, replenished at
|
|
32
|
+
* `refillPerSec` tokens per second. `lastRefillMs` is the clock reading at
|
|
33
|
+
* which `tokens` was last computed — `refill` advances it. All four fields are
|
|
34
|
+
* plain numbers so the whole bucket is trivially JSON-serializable into a
|
|
35
|
+
* consumer's `Store<S>`.
|
|
36
|
+
*/
|
|
37
|
+
interface TokenBucket {
|
|
38
|
+
readonly tokens: number;
|
|
39
|
+
readonly capacity: number;
|
|
40
|
+
readonly refillPerSec: number;
|
|
41
|
+
readonly lastRefillMs: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create a full bucket. Starts at `capacity` tokens (the conventional "a fresh
|
|
45
|
+
* limiter lets the first burst through" behavior) and stamps `lastRefillMs`
|
|
46
|
+
* with the caller's clock so the first `refill` measures elapsed time from
|
|
47
|
+
* boot, not from epoch 0.
|
|
48
|
+
*/
|
|
49
|
+
declare function initBucket(capacity: number, refillPerSec: number, nowMs: number): TokenBucket;
|
|
50
|
+
/**
|
|
51
|
+
* Add the tokens accrued since `lastRefillMs`, clamped to `capacity`, and
|
|
52
|
+
* advance `lastRefillMs` to `nowMs`. PURE — returns a new bucket; the input is
|
|
53
|
+
* untouched.
|
|
54
|
+
*
|
|
55
|
+
* A `nowMs` at or before `lastRefillMs` (clock not advanced, or skewed
|
|
56
|
+
* backwards) adds a non-positive amount; `Math.max(0, …)` floors the elapsed
|
|
57
|
+
* term so a backwards clock can never *remove* tokens. `lastRefillMs` still
|
|
58
|
+
* advances to `nowMs` so the bucket tracks the latest reading the caller
|
|
59
|
+
* presented — the next forward step measures from there, not from a stale
|
|
60
|
+
* future timestamp.
|
|
61
|
+
*/
|
|
62
|
+
declare function refill(bucket: TokenBucket, nowMs: number): TokenBucket;
|
|
63
|
+
/**
|
|
64
|
+
* Refill against `nowMs`, then attempt to take `n` tokens (default 1).
|
|
65
|
+
*
|
|
66
|
+
* On success returns `[next, true]` with `n` subtracted; on failure returns
|
|
67
|
+
* `[refilled, false]` — the refilled bucket (so the caller keeps the advanced
|
|
68
|
+
* clock + accrued tokens) with no tokens spent. Branch on the boolean, thread
|
|
69
|
+
* the bucket forward regardless:
|
|
70
|
+
*
|
|
71
|
+
* const [b1, ok] = tryConsume(bucket, nowMs);
|
|
72
|
+
* return ok ? [{ ...state, bucket: b1 }, [doWork]] : [{ ...state, bucket: b1 }, []];
|
|
73
|
+
*
|
|
74
|
+
* PURE — input bucket is never mutated.
|
|
75
|
+
*/
|
|
76
|
+
declare function tryConsume(bucket: TokenBucket, nowMs: number, n?: number): readonly [TokenBucket, boolean];
|
|
77
|
+
/**
|
|
78
|
+
* A sliding-window log: the timestamps of every hit still inside the trailing
|
|
79
|
+
* `windowMs`, capped at `limit` events per window. `hits` is `readonly` and
|
|
80
|
+
* kept oldest-first (pruning slices the stale prefix off the front, recording
|
|
81
|
+
* appends to the back), so the array is its own sorted index. Exact at the
|
|
82
|
+
* window edge — unlike a token bucket's continuous refill, a hit leaves the
|
|
83
|
+
* count the instant it ages past `windowMs`.
|
|
84
|
+
*/
|
|
85
|
+
interface SlidingWindow {
|
|
86
|
+
readonly hits: readonly number[];
|
|
87
|
+
readonly windowMs: number;
|
|
88
|
+
readonly limit: number;
|
|
89
|
+
}
|
|
90
|
+
/** Create an empty window. No hits recorded yet. */
|
|
91
|
+
declare function initWindow(windowMs: number, limit: number): SlidingWindow;
|
|
92
|
+
/**
|
|
93
|
+
* Prune hits older than `nowMs - windowMs`, then record `nowMs` if there is
|
|
94
|
+
* room. Returns `[next, true]` with the hit appended when `hits.length < limit`
|
|
95
|
+
* after pruning, else `[pruned, false]` — the pruned window (capacity recovers
|
|
96
|
+
* as old hits age out) with nothing appended.
|
|
97
|
+
*
|
|
98
|
+
* The cutoff is half-open: a hit exactly `windowMs` old is pruned (`<= cutoff`
|
|
99
|
+
* drops it), so a hit recorded at `t` no longer counts at `t + windowMs`. That
|
|
100
|
+
* makes the window's capacity fully recover `windowMs` after the last hit, the
|
|
101
|
+
* behavior tests pin.
|
|
102
|
+
*
|
|
103
|
+
* PURE — input window is never mutated; `hits` is rebuilt by `filter` +
|
|
104
|
+
* spread.
|
|
105
|
+
*/
|
|
106
|
+
declare function record(window: SlidingWindow, nowMs: number): readonly [SlidingWindow, boolean];
|
|
107
|
+
/**
|
|
108
|
+
* How many more hits `record` would accept at `nowMs` without blocking —
|
|
109
|
+
* `limit` minus the live (post-prune) hit count, floored at 0. Read-only: it
|
|
110
|
+
* prunes a local copy to answer but returns no new window. Use it to render a
|
|
111
|
+
* "N requests remaining" affordance without committing a hit.
|
|
112
|
+
*/
|
|
113
|
+
declare function remaining(window: SlidingWindow, nowMs: number): number;
|
|
114
|
+
|
|
115
|
+
export { type SlidingWindow, type TokenBucket, initBucket, initWindow, record, refill, remaining, tryConsume };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/rate-limit/index.ts
|
|
2
|
+
function initBucket(capacity, refillPerSec, nowMs) {
|
|
3
|
+
return {
|
|
4
|
+
tokens: capacity,
|
|
5
|
+
capacity,
|
|
6
|
+
refillPerSec,
|
|
7
|
+
lastRefillMs: nowMs
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function refill(bucket, nowMs) {
|
|
11
|
+
const elapsedSec = Math.max(0, nowMs - bucket.lastRefillMs) / 1e3;
|
|
12
|
+
const replenished = bucket.tokens + elapsedSec * bucket.refillPerSec;
|
|
13
|
+
return {
|
|
14
|
+
...bucket,
|
|
15
|
+
tokens: Math.min(bucket.capacity, replenished),
|
|
16
|
+
lastRefillMs: nowMs
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function tryConsume(bucket, nowMs, n = 1) {
|
|
20
|
+
const refilled = refill(bucket, nowMs);
|
|
21
|
+
if (refilled.tokens >= n) {
|
|
22
|
+
return [{ ...refilled, tokens: refilled.tokens - n }, true];
|
|
23
|
+
}
|
|
24
|
+
return [refilled, false];
|
|
25
|
+
}
|
|
26
|
+
function initWindow(windowMs, limit) {
|
|
27
|
+
return { hits: [], windowMs, limit };
|
|
28
|
+
}
|
|
29
|
+
function record(window, nowMs) {
|
|
30
|
+
const cutoff = nowMs - window.windowMs;
|
|
31
|
+
const pruned = window.hits.filter((t) => t > cutoff);
|
|
32
|
+
if (pruned.length < window.limit) {
|
|
33
|
+
return [{ ...window, hits: [...pruned, nowMs] }, true];
|
|
34
|
+
}
|
|
35
|
+
return [{ ...window, hits: pruned }, false];
|
|
36
|
+
}
|
|
37
|
+
function remaining(window, nowMs) {
|
|
38
|
+
const cutoff = nowMs - window.windowMs;
|
|
39
|
+
const live = window.hits.filter((t) => t > cutoff).length;
|
|
40
|
+
return Math.max(0, window.limit - live);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { initBucket, initWindow, record, refill, remaining, tryConsume };
|
|
44
|
+
//# sourceMappingURL=index.js.map
|
|
45
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/rate-limit/index.ts"],"names":[],"mappings":";AAsDO,SAAS,UAAA,CAAW,QAAA,EAAkB,YAAA,EAAsB,KAAA,EAA4B;AAC7F,EAAA,OAAO;AAAA,IACL,MAAA,EAAQ,QAAA;AAAA,IACR,QAAA;AAAA,IACA,YAAA;AAAA,IACA,YAAA,EAAc;AAAA,GAChB;AACF;AAcO,SAAS,MAAA,CAAO,QAAqB,KAAA,EAA4B;AACtE,EAAA,MAAM,aAAa,IAAA,CAAK,GAAA,CAAI,GAAG,KAAA,GAAQ,MAAA,CAAO,YAAY,CAAA,GAAI,GAAA;AAC9D,EAAA,MAAM,WAAA,GAAc,MAAA,CAAO,MAAA,GAAS,UAAA,GAAa,MAAA,CAAO,YAAA;AACxD,EAAA,OAAO;AAAA,IACL,GAAG,MAAA;AAAA,IACH,MAAA,EAAQ,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,UAAU,WAAW,CAAA;AAAA,IAC7C,YAAA,EAAc;AAAA,GAChB;AACF;AAeO,SAAS,UAAA,CACd,MAAA,EACA,KAAA,EACA,CAAA,GAAI,CAAA,EAC6B;AACjC,EAAA,MAAM,QAAA,GAAW,MAAA,CAAO,MAAA,EAAQ,KAAK,CAAA;AACrC,EAAA,IAAI,QAAA,CAAS,UAAU,CAAA,EAAG;AACxB,IAAA,OAAO,CAAC,EAAE,GAAG,QAAA,EAAU,QAAQ,QAAA,CAAS,MAAA,GAAS,CAAA,EAAE,EAAG,IAAI,CAAA;AAAA,EAC5D;AACA,EAAA,OAAO,CAAC,UAAU,KAAK,CAAA;AACzB;AAqBO,SAAS,UAAA,CAAW,UAAkB,KAAA,EAA8B;AACzE,EAAA,OAAO,EAAE,IAAA,EAAM,EAAC,EAAG,UAAU,KAAA,EAAM;AACrC;AAgBO,SAAS,MAAA,CAAO,QAAuB,KAAA,EAAkD;AAC9F,EAAA,MAAM,MAAA,GAAS,QAAQ,MAAA,CAAO,QAAA;AAC9B,EAAA,MAAM,SAAS,MAAA,CAAO,IAAA,CAAK,OAAO,CAAC,CAAA,KAAM,IAAI,MAAM,CAAA;AACnD,EAAA,IAAI,MAAA,CAAO,MAAA,GAAS,MAAA,CAAO,KAAA,EAAO;AAChC,IAAA,OAAO,CAAC,EAAE,GAAG,MAAA,EAAQ,IAAA,EAAM,CAAC,GAAG,MAAA,EAAQ,KAAK,CAAA,EAAE,EAAG,IAAI,CAAA;AAAA,EACvD;AACA,EAAA,OAAO,CAAC,EAAE,GAAG,QAAQ,IAAA,EAAM,MAAA,IAAU,KAAK,CAAA;AAC5C;AAQO,SAAS,SAAA,CAAU,QAAuB,KAAA,EAAuB;AACtE,EAAA,MAAM,MAAA,GAAS,QAAQ,MAAA,CAAO,QAAA;AAC9B,EAAA,MAAM,IAAA,GAAO,OAAO,IAAA,CAAK,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,GAAI,MAAM,CAAA,CAAE,MAAA;AACnD,EAAA,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,MAAA,CAAO,QAAQ,IAAI,CAAA;AACxC","file":"index.js","sourcesContent":["/**\n * @demlik/tea/rate-limit — two rate limiters as pure state + ops.\n *\n * Same shape as `@demlik/tea/work-queue`'s `ops.ts`: a state type plus pure\n * transition functions. Host-agnostic by construction — no `Date.now()`, no\n * timers, no I/O. The caller injects the clock (`nowMs`) and wires the result\n * into its own reducer / interpret. This keeps the limiters inside TEA's\n * invariant 2 (transitions are pure: same inputs → same outputs, no `Date.now`)\n * so a consumer can fold them through `update` and exercise them with `replay`\n * / the pbt fold without a fake 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). The `tryConsume` / `record` ops return a\n * `[next, ok]` tuple so the caller branches on the decision while threading the\n * advanced state forward; `refill` / `remaining` are read-shaped helpers.\n *\n * Two complementary algorithms, picked by the cost the caller wants to pay:\n *\n * - **Token bucket** — O(1) state (four numbers), smooths bursts against a\n * steady refill rate. Allows a burst up to `capacity`, then throttles to\n * `refillPerSec`. Cheapest; the default choice.\n * - **Sliding window log** — O(limit) state (one timestamp per in-window\n * hit), exact \"no more than `limit` events per `windowMs`\". Pay the memory\n * for precise window edges when a token bucket's continuous refill would\n * let through too many at a boundary.\n *\n * NOT exported from the package root — reached via the `@demlik/tea/rate-limit`\n * subpath, same one-shape-per-package rule as `work-queue` and `do`.\n */\n\n// ===========================================================================\n// Token bucket\n// ===========================================================================\n\n/**\n * A token bucket: `tokens` of `capacity` available now, replenished at\n * `refillPerSec` tokens per second. `lastRefillMs` is the clock reading at\n * which `tokens` was last computed — `refill` advances it. All four fields are\n * plain numbers so the whole bucket is trivially JSON-serializable into a\n * consumer's `Store<S>`.\n */\nexport interface TokenBucket {\n readonly tokens: number;\n readonly capacity: number;\n readonly refillPerSec: number;\n readonly lastRefillMs: number;\n}\n\n/**\n * Create a full bucket. Starts at `capacity` tokens (the conventional \"a fresh\n * limiter lets the first burst through\" behavior) and stamps `lastRefillMs`\n * with the caller's clock so the first `refill` measures elapsed time from\n * boot, not from epoch 0.\n */\nexport function initBucket(capacity: number, refillPerSec: number, nowMs: number): TokenBucket {\n return {\n tokens: capacity,\n capacity,\n refillPerSec,\n lastRefillMs: nowMs,\n };\n}\n\n/**\n * Add the tokens accrued since `lastRefillMs`, clamped to `capacity`, and\n * advance `lastRefillMs` to `nowMs`. PURE — returns a new bucket; the input is\n * untouched.\n *\n * A `nowMs` at or before `lastRefillMs` (clock not advanced, or skewed\n * backwards) adds a non-positive amount; `Math.max(0, …)` floors the elapsed\n * term so a backwards clock can never *remove* tokens. `lastRefillMs` still\n * advances to `nowMs` so the bucket tracks the latest reading the caller\n * presented — the next forward step measures from there, not from a stale\n * future timestamp.\n */\nexport function refill(bucket: TokenBucket, nowMs: number): TokenBucket {\n const elapsedSec = Math.max(0, nowMs - bucket.lastRefillMs) / 1000;\n const replenished = bucket.tokens + elapsedSec * bucket.refillPerSec;\n return {\n ...bucket,\n tokens: Math.min(bucket.capacity, replenished),\n lastRefillMs: nowMs,\n };\n}\n\n/**\n * Refill against `nowMs`, then attempt to take `n` tokens (default 1).\n *\n * On success returns `[next, true]` with `n` subtracted; on failure returns\n * `[refilled, false]` — the refilled bucket (so the caller keeps the advanced\n * clock + accrued tokens) with no tokens spent. Branch on the boolean, thread\n * the bucket forward regardless:\n *\n * const [b1, ok] = tryConsume(bucket, nowMs);\n * return ok ? [{ ...state, bucket: b1 }, [doWork]] : [{ ...state, bucket: b1 }, []];\n *\n * PURE — input bucket is never mutated.\n */\nexport function tryConsume(\n bucket: TokenBucket,\n nowMs: number,\n n = 1,\n): readonly [TokenBucket, boolean] {\n const refilled = refill(bucket, nowMs);\n if (refilled.tokens >= n) {\n return [{ ...refilled, tokens: refilled.tokens - n }, true];\n }\n return [refilled, false];\n}\n\n// ===========================================================================\n// Sliding window log\n// ===========================================================================\n\n/**\n * A sliding-window log: the timestamps of every hit still inside the trailing\n * `windowMs`, capped at `limit` events per window. `hits` is `readonly` and\n * kept oldest-first (pruning slices the stale prefix off the front, recording\n * appends to the back), so the array is its own sorted index. Exact at the\n * window edge — unlike a token bucket's continuous refill, a hit leaves the\n * count the instant it ages past `windowMs`.\n */\nexport interface SlidingWindow {\n readonly hits: readonly number[];\n readonly windowMs: number;\n readonly limit: number;\n}\n\n/** Create an empty window. No hits recorded yet. */\nexport function initWindow(windowMs: number, limit: number): SlidingWindow {\n return { hits: [], windowMs, limit };\n}\n\n/**\n * Prune hits older than `nowMs - windowMs`, then record `nowMs` if there is\n * room. Returns `[next, true]` with the hit appended when `hits.length < limit`\n * after pruning, else `[pruned, false]` — the pruned window (capacity recovers\n * as old hits age out) with nothing appended.\n *\n * The cutoff is half-open: a hit exactly `windowMs` old is pruned (`<= cutoff`\n * drops it), so a hit recorded at `t` no longer counts at `t + windowMs`. That\n * makes the window's capacity fully recover `windowMs` after the last hit, the\n * behavior tests pin.\n *\n * PURE — input window is never mutated; `hits` is rebuilt by `filter` +\n * spread.\n */\nexport function record(window: SlidingWindow, nowMs: number): readonly [SlidingWindow, boolean] {\n const cutoff = nowMs - window.windowMs;\n const pruned = window.hits.filter((t) => t > cutoff);\n if (pruned.length < window.limit) {\n return [{ ...window, hits: [...pruned, nowMs] }, true];\n }\n return [{ ...window, hits: pruned }, false];\n}\n\n/**\n * How many more hits `record` would accept at `nowMs` without blocking —\n * `limit` minus the live (post-prune) hit count, floored at 0. Read-only: it\n * prunes a local copy to answer but returns no new window. Use it to render a\n * \"N requests remaining\" affordance without committing a hit.\n */\nexport function remaining(window: SlidingWindow, nowMs: number): number {\n const cutoff = nowMs - window.windowMs;\n const live = window.hits.filter((t) => t > cutoff).length;\n return Math.max(0, window.limit - live);\n}\n"]}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Store, Cmd, Sub, Machine, Runtime } from '../index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @demlik/tea/react — React host adapter for `@demlik/tea`.
|
|
5
|
+
*
|
|
6
|
+
* The thinnest possible adapter that makes a tea machine consumable from a
|
|
7
|
+
* React component. Three rules (PRD §"React adapter"):
|
|
8
|
+
*
|
|
9
|
+
* 1. The runtime is the source of truth, NOT React state. We subscribe via
|
|
10
|
+
* `useSyncExternalStore(runtime.subscribe, runtime.getState,
|
|
11
|
+
* runtime.getState)`. React renders are driven by runtime state changes;
|
|
12
|
+
* React never owns the state.
|
|
13
|
+
* 2. The runtime is memoized per component-mount, not per render. The deps
|
|
14
|
+
* array is `[machine, opts.ctx, opts.store]` — IDENTITY, not value. See
|
|
15
|
+
* `README.md` for the ctx-identity footgun + fix.
|
|
16
|
+
* 3. Concurrent React is the design target. `useSyncExternalStore` is the
|
|
17
|
+
* React-blessed primitive for tearing-free external state.
|
|
18
|
+
*
|
|
19
|
+
* Client-only in v1 — components that call `useMachine` MUST be `"use client"`.
|
|
20
|
+
* Server Components consume data via Relay/RSC as today; tea machines own
|
|
21
|
+
* client-side stateful flows. No RSC streaming, no server-side state
|
|
22
|
+
* hydration. Crossing this boundary is a v2 concern.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options passed to `useMachine`. The shape is intentionally minimal — `ctx`
|
|
27
|
+
* is required (every machine has one), `store` is optional (omit it for
|
|
28
|
+
* volatile-state machines).
|
|
29
|
+
*
|
|
30
|
+
* **Identity matters.** The runtime is memoized on `[machine, opts.ctx,
|
|
31
|
+
* opts.store]`. A new `ctx` reference rebuilds the runtime. Always `useMemo`
|
|
32
|
+
* (or otherwise stabilize) the ctx at the call site. See README.
|
|
33
|
+
*/
|
|
34
|
+
interface UseMachineOpts<S, Ctx> {
|
|
35
|
+
ctx: Ctx;
|
|
36
|
+
store?: Store<S>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build and own a `Runtime<S, M>` for the lifetime of the component mount.
|
|
40
|
+
*
|
|
41
|
+
* - Builds via `useMemo(() => run(machine, opts), [machine, opts.ctx,
|
|
42
|
+
* opts.store])` — runtime is recreated when ANY of the three identities
|
|
43
|
+
* change.
|
|
44
|
+
* - Subscribes via `useSyncExternalStore(runtime.subscribe, runtime.getState,
|
|
45
|
+
* runtime.getState)` — tearing-free under React 18 concurrent rendering.
|
|
46
|
+
* - On unmount (or any dep change), `useEffect` cleanup calls
|
|
47
|
+
* `runtime.stop()` — drains the queue, runs every active sub cleanup,
|
|
48
|
+
* flushes state.
|
|
49
|
+
*
|
|
50
|
+
* Returns `[state, dispatch]` shaped like `useReducer`.
|
|
51
|
+
*/
|
|
52
|
+
declare function useMachine<S, M extends {
|
|
53
|
+
type: string;
|
|
54
|
+
}, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, opts: UseMachineOpts<S, Ctx>): [S, (msg: M) => Promise<void>];
|
|
55
|
+
/**
|
|
56
|
+
* Lower-level escape hatch: consume an externally-built `Runtime<S, M>`.
|
|
57
|
+
*
|
|
58
|
+
* Use this when the runtime is owned by something OTHER than the component —
|
|
59
|
+
* a parent component, a test harness, a singleton. **The component does NOT
|
|
60
|
+
* call `runtime.stop()` on unmount** — the caller owns the lifecycle.
|
|
61
|
+
*
|
|
62
|
+
* Primary use case in this repo: component tests. Build a runtime with
|
|
63
|
+
* `@demlik/tea/mem` and pre-seed it via `replay`, then mount the component with
|
|
64
|
+
* `useRuntime(testRuntime)` to render in any phase.
|
|
65
|
+
*
|
|
66
|
+
* Returns `[state, dispatch]` — same shape as `useMachine`.
|
|
67
|
+
*/
|
|
68
|
+
declare function useRuntime<S, M extends {
|
|
69
|
+
type: string;
|
|
70
|
+
}>(runtime: Runtime<S, M>): [S, (msg: M) => Promise<void>];
|
|
71
|
+
|
|
72
|
+
export { type UseMachineOpts, useMachine, useRuntime };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { run } from '../chunk-CMG2KMWX.js';
|
|
2
|
+
import { useMemo, useSyncExternalStore, useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
function useMachine(machine, opts) {
|
|
5
|
+
const ctx = opts.ctx;
|
|
6
|
+
const store = opts.store;
|
|
7
|
+
const runtime = useMemo(() => run(machine, { ctx, store }), [machine, ctx, store]);
|
|
8
|
+
const preliminaryState = useMemo(() => machine.init(null, ctx)[0], [machine, ctx]);
|
|
9
|
+
const getSnapshot = () => {
|
|
10
|
+
try {
|
|
11
|
+
return runtime.getState();
|
|
12
|
+
} catch {
|
|
13
|
+
return preliminaryState;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const state = useSyncExternalStore(runtime.subscribe, getSnapshot, getSnapshot);
|
|
17
|
+
useEffect(
|
|
18
|
+
() => () => {
|
|
19
|
+
runtime.stop().catch(() => {
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
[runtime]
|
|
23
|
+
);
|
|
24
|
+
return [state, runtime.dispatch];
|
|
25
|
+
}
|
|
26
|
+
function useRuntime(runtime) {
|
|
27
|
+
const state = useSyncExternalStore(runtime.subscribe, runtime.getState, runtime.getState);
|
|
28
|
+
return [state, runtime.dispatch];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { useMachine, useRuntime };
|
|
32
|
+
//# sourceMappingURL=index.js.map
|
|
33
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/index.ts"],"names":[],"mappings":";;;AAuDO,SAAS,UAAA,CACd,SACA,IAAA,EACgC;AAMhC,EAAA,MAAM,MAAM,IAAA,CAAK,GAAA;AACjB,EAAA,MAAM,QAAQ,IAAA,CAAK,KAAA;AACnB,EAAA,MAAM,OAAA,GAAU,OAAA,CAAuB,MAAM,GAAA,CAAI,SAAS,EAAE,GAAA,EAAK,KAAA,EAAO,CAAA,EAAG,CAAC,OAAA,EAAS,GAAA,EAAK,KAAK,CAAC,CAAA;AAahG,EAAA,MAAM,gBAAA,GAAmB,OAAA,CAAW,MAAM,OAAA,CAAQ,IAAA,CAAK,IAAA,EAAM,GAAG,CAAA,CAAE,CAAC,CAAA,EAAG,CAAC,OAAA,EAAS,GAAG,CAAC,CAAA;AAMpF,EAAA,MAAM,cAAc,MAAS;AAC3B,IAAA,IAAI;AACF,MAAA,OAAO,QAAQ,QAAA,EAAS;AAAA,IAC1B,CAAA,CAAA,MAAQ;AACN,MAAA,OAAO,gBAAA;AAAA,IACT;AAAA,EACF,CAAA;AAEA,EAAA,MAAM,KAAA,GAAQ,oBAAA,CAAqB,OAAA,CAAQ,SAAA,EAAW,aAAa,WAAW,CAAA;AAC9E,EAAA,SAAA;AAAA,IACE,MAAM,MAAM;AAIV,MAAA,OAAA,CAAQ,IAAA,EAAK,CAAE,KAAA,CAAM,MAAM;AAAA,MAAC,CAAC,CAAA;AAAA,IAC/B,CAAA;AAAA,IACA,CAAC,OAAO;AAAA,GACV;AACA,EAAA,OAAO,CAAC,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAA;AACjC;AAeO,SAAS,WACd,OAAA,EACgC;AAChC,EAAA,MAAM,QAAQ,oBAAA,CAAqB,OAAA,CAAQ,WAAW,OAAA,CAAQ,QAAA,EAAU,QAAQ,QAAQ,CAAA;AACxF,EAAA,OAAO,CAAC,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAA;AACjC","file":"index.js","sourcesContent":["/**\n * @demlik/tea/react — React host adapter for `@demlik/tea`.\n *\n * The thinnest possible adapter that makes a tea machine consumable from a\n * React component. Three rules (PRD §\"React adapter\"):\n *\n * 1. The runtime is the source of truth, NOT React state. We subscribe via\n * `useSyncExternalStore(runtime.subscribe, runtime.getState,\n * runtime.getState)`. React renders are driven by runtime state changes;\n * React never owns the state.\n * 2. The runtime is memoized per component-mount, not per render. The deps\n * array is `[machine, opts.ctx, opts.store]` — IDENTITY, not value. See\n * `README.md` for the ctx-identity footgun + fix.\n * 3. Concurrent React is the design target. `useSyncExternalStore` is the\n * React-blessed primitive for tearing-free external state.\n *\n * Client-only in v1 — components that call `useMachine` MUST be `\"use client\"`.\n * Server Components consume data via Relay/RSC as today; tea machines own\n * client-side stateful flows. No RSC streaming, no server-side state\n * hydration. Crossing this boundary is a v2 concern.\n */\n\n\"use client\";\n\nimport { type Cmd, type Machine, type Runtime, run, type Store, type Sub } from \"../index\";\nimport { useEffect, useMemo, useSyncExternalStore } from \"react\";\n\n/**\n * Options passed to `useMachine`. The shape is intentionally minimal — `ctx`\n * is required (every machine has one), `store` is optional (omit it for\n * volatile-state machines).\n *\n * **Identity matters.** The runtime is memoized on `[machine, opts.ctx,\n * opts.store]`. A new `ctx` reference rebuilds the runtime. Always `useMemo`\n * (or otherwise stabilize) the ctx at the call site. See README.\n */\nexport interface UseMachineOpts<S, Ctx> {\n ctx: Ctx;\n store?: Store<S>;\n}\n\n/**\n * Build and own a `Runtime<S, M>` for the lifetime of the component mount.\n *\n * - Builds via `useMemo(() => run(machine, opts), [machine, opts.ctx,\n * opts.store])` — runtime is recreated when ANY of the three identities\n * change.\n * - Subscribes via `useSyncExternalStore(runtime.subscribe, runtime.getState,\n * runtime.getState)` — tearing-free under React 18 concurrent rendering.\n * - On unmount (or any dep change), `useEffect` cleanup calls\n * `runtime.stop()` — drains the queue, runs every active sub cleanup,\n * flushes state.\n *\n * Returns `[state, dispatch]` shaped like `useReducer`.\n */\nexport function useMachine<S, M extends { type: string }, C extends Cmd, U extends Sub, Ctx>(\n machine: Machine<S, M, C, U, Ctx>,\n opts: UseMachineOpts<S, Ctx>,\n): [S, (msg: M) => Promise<void>] {\n // Deps are literal: machine identity, ctx identity, store identity. NOT\n // `[opts]` (would rebuild every render — callers pass fresh objects).\n // We pass explicit fields to `run()` so the closure captures only the\n // deps in the array — keeps biome's `useExhaustiveDependencies` happy\n // without lying about what the memo actually depends on.\n const ctx = opts.ctx;\n const store = opts.store;\n const runtime = useMemo<Runtime<S, M>>(() => run(machine, { ctx, store }), [machine, ctx, store]);\n\n // Preliminary state computed sync from `machine.init(null, ctx)`. This is\n // the snapshot React sees BEFORE the runtime finishes booting (matters\n // only when `opts.store` is present — without a store, the substrate\n // already sets state synchronously inside `run()`). Once the runtime\n // boots, `runtime.getState()` returns the real (possibly loaded) state.\n //\n // We deliberately recompute on each render but cache by `[machine,\n // opts.ctx]` — `init` is pure by convention, so cost is negligible. The\n // cached snapshot is also identity-stable across rerenders when the deps\n // don't change, which `useSyncExternalStore` rewards (no spurious\n // re-renders).\n const preliminaryState = useMemo<S>(() => machine.init(null, ctx)[0], [machine, ctx]);\n\n // `getSnapshot` reads the runtime if it has booted, else falls back to\n // the preliminary state. The runtime's `getState` throws pre-boot when a\n // store is configured — we catch that and substitute the preliminary\n // value.\n const getSnapshot = (): S => {\n try {\n return runtime.getState();\n } catch {\n return preliminaryState;\n }\n };\n\n const state = useSyncExternalStore(runtime.subscribe, getSnapshot, getSnapshot);\n useEffect(\n () => () => {\n // Fire-and-forget — `stop()` is async but `useEffect` cleanup is sync.\n // The runtime swallows in-flight rejections internally, so this never\n // rejects in practice; we still attach `.catch` for safety.\n runtime.stop().catch(() => {});\n },\n [runtime],\n );\n return [state, runtime.dispatch];\n}\n\n/**\n * Lower-level escape hatch: consume an externally-built `Runtime<S, M>`.\n *\n * Use this when the runtime is owned by something OTHER than the component —\n * a parent component, a test harness, a singleton. **The component does NOT\n * call `runtime.stop()` on unmount** — the caller owns the lifecycle.\n *\n * Primary use case in this repo: component tests. Build a runtime with\n * `@demlik/tea/mem` and pre-seed it via `replay`, then mount the component with\n * `useRuntime(testRuntime)` to render in any phase.\n *\n * Returns `[state, dispatch]` — same shape as `useMachine`.\n */\nexport function useRuntime<S, M extends { type: string }>(\n runtime: Runtime<S, M>,\n): [S, (msg: M) => Promise<void>] {\n const state = useSyncExternalStore(runtime.subscribe, runtime.getState, runtime.getState);\n return [state, runtime.dispatch];\n}\n"]}
|