actly 1.1.0 → 1.2.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 (130) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +262 -232
  3. package/dist/core/act.cjs +505 -39
  4. package/dist/core/act.d.ts +81 -10
  5. package/dist/core/act.d.ts.map +1 -1
  6. package/dist/core/act.js +504 -40
  7. package/dist/core/act.js.map +1 -1
  8. package/dist/core/executor.cjs +21 -9
  9. package/dist/core/executor.d.ts +31 -6
  10. package/dist/core/executor.d.ts.map +1 -1
  11. package/dist/core/executor.js +21 -9
  12. package/dist/core/executor.js.map +1 -1
  13. package/dist/core/health.cjs +34 -0
  14. package/dist/core/health.d.ts +18 -0
  15. package/dist/core/health.d.ts.map +1 -0
  16. package/dist/core/health.js +28 -0
  17. package/dist/core/health.js.map +1 -0
  18. package/dist/core/shutdown.cjs +60 -0
  19. package/dist/core/shutdown.d.ts +8 -0
  20. package/dist/core/shutdown.d.ts.map +1 -0
  21. package/dist/core/shutdown.js +56 -0
  22. package/dist/core/shutdown.js.map +1 -0
  23. package/dist/core/tenant.cjs +71 -0
  24. package/dist/core/tenant.d.ts +29 -0
  25. package/dist/core/tenant.d.ts.map +1 -0
  26. package/dist/core/tenant.js +68 -0
  27. package/dist/core/tenant.js.map +1 -0
  28. package/dist/errors.cjs +182 -0
  29. package/dist/errors.d.ts +151 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +171 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/index.cjs +56 -6
  34. package/dist/index.d.ts +22 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +24 -4
  37. package/dist/index.js.map +1 -1
  38. package/dist/observability.cjs +45 -0
  39. package/dist/observability.d.ts +145 -0
  40. package/dist/observability.d.ts.map +1 -0
  41. package/dist/observability.js +43 -0
  42. package/dist/observability.js.map +1 -0
  43. package/dist/policies/bulkhead.cjs +97 -0
  44. package/dist/policies/bulkhead.d.ts +3 -0
  45. package/dist/policies/bulkhead.d.ts.map +1 -0
  46. package/dist/policies/bulkhead.js +95 -0
  47. package/dist/policies/bulkhead.js.map +1 -0
  48. package/dist/policies/cache.cjs +223 -22
  49. package/dist/policies/cache.d.ts +42 -7
  50. package/dist/policies/cache.d.ts.map +1 -1
  51. package/dist/policies/cache.js +223 -22
  52. package/dist/policies/cache.js.map +1 -1
  53. package/dist/policies/circuitBreaker.cjs +85 -0
  54. package/dist/policies/circuitBreaker.d.ts +3 -0
  55. package/dist/policies/circuitBreaker.d.ts.map +1 -0
  56. package/dist/policies/circuitBreaker.js +83 -0
  57. package/dist/policies/circuitBreaker.js.map +1 -0
  58. package/dist/policies/dedupe.cjs +98 -21
  59. package/dist/policies/dedupe.d.ts +20 -13
  60. package/dist/policies/dedupe.d.ts.map +1 -1
  61. package/dist/policies/dedupe.js +98 -21
  62. package/dist/policies/dedupe.js.map +1 -1
  63. package/dist/policies/rateLimit.cjs +35 -0
  64. package/dist/policies/rateLimit.d.ts +3 -0
  65. package/dist/policies/rateLimit.d.ts.map +1 -0
  66. package/dist/policies/rateLimit.js +33 -0
  67. package/dist/policies/rateLimit.js.map +1 -0
  68. package/dist/policies/retry.cjs +103 -26
  69. package/dist/policies/retry.d.ts +25 -2
  70. package/dist/policies/retry.d.ts.map +1 -1
  71. package/dist/policies/retry.js +103 -26
  72. package/dist/policies/retry.js.map +1 -1
  73. package/dist/policies/timeout.cjs +78 -36
  74. package/dist/policies/timeout.d.ts +7 -18
  75. package/dist/policies/timeout.d.ts.map +1 -1
  76. package/dist/policies/timeout.js +84 -31
  77. package/dist/policies/timeout.js.map +1 -1
  78. package/dist/state/store.cjs +9 -2
  79. package/dist/state/store.d.ts +9 -0
  80. package/dist/state/store.d.ts.map +1 -1
  81. package/dist/state/store.js +9 -2
  82. package/dist/state/store.js.map +1 -1
  83. package/dist/stores/base.cjs +4 -4
  84. package/dist/stores/base.d.ts +32 -56
  85. package/dist/stores/base.d.ts.map +1 -1
  86. package/dist/stores/base.js +4 -4
  87. package/dist/stores/base.js.map +1 -1
  88. package/dist/stores/memory.cjs +161 -39
  89. package/dist/stores/memory.d.ts +64 -24
  90. package/dist/stores/memory.d.ts.map +1 -1
  91. package/dist/stores/memory.js +160 -39
  92. package/dist/stores/memory.js.map +1 -1
  93. package/dist/types/index.d.ts +315 -37
  94. package/dist/types/index.d.ts.map +1 -1
  95. package/dist/utils/abort.cjs +212 -0
  96. package/dist/utils/abort.d.ts +79 -0
  97. package/dist/utils/abort.d.ts.map +1 -0
  98. package/dist/utils/abort.js +206 -0
  99. package/dist/utils/abort.js.map +1 -0
  100. package/dist/utils/abortPool.cjs +26 -0
  101. package/dist/utils/abortPool.d.ts +4 -0
  102. package/dist/utils/abortPool.d.ts.map +1 -0
  103. package/dist/utils/abortPool.js +22 -0
  104. package/dist/utils/abortPool.js.map +1 -0
  105. package/dist/utils/backoff.cjs +54 -0
  106. package/dist/utils/backoff.d.ts +14 -0
  107. package/dist/utils/backoff.d.ts.map +1 -0
  108. package/dist/utils/backoff.js +52 -0
  109. package/dist/utils/backoff.js.map +1 -0
  110. package/dist/utils/key.cjs +77 -0
  111. package/dist/utils/key.d.ts +29 -0
  112. package/dist/utils/key.d.ts.map +1 -0
  113. package/dist/utils/key.js +75 -0
  114. package/dist/utils/key.js.map +1 -0
  115. package/dist/utils/limits.cjs +40 -0
  116. package/dist/utils/limits.d.ts +39 -0
  117. package/dist/utils/limits.d.ts.map +1 -0
  118. package/dist/utils/limits.js +38 -0
  119. package/dist/utils/limits.js.map +1 -0
  120. package/dist/utils/sanitize.cjs +29 -0
  121. package/dist/utils/sanitize.d.ts +3 -0
  122. package/dist/utils/sanitize.d.ts.map +1 -0
  123. package/dist/utils/sanitize.js +26 -0
  124. package/dist/utils/sanitize.js.map +1 -0
  125. package/dist/utils/validate.cjs +153 -0
  126. package/dist/utils/validate.d.ts +26 -0
  127. package/dist/utils/validate.d.ts.map +1 -0
  128. package/dist/utils/validate.js +141 -0
  129. package/dist/utils/validate.js.map +1 -0
  130. package/package.json +20 -37
package/dist/core/act.cjs CHANGED
@@ -1,34 +1,196 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.act = act;
4
+ exports.invalidate = invalidate;
5
+ exports.withStore = withStore;
4
6
  const executor_js_1 = require("./executor.js");
5
7
  const retry_js_1 = require("../policies/retry.js");
6
8
  const timeout_js_1 = require("../policies/timeout.js");
7
9
  const dedupe_js_1 = require("../policies/dedupe.js");
8
10
  const cache_js_1 = require("../policies/cache.js");
11
+ const circuitBreaker_js_1 = require("../policies/circuitBreaker.js");
12
+ const bulkhead_js_1 = require("../policies/bulkhead.js");
13
+ const rateLimit_js_1 = require("../policies/rateLimit.js");
9
14
  const memory_js_1 = require("../stores/memory.js");
15
+ const base_js_1 = require("../stores/base.js");
16
+ const abort_js_1 = require("../utils/abort.js");
17
+ const sanitize_js_1 = require("../utils/sanitize.js");
18
+ const health_js_1 = require("./health.js");
19
+ const shutdown_js_1 = require("./shutdown.js");
20
+ const validate_js_1 = require("../utils/validate.js");
10
21
  // Module-level default store so cache and dedupe persist across calls.
11
- // Always an InMemoryStore (SyncStateStore) required because the default
12
- // chain may include dedupePolicy, which mandates synchronous store access.
13
- // For SSR isolation or per-test control, construct an InMemoryStore and
14
- // call execute() directly with an explicit store.
15
- const defaultStore = new memory_js_1.InMemoryStore();
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()`.
26
+ 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
+ const CACHE_NS = 'cache:';
16
30
  /**
17
- * Execute fn with the given reliability policies.
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
+ */
36
+ function generateTraceId() {
37
+ const crypto = globalThis.crypto;
38
+ if (crypto && typeof crypto.randomUUID === 'function') {
39
+ return crypto.randomUUID();
40
+ }
41
+ return `actly-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
42
+ }
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
+ function buildObservability(hooks, traceId) {
49
+ // Contract: if no hooks are supplied, return undefined.
50
+ // Policies check `ctx.observability != null` before allocating events.
51
+ if (!hooks)
52
+ return undefined;
53
+ // Check that at least one hook is actually defined (avoid allocating
54
+ // context for an empty hooks object).
55
+ const hasAnyHook = !!hooks.onAttempt ||
56
+ !!hooks.onRetry ||
57
+ !!hooks.onCacheHit ||
58
+ !!hooks.onCacheMiss ||
59
+ !!hooks.onDedupeJoin ||
60
+ !!hooks.onTimeout ||
61
+ !!hooks.onFinalSuccess ||
62
+ !!hooks.onFinalFailure;
63
+ if (!hasAnyHook)
64
+ return undefined;
65
+ return {
66
+ traceId: traceId ?? generateTraceId(),
67
+ hooks,
68
+ joinerCounter: 0,
69
+ };
70
+ }
71
+ /**
72
+ * Determine the `failedBy` discriminator from a caught error.
73
+ * Name sniffing requires `instanceof Error` to prevent plain-object spoofing.
74
+ */
75
+ function classifyFailure(error) {
76
+ if (error == null || typeof error !== 'object')
77
+ return 'fn-error';
78
+ const code = error.code;
79
+ if (code === 'ACTLY_ABORT')
80
+ return 'abort';
81
+ if (code === 'ACTLY_TIMEOUT')
82
+ return 'timeout';
83
+ if (code === 'ACTLY_TOTAL_TIMEOUT')
84
+ return 'total-timeout';
85
+ if (code === 'ACTLY_RETRY_EXHAUSTED')
86
+ return 'retry-exhausted';
87
+ if (code === 'ACTLY_VALIDATION')
88
+ 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.
91
+ if (error instanceof Error) {
92
+ const name = error.name;
93
+ if (name === 'AbortError')
94
+ return 'abort';
95
+ }
96
+ return 'fn-error';
97
+ }
98
+ /**
99
+ * Normalise `dedupe: true` shorthand to `DedupeOptions`.
100
+ * Returns `undefined` if dedupe is disabled or absent.
101
+ */
102
+ function normalizeDedupe(opt) {
103
+ if (opt === true)
104
+ return { enabled: true };
105
+ if (opt && typeof opt === 'object' && opt.enabled) {
106
+ return {
107
+ enabled: true,
108
+ ...(opt.inflightTtl !== undefined ? { inflightTtl: opt.inflightTtl } : {}),
109
+ };
110
+ }
111
+ return undefined;
112
+ }
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
+ function buildPolicies(options) {
119
+ const dedupe = normalizeDedupe(options.dedupe);
120
+ const policies = [];
121
+ // Outermost: rate limiter (blocks before any work is done)
122
+ if (options.rateLimit) {
123
+ policies.push((0, rateLimit_js_1.rateLimitPolicy)(options.rateLimit));
124
+ }
125
+ // Circuit breaker (blocks if downstream is failing)
126
+ if (options.circuitBreaker) {
127
+ policies.push((0, circuitBreaker_js_1.circuitBreakerPolicy)(options.circuitBreaker));
128
+ }
129
+ // Hard wall-clock budget over the entire operation
130
+ if (options.totalTimeout && options.totalTimeout.ms > 0) {
131
+ policies.push((0, timeout_js_1.totalTimeoutPolicy)(options.totalTimeout));
132
+ }
133
+ // Cache: a hit short-circuits everything below it
134
+ if (options.cache && options.cache.ttl > 0) {
135
+ policies.push((0, cache_js_1.cachePolicy)(options.cache));
136
+ }
137
+ // Bulkhead: limits concurrency per key
138
+ if (options.bulkhead) {
139
+ policies.push((0, bulkhead_js_1.bulkheadPolicy)(options.bulkhead));
140
+ }
141
+ // Dedupe: collapses concurrent callers before retry fires
142
+ if (dedupe) {
143
+ policies.push((0, dedupe_js_1.dedupePolicy)(dedupe));
144
+ }
145
+ // Retry: owns the attempt loop (attempts: 1 is a no-op — skip)
146
+ if (options.retry && options.retry.attempts > 1) {
147
+ policies.push((0, retry_js_1.retryPolicy)(options.retry));
148
+ }
149
+ // Innermost: per-attempt clock. Resets on every retry
150
+ if (options.timeout && options.timeout.ms > 0) {
151
+ policies.push((0, timeout_js_1.timeoutPolicy)(options.timeout));
152
+ }
153
+ return policies;
154
+ }
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
+ function buildRootSignal(userSignal) {
165
+ const controller = new AbortController();
166
+ if (userSignal) {
167
+ const unlink = (0, abort_js_1.linkSignal)(userSignal, controller);
168
+ return { controller, cleanup: unlink };
169
+ }
170
+ return { controller, cleanup: () => { } };
171
+ }
172
+ /**
173
+ * Execute `fn` with the given reliability policies.
18
174
  *
19
175
  * @param key Stable identifier for this action. Scopes dedupe + cache.
20
- * @param fn The async work to run.
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).
21
179
  * @param options Which policies to apply and how. All fields are optional.
22
180
  *
23
- * @returns ActResult<T> — always resolves, never throws.
24
- * Check result.ok before reading result.value.
181
+ * @returns `ActResult<T>` — always resolves, never throws.
182
+ * Check `result.ok` before reading `result.value`.
25
183
  *
26
184
  * @example
27
- * const result = await act('user:42', () => fetchUser(42), {
28
- * retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
29
- * timeout: { ms: 5_000 },
30
- * dedupe: true,
31
- * cache: { ttl: 60_000 },
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 },
32
194
  * })
33
195
  *
34
196
  * if (result.ok) {
@@ -38,36 +200,340 @@ const defaultStore = new memory_js_1.InMemoryStore();
38
200
  * }
39
201
  */
40
202
  async function act(key, fn, options = {}) {
41
- const meta = { attempts: 1, source: 'fresh' };
42
- // Normalise dedupe: true → { enabled: true } so the rest of the function
43
- // always works with the object form.
44
- const dedupe = typeof options.dedupe === 'boolean'
45
- ? { enabled: options.dedupe }
46
- : options.dedupe;
47
- // Outermost -> innermost. See executor.ts for why this ordering matters.
48
- const policies = [];
49
- // totalTimeout sits before everything — it's a hard wall-clock budget over
50
- // the entire operation. If it fires, no inner policy can extend the deadline.
51
- if (options.totalTimeout && options.totalTimeout.ms > 0) {
52
- policies.push((0, timeout_js_1.totalTimeoutPolicy)(options.totalTimeout)); // 0. hardest outer wall
53
- }
54
- if (options.cache && options.cache.ttl > 0) {
55
- policies.push((0, cache_js_1.cachePolicy)(options.cache)); // 1. skip all on hit
203
+ (0, validate_js_1.assertKey)(key);
204
+ (0, validate_js_1.assertOptions)(options);
205
+ // Fast path: no options means no policies, no signal, no observability.
206
+ const hasAnyOption = options.retry !== undefined ||
207
+ options.timeout !== undefined ||
208
+ options.totalTimeout !== undefined ||
209
+ options.dedupe !== undefined ||
210
+ options.cache !== undefined ||
211
+ options.signal !== undefined ||
212
+ options.observability !== undefined ||
213
+ options.traceId !== undefined ||
214
+ options.circuitBreaker !== undefined ||
215
+ options.bulkhead !== undefined ||
216
+ options.rateLimit !== undefined ||
217
+ options.hedge !== undefined ||
218
+ options.fallback !== undefined ||
219
+ options.audit !== undefined;
220
+ if (!hasAnyOption) {
221
+ const startedAt = Date.now();
222
+ (0, health_js_1.registerInflight)('default');
223
+ (0, shutdown_js_1.registerDrainable)('default');
224
+ try {
225
+ const value = await fn(new AbortController().signal);
226
+ (0, health_js_1.recordSuccess)('default');
227
+ return { ok: true, value, source: 'fresh', attempts: 1, durationMs: Date.now() - startedAt };
228
+ }
229
+ catch (error) {
230
+ (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 };
232
+ }
233
+ finally {
234
+ (0, health_js_1.unregisterInflight)('default');
235
+ (0, shutdown_js_1.unregisterDrainable)('default');
236
+ }
56
237
  }
57
- if (dedupe?.enabled) {
58
- policies.push((0, dedupe_js_1.dedupePolicy)()); // 2. collapse concurrent callers
59
- }
60
- if (options.retry && options.retry.attempts > 1) {
61
- policies.push((0, retry_js_1.retryPolicy)(options.retry)); // 3. own the attempt loop
238
+ const meta = { attempts: 1, source: 'fresh' };
239
+ const { controller: rootController, cleanup } = buildRootSignal(options.signal);
240
+ const observability = buildObservability(options.observability, options.traceId);
241
+ const startedAt = Date.now();
242
+ const scope = 'default';
243
+ (0, health_js_1.registerInflight)(scope);
244
+ (0, shutdown_js_1.registerDrainable)(scope);
245
+ if (rootController.signal.aborted) {
246
+ cleanup();
247
+ (0, health_js_1.unregisterInflight)(scope);
248
+ (0, shutdown_js_1.unregisterDrainable)(scope);
249
+ const error = rootController.signal.reason;
250
+ if (observability) {
251
+ observability.hooks.onFinalFailure?.({
252
+ type: 'final-failure',
253
+ key, traceId: observability.traceId, timestamp: Date.now(),
254
+ attempts: 0, durationMs: Date.now() - startedAt,
255
+ failedBy: 'abort', error,
256
+ });
257
+ }
258
+ 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 });
260
+ }
261
+ (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 };
62
263
  }
63
- if (options.timeout && options.timeout.ms > 0) {
64
- policies.push((0, timeout_js_1.timeoutPolicy)(options.timeout)); // 4. innermost — per-attempt clock
264
+ const policies = buildPolicies(options);
265
+ const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
266
+ if (observability && !hasRetryPolicy) {
267
+ observability.hooks.onAttempt?.({
268
+ type: 'attempt', key, traceId: observability.traceId,
269
+ timestamp: Date.now(), attempt: 1,
270
+ });
65
271
  }
272
+ // Wrap fn with hedge request if configured
273
+ const fnWithHedge = options.hedge ? wrapHedge(fn, options.hedge.delayMs) : fn;
66
274
  try {
67
- const value = await (0, executor_js_1.execute)({ key, fn, policies, store: defaultStore, meta });
68
- return { ok: true, value, source: meta.source, attempts: meta.attempts };
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;
285
+ (0, health_js_1.recordSuccess)(scope);
286
+ if (observability) {
287
+ observability.hooks.onFinalSuccess?.({
288
+ type: 'final-success',
289
+ key, traceId: observability.traceId, timestamp: Date.now(),
290
+ source: meta.source, attempts: meta.attempts, durationMs,
291
+ });
292
+ }
293
+ if (options.audit) {
294
+ options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
295
+ }
296
+ return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
69
297
  }
70
298
  catch (error) {
71
- return { ok: false, error, attempts: meta.attempts };
299
+ const durationMs = Date.now() - startedAt;
300
+ 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
+ let errorRecorded = false;
305
+ if (options.fallback) {
306
+ (0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
307
+ errorRecorded = true;
308
+ try {
309
+ const fallbackValue = typeof options.fallback.value === 'function'
310
+ ? await options.fallback.value()
311
+ : options.fallback.value;
312
+ if (observability) {
313
+ observability.hooks.onFinalSuccess?.({
314
+ type: 'final-success',
315
+ key, traceId: observability.traceId, timestamp: Date.now(),
316
+ source: meta.source, attempts: meta.attempts, durationMs,
317
+ });
318
+ }
319
+ if (options.audit) {
320
+ options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
321
+ }
322
+ return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: observability?.traceId, durationMs };
323
+ }
324
+ catch {
325
+ // fallback itself failed — fall through to normal failure
326
+ }
327
+ }
328
+ const sanitizedError = options.audit ? (0, sanitize_js_1.sanitizeError)(error) : error;
329
+ if (!errorRecorded) {
330
+ (0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
331
+ }
332
+ if (observability) {
333
+ observability.hooks.onFinalFailure?.({
334
+ type: 'final-failure',
335
+ key, traceId: observability.traceId, timestamp: Date.now(),
336
+ attempts: meta.attempts, durationMs,
337
+ failedBy, error,
338
+ });
339
+ }
340
+ if (options.audit) {
341
+ options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
342
+ }
343
+ return { ok: false, error, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
72
344
  }
345
+ finally {
346
+ cleanup();
347
+ (0, health_js_1.unregisterInflight)(scope);
348
+ (0, shutdown_js_1.unregisterDrainable)(scope);
349
+ }
350
+ }
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) => {
356
+ let timer;
357
+ const primary = fn(signal);
358
+ const hedgeTimeout = new Promise((_, reject) => {
359
+ timer = setTimeout(() => reject(new Error('__HEDGE_TIMEOUT__')), delayMs);
360
+ });
361
+ 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]);
371
+ }
372
+ throw e;
373
+ }
374
+ finally {
375
+ if (timer)
376
+ clearTimeout(timer);
377
+ }
378
+ };
379
+ }
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
+ */
396
+ function invalidate(key) {
397
+ (0, validate_js_1.assertKey)(key);
398
+ const cacheKey = CACHE_NS + key;
399
+ const existed = defaultStore.has(cacheKey);
400
+ defaultStore.delete(cacheKey);
401
+ return existed;
402
+ }
403
+ function withStore(store) {
404
+ const scope = 'scoped:' + Math.random().toString(36).slice(2, 8);
405
+ const scopedAct = async (key, fn, options = {}) => {
406
+ (0, validate_js_1.assertKey)(key);
407
+ (0, validate_js_1.assertOptions)(options);
408
+ const meta = { attempts: 1, source: 'fresh' };
409
+ const { controller: rootController, cleanup } = buildRootSignal(options.signal);
410
+ const observability = buildObservability(options.observability, options.traceId);
411
+ const startedAt = Date.now();
412
+ (0, health_js_1.registerInflight)(scope);
413
+ (0, shutdown_js_1.registerDrainable)(scope);
414
+ if (rootController.signal.aborted) {
415
+ cleanup();
416
+ (0, health_js_1.unregisterInflight)(scope);
417
+ (0, shutdown_js_1.unregisterDrainable)(scope);
418
+ const error = rootController.signal.reason;
419
+ 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,
423
+ });
424
+ }
425
+ return { ok: false, error, attempts: 0, traceId: observability?.traceId, durationMs: Date.now() - startedAt };
426
+ }
427
+ const policies = buildPolicies(options);
428
+ const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
429
+ if (observability && !hasRetryPolicy) {
430
+ observability.hooks.onAttempt?.({
431
+ type: 'attempt', key, traceId: observability.traceId,
432
+ timestamp: Date.now(), attempt: 1,
433
+ });
434
+ }
435
+ const fnWithHedge = options.hedge ? wrapHedge(fn, options.hedge.delayMs) : fn;
436
+ 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;
447
+ (0, health_js_1.recordSuccess)(scope);
448
+ if (observability) {
449
+ observability.hooks.onFinalSuccess?.({
450
+ type: 'final-success', key, traceId: observability.traceId, timestamp: Date.now(),
451
+ source: meta.source, attempts: meta.attempts, durationMs,
452
+ });
453
+ }
454
+ if (options.audit) {
455
+ options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
456
+ }
457
+ return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
458
+ }
459
+ catch (error) {
460
+ const durationMs = Date.now() - startedAt;
461
+ const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
462
+ let errorRecorded = false;
463
+ if (options.fallback) {
464
+ (0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
465
+ errorRecorded = true;
466
+ try {
467
+ const fallbackValue = typeof options.fallback.value === 'function'
468
+ ? await options.fallback.value()
469
+ : options.fallback.value;
470
+ if (options.audit) {
471
+ options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
472
+ }
473
+ return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: observability?.traceId, durationMs };
474
+ }
475
+ catch {
476
+ // fallback failed — fall through
477
+ }
478
+ }
479
+ if (!errorRecorded) {
480
+ (0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
481
+ }
482
+ if (observability) {
483
+ observability.hooks.onFinalFailure?.({
484
+ type: 'final-failure', key, traceId: observability.traceId, timestamp: Date.now(),
485
+ attempts: meta.attempts, durationMs, failedBy, error,
486
+ });
487
+ }
488
+ if (options.audit) {
489
+ 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 });
491
+ }
492
+ return { ok: false, error, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
493
+ }
494
+ finally {
495
+ cleanup();
496
+ (0, health_js_1.unregisterInflight)(scope);
497
+ (0, shutdown_js_1.unregisterDrainable)(scope);
498
+ }
499
+ };
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
+ const invalidateImpl = (key) => {
511
+ (0, validate_js_1.assertKey)(key);
512
+ const cacheKey = CACHE_NS + key;
513
+ if ((0, base_js_1.isSyncStore)(store)) {
514
+ // Sync store: has() + delete() in the same synchronous frame is safe in single-frame.
515
+ const existed = store.has(cacheKey);
516
+ store.delete(cacheKey);
517
+ return existed;
518
+ }
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.
525
+ return (async () => {
526
+ const existed = await store.has(cacheKey);
527
+ await store.delete(cacheKey);
528
+ return existed;
529
+ })();
530
+ };
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
+ return Object.assign(scopedAct, {
536
+ invalidate: invalidateImpl,
537
+ store,
538
+ });
73
539
  }