actly 1.1.5 → 1.3.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 (139) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/MIGRATION.md +141 -0
  3. package/README.md +334 -428
  4. package/SECURITY.md +56 -0
  5. package/dist/core/act.cjs +630 -126
  6. package/dist/core/act.d.ts +6 -84
  7. package/dist/core/act.d.ts.map +1 -1
  8. package/dist/core/act.js +630 -129
  9. package/dist/core/executor.cjs +1 -26
  10. package/dist/core/executor.d.ts +2 -38
  11. package/dist/core/executor.d.ts.map +1 -1
  12. package/dist/core/executor.js +1 -27
  13. package/dist/core/health.cjs +163 -0
  14. package/dist/core/health.d.ts +33 -0
  15. package/dist/core/health.d.ts.map +1 -0
  16. package/dist/core/health.js +150 -0
  17. package/dist/core/shutdown.cjs +71 -0
  18. package/dist/core/shutdown.d.ts +5 -0
  19. package/dist/core/shutdown.d.ts.map +1 -0
  20. package/dist/core/shutdown.js +65 -0
  21. package/dist/core/tenant.cjs +113 -0
  22. package/dist/core/tenant.d.ts +24 -0
  23. package/dist/core/tenant.d.ts.map +1 -0
  24. package/dist/core/tenant.js +109 -0
  25. package/dist/errors.cjs +149 -0
  26. package/dist/errors.d.ts +94 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +134 -0
  29. package/dist/index.cjs +51 -8
  30. package/dist/index.d.ts +22 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +14 -6
  33. package/dist/observability.cjs +6 -0
  34. package/dist/observability.d.ts +86 -0
  35. package/dist/observability.d.ts.map +1 -0
  36. package/dist/observability.js +3 -0
  37. package/dist/policies/bulkhead.cjs +137 -0
  38. package/dist/policies/bulkhead.d.ts +3 -0
  39. package/dist/policies/bulkhead.d.ts.map +1 -0
  40. package/dist/policies/bulkhead.js +134 -0
  41. package/dist/policies/cache.cjs +84 -67
  42. package/dist/policies/cache.d.ts +0 -33
  43. package/dist/policies/cache.d.ts.map +1 -1
  44. package/dist/policies/cache.js +84 -68
  45. package/dist/policies/circuitBreaker.cjs +179 -0
  46. package/dist/policies/circuitBreaker.d.ts +3 -0
  47. package/dist/policies/circuitBreaker.d.ts.map +1 -0
  48. package/dist/policies/circuitBreaker.js +176 -0
  49. package/dist/policies/dedupe.cjs +46 -64
  50. package/dist/policies/dedupe.d.ts +0 -41
  51. package/dist/policies/dedupe.d.ts.map +1 -1
  52. package/dist/policies/dedupe.js +46 -65
  53. package/dist/policies/noop.cjs +6 -0
  54. package/dist/policies/noop.d.ts +3 -0
  55. package/dist/policies/noop.d.ts.map +1 -0
  56. package/dist/policies/noop.js +3 -0
  57. package/dist/policies/rateLimit.cjs +45 -0
  58. package/dist/policies/rateLimit.d.ts +3 -0
  59. package/dist/policies/rateLimit.d.ts.map +1 -0
  60. package/dist/policies/rateLimit.js +42 -0
  61. package/dist/policies/retry.cjs +129 -57
  62. package/dist/policies/retry.d.ts +0 -27
  63. package/dist/policies/retry.d.ts.map +1 -1
  64. package/dist/policies/retry.js +129 -58
  65. package/dist/policies/timeout.cjs +47 -103
  66. package/dist/policies/timeout.d.ts +2 -46
  67. package/dist/policies/timeout.d.ts.map +1 -1
  68. package/dist/policies/timeout.js +46 -102
  69. package/dist/stores/base.cjs +0 -10
  70. package/dist/stores/base.d.ts +4 -67
  71. package/dist/stores/base.d.ts.map +1 -1
  72. package/dist/stores/base.js +0 -11
  73. package/dist/stores/memory.cjs +147 -87
  74. package/dist/stores/memory.d.ts +11 -59
  75. package/dist/stores/memory.d.ts.map +1 -1
  76. package/dist/stores/memory.js +146 -88
  77. package/dist/testing/index.cjs +56 -0
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.d.ts.map +1 -0
  80. package/dist/testing/index.js +52 -0
  81. package/dist/types/index.cjs +0 -1
  82. package/dist/types/index.d.ts +62 -179
  83. package/dist/types/index.d.ts.map +1 -1
  84. package/dist/types/index.js +0 -2
  85. package/dist/utils/abort.cjs +46 -73
  86. package/dist/utils/abort.d.ts +4 -51
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +46 -74
  89. package/dist/utils/abortPool.cjs +25 -0
  90. package/dist/utils/abortPool.d.ts +4 -0
  91. package/dist/utils/abortPool.d.ts.map +1 -0
  92. package/dist/utils/abortPool.js +20 -0
  93. package/dist/utils/backoff.cjs +8 -15
  94. package/dist/utils/backoff.d.ts +0 -11
  95. package/dist/utils/backoff.d.ts.map +1 -1
  96. package/dist/utils/backoff.js +8 -16
  97. package/dist/utils/decorator.cjs +44 -0
  98. package/dist/utils/decorator.d.ts +3 -0
  99. package/dist/utils/decorator.d.ts.map +1 -0
  100. package/dist/utils/decorator.js +41 -0
  101. package/dist/utils/key.cjs +45 -0
  102. package/dist/utils/key.d.ts +2 -0
  103. package/dist/utils/key.d.ts.map +1 -0
  104. package/dist/utils/key.js +42 -0
  105. package/dist/utils/limits.cjs +22 -0
  106. package/dist/utils/limits.d.ts +21 -0
  107. package/dist/utils/limits.d.ts.map +1 -0
  108. package/dist/utils/limits.js +19 -0
  109. package/dist/utils/safeCall.cjs +28 -0
  110. package/dist/utils/safeCall.d.ts +2 -0
  111. package/dist/utils/safeCall.d.ts.map +1 -0
  112. package/dist/utils/safeCall.js +25 -0
  113. package/dist/utils/sanitize.cjs +50 -0
  114. package/dist/utils/sanitize.d.ts +3 -0
  115. package/dist/utils/sanitize.d.ts.map +1 -0
  116. package/dist/utils/sanitize.js +46 -0
  117. package/dist/utils/validate.cjs +163 -26
  118. package/dist/utils/validate.d.ts +6 -8
  119. package/dist/utils/validate.d.ts.map +1 -1
  120. package/dist/utils/validate.js +158 -27
  121. package/package.json +42 -7
  122. package/dist/core/act.js.map +0 -1
  123. package/dist/core/executor.js.map +0 -1
  124. package/dist/index.js.map +0 -1
  125. package/dist/policies/cache.js.map +0 -1
  126. package/dist/policies/dedupe.js.map +0 -1
  127. package/dist/policies/retry.js.map +0 -1
  128. package/dist/policies/timeout.js.map +0 -1
  129. package/dist/state/store.cjs +0 -14
  130. package/dist/state/store.d.ts +0 -11
  131. package/dist/state/store.d.ts.map +0 -1
  132. package/dist/state/store.js +0 -11
  133. package/dist/state/store.js.map +0 -1
  134. package/dist/stores/base.js.map +0 -1
  135. package/dist/stores/memory.js.map +0 -1
  136. package/dist/types/index.js.map +0 -1
  137. package/dist/utils/abort.js.map +0 -1
  138. package/dist/utils/backoff.js.map +0 -1
  139. package/dist/utils/validate.js.map +0 -1
package/dist/core/act.js CHANGED
@@ -3,215 +3,716 @@ import { retryPolicy } from '../policies/retry.js';
3
3
  import { timeoutPolicy, totalTimeoutPolicy } from '../policies/timeout.js';
4
4
  import { dedupePolicy } from '../policies/dedupe.js';
5
5
  import { cachePolicy } from '../policies/cache.js';
6
- import { InMemoryStore } from '../stores/memory.js';
6
+ import { circuitBreakerPolicy } from '../policies/circuitBreaker.js';
7
+ import { bulkheadPolicy } from '../policies/bulkhead.js';
8
+ import { rateLimitPolicy } from '../policies/rateLimit.js';
9
+ import { createDefaultStore } from '../stores/memory.js';
7
10
  import { isSyncStore } from '../stores/base.js';
8
- import { linkSignal } from '../utils/abort.js';
11
+ import { linkSignal, raceAbort } from '../utils/abort.js';
12
+ import { sanitizeError, sanitizeErrorMessage } from '../utils/sanitize.js';
13
+ import { safeCall } from '../utils/safeCall.js';
14
+ import { registerInflight, unregisterInflight, recordError, recordSuccess, registerStoreScope } from './health.js';
15
+ import { registerDrainable, unregisterDrainable } from './shutdown.js';
9
16
  import { assertKey, assertOptions, } from '../utils/validate.js';
10
- // Module-level default store so cache and dedupe persist across calls.
11
- // Always an InMemoryStore (SyncStateStore) — required because the default
12
- // chain may include dedupePolicy, which mandates synchronous store access.
13
- // For SSR isolation or per-test control, use `withStore()` or call
14
- // `execute()` directly with an explicit store.
15
- const defaultStore = new InMemoryStore();
16
- // Namespace prefixes used by policies. Kept here (not in policy files) so
17
- // `invalidate()` can resolve cache keys without importing policy internals.
17
+ import { HedgeTimeoutError } from '../errors.js';
18
+ const defaultStore = createDefaultStore();
18
19
  const CACHE_NS = 'cache:';
19
- /**
20
- * Normalise `dedupe: true` shorthand to `DedupeOptions`.
21
- * Returns `undefined` if dedupe is disabled or absent.
22
- */
20
+ function monotonicNow() {
21
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
22
+ return performance.now();
23
+ }
24
+ return Date.now();
25
+ }
26
+ function generateTraceId() {
27
+ const crypto = globalThis.crypto;
28
+ if (crypto && typeof crypto.randomUUID === 'function') {
29
+ return crypto.randomUUID();
30
+ }
31
+ return `actly-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
32
+ }
33
+ function buildObservability(hooks, traceId) {
34
+ if (!hooks)
35
+ return undefined;
36
+ const hasAnyHook = !!hooks.onAttempt ||
37
+ !!hooks.onRetry ||
38
+ !!hooks.onCacheHit ||
39
+ !!hooks.onCacheMiss ||
40
+ !!hooks.onDedupeJoin ||
41
+ !!hooks.onTimeout ||
42
+ !!hooks.onFinalSuccess ||
43
+ !!hooks.onFinalFailure ||
44
+ !!hooks.onBackpressure ||
45
+ !!hooks.onWatchdog;
46
+ if (!hasAnyHook)
47
+ return undefined;
48
+ return {
49
+ traceId: traceId ?? generateTraceId(),
50
+ hooks,
51
+ joinerCounter: 0,
52
+ };
53
+ }
54
+ function classifyFailure(error) {
55
+ if (error == null || typeof error !== 'object')
56
+ return 'fn-error';
57
+ const code = error.code;
58
+ if (code === 'ACTLY_ABORT')
59
+ return 'abort';
60
+ if (code === 'ACTLY_TIMEOUT')
61
+ return 'timeout';
62
+ if (code === 'ACTLY_TOTAL_TIMEOUT')
63
+ return 'total-timeout';
64
+ if (code === 'ACTLY_RETRY_EXHAUSTED')
65
+ return 'retry-exhausted';
66
+ if (code === 'ACTLY_VALIDATION')
67
+ return 'validation';
68
+ if (code === 'ACTLY_CIRCUIT_OPEN')
69
+ return 'circuit-open';
70
+ if (code === 'ACTLY_BULKHEAD_FULL')
71
+ return 'bulkhead-full';
72
+ if (code === 'ACTLY_RATE_LIMIT')
73
+ return 'rate-limited';
74
+ if (code === 'ACTLY_RESOURCE_EXHAUSTED')
75
+ return 'resource-exhausted';
76
+ if (code === 'ACTLY_HEDGE_TIMEOUT')
77
+ return 'hedge-timeout';
78
+ if (error instanceof Error) {
79
+ const name = error.name;
80
+ if (name === 'AbortError')
81
+ return 'abort';
82
+ }
83
+ return 'fn-error';
84
+ }
23
85
  function normalizeDedupe(opt) {
24
86
  if (opt === true)
25
87
  return { enabled: true };
26
88
  if (opt && typeof opt === 'object' && opt.enabled) {
27
- return {
28
- enabled: true,
29
- ...(opt.inflightTtl !== undefined ? { inflightTtl: opt.inflightTtl } : {}),
30
- };
89
+ if (opt.inflightTtl !== undefined && opt.inflightTtl !== 0 && !Number.isNaN(opt.inflightTtl)) {
90
+ return { enabled: true, inflightTtl: opt.inflightTtl };
91
+ }
92
+ return { enabled: true };
31
93
  }
32
94
  return undefined;
33
95
  }
34
- /**
35
- * Build the policy chain from `ActOptions`. The order is fixed and
36
- * documented in `executor.ts`. Policies with no effect (e.g. `retry.attempts: 1`)
37
- * are skipped — they would be pure overhead.
38
- */
39
96
  function buildPolicies(options) {
40
97
  const dedupe = normalizeDedupe(options.dedupe);
41
98
  const policies = [];
42
- // 0. Outermost: hard wall-clock budget over the entire operation.
43
- // If it fires, no inner policy can extend the deadline.
99
+ if (options.rateLimit) {
100
+ policies.push(rateLimitPolicy(options.rateLimit));
101
+ }
102
+ if (options.circuitBreaker) {
103
+ policies.push(circuitBreakerPolicy(options.circuitBreaker));
104
+ }
44
105
  if (options.totalTimeout && options.totalTimeout.ms > 0) {
45
106
  policies.push(totalTimeoutPolicy(options.totalTimeout));
46
107
  }
47
- // 1. Cache: a hit short-circuits everything below it.
48
108
  if (options.cache && options.cache.ttl > 0) {
49
109
  policies.push(cachePolicy(options.cache));
50
110
  }
51
- // 2. Dedupe: collapses concurrent callers before retry fires.
111
+ if (options.bulkhead) {
112
+ policies.push(bulkheadPolicy(options.bulkhead));
113
+ }
52
114
  if (dedupe) {
53
115
  policies.push(dedupePolicy(dedupe));
54
116
  }
55
- // 3. Retry: owns the attempt loop.
56
- // `attempts: 1` is a no-op — skip to avoid overhead.
57
117
  if (options.retry && options.retry.attempts > 1) {
58
118
  policies.push(retryPolicy(options.retry));
59
119
  }
60
- // 4. Innermost: per-attempt clock. Resets on every retry.
61
120
  if (options.timeout && options.timeout.ms > 0) {
62
121
  policies.push(timeoutPolicy(options.timeout));
63
122
  }
64
123
  return policies;
65
124
  }
66
- /**
67
- * Build a root AbortController from `options.signal`.
68
- *
69
- * - If no user signal: returns a fresh controller that never aborts unless
70
- * an outer timeout policy aborts it.
71
- * - If user signal is already aborted: returns a controller that is already
72
- * aborted with the user's reason (so the operation rejects immediately).
73
- * - Otherwise: links the user signal to the controller.
74
- */
75
125
  function buildRootSignal(userSignal) {
76
126
  const controller = new AbortController();
77
- if (userSignal)
78
- linkSignal(userSignal, controller);
79
- return controller;
127
+ if (userSignal) {
128
+ const unlink = linkSignal(userSignal, controller);
129
+ return { controller, cleanup: unlink };
130
+ }
131
+ return { controller, cleanup: () => { } };
80
132
  }
81
- /**
82
- * Execute `fn` with the given reliability policies.
83
- *
84
- * @param key Stable identifier for this action. Scopes dedupe + cache.
85
- * @param fn The async work to run. Receives an `AbortSignal` for
86
- * cooperative cancellation (legacy `() => Promise<T>` is
87
- * still accepted — the signal is simply ignored).
88
- * @param options Which policies to apply and how. All fields are optional.
89
- *
90
- * @returns `ActResult<T>` — always resolves, never throws.
91
- * Check `result.ok` before reading `result.value`.
92
- *
93
- * @example
94
- * // With cooperative cancellation
95
- * const result = await act('user:42', async (signal) => {
96
- * return fetch(`/api/users/42`, { signal })
97
- * }, {
98
- * retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
99
- * timeout: { ms: 5_000 },
100
- * totalTimeout: { ms: 12_000 },
101
- * dedupe: true,
102
- * cache: { ttl: 60_000 },
103
- * })
104
- *
105
- * if (result.ok) {
106
- * console.log(result.value, result.source, result.attempts)
107
- * } else {
108
- * console.error(result.error)
109
- * }
110
- */
111
133
  export async function act(key, fn, options = {}) {
112
- // Validate input upfront. Programmer errors throw — they should not be
113
- // swallowed into an ActFailure because the caller's code is broken.
114
134
  assertKey(key);
115
135
  assertOptions(options);
136
+ const hasAnyOption = options.retry !== undefined ||
137
+ options.timeout !== undefined ||
138
+ options.totalTimeout !== undefined ||
139
+ options.dedupe !== undefined ||
140
+ options.cache !== undefined ||
141
+ options.signal !== undefined ||
142
+ options.observability !== undefined ||
143
+ options.traceId !== undefined ||
144
+ options.circuitBreaker !== undefined ||
145
+ options.bulkhead !== undefined ||
146
+ options.rateLimit !== undefined ||
147
+ options.hedge !== undefined ||
148
+ options.fallback !== undefined ||
149
+ options.audit !== undefined;
150
+ if (!hasAnyOption) {
151
+ const startedAt = monotonicNow();
152
+ try {
153
+ registerInflight('default');
154
+ }
155
+ catch (e) {
156
+ const now = monotonicNow();
157
+ const durationMs = now - startedAt;
158
+ recordError('default', 'ACTLY_RESOURCE_EXHAUSTED', sanitizeErrorMessage(e));
159
+ return { ok: false, error: e, attempts: 0, durationMs };
160
+ }
161
+ registerDrainable('default');
162
+ try {
163
+ const value = await fn(new AbortController().signal);
164
+ recordSuccess('default');
165
+ return { ok: true, value, source: 'fresh', attempts: 1, durationMs: monotonicNow() - startedAt };
166
+ }
167
+ catch (error) {
168
+ recordError('default', 'fn-error', sanitizeErrorMessage(error));
169
+ return { ok: false, error, attempts: 1, durationMs: monotonicNow() - startedAt };
170
+ }
171
+ finally {
172
+ unregisterInflight('default');
173
+ unregisterDrainable('default');
174
+ }
175
+ }
116
176
  const meta = { attempts: 1, source: 'fresh' };
117
- const rootController = buildRootSignal(options.signal);
118
- // Fast-fail if the user signal is already aborted. We do this after
119
- // validation so the caller still gets a TypeError for bad options rather
120
- // than a silent abort.
177
+ const { controller: rootController, cleanup } = buildRootSignal(options.signal);
178
+ const observability = buildObservability(options.observability, options.traceId);
179
+ const effectiveTraceId = observability?.traceId ?? options.traceId;
180
+ const startedAt = monotonicNow();
181
+ const scope = 'default';
182
+ try {
183
+ registerInflight(scope);
184
+ }
185
+ catch (e) {
186
+ cleanup();
187
+ const now = monotonicNow();
188
+ const durationMs = now - startedAt;
189
+ recordError(scope, 'ACTLY_RESOURCE_EXHAUSTED', sanitizeErrorMessage(e));
190
+ if (observability) {
191
+ safeCall(observability.hooks.onFinalFailure, {
192
+ type: 'final-failure',
193
+ key, traceId: observability.traceId, timestamp: Date.now(),
194
+ attempts: 0, durationMs,
195
+ failedBy: 'resource-exhausted', error: e,
196
+ });
197
+ }
198
+ if (options.audit) {
199
+ safeCall(options.audit.log, {
200
+ key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs,
201
+ ok: false, attempts: 0, failedBy: 'resource-exhausted',
202
+ error: sanitizeError(e),
203
+ });
204
+ }
205
+ return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
206
+ }
207
+ registerDrainable(scope);
121
208
  if (rootController.signal.aborted) {
122
- return { ok: false, error: rootController.signal.reason, attempts: 0 };
209
+ cleanup();
210
+ unregisterInflight(scope);
211
+ unregisterDrainable(scope);
212
+ const error = rootController.signal.reason;
213
+ const now = Date.now();
214
+ const durationMs = monotonicNow() - startedAt;
215
+ if (observability) {
216
+ safeCall(observability.hooks.onFinalFailure, {
217
+ type: 'final-failure',
218
+ key, traceId: observability.traceId, timestamp: now,
219
+ attempts: 0, durationMs,
220
+ failedBy: 'abort', error,
221
+ });
222
+ }
223
+ if (options.audit) {
224
+ const sanitizedError = sanitizeError(error);
225
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: false, attempts: 0, failedBy: 'abort', error: sanitizedError });
226
+ }
227
+ recordError(scope, 'ACTLY_ABORT', sanitizeErrorMessage(error));
228
+ return { ok: false, error, attempts: 0, traceId: effectiveTraceId, durationMs };
123
229
  }
124
- const policies = buildPolicies(options);
230
+ let policies;
125
231
  try {
126
- const value = await execute({
127
- key,
128
- fn,
129
- policies,
130
- store: defaultStore,
131
- meta,
132
- signal: rootController.signal,
232
+ policies = buildPolicies(options);
233
+ }
234
+ catch (e) {
235
+ cleanup();
236
+ unregisterInflight(scope);
237
+ unregisterDrainable(scope);
238
+ const now = Date.now();
239
+ const durationMs = monotonicNow() - startedAt;
240
+ recordError(scope, 'ACTLY_VALIDATION', sanitizeErrorMessage(e));
241
+ if (observability) {
242
+ safeCall(observability.hooks.onFinalFailure, {
243
+ type: 'final-failure',
244
+ key, traceId: observability.traceId, timestamp: now,
245
+ attempts: 0, durationMs, failedBy: 'validation', error: e,
246
+ });
247
+ }
248
+ if (options.audit) {
249
+ safeCall(options.audit.log, {
250
+ key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs,
251
+ ok: false, attempts: 0, failedBy: 'validation',
252
+ error: sanitizeError(e),
253
+ });
254
+ }
255
+ return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
256
+ }
257
+ const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
258
+ if (observability && !hasRetryPolicy) {
259
+ safeCall(observability.hooks.onAttempt, {
260
+ type: 'attempt', key, traceId: observability.traceId,
261
+ timestamp: Date.now(), attempt: 1,
133
262
  });
134
- return { ok: true, value, source: meta.source, attempts: meta.attempts };
263
+ }
264
+ const hedgePlacement = options.hedge?.placement ?? 'outside-retry';
265
+ const hedgeKeepLoser = options.hedge?.keepLoser ?? false;
266
+ const fnWithHedge = (options.hedge && hedgePlacement === 'inside-retry')
267
+ ? wrapHedge(fn, options.hedge.delayMs, hedgeKeepLoser)
268
+ : fn;
269
+ const needsRaceAbort = options.signal !== undefined;
270
+ try {
271
+ let value;
272
+ if (options.hedge && hedgePlacement === 'outside-retry') {
273
+ const hedgeMeta = { attempts: 1, source: 'fresh' };
274
+ const chainFactory = (signal, m) => execute({
275
+ key,
276
+ fn: fnWithHedge,
277
+ policies,
278
+ store: defaultStore,
279
+ meta: m,
280
+ signal,
281
+ observability,
282
+ });
283
+ const hedgeResult = needsRaceAbort
284
+ ? await raceAbort(Promise.resolve(runWithHedge(chainFactory, meta, hedgeMeta, rootController.signal, options.hedge.delayMs, hedgeKeepLoser)), rootController.signal)
285
+ : await runWithHedge(chainFactory, meta, hedgeMeta, rootController.signal, options.hedge.delayMs, hedgeKeepLoser);
286
+ meta.attempts = hedgeResult.winnerMeta.attempts;
287
+ meta.source = hedgeResult.winnerMeta.source;
288
+ value = hedgeResult.value;
289
+ }
290
+ else {
291
+ const execPromise = execute({
292
+ key,
293
+ fn: fnWithHedge,
294
+ policies,
295
+ store: defaultStore,
296
+ meta,
297
+ signal: rootController.signal,
298
+ observability,
299
+ });
300
+ value = needsRaceAbort
301
+ ? await raceAbort(Promise.resolve(execPromise), rootController.signal)
302
+ : await execPromise;
303
+ }
304
+ const now = Date.now();
305
+ const durationMs = monotonicNow() - startedAt;
306
+ recordSuccess(scope);
307
+ if (observability) {
308
+ safeCall(observability.hooks.onFinalSuccess, {
309
+ type: 'final-success',
310
+ key, traceId: observability.traceId, timestamp: now,
311
+ source: meta.source, attempts: meta.attempts, durationMs,
312
+ });
313
+ }
314
+ if (options.audit) {
315
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
316
+ }
317
+ return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
135
318
  }
136
319
  catch (error) {
137
- return { ok: false, error, attempts: meta.attempts };
320
+ const durationMs = monotonicNow() - startedAt;
321
+ const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
322
+ let errorRecorded = false;
323
+ if (options.fallback) {
324
+ recordError(scope, failedBy, sanitizeErrorMessage(error));
325
+ errorRecorded = true;
326
+ try {
327
+ const fallbackValue = typeof options.fallback.value === 'function'
328
+ ? await options.fallback.value()
329
+ : options.fallback.value;
330
+ if (observability) {
331
+ safeCall(observability.hooks.onFinalSuccess, {
332
+ type: 'final-success',
333
+ key, traceId: observability.traceId, timestamp: Date.now(),
334
+ source: meta.source, attempts: meta.attempts, durationMs,
335
+ });
336
+ }
337
+ if (options.audit) {
338
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
339
+ }
340
+ return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
341
+ }
342
+ catch (fallbackErr) {
343
+ if (observability) {
344
+ safeCall(observability.hooks.onFinalFailure, {
345
+ type: 'final-failure',
346
+ key, traceId: observability.traceId, timestamp: Date.now(),
347
+ attempts: meta.attempts, durationMs,
348
+ failedBy: 'fn-error', error: fallbackErr,
349
+ });
350
+ }
351
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
352
+ console.warn('Actly: fallback threw — error swallowed, surfacing original fn error.', fallbackErr);
353
+ }
354
+ }
355
+ }
356
+ const sanitizedError = options.audit ? sanitizeError(error) : error;
357
+ if (!errorRecorded) {
358
+ recordError(scope, failedBy, sanitizeErrorMessage(error));
359
+ }
360
+ if (observability) {
361
+ safeCall(observability.hooks.onFinalFailure, {
362
+ type: 'final-failure',
363
+ key, traceId: observability.traceId, timestamp: Date.now(),
364
+ attempts: meta.attempts, durationMs,
365
+ failedBy, error,
366
+ });
367
+ }
368
+ if (options.audit) {
369
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
370
+ }
371
+ return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
372
+ }
373
+ finally {
374
+ cleanup();
375
+ unregisterInflight(scope);
376
+ unregisterDrainable(scope);
377
+ }
378
+ }
379
+ export { HedgeTimeoutError } from '../errors.js';
380
+ function wrapHedge(fn, delayMs, keepLoser) {
381
+ return async (parentSignal) => {
382
+ const primaryCtl = new AbortController();
383
+ const hedgeCtl = new AbortController();
384
+ const unlinkPrimary = linkSignal(parentSignal, primaryCtl);
385
+ const unlinkHedge = linkSignal(parentSignal, hedgeCtl);
386
+ let timer;
387
+ let primary;
388
+ let hedgePromise;
389
+ try {
390
+ primary = Promise.resolve(fn(primaryCtl.signal));
391
+ const hedgeTimeout = new Promise((_, reject) => {
392
+ timer = setTimeout(() => reject(new HedgeTimeoutError()), delayMs);
393
+ });
394
+ try {
395
+ return await Promise.race([primary, hedgeTimeout]);
396
+ }
397
+ catch (e) {
398
+ if (!(e instanceof HedgeTimeoutError)) {
399
+ if (!keepLoser)
400
+ hedgeCtl.abort(new Error('hedge cancelled: primary rejected'));
401
+ throw e;
402
+ }
403
+ hedgePromise = Promise.resolve(fn(hedgeCtl.signal));
404
+ primary.catch(() => { });
405
+ hedgePromise.catch(() => { });
406
+ try {
407
+ const primaryTagged = primary.then(v => ({ value: v, winner: 'primary' }));
408
+ const hedgeTagged = hedgePromise.then(v => ({ value: v, winner: 'hedge' }));
409
+ const winner = await Promise.race([primaryTagged, hedgeTagged]);
410
+ if (!keepLoser) {
411
+ if (winner.winner === 'primary') {
412
+ hedgeCtl.abort(new Error('hedge cancelled: loser'));
413
+ }
414
+ else {
415
+ primaryCtl.abort(new Error('hedge cancelled: loser'));
416
+ }
417
+ }
418
+ return winner.value;
419
+ }
420
+ catch (err) {
421
+ if (!keepLoser) {
422
+ primaryCtl.abort(new Error('hedge cancelled: loser rejected'));
423
+ hedgeCtl.abort(new Error('hedge cancelled: loser rejected'));
424
+ }
425
+ throw err;
426
+ }
427
+ }
428
+ }
429
+ finally {
430
+ if (timer)
431
+ clearTimeout(timer);
432
+ unlinkPrimary();
433
+ unlinkHedge();
434
+ }
435
+ };
436
+ }
437
+ async function runWithHedge(chainFactory, primaryMeta, hedgeMeta, parentSignal, delayMs, keepLoser) {
438
+ const primaryCtl = new AbortController();
439
+ const hedgeCtl = new AbortController();
440
+ const unlinkPrimary = linkSignal(parentSignal, primaryCtl);
441
+ const unlinkHedge = linkSignal(parentSignal, hedgeCtl);
442
+ let timer;
443
+ try {
444
+ const primary = Promise.resolve(chainFactory(primaryCtl.signal, primaryMeta));
445
+ const hedgeTimeout = new Promise((_, reject) => {
446
+ timer = setTimeout(() => reject(new HedgeTimeoutError()), delayMs);
447
+ });
448
+ try {
449
+ const value = await Promise.race([primary, hedgeTimeout]);
450
+ return { value, winnerMeta: primaryMeta };
451
+ }
452
+ catch (e) {
453
+ if (!(e instanceof HedgeTimeoutError)) {
454
+ if (!keepLoser)
455
+ hedgeCtl.abort(new Error('hedge cancelled: primary rejected'));
456
+ throw e;
457
+ }
458
+ const hedge = Promise.resolve(chainFactory(hedgeCtl.signal, hedgeMeta));
459
+ primary.catch(() => { });
460
+ hedge.catch(() => { });
461
+ const primaryTagged = primary.then(v => ({ value: v, winnerMeta: primaryMeta }));
462
+ const hedgeTagged = hedge.then(v => ({ value: v, winnerMeta: hedgeMeta }));
463
+ try {
464
+ const winner = await Promise.race([primaryTagged, hedgeTagged]);
465
+ if (!keepLoser) {
466
+ primaryCtl.abort(new Error('hedge cancelled: loser'));
467
+ hedgeCtl.abort(new Error('hedge cancelled: loser'));
468
+ }
469
+ return winner;
470
+ }
471
+ catch (err) {
472
+ if (!keepLoser) {
473
+ primaryCtl.abort(new Error('hedge cancelled: loser rejected'));
474
+ hedgeCtl.abort(new Error('hedge cancelled: loser rejected'));
475
+ }
476
+ throw err;
477
+ }
478
+ }
479
+ }
480
+ finally {
481
+ if (timer)
482
+ clearTimeout(timer);
483
+ unlinkPrimary();
484
+ unlinkHedge();
138
485
  }
139
486
  }
140
- /**
141
- * Invalidate the cached value for `key` on the default module-level store.
142
- *
143
- * Only clears the cache slot — does not affect in-flight dedupe entries
144
- * (those will settle on their own). Returns `true` if a cache entry was
145
- * removed, `false` otherwise.
146
- *
147
- * Useful when you know the underlying data has changed and you want the
148
- * next `act()` call to re-run `fn` instead of serving stale cache:
149
- *
150
- * ```ts
151
- * await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
152
- * // ... user updates their profile ...
153
- * invalidate('user:42') // next call will re-fetch
154
- * ```
155
- */
156
487
  export function invalidate(key) {
488
+ assertKey(key);
157
489
  const cacheKey = CACHE_NS + key;
158
490
  const existed = defaultStore.has(cacheKey);
159
491
  defaultStore.delete(cacheKey);
160
492
  return existed;
161
493
  }
162
494
  export function withStore(store) {
495
+ const crypto = globalThis.crypto;
496
+ const scope = 'scoped:' + (crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 14));
497
+ registerStoreScope(store, scope);
163
498
  const scopedAct = async (key, fn, options = {}) => {
164
499
  assertKey(key);
165
500
  assertOptions(options);
166
501
  const meta = { attempts: 1, source: 'fresh' };
167
- const rootController = buildRootSignal(options.signal);
502
+ const { controller: rootController, cleanup } = buildRootSignal(options.signal);
503
+ const observability = buildObservability(options.observability, options.traceId);
504
+ const effectiveTraceId = observability?.traceId ?? options.traceId;
505
+ const startedAt = monotonicNow();
506
+ try {
507
+ registerInflight(scope);
508
+ }
509
+ catch (e) {
510
+ cleanup();
511
+ const now = monotonicNow();
512
+ const durationMs = now - startedAt;
513
+ recordError(scope, 'ACTLY_RESOURCE_EXHAUSTED', sanitizeErrorMessage(e));
514
+ if (observability) {
515
+ safeCall(observability.hooks.onFinalFailure, {
516
+ type: 'final-failure', key, traceId: observability.traceId, timestamp: Date.now(),
517
+ attempts: 0, durationMs, failedBy: 'resource-exhausted', error: e,
518
+ });
519
+ }
520
+ if (options.audit) {
521
+ safeCall(options.audit.log, {
522
+ key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs,
523
+ ok: false, attempts: 0, failedBy: 'resource-exhausted',
524
+ error: sanitizeError(e),
525
+ });
526
+ }
527
+ return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
528
+ }
529
+ registerDrainable(scope);
168
530
  if (rootController.signal.aborted) {
169
- return { ok: false, error: rootController.signal.reason, attempts: 0 };
531
+ cleanup();
532
+ unregisterInflight(scope);
533
+ unregisterDrainable(scope);
534
+ const error = rootController.signal.reason;
535
+ const now = Date.now();
536
+ const durationMs = now - startedAt;
537
+ recordError(scope, 'ACTLY_ABORT', sanitizeErrorMessage(error));
538
+ if (observability) {
539
+ safeCall(observability.hooks.onFinalFailure, {
540
+ type: 'final-failure', key, traceId: observability.traceId, timestamp: now,
541
+ attempts: 0, durationMs, failedBy: 'abort', error,
542
+ });
543
+ }
544
+ if (options.audit) {
545
+ const sanitizedError = sanitizeError(error);
546
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: false, attempts: 0, failedBy: 'abort', error: sanitizedError });
547
+ }
548
+ return { ok: false, error, attempts: 0, traceId: effectiveTraceId, durationMs };
170
549
  }
171
- const policies = buildPolicies(options);
550
+ let policies;
172
551
  try {
173
- const value = await execute({
174
- key,
175
- fn,
176
- policies,
177
- store,
178
- meta,
179
- signal: rootController.signal,
552
+ policies = buildPolicies(options);
553
+ }
554
+ catch (e) {
555
+ cleanup();
556
+ unregisterInflight(scope);
557
+ unregisterDrainable(scope);
558
+ const now = Date.now();
559
+ const durationMs = monotonicNow() - startedAt;
560
+ recordError(scope, 'ACTLY_VALIDATION', sanitizeErrorMessage(e));
561
+ if (observability) {
562
+ safeCall(observability.hooks.onFinalFailure, {
563
+ type: 'final-failure', key, traceId: observability.traceId, timestamp: now,
564
+ attempts: 0, durationMs, failedBy: 'validation', error: e,
565
+ });
566
+ }
567
+ if (options.audit) {
568
+ safeCall(options.audit.log, {
569
+ key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs,
570
+ ok: false, attempts: 0, failedBy: 'validation',
571
+ error: sanitizeError(e),
572
+ });
573
+ }
574
+ return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
575
+ }
576
+ const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
577
+ if (observability && !hasRetryPolicy) {
578
+ safeCall(observability.hooks.onAttempt, {
579
+ type: 'attempt', key, traceId: observability.traceId,
580
+ timestamp: Date.now(), attempt: 1,
180
581
  });
181
- return { ok: true, value, source: meta.source, attempts: meta.attempts };
582
+ }
583
+ const hedgePlacement = options.hedge?.placement ?? 'outside-retry';
584
+ const hedgeKeepLoser = options.hedge?.keepLoser ?? false;
585
+ const fnWithHedge = (options.hedge && hedgePlacement === 'inside-retry')
586
+ ? wrapHedge(fn, options.hedge.delayMs, hedgeKeepLoser)
587
+ : fn;
588
+ const needsRaceAbort = options.signal !== undefined;
589
+ try {
590
+ let value;
591
+ if (options.hedge && hedgePlacement === 'outside-retry') {
592
+ const hedgeMeta = { attempts: 1, source: 'fresh' };
593
+ const chainFactory = (signal, m) => execute({
594
+ key,
595
+ fn: fnWithHedge,
596
+ policies,
597
+ store,
598
+ meta: m,
599
+ signal,
600
+ observability,
601
+ });
602
+ const hedgeResult = needsRaceAbort
603
+ ? await raceAbort(Promise.resolve(runWithHedge(chainFactory, meta, hedgeMeta, rootController.signal, options.hedge.delayMs, hedgeKeepLoser)), rootController.signal)
604
+ : await runWithHedge(chainFactory, meta, hedgeMeta, rootController.signal, options.hedge.delayMs, hedgeKeepLoser);
605
+ meta.attempts = hedgeResult.winnerMeta.attempts;
606
+ meta.source = hedgeResult.winnerMeta.source;
607
+ value = hedgeResult.value;
608
+ }
609
+ else {
610
+ const execPromise = execute({
611
+ key,
612
+ fn: fnWithHedge,
613
+ policies,
614
+ store,
615
+ meta,
616
+ signal: rootController.signal,
617
+ observability,
618
+ });
619
+ value = needsRaceAbort
620
+ ? await raceAbort(Promise.resolve(execPromise), rootController.signal)
621
+ : await execPromise;
622
+ }
623
+ const now = Date.now();
624
+ const durationMs = monotonicNow() - startedAt;
625
+ recordSuccess(scope);
626
+ if (observability) {
627
+ safeCall(observability.hooks.onFinalSuccess, {
628
+ type: 'final-success', key, traceId: observability.traceId, timestamp: now,
629
+ source: meta.source, attempts: meta.attempts, durationMs,
630
+ });
631
+ }
632
+ if (options.audit) {
633
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
634
+ }
635
+ return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
182
636
  }
183
637
  catch (error) {
184
- return { ok: false, error, attempts: meta.attempts };
638
+ const now = Date.now();
639
+ const durationMs = monotonicNow() - startedAt;
640
+ const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
641
+ let errorRecorded = false;
642
+ if (options.fallback) {
643
+ recordError(scope, failedBy, sanitizeErrorMessage(error));
644
+ errorRecorded = true;
645
+ try {
646
+ const fallbackValue = typeof options.fallback.value === 'function'
647
+ ? await options.fallback.value()
648
+ : options.fallback.value;
649
+ if (observability) {
650
+ safeCall(observability.hooks.onFinalSuccess, {
651
+ type: 'final-success', key, traceId: observability.traceId, timestamp: Date.now(),
652
+ source: meta.source, attempts: meta.attempts, durationMs,
653
+ });
654
+ }
655
+ if (options.audit) {
656
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
657
+ }
658
+ return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
659
+ }
660
+ catch (fallbackErr) {
661
+ if (observability) {
662
+ safeCall(observability.hooks.onFinalFailure, {
663
+ type: 'final-failure', key, traceId: observability.traceId, timestamp: Date.now(),
664
+ attempts: meta.attempts, durationMs, failedBy: 'fn-error', error: fallbackErr,
665
+ });
666
+ }
667
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
668
+ console.warn('Actly: fallback threw — error swallowed, surfacing original fn error.', fallbackErr);
669
+ }
670
+ }
671
+ }
672
+ if (!errorRecorded) {
673
+ recordError(scope, failedBy, sanitizeErrorMessage(error));
674
+ }
675
+ if (observability) {
676
+ safeCall(observability.hooks.onFinalFailure, {
677
+ type: 'final-failure', key, traceId: observability.traceId, timestamp: now,
678
+ attempts: meta.attempts, durationMs, failedBy, error,
679
+ });
680
+ }
681
+ if (options.audit) {
682
+ const sanitizedError = sanitizeError(error);
683
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
684
+ }
685
+ return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
686
+ }
687
+ finally {
688
+ cleanup();
689
+ unregisterInflight(scope);
690
+ unregisterDrainable(scope);
185
691
  }
186
692
  };
187
- // Build the `invalidate` implementation. The runtime branch on
188
- // `isSyncStore` selects the correct path; the cast through `unknown`
189
- // is required because TypeScript cannot narrow the union return type
190
- // (`boolean | Promise<boolean>`) to match either overload signature
191
- // individually. The overloads at the call site guarantee callers see
192
- // the correct type.
193
693
  const invalidateImpl = (key) => {
194
694
  assertKey(key);
195
695
  const cacheKey = CACHE_NS + key;
196
696
  if (isSyncStore(store)) {
697
+ if (typeof store.deleteIfExists === 'function') {
698
+ return store.deleteIfExists(cacheKey);
699
+ }
197
700
  const existed = store.has(cacheKey);
198
701
  store.delete(cacheKey);
199
702
  return existed;
200
703
  }
201
- // Async store branch.
704
+ if (typeof store.deleteIfExists === 'function') {
705
+ return store.deleteIfExists(cacheKey);
706
+ }
202
707
  return (async () => {
203
708
  const existed = await store.has(cacheKey);
204
709
  await store.delete(cacheKey);
205
710
  return existed;
206
711
  })();
207
712
  };
208
- // Attach `invalidate` and `store` to the function object. We use
209
- // `Object.assign` rather than mutation so the types narrow cleanly at
210
- // the call site. The cast through `unknown` is necessary because the
211
- // implementation signature is wider than either overload.
212
713
  return Object.assign(scopedAct, {
213
714
  invalidate: invalidateImpl,
214
715
  store,
716
+ scope,
215
717
  });
216
718
  }
217
- //# sourceMappingURL=act.js.map