actly 1.1.5 → 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 (119) hide show
  1. package/README.md +232 -434
  2. package/dist/core/act.cjs +353 -35
  3. package/dist/core/act.d.ts.map +1 -1
  4. package/dist/core/act.js +355 -37
  5. package/dist/core/act.js.map +1 -1
  6. package/dist/core/executor.cjs +2 -0
  7. package/dist/core/executor.d.ts +6 -1
  8. package/dist/core/executor.d.ts.map +1 -1
  9. package/dist/core/executor.js +2 -0
  10. package/dist/core/executor.js.map +1 -1
  11. package/dist/core/health.cjs +34 -0
  12. package/dist/core/health.d.ts +18 -0
  13. package/dist/core/health.d.ts.map +1 -0
  14. package/dist/core/health.js +28 -0
  15. package/dist/core/health.js.map +1 -0
  16. package/dist/core/shutdown.cjs +60 -0
  17. package/dist/core/shutdown.d.ts +8 -0
  18. package/dist/core/shutdown.d.ts.map +1 -0
  19. package/dist/core/shutdown.js +56 -0
  20. package/dist/core/shutdown.js.map +1 -0
  21. package/dist/core/tenant.cjs +71 -0
  22. package/dist/core/tenant.d.ts +29 -0
  23. package/dist/core/tenant.d.ts.map +1 -0
  24. package/dist/core/tenant.js +68 -0
  25. package/dist/core/tenant.js.map +1 -0
  26. package/dist/errors.cjs +182 -0
  27. package/dist/errors.d.ts +151 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +171 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/index.cjs +44 -4
  32. package/dist/index.d.ts +17 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +17 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/observability.cjs +45 -0
  37. package/dist/observability.d.ts +145 -0
  38. package/dist/observability.d.ts.map +1 -0
  39. package/dist/observability.js +43 -0
  40. package/dist/observability.js.map +1 -0
  41. package/dist/policies/bulkhead.cjs +97 -0
  42. package/dist/policies/bulkhead.d.ts +3 -0
  43. package/dist/policies/bulkhead.d.ts.map +1 -0
  44. package/dist/policies/bulkhead.js +95 -0
  45. package/dist/policies/bulkhead.js.map +1 -0
  46. package/dist/policies/cache.cjs +168 -31
  47. package/dist/policies/cache.d.ts +17 -4
  48. package/dist/policies/cache.d.ts.map +1 -1
  49. package/dist/policies/cache.js +168 -31
  50. package/dist/policies/cache.js.map +1 -1
  51. package/dist/policies/circuitBreaker.cjs +85 -0
  52. package/dist/policies/circuitBreaker.d.ts +3 -0
  53. package/dist/policies/circuitBreaker.d.ts.map +1 -0
  54. package/dist/policies/circuitBreaker.js +83 -0
  55. package/dist/policies/circuitBreaker.js.map +1 -0
  56. package/dist/policies/dedupe.cjs +74 -45
  57. package/dist/policies/dedupe.d.ts +10 -28
  58. package/dist/policies/dedupe.d.ts.map +1 -1
  59. package/dist/policies/dedupe.js +74 -45
  60. package/dist/policies/dedupe.js.map +1 -1
  61. package/dist/policies/rateLimit.cjs +35 -0
  62. package/dist/policies/rateLimit.d.ts +3 -0
  63. package/dist/policies/rateLimit.d.ts.map +1 -0
  64. package/dist/policies/rateLimit.js +33 -0
  65. package/dist/policies/rateLimit.js.map +1 -0
  66. package/dist/policies/retry.cjs +45 -8
  67. package/dist/policies/retry.d.ts.map +1 -1
  68. package/dist/policies/retry.js +45 -8
  69. package/dist/policies/retry.js.map +1 -1
  70. package/dist/policies/timeout.cjs +25 -53
  71. package/dist/policies/timeout.d.ts +2 -33
  72. package/dist/policies/timeout.d.ts.map +1 -1
  73. package/dist/policies/timeout.js +31 -48
  74. package/dist/policies/timeout.js.map +1 -1
  75. package/dist/state/store.cjs +3 -3
  76. package/dist/state/store.d.ts +3 -3
  77. package/dist/state/store.js +3 -3
  78. package/dist/stores/memory.cjs +126 -53
  79. package/dist/stores/memory.d.ts +35 -16
  80. package/dist/stores/memory.d.ts.map +1 -1
  81. package/dist/stores/memory.js +125 -53
  82. package/dist/stores/memory.js.map +1 -1
  83. package/dist/types/index.d.ts +176 -3
  84. package/dist/types/index.d.ts.map +1 -1
  85. package/dist/utils/abort.cjs +108 -38
  86. package/dist/utils/abort.d.ts +43 -19
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +108 -38
  89. package/dist/utils/abort.js.map +1 -1
  90. package/dist/utils/abortPool.cjs +26 -0
  91. package/dist/utils/abortPool.d.ts +4 -0
  92. package/dist/utils/abortPool.d.ts.map +1 -0
  93. package/dist/utils/abortPool.js +22 -0
  94. package/dist/utils/abortPool.js.map +1 -0
  95. package/dist/utils/backoff.cjs +12 -1
  96. package/dist/utils/backoff.d.ts.map +1 -1
  97. package/dist/utils/backoff.js +12 -1
  98. package/dist/utils/backoff.js.map +1 -1
  99. package/dist/utils/key.cjs +77 -0
  100. package/dist/utils/key.d.ts +29 -0
  101. package/dist/utils/key.d.ts.map +1 -0
  102. package/dist/utils/key.js +75 -0
  103. package/dist/utils/key.js.map +1 -0
  104. package/dist/utils/limits.cjs +40 -0
  105. package/dist/utils/limits.d.ts +39 -0
  106. package/dist/utils/limits.d.ts.map +1 -0
  107. package/dist/utils/limits.js +38 -0
  108. package/dist/utils/limits.js.map +1 -0
  109. package/dist/utils/sanitize.cjs +29 -0
  110. package/dist/utils/sanitize.d.ts +3 -0
  111. package/dist/utils/sanitize.d.ts.map +1 -0
  112. package/dist/utils/sanitize.js +26 -0
  113. package/dist/utils/sanitize.js.map +1 -0
  114. package/dist/utils/validate.cjs +90 -16
  115. package/dist/utils/validate.d.ts +11 -0
  116. package/dist/utils/validate.d.ts.map +1 -1
  117. package/dist/utils/validate.js +85 -16
  118. package/dist/utils/validate.js.map +1 -1
  119. package/package.json +6 -5
package/dist/core/act.cjs CHANGED
@@ -8,19 +8,93 @@ const retry_js_1 = require("../policies/retry.js");
8
8
  const timeout_js_1 = require("../policies/timeout.js");
9
9
  const dedupe_js_1 = require("../policies/dedupe.js");
10
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");
11
14
  const memory_js_1 = require("../stores/memory.js");
12
15
  const base_js_1 = require("../stores/base.js");
13
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");
14
20
  const validate_js_1 = require("../utils/validate.js");
15
21
  // 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();
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)();
21
27
  // Namespace prefixes used by policies. Kept here (not in policy files) so
22
28
  // `invalidate()` can resolve cache keys without importing policy internals.
23
29
  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
+ */
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
+ }
24
98
  /**
25
99
  * Normalise `dedupe: true` shorthand to `DedupeOptions`.
26
100
  * Returns `undefined` if dedupe is disabled or absent.
@@ -44,25 +118,35 @@ function normalizeDedupe(opt) {
44
118
  function buildPolicies(options) {
45
119
  const dedupe = normalizeDedupe(options.dedupe);
46
120
  const policies = [];
47
- // 0. Outermost: hard wall-clock budget over the entire operation.
48
- // If it fires, no inner policy can extend the deadline.
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
49
130
  if (options.totalTimeout && options.totalTimeout.ms > 0) {
50
131
  policies.push((0, timeout_js_1.totalTimeoutPolicy)(options.totalTimeout));
51
132
  }
52
- // 1. Cache: a hit short-circuits everything below it.
133
+ // Cache: a hit short-circuits everything below it
53
134
  if (options.cache && options.cache.ttl > 0) {
54
135
  policies.push((0, cache_js_1.cachePolicy)(options.cache));
55
136
  }
56
- // 2. Dedupe: collapses concurrent callers before retry fires.
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
57
142
  if (dedupe) {
58
143
  policies.push((0, dedupe_js_1.dedupePolicy)(dedupe));
59
144
  }
60
- // 3. Retry: owns the attempt loop.
61
- // `attempts: 1` is a no-op — skip to avoid overhead.
145
+ // Retry: owns the attempt loop (attempts: 1 is a no-op — skip)
62
146
  if (options.retry && options.retry.attempts > 1) {
63
147
  policies.push((0, retry_js_1.retryPolicy)(options.retry));
64
148
  }
65
- // 4. Innermost: per-attempt clock. Resets on every retry.
149
+ // Innermost: per-attempt clock. Resets on every retry
66
150
  if (options.timeout && options.timeout.ms > 0) {
67
151
  policies.push((0, timeout_js_1.timeoutPolicy)(options.timeout));
68
152
  }
@@ -79,9 +163,11 @@ function buildPolicies(options) {
79
163
  */
80
164
  function buildRootSignal(userSignal) {
81
165
  const controller = new AbortController();
82
- if (userSignal)
83
- (0, abort_js_1.linkSignal)(userSignal, controller);
84
- return controller;
166
+ if (userSignal) {
167
+ const unlink = (0, abort_js_1.linkSignal)(userSignal, controller);
168
+ return { controller, cleanup: unlink };
169
+ }
170
+ return { controller, cleanup: () => { } };
85
171
  }
86
172
  /**
87
173
  * Execute `fn` with the given reliability policies.
@@ -114,34 +200,183 @@ function buildRootSignal(userSignal) {
114
200
  * }
115
201
  */
116
202
  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
203
  (0, validate_js_1.assertKey)(key);
120
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
+ }
237
+ }
121
238
  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.
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);
126
245
  if (rootController.signal.aborted) {
127
- return { ok: false, error: rootController.signal.reason, attempts: 0 };
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 };
128
263
  }
129
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
+ });
271
+ }
272
+ // Wrap fn with hedge request if configured
273
+ const fnWithHedge = options.hedge ? wrapHedge(fn, options.hedge.delayMs) : fn;
130
274
  try {
131
- const value = await (0, executor_js_1.execute)({
275
+ const value = await (0, abort_js_1.raceAbort)((0, executor_js_1.execute)({
132
276
  key,
133
- fn,
277
+ fn: fnWithHedge,
134
278
  policies,
135
279
  store: defaultStore,
136
280
  meta,
137
281
  signal: rootController.signal,
138
- });
139
- return { ok: true, value, source: meta.source, attempts: meta.attempts };
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 };
140
297
  }
141
298
  catch (error) {
142
- 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 };
344
+ }
345
+ finally {
346
+ cleanup();
347
+ (0, health_js_1.unregisterInflight)(scope);
348
+ (0, shutdown_js_1.unregisterDrainable)(scope);
143
349
  }
144
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
+ }
145
380
  /**
146
381
  * Invalidate the cached value for `key` on the default module-level store.
147
382
  *
@@ -159,34 +394,107 @@ async function act(key, fn, options = {}) {
159
394
  * ```
160
395
  */
161
396
  function invalidate(key) {
397
+ (0, validate_js_1.assertKey)(key);
162
398
  const cacheKey = CACHE_NS + key;
163
399
  const existed = defaultStore.has(cacheKey);
164
400
  defaultStore.delete(cacheKey);
165
401
  return existed;
166
402
  }
167
403
  function withStore(store) {
404
+ const scope = 'scoped:' + Math.random().toString(36).slice(2, 8);
168
405
  const scopedAct = async (key, fn, options = {}) => {
169
406
  (0, validate_js_1.assertKey)(key);
170
407
  (0, validate_js_1.assertOptions)(options);
171
408
  const meta = { attempts: 1, source: 'fresh' };
172
- const rootController = buildRootSignal(options.signal);
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);
173
414
  if (rootController.signal.aborted) {
174
- return { ok: false, error: rootController.signal.reason, attempts: 0 };
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 };
175
426
  }
176
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;
177
436
  try {
178
- const value = await (0, executor_js_1.execute)({
437
+ const value = await (0, abort_js_1.raceAbort)((0, executor_js_1.execute)({
179
438
  key,
180
- fn,
439
+ fn: fnWithHedge,
181
440
  policies,
182
441
  store,
183
442
  meta,
184
443
  signal: rootController.signal,
185
- });
186
- return { ok: true, value, source: meta.source, attempts: meta.attempts };
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 };
187
458
  }
188
459
  catch (error) {
189
- return { ok: false, error, attempts: meta.attempts };
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);
190
498
  }
191
499
  };
192
500
  // Build the `invalidate` implementation. The runtime branch on
@@ -195,15 +503,25 @@ function withStore(store) {
195
503
  // (`boolean | Promise<boolean>`) to match either overload signature
196
504
  // individually. The overloads at the call site guarantee callers see
197
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.
198
510
  const invalidateImpl = (key) => {
199
511
  (0, validate_js_1.assertKey)(key);
200
512
  const cacheKey = CACHE_NS + key;
201
513
  if ((0, base_js_1.isSyncStore)(store)) {
514
+ // Sync store: has() + delete() in the same synchronous frame is safe in single-frame.
202
515
  const existed = store.has(cacheKey);
203
516
  store.delete(cacheKey);
204
517
  return existed;
205
518
  }
206
- // Async store branch.
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.
207
525
  return (async () => {
208
526
  const existed = await store.has(cacheKey);
209
527
  await store.delete(cacheKey);
@@ -1 +1 @@
1
- {"version":3,"file":"act.d.ts","sourceRoot":"","sources":["../../src/core/act.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,KAAK,EACL,UAAU,EACV,SAAS,EAGT,aAAa,EACb,cAAc,EACd,eAAe,EAChB,MAAM,mBAAmB,CAAA;AAgG1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,GAAG,CAAC,CAAC,EACzB,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,EACZ,OAAO,GAAE,UAAe,GACvB,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CA+BvB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAK/C;AAID,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;IAC3E,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;IAChC,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAC9B;AAED,8EAA8E;AAC9E,MAAM,WAAW,cAAc;IAC7B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;IAC3E,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACzC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,aAAa,CAAA;AAC/D,wBAAgB,SAAS,CAAC,KAAK,EAAE,eAAe,GAAG,cAAc,CAAA;AACjE,wBAAgB,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,aAAa,GAAG,cAAc,CAAA"}
1
+ {"version":3,"file":"act.d.ts","sourceRoot":"","sources":["../../src/core/act.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,KAAK,EACL,UAAU,EACV,SAAS,EAGT,aAAa,EACb,cAAc,EACd,eAAe,EAEhB,MAAM,mBAAmB,CAAA;AA4L1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,GAAG,CAAC,CAAC,EACzB,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,EACZ,OAAO,GAAE,UAAe,GACvB,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CA8JvB;AA+BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAM/C;AAID,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;IAC3E,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;IAChC,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAC9B;AAED,8EAA8E;AAC9E,MAAM,WAAW,cAAc;IAC7B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;IAC3E,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACzC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,aAAa,CAAA;AAC/D,wBAAgB,SAAS,CAAC,KAAK,EAAE,eAAe,GAAG,cAAc,CAAA;AACjE,wBAAgB,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,aAAa,GAAG,cAAc,CAAA"}