@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,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"]}
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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 };
|