actly 1.1.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/MIGRATION.md +141 -0
  3. package/README.md +334 -428
  4. package/SECURITY.md +56 -0
  5. package/dist/core/act.cjs +630 -126
  6. package/dist/core/act.d.ts +6 -84
  7. package/dist/core/act.d.ts.map +1 -1
  8. package/dist/core/act.js +630 -129
  9. package/dist/core/executor.cjs +1 -26
  10. package/dist/core/executor.d.ts +2 -38
  11. package/dist/core/executor.d.ts.map +1 -1
  12. package/dist/core/executor.js +1 -27
  13. package/dist/core/health.cjs +163 -0
  14. package/dist/core/health.d.ts +33 -0
  15. package/dist/core/health.d.ts.map +1 -0
  16. package/dist/core/health.js +150 -0
  17. package/dist/core/shutdown.cjs +71 -0
  18. package/dist/core/shutdown.d.ts +5 -0
  19. package/dist/core/shutdown.d.ts.map +1 -0
  20. package/dist/core/shutdown.js +65 -0
  21. package/dist/core/tenant.cjs +113 -0
  22. package/dist/core/tenant.d.ts +24 -0
  23. package/dist/core/tenant.d.ts.map +1 -0
  24. package/dist/core/tenant.js +109 -0
  25. package/dist/errors.cjs +149 -0
  26. package/dist/errors.d.ts +94 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +134 -0
  29. package/dist/index.cjs +51 -8
  30. package/dist/index.d.ts +22 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +14 -6
  33. package/dist/observability.cjs +6 -0
  34. package/dist/observability.d.ts +86 -0
  35. package/dist/observability.d.ts.map +1 -0
  36. package/dist/observability.js +3 -0
  37. package/dist/policies/bulkhead.cjs +137 -0
  38. package/dist/policies/bulkhead.d.ts +3 -0
  39. package/dist/policies/bulkhead.d.ts.map +1 -0
  40. package/dist/policies/bulkhead.js +134 -0
  41. package/dist/policies/cache.cjs +84 -67
  42. package/dist/policies/cache.d.ts +0 -33
  43. package/dist/policies/cache.d.ts.map +1 -1
  44. package/dist/policies/cache.js +84 -68
  45. package/dist/policies/circuitBreaker.cjs +179 -0
  46. package/dist/policies/circuitBreaker.d.ts +3 -0
  47. package/dist/policies/circuitBreaker.d.ts.map +1 -0
  48. package/dist/policies/circuitBreaker.js +176 -0
  49. package/dist/policies/dedupe.cjs +46 -64
  50. package/dist/policies/dedupe.d.ts +0 -41
  51. package/dist/policies/dedupe.d.ts.map +1 -1
  52. package/dist/policies/dedupe.js +46 -65
  53. package/dist/policies/noop.cjs +6 -0
  54. package/dist/policies/noop.d.ts +3 -0
  55. package/dist/policies/noop.d.ts.map +1 -0
  56. package/dist/policies/noop.js +3 -0
  57. package/dist/policies/rateLimit.cjs +45 -0
  58. package/dist/policies/rateLimit.d.ts +3 -0
  59. package/dist/policies/rateLimit.d.ts.map +1 -0
  60. package/dist/policies/rateLimit.js +42 -0
  61. package/dist/policies/retry.cjs +129 -57
  62. package/dist/policies/retry.d.ts +0 -27
  63. package/dist/policies/retry.d.ts.map +1 -1
  64. package/dist/policies/retry.js +129 -58
  65. package/dist/policies/timeout.cjs +47 -103
  66. package/dist/policies/timeout.d.ts +2 -46
  67. package/dist/policies/timeout.d.ts.map +1 -1
  68. package/dist/policies/timeout.js +46 -102
  69. package/dist/stores/base.cjs +0 -10
  70. package/dist/stores/base.d.ts +4 -67
  71. package/dist/stores/base.d.ts.map +1 -1
  72. package/dist/stores/base.js +0 -11
  73. package/dist/stores/memory.cjs +147 -87
  74. package/dist/stores/memory.d.ts +11 -59
  75. package/dist/stores/memory.d.ts.map +1 -1
  76. package/dist/stores/memory.js +146 -88
  77. package/dist/testing/index.cjs +56 -0
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.d.ts.map +1 -0
  80. package/dist/testing/index.js +52 -0
  81. package/dist/types/index.cjs +0 -1
  82. package/dist/types/index.d.ts +62 -179
  83. package/dist/types/index.d.ts.map +1 -1
  84. package/dist/types/index.js +0 -2
  85. package/dist/utils/abort.cjs +46 -73
  86. package/dist/utils/abort.d.ts +4 -51
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +46 -74
  89. package/dist/utils/abortPool.cjs +25 -0
  90. package/dist/utils/abortPool.d.ts +4 -0
  91. package/dist/utils/abortPool.d.ts.map +1 -0
  92. package/dist/utils/abortPool.js +20 -0
  93. package/dist/utils/backoff.cjs +8 -15
  94. package/dist/utils/backoff.d.ts +0 -11
  95. package/dist/utils/backoff.d.ts.map +1 -1
  96. package/dist/utils/backoff.js +8 -16
  97. package/dist/utils/decorator.cjs +44 -0
  98. package/dist/utils/decorator.d.ts +3 -0
  99. package/dist/utils/decorator.d.ts.map +1 -0
  100. package/dist/utils/decorator.js +41 -0
  101. package/dist/utils/key.cjs +45 -0
  102. package/dist/utils/key.d.ts +2 -0
  103. package/dist/utils/key.d.ts.map +1 -0
  104. package/dist/utils/key.js +42 -0
  105. package/dist/utils/limits.cjs +22 -0
  106. package/dist/utils/limits.d.ts +21 -0
  107. package/dist/utils/limits.d.ts.map +1 -0
  108. package/dist/utils/limits.js +19 -0
  109. package/dist/utils/safeCall.cjs +28 -0
  110. package/dist/utils/safeCall.d.ts +2 -0
  111. package/dist/utils/safeCall.d.ts.map +1 -0
  112. package/dist/utils/safeCall.js +25 -0
  113. package/dist/utils/sanitize.cjs +50 -0
  114. package/dist/utils/sanitize.d.ts +3 -0
  115. package/dist/utils/sanitize.d.ts.map +1 -0
  116. package/dist/utils/sanitize.js +46 -0
  117. package/dist/utils/validate.cjs +163 -26
  118. package/dist/utils/validate.d.ts +6 -8
  119. package/dist/utils/validate.d.ts.map +1 -1
  120. package/dist/utils/validate.js +158 -27
  121. package/package.json +42 -7
  122. package/dist/core/act.js.map +0 -1
  123. package/dist/core/executor.js.map +0 -1
  124. package/dist/index.js.map +0 -1
  125. package/dist/policies/cache.js.map +0 -1
  126. package/dist/policies/dedupe.js.map +0 -1
  127. package/dist/policies/retry.js.map +0 -1
  128. package/dist/policies/timeout.js.map +0 -1
  129. package/dist/state/store.cjs +0 -14
  130. package/dist/state/store.d.ts +0 -11
  131. package/dist/state/store.d.ts.map +0 -1
  132. package/dist/state/store.js +0 -11
  133. package/dist/state/store.js.map +0 -1
  134. package/dist/stores/base.js.map +0 -1
  135. package/dist/stores/memory.js.map +0 -1
  136. package/dist/types/index.js.map +0 -1
  137. package/dist/utils/abort.js.map +0 -1
  138. package/dist/utils/backoff.js.map +0 -1
  139. package/dist/utils/validate.js.map +0 -1
@@ -2,105 +2,122 @@
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:';
7
- /**
8
- * Short-circuit the entire downstream chain on a cache hit.
9
- * On a miss, run `fn` and store the result with TTL.
10
- *
11
- * # Single-flight (fixes cache stampede)
12
- *
13
- * On a sync store, the policy also stores the in-flight Promise under a
14
- * separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
15
- * cache but find an in-flight Promise join it instead of launching duplicate
16
- * work. This is the same mechanism `dedupePolicy` uses, applied internally
17
- * to cache misses so users don't need to combine `cache` + `dedupe` to
18
- * avoid stampedes.
19
- *
20
- * On an async store, single-flight is not possible (the same sync-store
21
- * constraint as dedupe applies). Stampedes are a known limitation — document
22
- * and pair with dedupe at a higher layer if you need single-flight semantics.
23
- *
24
- * # Fail-open writes
25
- *
26
- * If `store.set()` throws (e.g. Redis transient error), we swallow the error
27
- * and return the value anyway. The caller gets their result; the next call
28
- * will simply re-run `fn` and try to cache again. Caching is an optimisation,
29
- * not a correctness requirement.
30
- *
31
- * # Cache hit semantics
32
- *
33
- * On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
34
- * set to `0` — no work was performed. (v1.1.0 reported `attempts: 1` on
35
- * cache hits, which was inconsistent with the documented meaning of
36
- * `attempts`. v1.1.5 fixes this.)
37
- *
38
- * Failures are NEVER cached. Only successful values are stored.
39
- */
9
+ const INFLIGHT_NS = 'inflight:cache:';
10
+ let generationCounter = 0;
11
+ function nextGeneration() {
12
+ generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
13
+ return generationCounter;
14
+ }
40
15
  function cachePolicy(opts) {
41
16
  return (fn, ctx) => async (signal) => {
17
+ if (signal.aborted)
18
+ return Promise.reject(signal.reason);
42
19
  const key = NS + ctx.key;
43
20
  const inflightKey = INFLIGHT_NS + ctx.key;
44
- // ─── Sync store: fast path with single-flight ────────────────────────
45
21
  if ((0, base_js_1.isSyncStore)(ctx.store)) {
46
- // 1. Cache hit?
47
- const hit = ctx.store.get(key);
22
+ const store = ctx.store;
23
+ const obs = ctx.observability;
24
+ const hit = store.get(key);
48
25
  if (hit) {
49
26
  ctx.meta.source = 'cache';
50
27
  ctx.meta.attempts = 0;
28
+ if (obs) {
29
+ const ageMs = Date.now() - hit.insertedAt;
30
+ (0, safeCall_js_1.safeCall)(obs.hooks.onCacheHit, {
31
+ type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
32
+ timestamp: Date.now(), ageMs: Math.max(0, ageMs),
33
+ });
34
+ }
51
35
  return hit.value;
52
36
  }
53
- // 2. In-flight single-flight hit? Join it.
54
- const inflight = ctx.store.get(inflightKey);
55
- if (inflight)
56
- return inflight;
57
- // 3. Originator: launch fn, cache on success (fail-open).
58
- const promise = Promise.resolve(fn(signal)).then((value) => {
37
+ if (obs) {
38
+ (0, safeCall_js_1.safeCall)(obs.hooks.onCacheMiss, {
39
+ type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
40
+ timestamp: Date.now(),
41
+ });
42
+ }
43
+ const inflight = store.get(inflightKey);
44
+ if (inflight) {
45
+ try {
46
+ const value = await (0, abort_js_1.raceAbort)(inflight.promise, signal);
47
+ ctx.meta.attempts = inflight.meta.attempts;
48
+ ctx.meta.source = inflight.meta.source;
49
+ return value;
50
+ }
51
+ catch (err) {
52
+ if (signal.aborted) {
53
+ ctx.meta.attempts = 0;
54
+ }
55
+ else {
56
+ ctx.meta.attempts = inflight.meta.attempts;
57
+ ctx.meta.source = inflight.meta.source;
58
+ }
59
+ throw err;
60
+ }
61
+ }
62
+ const generation = nextGeneration();
63
+ const rawPromise = Promise.resolve(fn(signal)).then((value) => {
59
64
  try {
60
- ctx.store.set(key, { value }, opts.ttl);
65
+ store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
61
66
  }
62
67
  catch {
63
- // Fail-open: cache write failure should not surface to caller.
64
- // The value is still valid; next call will just re-run fn.
65
68
  }
66
69
  return value;
67
70
  }, (err) => { throw err; });
68
- // Try to publish the in-flight promise so concurrent callers can
69
- // join (single-flight). If store.set throws, single-flight is
70
- // disabled for this call — concurrent callers will all run fn.
71
- // That's still correct, just less efficient.
72
71
  try {
73
- ctx.store.set(inflightKey, promise);
74
- // Cleanup the in-flight slot on settle. The `.catch(() => {})`
75
- // suppresses the unhandled-rejection warning that would otherwise
76
- // fire on the `.finally()` chain — the original `promise` (with
77
- // its rejection) is returned to the caller, which has its own
78
- // try/catch via act().
79
- promise.finally(() => {
80
- try {
81
- ctx.store.delete(inflightKey);
82
- }
83
- catch { /* ignore */ }
84
- }).catch(() => { });
72
+ store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta }, limits_js_1.LIMITS.DEFAULT_INFLIGHT_TTL);
85
73
  }
86
74
  catch {
87
- // Single-flight unavailable; proceed without publishing.
88
75
  }
89
- return promise;
76
+ const cleanup = () => {
77
+ try {
78
+ const current = store.get(inflightKey);
79
+ if (current && current.generation === generation) {
80
+ try {
81
+ store.delete(inflightKey);
82
+ }
83
+ catch { }
84
+ }
85
+ }
86
+ catch {
87
+ }
88
+ };
89
+ rawPromise.then(cleanup, cleanup).catch(() => { });
90
+ return (0, abort_js_1.raceAbort)(rawPromise, signal);
90
91
  }
91
- // ─── Async store: no single-flight (race window unavoidable) ─────────
92
+ const obs = ctx.observability;
92
93
  const hit = await ctx.store.get(key);
94
+ if (signal.aborted)
95
+ return Promise.reject(signal.reason);
93
96
  if (hit) {
94
97
  ctx.meta.source = 'cache';
95
98
  ctx.meta.attempts = 0;
99
+ if (obs) {
100
+ const ageMs = Date.now() - hit.insertedAt;
101
+ (0, safeCall_js_1.safeCall)(obs.hooks.onCacheHit, {
102
+ type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
103
+ timestamp: Date.now(), ageMs: Math.max(0, ageMs),
104
+ });
105
+ }
96
106
  return hit.value;
97
107
  }
108
+ if (obs) {
109
+ (0, safeCall_js_1.safeCall)(obs.hooks.onCacheMiss, {
110
+ type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
111
+ timestamp: Date.now(),
112
+ });
113
+ }
98
114
  const value = await fn(signal);
115
+ if (signal.aborted)
116
+ return Promise.reject(signal.reason);
99
117
  try {
100
- await ctx.store.set(key, { value }, opts.ttl);
118
+ await ctx.store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
101
119
  }
102
120
  catch {
103
- // Fail-open: see sync path comment.
104
121
  }
105
122
  return value;
106
123
  };
@@ -1,36 +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
- * # Single-flight (fixes cache stampede)
7
- *
8
- * On a sync store, the policy also stores the in-flight Promise under a
9
- * separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
10
- * cache but find an in-flight Promise join it instead of launching duplicate
11
- * work. This is the same mechanism `dedupePolicy` uses, applied internally
12
- * to cache misses so users don't need to combine `cache` + `dedupe` to
13
- * avoid stampedes.
14
- *
15
- * On an async store, single-flight is not possible (the same sync-store
16
- * constraint as dedupe applies). Stampedes are a known limitation — document
17
- * and pair with dedupe at a higher layer if you need single-flight semantics.
18
- *
19
- * # Fail-open writes
20
- *
21
- * If `store.set()` throws (e.g. Redis transient error), we swallow the error
22
- * and return the value anyway. The caller gets their result; the next call
23
- * will simply re-run `fn` and try to cache again. Caching is an optimisation,
24
- * not a correctness requirement.
25
- *
26
- * # Cache hit semantics
27
- *
28
- * On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
29
- * set to `0` — no work was performed. (v1.1.0 reported `attempts: 1` on
30
- * cache hits, which was inconsistent with the documented meaning of
31
- * `attempts`. v1.1.5 fixes this.)
32
- *
33
- * Failures are NEVER cached. Only successful values are stored.
34
- */
35
2
  export declare function cachePolicy<T>(opts: CacheOptions): PolicyApplier<T>;
36
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;AAY1F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,CAAC,CAAC,CAuEnE"}
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,105 +1,121 @@
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:';
4
- /**
5
- * Short-circuit the entire downstream chain on a cache hit.
6
- * On a miss, run `fn` and store the result with TTL.
7
- *
8
- * # Single-flight (fixes cache stampede)
9
- *
10
- * On a sync store, the policy also stores the in-flight Promise under a
11
- * separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
12
- * cache but find an in-flight Promise join it instead of launching duplicate
13
- * work. This is the same mechanism `dedupePolicy` uses, applied internally
14
- * to cache misses so users don't need to combine `cache` + `dedupe` to
15
- * avoid stampedes.
16
- *
17
- * On an async store, single-flight is not possible (the same sync-store
18
- * constraint as dedupe applies). Stampedes are a known limitation — document
19
- * and pair with dedupe at a higher layer if you need single-flight semantics.
20
- *
21
- * # Fail-open writes
22
- *
23
- * If `store.set()` throws (e.g. Redis transient error), we swallow the error
24
- * and return the value anyway. The caller gets their result; the next call
25
- * will simply re-run `fn` and try to cache again. Caching is an optimisation,
26
- * not a correctness requirement.
27
- *
28
- * # Cache hit semantics
29
- *
30
- * On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
31
- * set to `0` — no work was performed. (v1.1.0 reported `attempts: 1` on
32
- * cache hits, which was inconsistent with the documented meaning of
33
- * `attempts`. v1.1.5 fixes this.)
34
- *
35
- * Failures are NEVER cached. Only successful values are stored.
36
- */
6
+ const INFLIGHT_NS = 'inflight:cache:';
7
+ let generationCounter = 0;
8
+ function nextGeneration() {
9
+ generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
10
+ return generationCounter;
11
+ }
37
12
  export function cachePolicy(opts) {
38
13
  return (fn, ctx) => async (signal) => {
14
+ if (signal.aborted)
15
+ return Promise.reject(signal.reason);
39
16
  const key = NS + ctx.key;
40
17
  const inflightKey = INFLIGHT_NS + ctx.key;
41
- // ─── Sync store: fast path with single-flight ────────────────────────
42
18
  if (isSyncStore(ctx.store)) {
43
- // 1. Cache hit?
44
- const hit = ctx.store.get(key);
19
+ const store = ctx.store;
20
+ const obs = ctx.observability;
21
+ const hit = store.get(key);
45
22
  if (hit) {
46
23
  ctx.meta.source = 'cache';
47
24
  ctx.meta.attempts = 0;
25
+ if (obs) {
26
+ const ageMs = Date.now() - hit.insertedAt;
27
+ safeCall(obs.hooks.onCacheHit, {
28
+ type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
29
+ timestamp: Date.now(), ageMs: Math.max(0, ageMs),
30
+ });
31
+ }
48
32
  return hit.value;
49
33
  }
50
- // 2. In-flight single-flight hit? Join it.
51
- const inflight = ctx.store.get(inflightKey);
52
- if (inflight)
53
- return inflight;
54
- // 3. Originator: launch fn, cache on success (fail-open).
55
- const promise = Promise.resolve(fn(signal)).then((value) => {
34
+ if (obs) {
35
+ safeCall(obs.hooks.onCacheMiss, {
36
+ type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
37
+ timestamp: Date.now(),
38
+ });
39
+ }
40
+ const inflight = store.get(inflightKey);
41
+ if (inflight) {
42
+ try {
43
+ const value = await raceAbort(inflight.promise, signal);
44
+ ctx.meta.attempts = inflight.meta.attempts;
45
+ ctx.meta.source = inflight.meta.source;
46
+ return value;
47
+ }
48
+ catch (err) {
49
+ if (signal.aborted) {
50
+ ctx.meta.attempts = 0;
51
+ }
52
+ else {
53
+ ctx.meta.attempts = inflight.meta.attempts;
54
+ ctx.meta.source = inflight.meta.source;
55
+ }
56
+ throw err;
57
+ }
58
+ }
59
+ const generation = nextGeneration();
60
+ const rawPromise = Promise.resolve(fn(signal)).then((value) => {
56
61
  try {
57
- ctx.store.set(key, { value }, opts.ttl);
62
+ store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
58
63
  }
59
64
  catch {
60
- // Fail-open: cache write failure should not surface to caller.
61
- // The value is still valid; next call will just re-run fn.
62
65
  }
63
66
  return value;
64
67
  }, (err) => { throw err; });
65
- // Try to publish the in-flight promise so concurrent callers can
66
- // join (single-flight). If store.set throws, single-flight is
67
- // disabled for this call — concurrent callers will all run fn.
68
- // That's still correct, just less efficient.
69
68
  try {
70
- ctx.store.set(inflightKey, promise);
71
- // Cleanup the in-flight slot on settle. The `.catch(() => {})`
72
- // suppresses the unhandled-rejection warning that would otherwise
73
- // fire on the `.finally()` chain — the original `promise` (with
74
- // its rejection) is returned to the caller, which has its own
75
- // try/catch via act().
76
- promise.finally(() => {
77
- try {
78
- ctx.store.delete(inflightKey);
79
- }
80
- catch { /* ignore */ }
81
- }).catch(() => { });
69
+ store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta }, LIMITS.DEFAULT_INFLIGHT_TTL);
82
70
  }
83
71
  catch {
84
- // Single-flight unavailable; proceed without publishing.
85
72
  }
86
- return promise;
73
+ const cleanup = () => {
74
+ try {
75
+ const current = store.get(inflightKey);
76
+ if (current && current.generation === generation) {
77
+ try {
78
+ store.delete(inflightKey);
79
+ }
80
+ catch { }
81
+ }
82
+ }
83
+ catch {
84
+ }
85
+ };
86
+ rawPromise.then(cleanup, cleanup).catch(() => { });
87
+ return raceAbort(rawPromise, signal);
87
88
  }
88
- // ─── Async store: no single-flight (race window unavoidable) ─────────
89
+ const obs = ctx.observability;
89
90
  const hit = await ctx.store.get(key);
91
+ if (signal.aborted)
92
+ return Promise.reject(signal.reason);
90
93
  if (hit) {
91
94
  ctx.meta.source = 'cache';
92
95
  ctx.meta.attempts = 0;
96
+ if (obs) {
97
+ const ageMs = Date.now() - hit.insertedAt;
98
+ safeCall(obs.hooks.onCacheHit, {
99
+ type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
100
+ timestamp: Date.now(), ageMs: Math.max(0, ageMs),
101
+ });
102
+ }
93
103
  return hit.value;
94
104
  }
105
+ if (obs) {
106
+ safeCall(obs.hooks.onCacheMiss, {
107
+ type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
108
+ timestamp: Date.now(),
109
+ });
110
+ }
95
111
  const value = await fn(signal);
112
+ if (signal.aborted)
113
+ return Promise.reject(signal.reason);
96
114
  try {
97
- await ctx.store.set(key, { value }, opts.ttl);
115
+ await ctx.store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
98
116
  }
99
117
  catch {
100
- // Fail-open: see sync path comment.
101
118
  }
102
119
  return value;
103
120
  };
104
121
  }
105
- //# sourceMappingURL=cache.js.map
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.circuitBreakerPolicy = circuitBreakerPolicy;
4
+ const executor_js_1 = require("../core/executor.js");
5
+ const errors_js_1 = require("../errors.js");
6
+ const abort_js_1 = require("../utils/abort.js");
7
+ const NS = 'cb:';
8
+ function getState(store, key, strategy) {
9
+ const existing = store.get(NS + key);
10
+ if (existing)
11
+ return existing;
12
+ if (strategy === 'count') {
13
+ return {
14
+ strategy: 'count',
15
+ outcomes: [],
16
+ index: 0,
17
+ failures: 0,
18
+ filled: 0,
19
+ isOpen: false,
20
+ openedAt: 0,
21
+ halfOpen: false,
22
+ lastFailureTime: 0,
23
+ };
24
+ }
25
+ return {
26
+ strategy: 'consecutive',
27
+ failures: 0,
28
+ lastFailureTime: 0,
29
+ isOpen: false,
30
+ openedAt: 0,
31
+ halfOpen: false,
32
+ };
33
+ }
34
+ function setState(store, key, state) {
35
+ store.set(NS + key, state);
36
+ }
37
+ function recordCountOutcome(state, success, windowSize) {
38
+ if (state.outcomes.length < windowSize) {
39
+ state.outcomes.push(success);
40
+ state.filled = state.outcomes.length;
41
+ if (!success)
42
+ state.failures++;
43
+ state.index = (state.index + 1) % windowSize;
44
+ return;
45
+ }
46
+ if (state.outcomes[state.index] === false)
47
+ state.failures--;
48
+ state.outcomes[state.index] = success;
49
+ if (!success)
50
+ state.failures++;
51
+ state.index = (state.index + 1) % windowSize;
52
+ state.filled = windowSize;
53
+ }
54
+ function countFailureRate(state) {
55
+ if (state.filled === 0)
56
+ return 0;
57
+ return state.failures / state.filled;
58
+ }
59
+ function resetCountState(state) {
60
+ state.outcomes = [];
61
+ state.index = 0;
62
+ state.failures = 0;
63
+ state.filled = 0;
64
+ state.isOpen = false;
65
+ state.openedAt = 0;
66
+ state.halfOpen = false;
67
+ state.lastFailureTime = 0;
68
+ }
69
+ function circuitBreakerPolicy(opts) {
70
+ const threshold = Math.max(1, Math.floor(opts.threshold));
71
+ const cooldownMs = opts.cooldownMs;
72
+ const resetTimeoutMs = opts.resetTimeoutMs ?? Number.POSITIVE_INFINITY;
73
+ const strategy = opts.strategy ?? 'consecutive';
74
+ const countSize = strategy === 'count' ? Math.max(1, Math.floor(opts.countSize ?? 100)) : 0;
75
+ const countThreshold = strategy === 'count'
76
+ ? Math.min(1, Math.max(0, opts.countThreshold ?? 0.5))
77
+ : 0;
78
+ const countMinimumCalls = strategy === 'count'
79
+ ? Math.max(1, Math.floor(opts.countMinimumCalls ?? countSize))
80
+ : 0;
81
+ const applier = (fn, ctx) => {
82
+ const syncCtx = ctx;
83
+ return async (signal) => {
84
+ const key = syncCtx.key;
85
+ const now = Date.now();
86
+ const state = getState(syncCtx.store, key, strategy);
87
+ if (state.isOpen) {
88
+ const elapsed = now - state.openedAt;
89
+ if (elapsed >= cooldownMs) {
90
+ state.isOpen = false;
91
+ state.halfOpen = true;
92
+ setState(syncCtx.store, key, state);
93
+ }
94
+ else {
95
+ throw new errors_js_1.CircuitBreakerOpenError(key, cooldownMs - elapsed);
96
+ }
97
+ }
98
+ else if (state.halfOpen) {
99
+ throw new errors_js_1.CircuitBreakerOpenError(key, 0);
100
+ }
101
+ if (now - state.lastFailureTime > resetTimeoutMs && state.lastFailureTime > 0) {
102
+ if (!state.halfOpen) {
103
+ if (state.strategy === 'count') {
104
+ resetCountState(state);
105
+ }
106
+ else {
107
+ state.failures = 0;
108
+ state.isOpen = false;
109
+ state.openedAt = 0;
110
+ state.halfOpen = false;
111
+ }
112
+ setState(syncCtx.store, key, state);
113
+ }
114
+ }
115
+ try {
116
+ const result = await fn(signal);
117
+ const updated = getState(syncCtx.store, key, strategy);
118
+ const wasHalfOpen = updated.halfOpen;
119
+ if (wasHalfOpen && updated.strategy === 'count') {
120
+ resetCountState(updated);
121
+ }
122
+ else if (updated.strategy === 'count') {
123
+ recordCountOutcome(updated, true, countSize);
124
+ }
125
+ else {
126
+ updated.failures = 0;
127
+ }
128
+ if (wasHalfOpen) {
129
+ updated.isOpen = false;
130
+ updated.halfOpen = false;
131
+ }
132
+ const isIdle = updated.failures === 0 && !updated.isOpen && !updated.halfOpen;
133
+ if (isIdle) {
134
+ syncCtx.store.delete(NS + key);
135
+ }
136
+ else {
137
+ setState(syncCtx.store, key, updated);
138
+ }
139
+ return result;
140
+ }
141
+ catch (err) {
142
+ if (signal.aborted && ((0, abort_js_1.isAbortError)(err) || err === signal.reason)) {
143
+ const updated = getState(syncCtx.store, key, strategy);
144
+ if (updated.failures === 0 && !updated.isOpen && !updated.halfOpen) {
145
+ syncCtx.store.delete(NS + key);
146
+ }
147
+ else {
148
+ setState(syncCtx.store, key, updated);
149
+ }
150
+ throw err;
151
+ }
152
+ const updated = getState(syncCtx.store, key, strategy);
153
+ const wasHalfOpen = updated.halfOpen;
154
+ updated.lastFailureTime = Date.now();
155
+ updated.halfOpen = false;
156
+ if (updated.strategy === 'count') {
157
+ recordCountOutcome(updated, false, countSize);
158
+ const rate = countFailureRate(updated);
159
+ const enoughCalls = updated.filled >= countMinimumCalls;
160
+ if (wasHalfOpen || (enoughCalls && rate > countThreshold)) {
161
+ updated.isOpen = true;
162
+ updated.openedAt = Date.now();
163
+ }
164
+ }
165
+ else {
166
+ updated.failures++;
167
+ if (updated.failures >= threshold || wasHalfOpen) {
168
+ updated.isOpen = true;
169
+ updated.openedAt = Date.now();
170
+ }
171
+ }
172
+ setState(syncCtx.store, key, updated);
173
+ throw err;
174
+ }
175
+ };
176
+ };
177
+ applier[executor_js_1.REQUIRES_SYNC_STORE] = true;
178
+ return applier;
179
+ }
@@ -0,0 +1,3 @@
1
+ import type { PolicyApplier, CircuitBreakerOptions } from '../types/index.js';
2
+ export declare function circuitBreakerPolicy<T>(opts: CircuitBreakerOptions): PolicyApplier<T>;
3
+ //# sourceMappingURL=circuitBreaker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"circuitBreaker.d.ts","sourceRoot":"","sources":["../../src/policies/circuitBreaker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,qBAAqB,EAAE,MAAM,mBAAmB,CAAA;AAoGnG,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,IAAI,EAAE,qBAAqB,GAAG,aAAa,CAAC,CAAC,CAAC,CAiIrF"}