@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,131 @@
1
+ /**
2
+ * @demlik/tea/idempotency — dedupe-by-key + last-result cache as pure state + ops.
3
+ *
4
+ * Same shape as `@demlik/tea/work-queue`'s `ops.ts` and `@demlik/tea/rate-limit`:
5
+ * a state type plus pure transition functions. Host-agnostic by construction —
6
+ * no `Date.now()`, no timers, no I/O. The caller injects the clock (`nowMs`)
7
+ * and wires the result into its own reducer / interpret. This keeps the store
8
+ * inside TEA's invariant 2 (transitions are pure: same inputs → same outputs,
9
+ * no `Date.now`) so a consumer can fold it through `update` and exercise it
10
+ * with `replay` / the pbt fold without a fake clock.
11
+ *
12
+ * Every op returns a NEW store value and never mutates its input (invariant 1 —
13
+ * state is a value, not a place). `seen` / `recall` are read-shaped predicates;
14
+ * `remember` / `evictExpired` return a new store.
15
+ *
16
+ * The use case: a webhook receiver, a retried queue message, or a repeated user
17
+ * action arrives carrying an idempotency key. `seen(store, key, now)` answers
18
+ * "did I already process this?"; `recall(store, key, now)` returns the cached
19
+ * result so the duplicate request gets the SAME response the original produced
20
+ * rather than re-running the side effect. `remember(store, key, value, now)`
21
+ * records the key + its result after the first run.
22
+ *
23
+ * Two eviction policies keep the store bounded, both injected at `initStore`:
24
+ *
25
+ * - **`ttlMs`** — an entry expires `ttlMs` after the `atMs` it was remembered
26
+ * at. `seen` / `recall` treat an expired entry as absent; `evictExpired`
27
+ * (and every `remember`) physically drops it. Without a TTL, entries live
28
+ * until evicted by capacity. The cutoff is half-open: an entry exactly
29
+ * `ttlMs` old is expired (`age >= ttlMs` drops it), matching `rate-limit`'s
30
+ * window edge — a key remembered at `t` no longer counts at `t + ttlMs`.
31
+ * - **`capacity`** — at most `capacity` entries are retained. When a
32
+ * `remember` would push the count past `capacity` (after expiry eviction),
33
+ * the OLDEST entries by insertion `order` are dropped until the count fits.
34
+ * Without a capacity, the store grows unbounded (TTL alone bounds it).
35
+ *
36
+ * `order` is the insertion-order index that capacity eviction reads — the keys
37
+ * oldest-first. It is the store's own LRU-by-insertion ledger, kept in lockstep
38
+ * with `entries` so `entries` never needs to be sorted to find the oldest.
39
+ *
40
+ * **Overwrite semantics** (decided + pinned by tests): re-`remember`ing an
41
+ * existing key is an UPDATE, not a no-op. It (1) overwrites `value`, (2)
42
+ * refreshes `atMs` to the new `nowMs` — so the TTL clock restarts and a
43
+ * frequently-seen key never expires — and (3) moves the key to the NEWEST
44
+ * position in `order` — so capacity eviction treats a re-remembered key as
45
+ * fresh, not as a stale candidate for eviction. This makes `remember` behave
46
+ * like "touch": a key actively being re-seen is kept alive on both the TTL and
47
+ * capacity axes.
48
+ *
49
+ * NOT exported from the package root — reached via the `@demlik/tea/idempotency`
50
+ * subpath, same one-shape-per-package rule as `work-queue`, `rate-limit`, and
51
+ * `do`.
52
+ */
53
+ /**
54
+ * A cached entry: the result `value` the original request produced and the
55
+ * clock reading `atMs` it was remembered at. `atMs` is the TTL anchor — expiry
56
+ * is measured from here, and a re-`remember` refreshes it. Both fields are
57
+ * plain data so the whole store is trivially JSON-serializable into a
58
+ * consumer's `Store<S>`.
59
+ */
60
+ interface IdempotencyEntry<V> {
61
+ readonly value: V;
62
+ readonly atMs: number;
63
+ }
64
+ /**
65
+ * The dedupe store: a map of `key → entry`, an insertion-`order` ledger
66
+ * (oldest-first) that capacity eviction reads, and the two optional bounds.
67
+ *
68
+ * `entries` and `order` are kept in lockstep — every key in `order` has an
69
+ * entry and vice versa. `capacity` / `ttlMs` are carried on the store (not
70
+ * passed per-op) so a single value fully describes the policy; a consumer
71
+ * persists the store and rehydrates the same bounds without re-deriving them.
72
+ */
73
+ interface IdempotencyStore<V> {
74
+ readonly entries: Readonly<Record<string, IdempotencyEntry<V>>>;
75
+ readonly order: readonly string[];
76
+ readonly capacity?: number;
77
+ readonly ttlMs?: number;
78
+ }
79
+ /**
80
+ * Create an empty store. `capacity` and `ttlMs` are both optional — omit both
81
+ * for an unbounded cache, supply either or both to bound by count, age, or
82
+ * both. Stored on the returned value so every later op reads the policy off
83
+ * the store rather than from a re-passed argument.
84
+ */
85
+ declare function initStore<V>(opts?: {
86
+ capacity?: number;
87
+ ttlMs?: number;
88
+ }): IdempotencyStore<V>;
89
+ /**
90
+ * True iff `key` is present AND not expired at `nowMs`. This is the dedupe
91
+ * predicate: a webhook / retried message / repeated action whose key returns
92
+ * `true` has already been processed and should be short-circuited (paired with
93
+ * `recall` to return the original result). PURE — reads only, never mutates.
94
+ */
95
+ declare function seen<V>(store: IdempotencyStore<V>, key: string, nowMs: number): boolean;
96
+ /**
97
+ * The cached `value` for `key` if present and unexpired at `nowMs`, else
98
+ * `undefined`. Use after `seen` returns `true` to replay the original result to
99
+ * a duplicate request. An expired entry reads as absent (returns `undefined`)
100
+ * even though it is physically still in `entries` until the next `remember` /
101
+ * `evictExpired` drops it. PURE — reads only, never mutates.
102
+ */
103
+ declare function recall<V>(store: IdempotencyStore<V>, key: string, nowMs: number): V | undefined;
104
+ /**
105
+ * Drop every entry expired at `nowMs` (older than `ttlMs`). Returns a NEW store
106
+ * with the survivors; `order` is rebuilt to drop the same keys, preserving the
107
+ * relative insertion order of the survivors. A no-TTL store returns an
108
+ * equivalent store (nothing ages out). PURE — the input store is never mutated.
109
+ */
110
+ declare function evictExpired<V>(store: IdempotencyStore<V>, nowMs: number): IdempotencyStore<V>;
111
+ /**
112
+ * Record `key → value` at `nowMs`, then enforce both bounds. PURE — returns a
113
+ * new store; the input is untouched.
114
+ *
115
+ * Order of operations (each step matters):
116
+ * 1. **Expiry eviction** — drop everything older than `ttlMs` first, so an
117
+ * expired key never occupies a capacity slot the new entry could take, and
118
+ * a stale entry under the same `key` is cleared before the overwrite.
119
+ * 2. **Insert / overwrite** — write the new entry. If `key` already exists,
120
+ * this is an UPDATE: `value` is overwritten, `atMs` is refreshed to
121
+ * `nowMs` (restarting the TTL clock), and the key is moved to the NEWEST
122
+ * position in `order` (removed from wherever it sat, appended at the end).
123
+ * See the module-level "Overwrite semantics" note.
124
+ * 3. **Capacity eviction** — if the entry count now exceeds `capacity`, drop
125
+ * the OLDEST keys by `order` until the count fits. Because step 2 moves a
126
+ * re-remembered key to the newest position, an actively re-seen key is
127
+ * never the eviction victim.
128
+ */
129
+ declare function remember<V>(store: IdempotencyStore<V>, key: string, value: V, nowMs: number): IdempotencyStore<V>;
130
+
131
+ export { type IdempotencyEntry, type IdempotencyStore, evictExpired, initStore, recall, remember, seen };
@@ -0,0 +1,58 @@
1
+ // src/idempotency/index.ts
2
+ function initStore(opts) {
3
+ return {
4
+ entries: {},
5
+ order: [],
6
+ capacity: opts?.capacity,
7
+ ttlMs: opts?.ttlMs
8
+ };
9
+ }
10
+ function isExpired(entry, nowMs, ttlMs) {
11
+ if (ttlMs === void 0) return false;
12
+ return nowMs - entry.atMs >= ttlMs;
13
+ }
14
+ function seen(store, key, nowMs) {
15
+ const entry = store.entries[key];
16
+ if (entry === void 0) return false;
17
+ return !isExpired(entry, nowMs, store.ttlMs);
18
+ }
19
+ function recall(store, key, nowMs) {
20
+ const entry = store.entries[key];
21
+ if (entry === void 0 || isExpired(entry, nowMs, store.ttlMs))
22
+ return void 0;
23
+ return entry.value;
24
+ }
25
+ function evictExpired(store, nowMs) {
26
+ if (store.ttlMs === void 0) return store;
27
+ const nextEntries = {};
28
+ const nextOrder = [];
29
+ for (const key of store.order) {
30
+ const entry = store.entries[key];
31
+ if (entry === void 0) continue;
32
+ if (isExpired(entry, nowMs, store.ttlMs)) continue;
33
+ nextEntries[key] = entry;
34
+ nextOrder.push(key);
35
+ }
36
+ return { ...store, entries: nextEntries, order: nextOrder };
37
+ }
38
+ function remember(store, key, value, nowMs) {
39
+ const pruned = evictExpired(store, nowMs);
40
+ const nextEntries = {
41
+ ...pruned.entries
42
+ };
43
+ nextEntries[key] = { value, atMs: nowMs };
44
+ const nextOrder = pruned.order.filter((k) => k !== key);
45
+ nextOrder.push(key);
46
+ if (store.capacity !== void 0) {
47
+ while (nextOrder.length > store.capacity) {
48
+ const oldest = nextOrder.shift();
49
+ if (oldest === void 0) break;
50
+ delete nextEntries[oldest];
51
+ }
52
+ }
53
+ return { ...store, entries: nextEntries, order: nextOrder };
54
+ }
55
+
56
+ export { evictExpired, initStore, recall, remember, seen };
57
+ //# sourceMappingURL=index.js.map
58
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/idempotency/index.ts"],"names":[],"mappings":";AAuFO,SAAS,UAAa,IAAA,EAGL;AACtB,EAAA,OAAO;AAAA,IACL,SAAS,EAAC;AAAA,IACV,OAAO,EAAC;AAAA,IACR,UAAU,IAAA,EAAM,QAAA;AAAA,IAChB,OAAO,IAAA,EAAM;AAAA,GACf;AACF;AAUA,SAAS,SAAA,CACP,KAAA,EACA,KAAA,EACA,KAAA,EACS;AACT,EAAA,IAAI,KAAA,KAAU,QAAW,OAAO,KAAA;AAChC,EAAA,OAAO,KAAA,GAAQ,MAAM,IAAA,IAAQ,KAAA;AAC/B;AAQO,SAAS,IAAA,CACd,KAAA,EACA,GAAA,EACA,KAAA,EACS;AACT,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA;AAC/B,EAAA,IAAI,KAAA,KAAU,QAAW,OAAO,KAAA;AAChC,EAAA,OAAO,CAAC,SAAA,CAAU,KAAA,EAAO,KAAA,EAAO,MAAM,KAAK,CAAA;AAC7C;AASO,SAAS,MAAA,CACd,KAAA,EACA,GAAA,EACA,KAAA,EACe;AACf,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA;AAC/B,EAAA,IAAI,UAAU,MAAA,IAAa,SAAA,CAAU,KAAA,EAAO,KAAA,EAAO,MAAM,KAAK,CAAA;AAC5D,IAAA,OAAO,MAAA;AACT,EAAA,OAAO,KAAA,CAAM,KAAA;AACf;AAQO,SAAS,YAAA,CACd,OACA,KAAA,EACqB;AACrB,EAAA,IAAI,KAAA,CAAM,KAAA,KAAU,MAAA,EAAW,OAAO,KAAA;AACtC,EAAA,MAAM,cAAmD,EAAC;AAC1D,EAAA,MAAM,YAAsB,EAAC;AAG7B,EAAA,KAAA,MAAW,GAAA,IAAO,MAAM,KAAA,EAAO;AAC7B,IAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA;AAC/B,IAAA,IAAI,UAAU,MAAA,EAAW;AACzB,IAAA,IAAI,SAAA,CAAU,KAAA,EAAO,KAAA,EAAO,KAAA,CAAM,KAAK,CAAA,EAAG;AAC1C,IAAA,WAAA,CAAY,GAAG,CAAA,GAAI,KAAA;AACnB,IAAA,SAAA,CAAU,KAAK,GAAG,CAAA;AAAA,EACpB;AACA,EAAA,OAAO,EAAE,GAAG,KAAA,EAAO,OAAA,EAAS,WAAA,EAAa,OAAO,SAAA,EAAU;AAC5D;AAoBO,SAAS,QAAA,CACd,KAAA,EACA,GAAA,EACA,KAAA,EACA,KAAA,EACqB;AAErB,EAAA,MAAM,MAAA,GAAS,YAAA,CAAa,KAAA,EAAO,KAAK,CAAA;AAKxC,EAAA,MAAM,WAAA,GAAmD;AAAA,IACvD,GAAG,MAAA,CAAO;AAAA,GACZ;AACA,EAAA,WAAA,CAAY,GAAG,CAAA,GAAI,EAAE,KAAA,EAAO,MAAM,KAAA,EAAM;AACxC,EAAA,MAAM,YAAY,MAAA,CAAO,KAAA,CAAM,OAAO,CAAC,CAAA,KAAM,MAAM,GAAG,CAAA;AACtD,EAAA,SAAA,CAAU,KAAK,GAAG,CAAA;AAKlB,EAAA,IAAI,KAAA,CAAM,aAAa,MAAA,EAAW;AAChC,IAAA,OAAO,SAAA,CAAU,MAAA,GAAS,KAAA,CAAM,QAAA,EAAU;AACxC,MAAA,MAAM,MAAA,GAAS,UAAU,KAAA,EAAM;AAC/B,MAAA,IAAI,WAAW,MAAA,EAAW;AAC1B,MAAA,OAAO,YAAY,MAAM,CAAA;AAAA,IAC3B;AAAA,EACF;AAEA,EAAA,OAAO,EAAE,GAAG,KAAA,EAAO,OAAA,EAAS,WAAA,EAAa,OAAO,SAAA,EAAU;AAC5D","file":"index.js","sourcesContent":["/**\n * @demlik/tea/idempotency — dedupe-by-key + last-result cache as pure state + ops.\n *\n * Same shape as `@demlik/tea/work-queue`'s `ops.ts` and `@demlik/tea/rate-limit`:\n * a state type plus pure transition functions. Host-agnostic by construction —\n * no `Date.now()`, no timers, no I/O. The caller injects the clock (`nowMs`)\n * and wires the result into its own reducer / interpret. This keeps the store\n * inside TEA's invariant 2 (transitions are pure: same inputs → same outputs,\n * no `Date.now`) so a consumer can fold it through `update` and exercise it\n * with `replay` / the pbt fold without a fake clock.\n *\n * Every op returns a NEW store value and never mutates its input (invariant 1 —\n * state is a value, not a place). `seen` / `recall` are read-shaped predicates;\n * `remember` / `evictExpired` return a new store.\n *\n * The use case: a webhook receiver, a retried queue message, or a repeated user\n * action arrives carrying an idempotency key. `seen(store, key, now)` answers\n * \"did I already process this?\"; `recall(store, key, now)` returns the cached\n * result so the duplicate request gets the SAME response the original produced\n * rather than re-running the side effect. `remember(store, key, value, now)`\n * records the key + its result after the first run.\n *\n * Two eviction policies keep the store bounded, both injected at `initStore`:\n *\n * - **`ttlMs`** — an entry expires `ttlMs` after the `atMs` it was remembered\n * at. `seen` / `recall` treat an expired entry as absent; `evictExpired`\n * (and every `remember`) physically drops it. Without a TTL, entries live\n * until evicted by capacity. The cutoff is half-open: an entry exactly\n * `ttlMs` old is expired (`age >= ttlMs` drops it), matching `rate-limit`'s\n * window edge — a key remembered at `t` no longer counts at `t + ttlMs`.\n * - **`capacity`** — at most `capacity` entries are retained. When a\n * `remember` would push the count past `capacity` (after expiry eviction),\n * the OLDEST entries by insertion `order` are dropped until the count fits.\n * Without a capacity, the store grows unbounded (TTL alone bounds it).\n *\n * `order` is the insertion-order index that capacity eviction reads — the keys\n * oldest-first. It is the store's own LRU-by-insertion ledger, kept in lockstep\n * with `entries` so `entries` never needs to be sorted to find the oldest.\n *\n * **Overwrite semantics** (decided + pinned by tests): re-`remember`ing an\n * existing key is an UPDATE, not a no-op. It (1) overwrites `value`, (2)\n * refreshes `atMs` to the new `nowMs` — so the TTL clock restarts and a\n * frequently-seen key never expires — and (3) moves the key to the NEWEST\n * position in `order` — so capacity eviction treats a re-remembered key as\n * fresh, not as a stale candidate for eviction. This makes `remember` behave\n * like \"touch\": a key actively being re-seen is kept alive on both the TTL and\n * capacity axes.\n *\n * NOT exported from the package root — reached via the `@demlik/tea/idempotency`\n * subpath, same one-shape-per-package rule as `work-queue`, `rate-limit`, and\n * `do`.\n */\n\n/**\n * A cached entry: the result `value` the original request produced and the\n * clock reading `atMs` it was remembered at. `atMs` is the TTL anchor — expiry\n * is measured from here, and a re-`remember` refreshes it. Both fields are\n * plain data so the whole store is trivially JSON-serializable into a\n * consumer's `Store<S>`.\n */\nexport interface IdempotencyEntry<V> {\n readonly value: V;\n readonly atMs: number;\n}\n\n/**\n * The dedupe store: a map of `key → entry`, an insertion-`order` ledger\n * (oldest-first) that capacity eviction reads, and the two optional bounds.\n *\n * `entries` and `order` are kept in lockstep — every key in `order` has an\n * entry and vice versa. `capacity` / `ttlMs` are carried on the store (not\n * passed per-op) so a single value fully describes the policy; a consumer\n * persists the store and rehydrates the same bounds without re-deriving them.\n */\nexport interface IdempotencyStore<V> {\n readonly entries: Readonly<Record<string, IdempotencyEntry<V>>>;\n readonly order: readonly string[];\n readonly capacity?: number;\n readonly ttlMs?: number;\n}\n\n/**\n * Create an empty store. `capacity` and `ttlMs` are both optional — omit both\n * for an unbounded cache, supply either or both to bound by count, age, or\n * both. Stored on the returned value so every later op reads the policy off\n * the store rather than from a re-passed argument.\n */\nexport function initStore<V>(opts?: {\n capacity?: number;\n ttlMs?: number;\n}): IdempotencyStore<V> {\n return {\n entries: {},\n order: [],\n capacity: opts?.capacity,\n ttlMs: opts?.ttlMs,\n };\n}\n\n/**\n * Whether `entry` (remembered at `entry.atMs`) is expired at `nowMs` under\n * `ttlMs`. Half-open: an entry exactly `ttlMs` old is expired (`age >= ttlMs`),\n * matching `rate-limit`'s window-edge cutoff. A `nowMs` before `atMs` (clock\n * skewed backwards) yields a negative age — never expired, the safe direction\n * (a duplicate is still deduped rather than re-run). When `ttlMs` is undefined\n * the entry never expires by age.\n */\nfunction isExpired<V>(\n entry: IdempotencyEntry<V>,\n nowMs: number,\n ttlMs: number | undefined,\n): boolean {\n if (ttlMs === undefined) return false;\n return nowMs - entry.atMs >= ttlMs;\n}\n\n/**\n * True iff `key` is present AND not expired at `nowMs`. This is the dedupe\n * predicate: a webhook / retried message / repeated action whose key returns\n * `true` has already been processed and should be short-circuited (paired with\n * `recall` to return the original result). PURE — reads only, never mutates.\n */\nexport function seen<V>(\n store: IdempotencyStore<V>,\n key: string,\n nowMs: number,\n): boolean {\n const entry = store.entries[key];\n if (entry === undefined) return false;\n return !isExpired(entry, nowMs, store.ttlMs);\n}\n\n/**\n * The cached `value` for `key` if present and unexpired at `nowMs`, else\n * `undefined`. Use after `seen` returns `true` to replay the original result to\n * a duplicate request. An expired entry reads as absent (returns `undefined`)\n * even though it is physically still in `entries` until the next `remember` /\n * `evictExpired` drops it. PURE — reads only, never mutates.\n */\nexport function recall<V>(\n store: IdempotencyStore<V>,\n key: string,\n nowMs: number,\n): V | undefined {\n const entry = store.entries[key];\n if (entry === undefined || isExpired(entry, nowMs, store.ttlMs))\n return undefined;\n return entry.value;\n}\n\n/**\n * Drop every entry expired at `nowMs` (older than `ttlMs`). Returns a NEW store\n * with the survivors; `order` is rebuilt to drop the same keys, preserving the\n * relative insertion order of the survivors. A no-TTL store returns an\n * equivalent store (nothing ages out). PURE — the input store is never mutated.\n */\nexport function evictExpired<V>(\n store: IdempotencyStore<V>,\n nowMs: number,\n): IdempotencyStore<V> {\n if (store.ttlMs === undefined) return store;\n const nextEntries: Record<string, IdempotencyEntry<V>> = {};\n const nextOrder: string[] = [];\n // Walk `order` (not `Object.keys`) so the surviving order ledger keeps the\n // exact relative insertion order capacity eviction depends on.\n for (const key of store.order) {\n const entry = store.entries[key];\n if (entry === undefined) continue; // defensive: order/entries kept in lockstep\n if (isExpired(entry, nowMs, store.ttlMs)) continue;\n nextEntries[key] = entry;\n nextOrder.push(key);\n }\n return { ...store, entries: nextEntries, order: nextOrder };\n}\n\n/**\n * Record `key → value` at `nowMs`, then enforce both bounds. PURE — returns a\n * new store; the input is untouched.\n *\n * Order of operations (each step matters):\n * 1. **Expiry eviction** — drop everything older than `ttlMs` first, so an\n * expired key never occupies a capacity slot the new entry could take, and\n * a stale entry under the same `key` is cleared before the overwrite.\n * 2. **Insert / overwrite** — write the new entry. If `key` already exists,\n * this is an UPDATE: `value` is overwritten, `atMs` is refreshed to\n * `nowMs` (restarting the TTL clock), and the key is moved to the NEWEST\n * position in `order` (removed from wherever it sat, appended at the end).\n * See the module-level \"Overwrite semantics\" note.\n * 3. **Capacity eviction** — if the entry count now exceeds `capacity`, drop\n * the OLDEST keys by `order` until the count fits. Because step 2 moves a\n * re-remembered key to the newest position, an actively re-seen key is\n * never the eviction victim.\n */\nexport function remember<V>(\n store: IdempotencyStore<V>,\n key: string,\n value: V,\n nowMs: number,\n): IdempotencyStore<V> {\n // Step 1 — expire first so stale entries free capacity slots before insert.\n const pruned = evictExpired(store, nowMs);\n\n // Step 2 — insert/overwrite. Drop any prior position of `key` from order,\n // then append it as newest. `delete` on a fresh shallow copy keeps the input\n // store untouched (invariant 1).\n const nextEntries: Record<string, IdempotencyEntry<V>> = {\n ...pruned.entries,\n };\n nextEntries[key] = { value, atMs: nowMs };\n const nextOrder = pruned.order.filter((k) => k !== key);\n nextOrder.push(key);\n\n // Step 3 — capacity eviction. Drop oldest (front of `order`) until the count\n // fits. `capacity <= 0` evicts everything just inserted down to the bound;\n // `undefined` capacity skips eviction entirely.\n if (store.capacity !== undefined) {\n while (nextOrder.length > store.capacity) {\n const oldest = nextOrder.shift();\n if (oldest === undefined) break; // order emptied — nothing left to evict\n delete nextEntries[oldest];\n }\n }\n\n return { ...store, entries: nextEntries, order: nextOrder };\n}\n"]}
@@ -0,0 +1,364 @@
1
+ type Reducer<S, M extends {
2
+ type: string;
3
+ }, C extends Cmd> = {
4
+ [K in M["type"]]: (state: S, msg: Extract<M, {
5
+ type: K;
6
+ }>) => readonly [S, readonly C[]];
7
+ };
8
+ type Transitions<S extends {
9
+ type: string;
10
+ }, M extends {
11
+ type: string;
12
+ }, C extends Cmd> = {
13
+ [P in S["type"]]: {
14
+ [K in M["type"]]: (state: Extract<S, {
15
+ type: P;
16
+ }>, msg: Extract<M, {
17
+ type: K;
18
+ }>) => readonly [S, readonly C[]];
19
+ };
20
+ };
21
+ /**
22
+ * Compile-time exhaustiveness assertion. Use in default branches of
23
+ * switches over discriminated unions where every case should be handled
24
+ * explicitly — TS narrows the type to `never` only if every variant has
25
+ * been covered. Strengthens invariant 7 (identity is explicit).
26
+ *
27
+ * @example
28
+ * switch (msg.type) {
29
+ * case "a": return ...;
30
+ * case "b": return ...;
31
+ * default: return absurd(msg); // compile error if a "c" is added
32
+ * }
33
+ */
34
+ declare function absurd(x: never): never;
35
+ declare const noop: <S, M, C extends Cmd>(state: S, _msg: M) => readonly [S, readonly C[]];
36
+ /**
37
+ * @demlik/tea — TEA-faithful state machine substrate.
38
+ *
39
+ * This module exposes the full Locked API Surface from the PRD as types and
40
+ * implements `defineMachine` (identity, task 1), `run` (task 2), and
41
+ * `replay` + `tryInterpret` (task 3).
42
+ *
43
+ * `replay` is the pure unit-test tool: it composes `init` + `update` only,
44
+ * never touches `Store`, never calls `interpret`, never starts subscriptions.
45
+ *
46
+ * `tryInterpret` is Railway sugar — wraps a fallible async function and routes
47
+ * Ok/Err to two Msg constructors via better-result.
48
+ */
49
+ type Cmd<T extends string = string> = {
50
+ type: T;
51
+ };
52
+ declare const Cmd: {
53
+ /**
54
+ * The empty Cmd array. Typed `readonly never[]` so it's assignable to any
55
+ * `readonly C[]` for any `C extends Cmd`. Use in `init` returns and
56
+ * Transitions cells that emit zero effects:
57
+ *
58
+ * init: (loaded) => [loaded ?? initial, Cmd.none],
59
+ * tick: (state) => [state, Cmd.none],
60
+ *
61
+ * Elm's `Cmd.none` analogue. Cultural signal alongside the runtime help:
62
+ * `[state, Cmd.none]` reads as intent ("this transition emits nothing"),
63
+ * `[state, []]` reads as "empty array of what".
64
+ *
65
+ * Frozen at runtime so a downstream consumer can't `.push()` into the
66
+ * shared reference.
67
+ */
68
+ readonly none: readonly never[];
69
+ /**
70
+ * Flat concat of cmd arrays. Use when a cell composes effects from
71
+ * multiple conditional sources:
72
+ *
73
+ * return [next, Cmd.batch(
74
+ * Cmd.whenDefined(state.queueItemId, (id) => ({ type: "complete", id })),
75
+ * Cmd.when(state.windowId !== undefined, { type: "close_window", windowId }),
76
+ * [{ type: "detach_debugger", tabId: state.tabId }],
77
+ * )];
78
+ *
79
+ * `<const C>` keeps inline cmd literals' discriminants narrow (same
80
+ * rationale as `when` / `whenDefined` above).
81
+ */
82
+ readonly batch: <const C extends Cmd>(...arrs: readonly (readonly C[])[]) => readonly C[];
83
+ /**
84
+ * Emit `cmd` wrapped in a single-element array iff `cond` is true.
85
+ * Otherwise return the empty array. Spreads cleanly into the cmds array
86
+ * returned by a Transitions cell.
87
+ *
88
+ * `const C` (TypeScript 5.0+) keeps the inferred `type:` discriminator
89
+ * literal narrow when called with an object literal — so `Cmd.when(b,
90
+ * { type: "x", ... })` infers as `{ type: "x", ... }`, not `{ type:
91
+ * string, ... }`, and stays assignable to a discriminated-union arm
92
+ * without `as const` at the call site.
93
+ */
94
+ readonly when: <const C extends Cmd>(cond: boolean, cmd: C) => readonly C[];
95
+ /**
96
+ * If `value` is defined, call `build(value)` and emit the resulting cmd
97
+ * wrapped in a single-element array. If `value` is `undefined`, return
98
+ * the empty array. `value` is narrowed to `T` inside `build`.
99
+ *
100
+ * `const C` on the return type keeps the callback's object-literal
101
+ * `type:` field narrow (see `when` above for the full rationale).
102
+ */
103
+ readonly whenDefined: <T, const C extends Cmd>(value: T | undefined, build: (value: T) => C) => readonly C[];
104
+ };
105
+ type SubId = string & {
106
+ readonly __brand: "SubId";
107
+ };
108
+ /**
109
+ * Construct a `SubId` from a string. Use this everywhere a Sub literal is
110
+ * built — including ids derived from runtime data (`subId(\`ws:${auditId}\`)`).
111
+ * The brand is structural, not nominal, so dynamic ids are permitted; the
112
+ * constructor's job is to make the identity decision explicit at every call
113
+ * site so the type system can catch accidental raw-string drift.
114
+ *
115
+ * Strengthens invariant 7 (identity is explicit).
116
+ */
117
+ declare function subId(s: string): SubId;
118
+ type Sub<T extends string = string> = {
119
+ id: SubId;
120
+ type: T;
121
+ };
122
+ interface Port<T> {
123
+ readonly __brand: "port";
124
+ readonly name: string;
125
+ readonly __t?: T;
126
+ }
127
+ /**
128
+ * Thrown by `definePort` when a name has already been registered in the
129
+ * current process. Symmetric with the `Sub.id` collision assert in
130
+ * `reconcileSubs` — both mechanize invariant 7 (identity is explicit) at the
131
+ * runtime layer the type system cannot reach (string names compared at
132
+ * runtime, not at compile time).
133
+ */
134
+ declare class PortNameCollisionError extends Error {
135
+ readonly name = "PortNameCollisionError";
136
+ constructor(portName: string);
137
+ }
138
+ /**
139
+ * Test-only escape hatch — clears the module-level port-name registry so the
140
+ * collision assert in `definePort` doesn't false-positive across vitest's
141
+ * non-isolated runs. Production code MUST NOT call this; the registry is the
142
+ * invariant-7 witness for the process lifetime.
143
+ *
144
+ * @internal test-only
145
+ */
146
+ declare function __resetPortRegistry(): void;
147
+ /**
148
+ * Define a typed port. `name` is metadata (shown in devtools / debug logs);
149
+ * port identity is by reference. Each definePort call must use a unique
150
+ * `name` — a second call with the same name throws
151
+ * `PortNameCollisionError`. If two modules need the same port, export it
152
+ * from one module and import it from the other.
153
+ *
154
+ * Use ports for "data leaving the runtime selectively" — e.g. cursor
155
+ * announcements, DOM-mutation broadcasts, telemetry events that should NOT be
156
+ * folded into State and should NOT be observed by every transition listener.
157
+ *
158
+ * Strengthens invariant 7 (identity is explicit) — the runtime assert is the
159
+ * Port-side counterpart of `SubId`'s id collision check in `reconcileSubs`.
160
+ */
161
+ declare function definePort<T>(name: string): Port<T>;
162
+ /**
163
+ * Augmentation injected onto `ctx` inside Cmd handlers. Handlers receive
164
+ * `ctx & PortEmitter` so they can call `ctx.emit(port, value)` synchronously.
165
+ * Subscribers registered via `runtime.subscribePort(port, listener)` receive
166
+ * the value immediately.
167
+ *
168
+ * Emitting to a port with no subscribers is a no-op (does not throw).
169
+ */
170
+ interface PortEmitter {
171
+ emit<T>(port: Port<T>, value: T): void;
172
+ }
173
+ type Interpret<M extends {
174
+ type: string;
175
+ }, C extends Cmd, Ctx> = {
176
+ [K in C["type"]]: (cmd: Extract<C, {
177
+ type: K;
178
+ }>, ctx: Ctx & PortEmitter) => Promise<M | void>;
179
+ };
180
+ type Subscribe<M extends {
181
+ type: string;
182
+ }, U extends Sub, Ctx> = {
183
+ [K in U["type"]]: (sub: Extract<U, {
184
+ type: K;
185
+ }>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;
186
+ };
187
+ type Machine<S, M extends {
188
+ type: string;
189
+ }, C extends Cmd, U extends Sub, Ctx> = {
190
+ /**
191
+ * Boot the runtime. Called once by `run(...)` with whatever `Store.load()`
192
+ * returned: `null` on fresh boot, the persisted state on rehydrate.
193
+ *
194
+ * **Contract:** when `loaded !== null`, init MUST return `[loaded, []]` —
195
+ * no Cmds. Init's rehydrate branch is the migration / parse boundary, not
196
+ * the boot-effect hook. See Invariant 2 in `.patterns/tea/tea-invariants.md`.
197
+ *
198
+ * Boot effects routes:
199
+ * - Stateless infrastructure → host module top, outside TEA.
200
+ * - State-conditional resume → a `boot` Msg the host dispatches once
201
+ * after `run(...)` returns.
202
+ *
203
+ * Violations are caught at runtime by `replay` (which throws with a
204
+ * pointer to the alternatives).
205
+ */
206
+ init: (loaded: S | null, ctx: Ctx) => readonly [S, readonly C[]];
207
+ update: Reducer<S, M, C> | ([S] extends [{
208
+ type: string;
209
+ }] ? Transitions<S, M, C> : never);
210
+ subscriptions?: (state: S) => readonly U[];
211
+ subscribe?: Subscribe<M, U, Ctx>;
212
+ } & ([C] extends [Cmd<never>] ? {
213
+ interpret?: Interpret<M, C, Ctx>;
214
+ } : {
215
+ interpret: Interpret<M, C, Ctx>;
216
+ });
217
+ interface Store<S> {
218
+ load(): Promise<unknown>;
219
+ save(state: S): Promise<void>;
220
+ migrate(raw: unknown): S | null;
221
+ }
222
+ interface RuntimeRef<M extends {
223
+ type: string;
224
+ }> {
225
+ dispatch(msg: M): Promise<void>;
226
+ }
227
+ interface Runtime<S, M extends {
228
+ type: string;
229
+ }> extends RuntimeRef<M> {
230
+ dispatch(msg: M): Promise<void>;
231
+ getState(): S;
232
+ subscribe(listener: () => void): () => void;
233
+ observe(observer: (msg: M | null, state: S) => void): () => void;
234
+ /**
235
+ * Subscribe to a typed Port. Returns a cleanup function that removes the
236
+ * listener. Multiple listeners per port are supported; fanout is synchronous
237
+ * (same shape as `observe`) and isolated — one listener throw does not strand
238
+ * the others.
239
+ *
240
+ * Ports are for "data leaving the runtime selectively" — see `definePort`.
241
+ * Distinct from `observe` (every transition) and from State (the world).
242
+ */
243
+ subscribePort<T>(port: Port<T>, listener: (value: T) => void): () => void;
244
+ /**
245
+ * Emit a value on a Port from OUTSIDE an interpret handler. Same synchronous
246
+ * fanout discipline as `ctx.emit` inside interpret — every subscriber
247
+ * receives the value immediately; emitting to a port with no subscribers is
248
+ * a no-op; listener throws are isolated.
249
+ *
250
+ * Use this for `observe`-driven Port emission: the canonical case is one
251
+ * runtime publishing a state-derived signal (e.g. "am I idle?") that
252
+ * another runtime subscribes to via `subscribePort`. Without
253
+ * `runtime.emitPort`, the only way to emit a Port is from inside an
254
+ * `interpret` handler — which forces the reducer to spawn a "tell the
255
+ * outside world" Cmd on every transition that should signal. With it,
256
+ * `runtime.observe((msg, state) => runtime.emitPort(port, derive(state)))`
257
+ * keeps the reducer free of Port-plumbing Cmds and matches the "every
258
+ * transition is observable" contract this invariant set already commits to.
259
+ *
260
+ * Strengthens invariant 6 (runtime is small and inspectable — Port emission
261
+ * is no longer privileged to interpret handlers; `observe`-driven emission
262
+ * is structurally supported).
263
+ */
264
+ emitPort<T>(port: Port<T>, value: T): void;
265
+ /**
266
+ * Resolves after boot completes — i.e. after `store.load()` (if any), `init`,
267
+ * `store.save()` of the initial state, `reconcileSubs`, `interpret` of any
268
+ * init-emitted cmds, and the initial listener / observer fanout. Rejects with
269
+ * the boot error if boot fails (the same error every subsequent `dispatch`
270
+ * call rejects with).
271
+ *
272
+ * Idempotent — subsequent reads return the same settled promise. The runtime
273
+ * holds exactly one `ready` promise per `run()` call.
274
+ *
275
+ * Closes the v1 boot-await gap previously documented in `stepBootEffects`:
276
+ * components that need synchronous initial state under a store (e.g.,
277
+ * `useSyncExternalStore` consumers) can `await runtime.ready` instead of
278
+ * `runtime.dispatch(noopMsg)`.
279
+ *
280
+ * Expresses canon §2.3 (the `init` contract — boot is a named, awaitable
281
+ * moment). Strengthens invariant 6 (runtime is small and inspectable).
282
+ */
283
+ ready: Promise<void>;
284
+ stop(): Promise<void>;
285
+ }
286
+ declare function defineMachine<S, M extends {
287
+ type: string;
288
+ }, C extends Cmd, U extends Sub, Ctx>(m: Omit<Machine<S, M, C, U, Ctx>, "update"> & {
289
+ update: [S] extends [{
290
+ type: string;
291
+ }] ? Transitions<S, M, C> : never;
292
+ }): Machine<S, M, C, U, Ctx>;
293
+ declare function defineMachine<S, M extends {
294
+ type: string;
295
+ }, C extends Cmd, U extends Sub, Ctx>(m: Omit<Machine<S, M, C, U, Ctx>, "update"> & {
296
+ update: Reducer<S, M, C>;
297
+ }): Machine<S, M, C, U, Ctx>;
298
+ declare function run<S, M extends {
299
+ type: string;
300
+ }, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, opts: {
301
+ ctx: Ctx;
302
+ store?: Store<S>;
303
+ }): Runtime<S, M>;
304
+ interface HistoryTracker<S, M extends {
305
+ type: string;
306
+ }> {
307
+ /**
308
+ * Snapshot of recorded transitions, oldest first. Each entry is
309
+ * `{ msg, state }` exactly as the underlying `observe` callback received
310
+ * it — `msg` is `null` for the boot transition, a Msg for every other.
311
+ *
312
+ * Returns a shallow copy: the array is fresh on every call (callers may
313
+ * iterate, slice, or replay without affecting the tracker's buffer).
314
+ * Entry values are references to the originals (TEA states/msgs are
315
+ * conventionally immutable; do not mutate them if your S/M is not).
316
+ */
317
+ snapshot(): readonly {
318
+ readonly msg: M | null;
319
+ readonly state: S;
320
+ }[];
321
+ /**
322
+ * Detach the underlying observer. After `stop()`:
323
+ * - No new transitions are recorded.
324
+ * - `snapshot()` continues to work and returns the buffer's contents
325
+ * at the moment of stop (useful for post-hoc inspection in tests).
326
+ * - The tracker holds no references that would prevent GC of the
327
+ * `runtime` argument other than the entries already buffered.
328
+ *
329
+ * Idempotent — subsequent calls are no-ops.
330
+ */
331
+ stop(): void;
332
+ }
333
+ /**
334
+ * Create a bounded history tracker over a Runtime. The tracker subscribes
335
+ * to the runtime via `observe(...)` and retains the last `size` `(msg,
336
+ * state)` transitions in a FIFO ring buffer.
337
+ *
338
+ * @param runtime Any `Runtime<S, M>`. The tracker uses only `observe`.
339
+ * @param size Buffer cap. `size <= 0` produces a tracker whose
340
+ * snapshot is always `[]` (the observer is still attached
341
+ * but pushes a no-op; cheaper to skip the tracker entirely
342
+ * in that case).
343
+ *
344
+ * Memory cost: O(size × avg(msg + state size)). For audit-extension's
345
+ * inspector use case (size 100, audit state ~5KB), ~500KB max — held
346
+ * in-memory only, no persistence.
347
+ */
348
+ declare function historyTracker<S, M extends {
349
+ type: string;
350
+ }>(runtime: Runtime<S, M>, size: number): HistoryTracker<S, M>;
351
+ declare function replay<S, M extends {
352
+ type: string;
353
+ }, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, opts: {
354
+ msgs: readonly M[];
355
+ ctx: Ctx;
356
+ loaded?: S | null;
357
+ }): {
358
+ state: S;
359
+ cmds: C[];
360
+ subs: U[];
361
+ };
362
+ declare function tryInterpret<C extends Cmd, Ok, M, Ctx>(work: (cmd: C, ctx: Ctx) => Promise<Ok>, onOk: (value: Ok, cmd: C) => M, onErr: (error: unknown, cmd: C) => M): (cmd: C, ctx: Ctx) => Promise<M>;
363
+
364
+ export { Cmd, type HistoryTracker, type Interpret, type Machine, type Port, type PortEmitter, PortNameCollisionError, type Reducer, type Runtime, type RuntimeRef, type Store, type Sub, type SubId, type Subscribe, type Transitions, __resetPortRegistry, absurd, defineMachine, definePort, historyTracker, noop, replay, run, subId, tryInterpret };
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { Cmd, PortNameCollisionError, __resetPortRegistry, absurd, defineMachine, definePort, historyTracker, noop, replay, run, subId, tryInterpret } from './chunk-CMG2KMWX.js';
2
+ //# sourceMappingURL=index.js.map
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
@@ -0,0 +1,78 @@
1
+ import { Cmd, Sub, Machine } from '../index.js';
2
+
3
+ /**
4
+ * @demlik/tea/machine-viz — turn a `Machine` into a Mermaid diagram string.
5
+ *
6
+ * PURE. No deps, no AST parsing, no file I/O. The function reads only the
7
+ * `Machine` value it is handed plus optional sample states/msgs/ctx.
8
+ *
9
+ * The trick: TEA update cells are PURE (invariant 2 — they return
10
+ * `[nextState, cmds]` as data, no effects). So we can SAFELY EXECUTE a cell to
11
+ * discover its target state — exactly what `replay` does. That gives us REAL
12
+ * edges (`red --> green : go`) without parsing source. When no sample is
13
+ * provided for a `(state, msg)` pair we cannot resolve the target, so we fall
14
+ * back to a STRUCTURAL self-edge annotated with `?` — visually distinct from a
15
+ * resolved edge and documented in the diagram's legend.
16
+ *
17
+ * Form detection mirrors `run`/`replay` (the `updateMode` logic): inspect the
18
+ * first own key of `update`; a value that is a function ⇒ Reducer form, a value
19
+ * that is a record ⇒ Transitions form. We do NOT add a runtime tag to Machine.
20
+ *
21
+ * What we MUST NOT do (these are the effectful paths, off-limits here):
22
+ * - call `interpret[*]` handlers (perform I/O)
23
+ * - call `subscribe[*]` handlers (install listeners)
24
+ * - call `run` / the runtime
25
+ * We MAY call `init`, `update[*]` cells, and `subscriptions(state)` — all pure
26
+ * by the invariants (same set `replay` is allowed to touch).
27
+ */
28
+
29
+ /**
30
+ * Options controlling diagram emission.
31
+ *
32
+ * `samples` is the lever that turns STRUCTURAL edges into RESOLVED edges. For a
33
+ * Transitions machine, providing a sample state for `stateType` AND a sample
34
+ * msg for `msgType` lets us execute `update[stateType][msgType](state, msg)` and
35
+ * read the real target `next.type`. Sample maps are keyed by the discriminant
36
+ * string (`state.type` / `msg.type`).
37
+ *
38
+ * `samples.ctx` is the argument to `init(null, ctx)` — provide it to draw the
39
+ * `[*] -->` initial-state edge. Omitted ⇒ no init edge (annotated as such),
40
+ * because `init`'s signature requires a ctx and we will not invent one.
41
+ */
42
+ interface MachineVizOptions<S, M> {
43
+ samples?: {
44
+ /** Sample state per `state.type` — unlocks RESOLVED edges + sub annotations. */
45
+ states?: Partial<Record<string, S>>;
46
+ /** Sample msg per `msg.type` — unlocks RESOLVED edges. */
47
+ msgs?: Partial<Record<string, M>>;
48
+ /** Ctx for `init(null, ctx)` — unlocks the `[*] -->` initial-state edge. */
49
+ ctx?: unknown;
50
+ };
51
+ /** Mermaid layout direction. Default `"TB"` (top-to-bottom). */
52
+ direction?: "TB" | "LR";
53
+ /** Optional diagram title, emitted as a Mermaid front-matter `title:` block. */
54
+ title?: string;
55
+ }
56
+ /**
57
+ * Render a `Machine` as a Mermaid `stateDiagram-v2` string.
58
+ *
59
+ * - **Transitions form** (`update` is a record-of-records): nodes are the
60
+ * `state.type` keys; each `(stateType × msgType)` cell becomes an edge. With
61
+ * samples for both, the edge is RESOLVED by executing the (pure) cell. Without
62
+ * samples it is STRUCTURAL — a `from --> from : msg?` self-loop (the `?`
63
+ * suffix marks "target unresolved"). A legend note documents the distinction.
64
+ * The `[*] -->` init edge is drawn only when `samples.ctx` is provided.
65
+ *
66
+ * - **Reducer form** (`update` values are functions): there is no static phase
67
+ * graph (state is not a discriminated union over phases). We emit a single
68
+ * node listing the handled `msg.type`s and an honest note saying so. No edges
69
+ * are invented.
70
+ *
71
+ * Output is deterministic for the same input (keys iterated in insertion order,
72
+ * no clocks, no randomness).
73
+ */
74
+ declare function toMermaid<S, M extends {
75
+ type: string;
76
+ }, C extends Cmd, U extends Sub, Ctx>(machine: Machine<S, M, C, U, Ctx>, opts?: MachineVizOptions<NoInfer<S>, NoInfer<M>>): string;
77
+
78
+ export { type MachineVizOptions, toMermaid };