actly 1.1.5 → 1.2.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 (119) hide show
  1. package/README.md +232 -434
  2. package/dist/core/act.cjs +353 -35
  3. package/dist/core/act.d.ts.map +1 -1
  4. package/dist/core/act.js +355 -37
  5. package/dist/core/act.js.map +1 -1
  6. package/dist/core/executor.cjs +2 -0
  7. package/dist/core/executor.d.ts +6 -1
  8. package/dist/core/executor.d.ts.map +1 -1
  9. package/dist/core/executor.js +2 -0
  10. package/dist/core/executor.js.map +1 -1
  11. package/dist/core/health.cjs +34 -0
  12. package/dist/core/health.d.ts +18 -0
  13. package/dist/core/health.d.ts.map +1 -0
  14. package/dist/core/health.js +28 -0
  15. package/dist/core/health.js.map +1 -0
  16. package/dist/core/shutdown.cjs +60 -0
  17. package/dist/core/shutdown.d.ts +8 -0
  18. package/dist/core/shutdown.d.ts.map +1 -0
  19. package/dist/core/shutdown.js +56 -0
  20. package/dist/core/shutdown.js.map +1 -0
  21. package/dist/core/tenant.cjs +71 -0
  22. package/dist/core/tenant.d.ts +29 -0
  23. package/dist/core/tenant.d.ts.map +1 -0
  24. package/dist/core/tenant.js +68 -0
  25. package/dist/core/tenant.js.map +1 -0
  26. package/dist/errors.cjs +182 -0
  27. package/dist/errors.d.ts +151 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +171 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/index.cjs +44 -4
  32. package/dist/index.d.ts +17 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +17 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/observability.cjs +45 -0
  37. package/dist/observability.d.ts +145 -0
  38. package/dist/observability.d.ts.map +1 -0
  39. package/dist/observability.js +43 -0
  40. package/dist/observability.js.map +1 -0
  41. package/dist/policies/bulkhead.cjs +97 -0
  42. package/dist/policies/bulkhead.d.ts +3 -0
  43. package/dist/policies/bulkhead.d.ts.map +1 -0
  44. package/dist/policies/bulkhead.js +95 -0
  45. package/dist/policies/bulkhead.js.map +1 -0
  46. package/dist/policies/cache.cjs +168 -31
  47. package/dist/policies/cache.d.ts +17 -4
  48. package/dist/policies/cache.d.ts.map +1 -1
  49. package/dist/policies/cache.js +168 -31
  50. package/dist/policies/cache.js.map +1 -1
  51. package/dist/policies/circuitBreaker.cjs +85 -0
  52. package/dist/policies/circuitBreaker.d.ts +3 -0
  53. package/dist/policies/circuitBreaker.d.ts.map +1 -0
  54. package/dist/policies/circuitBreaker.js +83 -0
  55. package/dist/policies/circuitBreaker.js.map +1 -0
  56. package/dist/policies/dedupe.cjs +74 -45
  57. package/dist/policies/dedupe.d.ts +10 -28
  58. package/dist/policies/dedupe.d.ts.map +1 -1
  59. package/dist/policies/dedupe.js +74 -45
  60. package/dist/policies/dedupe.js.map +1 -1
  61. package/dist/policies/rateLimit.cjs +35 -0
  62. package/dist/policies/rateLimit.d.ts +3 -0
  63. package/dist/policies/rateLimit.d.ts.map +1 -0
  64. package/dist/policies/rateLimit.js +33 -0
  65. package/dist/policies/rateLimit.js.map +1 -0
  66. package/dist/policies/retry.cjs +45 -8
  67. package/dist/policies/retry.d.ts.map +1 -1
  68. package/dist/policies/retry.js +45 -8
  69. package/dist/policies/retry.js.map +1 -1
  70. package/dist/policies/timeout.cjs +25 -53
  71. package/dist/policies/timeout.d.ts +2 -33
  72. package/dist/policies/timeout.d.ts.map +1 -1
  73. package/dist/policies/timeout.js +31 -48
  74. package/dist/policies/timeout.js.map +1 -1
  75. package/dist/state/store.cjs +3 -3
  76. package/dist/state/store.d.ts +3 -3
  77. package/dist/state/store.js +3 -3
  78. package/dist/stores/memory.cjs +126 -53
  79. package/dist/stores/memory.d.ts +35 -16
  80. package/dist/stores/memory.d.ts.map +1 -1
  81. package/dist/stores/memory.js +125 -53
  82. package/dist/stores/memory.js.map +1 -1
  83. package/dist/types/index.d.ts +176 -3
  84. package/dist/types/index.d.ts.map +1 -1
  85. package/dist/utils/abort.cjs +108 -38
  86. package/dist/utils/abort.d.ts +43 -19
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +108 -38
  89. package/dist/utils/abort.js.map +1 -1
  90. package/dist/utils/abortPool.cjs +26 -0
  91. package/dist/utils/abortPool.d.ts +4 -0
  92. package/dist/utils/abortPool.d.ts.map +1 -0
  93. package/dist/utils/abortPool.js +22 -0
  94. package/dist/utils/abortPool.js.map +1 -0
  95. package/dist/utils/backoff.cjs +12 -1
  96. package/dist/utils/backoff.d.ts.map +1 -1
  97. package/dist/utils/backoff.js +12 -1
  98. package/dist/utils/backoff.js.map +1 -1
  99. package/dist/utils/key.cjs +77 -0
  100. package/dist/utils/key.d.ts +29 -0
  101. package/dist/utils/key.d.ts.map +1 -0
  102. package/dist/utils/key.js +75 -0
  103. package/dist/utils/key.js.map +1 -0
  104. package/dist/utils/limits.cjs +40 -0
  105. package/dist/utils/limits.d.ts +39 -0
  106. package/dist/utils/limits.d.ts.map +1 -0
  107. package/dist/utils/limits.js +38 -0
  108. package/dist/utils/limits.js.map +1 -0
  109. package/dist/utils/sanitize.cjs +29 -0
  110. package/dist/utils/sanitize.d.ts +3 -0
  111. package/dist/utils/sanitize.d.ts.map +1 -0
  112. package/dist/utils/sanitize.js +26 -0
  113. package/dist/utils/sanitize.js.map +1 -0
  114. package/dist/utils/validate.cjs +90 -16
  115. package/dist/utils/validate.d.ts +11 -0
  116. package/dist/utils/validate.d.ts.map +1 -1
  117. package/dist/utils/validate.js +85 -16
  118. package/dist/utils/validate.js.map +1 -1
  119. package/package.json +6 -5
package/README.md CHANGED
@@ -1,25 +1,18 @@
1
1
  # actly
2
2
 
3
- **Reliability primitive for async functions.**
4
- Retry. Timeout. Dedupe. Cache. Composable, typed, zero-throw — with proper `AbortSignal` cancellation, single-flight cache, LRU store, and jittered backoff.
3
+ A typed reliability kernel for async execution. Retry, timeout, dedupe, cache — composable, zero-throw, with `AbortSignal` cancellation, jittered backoff, observability hooks, and a typed error taxonomy.
5
4
 
6
5
  ```bash
7
6
  npm install actly
8
7
  ```
9
8
 
10
- > Requires Node 18+. Ships ESM + CJS. Zero dependencies.
9
+ > Requires Node 20+. Ships ESM + CJS. Zero runtime dependencies. Tree-shakeable.
11
10
 
12
11
  ---
13
12
 
14
- ## The problem
13
+ ## What it does
15
14
 
16
- Every async call can fail. Networks blip. Services time out. The same UI mounts three times and fires the same fetch in parallel. You need retry logic, but not for 4xx errors. You need timeouts, but not the kind that let retry loops run forever.
17
-
18
- These patterns are solved the same way every time. `actly` solves them once.
19
-
20
- ---
21
-
22
- ## Quick start
15
+ `act()` wraps an async function with configurable reliability policies. It always resolves with a result object never rejects. You check `.ok` to branch on success or failure.
23
16
 
24
17
  ```ts
25
18
  import { act } from 'actly'
@@ -37,573 +30,378 @@ const result = await act(
37
30
  )
38
31
 
39
32
  if (result.ok) {
40
- console.log(result.value) // T
41
- console.log(result.source) // 'fresh' | 'cache'
42
- console.log(result.attempts) // number (0 on cache hit)
33
+ console.log(result.value) // T
34
+ console.log(result.source) // 'fresh' | 'cache'
35
+ console.log(result.attempts) // number (0 on cache hit)
36
+ console.log(result.durationMs) // number (wall-clock ms)
43
37
  } else {
44
- console.error(result.error) // unknownnever throws
38
+ console.error(result.error) // typedsee Error taxonomy
45
39
  }
46
40
  ```
47
41
 
48
- `act` always resolves. It never rejects. You check `.ok` and move on.
49
-
50
- The `signal` passed to `fn` is your escape hatch: wire it through to `fetch`, `AbortController`, database drivers, or any primitive that accepts one. If a timeout fires (per-attempt or total), or the caller aborts via `options.signal`, `fn`'s signal aborts and the operation rejects promptly — no leaked resources, no hung retry loops.
51
-
52
42
  ---
53
43
 
54
- ## API
44
+ ## Compared to Cockatiel
55
45
 
56
- ```ts
57
- act<T>(key: string, fn: ActFn<T>, options?: ActOptions): Promise<ActResult<T>>
58
- ```
46
+ Both are typed, zero-dependency resilience libraries for Node/TypeScript. The core difference is scope and composition model.
59
47
 
60
- | Argument | Type | Description |
48
+ | | actly | Cockatiel |
61
49
  |---|---|---|
62
- | `key` | `string` | Stable, unique identifier. Scopes dedupe and cache state. Must be non-empty and must not start with reserved prefixes (`dedupe:`, `cache:`, `__inflight:`). |
63
- | `fn` | `(signal: AbortSignal) => Promise<T> \| T` | The async function to execute. Receives an `AbortSignal` for cooperative cancellation. Backwards compatible: `() => Promise<T>` is accepted (the signal is simply ignored). |
64
- | `options` | `ActOptions` | All optional. See policies below. |
50
+ | Retry (backoff + jitter) | ✅ exponential/linear/constant, 4 jitter modes | exponential, `ExponentialBackoff` |
51
+ | Timeout (per-attempt) | | |
52
+ | Total/wall-clock timeout | `totalTimeout` spans the whole retry loop | — (compose manually) |
53
+ | Circuit breaker | ✅ half-open single-probe | ✅ |
54
+ | Bulkhead | ✅ with optional queue + queue timeout | ✅ |
55
+ | Rate limit | ✅ sliding window | — |
56
+ | Cache (with single-flight) | ✅ built in, stampede-proof | — |
57
+ | Request dedupe (single-flight) | ✅ generation-safe, joiner-isolated | — |
58
+ | Hedged requests | ✅ | — |
59
+ | Fallback | — (compose at call site) | ✅ |
60
+ | Composition model | one `act()` call, declarative options object | explicit `wrap()` chaining of policy objects |
61
+ | Cancellation | native `AbortSignal`, propagated through every policy | `CancellationToken` (custom, pre-dates broad `AbortSignal` adoption) |
62
+ | Observability | opt-in hooks, zero-cost when unused | events per policy object |
63
+ | State store | pluggable (`InMemoryStore` built in; bring your own for Redis etc.) | in-process only |
65
64
 
66
- ### Result shape
65
+ Where the two overlap (retry, timeout, circuit breaker, bulkhead), the semantics are comparable and both are well-suited to production use. actly additionally folds cache, dedupe, rate limiting, and hedging into the same policy chain and result type, so a single `act()` call can express what would otherwise require composing several Cockatiel policies plus your own caching/dedupe layer. Cockatiel's `fallback` policy has no direct equivalent in actly — compose it at the call site by catching the returned `ActResult` and substituting a default when `!result.ok`.
67
66
 
68
- ```ts
69
- type ActResult<T> =
70
- | { ok: true; value: T; source: 'fresh' | 'cache'; attempts: number }
71
- | { ok: false; error: unknown; attempts: number }
72
- ```
73
-
74
- `attempts` semantics:
75
- - Fresh success on first try: `1`
76
- - Fresh success after N retries: `N`
77
- - Cache hit: `0` (no work was performed)
78
- - Dedupe joiner: mirrors the originator's attempt count (v1.1.5 fix — see changelog)
79
-
80
- ---
67
+ ### Measured overhead: actly vs Cockatiel
81
68
 
82
- ## Policies
69
+ Feature parity is only half the story — here are actual numbers, not marketing claims. Run it yourself: `npm run build && npm install --no-save cockatiel && node bench/compare-cockatiel.mjs`.
83
70
 
84
- All policies are optional and compose freely. The execution order is **fixed**:
71
+ Representative run (Node 20, single machine, trivial no-op `fn`, 20,000 iterations per case — see `bench/compare-cockatiel.mjs` for full methodology):
85
72
 
86
- ```
87
- totalTimeout → cache → dedupe → retry → timeout
88
- ```
73
+ | Policy (happy path) | actly | Cockatiel | Cockatiel is faster by |
74
+ |---|---|---|---|
75
+ | retry (3 attempts configured, succeeds on 1st) | 5.0 µs/op | 0.9 µs/op | ~5.5x |
76
+ | timeout (5s limit, resolves fast) | 13.7 µs/op | 7.1 µs/op | ~1.9x |
77
+ | circuit breaker (closed) | 3.5 µs/op | 0.5 µs/op | ~7.1x |
78
+ | bulkhead (under cap) | 4.2 µs/op | 0.5 µs/op | ~8.0x |
79
+ | retry + timeout + circuitBreaker composed | 14.5 µs/op | 7.9 µs/op | ~1.8x |
89
80
 
90
- ### Retry
81
+ **Honest read:** Cockatiel is faster, consistently, on every policy they both implement. Its policies are closures with no external state lookup. actly's policies go through a keyed state store (`Map` get/set per call, even for `InMemoryStore`) to support cross-call features Cockatiel doesn't have — a circuit breaker's failure count has to live somewhere addressable by `key` across separate `act()` calls, not captured in a closure. That indirection costs microseconds per call.
91
82
 
92
- ```ts
93
- const result = await act('payments:charge', async (signal) => chargeCard(payload, signal), {
94
- retry: {
95
- attempts: 3, // total attempts (including first). Must be integer >= 1.
96
- delayMs: 200, // base delay between attempts (ms)
97
- backoff: 'exponential',// 'none' | 'linear' | 'exponential'
98
- maxDelay: 30_000, // cap (ms) — prevents 8.5-minute waits on long exponential chains
99
- jitter: 'full', // 'none' | 'full' | 'equal' | 'decorrelated' (default: 'full')
100
- },
101
- })
102
- ```
83
+ For nearly all real workloads this difference is noise — both add single-digit-to-low-double-digit microseconds against I/O that costs milliseconds. It matters if you're calling a policy millions of times per second in a hot loop with no I/O; it does not matter wrapping network or database calls, which is what both libraries are for. Pick actly when you want cache/dedupe/rate-limit/hedge in the same call and don't want to hand-wire a state layer around Cockatiel; pick Cockatiel when you only need retry/timeout/circuit-breaker/bulkhead/fallback and want the leanest possible per-call cost.
103
84
 
104
- `result.attempts` tells you how many tries it took.
85
+ ---
105
86
 
106
- #### Jitter (default: `'full'`)
87
+ ## API
107
88
 
108
- Jitter prevents thundering-herd retry storms when many callers fail simultaneously. Without jitter, all callers retry on the same tick after an upstream outage recovers. The default `'full'` jitter randomises each delay to `[0, computed]`.
89
+ ### `act(key, fn, options?)`
109
90
 
110
- | Strategy | Formula |
111
- |---|---|
112
- | `'none'` | `delay` |
113
- | `'full'` | `random() * delay` |
114
- | `'equal'` | `delay/2 + random() * delay/2` |
115
- | `'decorrelated'` | `base + random() * (delay - base)` |
91
+ Wraps `fn` with reliability policies. Returns `Promise<ActResult<T>>`.
116
92
 
117
- #### Selective retry with `shouldRetry`
93
+ | Param | Type | Description |
94
+ |-------|------|-------------|
95
+ | `key` | `string` | Stable identifier. Scopes dedupe + cache. Validated for prototype pollution, control chars, CRLF, length (≤1024), reserved prefixes. |
96
+ | `fn` | `(signal: AbortSignal) => Promise<T> \| T` | The async work. Signal aborts on caller cancel, per-attempt timeout, or total timeout. Legacy `() => Promise<T>` accepted (signal ignored). |
97
+ | `options` | `ActOptions` | Policy configuration. All fields optional. |
118
98
 
119
- By default, `actly` retries on every error EXCEPT `AbortError` (which indicates the caller or a timeout cancelled the operation retrying would just abort again). Use `shouldRetry` to skip retrying other permanent errors — a `404` will never recover, a `503` might.
99
+ **Returns:** `Promise<ActResult<T>>`always resolves, never rejects.
120
100
 
121
101
  ```ts
122
- const result = await act('api:resource', async (signal) => fetchResource(id, signal), {
123
- retry: {
124
- attempts: 4,
125
- delayMs: 150,
126
- backoff: 'exponential',
127
- shouldRetry: (err, attempt) => {
128
- if (err instanceof HttpError && err.status < 500) return false
129
- return true
130
- },
131
- },
132
- })
133
- ```
134
-
135
- `shouldRetry(error, attempt)` receives the error and the 1-based attempt number. Return `false` to surface the error immediately.
102
+ interface ActSuccess<T> {
103
+ ok: true
104
+ value: T
105
+ source: 'fresh' | 'cache'
106
+ attempts: number // 1-based; 0 on cache hit
107
+ traceId?: string // present when observability or traceId option set
108
+ durationMs?: number // wall-clock ms
109
+ }
136
110
 
137
- > **v1.1.5 change:** `shouldRetry` is now called on EVERY failure, including the final attempt. This preserves the predicate's contract for observers / metrics. The return value is only consulted when there are remaining attempts.
111
+ interface ActFailure {
112
+ ok: false
113
+ error: unknown // typed — see Error taxonomy
114
+ attempts: number
115
+ traceId?: string
116
+ durationMs?: number
117
+ }
118
+ ```
138
119
 
139
- ---
120
+ ### `invalidate(key)`
140
121
 
141
- ### Timeout
122
+ Removes the cached value for `key` from the default module-level store. Returns `true` if a cache entry was removed.
142
123
 
143
- Per-attempt deadline. Each retry gets a fresh clock. Implemented via `AbortController` + race `act()` returns promptly even if `fn` ignores the signal (the underlying work may continue in the background, but the caller is unblocked).
124
+ Does **not** cancel in-flight dedupe entriesthose settle on their own.
144
125
 
145
126
  ```ts
146
- import { act, TimeoutError } from 'actly'
147
-
148
- const result = await act('geo:lookup', async (signal) => lookupCoords(ip, signal), {
149
- timeout: { ms: 3_000 },
150
- })
151
-
152
- if (!result.ok && result.error instanceof TimeoutError) {
153
- console.log(`timed out after ${result.error.ms}ms`)
154
- }
127
+ await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
128
+ // ... user updates their profile ...
129
+ invalidate('user:42') // next call re-fetches
155
130
  ```
156
131
 
157
- For cooperative cancellation, pass `signal` through to your async primitive (`fetch`, `AbortController`, etc.). `actly` will abort the signal when the deadline fires — `fn` rejects immediately, no background leak.
158
-
159
- ---
160
-
161
- ### Total timeout
162
-
163
- Hard budget over the **entire** operation — all attempts, delays, and per-attempt timeouts combined. The per-attempt `timeout` resets on retry; `totalTimeout` does not.
132
+ ### `withStore(store)`
164
133
 
165
- **v1.1.5 fix:** When `totalTimeout` fires, the inner retry loop is cancelled no more attempts fire, no more delays are slept. Previous versions would continue running `fn` in the background after `TotalTimeoutError` was returned to the caller.
134
+ Creates a scoped `act` function bound to an explicit store. Use for SSR request isolation, multi-tenant scenarios, or test isolation.
166
135
 
167
136
  ```ts
168
- import { act, TimeoutError, TotalTimeoutError } from 'actly'
137
+ import { withStore, InMemoryStore } from 'actly'
169
138
 
170
- const result = await act('search:query', async (signal) => runQuery(q, signal), {
171
- retry: { attempts: 5, delayMs: 100, backoff: 'linear' },
172
- timeout: { ms: 2_000 }, // per attempt
173
- totalTimeout: { ms: 8_000 }, // whole operation
174
- })
139
+ const store = new InMemoryStore({ maxSize: 1000, autoCleanup: true })
140
+ const act = withStore(store)
175
141
 
176
- if (!result.ok) {
177
- if (result.error instanceof TotalTimeoutError) {
178
- console.log(`budget exhausted after ${result.error.ms}ms`)
179
- } else if (result.error instanceof TimeoutError) {
180
- console.log(`last attempt timed out after ${result.error.ms}ms`)
181
- }
142
+ try {
143
+ await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
144
+ act.invalidate('user:42')
145
+ } finally {
146
+ store.destroy()
182
147
  }
183
148
  ```
184
149
 
185
- `TotalTimeoutError` and `TimeoutError` are separate classes `instanceof` distinguishes which deadline fired.
150
+ For sync stores, `invalidate` returns `boolean`. For async stores, returns `Promise<boolean>`.
186
151
 
187
- ---
152
+ ### `execute(input)`
188
153
 
189
- ### Dedupe
154
+ Low-level execution engine for building custom policy chains. Accepts `{ key, fn, policies, store, meta, signal, observability }`. Most callers should use `act()` instead.
190
155
 
191
- Concurrent calls with the same key collapse into one in-flight Promise. The first caller executes; the rest wait and receive the same result. After settlement, the next call starts fresh.
156
+ ### `InMemoryStore`
157
+
158
+ Reference `SyncStateStore` implementation. Bounded LRU with TTL, background cleanup, O(1) operations.
192
159
 
193
160
  ```ts
194
- const result = await act('config:load', async (signal) => loadRemoteConfig(signal), {
195
- dedupe: true,
161
+ import { InMemoryStore } from 'actly'
162
+
163
+ const store = new InMemoryStore({
164
+ maxSize: 10_000, // default Infinity
165
+ autoCleanup: true, // default false
166
+ cleanupIntervalMs: 30_000 // default 30s
196
167
  })
197
168
  ```
198
169
 
199
- `dedupe: { enabled: true }` is also valid — object form for forward compatibility.
200
-
201
- #### v1.1.5 fixes
202
-
203
- - **Shared `attempts`**: joiners now mirror the originator's attempt count. If the originator retried twice before succeeding, all joiners report `attempts: 3` (was `1` in v1.0 — a misleading default).
204
- - **Abort safety**: joiners race the in-flight promise against their own `AbortSignal`. If a joiner's signal aborts (e.g. their `totalTimeout` fires), they reject immediately — they don't block on a hung originator.
205
- - **In-flight TTL** (optional): `dedupe: { enabled: true, inflightTtl: 30_000 }` removes the store entry after 30s if the originator never settles, so subsequent callers can start fresh. Pair with `timeout` / `totalTimeout` for proper cancellation in production.
206
-
207
170
  ---
208
171
 
209
- ### Cache
210
-
211
- Stores successful results for `ttl` milliseconds. Failures are never cached. A cache hit short-circuits everything — dedupe, retry, and timeout are all skipped.
212
-
213
- ```ts
214
- const result = await act('flags:all', async (signal) => fetchFeatureFlags(signal), {
215
- cache: { ttl: 60_000 },
216
- })
217
-
218
- console.log(result.source) // 'fresh' on miss, 'cache' on hit
219
- console.log(result.attempts) // 1 on miss, 0 on hit (v1.1.5 fix)
220
- ```
221
-
222
- #### v1.1.5 fixes
172
+ ## Options
223
173
 
224
- - **Single-flight (cache stampede prevention)**: on a sync store, concurrent cache misses for the same key now collapse into a single `fn` invocation. Previously, 10 concurrent callers would all run `fn` (10x redundant work). Now they share one in-flight Promise.
225
- - **`attempts: 0` on cache hit**: cache hits now report `attempts: 0` (was `1` in v1.0), consistent with the documented meaning ("number of attempts made before success").
226
- - **Fail-open writes**: if `store.set()` throws (e.g. Redis transient error), the value is still returned to the caller. Caching is an optimisation, not a correctness requirement.
174
+ ### `retry`
227
175
 
228
- #### Invalidation
176
+ Retry `fn` on failure.
229
177
 
230
178
  ```ts
231
- import { act, invalidate } from 'actly'
232
-
233
- await act('user:42', async (signal) => fetchUser(42, signal), { cache: { ttl: 60_000 } })
234
- // ... user updates their profile ...
235
- invalidate('user:42') // returns true if a cache entry was removed
236
- await act('user:42', ...) // re-fetches
179
+ {
180
+ retry: {
181
+ attempts: 3, // total attempts including first call (≥1, ≤100)
182
+ delayMs: 200, // base delay (≥0, ≤300_000)
183
+ backoff: 'exponential', // 'none' | 'linear' | 'exponential' (default 'none')
184
+ maxDelay: 30_000, // cap on computed delay (default Infinity)
185
+ jitter: 'full', // 'none' | 'full' | 'equal' | 'decorrelated' (default 'full')
186
+ shouldRetry: (error, attempt) => true // default: retry all except AbortError
187
+ }
188
+ }
237
189
  ```
238
190
 
239
- For scoped stores (see `withStore` below), use the scoped `invalidate`:
191
+ **Delay computation order:** backoff grows maxDelay caps jitter randomizes.
240
192
 
241
- ```ts
242
- const scopedAct = withStore(myStore)
243
- scopedAct.invalidate('user:42') // sync store: returns boolean
244
- // async store: returns Promise<boolean>
245
- ```
193
+ **Default `shouldRetry`:** retries on every error except `AbortError` (caller/timeout cancellation). Override to skip non-recoverable errors (HTTP 4xx, auth failures).
246
194
 
247
- ---
195
+ **`attempts: 1` is a no-op** — the retry policy is skipped (pure overhead if included).
196
+
197
+ ### `timeout`
248
198
 
249
- ### Combining policies
199
+ Per-attempt deadline. Each retry gets a fresh clock.
250
200
 
251
201
  ```ts
252
- const result = await act('product:detail', async (signal) => fetchProduct(id, signal), {
253
- totalTimeout: { ms: 10_000 }, // outermost wall
254
- cache: { ttl: 30_000 }, // short-circuits on hit
255
- dedupe: true, // collapses concurrent calls
256
- retry: { attempts: 3, delayMs: 100, backoff: 'linear' },
257
- timeout: { ms: 3_000 }, // per attempt
258
- signal: userSignal, // caller-provided cancellation
259
- })
202
+ { timeout: { ms: 5_000 } } // ms > 0, ≤100_000_000
260
203
  ```
261
204
 
262
- ---
205
+ If `fn` cooperates (passes `signal` to `fetch`, database drivers, etc.), the underlying work is cancelled. If `fn` ignores the signal, `act()` still returns promptly via a race — but the underlying work continues in the background (resource leak).
263
206
 
264
- ## Cancellation: `options.signal`
207
+ ### `totalTimeout`
265
208
 
266
- `act()` accepts an `AbortSignal` for caller-driven cancellation:
209
+ Hard wall-clock budget over the entire operation — including all retry attempts, delays, and per-attempt timeouts.
267
210
 
268
211
  ```ts
269
- const controller = new AbortController()
270
- const promise = act('search:big', async (signal) => runBigQuery(q, signal), {
271
- signal: controller.signal,
272
- timeout: { ms: 30_000 },
273
- })
274
-
275
- // User clicks "Cancel":
276
- controller.abort(new Error('user-cancelled'))
277
-
278
- const r = await promise
279
- // r.ok === false, r.error.message === 'user-cancelled'
212
+ { totalTimeout: { ms: 12_000 } }
280
213
  ```
281
214
 
282
- When `signal` aborts:
283
- - If the operation hasn't started, `act()` returns immediately with `attempts: 0` and the signal's `reason` as the error.
284
- - If it's in progress, `fn`'s inner signal aborts (cooperative). `fn` should propagate the signal to its primitives (`fetch`, `AbortController`, etc.) for proper cleanup.
285
- - If it has already settled, the result is returned as normal.
286
-
287
- The signal propagates through the entire chain: `totalTimeout`, `retry` (interrupts delay sleeps), and `timeout` all observe it. No more attempts fire after abort.
215
+ Use with `timeout` to express: "each attempt may take at most X ms, but the whole operation must finish within Y ms."
288
216
 
289
- ---
217
+ ### `dedupe`
290
218
 
291
- ## Options reference
219
+ Collapse concurrent calls with the same key into one in-flight Promise.
292
220
 
293
221
  ```ts
294
- interface ActOptions {
295
- retry?: RetryOptions
296
- timeout?: TimeoutOptions // per-attempt
297
- totalTimeout?: TimeoutOptions // entire operation
298
- dedupe?: boolean | DedupeOptions
299
- cache?: CacheOptions
300
- signal?: AbortSignal // caller-provided cancellation
301
- }
302
-
303
- interface RetryOptions {
304
- attempts: number // integer >= 1
305
- delayMs?: number // non-negative finite
306
- backoff?: 'none' | 'linear' | 'exponential'
307
- maxDelay?: number // cap, default Infinity
308
- jitter?: 'none' | 'full' | 'equal' | 'decorrelated' // default 'full'
309
- shouldRetry?: (error: unknown, attempt: number) => boolean
310
- }
311
-
312
- interface TimeoutOptions { ms: number } // positive finite
313
- interface DedupeOptions {
314
- enabled: boolean
315
- inflightTtl?: number // safety-net TTL for in-flight entry, default Infinity
316
- }
317
- interface CacheOptions { ttl: number } // positive finite
222
+ dedupe: true
223
+ // or
224
+ dedupe: { enabled: true, inflightTtl: 30_000 }
318
225
  ```
319
226
 
320
- All options are validated upfront. Invalid input (negative `attempts`, non-positive `ms`, empty `key`, etc.) throws `RangeError` or `TypeError` these are programmer errors, not runtime failures, so they surface as thrown exceptions rather than `ActFailure`.
227
+ `inflightTtl` is a safety-net TTL for the in-flight entry. If the originator's promise doesn't settle within this window, the entry is removed so subsequent callers can start fresh. Default: `Infinity` (no safety net). Pair with `timeout` or `totalTimeout` for proper cancellation.
321
228
 
322
- ---
229
+ **Joiner isolation:** if the originator's caller aborts, joiners are NOT affected. Each joiner races the in-flight promise against their own signal.
323
230
 
324
- ## Exported values
231
+ **Requires sync store.** `execute()` throws at chain-build time if an async store is used with dedupe.
325
232
 
326
- ```ts
327
- import {
328
- // Primary API
329
- act, // execute with reliability policies
330
- invalidate, // clear cache for a key on the default store
331
- withStore, // create a scoped act() with explicit store
332
-
333
- // Execution engine (for custom policy chains)
334
- execute, // run a policy chain with explicit store + signal
335
- REQUIRES_SYNC_STORE, // symbol stamped on dedupe policy
336
-
337
- // Stores
338
- InMemoryStore, // sync store with LRU + maxSize + autoCleanup
339
- isSyncStore, // type guard
340
- isAsyncStore, // type guard
341
-
342
- // Error classes
343
- TimeoutError, // per-attempt deadline (.ms)
344
- TotalTimeoutError, // total budget exhausted (.ms)
345
- } from 'actly'
346
- ```
233
+ ### `cache`
347
234
 
348
- ---
349
-
350
- ## Exported types
235
+ Store successful results for a TTL.
351
236
 
352
237
  ```ts
353
- import type {
354
- ActResult, ActSuccess, ActFailure, ActSource,
355
- ActOptions, ActFn,
356
- RetryOptions, TimeoutOptions, DedupeOptions, CacheOptions,
357
- // Policy internals (for custom policy authors)
358
- PolicyApplier, PolicyContext, RunMeta,
359
- // Store interfaces
360
- SyncStateStore,
361
- AsyncStateStore,
362
- AnyStateStore,
363
- InMemoryStoreOptions,
364
- // v1.0 alias — still valid, zero migration needed
365
- StateStore,
366
- // withStore result types
367
- ScopedActSync,
368
- ScopedActAsync,
369
- } from 'actly'
238
+ { cache: { ttl: 60_000 } } // ttl > 0, ≤86_400_000
370
239
  ```
371
240
 
372
- ---
241
+ **Failures are never cached.** Only successful values are stored.
373
242
 
374
- ## Custom store
243
+ **Single-flight (cache stampede prevention):** on sync stores, concurrent cache misses join a single in-flight Promise. On async stores, stampedes are a known limitation.
375
244
 
376
- By default `act` uses a module-level `InMemoryStore`. For SSR request isolation, multi-tenant scenarios, or test control, use `withStore()`:
245
+ **Fail-open writes:** if `store.set()` throws (e.g. Redis transient error), the error is swallowed and the value is returned to the caller. Caching is an optimization, not a correctness requirement.
377
246
 
378
- ### `withStore(store)`
247
+ ### `signal`
379
248
 
380
- Returns a scoped `act` function bound to the provided store, with an `invalidate(key)` method and a `store` reference attached.
249
+ Caller-provided `AbortSignal` for external cancellation.
381
250
 
382
251
  ```ts
383
- import { withStore, InMemoryStore } from 'actly'
384
-
385
- // Basic
386
- const store = new InMemoryStore()
387
- const act = withStore(store)
252
+ const controller = new AbortController()
253
+ setTimeout(() => controller.abort(new Error('user-cancelled')), 5_000)
388
254
 
389
- // With LRU + background cleanup (long-lived server store)
390
- const serverStore = new InMemoryStore({
391
- maxSize: 10_000, // evict least-recently-used when exceeded
392
- autoCleanup: true,
393
- cleanupIntervalMs: 60_000, // sweep every 60s (default: 30s)
255
+ await act('user:42', async (signal) => fetch(url, { signal }), {
256
+ signal: controller.signal,
394
257
  })
395
- const serverAct = withStore(serverStore)
258
+ ```
396
259
 
397
- try {
398
- await serverAct('user:42', async (signal) => fetchUser(42, signal), {
399
- cache: { ttl: 60_000 },
400
- })
260
+ ### `observability`
401
261
 
402
- // ... user updates their profile ...
403
- serverAct.invalidate('user:42') // next call re-fetches
262
+ Event hooks for metrics, logging, and tracing. Zero overhead when not registered.
404
263
 
405
- // Access the underlying store if needed:
406
- console.log(serverAct.store.size())
407
- } finally {
408
- serverStore.destroy() // stop the cleanup timer
264
+ ```ts
265
+ {
266
+ observability: {
267
+ onAttempt: (e) => metrics.increment('act.attempt', { attempt: e.attempt }),
268
+ onRetry: (e) => metrics.increment('act.retry', { attempt: e.attempt }),
269
+ onCacheHit: (e) => metrics.increment('act.cache_hit', { ageMs: e.ageMs }),
270
+ onCacheMiss: (e) => metrics.increment('act.cache_miss'),
271
+ onDedupeJoin: (e) => metrics.increment('act.dedupe_join'),
272
+ onTimeout: (e) => metrics.increment('act.timeout', { kind: e.kind }),
273
+ onFinalSuccess: (e) => metrics.histogram('act.duration', e.durationMs),
274
+ onFinalFailure: (e) => logger.error({ traceId: e.traceId, failedBy: e.failedBy }, 'act failed'),
275
+ }
409
276
  }
410
277
  ```
411
278
 
412
- For sync stores, `invalidate` returns `boolean` synchronously.
413
- For async stores, `invalidate` returns `Promise<boolean>`.
279
+ **Zero-cost contract:** when `observability` is `undefined` or an empty hooks object, no event objects are allocated and no function calls are made. Verified by benchmark: empty observability adds ~0.24 µs over fast path.
414
280
 
415
- ### `execute()` directly
281
+ ### `traceId`
416
282
 
417
- For full control (custom policy chains, custom meta, custom signal composition), call `execute()` directly:
283
+ Correlation ID for logs/metrics. Auto-generated via `crypto.randomUUID()` on Node 20+. Falls back to timestamp+random string. Surfaces on every event and on `ActResult.traceId`.
418
284
 
419
- ```ts
420
- import { execute, InMemoryStore, retryPolicy, timeoutPolicy } from 'actly'
421
-
422
- const store = new InMemoryStore()
423
- const meta = { attempts: 1, source: 'fresh' as const }
424
-
425
- const value = await execute({
426
- key: 'custom:chain',
427
- fn: async (signal) => fetchThing(signal),
428
- policies: [
429
- retryPolicy({ attempts: 3, delayMs: 100 }),
430
- timeoutPolicy({ ms: 5_000 }),
431
- ],
432
- store,
433
- meta,
434
- signal: new AbortController().signal,
435
- })
285
+ ---
436
286
 
437
- console.log(meta.attempts) // final attempt count
438
- ```
287
+ ## Error taxonomy
439
288
 
440
- ### InMemoryStore options
289
+ Six error classes. All extend `ActlyError` which extends `Error`. Each carries a stable `.code` string for cross-realm telemetry (e.g. errors serialized over IPC).
441
290
 
442
291
  ```ts
443
- new InMemoryStore({
444
- // Periodically sweep expired entries. Default: false (lazy eviction on access).
445
- autoCleanup?: boolean,
446
- cleanupIntervalMs?: number, // default 30_000
447
-
448
- // Bounded cache with LRU eviction. Default: Infinity (unbounded).
449
- // On set(), if size would exceed maxSize, the least-recently-used entry
450
- // is evicted before the new one is inserted.
451
- maxSize?: number,
452
- })
292
+ import {
293
+ ActlyError, // abstract base instanceof ActlyError
294
+ ActlyAbortError, // .code = 'ACTLY_ABORT' — caller/signal cancellation
295
+ TimeoutError, // .code = 'ACTLY_TIMEOUT' — per-attempt deadline
296
+ TotalTimeoutError, // .code = 'ACTLY_TOTAL_TIMEOUT' — operation-wide budget
297
+ RetryExhaustedError, // .code = 'ACTLY_RETRY_EXHAUSTED' all attempts failed
298
+ ValidationError, // .code = 'ACTLY_VALIDATION' programmer error (invalid options)
299
+ } from 'actly'
453
300
  ```
454
301
 
455
- `InMemoryStore` satisfies `SyncStateStore`. Call `destroy()` to stop the background timer and prevent leaks.
456
-
457
- ### Async store (v1.1+)
302
+ **`ValidationError` throws synchronously** it's not wrapped in `ActFailure` because the caller's code is broken, not the runtime.
458
303
 
459
- For external cache backends (Redis, Upstash, etc.), implement `AsyncStateStore`:
304
+ **`RetryExhaustedError`** carries `attempts`, `lastError`, and `errors[]` for debugging retry patterns.
460
305
 
461
306
  ```ts
462
- import type { AsyncStateStore } from 'actly'
463
-
464
- class RedisStore implements AsyncStateStore {
465
- readonly _sync = false as const
466
-
467
- async get<T>(key: string): Promise<T | undefined> { /* ... */ }
468
- async set<T>(key: string, value: T, ttlMs?: number): Promise<void> { /* ... */ }
469
- async delete(key: string): Promise<void> { /* ... */ }
470
- async has(key: string): Promise<boolean> { /* ... */ }
471
- async clear(): Promise<void> { /* ... */ }
472
- async size(): Promise<number> { /* ... */ }
307
+ if (!result.ok && result.error instanceof RetryExhaustedError) {
308
+ console.log(`failed after ${result.error.attempts} attempts`)
309
+ console.log(`last error:`, result.error.lastError)
473
310
  }
474
311
  ```
475
312
 
476
- `AsyncStateStore` is compatible with `cache` only. Using it with `dedupe` is a TypeScript error and a runtime error — `execute()` throws at chain-build time. Dedupe requires synchronous store access (the read-then-write that makes deduplication work must happen in a single tick; an async `await` between `get()` and `set()` would let two concurrent callers both see a miss).
313
+ **Cross-realm safety:** prefer `err.code === 'ACTLY_TIMEOUT'` over `err instanceof TimeoutError` when errors cross process/worker boundaries.
477
314
 
478
315
  ---
479
316
 
480
- ## Store interfaces
317
+ ## Stores
481
318
 
482
- ```ts
483
- interface SyncStateStore {
484
- readonly _sync: true
485
- get<T>(key: string): T | undefined
486
- set<T>(key: string, value: T, ttlMs?: number): void
487
- delete(key: string): void
488
- has(key: string): boolean
489
- clear(): void
490
- size(): number
491
- }
319
+ ### Sync vs Async
320
+
321
+ `SyncStateStore` all operations complete synchronously. Required for `dedupePolicy` (the read-then-write that makes deduplication work must happen in a single synchronous frame).
322
+
323
+ `AsyncStateStore` all operations return Promises. Compatible with `cachePolicy` only.
492
324
 
493
- interface AsyncStateStore {
494
- readonly _sync: false
495
- get<T>(key: string): Promise<T | undefined>
496
- set<T>(key: string, value: T, ttlMs?: number): Promise<void>
497
- delete(key: string): Promise<void>
498
- has(key: string): Promise<boolean>
499
- clear(): Promise<void>
500
- size(): Promise<number>
325
+ ```ts
326
+ import type { SyncStateStore, AsyncStateStore } from 'actly'
327
+
328
+ // Sync: InMemoryStore is the reference implementation
329
+ const syncStore: SyncStateStore = new InMemoryStore()
330
+
331
+ // Async: implement the interface for Redis, DynamoDB, etc.
332
+ const asyncStore: AsyncStateStore = {
333
+ _sync: false as const,
334
+ async get<T>(key: string): Promise<T | undefined> { /* ... */ },
335
+ async set<T>(key: string, value: T, ttlMs?: number): Promise<void> { /* ... */ },
336
+ async delete(key: string): Promise<void> { /* ... */ },
337
+ async has(key: string): Promise<boolean> { /* ... */ },
338
+ async clear(): Promise<void> { /* ... */ },
339
+ async size(): Promise<number> { /* ... */ },
501
340
  }
502
341
  ```
503
342
 
504
- The `_sync` discriminant is read at runtime by `execute()` to enforce the dedupe constraint. Set it as a `readonly` literal — `true as const` or `false as const`.
343
+ ### Default store
505
344
 
506
- ---
345
+ `act()` without `withStore()` uses a module-level default `InMemoryStore` bounded at 10,000 entries with 60-second background cleanup.
507
346
 
508
- ## Zero-throw contract
347
+ **Multi-tenant warning:** the default store is shared across all callers in the process. For multi-tenant applications, use `withStore()` with per-tenant stores to prevent cross-tenant data leakage.
509
348
 
510
- `act()` always resolves. Under any condition — network error, thrown exception, timeout, total budget exhaustion, caller abort — the result is an `ActFailure`, not a rejected promise. This contract holds across all versions.
349
+ ### `InMemoryStore` internals
511
350
 
512
- The only exceptions are programmer errors (invalid options, invalid keys) these throw synchronously so bugs surface at development time rather than degrading silently in production.
351
+ - **LRU:** doubly-linked list for O(1) reordering (no `delete + set` Map churn).
352
+ - **TTL:** lazy on `get()`/`has()`, background sweep for unreferenced entries.
353
+ - **`size()`:** O(1) via Map size counter.
354
+ - **`destroy():** stops cleanup timer. Idempotent.
513
355
 
514
356
  ---
515
357
 
516
- ## Philosophy
358
+ ## Policy order
517
359
 
518
- One function. One return type. No exceptions in userland.
360
+ Policies are applied in a fixed order, outermost to innermost:
519
361
 
520
- The key is your responsibility. Make it stable and specific. `user:42` is good. `fetch` is not. (Empty keys and reserved prefixes are rejected — see validation.)
362
+ ```
363
+ totalTimeout → cache → dedupe → retry → timeout → fn
364
+ ```
521
365
 
522
- Policies are composable but intentionally constrained. There is no builder API, no middleware system, no hooks. If you need something `act` doesn't do, write a wrapper around it — that's the right boundary.
366
+ - `totalTimeout` is outermost if it fires, no inner policy can extend the deadline.
367
+ - `cache` short-circuits everything below it on a hit.
368
+ - `dedupe` collapses concurrent callers before retry fires.
369
+ - `retry` owns the attempt loop.
370
+ - `timeout` is innermost — each attempt gets a fresh clock.
523
371
 
524
- Cancellation is cooperative: pass the `signal` through. `act()` will return promptly regardless (via internal race), but only you can stop `fn` from leaking resources.
372
+ Policies with no effect (e.g. `retry.attempts: 1`) are skipped.
525
373
 
526
374
  ---
527
375
 
528
- ## Changelog
529
-
530
- ### v1.1.5 — 2026-06-25
531
-
532
- **Critical correctness fixes**
533
-
534
- - **`totalTimeout` now cancels the inner retry loop.** Previously, when `totalTimeout` fired, `act()` returned `TotalTimeoutError` to the caller but the inner `retryPolicy` continued firing attempts and sleeping delays in the background — leaking resources for the full retry budget. Now, the abort propagates through the chain: no more attempts fire, delay sleeps reject immediately. *(Fixes C-1.)*
535
- - **`timeout` (per-attempt) cancels `fn` via `AbortSignal`.** `fn` now receives a signal that aborts when the per-attempt deadline fires. If `fn` cooperates (passes signal to `fetch`, `AbortController`, etc.), the underlying work is cancelled properly. `act()` returns promptly even if `fn` ignores the signal (via internal race). *(Fixes C-2.)*
536
- - **Dedupe joiners can abort independently.** Previously, if the originator's `fn` hung, all joiners blocked forever waiting for the in-flight promise. Now, joiners race the in-flight promise against their own `AbortSignal` — they reject immediately when their signal aborts, without waiting for the originator. *(Fixes C-3.)*
537
- - **Cache stampede prevention (single-flight).** Concurrent cache misses for the same key on a sync store now collapse into a single `fn` invocation. Previously, 10 concurrent callers would all run `fn` (10x redundant work). Now they share one in-flight Promise via an internal `__inflight:cache:<key>` slot. *(Fixes C-4.)*
538
- - **Dedupe joiners mirror originator's `attempts`.** Previously, joiners reported `attempts: 1` regardless of how many retries the originator performed. Now, joiners copy the originator's final `attempts` and `source` from a shared `RunMeta` reference. *(Fixes C-5.)*
539
- - **Cache hit reports `attempts: 0`.** Previously, cache hits reported `attempts: 1`, inconsistent with the documented meaning ("number of attempts made before success"). Now: `0` on hit, `1+` on miss. *(Fixes C-6.)*
540
-
541
- **Public API additions (fixes A-1, A-2, m-8)**
376
+ ## Limits
542
377
 
543
- - **`execute()` is now exported.** Call it directly with a custom policy chain, store, meta, and signal for full control (SSR isolation, multi-tenant scenarios, custom policy composition).
544
- - **`isSyncStore()` / `isAsyncStore()` are now exported.** Type guards for `AnyStateStore`, useful when building custom policy chains or store adapters.
545
- - **`invalidate(key)`** clears cache entries on the default store. Returns `boolean` (true if an entry was removed).
546
- - **`withStore(store)`** returns a scoped `act` function bound to an explicit store, with `invalidate(key)` and `store` attached. Overloads for sync (`invalidate: boolean`) vs async (`invalidate: Promise<boolean>`) stores.
547
- - **`REQUIRES_SYNC_STORE` symbol** is now exported for consumers building custom policy chains that need to declare sync-store requirements.
548
- - **`ScopedActSync` / `ScopedActAsync` types** exported for typed `withStore` results.
549
- - **`PolicyApplier`, `PolicyContext`, `RunMeta`, `AnyStateStore`** are now exported as public types (previously internal).
378
+ All numeric inputs are bounded to prevent memory/CPU/timer exhaustion:
550
379
 
551
- **New options**
380
+ | Limit | Value | Rationale |
381
+ |-------|-------|-----------|
382
+ | `MAX_KEY_LENGTH` | 1024 chars | Prevents multi-MB keys bloating stores |
383
+ | `MAX_RETRY_ATTEMPTS` | 100 | ~100 fn invocations |
384
+ | `MAX_TIMEOUT_MS` | 100,000,000 ms (~27h) | Prevents timer overflow |
385
+ | `MAX_CACHE_TTL` | 86,400,000 ms (24h) | Prevents pathological TTLs |
386
+ | `MAX_RETRY_DELAY_MS` | 300,000 ms (5 min) | Bounds worst-case latency per retry |
387
+ | `MAX_INFLIGHT_TTL` | 86,400,000 ms (24h) | Bounds dedupe inflight window |
388
+ | `DEFAULT_STORE_MAX_SIZE` | 10,000 entries | Bounds default store memory |
552
389
 
553
- - **`options.signal: AbortSignal`** caller-provided cancellation signal. Propagates through the entire chain: `totalTimeout`, `retry` (interrupts delay sleeps), and `timeout` all observe it. `fn` receives the composite signal.
554
- - **`RetryOptions.maxDelay`** — cap on computed delay. Prevents 8.5-minute waits on long exponential chains (`delayMs: 1000, attempts: 10, exponential` → 256s between attempts 9 and 10 without a cap).
555
- - **`RetryOptions.jitter`** — jitter strategy: `'none' | 'full' | 'equal' | 'decorrelated'`. Default: `'full'` (random in `[0, delay]`). Prevents thundering-herd retry storms.
556
- - **`DedupeOptions.inflightTtl`** — safety-net TTL for the in-flight entry. If the originator never settles, subsequent callers can start fresh after the TTL expires.
557
- - **`InMemoryStoreOptions.maxSize`** — bounded cache with LRU eviction. On `set()`, if size would exceed `maxSize`, the least-recently-used entry is evicted before the new one is inserted. Default: `Infinity` (unbounded).
558
-
559
- **Behaviour changes (non-breaking)**
560
-
561
- - **`shouldRetry` is now called on every failure, including the final attempt.** Previously, it was skipped on the last attempt. The return value is still only consulted when there are remaining attempts — but observers / metrics now see every failure. *(Fixes M-2.)*
562
- - **Default `shouldRetry` skips abort errors.** Previously, the default retried on every error. Now it skips `AbortError` (which indicates cancellation — retrying would just abort again, wasting delay budget). User-supplied `shouldRetry` overrides this entirely.
563
- - **Input validation throws on invalid options.** Empty keys, reserved prefixes (`dedupe:`, `cache:`, `__inflight:`), non-integer `attempts`, non-positive `ms`/`ttl`, non-finite delays — all throw `RangeError` or `TypeError` synchronously, rather than degrading silently. *(Fixes M-3, M-4.)*
564
- - **`RetryOptions.attempts: 1` is a documented no-op.** The retry policy is not added to the chain when `attempts <= 1` (would be pure overhead). This was already the behaviour; it's now explicitly documented.
565
-
566
- **Internal improvements**
567
-
568
- - **`ActFn<T>` signature changed to `(signal: AbortSignal) => Promise<T> | T`.** Backwards compatible: `() => Promise<T>` is assignable (signal arg is ignored). New code can opt-in to cooperative cancellation.
569
- - **`cachePolicy` fails open on `store.set()` errors.** If the cache write throws (e.g. Redis transient error), the value is still returned to the caller. Caching is an optimisation, not a correctness requirement. *(Fixes M-6.)*
570
- - **`InMemoryStore.size()` is now side-effect free** with respect to LRU order. Previously, it called `has()` (which delegated to `get()`), refreshing LRU positions. Now it scans without touching order. *(Fixes m-1.)*
571
- - **`InMemoryStore` implements LRU** via `Map` insertion-order semantics: `delete` + `set` on every access moves the key to the most-recent position. *(Fixes P-3.)*
572
- - **`retryPolicy` checks `parentSignal.aborted` before each attempt and before each delay sleep.** When `totalTimeout` or caller signal aborts, the retry loop bails immediately — no more attempts, no more delays. Delay sleeps use a signal-aware `sleep()` helper that rejects early on abort.
573
- - **`package.json` `engines.node` set to `>=18`** (was missing — README claimed Node 18+ but it wasn't enforced). *(Fixes D-7.)*
574
- - **`package.json` `sideEffects: false`** for tree-shaking. *(Fixes D-8.)*
575
- - **`package.json` `exports` field:** `types` condition is now first (was last) — required for correct TypeScript resolution in some bundlers. *(Fixes A-3.)*
576
- - **`tsconfig` updated:** target ES2022, `lib: ["ES2022"]`, `types: ["node"]`. Removed `DOM` lib (was unnecessary for a Node-targeted library). *(Fixes D-9, D-11, D-12.)*
577
- - **`tsconfig.cjs.json` `moduleResolution: "Node"`** (was `"Bundler"` — incompatible with `module: "CommonJS"`). *(Fixes D-9.)*
578
- - **Test suite added.** 64 tests covering: API surface, zero-throw contract, input validation, retry (jitter, maxDelay, shouldRetry on final attempt, abort-skip), timeout (cooperative + race fallback), totalTimeout (cancellation, delay interrupt), dedupe (collapse, shared meta, abort safety, hung originator), cache (single-flight, attempts=0, fail-open, TTL), invalidate, withStore (sync + async, isolation, scoped invalidate), InMemoryStore (LRU, maxSize, TTL, size() purity, destroy), AbortSignal integration, execute() public API. *(Fixes D-1.)*
579
-
580
- **Non-breaking changes**
581
-
582
- - `StateStore` preserved as type alias for `SyncStateStore` — all v1.0 code compiles without changes.
583
- - All new exports are purely additive.
584
- - `ActFn<T>` signature widened to accept `(signal) => ...` — existing `() => Promise<T>` is assignable.
585
- - `withStore()` is a new function — no impact on existing `act()` callers.
390
+ Exceeding a limit throws `RangeError`. If you need more, you likely have a bug.
586
391
 
587
392
  ---
588
393
 
589
- ### v1.1.0 — 2026-06-01
394
+ ## Migration
590
395
 
591
- - `AsyncStateStore` interface for plug-in async backends (Redis, Upstash, Cloudflare KV).
592
- - `SyncStateStore` canonical name; `StateStore` preserved as alias.
593
- - `InMemoryStore` public export with `InMemoryStoreOptions` (`autoCleanup`, `cleanupIntervalMs`).
594
- - `TotalTimeoutError` class distinct from `TimeoutError`.
595
- - `totalTimeout` option for hard wall-clock budget.
596
- - `dedupe: true` shorthand.
597
- - `isSyncStore()` / `isAsyncStore()` type guards.
396
+ ### From v1.1.x to v1.2.0
598
397
 
599
- ---
398
+ **Node 20+ required** (was 18+). Node 18 reached EOL April 2025.
399
+
400
+ **`retryPolicy` now throws `RetryExhaustedError`** when all attempts fail AND at least one retry happened. Previously, the raw last error was surfaced. The raw error is available on `err.lastError` and `err.errors[]`. If `shouldRetry` returned `false` on the first attempt (no retries), the raw error is still surfaced — no wrapping.
600
401
 
601
- ### v1.0.1
402
+ **`TimeoutError` and `TotalTimeoutError` now extend `ActlyError`** (which extends `Error`). Existing `instanceof Error` and `instanceof TimeoutError` checks continue to work. Optional: switch to `err.code === 'ACTLY_TIMEOUT'` for cross-realm safety.
602
403
 
603
- - Initial stable release.
604
- - `act()`, `retry`, `timeout`, `totalTimeout`, `dedupe`, `cache`.
605
- - `TimeoutError` exported for `instanceof` checks.
606
- - Zero dependencies. ESM + CJS. Node 18+.
404
+ **`ActResult` has new optional fields**: `traceId?`, `durationMs?`. Existing code that reads only `ok`, `value`, `error`, `source`, `attempts` is unaffected.
607
405
 
608
406
  ---
609
407