actly 1.2.0 → 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 (151) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/MIGRATION.md +141 -0
  3. package/README.md +326 -218
  4. package/SECURITY.md +56 -0
  5. package/dist/core/act.cjs +406 -220
  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 +405 -222
  9. package/dist/core/executor.cjs +0 -27
  10. package/dist/core/executor.d.ts +0 -41
  11. package/dist/core/executor.d.ts.map +1 -1
  12. package/dist/core/executor.js +0 -28
  13. package/dist/core/health.cjs +146 -17
  14. package/dist/core/health.d.ts +20 -5
  15. package/dist/core/health.d.ts.map +1 -1
  16. package/dist/core/health.js +140 -18
  17. package/dist/core/shutdown.cjs +19 -8
  18. package/dist/core/shutdown.d.ts +1 -4
  19. package/dist/core/shutdown.d.ts.map +1 -1
  20. package/dist/core/shutdown.js +18 -9
  21. package/dist/core/tenant.cjs +61 -19
  22. package/dist/core/tenant.d.ts +5 -10
  23. package/dist/core/tenant.d.ts.map +1 -1
  24. package/dist/core/tenant.js +61 -20
  25. package/dist/errors.cjs +70 -103
  26. package/dist/errors.d.ts +33 -90
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +66 -103
  29. package/dist/index.cjs +25 -22
  30. package/dist/index.d.ts +12 -8
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -15
  33. package/dist/observability.cjs +0 -39
  34. package/dist/observability.d.ts +19 -78
  35. package/dist/observability.d.ts.map +1 -1
  36. package/dist/observability.js +0 -40
  37. package/dist/policies/bulkhead.cjs +44 -4
  38. package/dist/policies/bulkhead.d.ts +0 -0
  39. package/dist/policies/bulkhead.d.ts.map +1 -1
  40. package/dist/policies/bulkhead.js +44 -5
  41. package/dist/policies/cache.cjs +22 -142
  42. package/dist/policies/cache.d.ts +0 -46
  43. package/dist/policies/cache.d.ts.map +1 -1
  44. package/dist/policies/cache.js +22 -143
  45. package/dist/policies/circuitBreaker.cjs +125 -31
  46. package/dist/policies/circuitBreaker.d.ts +0 -0
  47. package/dist/policies/circuitBreaker.d.ts.map +1 -1
  48. package/dist/policies/circuitBreaker.js +125 -32
  49. package/dist/policies/dedupe.cjs +19 -66
  50. package/dist/policies/dedupe.d.ts +0 -23
  51. package/dist/policies/dedupe.d.ts.map +1 -1
  52. package/dist/policies/dedupe.js +19 -67
  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 +15 -5
  58. package/dist/policies/rateLimit.d.ts +0 -0
  59. package/dist/policies/rateLimit.d.ts.map +1 -1
  60. package/dist/policies/rateLimit.js +15 -6
  61. package/dist/policies/retry.cjs +100 -65
  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 +100 -66
  65. package/dist/policies/timeout.cjs +32 -60
  66. package/dist/policies/timeout.d.ts +0 -13
  67. package/dist/policies/timeout.d.ts.map +1 -1
  68. package/dist/policies/timeout.js +32 -71
  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 +75 -88
  74. package/dist/stores/memory.d.ts +7 -74
  75. package/dist/stores/memory.d.ts.map +1 -1
  76. package/dist/stores/memory.js +75 -89
  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 +17 -307
  83. package/dist/types/index.d.ts.map +1 -1
  84. package/dist/types/index.js +0 -2
  85. package/dist/utils/abort.cjs +14 -111
  86. package/dist/utils/abort.d.ts +3 -74
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +14 -112
  89. package/dist/utils/abortPool.cjs +1 -2
  90. package/dist/utils/abortPool.d.ts +0 -0
  91. package/dist/utils/abortPool.d.ts.map +1 -1
  92. package/dist/utils/abortPool.js +1 -3
  93. package/dist/utils/backoff.cjs +3 -21
  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 +3 -22
  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 +13 -45
  102. package/dist/utils/key.d.ts +0 -27
  103. package/dist/utils/key.d.ts.map +1 -1
  104. package/dist/utils/key.js +13 -46
  105. package/dist/utils/limits.cjs +9 -27
  106. package/dist/utils/limits.d.ts +9 -27
  107. package/dist/utils/limits.d.ts.map +1 -1
  108. package/dist/utils/limits.js +9 -28
  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 +22 -1
  114. package/dist/utils/sanitize.d.ts +0 -0
  115. package/dist/utils/sanitize.d.ts.map +1 -1
  116. package/dist/utils/sanitize.js +22 -2
  117. package/dist/utils/validate.cjs +81 -18
  118. package/dist/utils/validate.d.ts +6 -19
  119. package/dist/utils/validate.d.ts.map +1 -1
  120. package/dist/utils/validate.js +81 -19
  121. package/package.json +38 -4
  122. package/dist/core/act.js.map +0 -1
  123. package/dist/core/executor.js.map +0 -1
  124. package/dist/core/health.js.map +0 -1
  125. package/dist/core/shutdown.js.map +0 -1
  126. package/dist/core/tenant.js.map +0 -1
  127. package/dist/errors.js.map +0 -1
  128. package/dist/index.js.map +0 -1
  129. package/dist/observability.js.map +0 -1
  130. package/dist/policies/bulkhead.js.map +0 -1
  131. package/dist/policies/cache.js.map +0 -1
  132. package/dist/policies/circuitBreaker.js.map +0 -1
  133. package/dist/policies/dedupe.js.map +0 -1
  134. package/dist/policies/rateLimit.js.map +0 -1
  135. package/dist/policies/retry.js.map +0 -1
  136. package/dist/policies/timeout.js.map +0 -1
  137. package/dist/state/store.cjs +0 -14
  138. package/dist/state/store.d.ts +0 -11
  139. package/dist/state/store.d.ts.map +0 -1
  140. package/dist/state/store.js +0 -11
  141. package/dist/state/store.js.map +0 -1
  142. package/dist/stores/base.js.map +0 -1
  143. package/dist/stores/memory.js.map +0 -1
  144. package/dist/types/index.js.map +0 -1
  145. package/dist/utils/abort.js.map +0 -1
  146. package/dist/utils/abortPool.js.map +0 -1
  147. package/dist/utils/backoff.js.map +0 -1
  148. package/dist/utils/key.js.map +0 -1
  149. package/dist/utils/limits.js.map +0 -1
  150. package/dist/utils/sanitize.js.map +0 -1
  151. package/dist/utils/validate.js.map +0 -1
package/dist/core/act.js CHANGED
@@ -10,24 +10,19 @@ import { createDefaultStore } from '../stores/memory.js';
10
10
  import { isSyncStore } from '../stores/base.js';
11
11
  import { linkSignal, raceAbort } from '../utils/abort.js';
12
12
  import { sanitizeError, sanitizeErrorMessage } from '../utils/sanitize.js';
13
- import { registerInflight, unregisterInflight, recordError, recordSuccess } from './health.js';
13
+ import { safeCall } from '../utils/safeCall.js';
14
+ import { registerInflight, unregisterInflight, recordError, recordSuccess, registerStoreScope } from './health.js';
14
15
  import { registerDrainable, unregisterDrainable } from './shutdown.js';
15
16
  import { assertKey, assertOptions, } from '../utils/validate.js';
16
- // Module-level default store so cache and dedupe persist across calls.
17
- // Bounded by default (maxSize: 10_000, autoCleanup: 60s) to
18
- // prevent unbounded memory growth in long-running servers. Callers who
19
- // want truly unbounded storage must construct their own `InMemoryStore`
20
- // with `maxSize: Infinity` and pass it via `withStore()`.
17
+ import { HedgeTimeoutError } from '../errors.js';
21
18
  const defaultStore = createDefaultStore();
22
- // Namespace prefixes used by policies. Kept here (not in policy files) so
23
- // `invalidate()` can resolve cache keys without importing policy internals.
24
19
  const CACHE_NS = 'cache:';
25
- /**
26
- * Generate a trace ID for correlation across logs/metrics.
27
- *
28
- * Uses `crypto.randomUUID()` on Node 20+ (fast, native). Falls back to a
29
- * timestamp+random string for environments without it.
30
- */
20
+ function monotonicNow() {
21
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
22
+ return performance.now();
23
+ }
24
+ return Date.now();
25
+ }
31
26
  function generateTraceId() {
32
27
  const crypto = globalThis.crypto;
33
28
  if (crypto && typeof crypto.randomUUID === 'function') {
@@ -35,18 +30,9 @@ function generateTraceId() {
35
30
  }
36
31
  return `actly-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
37
32
  }
38
- /**
39
- * Build the observability context (or return undefined if no hooks).
40
- * The context is shared via PolicyContext.observability so every policy
41
- * can emit events through the same traceId + hooks.
42
- */
43
33
  function buildObservability(hooks, traceId) {
44
- // Contract: if no hooks are supplied, return undefined.
45
- // Policies check `ctx.observability != null` before allocating events.
46
34
  if (!hooks)
47
35
  return undefined;
48
- // Check that at least one hook is actually defined (avoid allocating
49
- // context for an empty hooks object).
50
36
  const hasAnyHook = !!hooks.onAttempt ||
51
37
  !!hooks.onRetry ||
52
38
  !!hooks.onCacheHit ||
@@ -54,7 +40,9 @@ function buildObservability(hooks, traceId) {
54
40
  !!hooks.onDedupeJoin ||
55
41
  !!hooks.onTimeout ||
56
42
  !!hooks.onFinalSuccess ||
57
- !!hooks.onFinalFailure;
43
+ !!hooks.onFinalFailure ||
44
+ !!hooks.onBackpressure ||
45
+ !!hooks.onWatchdog;
58
46
  if (!hasAnyHook)
59
47
  return undefined;
60
48
  return {
@@ -63,10 +51,6 @@ function buildObservability(hooks, traceId) {
63
51
  joinerCounter: 0,
64
52
  };
65
53
  }
66
- /**
67
- * Determine the `failedBy` discriminator from a caught error.
68
- * Name sniffing requires `instanceof Error` to prevent plain-object spoofing.
69
- */
70
54
  function classifyFailure(error) {
71
55
  if (error == null || typeof error !== 'object')
72
56
  return 'fn-error';
@@ -81,8 +65,16 @@ function classifyFailure(error) {
81
65
  return 'retry-exhausted';
82
66
  if (code === 'ACTLY_VALIDATION')
83
67
  return 'validation';
84
- // Fall back to name sniffing for non-actly errors (e.g. DOMException abort).
85
- // Require instanceof Error to prevent plain-object spoofing.
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';
86
78
  if (error instanceof Error) {
87
79
  const name = error.name;
88
80
  if (name === 'AbortError')
@@ -90,72 +82,46 @@ function classifyFailure(error) {
90
82
  }
91
83
  return 'fn-error';
92
84
  }
93
- /**
94
- * Normalise `dedupe: true` shorthand to `DedupeOptions`.
95
- * Returns `undefined` if dedupe is disabled or absent.
96
- */
97
85
  function normalizeDedupe(opt) {
98
86
  if (opt === true)
99
87
  return { enabled: true };
100
88
  if (opt && typeof opt === 'object' && opt.enabled) {
101
- return {
102
- enabled: true,
103
- ...(opt.inflightTtl !== undefined ? { inflightTtl: opt.inflightTtl } : {}),
104
- };
89
+ if (opt.inflightTtl !== undefined && opt.inflightTtl !== 0 && !Number.isNaN(opt.inflightTtl)) {
90
+ return { enabled: true, inflightTtl: opt.inflightTtl };
91
+ }
92
+ return { enabled: true };
105
93
  }
106
94
  return undefined;
107
95
  }
108
- /**
109
- * Build the policy chain from `ActOptions`. The order is fixed and
110
- * documented in `executor.ts`. Policies with no effect (e.g. `retry.attempts: 1`)
111
- * are skipped — they would be pure overhead.
112
- */
113
96
  function buildPolicies(options) {
114
97
  const dedupe = normalizeDedupe(options.dedupe);
115
98
  const policies = [];
116
- // Outermost: rate limiter (blocks before any work is done)
117
99
  if (options.rateLimit) {
118
100
  policies.push(rateLimitPolicy(options.rateLimit));
119
101
  }
120
- // Circuit breaker (blocks if downstream is failing)
121
102
  if (options.circuitBreaker) {
122
103
  policies.push(circuitBreakerPolicy(options.circuitBreaker));
123
104
  }
124
- // Hard wall-clock budget over the entire operation
125
105
  if (options.totalTimeout && options.totalTimeout.ms > 0) {
126
106
  policies.push(totalTimeoutPolicy(options.totalTimeout));
127
107
  }
128
- // Cache: a hit short-circuits everything below it
129
108
  if (options.cache && options.cache.ttl > 0) {
130
109
  policies.push(cachePolicy(options.cache));
131
110
  }
132
- // Bulkhead: limits concurrency per key
133
111
  if (options.bulkhead) {
134
112
  policies.push(bulkheadPolicy(options.bulkhead));
135
113
  }
136
- // Dedupe: collapses concurrent callers before retry fires
137
114
  if (dedupe) {
138
115
  policies.push(dedupePolicy(dedupe));
139
116
  }
140
- // Retry: owns the attempt loop (attempts: 1 is a no-op — skip)
141
117
  if (options.retry && options.retry.attempts > 1) {
142
118
  policies.push(retryPolicy(options.retry));
143
119
  }
144
- // Innermost: per-attempt clock. Resets on every retry
145
120
  if (options.timeout && options.timeout.ms > 0) {
146
121
  policies.push(timeoutPolicy(options.timeout));
147
122
  }
148
123
  return policies;
149
124
  }
150
- /**
151
- * Build a root AbortController from `options.signal`.
152
- *
153
- * - If no user signal: returns a fresh controller that never aborts unless
154
- * an outer timeout policy aborts it.
155
- * - If user signal is already aborted: returns a controller that is already
156
- * aborted with the user's reason (so the operation rejects immediately).
157
- * - Otherwise: links the user signal to the controller.
158
- */
159
125
  function buildRootSignal(userSignal) {
160
126
  const controller = new AbortController();
161
127
  if (userSignal) {
@@ -164,40 +130,9 @@ function buildRootSignal(userSignal) {
164
130
  }
165
131
  return { controller, cleanup: () => { } };
166
132
  }
167
- /**
168
- * Execute `fn` with the given reliability policies.
169
- *
170
- * @param key Stable identifier for this action. Scopes dedupe + cache.
171
- * @param fn The async work to run. Receives an `AbortSignal` for
172
- * cooperative cancellation (legacy `() => Promise<T>` is
173
- * still accepted — the signal is simply ignored).
174
- * @param options Which policies to apply and how. All fields are optional.
175
- *
176
- * @returns `ActResult<T>` — always resolves, never throws.
177
- * Check `result.ok` before reading `result.value`.
178
- *
179
- * @example
180
- * // With cooperative cancellation
181
- * const result = await act('user:42', async (signal) => {
182
- * return fetch(`/api/users/42`, { signal })
183
- * }, {
184
- * retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
185
- * timeout: { ms: 5_000 },
186
- * totalTimeout: { ms: 12_000 },
187
- * dedupe: true,
188
- * cache: { ttl: 60_000 },
189
- * })
190
- *
191
- * if (result.ok) {
192
- * console.log(result.value, result.source, result.attempts)
193
- * } else {
194
- * console.error(result.error)
195
- * }
196
- */
197
133
  export async function act(key, fn, options = {}) {
198
134
  assertKey(key);
199
135
  assertOptions(options);
200
- // Fast path: no options means no policies, no signal, no observability.
201
136
  const hasAnyOption = options.retry !== undefined ||
202
137
  options.timeout !== undefined ||
203
138
  options.totalTimeout !== undefined ||
@@ -213,17 +148,25 @@ export async function act(key, fn, options = {}) {
213
148
  options.fallback !== undefined ||
214
149
  options.audit !== undefined;
215
150
  if (!hasAnyOption) {
216
- const startedAt = Date.now();
217
- registerInflight('default');
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
+ }
218
161
  registerDrainable('default');
219
162
  try {
220
163
  const value = await fn(new AbortController().signal);
221
164
  recordSuccess('default');
222
- return { ok: true, value, source: 'fresh', attempts: 1, durationMs: Date.now() - startedAt };
165
+ return { ok: true, value, source: 'fresh', attempts: 1, durationMs: monotonicNow() - startedAt };
223
166
  }
224
167
  catch (error) {
225
168
  recordError('default', 'fn-error', sanitizeErrorMessage(error));
226
- return { ok: false, error, attempts: 1, durationMs: Date.now() - startedAt };
169
+ return { ok: false, error, attempts: 1, durationMs: monotonicNow() - startedAt };
227
170
  }
228
171
  finally {
229
172
  unregisterInflight('default');
@@ -233,69 +176,149 @@ export async function act(key, fn, options = {}) {
233
176
  const meta = { attempts: 1, source: 'fresh' };
234
177
  const { controller: rootController, cleanup } = buildRootSignal(options.signal);
235
178
  const observability = buildObservability(options.observability, options.traceId);
236
- const startedAt = Date.now();
179
+ const effectiveTraceId = observability?.traceId ?? options.traceId;
180
+ const startedAt = monotonicNow();
237
181
  const scope = 'default';
238
- registerInflight(scope);
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
+ }
239
207
  registerDrainable(scope);
240
208
  if (rootController.signal.aborted) {
241
209
  cleanup();
242
210
  unregisterInflight(scope);
243
211
  unregisterDrainable(scope);
244
212
  const error = rootController.signal.reason;
213
+ const now = Date.now();
214
+ const durationMs = monotonicNow() - startedAt;
245
215
  if (observability) {
246
- observability.hooks.onFinalFailure?.({
216
+ safeCall(observability.hooks.onFinalFailure, {
247
217
  type: 'final-failure',
248
- key, traceId: observability.traceId, timestamp: Date.now(),
249
- attempts: 0, durationMs: Date.now() - startedAt,
218
+ key, traceId: observability.traceId, timestamp: now,
219
+ attempts: 0, durationMs,
250
220
  failedBy: 'abort', error,
251
221
  });
252
222
  }
253
223
  if (options.audit) {
254
- options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs: Date.now() - startedAt, ok: false, attempts: 0, failedBy: 'abort', error });
224
+ const sanitizedError = sanitizeError(error);
225
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: false, attempts: 0, failedBy: 'abort', error: sanitizedError });
255
226
  }
256
227
  recordError(scope, 'ACTLY_ABORT', sanitizeErrorMessage(error));
257
- return { ok: false, error, attempts: 0, traceId: observability?.traceId, durationMs: Date.now() - startedAt };
228
+ return { ok: false, error, attempts: 0, traceId: effectiveTraceId, durationMs };
229
+ }
230
+ let policies;
231
+ try {
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 };
258
256
  }
259
- const policies = buildPolicies(options);
260
257
  const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
261
258
  if (observability && !hasRetryPolicy) {
262
- observability.hooks.onAttempt?.({
259
+ safeCall(observability.hooks.onAttempt, {
263
260
  type: 'attempt', key, traceId: observability.traceId,
264
261
  timestamp: Date.now(), attempt: 1,
265
262
  });
266
263
  }
267
- // Wrap fn with hedge request if configured
268
- const fnWithHedge = options.hedge ? wrapHedge(fn, options.hedge.delayMs) : fn;
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;
269
270
  try {
270
- const value = await raceAbort(execute({
271
- key,
272
- fn: fnWithHedge,
273
- policies,
274
- store: defaultStore,
275
- meta,
276
- signal: rootController.signal,
277
- observability,
278
- }), rootController.signal);
279
- const durationMs = Date.now() - startedAt;
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;
280
306
  recordSuccess(scope);
281
307
  if (observability) {
282
- observability.hooks.onFinalSuccess?.({
308
+ safeCall(observability.hooks.onFinalSuccess, {
283
309
  type: 'final-success',
284
- key, traceId: observability.traceId, timestamp: Date.now(),
310
+ key, traceId: observability.traceId, timestamp: now,
285
311
  source: meta.source, attempts: meta.attempts, durationMs,
286
312
  });
287
313
  }
288
314
  if (options.audit) {
289
- options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
315
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
290
316
  }
291
- return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
317
+ return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
292
318
  }
293
319
  catch (error) {
294
- const durationMs = Date.now() - startedAt;
320
+ const durationMs = monotonicNow() - startedAt;
295
321
  const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
296
- // Fallback: if configured, return fallback value instead of failure.
297
- // Record the error in health check so monitoring can detect downstream
298
- // failures even when fallback masks them from the caller.
299
322
  let errorRecorded = false;
300
323
  if (options.fallback) {
301
324
  recordError(scope, failedBy, sanitizeErrorMessage(error));
@@ -305,19 +328,29 @@ export async function act(key, fn, options = {}) {
305
328
  ? await options.fallback.value()
306
329
  : options.fallback.value;
307
330
  if (observability) {
308
- observability.hooks.onFinalSuccess?.({
331
+ safeCall(observability.hooks.onFinalSuccess, {
309
332
  type: 'final-success',
310
333
  key, traceId: observability.traceId, timestamp: Date.now(),
311
334
  source: meta.source, attempts: meta.attempts, durationMs,
312
335
  });
313
336
  }
314
337
  if (options.audit) {
315
- options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
338
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
316
339
  }
317
- return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: observability?.traceId, durationMs };
340
+ return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
318
341
  }
319
- catch {
320
- // fallback itself failed — fall through to normal failure
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
+ }
321
354
  }
322
355
  }
323
356
  const sanitizedError = options.audit ? sanitizeError(error) : error;
@@ -325,7 +358,7 @@ export async function act(key, fn, options = {}) {
325
358
  recordError(scope, failedBy, sanitizeErrorMessage(error));
326
359
  }
327
360
  if (observability) {
328
- observability.hooks.onFinalFailure?.({
361
+ safeCall(observability.hooks.onFinalFailure, {
329
362
  type: 'final-failure',
330
363
  key, traceId: observability.traceId, timestamp: Date.now(),
331
364
  attempts: meta.attempts, durationMs,
@@ -333,9 +366,9 @@ export async function act(key, fn, options = {}) {
333
366
  });
334
367
  }
335
368
  if (options.audit) {
336
- options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
369
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
337
370
  }
338
- return { ok: false, error, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
371
+ return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
339
372
  }
340
373
  finally {
341
374
  cleanup();
@@ -343,51 +376,114 @@ export async function act(key, fn, options = {}) {
343
376
  unregisterDrainable(scope);
344
377
  }
345
378
  }
346
- // Hedge request: sends a second call after delayMs, races them.
347
- // Clears timer on settle to prevent leak. Marks primary as handled
348
- // to prevent unhandled rejection if hedge wins.
349
- function wrapHedge(fn, delayMs) {
350
- return async (signal) => {
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);
351
386
  let timer;
352
- const primary = fn(signal);
353
- const hedgeTimeout = new Promise((_, reject) => {
354
- timer = setTimeout(() => reject(new Error('__HEDGE_TIMEOUT__')), delayMs);
355
- });
387
+ let primary;
388
+ let hedgePromise;
356
389
  try {
357
- return await Promise.race([primary, hedgeTimeout]);
358
- }
359
- catch (e) {
360
- if (e instanceof Error && e.message === '__HEDGE_TIMEOUT__') {
361
- // Primary is still running — mark as handled to prevent unhandled rejection
362
- Promise.resolve(primary).catch(() => { });
363
- const hedgePromise = Promise.resolve(fn(signal));
364
- hedgePromise.catch(() => { }); // mark hedge as handled if primary wins
365
- return await Promise.race([primary, hedgePromise]);
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
+ }
366
427
  }
367
- throw e;
368
428
  }
369
429
  finally {
370
430
  if (timer)
371
431
  clearTimeout(timer);
432
+ unlinkPrimary();
433
+ unlinkHedge();
372
434
  }
373
435
  };
374
436
  }
375
- /**
376
- * Invalidate the cached value for `key` on the default module-level store.
377
- *
378
- * Only clears the cache slot — does not affect in-flight dedupe entries
379
- * (those will settle on their own). Returns `true` if a cache entry was
380
- * removed, `false` otherwise.
381
- *
382
- * Useful when you know the underlying data has changed and you want the
383
- * next `act()` call to re-run `fn` instead of serving stale cache:
384
- *
385
- * ```ts
386
- * await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
387
- * // ... user updates their profile ...
388
- * invalidate('user:42') // next call will re-fetch
389
- * ```
390
- */
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();
485
+ }
486
+ }
391
487
  export function invalidate(key) {
392
488
  assertKey(key);
393
489
  const cacheKey = CACHE_NS + key;
@@ -396,63 +492,151 @@ export function invalidate(key) {
396
492
  return existed;
397
493
  }
398
494
  export function withStore(store) {
399
- const scope = 'scoped:' + Math.random().toString(36).slice(2, 8);
495
+ const crypto = globalThis.crypto;
496
+ const scope = 'scoped:' + (crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 14));
497
+ registerStoreScope(store, scope);
400
498
  const scopedAct = async (key, fn, options = {}) => {
401
499
  assertKey(key);
402
500
  assertOptions(options);
403
501
  const meta = { attempts: 1, source: 'fresh' };
404
502
  const { controller: rootController, cleanup } = buildRootSignal(options.signal);
405
503
  const observability = buildObservability(options.observability, options.traceId);
406
- const startedAt = Date.now();
407
- registerInflight(scope);
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
+ }
408
529
  registerDrainable(scope);
409
530
  if (rootController.signal.aborted) {
410
531
  cleanup();
411
532
  unregisterInflight(scope);
412
533
  unregisterDrainable(scope);
413
534
  const error = rootController.signal.reason;
535
+ const now = Date.now();
536
+ const durationMs = now - startedAt;
537
+ recordError(scope, 'ACTLY_ABORT', sanitizeErrorMessage(error));
414
538
  if (observability) {
415
- observability.hooks.onFinalFailure?.({
416
- type: 'final-failure', key, traceId: observability.traceId, timestamp: Date.now(),
417
- attempts: 0, durationMs: Date.now() - startedAt, failedBy: 'abort', error,
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 };
549
+ }
550
+ let policies;
551
+ try {
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),
418
572
  });
419
573
  }
420
- return { ok: false, error, attempts: 0, traceId: observability?.traceId, durationMs: Date.now() - startedAt };
574
+ return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
421
575
  }
422
- const policies = buildPolicies(options);
423
576
  const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
424
577
  if (observability && !hasRetryPolicy) {
425
- observability.hooks.onAttempt?.({
578
+ safeCall(observability.hooks.onAttempt, {
426
579
  type: 'attempt', key, traceId: observability.traceId,
427
580
  timestamp: Date.now(), attempt: 1,
428
581
  });
429
582
  }
430
- const fnWithHedge = options.hedge ? wrapHedge(fn, options.hedge.delayMs) : fn;
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;
431
589
  try {
432
- const value = await raceAbort(execute({
433
- key,
434
- fn: fnWithHedge,
435
- policies,
436
- store,
437
- meta,
438
- signal: rootController.signal,
439
- observability,
440
- }), rootController.signal);
441
- const durationMs = Date.now() - startedAt;
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;
442
625
  recordSuccess(scope);
443
626
  if (observability) {
444
- observability.hooks.onFinalSuccess?.({
445
- type: 'final-success', key, traceId: observability.traceId, timestamp: Date.now(),
627
+ safeCall(observability.hooks.onFinalSuccess, {
628
+ type: 'final-success', key, traceId: observability.traceId, timestamp: now,
446
629
  source: meta.source, attempts: meta.attempts, durationMs,
447
630
  });
448
631
  }
449
632
  if (options.audit) {
450
- options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
633
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
451
634
  }
452
- return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
635
+ return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
453
636
  }
454
637
  catch (error) {
455
- const durationMs = Date.now() - startedAt;
638
+ const now = Date.now();
639
+ const durationMs = monotonicNow() - startedAt;
456
640
  const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
457
641
  let errorRecorded = false;
458
642
  if (options.fallback) {
@@ -462,29 +646,43 @@ export function withStore(store) {
462
646
  const fallbackValue = typeof options.fallback.value === 'function'
463
647
  ? await options.fallback.value()
464
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
+ }
465
655
  if (options.audit) {
466
- options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
656
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
467
657
  }
468
- return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: observability?.traceId, durationMs };
658
+ return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
469
659
  }
470
- catch {
471
- // fallback failed — fall through
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
+ }
472
670
  }
473
671
  }
474
672
  if (!errorRecorded) {
475
673
  recordError(scope, failedBy, sanitizeErrorMessage(error));
476
674
  }
477
675
  if (observability) {
478
- observability.hooks.onFinalFailure?.({
479
- type: 'final-failure', key, traceId: observability.traceId, timestamp: Date.now(),
676
+ safeCall(observability.hooks.onFinalFailure, {
677
+ type: 'final-failure', key, traceId: observability.traceId, timestamp: now,
480
678
  attempts: meta.attempts, durationMs, failedBy, error,
481
679
  });
482
680
  }
483
681
  if (options.audit) {
484
682
  const sanitizedError = sanitizeError(error);
485
- options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
683
+ safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
486
684
  }
487
- return { ok: false, error, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
685
+ return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
488
686
  }
489
687
  finally {
490
688
  cleanup();
@@ -492,44 +690,29 @@ export function withStore(store) {
492
690
  unregisterDrainable(scope);
493
691
  }
494
692
  };
495
- // Build the `invalidate` implementation. The runtime branch on
496
- // `isSyncStore` selects the correct path; the cast through `unknown`
497
- // is required because TypeScript cannot narrow the union return type
498
- // (`boolean | Promise<boolean>`) to match either overload signature
499
- // individually. The overloads at the call site guarantee callers see
500
- // the correct type.
501
- //
502
- // FIX: The old async path did has() then delete() in separate awaits —
503
- // a TOCTOU race. Sync stores are safe (single synchronous frame).
504
- // Async stores document this as a known limitation.
505
693
  const invalidateImpl = (key) => {
506
694
  assertKey(key);
507
695
  const cacheKey = CACHE_NS + key;
508
696
  if (isSyncStore(store)) {
509
- // Sync store: has() + delete() in the same synchronous frame is safe in single-frame.
697
+ if (typeof store.deleteIfExists === 'function') {
698
+ return store.deleteIfExists(cacheKey);
699
+ }
510
700
  const existed = store.has(cacheKey);
511
701
  store.delete(cacheKey);
512
702
  return existed;
513
703
  }
514
- // Async store: safe in single-frame delete. We check has() first, then delete.
515
- // Between the two awaits, another caller could delete — but the return
516
- // value is "did this key exist at the time we checked", which is the
517
- // best we can do without a store-level delete-and-return-existed API.
518
- // For true safe in single-frame semantics, async stores should implement a
519
- // `deleteIfExists()` method — documented as a future API enhancement.
704
+ if (typeof store.deleteIfExists === 'function') {
705
+ return store.deleteIfExists(cacheKey);
706
+ }
520
707
  return (async () => {
521
708
  const existed = await store.has(cacheKey);
522
709
  await store.delete(cacheKey);
523
710
  return existed;
524
711
  })();
525
712
  };
526
- // Attach `invalidate` and `store` to the function object. We use
527
- // `Object.assign` rather than mutation so the types narrow cleanly at
528
- // the call site. The cast through `unknown` is necessary because the
529
- // implementation signature is wider than either overload.
530
713
  return Object.assign(scopedAct, {
531
714
  invalidate: invalidateImpl,
532
715
  store,
716
+ scope,
533
717
  });
534
718
  }
535
- //# sourceMappingURL=act.js.map