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