actly 1.0.2 → 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 +1 -1
  2. package/README.md +612 -0
  3. package/dist/core/act.cjs +187 -37
  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 +184 -36
  7. package/dist/core/act.js.map +1 -1
  8. package/dist/core/executor.cjs +30 -4
  9. package/dist/core/executor.d.ts +33 -11
  10. package/dist/core/executor.d.ts.map +1 -1
  11. package/dist/core/executor.js +29 -4
  12. package/dist/core/executor.js.map +1 -1
  13. package/dist/index.cjs +15 -5
  14. package/dist/index.d.ts +9 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +9 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/policies/cache.cjs +91 -7
  19. package/dist/policies/cache.d.ts +30 -3
  20. package/dist/policies/cache.d.ts.map +1 -1
  21. package/dist/policies/cache.js +91 -7
  22. package/dist/policies/cache.js.map +1 -1
  23. package/dist/policies/dedupe.cjs +79 -16
  24. package/dist/policies/dedupe.d.ts +39 -7
  25. package/dist/policies/dedupe.d.ts.map +1 -1
  26. package/dist/policies/dedupe.js +79 -16
  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 +11 -38
  39. package/dist/state/store.d.ts +10 -12
  40. package/dist/state/store.d.ts.map +1 -1
  41. package/dist/state/store.js +10 -37
  42. package/dist/state/store.js.map +1 -1
  43. package/dist/stores/base.cjs +20 -0
  44. package/dist/stores/base.d.ts +88 -0
  45. package/dist/stores/base.d.ts.map +1 -0
  46. package/dist/stores/base.js +17 -0
  47. package/dist/stores/base.js.map +1 -0
  48. package/dist/stores/memory.cjs +140 -0
  49. package/dist/stores/memory.d.ts +79 -0
  50. package/dist/stores/memory.d.ts.map +1 -0
  51. package/dist/stores/memory.js +137 -0
  52. package/dist/stores/memory.js.map +1 -0
  53. package/dist/types/index.d.ts +151 -34
  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
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 ACT contributors
3
+ Copyright (c) 2026 ACT contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md ADDED
@@ -0,0 +1,612 @@
1
+ # actly
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.
5
+
6
+ ```bash
7
+ npm install actly
8
+ ```
9
+
10
+ > Requires Node 18+. Ships ESM + CJS. Zero dependencies.
11
+
12
+ ---
13
+
14
+ ## The problem
15
+
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
23
+
24
+ ```ts
25
+ import { act } from 'actly'
26
+
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
+ )
38
+
39
+ 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)
43
+ } else {
44
+ console.error(result.error) // unknown — never throws
45
+ }
46
+ ```
47
+
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
+ ---
53
+
54
+ ## API
55
+
56
+ ```ts
57
+ act<T>(key: string, fn: ActFn<T>, options?: ActOptions): Promise<ActResult<T>>
58
+ ```
59
+
60
+ | Argument | Type | Description |
61
+ |---|---|---|
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. |
65
+
66
+ ### Result shape
67
+
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
+ ---
81
+
82
+ ## Policies
83
+
84
+ All policies are optional and compose freely. The execution order is **fixed**:
85
+
86
+ ```
87
+ totalTimeout → cache → dedupe → retry → timeout
88
+ ```
89
+
90
+ ### Retry
91
+
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
+ ```
103
+
104
+ `result.attempts` tells you how many tries it took.
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
+
117
+ #### Selective retry with `shouldRetry`
118
+
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.
120
+
121
+ ```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.
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.
138
+
139
+ ---
140
+
141
+ ### Timeout
142
+
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).
144
+
145
+ ```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
+ }
155
+ ```
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
+
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.
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
+
167
+ ```ts
168
+ import { act, TimeoutError, TotalTimeoutError } from 'actly'
169
+
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
+ })
175
+
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
+ }
182
+ }
183
+ ```
184
+
185
+ `TotalTimeoutError` and `TimeoutError` are separate classes — `instanceof` distinguishes which deadline fired.
186
+
187
+ ---
188
+
189
+ ### Dedupe
190
+
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.
192
+
193
+ ```ts
194
+ const result = await act('config:load', async (signal) => loadRemoteConfig(signal), {
195
+ dedupe: true,
196
+ })
197
+ ```
198
+
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
+ ---
208
+
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
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>
245
+ ```
246
+
247
+ ---
248
+
249
+ ### Combining policies
250
+
251
+ ```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
+ })
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 },
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'
280
+ ```
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
+
289
+ ---
290
+
291
+ ## Options reference
292
+
293
+ ```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
318
+ ```
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
+
322
+ ---
323
+
324
+ ## Exported values
325
+
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
+ ```
347
+
348
+ ---
349
+
350
+ ## Exported types
351
+
352
+ ```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'
370
+ ```
371
+
372
+ ---
373
+
374
+ ## Custom store
375
+
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.
381
+
382
+ ```ts
383
+ import { withStore, InMemoryStore } from 'actly'
384
+
385
+ // Basic
386
+ const store = new InMemoryStore()
387
+ const act = withStore(store)
388
+
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)
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
441
+
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
+ })
453
+ ```
454
+
455
+ `InMemoryStore` satisfies `SyncStateStore`. Call `destroy()` to stop the background timer and prevent leaks.
456
+
457
+ ### Async store (v1.1+)
458
+
459
+ For external cache backends (Redis, Upstash, etc.), implement `AsyncStateStore`:
460
+
461
+ ```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> { /* ... */ }
473
+ }
474
+ ```
475
+
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).
477
+
478
+ ---
479
+
480
+ ## Store interfaces
481
+
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
+ }
492
+
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>
501
+ }
502
+ ```
503
+
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`.
505
+
506
+ ---
507
+
508
+ ## Zero-throw contract
509
+
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.
513
+
514
+ ---
515
+
516
+ ## Philosophy
517
+
518
+ One function. One return type. No exceptions in userland.
519
+
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.)
521
+
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.
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
+
526
+ ---
527
+
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)**
542
+
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.
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.
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.
598
+
599
+ ---
600
+
601
+ ### v1.0.1
602
+
603
+ - Initial stable release.
604
+ - `act()`, `retry`, `timeout`, `totalTimeout`, `dedupe`, `cache`.
605
+ - `TimeoutError` exported for `instanceof` checks.
606
+ - Zero dependencies. ESM + CJS. Node 18+.
607
+
608
+ ---
609
+
610
+ ## License
611
+
612
+ MIT