actly 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/MIGRATION.md +141 -0
  3. package/README.md +326 -218
  4. package/SECURITY.md +56 -0
  5. package/dist/core/act.cjs +406 -220
  6. package/dist/core/act.d.ts +6 -84
  7. package/dist/core/act.d.ts.map +1 -1
  8. package/dist/core/act.js +405 -222
  9. package/dist/core/executor.cjs +0 -27
  10. package/dist/core/executor.d.ts +0 -41
  11. package/dist/core/executor.d.ts.map +1 -1
  12. package/dist/core/executor.js +0 -28
  13. package/dist/core/health.cjs +146 -17
  14. package/dist/core/health.d.ts +20 -5
  15. package/dist/core/health.d.ts.map +1 -1
  16. package/dist/core/health.js +140 -18
  17. package/dist/core/shutdown.cjs +19 -8
  18. package/dist/core/shutdown.d.ts +1 -4
  19. package/dist/core/shutdown.d.ts.map +1 -1
  20. package/dist/core/shutdown.js +18 -9
  21. package/dist/core/tenant.cjs +61 -19
  22. package/dist/core/tenant.d.ts +5 -10
  23. package/dist/core/tenant.d.ts.map +1 -1
  24. package/dist/core/tenant.js +61 -20
  25. package/dist/errors.cjs +70 -103
  26. package/dist/errors.d.ts +33 -90
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +66 -103
  29. package/dist/index.cjs +25 -22
  30. package/dist/index.d.ts +12 -8
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -15
  33. package/dist/observability.cjs +0 -39
  34. package/dist/observability.d.ts +19 -78
  35. package/dist/observability.d.ts.map +1 -1
  36. package/dist/observability.js +0 -40
  37. package/dist/policies/bulkhead.cjs +44 -4
  38. package/dist/policies/bulkhead.d.ts +0 -0
  39. package/dist/policies/bulkhead.d.ts.map +1 -1
  40. package/dist/policies/bulkhead.js +44 -5
  41. package/dist/policies/cache.cjs +22 -142
  42. package/dist/policies/cache.d.ts +0 -46
  43. package/dist/policies/cache.d.ts.map +1 -1
  44. package/dist/policies/cache.js +22 -143
  45. package/dist/policies/circuitBreaker.cjs +125 -31
  46. package/dist/policies/circuitBreaker.d.ts +0 -0
  47. package/dist/policies/circuitBreaker.d.ts.map +1 -1
  48. package/dist/policies/circuitBreaker.js +125 -32
  49. package/dist/policies/dedupe.cjs +19 -66
  50. package/dist/policies/dedupe.d.ts +0 -23
  51. package/dist/policies/dedupe.d.ts.map +1 -1
  52. package/dist/policies/dedupe.js +19 -67
  53. package/dist/policies/noop.cjs +6 -0
  54. package/dist/policies/noop.d.ts +3 -0
  55. package/dist/policies/noop.d.ts.map +1 -0
  56. package/dist/policies/noop.js +3 -0
  57. package/dist/policies/rateLimit.cjs +15 -5
  58. package/dist/policies/rateLimit.d.ts +0 -0
  59. package/dist/policies/rateLimit.d.ts.map +1 -1
  60. package/dist/policies/rateLimit.js +15 -6
  61. package/dist/policies/retry.cjs +100 -65
  62. package/dist/policies/retry.d.ts +0 -27
  63. package/dist/policies/retry.d.ts.map +1 -1
  64. package/dist/policies/retry.js +100 -66
  65. package/dist/policies/timeout.cjs +32 -60
  66. package/dist/policies/timeout.d.ts +0 -13
  67. package/dist/policies/timeout.d.ts.map +1 -1
  68. package/dist/policies/timeout.js +32 -71
  69. package/dist/stores/base.cjs +0 -10
  70. package/dist/stores/base.d.ts +4 -67
  71. package/dist/stores/base.d.ts.map +1 -1
  72. package/dist/stores/base.js +0 -11
  73. package/dist/stores/memory.cjs +75 -88
  74. package/dist/stores/memory.d.ts +7 -74
  75. package/dist/stores/memory.d.ts.map +1 -1
  76. package/dist/stores/memory.js +75 -89
  77. package/dist/testing/index.cjs +56 -0
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.d.ts.map +1 -0
  80. package/dist/testing/index.js +52 -0
  81. package/dist/types/index.cjs +0 -1
  82. package/dist/types/index.d.ts +17 -307
  83. package/dist/types/index.d.ts.map +1 -1
  84. package/dist/types/index.js +0 -2
  85. package/dist/utils/abort.cjs +14 -111
  86. package/dist/utils/abort.d.ts +3 -74
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +14 -112
  89. package/dist/utils/abortPool.cjs +1 -2
  90. package/dist/utils/abortPool.d.ts +0 -0
  91. package/dist/utils/abortPool.d.ts.map +1 -1
  92. package/dist/utils/abortPool.js +1 -3
  93. package/dist/utils/backoff.cjs +3 -21
  94. package/dist/utils/backoff.d.ts +0 -11
  95. package/dist/utils/backoff.d.ts.map +1 -1
  96. package/dist/utils/backoff.js +3 -22
  97. package/dist/utils/decorator.cjs +44 -0
  98. package/dist/utils/decorator.d.ts +3 -0
  99. package/dist/utils/decorator.d.ts.map +1 -0
  100. package/dist/utils/decorator.js +41 -0
  101. package/dist/utils/key.cjs +13 -45
  102. package/dist/utils/key.d.ts +0 -27
  103. package/dist/utils/key.d.ts.map +1 -1
  104. package/dist/utils/key.js +13 -46
  105. package/dist/utils/limits.cjs +9 -27
  106. package/dist/utils/limits.d.ts +9 -27
  107. package/dist/utils/limits.d.ts.map +1 -1
  108. package/dist/utils/limits.js +9 -28
  109. package/dist/utils/safeCall.cjs +28 -0
  110. package/dist/utils/safeCall.d.ts +2 -0
  111. package/dist/utils/safeCall.d.ts.map +1 -0
  112. package/dist/utils/safeCall.js +25 -0
  113. package/dist/utils/sanitize.cjs +22 -1
  114. package/dist/utils/sanitize.d.ts +0 -0
  115. package/dist/utils/sanitize.d.ts.map +1 -1
  116. package/dist/utils/sanitize.js +22 -2
  117. package/dist/utils/validate.cjs +81 -18
  118. package/dist/utils/validate.d.ts +6 -19
  119. package/dist/utils/validate.d.ts.map +1 -1
  120. package/dist/utils/validate.js +81 -19
  121. package/package.json +38 -4
  122. package/dist/core/act.js.map +0 -1
  123. package/dist/core/executor.js.map +0 -1
  124. package/dist/core/health.js.map +0 -1
  125. package/dist/core/shutdown.js.map +0 -1
  126. package/dist/core/tenant.js.map +0 -1
  127. package/dist/errors.js.map +0 -1
  128. package/dist/index.js.map +0 -1
  129. package/dist/observability.js.map +0 -1
  130. package/dist/policies/bulkhead.js.map +0 -1
  131. package/dist/policies/cache.js.map +0 -1
  132. package/dist/policies/circuitBreaker.js.map +0 -1
  133. package/dist/policies/dedupe.js.map +0 -1
  134. package/dist/policies/rateLimit.js.map +0 -1
  135. package/dist/policies/retry.js.map +0 -1
  136. package/dist/policies/timeout.js.map +0 -1
  137. package/dist/state/store.cjs +0 -14
  138. package/dist/state/store.d.ts +0 -11
  139. package/dist/state/store.d.ts.map +0 -1
  140. package/dist/state/store.js +0 -11
  141. package/dist/state/store.js.map +0 -1
  142. package/dist/stores/base.js.map +0 -1
  143. package/dist/stores/memory.js.map +0 -1
  144. package/dist/types/index.js.map +0 -1
  145. package/dist/utils/abort.js.map +0 -1
  146. package/dist/utils/abortPool.js.map +0 -1
  147. package/dist/utils/backoff.js.map +0 -1
  148. package/dist/utils/key.js.map +0 -1
  149. package/dist/utils/limits.js.map +0 -1
  150. package/dist/utils/sanitize.js.map +0 -1
  151. package/dist/utils/validate.js.map +0 -1
@@ -2,117 +2,53 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.cachePolicy = cachePolicy;
4
4
  const base_js_1 = require("../stores/base.js");
5
+ const safeCall_js_1 = require("../utils/safeCall.js");
6
+ const abort_js_1 = require("../utils/abort.js");
7
+ const limits_js_1 = require("../utils/limits.js");
5
8
  const NS = 'cache:';
6
- const INFLIGHT_NS = '__inflight:cache:';
9
+ const INFLIGHT_NS = 'inflight:cache:';
7
10
  let generationCounter = 0;
8
11
  function nextGeneration() {
9
12
  generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
10
13
  return generationCounter;
11
14
  }
12
- /**
13
- * Short-circuit the entire downstream chain on a cache hit.
14
- * On a miss, run `fn` and store the result with TTL.
15
- *
16
- * # Properties
17
- *
18
- * - **Single-flight originator isolation**: the stored in-flight promise is
19
- * the RAW `fn(signal)` — NOT raceAbort-wrapped. An originator's signal
20
- * abort no longer causes all cache-miss joiners to reject. Each caller
21
- * races against their OWN signal only.
22
- * - **Generation-safe inflight cleanup**: stale originators don't delete
23
- * newer entries (same pattern as dedupePolicy).
24
- * - **Signal-aware async store path**: between `await store.get()` and
25
- * `await store.set()`, we re-check `signal.aborted`. The previous
26
- * version would happily return a cached value even after the caller
27
- * aborted mid-Redis-latency.
28
- * - **Cache hit honours abort**: if `signal.aborted` is true at policy
29
- * entry, we reject immediately — consistent with `act()`'s contract.
30
- *
31
- * # Single-flight (cache stampede prevention)
32
- *
33
- * On a sync store, the policy also stores the in-flight Promise under a
34
- * separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
35
- * cache but find an in-flight Promise join it instead of launching duplicate
36
- * work. This is the same mechanism `dedupePolicy` uses, applied internally
37
- * to cache misses so users don't need to combine `cache` + `dedupe` to
38
- * avoid stampedes.
39
- *
40
- * On an async store, single-flight is not possible (the same sync-store
41
- * constraint as dedupe applies). Stampedes are a known limitation — document
42
- * and pair with dedupe at a higher layer if you need single-flight semantics.
43
- *
44
- * # Fail-open writes
45
- *
46
- * If `store.set()` throws (e.g. Redis transient error), we swallow the error
47
- * and return the value anyway. The caller gets their result; the next call
48
- * will simply re-run `fn` and try to cache again. Caching is an optimisation,
49
- * not a correctness requirement.
50
- *
51
- * # Cache hit semantics
52
- *
53
- * On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
54
- * set to `0` — no work was performed.
55
- *
56
- * Failures are NEVER cached. Only successful values are stored.
57
- */
58
15
  function cachePolicy(opts) {
59
16
  return (fn, ctx) => async (signal) => {
60
- // Honour abort on cache hit too. Consistent with act()'s
61
- // "reject immediately if signal aborted" contract.
62
17
  if (signal.aborted)
63
18
  return Promise.reject(signal.reason);
64
19
  const key = NS + ctx.key;
65
20
  const inflightKey = INFLIGHT_NS + ctx.key;
66
- // ─── Sync store: fast path with single-flight ────────────────────────
67
21
  if ((0, base_js_1.isSyncStore)(ctx.store)) {
68
- // Capture the narrowed sync store so closures (cleanup) keep the
69
- // type information — TS doesn't carry `isSyncStore` narrowing
70
- // into nested function bodies.
71
22
  const store = ctx.store;
72
23
  const obs = ctx.observability;
73
- // 1. Cache hit?
74
24
  const hit = store.get(key);
75
25
  if (hit) {
76
26
  ctx.meta.source = 'cache';
77
27
  ctx.meta.attempts = 0;
78
- // Emit cache-hit event if hooks are registered.
79
- // Compute actual age from the entry's insertion timestamp.
80
28
  if (obs) {
81
29
  const ageMs = Date.now() - hit.insertedAt;
82
- obs.hooks.onCacheHit?.({
30
+ (0, safeCall_js_1.safeCall)(obs.hooks.onCacheHit, {
83
31
  type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
84
32
  timestamp: Date.now(), ageMs: Math.max(0, ageMs),
85
33
  });
86
34
  }
87
35
  return hit.value;
88
36
  }
89
- // Emit cache-miss event if hooks are registered.
90
37
  if (obs) {
91
- obs.hooks.onCacheMiss?.({
38
+ (0, safeCall_js_1.safeCall)(obs.hooks.onCacheMiss, {
92
39
  type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
93
40
  timestamp: Date.now(),
94
41
  });
95
42
  }
96
- // 2. In-flight single-flight hit? Join it.
97
- //
98
- // The stored promise is RAW fn(signal). Joiners race it
99
- // against their OWN signal only — originator abort doesn't
100
- // propagate to joiners.
101
43
  const inflight = store.get(inflightKey);
102
44
  if (inflight) {
103
- // Race the in-flight promise against OUR OWN signal. If we
104
- // abort, we reject — but other joiners and the originator
105
- // continue unaffected.
106
45
  try {
107
- const value = await raceWithOwnSignal(inflight.promise, signal);
108
- // Mirror originator's meta so joiner's ActResult reflects
109
- // the real effort (attempts, source).
46
+ const value = await (0, abort_js_1.raceAbort)(inflight.promise, signal);
110
47
  ctx.meta.attempts = inflight.meta.attempts;
111
48
  ctx.meta.source = inflight.meta.source;
112
49
  return value;
113
50
  }
114
51
  catch (err) {
115
- // Copy meta on failure too (same as dedupe joiners)
116
52
  if (signal.aborted) {
117
53
  ctx.meta.attempts = 0;
118
54
  }
@@ -123,49 +59,36 @@ function cachePolicy(opts) {
123
59
  throw err;
124
60
  }
125
61
  }
126
- // 3. Originator: launch fn, cache on success (fail-open).
127
- //
128
- // The stored promise is the RAW fn(signal) — NOT raceAbort-wrapped.
129
- // Originator's own await is wrapped in raceWithOwnSignal so they
130
- // can bail on their signal without affecting joiners.
131
62
  const generation = nextGeneration();
132
63
  const rawPromise = Promise.resolve(fn(signal)).then((value) => {
133
64
  try {
134
65
  store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
135
66
  }
136
67
  catch {
137
- // Fail-open: cache write failure should not surface to caller.
138
68
  }
139
69
  return value;
140
- },
141
- // Re-throw the error so joiners see it. Don't cache failures.
142
- (err) => { throw err; });
143
- // Publish the in-flight entry for single-flight. If store.set
144
- // throws, single-flight is disabled for this call — concurrent
145
- // callers will all run fn. Still correct, just less efficient.
70
+ }, (err) => { throw err; });
146
71
  try {
147
- store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta });
72
+ store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta }, limits_js_1.LIMITS.DEFAULT_INFLIGHT_TTL);
148
73
  }
149
74
  catch {
150
- // Single-flight unavailable; proceed without publishing.
151
- // Originator still gets their value (or error).
152
75
  }
153
- // Cleanup the in-flight slot on settle — generation-safe.
154
76
  const cleanup = () => {
155
- const current = store.get(inflightKey);
156
- if (current && current.generation === generation) {
157
- try {
158
- store.delete(inflightKey);
77
+ try {
78
+ const current = store.get(inflightKey);
79
+ if (current && current.generation === generation) {
80
+ try {
81
+ store.delete(inflightKey);
82
+ }
83
+ catch { }
159
84
  }
160
- catch { /* ignore */ }
85
+ }
86
+ catch {
161
87
  }
162
88
  };
163
- rawPromise.then(cleanup, cleanup);
164
- // Originator races the raw promise against their OWN signal.
165
- return raceWithOwnSignal(rawPromise, signal);
89
+ rawPromise.then(cleanup, cleanup).catch(() => { });
90
+ return (0, abort_js_1.raceAbort)(rawPromise, signal);
166
91
  }
167
- // Async store: no single-flight (race window unavoidable).
168
- // Re-check signal.aborted between awaits.
169
92
  const obs = ctx.observability;
170
93
  const hit = await ctx.store.get(key);
171
94
  if (signal.aborted)
@@ -175,7 +98,7 @@ function cachePolicy(opts) {
175
98
  ctx.meta.attempts = 0;
176
99
  if (obs) {
177
100
  const ageMs = Date.now() - hit.insertedAt;
178
- obs.hooks.onCacheHit?.({
101
+ (0, safeCall_js_1.safeCall)(obs.hooks.onCacheHit, {
179
102
  type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
180
103
  timestamp: Date.now(), ageMs: Math.max(0, ageMs),
181
104
  });
@@ -183,7 +106,7 @@ function cachePolicy(opts) {
183
106
  return hit.value;
184
107
  }
185
108
  if (obs) {
186
- obs.hooks.onCacheMiss?.({
109
+ (0, safeCall_js_1.safeCall)(obs.hooks.onCacheMiss, {
187
110
  type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
188
111
  timestamp: Date.now(),
189
112
  });
@@ -195,50 +118,7 @@ function cachePolicy(opts) {
195
118
  await ctx.store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
196
119
  }
197
120
  catch {
198
- // Fail-open: see sync path comment.
199
121
  }
200
122
  return value;
201
123
  };
202
124
  }
203
- /**
204
- * Race a promise against a signal — but if the signal aborts, mark the
205
- * underlying promise as handled (so its eventual rejection doesn't surface
206
- * as an unhandled rejection). The underlying promise keeps running for any
207
- * other consumers (e.g. joiners in single-flight).
208
- *
209
- * This is `raceAbort` from `utils/abort.ts`, inlined here to avoid an import
210
- * cycle (cache.ts already imports from stores, dedupe imports from abort —
211
- * keeping cache independent of abort reduces the import graph depth).
212
- *
213
- * Internal — not exported.
214
- */
215
- function raceWithOwnSignal(promise, signal) {
216
- if (signal.aborted) {
217
- promise.catch(() => { }); // mark as handled
218
- return Promise.reject(signal.reason);
219
- }
220
- return new Promise((resolve, reject) => {
221
- let settled = false;
222
- const onAbort = () => {
223
- if (settled)
224
- return;
225
- settled = true;
226
- signal.removeEventListener('abort', onAbort);
227
- reject(signal.reason);
228
- };
229
- signal.addEventListener('abort', onAbort);
230
- promise.then((value) => {
231
- if (settled)
232
- return;
233
- settled = true;
234
- signal.removeEventListener('abort', onAbort);
235
- resolve(value);
236
- }, (error) => {
237
- if (settled)
238
- return;
239
- settled = true;
240
- signal.removeEventListener('abort', onAbort);
241
- reject(error);
242
- });
243
- });
244
- }
@@ -1,49 +1,3 @@
1
1
  import type { CacheOptions, PolicyApplier } from '../types/index.js';
2
- /**
3
- * Short-circuit the entire downstream chain on a cache hit.
4
- * On a miss, run `fn` and store the result with TTL.
5
- *
6
- * # Properties
7
- *
8
- * - **Single-flight originator isolation**: the stored in-flight promise is
9
- * the RAW `fn(signal)` — NOT raceAbort-wrapped. An originator's signal
10
- * abort no longer causes all cache-miss joiners to reject. Each caller
11
- * races against their OWN signal only.
12
- * - **Generation-safe inflight cleanup**: stale originators don't delete
13
- * newer entries (same pattern as dedupePolicy).
14
- * - **Signal-aware async store path**: between `await store.get()` and
15
- * `await store.set()`, we re-check `signal.aborted`. The previous
16
- * version would happily return a cached value even after the caller
17
- * aborted mid-Redis-latency.
18
- * - **Cache hit honours abort**: if `signal.aborted` is true at policy
19
- * entry, we reject immediately — consistent with `act()`'s contract.
20
- *
21
- * # Single-flight (cache stampede prevention)
22
- *
23
- * On a sync store, the policy also stores the in-flight Promise under a
24
- * separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
25
- * cache but find an in-flight Promise join it instead of launching duplicate
26
- * work. This is the same mechanism `dedupePolicy` uses, applied internally
27
- * to cache misses so users don't need to combine `cache` + `dedupe` to
28
- * avoid stampedes.
29
- *
30
- * On an async store, single-flight is not possible (the same sync-store
31
- * constraint as dedupe applies). Stampedes are a known limitation — document
32
- * and pair with dedupe at a higher layer if you need single-flight semantics.
33
- *
34
- * # Fail-open writes
35
- *
36
- * If `store.set()` throws (e.g. Redis transient error), we swallow the error
37
- * and return the value anyway. The caller gets their result; the next call
38
- * will simply re-run `fn` and try to cache again. Caching is an optimisation,
39
- * not a correctness requirement.
40
- *
41
- * # Cache hit semantics
42
- *
43
- * On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
44
- * set to `0` — no work was performed.
45
- *
46
- * Failures are NEVER cached. Only successful values are stored.
47
- */
48
2
  export declare function cachePolicy<T>(opts: CacheOptions): PolicyApplier<T>;
49
3
  //# sourceMappingURL=cache.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/policies/cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,YAAY,EAAE,aAAa,EAAiB,MAAM,mBAAmB,CAAA;AA8B1F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,CAAC,CAAC,CAkJnE"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/policies/cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,YAAY,EAAE,aAAa,EAAiB,MAAM,mBAAmB,CAAA;AAiD1F,wBAAgB,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,CAAC,CAAC,CAqInE"}
@@ -1,115 +1,51 @@
1
1
  import { isSyncStore } from '../stores/base.js';
2
+ import { safeCall } from '../utils/safeCall.js';
3
+ import { raceAbort } from '../utils/abort.js';
4
+ import { LIMITS } from '../utils/limits.js';
2
5
  const NS = 'cache:';
3
- const INFLIGHT_NS = '__inflight:cache:';
6
+ const INFLIGHT_NS = 'inflight:cache:';
4
7
  let generationCounter = 0;
5
8
  function nextGeneration() {
6
9
  generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
7
10
  return generationCounter;
8
11
  }
9
- /**
10
- * Short-circuit the entire downstream chain on a cache hit.
11
- * On a miss, run `fn` and store the result with TTL.
12
- *
13
- * # Properties
14
- *
15
- * - **Single-flight originator isolation**: the stored in-flight promise is
16
- * the RAW `fn(signal)` — NOT raceAbort-wrapped. An originator's signal
17
- * abort no longer causes all cache-miss joiners to reject. Each caller
18
- * races against their OWN signal only.
19
- * - **Generation-safe inflight cleanup**: stale originators don't delete
20
- * newer entries (same pattern as dedupePolicy).
21
- * - **Signal-aware async store path**: between `await store.get()` and
22
- * `await store.set()`, we re-check `signal.aborted`. The previous
23
- * version would happily return a cached value even after the caller
24
- * aborted mid-Redis-latency.
25
- * - **Cache hit honours abort**: if `signal.aborted` is true at policy
26
- * entry, we reject immediately — consistent with `act()`'s contract.
27
- *
28
- * # Single-flight (cache stampede prevention)
29
- *
30
- * On a sync store, the policy also stores the in-flight Promise under a
31
- * separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
32
- * cache but find an in-flight Promise join it instead of launching duplicate
33
- * work. This is the same mechanism `dedupePolicy` uses, applied internally
34
- * to cache misses so users don't need to combine `cache` + `dedupe` to
35
- * avoid stampedes.
36
- *
37
- * On an async store, single-flight is not possible (the same sync-store
38
- * constraint as dedupe applies). Stampedes are a known limitation — document
39
- * and pair with dedupe at a higher layer if you need single-flight semantics.
40
- *
41
- * # Fail-open writes
42
- *
43
- * If `store.set()` throws (e.g. Redis transient error), we swallow the error
44
- * and return the value anyway. The caller gets their result; the next call
45
- * will simply re-run `fn` and try to cache again. Caching is an optimisation,
46
- * not a correctness requirement.
47
- *
48
- * # Cache hit semantics
49
- *
50
- * On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
51
- * set to `0` — no work was performed.
52
- *
53
- * Failures are NEVER cached. Only successful values are stored.
54
- */
55
12
  export function cachePolicy(opts) {
56
13
  return (fn, ctx) => async (signal) => {
57
- // Honour abort on cache hit too. Consistent with act()'s
58
- // "reject immediately if signal aborted" contract.
59
14
  if (signal.aborted)
60
15
  return Promise.reject(signal.reason);
61
16
  const key = NS + ctx.key;
62
17
  const inflightKey = INFLIGHT_NS + ctx.key;
63
- // ─── Sync store: fast path with single-flight ────────────────────────
64
18
  if (isSyncStore(ctx.store)) {
65
- // Capture the narrowed sync store so closures (cleanup) keep the
66
- // type information — TS doesn't carry `isSyncStore` narrowing
67
- // into nested function bodies.
68
19
  const store = ctx.store;
69
20
  const obs = ctx.observability;
70
- // 1. Cache hit?
71
21
  const hit = store.get(key);
72
22
  if (hit) {
73
23
  ctx.meta.source = 'cache';
74
24
  ctx.meta.attempts = 0;
75
- // Emit cache-hit event if hooks are registered.
76
- // Compute actual age from the entry's insertion timestamp.
77
25
  if (obs) {
78
26
  const ageMs = Date.now() - hit.insertedAt;
79
- obs.hooks.onCacheHit?.({
27
+ safeCall(obs.hooks.onCacheHit, {
80
28
  type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
81
29
  timestamp: Date.now(), ageMs: Math.max(0, ageMs),
82
30
  });
83
31
  }
84
32
  return hit.value;
85
33
  }
86
- // Emit cache-miss event if hooks are registered.
87
34
  if (obs) {
88
- obs.hooks.onCacheMiss?.({
35
+ safeCall(obs.hooks.onCacheMiss, {
89
36
  type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
90
37
  timestamp: Date.now(),
91
38
  });
92
39
  }
93
- // 2. In-flight single-flight hit? Join it.
94
- //
95
- // The stored promise is RAW fn(signal). Joiners race it
96
- // against their OWN signal only — originator abort doesn't
97
- // propagate to joiners.
98
40
  const inflight = store.get(inflightKey);
99
41
  if (inflight) {
100
- // Race the in-flight promise against OUR OWN signal. If we
101
- // abort, we reject — but other joiners and the originator
102
- // continue unaffected.
103
42
  try {
104
- const value = await raceWithOwnSignal(inflight.promise, signal);
105
- // Mirror originator's meta so joiner's ActResult reflects
106
- // the real effort (attempts, source).
43
+ const value = await raceAbort(inflight.promise, signal);
107
44
  ctx.meta.attempts = inflight.meta.attempts;
108
45
  ctx.meta.source = inflight.meta.source;
109
46
  return value;
110
47
  }
111
48
  catch (err) {
112
- // Copy meta on failure too (same as dedupe joiners)
113
49
  if (signal.aborted) {
114
50
  ctx.meta.attempts = 0;
115
51
  }
@@ -120,49 +56,36 @@ export function cachePolicy(opts) {
120
56
  throw err;
121
57
  }
122
58
  }
123
- // 3. Originator: launch fn, cache on success (fail-open).
124
- //
125
- // The stored promise is the RAW fn(signal) — NOT raceAbort-wrapped.
126
- // Originator's own await is wrapped in raceWithOwnSignal so they
127
- // can bail on their signal without affecting joiners.
128
59
  const generation = nextGeneration();
129
60
  const rawPromise = Promise.resolve(fn(signal)).then((value) => {
130
61
  try {
131
62
  store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
132
63
  }
133
64
  catch {
134
- // Fail-open: cache write failure should not surface to caller.
135
65
  }
136
66
  return value;
137
- },
138
- // Re-throw the error so joiners see it. Don't cache failures.
139
- (err) => { throw err; });
140
- // Publish the in-flight entry for single-flight. If store.set
141
- // throws, single-flight is disabled for this call — concurrent
142
- // callers will all run fn. Still correct, just less efficient.
67
+ }, (err) => { throw err; });
143
68
  try {
144
- store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta });
69
+ store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta }, LIMITS.DEFAULT_INFLIGHT_TTL);
145
70
  }
146
71
  catch {
147
- // Single-flight unavailable; proceed without publishing.
148
- // Originator still gets their value (or error).
149
72
  }
150
- // Cleanup the in-flight slot on settle — generation-safe.
151
73
  const cleanup = () => {
152
- const current = store.get(inflightKey);
153
- if (current && current.generation === generation) {
154
- try {
155
- store.delete(inflightKey);
74
+ try {
75
+ const current = store.get(inflightKey);
76
+ if (current && current.generation === generation) {
77
+ try {
78
+ store.delete(inflightKey);
79
+ }
80
+ catch { }
156
81
  }
157
- catch { /* ignore */ }
82
+ }
83
+ catch {
158
84
  }
159
85
  };
160
- rawPromise.then(cleanup, cleanup);
161
- // Originator races the raw promise against their OWN signal.
162
- return raceWithOwnSignal(rawPromise, signal);
86
+ rawPromise.then(cleanup, cleanup).catch(() => { });
87
+ return raceAbort(rawPromise, signal);
163
88
  }
164
- // Async store: no single-flight (race window unavoidable).
165
- // Re-check signal.aborted between awaits.
166
89
  const obs = ctx.observability;
167
90
  const hit = await ctx.store.get(key);
168
91
  if (signal.aborted)
@@ -172,7 +95,7 @@ export function cachePolicy(opts) {
172
95
  ctx.meta.attempts = 0;
173
96
  if (obs) {
174
97
  const ageMs = Date.now() - hit.insertedAt;
175
- obs.hooks.onCacheHit?.({
98
+ safeCall(obs.hooks.onCacheHit, {
176
99
  type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
177
100
  timestamp: Date.now(), ageMs: Math.max(0, ageMs),
178
101
  });
@@ -180,7 +103,7 @@ export function cachePolicy(opts) {
180
103
  return hit.value;
181
104
  }
182
105
  if (obs) {
183
- obs.hooks.onCacheMiss?.({
106
+ safeCall(obs.hooks.onCacheMiss, {
184
107
  type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
185
108
  timestamp: Date.now(),
186
109
  });
@@ -192,51 +115,7 @@ export function cachePolicy(opts) {
192
115
  await ctx.store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
193
116
  }
194
117
  catch {
195
- // Fail-open: see sync path comment.
196
118
  }
197
119
  return value;
198
120
  };
199
121
  }
200
- /**
201
- * Race a promise against a signal — but if the signal aborts, mark the
202
- * underlying promise as handled (so its eventual rejection doesn't surface
203
- * as an unhandled rejection). The underlying promise keeps running for any
204
- * other consumers (e.g. joiners in single-flight).
205
- *
206
- * This is `raceAbort` from `utils/abort.ts`, inlined here to avoid an import
207
- * cycle (cache.ts already imports from stores, dedupe imports from abort —
208
- * keeping cache independent of abort reduces the import graph depth).
209
- *
210
- * Internal — not exported.
211
- */
212
- function raceWithOwnSignal(promise, signal) {
213
- if (signal.aborted) {
214
- promise.catch(() => { }); // mark as handled
215
- return Promise.reject(signal.reason);
216
- }
217
- return new Promise((resolve, reject) => {
218
- let settled = false;
219
- const onAbort = () => {
220
- if (settled)
221
- return;
222
- settled = true;
223
- signal.removeEventListener('abort', onAbort);
224
- reject(signal.reason);
225
- };
226
- signal.addEventListener('abort', onAbort);
227
- promise.then((value) => {
228
- if (settled)
229
- return;
230
- settled = true;
231
- signal.removeEventListener('abort', onAbort);
232
- resolve(value);
233
- }, (error) => {
234
- if (settled)
235
- return;
236
- settled = true;
237
- signal.removeEventListener('abort', onAbort);
238
- reject(error);
239
- });
240
- });
241
- }
242
- //# sourceMappingURL=cache.js.map