actly 1.1.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/MIGRATION.md +141 -0
  3. package/README.md +334 -428
  4. package/SECURITY.md +56 -0
  5. package/dist/core/act.cjs +630 -126
  6. package/dist/core/act.d.ts +6 -84
  7. package/dist/core/act.d.ts.map +1 -1
  8. package/dist/core/act.js +630 -129
  9. package/dist/core/executor.cjs +1 -26
  10. package/dist/core/executor.d.ts +2 -38
  11. package/dist/core/executor.d.ts.map +1 -1
  12. package/dist/core/executor.js +1 -27
  13. package/dist/core/health.cjs +163 -0
  14. package/dist/core/health.d.ts +33 -0
  15. package/dist/core/health.d.ts.map +1 -0
  16. package/dist/core/health.js +150 -0
  17. package/dist/core/shutdown.cjs +71 -0
  18. package/dist/core/shutdown.d.ts +5 -0
  19. package/dist/core/shutdown.d.ts.map +1 -0
  20. package/dist/core/shutdown.js +65 -0
  21. package/dist/core/tenant.cjs +113 -0
  22. package/dist/core/tenant.d.ts +24 -0
  23. package/dist/core/tenant.d.ts.map +1 -0
  24. package/dist/core/tenant.js +109 -0
  25. package/dist/errors.cjs +149 -0
  26. package/dist/errors.d.ts +94 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +134 -0
  29. package/dist/index.cjs +51 -8
  30. package/dist/index.d.ts +22 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +14 -6
  33. package/dist/observability.cjs +6 -0
  34. package/dist/observability.d.ts +86 -0
  35. package/dist/observability.d.ts.map +1 -0
  36. package/dist/observability.js +3 -0
  37. package/dist/policies/bulkhead.cjs +137 -0
  38. package/dist/policies/bulkhead.d.ts +3 -0
  39. package/dist/policies/bulkhead.d.ts.map +1 -0
  40. package/dist/policies/bulkhead.js +134 -0
  41. package/dist/policies/cache.cjs +84 -67
  42. package/dist/policies/cache.d.ts +0 -33
  43. package/dist/policies/cache.d.ts.map +1 -1
  44. package/dist/policies/cache.js +84 -68
  45. package/dist/policies/circuitBreaker.cjs +179 -0
  46. package/dist/policies/circuitBreaker.d.ts +3 -0
  47. package/dist/policies/circuitBreaker.d.ts.map +1 -0
  48. package/dist/policies/circuitBreaker.js +176 -0
  49. package/dist/policies/dedupe.cjs +46 -64
  50. package/dist/policies/dedupe.d.ts +0 -41
  51. package/dist/policies/dedupe.d.ts.map +1 -1
  52. package/dist/policies/dedupe.js +46 -65
  53. package/dist/policies/noop.cjs +6 -0
  54. package/dist/policies/noop.d.ts +3 -0
  55. package/dist/policies/noop.d.ts.map +1 -0
  56. package/dist/policies/noop.js +3 -0
  57. package/dist/policies/rateLimit.cjs +45 -0
  58. package/dist/policies/rateLimit.d.ts +3 -0
  59. package/dist/policies/rateLimit.d.ts.map +1 -0
  60. package/dist/policies/rateLimit.js +42 -0
  61. package/dist/policies/retry.cjs +129 -57
  62. package/dist/policies/retry.d.ts +0 -27
  63. package/dist/policies/retry.d.ts.map +1 -1
  64. package/dist/policies/retry.js +129 -58
  65. package/dist/policies/timeout.cjs +47 -103
  66. package/dist/policies/timeout.d.ts +2 -46
  67. package/dist/policies/timeout.d.ts.map +1 -1
  68. package/dist/policies/timeout.js +46 -102
  69. package/dist/stores/base.cjs +0 -10
  70. package/dist/stores/base.d.ts +4 -67
  71. package/dist/stores/base.d.ts.map +1 -1
  72. package/dist/stores/base.js +0 -11
  73. package/dist/stores/memory.cjs +147 -87
  74. package/dist/stores/memory.d.ts +11 -59
  75. package/dist/stores/memory.d.ts.map +1 -1
  76. package/dist/stores/memory.js +146 -88
  77. package/dist/testing/index.cjs +56 -0
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.d.ts.map +1 -0
  80. package/dist/testing/index.js +52 -0
  81. package/dist/types/index.cjs +0 -1
  82. package/dist/types/index.d.ts +62 -179
  83. package/dist/types/index.d.ts.map +1 -1
  84. package/dist/types/index.js +0 -2
  85. package/dist/utils/abort.cjs +46 -73
  86. package/dist/utils/abort.d.ts +4 -51
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +46 -74
  89. package/dist/utils/abortPool.cjs +25 -0
  90. package/dist/utils/abortPool.d.ts +4 -0
  91. package/dist/utils/abortPool.d.ts.map +1 -0
  92. package/dist/utils/abortPool.js +20 -0
  93. package/dist/utils/backoff.cjs +8 -15
  94. package/dist/utils/backoff.d.ts +0 -11
  95. package/dist/utils/backoff.d.ts.map +1 -1
  96. package/dist/utils/backoff.js +8 -16
  97. package/dist/utils/decorator.cjs +44 -0
  98. package/dist/utils/decorator.d.ts +3 -0
  99. package/dist/utils/decorator.d.ts.map +1 -0
  100. package/dist/utils/decorator.js +41 -0
  101. package/dist/utils/key.cjs +45 -0
  102. package/dist/utils/key.d.ts +2 -0
  103. package/dist/utils/key.d.ts.map +1 -0
  104. package/dist/utils/key.js +42 -0
  105. package/dist/utils/limits.cjs +22 -0
  106. package/dist/utils/limits.d.ts +21 -0
  107. package/dist/utils/limits.d.ts.map +1 -0
  108. package/dist/utils/limits.js +19 -0
  109. package/dist/utils/safeCall.cjs +28 -0
  110. package/dist/utils/safeCall.d.ts +2 -0
  111. package/dist/utils/safeCall.d.ts.map +1 -0
  112. package/dist/utils/safeCall.js +25 -0
  113. package/dist/utils/sanitize.cjs +50 -0
  114. package/dist/utils/sanitize.d.ts +3 -0
  115. package/dist/utils/sanitize.d.ts.map +1 -0
  116. package/dist/utils/sanitize.js +46 -0
  117. package/dist/utils/validate.cjs +163 -26
  118. package/dist/utils/validate.d.ts +6 -8
  119. package/dist/utils/validate.d.ts.map +1 -1
  120. package/dist/utils/validate.js +158 -27
  121. package/package.json +42 -7
  122. package/dist/core/act.js.map +0 -1
  123. package/dist/core/executor.js.map +0 -1
  124. package/dist/index.js.map +0 -1
  125. package/dist/policies/cache.js.map +0 -1
  126. package/dist/policies/dedupe.js.map +0 -1
  127. package/dist/policies/retry.js.map +0 -1
  128. package/dist/policies/timeout.js.map +0 -1
  129. package/dist/state/store.cjs +0 -14
  130. package/dist/state/store.d.ts +0 -11
  131. package/dist/state/store.d.ts.map +0 -1
  132. package/dist/state/store.js +0 -11
  133. package/dist/state/store.js.map +0 -1
  134. package/dist/stores/base.js.map +0 -1
  135. package/dist/stores/memory.js.map +0 -1
  136. package/dist/types/index.js.map +0 -1
  137. package/dist/utils/abort.js.map +0 -1
  138. package/dist/utils/backoff.js.map +0 -1
  139. package/dist/utils/validate.js.map +0 -1
package/dist/core/act.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;
@@ -8,214 +9,717 @@ const retry_js_1 = require("../policies/retry.js");
8
9
  const timeout_js_1 = require("../policies/timeout.js");
9
10
  const dedupe_js_1 = require("../policies/dedupe.js");
10
11
  const cache_js_1 = require("../policies/cache.js");
12
+ const circuitBreaker_js_1 = require("../policies/circuitBreaker.js");
13
+ const bulkhead_js_1 = require("../policies/bulkhead.js");
14
+ const rateLimit_js_1 = require("../policies/rateLimit.js");
11
15
  const memory_js_1 = require("../stores/memory.js");
12
16
  const base_js_1 = require("../stores/base.js");
13
17
  const abort_js_1 = require("../utils/abort.js");
18
+ const sanitize_js_1 = require("../utils/sanitize.js");
19
+ const safeCall_js_1 = require("../utils/safeCall.js");
20
+ const health_js_1 = require("./health.js");
21
+ const shutdown_js_1 = require("./shutdown.js");
14
22
  const validate_js_1 = require("../utils/validate.js");
15
- // Module-level default store so cache and dedupe persist across calls.
16
- // Always an InMemoryStore (SyncStateStore) — required because the default
17
- // chain may include dedupePolicy, which mandates synchronous store access.
18
- // For SSR isolation or per-test control, use `withStore()` or call
19
- // `execute()` directly with an explicit store.
20
- const defaultStore = new memory_js_1.InMemoryStore();
21
- // Namespace prefixes used by policies. Kept here (not in policy files) so
22
- // `invalidate()` can resolve cache keys without importing policy internals.
23
+ const errors_js_1 = require("../errors.js");
24
+ const defaultStore = (0, memory_js_1.createDefaultStore)();
23
25
  const CACHE_NS = 'cache:';
24
- /**
25
- * Normalise `dedupe: true` shorthand to `DedupeOptions`.
26
- * Returns `undefined` if dedupe is disabled or absent.
27
- */
26
+ function monotonicNow() {
27
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
28
+ return performance.now();
29
+ }
30
+ return Date.now();
31
+ }
32
+ function generateTraceId() {
33
+ const crypto = globalThis.crypto;
34
+ if (crypto && typeof crypto.randomUUID === 'function') {
35
+ return crypto.randomUUID();
36
+ }
37
+ return `actly-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
38
+ }
39
+ function buildObservability(hooks, traceId) {
40
+ if (!hooks)
41
+ return undefined;
42
+ const hasAnyHook = !!hooks.onAttempt ||
43
+ !!hooks.onRetry ||
44
+ !!hooks.onCacheHit ||
45
+ !!hooks.onCacheMiss ||
46
+ !!hooks.onDedupeJoin ||
47
+ !!hooks.onTimeout ||
48
+ !!hooks.onFinalSuccess ||
49
+ !!hooks.onFinalFailure ||
50
+ !!hooks.onBackpressure ||
51
+ !!hooks.onWatchdog;
52
+ if (!hasAnyHook)
53
+ return undefined;
54
+ return {
55
+ traceId: traceId ?? generateTraceId(),
56
+ hooks,
57
+ joinerCounter: 0,
58
+ };
59
+ }
60
+ function classifyFailure(error) {
61
+ if (error == null || typeof error !== 'object')
62
+ return 'fn-error';
63
+ const code = error.code;
64
+ if (code === 'ACTLY_ABORT')
65
+ return 'abort';
66
+ if (code === 'ACTLY_TIMEOUT')
67
+ return 'timeout';
68
+ if (code === 'ACTLY_TOTAL_TIMEOUT')
69
+ return 'total-timeout';
70
+ if (code === 'ACTLY_RETRY_EXHAUSTED')
71
+ return 'retry-exhausted';
72
+ if (code === 'ACTLY_VALIDATION')
73
+ return 'validation';
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';
84
+ if (error instanceof Error) {
85
+ const name = error.name;
86
+ if (name === 'AbortError')
87
+ return 'abort';
88
+ }
89
+ return 'fn-error';
90
+ }
28
91
  function normalizeDedupe(opt) {
29
92
  if (opt === true)
30
93
  return { enabled: true };
31
94
  if (opt && typeof opt === 'object' && opt.enabled) {
32
- return {
33
- enabled: true,
34
- ...(opt.inflightTtl !== undefined ? { inflightTtl: opt.inflightTtl } : {}),
35
- };
95
+ if (opt.inflightTtl !== undefined && opt.inflightTtl !== 0 && !Number.isNaN(opt.inflightTtl)) {
96
+ return { enabled: true, inflightTtl: opt.inflightTtl };
97
+ }
98
+ return { enabled: true };
36
99
  }
37
100
  return undefined;
38
101
  }
39
- /**
40
- * Build the policy chain from `ActOptions`. The order is fixed and
41
- * documented in `executor.ts`. Policies with no effect (e.g. `retry.attempts: 1`)
42
- * are skipped — they would be pure overhead.
43
- */
44
102
  function buildPolicies(options) {
45
103
  const dedupe = normalizeDedupe(options.dedupe);
46
104
  const policies = [];
47
- // 0. Outermost: hard wall-clock budget over the entire operation.
48
- // If it fires, no inner policy can extend the deadline.
105
+ if (options.rateLimit) {
106
+ policies.push((0, rateLimit_js_1.rateLimitPolicy)(options.rateLimit));
107
+ }
108
+ if (options.circuitBreaker) {
109
+ policies.push((0, circuitBreaker_js_1.circuitBreakerPolicy)(options.circuitBreaker));
110
+ }
49
111
  if (options.totalTimeout && options.totalTimeout.ms > 0) {
50
112
  policies.push((0, timeout_js_1.totalTimeoutPolicy)(options.totalTimeout));
51
113
  }
52
- // 1. Cache: a hit short-circuits everything below it.
53
114
  if (options.cache && options.cache.ttl > 0) {
54
115
  policies.push((0, cache_js_1.cachePolicy)(options.cache));
55
116
  }
56
- // 2. Dedupe: collapses concurrent callers before retry fires.
117
+ if (options.bulkhead) {
118
+ policies.push((0, bulkhead_js_1.bulkheadPolicy)(options.bulkhead));
119
+ }
57
120
  if (dedupe) {
58
121
  policies.push((0, dedupe_js_1.dedupePolicy)(dedupe));
59
122
  }
60
- // 3. Retry: owns the attempt loop.
61
- // `attempts: 1` is a no-op — skip to avoid overhead.
62
123
  if (options.retry && options.retry.attempts > 1) {
63
124
  policies.push((0, retry_js_1.retryPolicy)(options.retry));
64
125
  }
65
- // 4. Innermost: per-attempt clock. Resets on every retry.
66
126
  if (options.timeout && options.timeout.ms > 0) {
67
127
  policies.push((0, timeout_js_1.timeoutPolicy)(options.timeout));
68
128
  }
69
129
  return policies;
70
130
  }
71
- /**
72
- * Build a root AbortController from `options.signal`.
73
- *
74
- * - If no user signal: returns a fresh controller that never aborts unless
75
- * an outer timeout policy aborts it.
76
- * - If user signal is already aborted: returns a controller that is already
77
- * aborted with the user's reason (so the operation rejects immediately).
78
- * - Otherwise: links the user signal to the controller.
79
- */
80
131
  function buildRootSignal(userSignal) {
81
132
  const controller = new AbortController();
82
- if (userSignal)
83
- (0, abort_js_1.linkSignal)(userSignal, controller);
84
- return controller;
133
+ if (userSignal) {
134
+ const unlink = (0, abort_js_1.linkSignal)(userSignal, controller);
135
+ return { controller, cleanup: unlink };
136
+ }
137
+ return { controller, cleanup: () => { } };
85
138
  }
86
- /**
87
- * Execute `fn` with the given reliability policies.
88
- *
89
- * @param key Stable identifier for this action. Scopes dedupe + cache.
90
- * @param fn The async work to run. Receives an `AbortSignal` for
91
- * cooperative cancellation (legacy `() => Promise<T>` is
92
- * still accepted — the signal is simply ignored).
93
- * @param options Which policies to apply and how. All fields are optional.
94
- *
95
- * @returns `ActResult<T>` — always resolves, never throws.
96
- * Check `result.ok` before reading `result.value`.
97
- *
98
- * @example
99
- * // With cooperative cancellation
100
- * const result = await act('user:42', async (signal) => {
101
- * return fetch(`/api/users/42`, { signal })
102
- * }, {
103
- * retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
104
- * timeout: { ms: 5_000 },
105
- * totalTimeout: { ms: 12_000 },
106
- * dedupe: true,
107
- * cache: { ttl: 60_000 },
108
- * })
109
- *
110
- * if (result.ok) {
111
- * console.log(result.value, result.source, result.attempts)
112
- * } else {
113
- * console.error(result.error)
114
- * }
115
- */
116
139
  async function act(key, fn, options = {}) {
117
- // Validate input upfront. Programmer errors throw — they should not be
118
- // swallowed into an ActFailure because the caller's code is broken.
119
140
  (0, validate_js_1.assertKey)(key);
120
141
  (0, validate_js_1.assertOptions)(options);
142
+ const hasAnyOption = options.retry !== undefined ||
143
+ options.timeout !== undefined ||
144
+ options.totalTimeout !== undefined ||
145
+ options.dedupe !== undefined ||
146
+ options.cache !== undefined ||
147
+ options.signal !== undefined ||
148
+ options.observability !== undefined ||
149
+ options.traceId !== undefined ||
150
+ options.circuitBreaker !== undefined ||
151
+ options.bulkhead !== undefined ||
152
+ options.rateLimit !== undefined ||
153
+ options.hedge !== undefined ||
154
+ options.fallback !== undefined ||
155
+ options.audit !== undefined;
156
+ if (!hasAnyOption) {
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
+ }
167
+ (0, shutdown_js_1.registerDrainable)('default');
168
+ try {
169
+ const value = await fn(new AbortController().signal);
170
+ (0, health_js_1.recordSuccess)('default');
171
+ return { ok: true, value, source: 'fresh', attempts: 1, durationMs: monotonicNow() - startedAt };
172
+ }
173
+ catch (error) {
174
+ (0, health_js_1.recordError)('default', 'fn-error', (0, sanitize_js_1.sanitizeErrorMessage)(error));
175
+ return { ok: false, error, attempts: 1, durationMs: monotonicNow() - startedAt };
176
+ }
177
+ finally {
178
+ (0, health_js_1.unregisterInflight)('default');
179
+ (0, shutdown_js_1.unregisterDrainable)('default');
180
+ }
181
+ }
121
182
  const meta = { attempts: 1, source: 'fresh' };
122
- const rootController = buildRootSignal(options.signal);
123
- // Fast-fail if the user signal is already aborted. We do this after
124
- // validation so the caller still gets a TypeError for bad options rather
125
- // than a silent abort.
183
+ const { controller: rootController, cleanup } = buildRootSignal(options.signal);
184
+ const observability = buildObservability(options.observability, options.traceId);
185
+ const effectiveTraceId = observability?.traceId ?? options.traceId;
186
+ const startedAt = monotonicNow();
187
+ const scope = 'default';
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
+ }
213
+ (0, shutdown_js_1.registerDrainable)(scope);
126
214
  if (rootController.signal.aborted) {
127
- return { ok: false, error: rootController.signal.reason, attempts: 0 };
215
+ cleanup();
216
+ (0, health_js_1.unregisterInflight)(scope);
217
+ (0, shutdown_js_1.unregisterDrainable)(scope);
218
+ const error = rootController.signal.reason;
219
+ const now = Date.now();
220
+ const durationMs = monotonicNow() - startedAt;
221
+ if (observability) {
222
+ (0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
223
+ type: 'final-failure',
224
+ key, traceId: observability.traceId, timestamp: now,
225
+ attempts: 0, durationMs,
226
+ failedBy: 'abort', error,
227
+ });
228
+ }
229
+ if (options.audit) {
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 });
232
+ }
233
+ (0, health_js_1.recordError)(scope, 'ACTLY_ABORT', (0, sanitize_js_1.sanitizeErrorMessage)(error));
234
+ return { ok: false, error, attempts: 0, traceId: effectiveTraceId, durationMs };
128
235
  }
129
- const policies = buildPolicies(options);
236
+ let policies;
130
237
  try {
131
- const value = await (0, executor_js_1.execute)({
132
- key,
133
- fn,
134
- policies,
135
- store: defaultStore,
136
- meta,
137
- signal: rootController.signal,
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 };
262
+ }
263
+ const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
264
+ if (observability && !hasRetryPolicy) {
265
+ (0, safeCall_js_1.safeCall)(observability.hooks.onAttempt, {
266
+ type: 'attempt', key, traceId: observability.traceId,
267
+ timestamp: Date.now(), attempt: 1,
138
268
  });
139
- return { ok: true, value, source: meta.source, attempts: meta.attempts };
269
+ }
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;
276
+ try {
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;
312
+ (0, health_js_1.recordSuccess)(scope);
313
+ if (observability) {
314
+ (0, safeCall_js_1.safeCall)(observability.hooks.onFinalSuccess, {
315
+ type: 'final-success',
316
+ key, traceId: observability.traceId, timestamp: now,
317
+ source: meta.source, attempts: meta.attempts, durationMs,
318
+ });
319
+ }
320
+ if (options.audit) {
321
+ (0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
322
+ }
323
+ return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
140
324
  }
141
325
  catch (error) {
142
- return { ok: false, error, attempts: meta.attempts };
326
+ const durationMs = monotonicNow() - startedAt;
327
+ const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
328
+ let errorRecorded = false;
329
+ if (options.fallback) {
330
+ (0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
331
+ errorRecorded = true;
332
+ try {
333
+ const fallbackValue = typeof options.fallback.value === 'function'
334
+ ? await options.fallback.value()
335
+ : options.fallback.value;
336
+ if (observability) {
337
+ (0, safeCall_js_1.safeCall)(observability.hooks.onFinalSuccess, {
338
+ type: 'final-success',
339
+ key, traceId: observability.traceId, timestamp: Date.now(),
340
+ source: meta.source, attempts: meta.attempts, durationMs,
341
+ });
342
+ }
343
+ if (options.audit) {
344
+ (0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
345
+ }
346
+ return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
347
+ }
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
+ }
360
+ }
361
+ }
362
+ const sanitizedError = options.audit ? (0, sanitize_js_1.sanitizeError)(error) : error;
363
+ if (!errorRecorded) {
364
+ (0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
365
+ }
366
+ if (observability) {
367
+ (0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
368
+ type: 'final-failure',
369
+ key, traceId: observability.traceId, timestamp: Date.now(),
370
+ attempts: meta.attempts, durationMs,
371
+ failedBy, error,
372
+ });
373
+ }
374
+ if (options.audit) {
375
+ (0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
376
+ }
377
+ return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
378
+ }
379
+ finally {
380
+ cleanup();
381
+ (0, health_js_1.unregisterInflight)(scope);
382
+ (0, shutdown_js_1.unregisterDrainable)(scope);
383
+ }
384
+ }
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);
393
+ let timer;
394
+ let primary;
395
+ let hedgePromise;
396
+ try {
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
+ }
434
+ }
435
+ }
436
+ finally {
437
+ if (timer)
438
+ clearTimeout(timer);
439
+ unlinkPrimary();
440
+ unlinkHedge();
441
+ }
442
+ };
443
+ }
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();
143
492
  }
144
493
  }
145
- /**
146
- * Invalidate the cached value for `key` on the default module-level store.
147
- *
148
- * Only clears the cache slot — does not affect in-flight dedupe entries
149
- * (those will settle on their own). Returns `true` if a cache entry was
150
- * removed, `false` otherwise.
151
- *
152
- * Useful when you know the underlying data has changed and you want the
153
- * next `act()` call to re-run `fn` instead of serving stale cache:
154
- *
155
- * ```ts
156
- * await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
157
- * // ... user updates their profile ...
158
- * invalidate('user:42') // next call will re-fetch
159
- * ```
160
- */
161
494
  function invalidate(key) {
495
+ (0, validate_js_1.assertKey)(key);
162
496
  const cacheKey = CACHE_NS + key;
163
497
  const existed = defaultStore.has(cacheKey);
164
498
  defaultStore.delete(cacheKey);
165
499
  return existed;
166
500
  }
167
501
  function withStore(store) {
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);
168
505
  const scopedAct = async (key, fn, options = {}) => {
169
506
  (0, validate_js_1.assertKey)(key);
170
507
  (0, validate_js_1.assertOptions)(options);
171
508
  const meta = { attempts: 1, source: 'fresh' };
172
- const rootController = buildRootSignal(options.signal);
509
+ const { controller: rootController, cleanup } = buildRootSignal(options.signal);
510
+ const observability = buildObservability(options.observability, options.traceId);
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
+ }
536
+ (0, shutdown_js_1.registerDrainable)(scope);
173
537
  if (rootController.signal.aborted) {
174
- return { ok: false, error: rootController.signal.reason, attempts: 0 };
538
+ cleanup();
539
+ (0, health_js_1.unregisterInflight)(scope);
540
+ (0, shutdown_js_1.unregisterDrainable)(scope);
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));
545
+ if (observability) {
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 };
175
556
  }
176
- const policies = buildPolicies(options);
557
+ let policies;
177
558
  try {
178
- const value = await (0, executor_js_1.execute)({
179
- key,
180
- fn,
181
- policies,
182
- store,
183
- meta,
184
- signal: rootController.signal,
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),
579
+ });
580
+ }
581
+ return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
582
+ }
583
+ const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
584
+ if (observability && !hasRetryPolicy) {
585
+ (0, safeCall_js_1.safeCall)(observability.hooks.onAttempt, {
586
+ type: 'attempt', key, traceId: observability.traceId,
587
+ timestamp: Date.now(), attempt: 1,
185
588
  });
186
- return { ok: true, value, source: meta.source, attempts: meta.attempts };
589
+ }
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;
596
+ try {
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;
632
+ (0, health_js_1.recordSuccess)(scope);
633
+ if (observability) {
634
+ (0, safeCall_js_1.safeCall)(observability.hooks.onFinalSuccess, {
635
+ type: 'final-success', key, traceId: observability.traceId, timestamp: now,
636
+ source: meta.source, attempts: meta.attempts, durationMs,
637
+ });
638
+ }
639
+ if (options.audit) {
640
+ (0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
641
+ }
642
+ return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
187
643
  }
188
644
  catch (error) {
189
- return { ok: false, error, attempts: meta.attempts };
645
+ const now = Date.now();
646
+ const durationMs = monotonicNow() - startedAt;
647
+ const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
648
+ let errorRecorded = false;
649
+ if (options.fallback) {
650
+ (0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
651
+ errorRecorded = true;
652
+ try {
653
+ const fallbackValue = typeof options.fallback.value === 'function'
654
+ ? await options.fallback.value()
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
+ }
662
+ if (options.audit) {
663
+ (0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
664
+ }
665
+ return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
666
+ }
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
+ }
677
+ }
678
+ }
679
+ if (!errorRecorded) {
680
+ (0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
681
+ }
682
+ if (observability) {
683
+ (0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
684
+ type: 'final-failure', key, traceId: observability.traceId, timestamp: now,
685
+ attempts: meta.attempts, durationMs, failedBy, error,
686
+ });
687
+ }
688
+ if (options.audit) {
689
+ const sanitizedError = (0, sanitize_js_1.sanitizeError)(error);
690
+ (0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
691
+ }
692
+ return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
693
+ }
694
+ finally {
695
+ cleanup();
696
+ (0, health_js_1.unregisterInflight)(scope);
697
+ (0, shutdown_js_1.unregisterDrainable)(scope);
190
698
  }
191
699
  };
192
- // Build the `invalidate` implementation. The runtime branch on
193
- // `isSyncStore` selects the correct path; the cast through `unknown`
194
- // is required because TypeScript cannot narrow the union return type
195
- // (`boolean | Promise<boolean>`) to match either overload signature
196
- // individually. The overloads at the call site guarantee callers see
197
- // the correct type.
198
700
  const invalidateImpl = (key) => {
199
701
  (0, validate_js_1.assertKey)(key);
200
702
  const cacheKey = CACHE_NS + key;
201
703
  if ((0, base_js_1.isSyncStore)(store)) {
704
+ if (typeof store.deleteIfExists === 'function') {
705
+ return store.deleteIfExists(cacheKey);
706
+ }
202
707
  const existed = store.has(cacheKey);
203
708
  store.delete(cacheKey);
204
709
  return existed;
205
710
  }
206
- // Async store branch.
711
+ if (typeof store.deleteIfExists === 'function') {
712
+ return store.deleteIfExists(cacheKey);
713
+ }
207
714
  return (async () => {
208
715
  const existed = await store.has(cacheKey);
209
716
  await store.delete(cacheKey);
210
717
  return existed;
211
718
  })();
212
719
  };
213
- // Attach `invalidate` and `store` to the function object. We use
214
- // `Object.assign` rather than mutation so the types narrow cleanly at
215
- // the call site. The cast through `unknown` is necessary because the
216
- // implementation signature is wider than either overload.
217
720
  return Object.assign(scopedAct, {
218
721
  invalidate: invalidateImpl,
219
722
  store,
723
+ scope,
220
724
  });
221
725
  }