actly 1.1.0 → 1.1.5

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 (70) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +284 -52
  3. package/dist/core/act.cjs +183 -35
  4. package/dist/core/act.d.ts +81 -10
  5. package/dist/core/act.d.ts.map +1 -1
  6. package/dist/core/act.js +181 -35
  7. package/dist/core/act.js.map +1 -1
  8. package/dist/core/executor.cjs +19 -9
  9. package/dist/core/executor.d.ts +25 -5
  10. package/dist/core/executor.d.ts.map +1 -1
  11. package/dist/core/executor.js +19 -9
  12. package/dist/core/executor.js.map +1 -1
  13. package/dist/index.cjs +13 -3
  14. package/dist/index.d.ts +7 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/policies/cache.cjs +85 -21
  19. package/dist/policies/cache.d.ts +29 -7
  20. package/dist/policies/cache.d.ts.map +1 -1
  21. package/dist/policies/cache.js +85 -21
  22. package/dist/policies/cache.js.map +1 -1
  23. package/dist/policies/dedupe.cjs +68 -20
  24. package/dist/policies/dedupe.d.ts +38 -13
  25. package/dist/policies/dedupe.d.ts.map +1 -1
  26. package/dist/policies/dedupe.js +68 -20
  27. package/dist/policies/dedupe.js.map +1 -1
  28. package/dist/policies/retry.cjs +65 -25
  29. package/dist/policies/retry.d.ts +25 -2
  30. package/dist/policies/retry.d.ts.map +1 -1
  31. package/dist/policies/retry.js +65 -25
  32. package/dist/policies/retry.js.map +1 -1
  33. package/dist/policies/timeout.cjs +87 -17
  34. package/dist/policies/timeout.d.ts +28 -8
  35. package/dist/policies/timeout.d.ts.map +1 -1
  36. package/dist/policies/timeout.js +87 -17
  37. package/dist/policies/timeout.js.map +1 -1
  38. package/dist/state/store.cjs +9 -2
  39. package/dist/state/store.d.ts +9 -0
  40. package/dist/state/store.d.ts.map +1 -1
  41. package/dist/state/store.js +9 -2
  42. package/dist/state/store.js.map +1 -1
  43. package/dist/stores/base.cjs +4 -4
  44. package/dist/stores/base.d.ts +32 -56
  45. package/dist/stores/base.d.ts.map +1 -1
  46. package/dist/stores/base.js +4 -4
  47. package/dist/stores/base.js.map +1 -1
  48. package/dist/stores/memory.cjs +77 -28
  49. package/dist/stores/memory.d.ts +44 -23
  50. package/dist/stores/memory.d.ts.map +1 -1
  51. package/dist/stores/memory.js +77 -28
  52. package/dist/stores/memory.js.map +1 -1
  53. package/dist/types/index.d.ts +141 -36
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/dist/utils/abort.cjs +142 -0
  56. package/dist/utils/abort.d.ts +55 -0
  57. package/dist/utils/abort.d.ts.map +1 -0
  58. package/dist/utils/abort.js +136 -0
  59. package/dist/utils/abort.js.map +1 -0
  60. package/dist/utils/backoff.cjs +43 -0
  61. package/dist/utils/backoff.d.ts +14 -0
  62. package/dist/utils/backoff.d.ts.map +1 -0
  63. package/dist/utils/backoff.js +41 -0
  64. package/dist/utils/backoff.js.map +1 -0
  65. package/dist/utils/validate.cjs +79 -0
  66. package/dist/utils/validate.d.ts +15 -0
  67. package/dist/utils/validate.d.ts.map +1 -0
  68. package/dist/utils/validate.js +72 -0
  69. package/dist/utils/validate.js.map +1 -0
  70. package/package.json +19 -37
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # actly
2
2
 
3
- **Reliability primitive for async functions.**
4
- Retry. Timeout. Dedupe. Cache. Composable, typed, zero-throw.
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.
5
5
 
6
6
  ```bash
7
7
  npm install actly
@@ -24,12 +24,22 @@ These patterns are solved the same way every time. `actly` solves them once.
24
24
  ```ts
25
25
  import { act } from 'actly'
26
26
 
27
- const result = await act('user:42', () => fetchUser(42))
27
+ const result = await act(
28
+ 'user:42',
29
+ async (signal) => fetch(`/api/users/42`, { signal }),
30
+ {
31
+ retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
32
+ timeout: { ms: 5_000 },
33
+ totalTimeout: { ms: 12_000 },
34
+ dedupe: true,
35
+ cache: { ttl: 60_000 },
36
+ },
37
+ )
28
38
 
29
39
  if (result.ok) {
30
40
  console.log(result.value) // T
31
41
  console.log(result.source) // 'fresh' | 'cache'
32
- console.log(result.attempts) // number
42
+ console.log(result.attempts) // number (0 on cache hit)
33
43
  } else {
34
44
  console.error(result.error) // unknown — never throws
35
45
  }
@@ -37,18 +47,20 @@ if (result.ok) {
37
47
 
38
48
  `act` always resolves. It never rejects. You check `.ok` and move on.
39
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
+
40
52
  ---
41
53
 
42
54
  ## API
43
55
 
44
56
  ```ts
45
- act<T>(key: string, fn: () => Promise<T>, options?: ActOptions): Promise<ActResult<T>>
57
+ act<T>(key: string, fn: ActFn<T>, options?: ActOptions): Promise<ActResult<T>>
46
58
  ```
47
59
 
48
60
  | Argument | Type | Description |
49
61
  |---|---|---|
50
- | `key` | `string` | Stable, unique identifier. Scopes dedupe and cache state. |
51
- | `fn` | `() => Promise<T>` | The async function to execute. |
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). |
52
64
  | `options` | `ActOptions` | All optional. See policies below. |
53
65
 
54
66
  ### Result shape
@@ -59,6 +71,12 @@ type ActResult<T> =
59
71
  | { ok: false; error: unknown; attempts: number }
60
72
  ```
61
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
+
62
80
  ---
63
81
 
64
82
  ## Policies
@@ -72,23 +90,36 @@ totalTimeout → cache → dedupe → retry → timeout
72
90
  ### Retry
73
91
 
74
92
  ```ts
75
- const result = await act('payments:charge', () => chargeCard(payload), {
93
+ const result = await act('payments:charge', async (signal) => chargeCard(payload, signal), {
76
94
  retry: {
77
- attempts: 3, // total attempts (including first)
95
+ attempts: 3, // total attempts (including first). Must be integer >= 1.
78
96
  delayMs: 200, // base delay between attempts (ms)
79
- backoff: 'exponential' // 'none' | 'linear' | 'exponential'
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')
80
100
  },
81
101
  })
82
102
  ```
83
103
 
84
104
  `result.attempts` tells you how many tries it took.
85
105
 
106
+ #### Jitter (default: `'full'`)
107
+
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]`.
109
+
110
+ | Strategy | Formula |
111
+ |---|---|
112
+ | `'none'` | `delay` |
113
+ | `'full'` | `random() * delay` |
114
+ | `'equal'` | `delay/2 + random() * delay/2` |
115
+ | `'decorrelated'` | `base + random() * (delay - base)` |
116
+
86
117
  #### Selective retry with `shouldRetry`
87
118
 
88
- By default, every error triggers a retry. Use `shouldRetry` to skip retrying permanent errors — a `404` will never recover, a `503` might.
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.
89
120
 
90
121
  ```ts
91
- const result = await act('api:resource', () => fetchResource(id), {
122
+ const result = await act('api:resource', async (signal) => fetchResource(id, signal), {
92
123
  retry: {
93
124
  attempts: 4,
94
125
  delayMs: 150,
@@ -101,18 +132,20 @@ const result = await act('api:resource', () => fetchResource(id), {
101
132
  })
102
133
  ```
103
134
 
104
- `shouldRetry(error, attempt)` receives the error and the 1-based attempt number. Return `false` to surface the error immediately. Not called on the final attempt — that always surfaces.
135
+ `shouldRetry(error, attempt)` receives the error and the 1-based attempt number. Return `false` to surface the error immediately.
136
+
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.
105
138
 
106
139
  ---
107
140
 
108
141
  ### Timeout
109
142
 
110
- Per-attempt deadline. Each retry gets a fresh clock.
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).
111
144
 
112
145
  ```ts
113
146
  import { act, TimeoutError } from 'actly'
114
147
 
115
- const result = await act('geo:lookup', () => lookupCoords(ip), {
148
+ const result = await act('geo:lookup', async (signal) => lookupCoords(ip, signal), {
116
149
  timeout: { ms: 3_000 },
117
150
  })
118
151
 
@@ -121,16 +154,20 @@ if (!result.ok && result.error instanceof TimeoutError) {
121
154
  }
122
155
  ```
123
156
 
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
+
124
159
  ---
125
160
 
126
161
  ### Total timeout
127
162
 
128
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.
129
164
 
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.
166
+
130
167
  ```ts
131
168
  import { act, TimeoutError, TotalTimeoutError } from 'actly'
132
169
 
133
- const result = await act('search:query', () => runQuery(q), {
170
+ const result = await act('search:query', async (signal) => runQuery(q, signal), {
134
171
  retry: { attempts: 5, delayMs: 100, backoff: 'linear' },
135
172
  timeout: { ms: 2_000 }, // per attempt
136
173
  totalTimeout: { ms: 8_000 }, // whole operation
@@ -154,14 +191,18 @@ if (!result.ok) {
154
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.
155
192
 
156
193
  ```ts
157
- const result = await act('config:load', () => loadRemoteConfig(), {
194
+ const result = await act('config:load', async (signal) => loadRemoteConfig(signal), {
158
195
  dedupe: true,
159
196
  })
160
197
  ```
161
198
 
162
199
  `dedupe: { enabled: true }` is also valid — object form for forward compatibility.
163
200
 
164
- > **Note:** Deduped callers (those that joined an in-flight Promise) always receive `attempts: 1` in their result, because the retry counter belongs to the originating call. This is a known trade-off.
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.
165
206
 
166
207
  ---
167
208
 
@@ -170,11 +211,37 @@ const result = await act('config:load', () => loadRemoteConfig(), {
170
211
  Stores successful results for `ttl` milliseconds. Failures are never cached. A cache hit short-circuits everything — dedupe, retry, and timeout are all skipped.
171
212
 
172
213
  ```ts
173
- const result = await act('flags:all', () => fetchFeatureFlags(), {
214
+ const result = await act('flags:all', async (signal) => fetchFeatureFlags(signal), {
174
215
  cache: { ttl: 60_000 },
175
216
  })
176
217
 
177
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
223
+
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.
227
+
228
+ #### Invalidation
229
+
230
+ ```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
237
+ ```
238
+
239
+ For scoped stores (see `withStore` below), use the scoped `invalidate`:
240
+
241
+ ```ts
242
+ const scopedAct = withStore(myStore)
243
+ scopedAct.invalidate('user:42') // sync store: returns boolean
244
+ // async store: returns Promise<boolean>
178
245
  ```
179
246
 
180
247
  ---
@@ -182,15 +249,43 @@ console.log(result.source) // 'fresh' on miss, 'cache' on hit
182
249
  ### Combining policies
183
250
 
184
251
  ```ts
185
- const result = await act('product:detail', () => fetchProduct(id), {
252
+ const result = await act('product:detail', async (signal) => fetchProduct(id, signal), {
186
253
  totalTimeout: { ms: 10_000 }, // outermost wall
187
254
  cache: { ttl: 30_000 }, // short-circuits on hit
188
255
  dedupe: true, // collapses concurrent calls
189
256
  retry: { attempts: 3, delayMs: 100, backoff: 'linear' },
190
257
  timeout: { ms: 3_000 }, // per attempt
258
+ signal: userSignal, // caller-provided cancellation
259
+ })
260
+ ```
261
+
262
+ ---
263
+
264
+ ## Cancellation: `options.signal`
265
+
266
+ `act()` accepts an `AbortSignal` for caller-driven cancellation:
267
+
268
+ ```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 },
191
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'
192
280
  ```
193
281
 
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.
288
+
194
289
  ---
195
290
 
196
291
  ## Options reference
@@ -202,28 +297,49 @@ interface ActOptions {
202
297
  totalTimeout?: TimeoutOptions // entire operation
203
298
  dedupe?: boolean | DedupeOptions
204
299
  cache?: CacheOptions
300
+ signal?: AbortSignal // caller-provided cancellation
205
301
  }
206
302
 
207
303
  interface RetryOptions {
208
- attempts: number
209
- delayMs?: number
304
+ attempts: number // integer >= 1
305
+ delayMs?: number // non-negative finite
210
306
  backoff?: 'none' | 'linear' | 'exponential'
307
+ maxDelay?: number // cap, default Infinity
308
+ jitter?: 'none' | 'full' | 'equal' | 'decorrelated' // default 'full'
211
309
  shouldRetry?: (error: unknown, attempt: number) => boolean
212
310
  }
213
311
 
214
- interface TimeoutOptions { ms: number }
215
- interface DedupeOptions { enabled: boolean }
216
- interface CacheOptions { ttl: number }
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
217
318
  ```
218
319
 
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`.
321
+
219
322
  ---
220
323
 
221
324
  ## Exported values
222
325
 
223
326
  ```ts
224
327
  import {
225
- act, // primary function
226
- InMemoryStore, // isolated store for SSR / tests
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
227
343
  TimeoutError, // per-attempt deadline (.ms)
228
344
  TotalTimeoutError, // total budget exhausted (.ms)
229
345
  } from 'actly'
@@ -238,12 +354,18 @@ import type {
238
354
  ActResult, ActSuccess, ActFailure, ActSource,
239
355
  ActOptions, ActFn,
240
356
  RetryOptions, TimeoutOptions, DedupeOptions, CacheOptions,
241
- // v1.1 store interfaces
357
+ // Policy internals (for custom policy authors)
358
+ PolicyApplier, PolicyContext, RunMeta,
359
+ // Store interfaces
242
360
  SyncStateStore,
243
361
  AsyncStateStore,
362
+ AnyStateStore,
244
363
  InMemoryStoreOptions,
245
364
  // v1.0 alias — still valid, zero migration needed
246
365
  StateStore,
366
+ // withStore result types
367
+ ScopedActSync,
368
+ ScopedActAsync,
247
369
  } from 'actly'
248
370
  ```
249
371
 
@@ -251,25 +373,86 @@ import type {
251
373
 
252
374
  ## Custom store
253
375
 
254
- By default `act` uses a module-level `InMemoryStore`. For SSR request isolation or test control, instantiate your own:
376
+ By default `act` uses a module-level `InMemoryStore`. For SSR request isolation, multi-tenant scenarios, or test control, use `withStore()`:
377
+
378
+ ### `withStore(store)`
379
+
380
+ Returns a scoped `act` function bound to the provided store, with an `invalidate(key)` method and a `store` reference attached.
255
381
 
256
382
  ```ts
257
- import { InMemoryStore } from 'actly'
383
+ import { withStore, InMemoryStore } from 'actly'
258
384
 
259
385
  // Basic
260
386
  const store = new InMemoryStore()
387
+ const act = withStore(store)
261
388
 
262
- // With background cleanup (useful for long-lived server-side stores)
263
- const store = new InMemoryStore({
389
+ // With LRU + background cleanup (long-lived server store)
390
+ const serverStore = new InMemoryStore({
391
+ maxSize: 10_000, // evict least-recently-used when exceeded
264
392
  autoCleanup: true,
265
393
  cleanupIntervalMs: 60_000, // sweep every 60s (default: 30s)
266
394
  })
395
+ const serverAct = withStore(serverStore)
396
+
397
+ try {
398
+ await serverAct('user:42', async (signal) => fetchUser(42, signal), {
399
+ cache: { ttl: 60_000 },
400
+ })
401
+
402
+ // ... user updates their profile ...
403
+ serverAct.invalidate('user:42') // next call re-fetches
404
+
405
+ // Access the underlying store if needed:
406
+ console.log(serverAct.store.size())
407
+ } finally {
408
+ serverStore.destroy() // stop the cleanup timer
409
+ }
410
+ ```
411
+
412
+ For sync stores, `invalidate` returns `boolean` synchronously.
413
+ For async stores, `invalidate` returns `Promise<boolean>`.
414
+
415
+ ### `execute()` directly
416
+
417
+ For full control (custom policy chains, custom meta, custom signal composition), call `execute()` directly:
418
+
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
+ })
436
+
437
+ console.log(meta.attempts) // final attempt count
438
+ ```
439
+
440
+ ### InMemoryStore options
267
441
 
268
- // Always call destroy() when done to prevent timer leaks
269
- store.destroy()
442
+ ```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
+ })
270
453
  ```
271
454
 
272
- `InMemoryStore` satisfies `SyncStateStore`. Use it with `execute()` directly for full control.
455
+ `InMemoryStore` satisfies `SyncStateStore`. Call `destroy()` to stop the background timer and prevent leaks.
273
456
 
274
457
  ### Async store (v1.1+)
275
458
 
@@ -290,7 +473,7 @@ class RedisStore implements AsyncStateStore {
290
473
  }
291
474
  ```
292
475
 
293
- > `AsyncStateStore` is compatible with `cache` only. Using it with `dedupe` is a TypeScript error and a runtime error — dedupe requires synchronous store access. See `SyncStateStore` docs for the reason.
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).
294
477
 
295
478
  ---
296
479
 
@@ -318,13 +501,15 @@ interface AsyncStateStore {
318
501
  }
319
502
  ```
320
503
 
321
- The `_sync` discriminant is read at runtime by the executor to enforce the dedupe constraint. Set it as a `readonly` literal — `true as const` or `false as const`.
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`.
322
505
 
323
506
  ---
324
507
 
325
508
  ## Zero-throw contract
326
509
 
327
- `act()` always resolves. Under any condition — network error, thrown exception, timeout, total budget exhaustion — the result is an `ActFailure`, not a rejected promise. This contract holds across all versions.
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.
511
+
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.
328
513
 
329
514
  ---
330
515
 
@@ -332,37 +517,84 @@ The `_sync` discriminant is read at runtime by the executor to enforce the dedup
332
517
 
333
518
  One function. One return type. No exceptions in userland.
334
519
 
335
- The key is your responsibility. Make it stable and specific. `user:42` is good. `fetch` is not.
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.)
336
521
 
337
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.
338
523
 
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.
525
+
339
526
  ---
340
527
 
341
528
  ## Changelog
342
529
 
343
- ### v1.1.0 — 2026-06-01
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.)*
344
540
 
345
- **New features**
541
+ **Public API additions (fixes A-1, A-2, m-8)**
346
542
 
347
- - **`AsyncStateStore` interface** plug in any async key-value backend (Redis, Upstash, Cloudflare KV). Compatible with `cache` policy. Using with `dedupe` is a compile-time and runtime error by design.
348
- - **`SyncStateStore` interface** the canonical public name for what was previously the internal store shape. `StateStore` is preserved as an alias with zero breakage for existing code.
349
- - **`InMemoryStore` is now a public export** construct isolated stores for SSR request isolation, per-test control, or multi-tenant scenarios. Accepts `InMemoryStoreOptions`.
350
- - **`InMemoryStoreOptions`** opt-in `autoCleanup` with configurable `cleanupIntervalMs`. Call `destroy()` to stop the background timer and prevent leaks.
351
- - **`TotalTimeoutError`** new error class thrown when `totalTimeout` fires. Distinct from `TimeoutError` (per-attempt) so `instanceof` tells you which deadline fired.
352
- - **`totalTimeout` option** hard wall-clock budget over the entire operation including all retry attempts and delays. Use alongside `timeout` to express both per-attempt and total constraints.
353
- - **`dedupe: true` shorthand** — equivalent to `dedupe: { enabled: true }`. Object form still valid for forward compatibility.
354
- - **`isSyncStore()` / `isAsyncStore()` type guards** — exported from `actly` for consumers building custom policy chains or store adapters.
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).
550
+
551
+ **New options**
552
+
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.
355
565
 
356
566
  **Internal improvements**
357
567
 
358
- - Executor validates sync-store requirement at chain-build time async store + `dedupePolicy` throws immediately with a clear message instead of producing silent correctness failures.
359
- - `dedupePolicy` tagged with `REQUIRES_SYNC_STORE` symbol allows executor to detect the constraint without importing the policy module (avoids circular deps).
360
- - `cachePolicy` now branches sync/async paths fast synchronous path for `InMemoryStore`, async-await path for external stores.
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.)*
361
579
 
362
580
  **Non-breaking changes**
363
581
 
364
582
  - `StateStore` preserved as type alias for `SyncStateStore` — all v1.0 code compiles without changes.
365
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.
586
+
587
+ ---
588
+
589
+ ### v1.1.0 — 2026-06-01
590
+
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.
366
598
 
367
599
  ---
368
600