actly 1.1.0 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +284 -52
- package/dist/core/act.cjs +183 -35
- package/dist/core/act.d.ts +81 -10
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +181 -35
- package/dist/core/act.js.map +1 -1
- package/dist/core/executor.cjs +19 -9
- package/dist/core/executor.d.ts +25 -5
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +19 -9
- package/dist/core/executor.js.map +1 -1
- package/dist/index.cjs +13 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/policies/cache.cjs +85 -21
- package/dist/policies/cache.d.ts +29 -7
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +85 -21
- package/dist/policies/cache.js.map +1 -1
- package/dist/policies/dedupe.cjs +68 -20
- package/dist/policies/dedupe.d.ts +38 -13
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +68 -20
- package/dist/policies/dedupe.js.map +1 -1
- package/dist/policies/retry.cjs +65 -25
- package/dist/policies/retry.d.ts +25 -2
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +65 -25
- package/dist/policies/retry.js.map +1 -1
- package/dist/policies/timeout.cjs +87 -17
- package/dist/policies/timeout.d.ts +28 -8
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +87 -17
- package/dist/policies/timeout.js.map +1 -1
- package/dist/state/store.cjs +9 -2
- package/dist/state/store.d.ts +9 -0
- package/dist/state/store.d.ts.map +1 -1
- package/dist/state/store.js +9 -2
- package/dist/state/store.js.map +1 -1
- package/dist/stores/base.cjs +4 -4
- package/dist/stores/base.d.ts +32 -56
- package/dist/stores/base.d.ts.map +1 -1
- package/dist/stores/base.js +4 -4
- package/dist/stores/base.js.map +1 -1
- package/dist/stores/memory.cjs +77 -28
- package/dist/stores/memory.d.ts +44 -23
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +77 -28
- package/dist/stores/memory.js.map +1 -1
- package/dist/types/index.d.ts +141 -36
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/abort.cjs +142 -0
- package/dist/utils/abort.d.ts +55 -0
- package/dist/utils/abort.d.ts.map +1 -0
- package/dist/utils/abort.js +136 -0
- package/dist/utils/abort.js.map +1 -0
- package/dist/utils/backoff.cjs +43 -0
- package/dist/utils/backoff.d.ts +14 -0
- package/dist/utils/backoff.d.ts.map +1 -0
- package/dist/utils/backoff.js +41 -0
- package/dist/utils/backoff.js.map +1 -0
- package/dist/utils/validate.cjs +79 -0
- package/dist/utils/validate.d.ts +15 -0
- package/dist/utils/validate.d.ts.map +1 -0
- package/dist/utils/validate.js +72 -0
- package/dist/utils/validate.js.map +1 -0
- package/package.json +19 -37
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# actly
|
|
2
2
|
|
|
3
|
-
**Reliability primitive for async functions.**
|
|
4
|
-
Retry. Timeout. Dedupe. Cache. Composable, typed, zero-throw.
|
|
3
|
+
**Reliability primitive for async functions.**
|
|
4
|
+
Retry. Timeout. Dedupe. Cache. Composable, typed, zero-throw — with proper `AbortSignal` cancellation, single-flight cache, LRU store, and jittered backoff.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
npm install actly
|
|
@@ -24,12 +24,22 @@ These patterns are solved the same way every time. `actly` solves them once.
|
|
|
24
24
|
```ts
|
|
25
25
|
import { act } from 'actly'
|
|
26
26
|
|
|
27
|
-
const result = await act(
|
|
27
|
+
const result = await act(
|
|
28
|
+
'user:42',
|
|
29
|
+
async (signal) => fetch(`/api/users/42`, { signal }),
|
|
30
|
+
{
|
|
31
|
+
retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
|
|
32
|
+
timeout: { ms: 5_000 },
|
|
33
|
+
totalTimeout: { ms: 12_000 },
|
|
34
|
+
dedupe: true,
|
|
35
|
+
cache: { ttl: 60_000 },
|
|
36
|
+
},
|
|
37
|
+
)
|
|
28
38
|
|
|
29
39
|
if (result.ok) {
|
|
30
40
|
console.log(result.value) // T
|
|
31
41
|
console.log(result.source) // 'fresh' | 'cache'
|
|
32
|
-
console.log(result.attempts) // number
|
|
42
|
+
console.log(result.attempts) // number (0 on cache hit)
|
|
33
43
|
} else {
|
|
34
44
|
console.error(result.error) // unknown — never throws
|
|
35
45
|
}
|
|
@@ -37,18 +47,20 @@ if (result.ok) {
|
|
|
37
47
|
|
|
38
48
|
`act` always resolves. It never rejects. You check `.ok` and move on.
|
|
39
49
|
|
|
50
|
+
The `signal` passed to `fn` is your escape hatch: wire it through to `fetch`, `AbortController`, database drivers, or any primitive that accepts one. If a timeout fires (per-attempt or total), or the caller aborts via `options.signal`, `fn`'s signal aborts and the operation rejects promptly — no leaked resources, no hung retry loops.
|
|
51
|
+
|
|
40
52
|
---
|
|
41
53
|
|
|
42
54
|
## API
|
|
43
55
|
|
|
44
56
|
```ts
|
|
45
|
-
act<T>(key: string, fn:
|
|
57
|
+
act<T>(key: string, fn: ActFn<T>, options?: ActOptions): Promise<ActResult<T>>
|
|
46
58
|
```
|
|
47
59
|
|
|
48
60
|
| Argument | Type | Description |
|
|
49
61
|
|---|---|---|
|
|
50
|
-
| `key` | `string` | Stable, unique identifier. Scopes dedupe and cache state. |
|
|
51
|
-
| `fn` | `() => Promise<T
|
|
62
|
+
| `key` | `string` | Stable, unique identifier. Scopes dedupe and cache state. Must be non-empty and must not start with reserved prefixes (`dedupe:`, `cache:`, `__inflight:`). |
|
|
63
|
+
| `fn` | `(signal: AbortSignal) => Promise<T> \| T` | The async function to execute. Receives an `AbortSignal` for cooperative cancellation. Backwards compatible: `() => Promise<T>` is accepted (the signal is simply ignored). |
|
|
52
64
|
| `options` | `ActOptions` | All optional. See policies below. |
|
|
53
65
|
|
|
54
66
|
### Result shape
|
|
@@ -59,6 +71,12 @@ type ActResult<T> =
|
|
|
59
71
|
| { ok: false; error: unknown; attempts: number }
|
|
60
72
|
```
|
|
61
73
|
|
|
74
|
+
`attempts` semantics:
|
|
75
|
+
- Fresh success on first try: `1`
|
|
76
|
+
- Fresh success after N retries: `N`
|
|
77
|
+
- Cache hit: `0` (no work was performed)
|
|
78
|
+
- Dedupe joiner: mirrors the originator's attempt count (v1.1.5 fix — see changelog)
|
|
79
|
+
|
|
62
80
|
---
|
|
63
81
|
|
|
64
82
|
## Policies
|
|
@@ -72,23 +90,36 @@ totalTimeout → cache → dedupe → retry → timeout
|
|
|
72
90
|
### Retry
|
|
73
91
|
|
|
74
92
|
```ts
|
|
75
|
-
const result = await act('payments:charge', () => chargeCard(payload), {
|
|
93
|
+
const result = await act('payments:charge', async (signal) => chargeCard(payload, signal), {
|
|
76
94
|
retry: {
|
|
77
|
-
attempts: 3, // total attempts (including first)
|
|
95
|
+
attempts: 3, // total attempts (including first). Must be integer >= 1.
|
|
78
96
|
delayMs: 200, // base delay between attempts (ms)
|
|
79
|
-
backoff: 'exponential'
|
|
97
|
+
backoff: 'exponential',// 'none' | 'linear' | 'exponential'
|
|
98
|
+
maxDelay: 30_000, // cap (ms) — prevents 8.5-minute waits on long exponential chains
|
|
99
|
+
jitter: 'full', // 'none' | 'full' | 'equal' | 'decorrelated' (default: 'full')
|
|
80
100
|
},
|
|
81
101
|
})
|
|
82
102
|
```
|
|
83
103
|
|
|
84
104
|
`result.attempts` tells you how many tries it took.
|
|
85
105
|
|
|
106
|
+
#### Jitter (default: `'full'`)
|
|
107
|
+
|
|
108
|
+
Jitter prevents thundering-herd retry storms when many callers fail simultaneously. Without jitter, all callers retry on the same tick after an upstream outage recovers. The default `'full'` jitter randomises each delay to `[0, computed]`.
|
|
109
|
+
|
|
110
|
+
| Strategy | Formula |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `'none'` | `delay` |
|
|
113
|
+
| `'full'` | `random() * delay` |
|
|
114
|
+
| `'equal'` | `delay/2 + random() * delay/2` |
|
|
115
|
+
| `'decorrelated'` | `base + random() * (delay - base)` |
|
|
116
|
+
|
|
86
117
|
#### Selective retry with `shouldRetry`
|
|
87
118
|
|
|
88
|
-
By default, every error
|
|
119
|
+
By default, `actly` retries on every error EXCEPT `AbortError` (which indicates the caller or a timeout cancelled the operation — retrying would just abort again). Use `shouldRetry` to skip retrying other permanent errors — a `404` will never recover, a `503` might.
|
|
89
120
|
|
|
90
121
|
```ts
|
|
91
|
-
const result = await act('api:resource', () => fetchResource(id), {
|
|
122
|
+
const result = await act('api:resource', async (signal) => fetchResource(id, signal), {
|
|
92
123
|
retry: {
|
|
93
124
|
attempts: 4,
|
|
94
125
|
delayMs: 150,
|
|
@@ -101,18 +132,20 @@ const result = await act('api:resource', () => fetchResource(id), {
|
|
|
101
132
|
})
|
|
102
133
|
```
|
|
103
134
|
|
|
104
|
-
`shouldRetry(error, attempt)` receives the error and the 1-based attempt number. Return `false` to surface the error immediately.
|
|
135
|
+
`shouldRetry(error, attempt)` receives the error and the 1-based attempt number. Return `false` to surface the error immediately.
|
|
136
|
+
|
|
137
|
+
> **v1.1.5 change:** `shouldRetry` is now called on EVERY failure, including the final attempt. This preserves the predicate's contract for observers / metrics. The return value is only consulted when there are remaining attempts.
|
|
105
138
|
|
|
106
139
|
---
|
|
107
140
|
|
|
108
141
|
### Timeout
|
|
109
142
|
|
|
110
|
-
Per-attempt deadline. Each retry gets a fresh clock.
|
|
143
|
+
Per-attempt deadline. Each retry gets a fresh clock. Implemented via `AbortController` + race — `act()` returns promptly even if `fn` ignores the signal (the underlying work may continue in the background, but the caller is unblocked).
|
|
111
144
|
|
|
112
145
|
```ts
|
|
113
146
|
import { act, TimeoutError } from 'actly'
|
|
114
147
|
|
|
115
|
-
const result = await act('geo:lookup', () => lookupCoords(ip), {
|
|
148
|
+
const result = await act('geo:lookup', async (signal) => lookupCoords(ip, signal), {
|
|
116
149
|
timeout: { ms: 3_000 },
|
|
117
150
|
})
|
|
118
151
|
|
|
@@ -121,16 +154,20 @@ if (!result.ok && result.error instanceof TimeoutError) {
|
|
|
121
154
|
}
|
|
122
155
|
```
|
|
123
156
|
|
|
157
|
+
For cooperative cancellation, pass `signal` through to your async primitive (`fetch`, `AbortController`, etc.). `actly` will abort the signal when the deadline fires — `fn` rejects immediately, no background leak.
|
|
158
|
+
|
|
124
159
|
---
|
|
125
160
|
|
|
126
161
|
### Total timeout
|
|
127
162
|
|
|
128
163
|
Hard budget over the **entire** operation — all attempts, delays, and per-attempt timeouts combined. The per-attempt `timeout` resets on retry; `totalTimeout` does not.
|
|
129
164
|
|
|
165
|
+
**v1.1.5 fix:** When `totalTimeout` fires, the inner retry loop is cancelled — no more attempts fire, no more delays are slept. Previous versions would continue running `fn` in the background after `TotalTimeoutError` was returned to the caller.
|
|
166
|
+
|
|
130
167
|
```ts
|
|
131
168
|
import { act, TimeoutError, TotalTimeoutError } from 'actly'
|
|
132
169
|
|
|
133
|
-
const result = await act('search:query', () => runQuery(q), {
|
|
170
|
+
const result = await act('search:query', async (signal) => runQuery(q, signal), {
|
|
134
171
|
retry: { attempts: 5, delayMs: 100, backoff: 'linear' },
|
|
135
172
|
timeout: { ms: 2_000 }, // per attempt
|
|
136
173
|
totalTimeout: { ms: 8_000 }, // whole operation
|
|
@@ -154,14 +191,18 @@ if (!result.ok) {
|
|
|
154
191
|
Concurrent calls with the same key collapse into one in-flight Promise. The first caller executes; the rest wait and receive the same result. After settlement, the next call starts fresh.
|
|
155
192
|
|
|
156
193
|
```ts
|
|
157
|
-
const result = await act('config:load', () => loadRemoteConfig(), {
|
|
194
|
+
const result = await act('config:load', async (signal) => loadRemoteConfig(signal), {
|
|
158
195
|
dedupe: true,
|
|
159
196
|
})
|
|
160
197
|
```
|
|
161
198
|
|
|
162
199
|
`dedupe: { enabled: true }` is also valid — object form for forward compatibility.
|
|
163
200
|
|
|
164
|
-
|
|
201
|
+
#### v1.1.5 fixes
|
|
202
|
+
|
|
203
|
+
- **Shared `attempts`**: joiners now mirror the originator's attempt count. If the originator retried twice before succeeding, all joiners report `attempts: 3` (was `1` in v1.0 — a misleading default).
|
|
204
|
+
- **Abort safety**: joiners race the in-flight promise against their own `AbortSignal`. If a joiner's signal aborts (e.g. their `totalTimeout` fires), they reject immediately — they don't block on a hung originator.
|
|
205
|
+
- **In-flight TTL** (optional): `dedupe: { enabled: true, inflightTtl: 30_000 }` removes the store entry after 30s if the originator never settles, so subsequent callers can start fresh. Pair with `timeout` / `totalTimeout` for proper cancellation in production.
|
|
165
206
|
|
|
166
207
|
---
|
|
167
208
|
|
|
@@ -170,11 +211,37 @@ const result = await act('config:load', () => loadRemoteConfig(), {
|
|
|
170
211
|
Stores successful results for `ttl` milliseconds. Failures are never cached. A cache hit short-circuits everything — dedupe, retry, and timeout are all skipped.
|
|
171
212
|
|
|
172
213
|
```ts
|
|
173
|
-
const result = await act('flags:all', () => fetchFeatureFlags(), {
|
|
214
|
+
const result = await act('flags:all', async (signal) => fetchFeatureFlags(signal), {
|
|
174
215
|
cache: { ttl: 60_000 },
|
|
175
216
|
})
|
|
176
217
|
|
|
177
218
|
console.log(result.source) // 'fresh' on miss, 'cache' on hit
|
|
219
|
+
console.log(result.attempts) // 1 on miss, 0 on hit (v1.1.5 fix)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
#### v1.1.5 fixes
|
|
223
|
+
|
|
224
|
+
- **Single-flight (cache stampede prevention)**: on a sync store, concurrent cache misses for the same key now collapse into a single `fn` invocation. Previously, 10 concurrent callers would all run `fn` (10x redundant work). Now they share one in-flight Promise.
|
|
225
|
+
- **`attempts: 0` on cache hit**: cache hits now report `attempts: 0` (was `1` in v1.0), consistent with the documented meaning ("number of attempts made before success").
|
|
226
|
+
- **Fail-open writes**: if `store.set()` throws (e.g. Redis transient error), the value is still returned to the caller. Caching is an optimisation, not a correctness requirement.
|
|
227
|
+
|
|
228
|
+
#### Invalidation
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { act, invalidate } from 'actly'
|
|
232
|
+
|
|
233
|
+
await act('user:42', async (signal) => fetchUser(42, signal), { cache: { ttl: 60_000 } })
|
|
234
|
+
// ... user updates their profile ...
|
|
235
|
+
invalidate('user:42') // returns true if a cache entry was removed
|
|
236
|
+
await act('user:42', ...) // re-fetches
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
For scoped stores (see `withStore` below), use the scoped `invalidate`:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
const scopedAct = withStore(myStore)
|
|
243
|
+
scopedAct.invalidate('user:42') // sync store: returns boolean
|
|
244
|
+
// async store: returns Promise<boolean>
|
|
178
245
|
```
|
|
179
246
|
|
|
180
247
|
---
|
|
@@ -182,15 +249,43 @@ console.log(result.source) // 'fresh' on miss, 'cache' on hit
|
|
|
182
249
|
### Combining policies
|
|
183
250
|
|
|
184
251
|
```ts
|
|
185
|
-
const result = await act('product:detail', () => fetchProduct(id), {
|
|
252
|
+
const result = await act('product:detail', async (signal) => fetchProduct(id, signal), {
|
|
186
253
|
totalTimeout: { ms: 10_000 }, // outermost wall
|
|
187
254
|
cache: { ttl: 30_000 }, // short-circuits on hit
|
|
188
255
|
dedupe: true, // collapses concurrent calls
|
|
189
256
|
retry: { attempts: 3, delayMs: 100, backoff: 'linear' },
|
|
190
257
|
timeout: { ms: 3_000 }, // per attempt
|
|
258
|
+
signal: userSignal, // caller-provided cancellation
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Cancellation: `options.signal`
|
|
265
|
+
|
|
266
|
+
`act()` accepts an `AbortSignal` for caller-driven cancellation:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
const controller = new AbortController()
|
|
270
|
+
const promise = act('search:big', async (signal) => runBigQuery(q, signal), {
|
|
271
|
+
signal: controller.signal,
|
|
272
|
+
timeout: { ms: 30_000 },
|
|
191
273
|
})
|
|
274
|
+
|
|
275
|
+
// User clicks "Cancel":
|
|
276
|
+
controller.abort(new Error('user-cancelled'))
|
|
277
|
+
|
|
278
|
+
const r = await promise
|
|
279
|
+
// r.ok === false, r.error.message === 'user-cancelled'
|
|
192
280
|
```
|
|
193
281
|
|
|
282
|
+
When `signal` aborts:
|
|
283
|
+
- If the operation hasn't started, `act()` returns immediately with `attempts: 0` and the signal's `reason` as the error.
|
|
284
|
+
- If it's in progress, `fn`'s inner signal aborts (cooperative). `fn` should propagate the signal to its primitives (`fetch`, `AbortController`, etc.) for proper cleanup.
|
|
285
|
+
- If it has already settled, the result is returned as normal.
|
|
286
|
+
|
|
287
|
+
The signal propagates through the entire chain: `totalTimeout`, `retry` (interrupts delay sleeps), and `timeout` all observe it. No more attempts fire after abort.
|
|
288
|
+
|
|
194
289
|
---
|
|
195
290
|
|
|
196
291
|
## Options reference
|
|
@@ -202,28 +297,49 @@ interface ActOptions {
|
|
|
202
297
|
totalTimeout?: TimeoutOptions // entire operation
|
|
203
298
|
dedupe?: boolean | DedupeOptions
|
|
204
299
|
cache?: CacheOptions
|
|
300
|
+
signal?: AbortSignal // caller-provided cancellation
|
|
205
301
|
}
|
|
206
302
|
|
|
207
303
|
interface RetryOptions {
|
|
208
|
-
attempts: number
|
|
209
|
-
delayMs?: number
|
|
304
|
+
attempts: number // integer >= 1
|
|
305
|
+
delayMs?: number // non-negative finite
|
|
210
306
|
backoff?: 'none' | 'linear' | 'exponential'
|
|
307
|
+
maxDelay?: number // cap, default Infinity
|
|
308
|
+
jitter?: 'none' | 'full' | 'equal' | 'decorrelated' // default 'full'
|
|
211
309
|
shouldRetry?: (error: unknown, attempt: number) => boolean
|
|
212
310
|
}
|
|
213
311
|
|
|
214
|
-
interface TimeoutOptions { ms: number }
|
|
215
|
-
interface DedupeOptions {
|
|
216
|
-
|
|
312
|
+
interface TimeoutOptions { ms: number } // positive finite
|
|
313
|
+
interface DedupeOptions {
|
|
314
|
+
enabled: boolean
|
|
315
|
+
inflightTtl?: number // safety-net TTL for in-flight entry, default Infinity
|
|
316
|
+
}
|
|
317
|
+
interface CacheOptions { ttl: number } // positive finite
|
|
217
318
|
```
|
|
218
319
|
|
|
320
|
+
All options are validated upfront. Invalid input (negative `attempts`, non-positive `ms`, empty `key`, etc.) throws `RangeError` or `TypeError` — these are programmer errors, not runtime failures, so they surface as thrown exceptions rather than `ActFailure`.
|
|
321
|
+
|
|
219
322
|
---
|
|
220
323
|
|
|
221
324
|
## Exported values
|
|
222
325
|
|
|
223
326
|
```ts
|
|
224
327
|
import {
|
|
225
|
-
|
|
226
|
-
|
|
328
|
+
// Primary API
|
|
329
|
+
act, // execute with reliability policies
|
|
330
|
+
invalidate, // clear cache for a key on the default store
|
|
331
|
+
withStore, // create a scoped act() with explicit store
|
|
332
|
+
|
|
333
|
+
// Execution engine (for custom policy chains)
|
|
334
|
+
execute, // run a policy chain with explicit store + signal
|
|
335
|
+
REQUIRES_SYNC_STORE, // symbol stamped on dedupe policy
|
|
336
|
+
|
|
337
|
+
// Stores
|
|
338
|
+
InMemoryStore, // sync store with LRU + maxSize + autoCleanup
|
|
339
|
+
isSyncStore, // type guard
|
|
340
|
+
isAsyncStore, // type guard
|
|
341
|
+
|
|
342
|
+
// Error classes
|
|
227
343
|
TimeoutError, // per-attempt deadline (.ms)
|
|
228
344
|
TotalTimeoutError, // total budget exhausted (.ms)
|
|
229
345
|
} from 'actly'
|
|
@@ -238,12 +354,18 @@ import type {
|
|
|
238
354
|
ActResult, ActSuccess, ActFailure, ActSource,
|
|
239
355
|
ActOptions, ActFn,
|
|
240
356
|
RetryOptions, TimeoutOptions, DedupeOptions, CacheOptions,
|
|
241
|
-
//
|
|
357
|
+
// Policy internals (for custom policy authors)
|
|
358
|
+
PolicyApplier, PolicyContext, RunMeta,
|
|
359
|
+
// Store interfaces
|
|
242
360
|
SyncStateStore,
|
|
243
361
|
AsyncStateStore,
|
|
362
|
+
AnyStateStore,
|
|
244
363
|
InMemoryStoreOptions,
|
|
245
364
|
// v1.0 alias — still valid, zero migration needed
|
|
246
365
|
StateStore,
|
|
366
|
+
// withStore result types
|
|
367
|
+
ScopedActSync,
|
|
368
|
+
ScopedActAsync,
|
|
247
369
|
} from 'actly'
|
|
248
370
|
```
|
|
249
371
|
|
|
@@ -251,25 +373,86 @@ import type {
|
|
|
251
373
|
|
|
252
374
|
## Custom store
|
|
253
375
|
|
|
254
|
-
By default `act` uses a module-level `InMemoryStore`. For SSR request isolation or test control,
|
|
376
|
+
By default `act` uses a module-level `InMemoryStore`. For SSR request isolation, multi-tenant scenarios, or test control, use `withStore()`:
|
|
377
|
+
|
|
378
|
+
### `withStore(store)`
|
|
379
|
+
|
|
380
|
+
Returns a scoped `act` function bound to the provided store, with an `invalidate(key)` method and a `store` reference attached.
|
|
255
381
|
|
|
256
382
|
```ts
|
|
257
|
-
import { InMemoryStore } from 'actly'
|
|
383
|
+
import { withStore, InMemoryStore } from 'actly'
|
|
258
384
|
|
|
259
385
|
// Basic
|
|
260
386
|
const store = new InMemoryStore()
|
|
387
|
+
const act = withStore(store)
|
|
261
388
|
|
|
262
|
-
// With background cleanup (
|
|
263
|
-
const
|
|
389
|
+
// With LRU + background cleanup (long-lived server store)
|
|
390
|
+
const serverStore = new InMemoryStore({
|
|
391
|
+
maxSize: 10_000, // evict least-recently-used when exceeded
|
|
264
392
|
autoCleanup: true,
|
|
265
393
|
cleanupIntervalMs: 60_000, // sweep every 60s (default: 30s)
|
|
266
394
|
})
|
|
395
|
+
const serverAct = withStore(serverStore)
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
await serverAct('user:42', async (signal) => fetchUser(42, signal), {
|
|
399
|
+
cache: { ttl: 60_000 },
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
// ... user updates their profile ...
|
|
403
|
+
serverAct.invalidate('user:42') // next call re-fetches
|
|
404
|
+
|
|
405
|
+
// Access the underlying store if needed:
|
|
406
|
+
console.log(serverAct.store.size())
|
|
407
|
+
} finally {
|
|
408
|
+
serverStore.destroy() // stop the cleanup timer
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
For sync stores, `invalidate` returns `boolean` synchronously.
|
|
413
|
+
For async stores, `invalidate` returns `Promise<boolean>`.
|
|
414
|
+
|
|
415
|
+
### `execute()` directly
|
|
416
|
+
|
|
417
|
+
For full control (custom policy chains, custom meta, custom signal composition), call `execute()` directly:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
import { execute, InMemoryStore, retryPolicy, timeoutPolicy } from 'actly'
|
|
421
|
+
|
|
422
|
+
const store = new InMemoryStore()
|
|
423
|
+
const meta = { attempts: 1, source: 'fresh' as const }
|
|
424
|
+
|
|
425
|
+
const value = await execute({
|
|
426
|
+
key: 'custom:chain',
|
|
427
|
+
fn: async (signal) => fetchThing(signal),
|
|
428
|
+
policies: [
|
|
429
|
+
retryPolicy({ attempts: 3, delayMs: 100 }),
|
|
430
|
+
timeoutPolicy({ ms: 5_000 }),
|
|
431
|
+
],
|
|
432
|
+
store,
|
|
433
|
+
meta,
|
|
434
|
+
signal: new AbortController().signal,
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
console.log(meta.attempts) // final attempt count
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### InMemoryStore options
|
|
267
441
|
|
|
268
|
-
|
|
269
|
-
|
|
442
|
+
```ts
|
|
443
|
+
new InMemoryStore({
|
|
444
|
+
// Periodically sweep expired entries. Default: false (lazy eviction on access).
|
|
445
|
+
autoCleanup?: boolean,
|
|
446
|
+
cleanupIntervalMs?: number, // default 30_000
|
|
447
|
+
|
|
448
|
+
// Bounded cache with LRU eviction. Default: Infinity (unbounded).
|
|
449
|
+
// On set(), if size would exceed maxSize, the least-recently-used entry
|
|
450
|
+
// is evicted before the new one is inserted.
|
|
451
|
+
maxSize?: number,
|
|
452
|
+
})
|
|
270
453
|
```
|
|
271
454
|
|
|
272
|
-
`InMemoryStore` satisfies `SyncStateStore`.
|
|
455
|
+
`InMemoryStore` satisfies `SyncStateStore`. Call `destroy()` to stop the background timer and prevent leaks.
|
|
273
456
|
|
|
274
457
|
### Async store (v1.1+)
|
|
275
458
|
|
|
@@ -290,7 +473,7 @@ class RedisStore implements AsyncStateStore {
|
|
|
290
473
|
}
|
|
291
474
|
```
|
|
292
475
|
|
|
293
|
-
|
|
476
|
+
`AsyncStateStore` is compatible with `cache` only. Using it with `dedupe` is a TypeScript error and a runtime error — `execute()` throws at chain-build time. Dedupe requires synchronous store access (the read-then-write that makes deduplication work must happen in a single tick; an async `await` between `get()` and `set()` would let two concurrent callers both see a miss).
|
|
294
477
|
|
|
295
478
|
---
|
|
296
479
|
|
|
@@ -318,13 +501,15 @@ interface AsyncStateStore {
|
|
|
318
501
|
}
|
|
319
502
|
```
|
|
320
503
|
|
|
321
|
-
The `_sync` discriminant is read at runtime by
|
|
504
|
+
The `_sync` discriminant is read at runtime by `execute()` to enforce the dedupe constraint. Set it as a `readonly` literal — `true as const` or `false as const`.
|
|
322
505
|
|
|
323
506
|
---
|
|
324
507
|
|
|
325
508
|
## Zero-throw contract
|
|
326
509
|
|
|
327
|
-
`act()` always resolves. Under any condition — network error, thrown exception, timeout, total budget exhaustion — the result is an `ActFailure`, not a rejected promise. This contract holds across all versions.
|
|
510
|
+
`act()` always resolves. Under any condition — network error, thrown exception, timeout, total budget exhaustion, caller abort — the result is an `ActFailure`, not a rejected promise. This contract holds across all versions.
|
|
511
|
+
|
|
512
|
+
The only exceptions are programmer errors (invalid options, invalid keys) — these throw synchronously so bugs surface at development time rather than degrading silently in production.
|
|
328
513
|
|
|
329
514
|
---
|
|
330
515
|
|
|
@@ -332,37 +517,84 @@ The `_sync` discriminant is read at runtime by the executor to enforce the dedup
|
|
|
332
517
|
|
|
333
518
|
One function. One return type. No exceptions in userland.
|
|
334
519
|
|
|
335
|
-
The key is your responsibility. Make it stable and specific. `user:42` is good. `fetch` is not.
|
|
520
|
+
The key is your responsibility. Make it stable and specific. `user:42` is good. `fetch` is not. (Empty keys and reserved prefixes are rejected — see validation.)
|
|
336
521
|
|
|
337
522
|
Policies are composable but intentionally constrained. There is no builder API, no middleware system, no hooks. If you need something `act` doesn't do, write a wrapper around it — that's the right boundary.
|
|
338
523
|
|
|
524
|
+
Cancellation is cooperative: pass the `signal` through. `act()` will return promptly regardless (via internal race), but only you can stop `fn` from leaking resources.
|
|
525
|
+
|
|
339
526
|
---
|
|
340
527
|
|
|
341
528
|
## Changelog
|
|
342
529
|
|
|
343
|
-
### v1.1.
|
|
530
|
+
### v1.1.5 — 2026-06-25
|
|
531
|
+
|
|
532
|
+
**Critical correctness fixes**
|
|
533
|
+
|
|
534
|
+
- **`totalTimeout` now cancels the inner retry loop.** Previously, when `totalTimeout` fired, `act()` returned `TotalTimeoutError` to the caller but the inner `retryPolicy` continued firing attempts and sleeping delays in the background — leaking resources for the full retry budget. Now, the abort propagates through the chain: no more attempts fire, delay sleeps reject immediately. *(Fixes C-1.)*
|
|
535
|
+
- **`timeout` (per-attempt) cancels `fn` via `AbortSignal`.** `fn` now receives a signal that aborts when the per-attempt deadline fires. If `fn` cooperates (passes signal to `fetch`, `AbortController`, etc.), the underlying work is cancelled properly. `act()` returns promptly even if `fn` ignores the signal (via internal race). *(Fixes C-2.)*
|
|
536
|
+
- **Dedupe joiners can abort independently.** Previously, if the originator's `fn` hung, all joiners blocked forever waiting for the in-flight promise. Now, joiners race the in-flight promise against their own `AbortSignal` — they reject immediately when their signal aborts, without waiting for the originator. *(Fixes C-3.)*
|
|
537
|
+
- **Cache stampede prevention (single-flight).** Concurrent cache misses for the same key on a sync store now collapse into a single `fn` invocation. Previously, 10 concurrent callers would all run `fn` (10x redundant work). Now they share one in-flight Promise via an internal `__inflight:cache:<key>` slot. *(Fixes C-4.)*
|
|
538
|
+
- **Dedupe joiners mirror originator's `attempts`.** Previously, joiners reported `attempts: 1` regardless of how many retries the originator performed. Now, joiners copy the originator's final `attempts` and `source` from a shared `RunMeta` reference. *(Fixes C-5.)*
|
|
539
|
+
- **Cache hit reports `attempts: 0`.** Previously, cache hits reported `attempts: 1`, inconsistent with the documented meaning ("number of attempts made before success"). Now: `0` on hit, `1+` on miss. *(Fixes C-6.)*
|
|
344
540
|
|
|
345
|
-
**
|
|
541
|
+
**Public API additions (fixes A-1, A-2, m-8)**
|
|
346
542
|
|
|
347
|
-
- **`
|
|
348
|
-
- **`
|
|
349
|
-
- **`
|
|
350
|
-
- **`
|
|
351
|
-
- **`
|
|
352
|
-
- **`
|
|
353
|
-
- **`
|
|
354
|
-
|
|
543
|
+
- **`execute()` is now exported.** Call it directly with a custom policy chain, store, meta, and signal for full control (SSR isolation, multi-tenant scenarios, custom policy composition).
|
|
544
|
+
- **`isSyncStore()` / `isAsyncStore()` are now exported.** Type guards for `AnyStateStore`, useful when building custom policy chains or store adapters.
|
|
545
|
+
- **`invalidate(key)`** clears cache entries on the default store. Returns `boolean` (true if an entry was removed).
|
|
546
|
+
- **`withStore(store)`** returns a scoped `act` function bound to an explicit store, with `invalidate(key)` and `store` attached. Overloads for sync (`invalidate: boolean`) vs async (`invalidate: Promise<boolean>`) stores.
|
|
547
|
+
- **`REQUIRES_SYNC_STORE` symbol** is now exported for consumers building custom policy chains that need to declare sync-store requirements.
|
|
548
|
+
- **`ScopedActSync` / `ScopedActAsync` types** exported for typed `withStore` results.
|
|
549
|
+
- **`PolicyApplier`, `PolicyContext`, `RunMeta`, `AnyStateStore`** are now exported as public types (previously internal).
|
|
550
|
+
|
|
551
|
+
**New options**
|
|
552
|
+
|
|
553
|
+
- **`options.signal: AbortSignal`** — caller-provided cancellation signal. Propagates through the entire chain: `totalTimeout`, `retry` (interrupts delay sleeps), and `timeout` all observe it. `fn` receives the composite signal.
|
|
554
|
+
- **`RetryOptions.maxDelay`** — cap on computed delay. Prevents 8.5-minute waits on long exponential chains (`delayMs: 1000, attempts: 10, exponential` → 256s between attempts 9 and 10 without a cap).
|
|
555
|
+
- **`RetryOptions.jitter`** — jitter strategy: `'none' | 'full' | 'equal' | 'decorrelated'`. Default: `'full'` (random in `[0, delay]`). Prevents thundering-herd retry storms.
|
|
556
|
+
- **`DedupeOptions.inflightTtl`** — safety-net TTL for the in-flight entry. If the originator never settles, subsequent callers can start fresh after the TTL expires.
|
|
557
|
+
- **`InMemoryStoreOptions.maxSize`** — bounded cache with LRU eviction. On `set()`, if size would exceed `maxSize`, the least-recently-used entry is evicted before the new one is inserted. Default: `Infinity` (unbounded).
|
|
558
|
+
|
|
559
|
+
**Behaviour changes (non-breaking)**
|
|
560
|
+
|
|
561
|
+
- **`shouldRetry` is now called on every failure, including the final attempt.** Previously, it was skipped on the last attempt. The return value is still only consulted when there are remaining attempts — but observers / metrics now see every failure. *(Fixes M-2.)*
|
|
562
|
+
- **Default `shouldRetry` skips abort errors.** Previously, the default retried on every error. Now it skips `AbortError` (which indicates cancellation — retrying would just abort again, wasting delay budget). User-supplied `shouldRetry` overrides this entirely.
|
|
563
|
+
- **Input validation throws on invalid options.** Empty keys, reserved prefixes (`dedupe:`, `cache:`, `__inflight:`), non-integer `attempts`, non-positive `ms`/`ttl`, non-finite delays — all throw `RangeError` or `TypeError` synchronously, rather than degrading silently. *(Fixes M-3, M-4.)*
|
|
564
|
+
- **`RetryOptions.attempts: 1` is a documented no-op.** The retry policy is not added to the chain when `attempts <= 1` (would be pure overhead). This was already the behaviour; it's now explicitly documented.
|
|
355
565
|
|
|
356
566
|
**Internal improvements**
|
|
357
567
|
|
|
358
|
-
-
|
|
359
|
-
- `
|
|
360
|
-
- `
|
|
568
|
+
- **`ActFn<T>` signature changed to `(signal: AbortSignal) => Promise<T> | T`.** Backwards compatible: `() => Promise<T>` is assignable (signal arg is ignored). New code can opt-in to cooperative cancellation.
|
|
569
|
+
- **`cachePolicy` fails open on `store.set()` errors.** If the cache write throws (e.g. Redis transient error), the value is still returned to the caller. Caching is an optimisation, not a correctness requirement. *(Fixes M-6.)*
|
|
570
|
+
- **`InMemoryStore.size()` is now side-effect free** with respect to LRU order. Previously, it called `has()` (which delegated to `get()`), refreshing LRU positions. Now it scans without touching order. *(Fixes m-1.)*
|
|
571
|
+
- **`InMemoryStore` implements LRU** via `Map` insertion-order semantics: `delete` + `set` on every access moves the key to the most-recent position. *(Fixes P-3.)*
|
|
572
|
+
- **`retryPolicy` checks `parentSignal.aborted` before each attempt and before each delay sleep.** When `totalTimeout` or caller signal aborts, the retry loop bails immediately — no more attempts, no more delays. Delay sleeps use a signal-aware `sleep()` helper that rejects early on abort.
|
|
573
|
+
- **`package.json` `engines.node` set to `>=18`** (was missing — README claimed Node 18+ but it wasn't enforced). *(Fixes D-7.)*
|
|
574
|
+
- **`package.json` `sideEffects: false`** for tree-shaking. *(Fixes D-8.)*
|
|
575
|
+
- **`package.json` `exports` field:** `types` condition is now first (was last) — required for correct TypeScript resolution in some bundlers. *(Fixes A-3.)*
|
|
576
|
+
- **`tsconfig` updated:** target ES2022, `lib: ["ES2022"]`, `types: ["node"]`. Removed `DOM` lib (was unnecessary for a Node-targeted library). *(Fixes D-9, D-11, D-12.)*
|
|
577
|
+
- **`tsconfig.cjs.json` `moduleResolution: "Node"`** (was `"Bundler"` — incompatible with `module: "CommonJS"`). *(Fixes D-9.)*
|
|
578
|
+
- **Test suite added.** 64 tests covering: API surface, zero-throw contract, input validation, retry (jitter, maxDelay, shouldRetry on final attempt, abort-skip), timeout (cooperative + race fallback), totalTimeout (cancellation, delay interrupt), dedupe (collapse, shared meta, abort safety, hung originator), cache (single-flight, attempts=0, fail-open, TTL), invalidate, withStore (sync + async, isolation, scoped invalidate), InMemoryStore (LRU, maxSize, TTL, size() purity, destroy), AbortSignal integration, execute() public API. *(Fixes D-1.)*
|
|
361
579
|
|
|
362
580
|
**Non-breaking changes**
|
|
363
581
|
|
|
364
582
|
- `StateStore` preserved as type alias for `SyncStateStore` — all v1.0 code compiles without changes.
|
|
365
583
|
- All new exports are purely additive.
|
|
584
|
+
- `ActFn<T>` signature widened to accept `(signal) => ...` — existing `() => Promise<T>` is assignable.
|
|
585
|
+
- `withStore()` is a new function — no impact on existing `act()` callers.
|
|
586
|
+
|
|
587
|
+
---
|
|
588
|
+
|
|
589
|
+
### v1.1.0 — 2026-06-01
|
|
590
|
+
|
|
591
|
+
- `AsyncStateStore` interface for plug-in async backends (Redis, Upstash, Cloudflare KV).
|
|
592
|
+
- `SyncStateStore` canonical name; `StateStore` preserved as alias.
|
|
593
|
+
- `InMemoryStore` public export with `InMemoryStoreOptions` (`autoCleanup`, `cleanupIntervalMs`).
|
|
594
|
+
- `TotalTimeoutError` class distinct from `TimeoutError`.
|
|
595
|
+
- `totalTimeout` option for hard wall-clock budget.
|
|
596
|
+
- `dedupe: true` shorthand.
|
|
597
|
+
- `isSyncStore()` / `isAsyncStore()` type guards.
|
|
366
598
|
|
|
367
599
|
---
|
|
368
600
|
|