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