@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,151 @@
|
|
|
1
|
+
import { Sub } from '../index.js';
|
|
2
|
+
import { S as SubscribeHandler } from '../types-CwBC5wMn.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @demlik/tea/cache — a TTL cache as pure state, PLUS a periodic eviction Sub.
|
|
6
|
+
*
|
|
7
|
+
* Same pure-state-ops shape as `@demlik/tea/idempotency` and
|
|
8
|
+
* `@demlik/tea/work-queue`: a state type plus pure transition functions. Host-
|
|
9
|
+
* agnostic by construction — no `Date.now()`, no timers, no I/O inside the ops.
|
|
10
|
+
* The caller injects the clock (`nowMs`) and folds the result through its own
|
|
11
|
+
* reducer, keeping the store inside TEA's invariant 2 (transitions are pure:
|
|
12
|
+
* same inputs → same outputs) so a consumer can exercise it with `replay`
|
|
13
|
+
* without a fake clock.
|
|
14
|
+
*
|
|
15
|
+
* Every op returns a NEW cache value and never mutates its input (invariant 1 —
|
|
16
|
+
* state is a value, not a place). `get` / `has` are read-shaped; `set` /
|
|
17
|
+
* `remove` / `evictExpired` return a new cache.
|
|
18
|
+
*
|
|
19
|
+
* **Distinct from `/idempotency`.** Both are TTL-bounded key maps, but they
|
|
20
|
+
* answer different questions and so carry different shapes:
|
|
21
|
+
*
|
|
22
|
+
* - `/idempotency` is DEDUPE-focused — "have I already processed this key?"
|
|
23
|
+
* TTL is a single store-level policy (`ttlMs`), each entry stores the
|
|
24
|
+
* `atMs` it was remembered at, and expiry is derived (`now - atMs >=
|
|
25
|
+
* ttlMs`). It carries an `order` ledger + `capacity` because the use case
|
|
26
|
+
* (webhook receivers, retried messages) needs bounded LRU eviction.
|
|
27
|
+
* - `/cache` is RETRIEVAL-focused — "what is the value for this key right
|
|
28
|
+
* now?" TTL is set PER ENTRY at write time: `set(key, value, nowMs,
|
|
29
|
+
* ttlMs)` bakes the absolute `expiresAtMs = nowMs + ttlMs` into the entry.
|
|
30
|
+
* Different keys can carry different lifetimes (a hot key cached for 1s, a
|
|
31
|
+
* cold one for 1h) — the dimension idempotency's single store-level
|
|
32
|
+
* `ttlMs` cannot express. No `order`, no `capacity`: a cache bounds itself
|
|
33
|
+
* by TTL + periodic eviction, not by LRU count.
|
|
34
|
+
*
|
|
35
|
+
* The eviction Sub is the "eviction is a Msg" half: a `setInterval`-shaped Sub
|
|
36
|
+
* (composed directly on `@demlik/tea/subs`' `fromInterval`) that periodically
|
|
37
|
+
* dispatches a `cache_evict` Msg the consumer's reducer handles by calling
|
|
38
|
+
* `evictExpired`. Time still never enters the reducer — the Sub fires the Msg,
|
|
39
|
+
* the reducer reads `nowMs` off the Msg (the consumer stamps it at the Sub
|
|
40
|
+
* boundary), and `evictExpired` does the pure drop.
|
|
41
|
+
*
|
|
42
|
+
* NOT exported from the package root — reached via the `@demlik/tea/cache`
|
|
43
|
+
* subpath, same one-shape-per-package rule as `work-queue`, `idempotency`, and
|
|
44
|
+
* `rate-limit`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A cached entry: the `value` and the absolute clock reading `expiresAtMs` it
|
|
49
|
+
* expires at. Unlike `/idempotency`'s entry (which stores `atMs` and derives
|
|
50
|
+
* expiry from a store-level `ttlMs`), the expiry instant is baked in at write
|
|
51
|
+
* time — so per-entry TTLs are a first-class capability. Both fields are plain
|
|
52
|
+
* data so the whole cache is trivially JSON-serializable into a consumer's
|
|
53
|
+
* `Store<S>`.
|
|
54
|
+
*/
|
|
55
|
+
interface CacheEntry<V> {
|
|
56
|
+
readonly value: V;
|
|
57
|
+
readonly expiresAtMs: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The cache: a map of `key → entry`. No `order` ledger and no `capacity` (cf.
|
|
61
|
+
* `/idempotency`) — a TTL cache bounds itself by expiry + periodic eviction,
|
|
62
|
+
* not by LRU count. Each entry carries its own `expiresAtMs`, so the cache
|
|
63
|
+
* needs no store-level TTL policy field.
|
|
64
|
+
*/
|
|
65
|
+
interface TtlCache<V> {
|
|
66
|
+
readonly entries: Readonly<Record<string, CacheEntry<V>>>;
|
|
67
|
+
}
|
|
68
|
+
/** Create an empty cache. */
|
|
69
|
+
declare function initCache<V>(): TtlCache<V>;
|
|
70
|
+
/**
|
|
71
|
+
* The cached `value` for `key` iff present AND unexpired at `nowMs`, else
|
|
72
|
+
* `undefined`. An expired entry reads as absent even though it is physically
|
|
73
|
+
* still in `entries` until the next `evictExpired` (or an overwriting `set`)
|
|
74
|
+
* drops it. PURE — reads only, never mutates.
|
|
75
|
+
*/
|
|
76
|
+
declare function get<V>(cache: TtlCache<V>, key: string, nowMs: number): V | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* True iff `key` is present AND unexpired at `nowMs`. The boolean sibling of
|
|
79
|
+
* `get` for callers that only need presence, not the value. PURE.
|
|
80
|
+
*/
|
|
81
|
+
declare function has<V>(cache: TtlCache<V>, key: string, nowMs: number): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Write `key → value` with an absolute expiry of `nowMs + ttlMs`. Re-`set`ting
|
|
84
|
+
* an existing key overwrites both the value and the expiry — the TTL clock
|
|
85
|
+
* restarts from the new `nowMs`. PURE — returns a new cache; the input is
|
|
86
|
+
* untouched (a fresh shallow copy of `entries`).
|
|
87
|
+
*/
|
|
88
|
+
declare function set<V>(cache: TtlCache<V>, key: string, value: V, nowMs: number, ttlMs: number): TtlCache<V>;
|
|
89
|
+
/**
|
|
90
|
+
* Drop `key` regardless of expiry. PURE — returns a new cache; the input is
|
|
91
|
+
* untouched. Removing an absent key returns an equivalent cache (the shallow
|
|
92
|
+
* copy still happens, but no entry is dropped).
|
|
93
|
+
*/
|
|
94
|
+
declare function remove<V>(cache: TtlCache<V>, key: string): TtlCache<V>;
|
|
95
|
+
/**
|
|
96
|
+
* Physically drop every entry expired at `nowMs` (`nowMs >= expiresAtMs`).
|
|
97
|
+
* Returns a NEW cache with the survivors. This is the pure work the eviction
|
|
98
|
+
* Sub's `cache_evict` Msg triggers — `get` / `has` already treat expired
|
|
99
|
+
* entries as absent, so eviction is purely a memory-reclaim pass, not a
|
|
100
|
+
* correctness one. PURE — the input cache is never mutated.
|
|
101
|
+
*/
|
|
102
|
+
declare function evictExpired<V>(cache: TtlCache<V>, nowMs: number): TtlCache<V>;
|
|
103
|
+
/**
|
|
104
|
+
* The Msg the eviction Sub dispatches each tick. `id` is the Sub's own id so a
|
|
105
|
+
* consumer with multiple caches can route the tick to the matching cache. The
|
|
106
|
+
* reducer reads `Date.now()`-free time by stamping `nowMs` itself at the Sub
|
|
107
|
+
* boundary if needed; the canonical reducer cell is
|
|
108
|
+
* `cache_evict: (s) => [{ ...s, cache: evictExpired(s.cache, nowMs) }, []]`.
|
|
109
|
+
*/
|
|
110
|
+
interface CacheEvictMsg {
|
|
111
|
+
readonly type: "cache_evict";
|
|
112
|
+
readonly id: string;
|
|
113
|
+
}
|
|
114
|
+
/** Construct a `cache_evict` Msg for the cache identified by `id`. */
|
|
115
|
+
declare function cacheEvictMsg(id: string): CacheEvictMsg;
|
|
116
|
+
/**
|
|
117
|
+
* The Sub shape the eviction factory installs: a `setInterval`-shaped Sub
|
|
118
|
+
* carrying its tick period (`intervalMs`) per the substrate's
|
|
119
|
+
* reconcile-by-id-with-data-on-the-Sub contract. `type: "cache"` is the
|
|
120
|
+
* lowercase source-noun Sub convention (canon §2.5 / naming-style).
|
|
121
|
+
*/
|
|
122
|
+
type CacheEvictionSub = Sub<"cache"> & {
|
|
123
|
+
readonly intervalMs: number;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Declare an eviction Sub for the cache identified by `id`, ticking every
|
|
127
|
+
* `everyMs`. Drop the result into `subscriptions(state)`:
|
|
128
|
+
*
|
|
129
|
+
* subscriptions: (s) => [cacheEvictionSub("session-cache", 30_000)]
|
|
130
|
+
*
|
|
131
|
+
* Same id ⇒ same running interval across transitions (no reconcile churn);
|
|
132
|
+
* changing `everyMs` alone will NOT re-arm the timer (the substrate leaves a
|
|
133
|
+
* same-id Sub running) — change the `id` too if the period must change. This
|
|
134
|
+
* mirrors `fromInterval`'s data-on-the-Sub tradeoff exactly.
|
|
135
|
+
*/
|
|
136
|
+
declare function cacheEvictionSub(id: string, everyMs: number): CacheEvictionSub;
|
|
137
|
+
/**
|
|
138
|
+
* The `subscribe` cell for the eviction Sub. Assign it to
|
|
139
|
+
* `machine.subscribe.cache`:
|
|
140
|
+
*
|
|
141
|
+
* subscribe: { cache: cacheEvictionSubscribe }
|
|
142
|
+
*
|
|
143
|
+
* Composed directly on `fromInterval` — it owns the `setInterval` /
|
|
144
|
+
* `clearInterval` lifecycle; this cell only pins the Msg the tick dispatches
|
|
145
|
+
* (`cacheEvictMsg(sub.id)`). The cleanup `fromInterval` returns stops the
|
|
146
|
+
* interval when the Sub is reconciled out (the cache's state exits the phase
|
|
147
|
+
* that declared it).
|
|
148
|
+
*/
|
|
149
|
+
declare const cacheEvictionSubscribe: SubscribeHandler<CacheEvictionSub, CacheEvictMsg, unknown>;
|
|
150
|
+
|
|
151
|
+
export { type CacheEntry, type CacheEvictMsg, type CacheEvictionSub, type TtlCache, cacheEvictMsg, cacheEvictionSub, cacheEvictionSubscribe, evictExpired, get, has, initCache, remove, set };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { fromInterval } from '../chunk-Y5JM3UUH.js';
|
|
2
|
+
import '../chunk-2YFLOCWX.js';
|
|
3
|
+
|
|
4
|
+
// src/cache/index.ts
|
|
5
|
+
function initCache() {
|
|
6
|
+
return { entries: {} };
|
|
7
|
+
}
|
|
8
|
+
function isExpired(entry, nowMs) {
|
|
9
|
+
return nowMs >= entry.expiresAtMs;
|
|
10
|
+
}
|
|
11
|
+
function get(cache, key, nowMs) {
|
|
12
|
+
const entry = cache.entries[key];
|
|
13
|
+
if (entry === void 0 || isExpired(entry, nowMs)) return void 0;
|
|
14
|
+
return entry.value;
|
|
15
|
+
}
|
|
16
|
+
function has(cache, key, nowMs) {
|
|
17
|
+
const entry = cache.entries[key];
|
|
18
|
+
if (entry === void 0) return false;
|
|
19
|
+
return !isExpired(entry, nowMs);
|
|
20
|
+
}
|
|
21
|
+
function set(cache, key, value, nowMs, ttlMs) {
|
|
22
|
+
const nextEntries = { ...cache.entries };
|
|
23
|
+
nextEntries[key] = { value, expiresAtMs: nowMs + ttlMs };
|
|
24
|
+
return { ...cache, entries: nextEntries };
|
|
25
|
+
}
|
|
26
|
+
function remove(cache, key) {
|
|
27
|
+
const nextEntries = { ...cache.entries };
|
|
28
|
+
delete nextEntries[key];
|
|
29
|
+
return { ...cache, entries: nextEntries };
|
|
30
|
+
}
|
|
31
|
+
function evictExpired(cache, nowMs) {
|
|
32
|
+
const nextEntries = {};
|
|
33
|
+
for (const [key, entry] of Object.entries(cache.entries)) {
|
|
34
|
+
if (isExpired(entry, nowMs)) continue;
|
|
35
|
+
nextEntries[key] = entry;
|
|
36
|
+
}
|
|
37
|
+
return { ...cache, entries: nextEntries };
|
|
38
|
+
}
|
|
39
|
+
function cacheEvictMsg(id) {
|
|
40
|
+
return { type: "cache_evict", id };
|
|
41
|
+
}
|
|
42
|
+
function cacheEvictionSub(id, everyMs) {
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
type: "cache",
|
|
46
|
+
intervalMs: everyMs
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
var cacheEvictionSubscribe = fromInterval((sub) => cacheEvictMsg(sub.id));
|
|
50
|
+
|
|
51
|
+
export { cacheEvictMsg, cacheEvictionSub, cacheEvictionSubscribe, evictExpired, get, has, initCache, remove, set };
|
|
52
|
+
//# sourceMappingURL=index.js.map
|
|
53
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/cache/index.ts"],"names":[],"mappings":";;;;AAuEO,SAAS,SAAA,GAA4B;AAC1C,EAAA,OAAO,EAAE,OAAA,EAAS,EAAC,EAAE;AACvB;AAQA,SAAS,SAAA,CAAa,OAAsB,KAAA,EAAwB;AAClE,EAAA,OAAO,SAAS,KAAA,CAAM,WAAA;AACxB;AAQO,SAAS,GAAA,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,KAAK,GAAG,OAAO,MAAA;AAC3D,EAAA,OAAO,KAAA,CAAM,KAAA;AACf;AAMO,SAAS,GAAA,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,KAAK,CAAA;AAChC;AAQO,SAAS,GAAA,CACd,KAAA,EACA,GAAA,EACA,KAAA,EACA,OACA,KAAA,EACa;AACb,EAAA,MAAM,WAAA,GAA6C,EAAE,GAAG,KAAA,CAAM,OAAA,EAAQ;AACtE,EAAA,WAAA,CAAY,GAAG,CAAA,GAAI,EAAE,KAAA,EAAO,WAAA,EAAa,QAAQ,KAAA,EAAM;AACvD,EAAA,OAAO,EAAE,GAAG,KAAA,EAAO,OAAA,EAAS,WAAA,EAAY;AAC1C;AAOO,SAAS,MAAA,CAAU,OAAoB,GAAA,EAA0B;AACtE,EAAA,MAAM,WAAA,GAA6C,EAAE,GAAG,KAAA,CAAM,OAAA,EAAQ;AACtE,EAAA,OAAO,YAAY,GAAG,CAAA;AACtB,EAAA,OAAO,EAAE,GAAG,KAAA,EAAO,OAAA,EAAS,WAAA,EAAY;AAC1C;AASO,SAAS,YAAA,CACd,OACA,KAAA,EACa;AACb,EAAA,MAAM,cAA6C,EAAC;AACpD,EAAA,KAAA,MAAW,CAAC,KAAK,KAAK,CAAA,IAAK,OAAO,OAAA,CAAQ,KAAA,CAAM,OAAO,CAAA,EAAG;AACxD,IAAA,IAAI,SAAA,CAAU,KAAA,EAAO,KAAK,CAAA,EAAG;AAC7B,IAAA,WAAA,CAAY,GAAG,CAAA,GAAI,KAAA;AAAA,EACrB;AACA,EAAA,OAAO,EAAE,GAAG,KAAA,EAAO,OAAA,EAAS,WAAA,EAAY;AAC1C;AAmCO,SAAS,cAAc,EAAA,EAA2B;AACvD,EAAA,OAAO,EAAE,IAAA,EAAM,aAAA,EAAe,EAAA,EAAG;AACnC;AAqBO,SAAS,gBAAA,CACd,IACA,OAAA,EACkB;AAClB,EAAA,OAAO;AAAA,IACL,EAAA;AAAA,IACA,IAAA,EAAM,OAAA;AAAA,IACN,UAAA,EAAY;AAAA,GACd;AACF;AAcO,IAAM,yBAIT,YAAA,CAAa,CAAC,QAAQ,aAAA,CAAc,GAAA,CAAI,EAAE,CAAC","file":"index.js","sourcesContent":["/**\n * @demlik/tea/cache — a TTL cache as pure state, PLUS a periodic eviction Sub.\n *\n * Same pure-state-ops shape as `@demlik/tea/idempotency` and\n * `@demlik/tea/work-queue`: a state type plus pure transition functions. Host-\n * agnostic by construction — no `Date.now()`, no timers, no I/O inside the ops.\n * The caller injects the clock (`nowMs`) and folds the result through its own\n * reducer, keeping the store inside TEA's invariant 2 (transitions are pure:\n * same inputs → same outputs) so a consumer can exercise it with `replay`\n * without a fake clock.\n *\n * Every op returns a NEW cache value and never mutates its input (invariant 1 —\n * state is a value, not a place). `get` / `has` are read-shaped; `set` /\n * `remove` / `evictExpired` return a new cache.\n *\n * **Distinct from `/idempotency`.** Both are TTL-bounded key maps, but they\n * answer different questions and so carry different shapes:\n *\n * - `/idempotency` is DEDUPE-focused — \"have I already processed this key?\"\n * TTL is a single store-level policy (`ttlMs`), each entry stores the\n * `atMs` it was remembered at, and expiry is derived (`now - atMs >=\n * ttlMs`). It carries an `order` ledger + `capacity` because the use case\n * (webhook receivers, retried messages) needs bounded LRU eviction.\n * - `/cache` is RETRIEVAL-focused — \"what is the value for this key right\n * now?\" TTL is set PER ENTRY at write time: `set(key, value, nowMs,\n * ttlMs)` bakes the absolute `expiresAtMs = nowMs + ttlMs` into the entry.\n * Different keys can carry different lifetimes (a hot key cached for 1s, a\n * cold one for 1h) — the dimension idempotency's single store-level\n * `ttlMs` cannot express. No `order`, no `capacity`: a cache bounds itself\n * by TTL + periodic eviction, not by LRU count.\n *\n * The eviction Sub is the \"eviction is a Msg\" half: a `setInterval`-shaped Sub\n * (composed directly on `@demlik/tea/subs`' `fromInterval`) that periodically\n * dispatches a `cache_evict` Msg the consumer's reducer handles by calling\n * `evictExpired`. Time still never enters the reducer — the Sub fires the Msg,\n * the reducer reads `nowMs` off the Msg (the consumer stamps it at the Sub\n * boundary), and `evictExpired` does the pure drop.\n *\n * NOT exported from the package root — reached via the `@demlik/tea/cache`\n * subpath, same one-shape-per-package rule as `work-queue`, `idempotency`, and\n * `rate-limit`.\n */\n\nimport type { Sub } from \"../index\";\nimport { fromInterval } from \"../subs\";\nimport type { SubscribeHandler } from \"../subs/types\";\n\n/**\n * A cached entry: the `value` and the absolute clock reading `expiresAtMs` it\n * expires at. Unlike `/idempotency`'s entry (which stores `atMs` and derives\n * expiry from a store-level `ttlMs`), the expiry instant is baked in at write\n * time — so per-entry TTLs are a first-class capability. Both fields are plain\n * data so the whole cache is trivially JSON-serializable into a consumer's\n * `Store<S>`.\n */\nexport interface CacheEntry<V> {\n readonly value: V;\n readonly expiresAtMs: number;\n}\n\n/**\n * The cache: a map of `key → entry`. No `order` ledger and no `capacity` (cf.\n * `/idempotency`) — a TTL cache bounds itself by expiry + periodic eviction,\n * not by LRU count. Each entry carries its own `expiresAtMs`, so the cache\n * needs no store-level TTL policy field.\n */\nexport interface TtlCache<V> {\n readonly entries: Readonly<Record<string, CacheEntry<V>>>;\n}\n\n/** Create an empty cache. */\nexport function initCache<V>(): TtlCache<V> {\n return { entries: {} };\n}\n\n/**\n * Whether `entry` is expired at `nowMs`. Half-open: an entry whose\n * `expiresAtMs` exactly equals `nowMs` is expired (`nowMs >= expiresAtMs`),\n * matching `/idempotency` and `/rate-limit`'s window-edge cutoff — a value\n * cached at `t` with a `ttlMs` lifetime is gone at exactly `t + ttlMs`.\n */\nfunction isExpired<V>(entry: CacheEntry<V>, nowMs: number): boolean {\n return nowMs >= entry.expiresAtMs;\n}\n\n/**\n * The cached `value` for `key` iff present AND unexpired at `nowMs`, else\n * `undefined`. An expired entry reads as absent even though it is physically\n * still in `entries` until the next `evictExpired` (or an overwriting `set`)\n * drops it. PURE — reads only, never mutates.\n */\nexport function get<V>(\n cache: TtlCache<V>,\n key: string,\n nowMs: number,\n): V | undefined {\n const entry = cache.entries[key];\n if (entry === undefined || isExpired(entry, nowMs)) return undefined;\n return entry.value;\n}\n\n/**\n * True iff `key` is present AND unexpired at `nowMs`. The boolean sibling of\n * `get` for callers that only need presence, not the value. PURE.\n */\nexport function has<V>(\n cache: TtlCache<V>,\n key: string,\n nowMs: number,\n): boolean {\n const entry = cache.entries[key];\n if (entry === undefined) return false;\n return !isExpired(entry, nowMs);\n}\n\n/**\n * Write `key → value` with an absolute expiry of `nowMs + ttlMs`. Re-`set`ting\n * an existing key overwrites both the value and the expiry — the TTL clock\n * restarts from the new `nowMs`. PURE — returns a new cache; the input is\n * untouched (a fresh shallow copy of `entries`).\n */\nexport function set<V>(\n cache: TtlCache<V>,\n key: string,\n value: V,\n nowMs: number,\n ttlMs: number,\n): TtlCache<V> {\n const nextEntries: Record<string, CacheEntry<V>> = { ...cache.entries };\n nextEntries[key] = { value, expiresAtMs: nowMs + ttlMs };\n return { ...cache, entries: nextEntries };\n}\n\n/**\n * Drop `key` regardless of expiry. PURE — returns a new cache; the input is\n * untouched. Removing an absent key returns an equivalent cache (the shallow\n * copy still happens, but no entry is dropped).\n */\nexport function remove<V>(cache: TtlCache<V>, key: string): TtlCache<V> {\n const nextEntries: Record<string, CacheEntry<V>> = { ...cache.entries };\n delete nextEntries[key];\n return { ...cache, entries: nextEntries };\n}\n\n/**\n * Physically drop every entry expired at `nowMs` (`nowMs >= expiresAtMs`).\n * Returns a NEW cache with the survivors. This is the pure work the eviction\n * Sub's `cache_evict` Msg triggers — `get` / `has` already treat expired\n * entries as absent, so eviction is purely a memory-reclaim pass, not a\n * correctness one. PURE — the input cache is never mutated.\n */\nexport function evictExpired<V>(\n cache: TtlCache<V>,\n nowMs: number,\n): TtlCache<V> {\n const nextEntries: Record<string, CacheEntry<V>> = {};\n for (const [key, entry] of Object.entries(cache.entries)) {\n if (isExpired(entry, nowMs)) continue;\n nextEntries[key] = entry;\n }\n return { ...cache, entries: nextEntries };\n}\n\n// ---------------------------------------------------------------------------\n// Eviction Sub — \"eviction is a Msg\".\n//\n// A periodic timer Sub that dispatches `cache_evict` so the consumer's reducer\n// can call `evictExpired`. It is `fromInterval` specialized to a fixed Msg\n// shape — the factory carries the interval ON the Sub (per the substrate's\n// reconcile-by-id contract) and dispatches a `cache_evict` Msg tagged with the\n// Sub's own `id`, so a consumer running several caches can route each tick to\n// the right cache by `msg.id`.\n//\n// Mirrors `fromInterval` exactly (factory + SubscribeHandler cell + exported\n// Msg constructor); it does NOT reimplement the timer lifecycle — it composes\n// directly on `fromInterval`, which owns `setInterval` / `clearInterval`. That\n// keeps the timer-handle bookkeeping (and its cleanup) in one place and this\n// module purely declarative.\n//\n// Strengthens invariant 4 (external time is a Sub, reconciled by id) and\n// invariant 9 (named, small Sub surface composed on the shared factory).\n// ---------------------------------------------------------------------------\n\n/**\n * The Msg the eviction Sub dispatches each tick. `id` is the Sub's own id so a\n * consumer with multiple caches can route the tick to the matching cache. The\n * reducer reads `Date.now()`-free time by stamping `nowMs` itself at the Sub\n * boundary if needed; the canonical reducer cell is\n * `cache_evict: (s) => [{ ...s, cache: evictExpired(s.cache, nowMs) }, []]`.\n */\nexport interface CacheEvictMsg {\n readonly type: \"cache_evict\";\n readonly id: string;\n}\n\n/** Construct a `cache_evict` Msg for the cache identified by `id`. */\nexport function cacheEvictMsg(id: string): CacheEvictMsg {\n return { type: \"cache_evict\", id };\n}\n\n/**\n * The Sub shape the eviction factory installs: a `setInterval`-shaped Sub\n * carrying its tick period (`intervalMs`) per the substrate's\n * reconcile-by-id-with-data-on-the-Sub contract. `type: \"cache\"` is the\n * lowercase source-noun Sub convention (canon §2.5 / naming-style).\n */\nexport type CacheEvictionSub = Sub<\"cache\"> & { readonly intervalMs: number };\n\n/**\n * Declare an eviction Sub for the cache identified by `id`, ticking every\n * `everyMs`. Drop the result into `subscriptions(state)`:\n *\n * subscriptions: (s) => [cacheEvictionSub(\"session-cache\", 30_000)]\n *\n * Same id ⇒ same running interval across transitions (no reconcile churn);\n * changing `everyMs` alone will NOT re-arm the timer (the substrate leaves a\n * same-id Sub running) — change the `id` too if the period must change. This\n * mirrors `fromInterval`'s data-on-the-Sub tradeoff exactly.\n */\nexport function cacheEvictionSub(\n id: string,\n everyMs: number,\n): CacheEvictionSub {\n return {\n id: id as CacheEvictionSub[\"id\"],\n type: \"cache\",\n intervalMs: everyMs,\n };\n}\n\n/**\n * The `subscribe` cell for the eviction Sub. Assign it to\n * `machine.subscribe.cache`:\n *\n * subscribe: { cache: cacheEvictionSubscribe }\n *\n * Composed directly on `fromInterval` — it owns the `setInterval` /\n * `clearInterval` lifecycle; this cell only pins the Msg the tick dispatches\n * (`cacheEvictMsg(sub.id)`). The cleanup `fromInterval` returns stops the\n * interval when the Sub is reconciled out (the cache's state exits the phase\n * that declared it).\n */\nexport const cacheEvictionSubscribe: SubscribeHandler<\n CacheEvictionSub,\n CacheEvictMsg,\n unknown\n> = fromInterval((sub) => cacheEvictMsg(sub.id));\n"]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/subs/from-timeout.ts
|
|
2
|
+
function fromTimeout(msgFn) {
|
|
3
|
+
return (sub, _ctx, dispatch) => {
|
|
4
|
+
const handle = setTimeout(() => {
|
|
5
|
+
dispatch(msgFn(sub));
|
|
6
|
+
}, sub.delayMs);
|
|
7
|
+
return () => {
|
|
8
|
+
clearTimeout(handle);
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { fromTimeout };
|
|
14
|
+
//# sourceMappingURL=chunk-2YFLOCWX.js.map
|
|
15
|
+
//# sourceMappingURL=chunk-2YFLOCWX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/subs/from-timeout.ts"],"names":[],"mappings":";AAgCO,SAAS,YACd,KAAA,EACiC;AACjC,EAAA,OAAO,CAAC,GAAA,EAAK,IAAA,EAAM,QAAA,KAAa;AAC9B,IAAA,MAAM,MAAA,GAAS,WAAW,MAAM;AAC9B,MAAA,QAAA,CAAS,KAAA,CAAM,GAAG,CAAC,CAAA;AAAA,IACrB,CAAA,EAAG,IAAI,OAAO,CAAA;AACd,IAAA,OAAO,MAAM;AACX,MAAA,YAAA,CAAa,MAAM,CAAA;AAAA,IACrB,CAAA;AAAA,EACF,CAAA;AACF","file":"chunk-2YFLOCWX.js","sourcesContent":["// ---------------------------------------------------------------------------\n// fromTimeout — universal `setTimeout`-shaped Sub factory.\n//\n// Same lifecycle shape as `fromInterval` minus the recurrence: the\n// scheduled callback fires once, the cleanup is `clearTimeout`. Two\n// observable consequences worth pinning explicitly:\n//\n// 1. The Sub stays in the desired set until the caller removes it from\n// `subscriptions(state)`. The substrate's reconcile pass does NOT\n// auto-remove a Sub on cleanup — the timer fires, the callback\n// dispatches its Msg, and if the Sub is still present on the next\n// transition's desired set, the factory will... NOT re-fire. The\n// timer handle is captured per-subscribe call; after it fires, the\n// handle is exhausted. Adding the same Sub a second time (via a new\n// id) is the only way to re-arm.\n//\n// 2. Cleanup before the timer fires cancels the pending dispatch. This\n// is the \"cancel on state exit\" pattern: model the timeout as a Sub\n// whose `subscriptions(state)` returns it only in the state where the\n// timeout should be armed. When the state changes, the substrate's\n// reconcile drops the Sub and the factory's cleanup fires before the\n// timer does.\n//\n// Like `fromInterval`, the delay lives on the Sub (`delayMs`) — same id\n// means same scheduled timeout; changing the delay requires a new id.\n// ---------------------------------------------------------------------------\n\nimport type { Sub } from \"../index\";\nimport type { SubscribeHandler } from \"./types\";\n\ntype TimeoutSubData = { delayMs: number };\n\nexport function fromTimeout<S extends Sub & TimeoutSubData, M>(\n msgFn: (sub: S) => M,\n): SubscribeHandler<S, M, unknown> {\n return (sub, _ctx, dispatch) => {\n const handle = setTimeout(() => {\n dispatch(msgFn(sub));\n }, sub.delayMs);\n return () => {\n clearTimeout(handle);\n };\n };\n}\n"]}
|