actly 1.1.0 → 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 (130) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +262 -232
  3. package/dist/core/act.cjs +505 -39
  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 +504 -40
  7. package/dist/core/act.js.map +1 -1
  8. package/dist/core/executor.cjs +21 -9
  9. package/dist/core/executor.d.ts +31 -6
  10. package/dist/core/executor.d.ts.map +1 -1
  11. package/dist/core/executor.js +21 -9
  12. package/dist/core/executor.js.map +1 -1
  13. package/dist/core/health.cjs +34 -0
  14. package/dist/core/health.d.ts +18 -0
  15. package/dist/core/health.d.ts.map +1 -0
  16. package/dist/core/health.js +28 -0
  17. package/dist/core/health.js.map +1 -0
  18. package/dist/core/shutdown.cjs +60 -0
  19. package/dist/core/shutdown.d.ts +8 -0
  20. package/dist/core/shutdown.d.ts.map +1 -0
  21. package/dist/core/shutdown.js +56 -0
  22. package/dist/core/shutdown.js.map +1 -0
  23. package/dist/core/tenant.cjs +71 -0
  24. package/dist/core/tenant.d.ts +29 -0
  25. package/dist/core/tenant.d.ts.map +1 -0
  26. package/dist/core/tenant.js +68 -0
  27. package/dist/core/tenant.js.map +1 -0
  28. package/dist/errors.cjs +182 -0
  29. package/dist/errors.d.ts +151 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +171 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/index.cjs +56 -6
  34. package/dist/index.d.ts +22 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +24 -4
  37. package/dist/index.js.map +1 -1
  38. package/dist/observability.cjs +45 -0
  39. package/dist/observability.d.ts +145 -0
  40. package/dist/observability.d.ts.map +1 -0
  41. package/dist/observability.js +43 -0
  42. package/dist/observability.js.map +1 -0
  43. package/dist/policies/bulkhead.cjs +97 -0
  44. package/dist/policies/bulkhead.d.ts +3 -0
  45. package/dist/policies/bulkhead.d.ts.map +1 -0
  46. package/dist/policies/bulkhead.js +95 -0
  47. package/dist/policies/bulkhead.js.map +1 -0
  48. package/dist/policies/cache.cjs +223 -22
  49. package/dist/policies/cache.d.ts +42 -7
  50. package/dist/policies/cache.d.ts.map +1 -1
  51. package/dist/policies/cache.js +223 -22
  52. package/dist/policies/cache.js.map +1 -1
  53. package/dist/policies/circuitBreaker.cjs +85 -0
  54. package/dist/policies/circuitBreaker.d.ts +3 -0
  55. package/dist/policies/circuitBreaker.d.ts.map +1 -0
  56. package/dist/policies/circuitBreaker.js +83 -0
  57. package/dist/policies/circuitBreaker.js.map +1 -0
  58. package/dist/policies/dedupe.cjs +98 -21
  59. package/dist/policies/dedupe.d.ts +20 -13
  60. package/dist/policies/dedupe.d.ts.map +1 -1
  61. package/dist/policies/dedupe.js +98 -21
  62. package/dist/policies/dedupe.js.map +1 -1
  63. package/dist/policies/rateLimit.cjs +35 -0
  64. package/dist/policies/rateLimit.d.ts +3 -0
  65. package/dist/policies/rateLimit.d.ts.map +1 -0
  66. package/dist/policies/rateLimit.js +33 -0
  67. package/dist/policies/rateLimit.js.map +1 -0
  68. package/dist/policies/retry.cjs +103 -26
  69. package/dist/policies/retry.d.ts +25 -2
  70. package/dist/policies/retry.d.ts.map +1 -1
  71. package/dist/policies/retry.js +103 -26
  72. package/dist/policies/retry.js.map +1 -1
  73. package/dist/policies/timeout.cjs +78 -36
  74. package/dist/policies/timeout.d.ts +7 -18
  75. package/dist/policies/timeout.d.ts.map +1 -1
  76. package/dist/policies/timeout.js +84 -31
  77. package/dist/policies/timeout.js.map +1 -1
  78. package/dist/state/store.cjs +9 -2
  79. package/dist/state/store.d.ts +9 -0
  80. package/dist/state/store.d.ts.map +1 -1
  81. package/dist/state/store.js +9 -2
  82. package/dist/state/store.js.map +1 -1
  83. package/dist/stores/base.cjs +4 -4
  84. package/dist/stores/base.d.ts +32 -56
  85. package/dist/stores/base.d.ts.map +1 -1
  86. package/dist/stores/base.js +4 -4
  87. package/dist/stores/base.js.map +1 -1
  88. package/dist/stores/memory.cjs +161 -39
  89. package/dist/stores/memory.d.ts +64 -24
  90. package/dist/stores/memory.d.ts.map +1 -1
  91. package/dist/stores/memory.js +160 -39
  92. package/dist/stores/memory.js.map +1 -1
  93. package/dist/types/index.d.ts +315 -37
  94. package/dist/types/index.d.ts.map +1 -1
  95. package/dist/utils/abort.cjs +212 -0
  96. package/dist/utils/abort.d.ts +79 -0
  97. package/dist/utils/abort.d.ts.map +1 -0
  98. package/dist/utils/abort.js +206 -0
  99. package/dist/utils/abort.js.map +1 -0
  100. package/dist/utils/abortPool.cjs +26 -0
  101. package/dist/utils/abortPool.d.ts +4 -0
  102. package/dist/utils/abortPool.d.ts.map +1 -0
  103. package/dist/utils/abortPool.js +22 -0
  104. package/dist/utils/abortPool.js.map +1 -0
  105. package/dist/utils/backoff.cjs +54 -0
  106. package/dist/utils/backoff.d.ts +14 -0
  107. package/dist/utils/backoff.d.ts.map +1 -0
  108. package/dist/utils/backoff.js +52 -0
  109. package/dist/utils/backoff.js.map +1 -0
  110. package/dist/utils/key.cjs +77 -0
  111. package/dist/utils/key.d.ts +29 -0
  112. package/dist/utils/key.d.ts.map +1 -0
  113. package/dist/utils/key.js +75 -0
  114. package/dist/utils/key.js.map +1 -0
  115. package/dist/utils/limits.cjs +40 -0
  116. package/dist/utils/limits.d.ts +39 -0
  117. package/dist/utils/limits.d.ts.map +1 -0
  118. package/dist/utils/limits.js +38 -0
  119. package/dist/utils/limits.js.map +1 -0
  120. package/dist/utils/sanitize.cjs +29 -0
  121. package/dist/utils/sanitize.d.ts +3 -0
  122. package/dist/utils/sanitize.d.ts.map +1 -0
  123. package/dist/utils/sanitize.js +26 -0
  124. package/dist/utils/sanitize.js.map +1 -0
  125. package/dist/utils/validate.cjs +153 -0
  126. package/dist/utils/validate.d.ts +26 -0
  127. package/dist/utils/validate.d.ts.map +1 -0
  128. package/dist/utils/validate.js +141 -0
  129. package/dist/utils/validate.js.map +1 -0
  130. package/package.json +20 -37
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -1,377 +1,407 @@
1
1
  # actly
2
2
 
3
- **Reliability primitive for async functions.**
4
- Retry. Timeout. Dedupe. Cache. Composable, typed, zero-throw.
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'
26
19
 
27
- const result = await act('user:42', () => fetchUser(42))
20
+ const result = await act(
21
+ 'user:42',
22
+ async (signal) => fetch(`/api/users/42`, { signal }),
23
+ {
24
+ retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
25
+ timeout: { ms: 5_000 },
26
+ totalTimeout: { ms: 12_000 },
27
+ dedupe: true,
28
+ cache: { ttl: 60_000 },
29
+ },
30
+ )
28
31
 
29
32
  if (result.ok) {
30
- console.log(result.value) // T
31
- console.log(result.source) // 'fresh' | 'cache'
32
- console.log(result.attempts) // number
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)
33
37
  } else {
34
- console.error(result.error) // unknownnever throws
38
+ console.error(result.error) // typedsee Error taxonomy
35
39
  }
36
40
  ```
37
41
 
38
- `act` always resolves. It never rejects. You check `.ok` and move on.
39
-
40
42
  ---
41
43
 
42
- ## API
44
+ ## Compared to Cockatiel
43
45
 
44
- ```ts
45
- act<T>(key: string, fn: () => Promise<T>, options?: ActOptions): Promise<ActResult<T>>
46
- ```
46
+ Both are typed, zero-dependency resilience libraries for Node/TypeScript. The core difference is scope and composition model.
47
47
 
48
- | Argument | Type | Description |
48
+ | | actly | Cockatiel |
49
49
  |---|---|---|
50
- | `key` | `string` | Stable, unique identifier. Scopes dedupe and cache state. |
51
- | `fn` | `() => Promise<T>` | The async function to execute. |
52
- | `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 |
53
64
 
54
- ### 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`.
55
66
 
56
- ```ts
57
- type ActResult<T> =
58
- | { ok: true; value: T; source: 'fresh' | 'cache'; attempts: number }
59
- | { ok: false; error: unknown; attempts: number }
60
- ```
67
+ ### Measured overhead: actly vs Cockatiel
68
+
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`.
70
+
71
+ Representative run (Node 20, single machine, trivial no-op `fn`, 20,000 iterations per case — see `bench/compare-cockatiel.mjs` for full methodology):
72
+
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 |
80
+
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.
82
+
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.
61
84
 
62
85
  ---
63
86
 
64
- ## Policies
87
+ ## API
65
88
 
66
- All policies are optional and compose freely. The execution order is **fixed**:
89
+ ### `act(key, fn, options?)`
67
90
 
68
- ```
69
- totalTimeout → cache → dedupe → retry → timeout
70
- ```
91
+ Wraps `fn` with reliability policies. Returns `Promise<ActResult<T>>`.
71
92
 
72
- ### Retry
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. |
98
+
99
+ **Returns:** `Promise<ActResult<T>>` — always resolves, never rejects.
73
100
 
74
101
  ```ts
75
- const result = await act('payments:charge', () => chargeCard(payload), {
76
- retry: {
77
- attempts: 3, // total attempts (including first)
78
- delayMs: 200, // base delay between attempts (ms)
79
- backoff: 'exponential' // 'none' | 'linear' | 'exponential'
80
- },
81
- })
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
+ }
110
+
111
+ interface ActFailure {
112
+ ok: false
113
+ error: unknown // typed — see Error taxonomy
114
+ attempts: number
115
+ traceId?: string
116
+ durationMs?: number
117
+ }
82
118
  ```
83
119
 
84
- `result.attempts` tells you how many tries it took.
120
+ ### `invalidate(key)`
85
121
 
86
- #### Selective retry with `shouldRetry`
122
+ Removes the cached value for `key` from the default module-level store. Returns `true` if a cache entry was removed.
87
123
 
88
- By default, every error triggers a retry. Use `shouldRetry` to skip retrying permanent errors a `404` will never recover, a `503` might.
124
+ Does **not** cancel in-flight dedupe entriesthose settle on their own.
89
125
 
90
126
  ```ts
91
- const result = await act('api:resource', () => fetchResource(id), {
92
- retry: {
93
- attempts: 4,
94
- delayMs: 150,
95
- backoff: 'exponential',
96
- shouldRetry: (err, attempt) => {
97
- if (err instanceof HttpError && err.status < 500) return false
98
- return true
99
- },
100
- },
101
- })
127
+ await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
128
+ // ... user updates their profile ...
129
+ invalidate('user:42') // next call re-fetches
102
130
  ```
103
131
 
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.
132
+ ### `withStore(store)`
105
133
 
106
- ---
107
-
108
- ### Timeout
109
-
110
- Per-attempt deadline. Each retry gets a fresh clock.
134
+ Creates a scoped `act` function bound to an explicit store. Use for SSR request isolation, multi-tenant scenarios, or test isolation.
111
135
 
112
136
  ```ts
113
- import { act, TimeoutError } from 'actly'
137
+ import { withStore, InMemoryStore } from 'actly'
114
138
 
115
- const result = await act('geo:lookup', () => lookupCoords(ip), {
116
- timeout: { ms: 3_000 },
117
- })
139
+ const store = new InMemoryStore({ maxSize: 1000, autoCleanup: true })
140
+ const act = withStore(store)
118
141
 
119
- if (!result.ok && result.error instanceof TimeoutError) {
120
- console.log(`timed out after ${result.error.ms}ms`)
142
+ try {
143
+ await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
144
+ act.invalidate('user:42')
145
+ } finally {
146
+ store.destroy()
121
147
  }
122
148
  ```
123
149
 
124
- ---
150
+ For sync stores, `invalidate` returns `boolean`. For async stores, returns `Promise<boolean>`.
125
151
 
126
- ### Total timeout
152
+ ### `execute(input)`
127
153
 
128
- Hard budget over the **entire** operation all attempts, delays, and per-attempt timeouts combined. The per-attempt `timeout` resets on retry; `totalTimeout` does not.
154
+ Low-level execution engine for building custom policy chains. Accepts `{ key, fn, policies, store, meta, signal, observability }`. Most callers should use `act()` instead.
155
+
156
+ ### `InMemoryStore`
157
+
158
+ Reference `SyncStateStore` implementation. Bounded LRU with TTL, background cleanup, O(1) operations.
129
159
 
130
160
  ```ts
131
- import { act, TimeoutError, TotalTimeoutError } from 'actly'
161
+ import { InMemoryStore } from 'actly'
132
162
 
133
- const result = await act('search:query', () => runQuery(q), {
134
- retry: { attempts: 5, delayMs: 100, backoff: 'linear' },
135
- timeout: { ms: 2_000 }, // per attempt
136
- totalTimeout: { ms: 8_000 }, // whole operation
163
+ const store = new InMemoryStore({
164
+ maxSize: 10_000, // default Infinity
165
+ autoCleanup: true, // default false
166
+ cleanupIntervalMs: 30_000 // default 30s
137
167
  })
138
-
139
- if (!result.ok) {
140
- if (result.error instanceof TotalTimeoutError) {
141
- console.log(`budget exhausted after ${result.error.ms}ms`)
142
- } else if (result.error instanceof TimeoutError) {
143
- console.log(`last attempt timed out after ${result.error.ms}ms`)
144
- }
145
- }
146
168
  ```
147
169
 
148
- `TotalTimeoutError` and `TimeoutError` are separate classes — `instanceof` distinguishes which deadline fired.
149
-
150
170
  ---
151
171
 
152
- ### Dedupe
172
+ ## Options
173
+
174
+ ### `retry`
153
175
 
154
- 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.
176
+ Retry `fn` on failure.
155
177
 
156
178
  ```ts
157
- const result = await act('config:load', () => loadRemoteConfig(), {
158
- dedupe: true,
159
- })
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
+ }
160
189
  ```
161
190
 
162
- `dedupe: { enabled: true }` is also valid object form for forward compatibility.
191
+ **Delay computation order:** backoff grows maxDelay caps jitter randomizes.
163
192
 
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.
193
+ **Default `shouldRetry`:** retries on every error except `AbortError` (caller/timeout cancellation). Override to skip non-recoverable errors (HTTP 4xx, auth failures).
165
194
 
166
- ---
195
+ **`attempts: 1` is a no-op** — the retry policy is skipped (pure overhead if included).
167
196
 
168
- ### Cache
197
+ ### `timeout`
169
198
 
170
- Stores successful results for `ttl` milliseconds. Failures are never cached. A cache hit short-circuits everything — dedupe, retry, and timeout are all skipped.
199
+ Per-attempt deadline. Each retry gets a fresh clock.
171
200
 
172
201
  ```ts
173
- const result = await act('flags:all', () => fetchFeatureFlags(), {
174
- cache: { ttl: 60_000 },
175
- })
176
-
177
- console.log(result.source) // 'fresh' on miss, 'cache' on hit
202
+ { timeout: { ms: 5_000 } } // ms > 0, ≤100_000_000
178
203
  ```
179
204
 
180
- ---
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).
206
+
207
+ ### `totalTimeout`
181
208
 
182
- ### Combining policies
209
+ Hard wall-clock budget over the entire operation — including all retry attempts, delays, and per-attempt timeouts.
183
210
 
184
211
  ```ts
185
- const result = await act('product:detail', () => fetchProduct(id), {
186
- totalTimeout: { ms: 10_000 }, // outermost wall
187
- cache: { ttl: 30_000 }, // short-circuits on hit
188
- dedupe: true, // collapses concurrent calls
189
- retry: { attempts: 3, delayMs: 100, backoff: 'linear' },
190
- timeout: { ms: 3_000 }, // per attempt
191
- })
212
+ { totalTimeout: { ms: 12_000 } }
192
213
  ```
193
214
 
194
- ---
215
+ Use with `timeout` to express: "each attempt may take at most X ms, but the whole operation must finish within Y ms."
216
+
217
+ ### `dedupe`
195
218
 
196
- ## Options reference
219
+ Collapse concurrent calls with the same key into one in-flight Promise.
197
220
 
198
221
  ```ts
199
- interface ActOptions {
200
- retry?: RetryOptions
201
- timeout?: TimeoutOptions // per-attempt
202
- totalTimeout?: TimeoutOptions // entire operation
203
- dedupe?: boolean | DedupeOptions
204
- cache?: CacheOptions
205
- }
222
+ dedupe: true
223
+ // or
224
+ dedupe: { enabled: true, inflightTtl: 30_000 }
225
+ ```
206
226
 
207
- interface RetryOptions {
208
- attempts: number
209
- delayMs?: number
210
- backoff?: 'none' | 'linear' | 'exponential'
211
- shouldRetry?: (error: unknown, attempt: number) => boolean
212
- }
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.
213
228
 
214
- interface TimeoutOptions { ms: number }
215
- interface DedupeOptions { enabled: boolean }
216
- interface CacheOptions { ttl: number }
217
- ```
229
+ **Joiner isolation:** if the originator's caller aborts, joiners are NOT affected. Each joiner races the in-flight promise against their own signal.
218
230
 
219
- ---
231
+ **Requires sync store.** `execute()` throws at chain-build time if an async store is used with dedupe.
232
+
233
+ ### `cache`
220
234
 
221
- ## Exported values
235
+ Store successful results for a TTL.
222
236
 
223
237
  ```ts
224
- import {
225
- act, // primary function
226
- InMemoryStore, // isolated store for SSR / tests
227
- TimeoutError, // per-attempt deadline (.ms)
228
- TotalTimeoutError, // total budget exhausted (.ms)
229
- } from 'actly'
238
+ { cache: { ttl: 60_000 } } // ttl > 0, ≤86_400_000
230
239
  ```
231
240
 
232
- ---
241
+ **Failures are never cached.** Only successful values are stored.
242
+
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.
244
+
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.
246
+
247
+ ### `signal`
233
248
 
234
- ## Exported types
249
+ Caller-provided `AbortSignal` for external cancellation.
235
250
 
236
251
  ```ts
237
- import type {
238
- ActResult, ActSuccess, ActFailure, ActSource,
239
- ActOptions, ActFn,
240
- RetryOptions, TimeoutOptions, DedupeOptions, CacheOptions,
241
- // v1.1 — store interfaces
242
- SyncStateStore,
243
- AsyncStateStore,
244
- InMemoryStoreOptions,
245
- // v1.0 alias — still valid, zero migration needed
246
- StateStore,
247
- } from 'actly'
248
- ```
252
+ const controller = new AbortController()
253
+ setTimeout(() => controller.abort(new Error('user-cancelled')), 5_000)
249
254
 
250
- ---
255
+ await act('user:42', async (signal) => fetch(url, { signal }), {
256
+ signal: controller.signal,
257
+ })
258
+ ```
251
259
 
252
- ## Custom store
260
+ ### `observability`
253
261
 
254
- By default `act` uses a module-level `InMemoryStore`. For SSR request isolation or test control, instantiate your own:
262
+ Event hooks for metrics, logging, and tracing. Zero overhead when not registered.
255
263
 
256
264
  ```ts
257
- import { InMemoryStore } from 'actly'
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
+ }
276
+ }
277
+ ```
258
278
 
259
- // Basic
260
- const store = new InMemoryStore()
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.
261
280
 
262
- // With background cleanup (useful for long-lived server-side stores)
263
- const store = new InMemoryStore({
264
- autoCleanup: true,
265
- cleanupIntervalMs: 60_000, // sweep every 60s (default: 30s)
266
- })
281
+ ### `traceId`
267
282
 
268
- // Always call destroy() when done to prevent timer leaks
269
- store.destroy()
270
- ```
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`.
271
284
 
272
- `InMemoryStore` satisfies `SyncStateStore`. Use it with `execute()` directly for full control.
285
+ ---
273
286
 
274
- ### Async store (v1.1+)
287
+ ## Error taxonomy
275
288
 
276
- For external cache backends (Redis, Upstash, etc.), implement `AsyncStateStore`:
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).
277
290
 
278
291
  ```ts
279
- import type { AsyncStateStore } from 'actly'
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'
300
+ ```
301
+
302
+ **`ValidationError` throws synchronously** — it's not wrapped in `ActFailure` because the caller's code is broken, not the runtime.
280
303
 
281
- class RedisStore implements AsyncStateStore {
282
- readonly _sync = false as const
304
+ **`RetryExhaustedError`** carries `attempts`, `lastError`, and `errors[]` for debugging retry patterns.
283
305
 
284
- async get<T>(key: string): Promise<T | undefined> { /* ... */ }
285
- async set<T>(key: string, value: T, ttlMs?: number): Promise<void> { /* ... */ }
286
- async delete(key: string): Promise<void> { /* ... */ }
287
- async has(key: string): Promise<boolean> { /* ... */ }
288
- async clear(): Promise<void> { /* ... */ }
289
- async size(): Promise<number> { /* ... */ }
306
+ ```ts
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)
290
310
  }
291
311
  ```
292
312
 
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.
313
+ **Cross-realm safety:** prefer `err.code === 'ACTLY_TIMEOUT'` over `err instanceof TimeoutError` when errors cross process/worker boundaries.
294
314
 
295
315
  ---
296
316
 
297
- ## Store interfaces
317
+ ## Stores
298
318
 
299
- ```ts
300
- interface SyncStateStore {
301
- readonly _sync: true
302
- get<T>(key: string): T | undefined
303
- set<T>(key: string, value: T, ttlMs?: number): void
304
- delete(key: string): void
305
- has(key: string): boolean
306
- clear(): void
307
- size(): number
308
- }
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).
309
322
 
310
- interface AsyncStateStore {
311
- readonly _sync: false
312
- get<T>(key: string): Promise<T | undefined>
313
- set<T>(key: string, value: T, ttlMs?: number): Promise<void>
314
- delete(key: string): Promise<void>
315
- has(key: string): Promise<boolean>
316
- clear(): Promise<void>
317
- size(): Promise<number>
323
+ `AsyncStateStore` — all operations return Promises. Compatible with `cachePolicy` only.
324
+
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> { /* ... */ },
318
340
  }
319
341
  ```
320
342
 
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`.
343
+ ### Default store
322
344
 
323
- ---
345
+ `act()` without `withStore()` uses a module-level default `InMemoryStore` bounded at 10,000 entries with 60-second background cleanup.
324
346
 
325
- ## 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.
326
348
 
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.
349
+ ### `InMemoryStore` internals
350
+
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.
328
355
 
329
356
  ---
330
357
 
331
- ## Philosophy
358
+ ## Policy order
359
+
360
+ Policies are applied in a fixed order, outermost to innermost:
332
361
 
333
- One function. One return type. No exceptions in userland.
362
+ ```
363
+ totalTimeout → cache → dedupe → retry → timeout → fn
364
+ ```
334
365
 
335
- The key is your responsibility. Make it stable and specific. `user:42` is good. `fetch` is not.
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.
336
371
 
337
- 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.
372
+ Policies with no effect (e.g. `retry.attempts: 1`) are skipped.
338
373
 
339
374
  ---
340
375
 
341
- ## Changelog
376
+ ## Limits
342
377
 
343
- ### v1.1.0 2026-06-01
378
+ All numeric inputs are bounded to prevent memory/CPU/timer exhaustion:
344
379
 
345
- **New features**
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 |
346
389
 
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.
390
+ Exceeding a limit throws `RangeError`. If you need more, you likely have a bug.
355
391
 
356
- **Internal improvements**
392
+ ---
357
393
 
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.
394
+ ## Migration
361
395
 
362
- **Non-breaking changes**
396
+ ### From v1.1.x to v1.2.0
363
397
 
364
- - `StateStore` preserved as type alias for `SyncStateStore` all v1.0 code compiles without changes.
365
- - All new exports are purely additive.
398
+ **Node 20+ required** (was 18+). Node 18 reached EOL April 2025.
366
399
 
367
- ---
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.
368
401
 
369
- ### 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.
370
403
 
371
- - Initial stable release.
372
- - `act()`, `retry`, `timeout`, `totalTimeout`, `dedupe`, `cache`.
373
- - `TimeoutError` exported for `instanceof` checks.
374
- - 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.
375
405
 
376
406
  ---
377
407