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
@@ -0,0 +1,176 @@
1
+ import { REQUIRES_SYNC_STORE } from '../core/executor.js';
2
+ import { CircuitBreakerOpenError } from '../errors.js';
3
+ import { isAbortError } from '../utils/abort.js';
4
+ const NS = 'cb:';
5
+ function getState(store, key, strategy) {
6
+ const existing = store.get(NS + key);
7
+ if (existing)
8
+ return existing;
9
+ if (strategy === 'count') {
10
+ return {
11
+ strategy: 'count',
12
+ outcomes: [],
13
+ index: 0,
14
+ failures: 0,
15
+ filled: 0,
16
+ isOpen: false,
17
+ openedAt: 0,
18
+ halfOpen: false,
19
+ lastFailureTime: 0,
20
+ };
21
+ }
22
+ return {
23
+ strategy: 'consecutive',
24
+ failures: 0,
25
+ lastFailureTime: 0,
26
+ isOpen: false,
27
+ openedAt: 0,
28
+ halfOpen: false,
29
+ };
30
+ }
31
+ function setState(store, key, state) {
32
+ store.set(NS + key, state);
33
+ }
34
+ function recordCountOutcome(state, success, windowSize) {
35
+ if (state.outcomes.length < windowSize) {
36
+ state.outcomes.push(success);
37
+ state.filled = state.outcomes.length;
38
+ if (!success)
39
+ state.failures++;
40
+ state.index = (state.index + 1) % windowSize;
41
+ return;
42
+ }
43
+ if (state.outcomes[state.index] === false)
44
+ state.failures--;
45
+ state.outcomes[state.index] = success;
46
+ if (!success)
47
+ state.failures++;
48
+ state.index = (state.index + 1) % windowSize;
49
+ state.filled = windowSize;
50
+ }
51
+ function countFailureRate(state) {
52
+ if (state.filled === 0)
53
+ return 0;
54
+ return state.failures / state.filled;
55
+ }
56
+ function resetCountState(state) {
57
+ state.outcomes = [];
58
+ state.index = 0;
59
+ state.failures = 0;
60
+ state.filled = 0;
61
+ state.isOpen = false;
62
+ state.openedAt = 0;
63
+ state.halfOpen = false;
64
+ state.lastFailureTime = 0;
65
+ }
66
+ export function circuitBreakerPolicy(opts) {
67
+ const threshold = Math.max(1, Math.floor(opts.threshold));
68
+ const cooldownMs = opts.cooldownMs;
69
+ const resetTimeoutMs = opts.resetTimeoutMs ?? Number.POSITIVE_INFINITY;
70
+ const strategy = opts.strategy ?? 'consecutive';
71
+ const countSize = strategy === 'count' ? Math.max(1, Math.floor(opts.countSize ?? 100)) : 0;
72
+ const countThreshold = strategy === 'count'
73
+ ? Math.min(1, Math.max(0, opts.countThreshold ?? 0.5))
74
+ : 0;
75
+ const countMinimumCalls = strategy === 'count'
76
+ ? Math.max(1, Math.floor(opts.countMinimumCalls ?? countSize))
77
+ : 0;
78
+ const applier = (fn, ctx) => {
79
+ const syncCtx = ctx;
80
+ return async (signal) => {
81
+ const key = syncCtx.key;
82
+ const now = Date.now();
83
+ const state = getState(syncCtx.store, key, strategy);
84
+ if (state.isOpen) {
85
+ const elapsed = now - state.openedAt;
86
+ if (elapsed >= cooldownMs) {
87
+ state.isOpen = false;
88
+ state.halfOpen = true;
89
+ setState(syncCtx.store, key, state);
90
+ }
91
+ else {
92
+ throw new CircuitBreakerOpenError(key, cooldownMs - elapsed);
93
+ }
94
+ }
95
+ else if (state.halfOpen) {
96
+ throw new CircuitBreakerOpenError(key, 0);
97
+ }
98
+ if (now - state.lastFailureTime > resetTimeoutMs && state.lastFailureTime > 0) {
99
+ if (!state.halfOpen) {
100
+ if (state.strategy === 'count') {
101
+ resetCountState(state);
102
+ }
103
+ else {
104
+ state.failures = 0;
105
+ state.isOpen = false;
106
+ state.openedAt = 0;
107
+ state.halfOpen = false;
108
+ }
109
+ setState(syncCtx.store, key, state);
110
+ }
111
+ }
112
+ try {
113
+ const result = await fn(signal);
114
+ const updated = getState(syncCtx.store, key, strategy);
115
+ const wasHalfOpen = updated.halfOpen;
116
+ if (wasHalfOpen && updated.strategy === 'count') {
117
+ resetCountState(updated);
118
+ }
119
+ else if (updated.strategy === 'count') {
120
+ recordCountOutcome(updated, true, countSize);
121
+ }
122
+ else {
123
+ updated.failures = 0;
124
+ }
125
+ if (wasHalfOpen) {
126
+ updated.isOpen = false;
127
+ updated.halfOpen = false;
128
+ }
129
+ const isIdle = updated.failures === 0 && !updated.isOpen && !updated.halfOpen;
130
+ if (isIdle) {
131
+ syncCtx.store.delete(NS + key);
132
+ }
133
+ else {
134
+ setState(syncCtx.store, key, updated);
135
+ }
136
+ return result;
137
+ }
138
+ catch (err) {
139
+ if (signal.aborted && (isAbortError(err) || err === signal.reason)) {
140
+ const updated = getState(syncCtx.store, key, strategy);
141
+ if (updated.failures === 0 && !updated.isOpen && !updated.halfOpen) {
142
+ syncCtx.store.delete(NS + key);
143
+ }
144
+ else {
145
+ setState(syncCtx.store, key, updated);
146
+ }
147
+ throw err;
148
+ }
149
+ const updated = getState(syncCtx.store, key, strategy);
150
+ const wasHalfOpen = updated.halfOpen;
151
+ updated.lastFailureTime = Date.now();
152
+ updated.halfOpen = false;
153
+ if (updated.strategy === 'count') {
154
+ recordCountOutcome(updated, false, countSize);
155
+ const rate = countFailureRate(updated);
156
+ const enoughCalls = updated.filled >= countMinimumCalls;
157
+ if (wasHalfOpen || (enoughCalls && rate > countThreshold)) {
158
+ updated.isOpen = true;
159
+ updated.openedAt = Date.now();
160
+ }
161
+ }
162
+ else {
163
+ updated.failures++;
164
+ if (updated.failures >= threshold || wasHalfOpen) {
165
+ updated.isOpen = true;
166
+ updated.openedAt = Date.now();
167
+ }
168
+ }
169
+ setState(syncCtx.store, key, updated);
170
+ throw err;
171
+ }
172
+ };
173
+ };
174
+ applier[REQUIRES_SYNC_STORE] = true;
175
+ return applier;
176
+ }
@@ -3,85 +3,67 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.dedupePolicy = dedupePolicy;
4
4
  const executor_js_1 = require("../core/executor.js");
5
5
  const abort_js_1 = require("../utils/abort.js");
6
- // Namespace so dedupe keys never collide with cache keys in the shared store.
6
+ const safeCall_js_1 = require("../utils/safeCall.js");
7
+ const limits_js_1 = require("../utils/limits.js");
7
8
  const NS = 'dedupe:';
8
- /**
9
- * Collapse concurrent calls that share the same key into one in-flight Promise.
10
- *
11
- * # How it works
12
- *
13
- * The first caller (originator) starts the work and stores
14
- * `{ promise, meta }` in the store under `dedupe:<key>`. Every subsequent
15
- * caller that arrives before the promise settles receives the SAME promise
16
- * — no duplicate work.
17
- *
18
- * # Shared `meta` (fixes v1.0 trade-off)
19
- *
20
- * The originator's `ctx.meta` reference is stored alongside the promise.
21
- * Inner policies (e.g. `retryPolicy`) mutate it as they run. After the
22
- * promise settles, joiners copy `attempts` and `source` from the shared
23
- * meta into their own `ctx.meta`. This means a joiner's `ActResult.attempts`
24
- * reflects the real effort (e.g. `3` if the originator retried twice), not
25
- * the misleading default of `1`.
26
- *
27
- * # Abort safety (fixes hung-fn block)
28
- *
29
- * Joiners race the in-flight promise against their own AbortSignal via
30
- * `raceAbort`. If a joiner's signal aborts (e.g. their `totalTimeout`
31
- * fires), they reject immediately — they don't have to wait for the
32
- * originator to finish. The originator's promise continues in the
33
- * background for any other joiners that haven't aborted.
34
- *
35
- * If `inflightTtl` is set, the store entry is also TTL'd: if the
36
- * originator never settles, new callers can start fresh after the TTL
37
- * expires (the original promise still leaks unless an outer timeout
38
- * fires, but new callers aren't blocked).
39
- *
40
- * # INVARIANT: requires SyncStateStore
41
- *
42
- * The read-then-write that makes deduplication work must happen in a single
43
- * synchronous frame. An async store would introduce an `await` between
44
- * `get()` and `set()`, letting two concurrent callers both see a miss and
45
- * both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
46
- * `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
47
- * that bypass TypeScript.
48
- */
9
+ let generationCounter = 0;
10
+ function nextGeneration() {
11
+ generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
12
+ return generationCounter;
13
+ }
49
14
  function dedupePolicy(opts = { enabled: true }) {
50
- const inflightTtl = opts.inflightTtl;
15
+ const inflightTtl = opts.inflightTtl ?? limits_js_1.LIMITS.DEFAULT_INFLIGHT_TTL;
51
16
  const applier = (fn, ctx) => {
52
- // Cast is safe: execute() verifies isSyncStore(ctx.store) before calling
53
- // any policy tagged with REQUIRES_SYNC_STORE.
54
17
  const syncCtx = ctx;
55
18
  return async (signal) => {
56
19
  const key = NS + syncCtx.key;
57
- // Fast path: an in-flight promise already exists. Join it.
58
- // raceAbort ensures we don't block on a hung originator if our own
59
- // signal aborts.
60
20
  const existing = syncCtx.store.get(key);
61
21
  if (existing) {
22
+ const obs = syncCtx.observability;
23
+ if (obs) {
24
+ obs.joinerCounter++;
25
+ (0, safeCall_js_1.safeCall)(obs.hooks.onDedupeJoin, {
26
+ type: 'dedupe-join', key: syncCtx.key, traceId: obs.traceId,
27
+ timestamp: Date.now(), joinerPosition: obs.joinerCounter,
28
+ });
29
+ }
62
30
  try {
63
31
  const value = await (0, abort_js_1.raceAbort)(existing.promise, signal);
64
- return value;
65
- }
66
- finally {
67
- // Copy the originator's final meta into our own, regardless of
68
- // success or failure. By the time `existing.promise` has settled
69
- // (or our signal aborted), the originator's retry loop has set
70
- // the final attempt count.
71
32
  syncCtx.meta.attempts = existing.meta.attempts;
72
33
  syncCtx.meta.source = existing.meta.source;
34
+ return value;
35
+ }
36
+ catch (err) {
37
+ if (signal.aborted) {
38
+ syncCtx.meta.attempts = 0;
39
+ }
40
+ else {
41
+ syncCtx.meta.attempts = existing.meta.attempts;
42
+ syncCtx.meta.source = existing.meta.source;
43
+ }
44
+ throw err;
73
45
  }
74
46
  }
75
- // Originator path: start the work and publish the promise.
76
- //
77
- // We race fn against `signal` so that if our own signal aborts while
78
- // fn is pending, we reject (and the .finally cleans up the store).
79
- // The stored promise is the RACED one — joiners see the same
80
- // rejection if they join before cleanup.
81
- const promise = (0, abort_js_1.raceAbort)(Promise.resolve(fn(signal)), signal).finally(() => syncCtx.store.delete(key));
82
- const entry = { promise, meta: syncCtx.meta };
47
+ const generation = nextGeneration();
48
+ const rawPromise = Promise.resolve(fn(signal));
49
+ const entry = {
50
+ promise: rawPromise,
51
+ meta: syncCtx.meta,
52
+ generation,
53
+ };
83
54
  syncCtx.store.set(key, entry, inflightTtl);
84
- return promise;
55
+ const cleanup = () => {
56
+ try {
57
+ const current = syncCtx.store.get(key);
58
+ if (current && current.generation === generation) {
59
+ syncCtx.store.delete(key);
60
+ }
61
+ }
62
+ catch {
63
+ }
64
+ };
65
+ rawPromise.then(cleanup, cleanup).catch(() => { });
66
+ return (0, abort_js_1.raceAbort)(rawPromise, signal);
85
67
  };
86
68
  };
87
69
  applier[executor_js_1.REQUIRES_SYNC_STORE] = true;
@@ -1,44 +1,3 @@
1
1
  import type { PolicyApplier, DedupeOptions } from '../types/index.js';
2
- /**
3
- * Collapse concurrent calls that share the same key into one in-flight Promise.
4
- *
5
- * # How it works
6
- *
7
- * The first caller (originator) starts the work and stores
8
- * `{ promise, meta }` in the store under `dedupe:<key>`. Every subsequent
9
- * caller that arrives before the promise settles receives the SAME promise
10
- * — no duplicate work.
11
- *
12
- * # Shared `meta` (fixes v1.0 trade-off)
13
- *
14
- * The originator's `ctx.meta` reference is stored alongside the promise.
15
- * Inner policies (e.g. `retryPolicy`) mutate it as they run. After the
16
- * promise settles, joiners copy `attempts` and `source` from the shared
17
- * meta into their own `ctx.meta`. This means a joiner's `ActResult.attempts`
18
- * reflects the real effort (e.g. `3` if the originator retried twice), not
19
- * the misleading default of `1`.
20
- *
21
- * # Abort safety (fixes hung-fn block)
22
- *
23
- * Joiners race the in-flight promise against their own AbortSignal via
24
- * `raceAbort`. If a joiner's signal aborts (e.g. their `totalTimeout`
25
- * fires), they reject immediately — they don't have to wait for the
26
- * originator to finish. The originator's promise continues in the
27
- * background for any other joiners that haven't aborted.
28
- *
29
- * If `inflightTtl` is set, the store entry is also TTL'd: if the
30
- * originator never settles, new callers can start fresh after the TTL
31
- * expires (the original promise still leaks unless an outer timeout
32
- * fires, but new callers aren't blocked).
33
- *
34
- * # INVARIANT: requires SyncStateStore
35
- *
36
- * The read-then-write that makes deduplication work must happen in a single
37
- * synchronous frame. An async store would introduce an `await` between
38
- * `get()` and `set()`, letting two concurrent callers both see a miss and
39
- * both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
40
- * `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
41
- * that bypass TypeScript.
42
- */
43
2
  export declare function dedupePolicy<T>(opts?: DedupeOptions): PolicyApplier<T>;
44
3
  //# sourceMappingURL=dedupe.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dedupe.d.ts","sourceRoot":"","sources":["../../src/policies/dedupe.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAqB3F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,IAAI,GAAE,aAAiC,GAAG,aAAa,CAAC,CAAC,CAAC,CAkDzF"}
1
+ {"version":3,"file":"dedupe.d.ts","sourceRoot":"","sources":["../../src/policies/dedupe.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,aAAa,EAAE,MAAM,mBAAmB,CAAA;AA0D3F,wBAAgB,YAAY,CAAC,CAAC,EAAE,IAAI,GAAE,aAAiC,GAAG,aAAa,CAAC,CAAC,CAAC,CA6FzF"}
@@ -1,87 +1,68 @@
1
1
  import { REQUIRES_SYNC_STORE } from '../core/executor.js';
2
2
  import { raceAbort } from '../utils/abort.js';
3
- // Namespace so dedupe keys never collide with cache keys in the shared store.
3
+ import { safeCall } from '../utils/safeCall.js';
4
+ import { LIMITS } from '../utils/limits.js';
4
5
  const NS = 'dedupe:';
5
- /**
6
- * Collapse concurrent calls that share the same key into one in-flight Promise.
7
- *
8
- * # How it works
9
- *
10
- * The first caller (originator) starts the work and stores
11
- * `{ promise, meta }` in the store under `dedupe:<key>`. Every subsequent
12
- * caller that arrives before the promise settles receives the SAME promise
13
- * — no duplicate work.
14
- *
15
- * # Shared `meta` (fixes v1.0 trade-off)
16
- *
17
- * The originator's `ctx.meta` reference is stored alongside the promise.
18
- * Inner policies (e.g. `retryPolicy`) mutate it as they run. After the
19
- * promise settles, joiners copy `attempts` and `source` from the shared
20
- * meta into their own `ctx.meta`. This means a joiner's `ActResult.attempts`
21
- * reflects the real effort (e.g. `3` if the originator retried twice), not
22
- * the misleading default of `1`.
23
- *
24
- * # Abort safety (fixes hung-fn block)
25
- *
26
- * Joiners race the in-flight promise against their own AbortSignal via
27
- * `raceAbort`. If a joiner's signal aborts (e.g. their `totalTimeout`
28
- * fires), they reject immediately — they don't have to wait for the
29
- * originator to finish. The originator's promise continues in the
30
- * background for any other joiners that haven't aborted.
31
- *
32
- * If `inflightTtl` is set, the store entry is also TTL'd: if the
33
- * originator never settles, new callers can start fresh after the TTL
34
- * expires (the original promise still leaks unless an outer timeout
35
- * fires, but new callers aren't blocked).
36
- *
37
- * # INVARIANT: requires SyncStateStore
38
- *
39
- * The read-then-write that makes deduplication work must happen in a single
40
- * synchronous frame. An async store would introduce an `await` between
41
- * `get()` and `set()`, letting two concurrent callers both see a miss and
42
- * both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
43
- * `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
44
- * that bypass TypeScript.
45
- */
6
+ let generationCounter = 0;
7
+ function nextGeneration() {
8
+ generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
9
+ return generationCounter;
10
+ }
46
11
  export function dedupePolicy(opts = { enabled: true }) {
47
- const inflightTtl = opts.inflightTtl;
12
+ const inflightTtl = opts.inflightTtl ?? LIMITS.DEFAULT_INFLIGHT_TTL;
48
13
  const applier = (fn, ctx) => {
49
- // Cast is safe: execute() verifies isSyncStore(ctx.store) before calling
50
- // any policy tagged with REQUIRES_SYNC_STORE.
51
14
  const syncCtx = ctx;
52
15
  return async (signal) => {
53
16
  const key = NS + syncCtx.key;
54
- // Fast path: an in-flight promise already exists. Join it.
55
- // raceAbort ensures we don't block on a hung originator if our own
56
- // signal aborts.
57
17
  const existing = syncCtx.store.get(key);
58
18
  if (existing) {
19
+ const obs = syncCtx.observability;
20
+ if (obs) {
21
+ obs.joinerCounter++;
22
+ safeCall(obs.hooks.onDedupeJoin, {
23
+ type: 'dedupe-join', key: syncCtx.key, traceId: obs.traceId,
24
+ timestamp: Date.now(), joinerPosition: obs.joinerCounter,
25
+ });
26
+ }
59
27
  try {
60
28
  const value = await raceAbort(existing.promise, signal);
61
- return value;
62
- }
63
- finally {
64
- // Copy the originator's final meta into our own, regardless of
65
- // success or failure. By the time `existing.promise` has settled
66
- // (or our signal aborted), the originator's retry loop has set
67
- // the final attempt count.
68
29
  syncCtx.meta.attempts = existing.meta.attempts;
69
30
  syncCtx.meta.source = existing.meta.source;
31
+ return value;
32
+ }
33
+ catch (err) {
34
+ if (signal.aborted) {
35
+ syncCtx.meta.attempts = 0;
36
+ }
37
+ else {
38
+ syncCtx.meta.attempts = existing.meta.attempts;
39
+ syncCtx.meta.source = existing.meta.source;
40
+ }
41
+ throw err;
70
42
  }
71
43
  }
72
- // Originator path: start the work and publish the promise.
73
- //
74
- // We race fn against `signal` so that if our own signal aborts while
75
- // fn is pending, we reject (and the .finally cleans up the store).
76
- // The stored promise is the RACED one — joiners see the same
77
- // rejection if they join before cleanup.
78
- const promise = raceAbort(Promise.resolve(fn(signal)), signal).finally(() => syncCtx.store.delete(key));
79
- const entry = { promise, meta: syncCtx.meta };
44
+ const generation = nextGeneration();
45
+ const rawPromise = Promise.resolve(fn(signal));
46
+ const entry = {
47
+ promise: rawPromise,
48
+ meta: syncCtx.meta,
49
+ generation,
50
+ };
80
51
  syncCtx.store.set(key, entry, inflightTtl);
81
- return promise;
52
+ const cleanup = () => {
53
+ try {
54
+ const current = syncCtx.store.get(key);
55
+ if (current && current.generation === generation) {
56
+ syncCtx.store.delete(key);
57
+ }
58
+ }
59
+ catch {
60
+ }
61
+ };
62
+ rawPromise.then(cleanup, cleanup).catch(() => { });
63
+ return raceAbort(rawPromise, signal);
82
64
  };
83
65
  };
84
66
  applier[REQUIRES_SYNC_STORE] = true;
85
67
  return applier;
86
68
  }
87
- //# sourceMappingURL=dedupe.js.map
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.noopPolicy = noopPolicy;
4
+ function noopPolicy() {
5
+ return (fn, _ctx) => fn;
6
+ }
@@ -0,0 +1,3 @@
1
+ import type { PolicyApplier } from '../types/index.js';
2
+ export declare function noopPolicy<T>(): PolicyApplier<T>;
3
+ //# sourceMappingURL=noop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"noop.d.ts","sourceRoot":"","sources":["../../src/policies/noop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,MAAM,mBAAmB,CAAA;AAoB5E,wBAAgB,UAAU,CAAC,CAAC,KAAK,aAAa,CAAC,CAAC,CAAC,CAEhD"}
@@ -0,0 +1,3 @@
1
+ export function noopPolicy() {
2
+ return (fn, _ctx) => fn;
3
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rateLimitPolicy = rateLimitPolicy;
4
+ const executor_js_1 = require("../core/executor.js");
5
+ const errors_js_1 = require("../errors.js");
6
+ const NS = 'rl:';
7
+ function getState(store, key) {
8
+ return store.get(NS + key) ?? { timestamps: [] };
9
+ }
10
+ function setState(store, key, state, ttlMs) {
11
+ store.set(NS + key, state, ttlMs);
12
+ }
13
+ function rateLimitPolicy(opts) {
14
+ const maxCalls = Math.max(1, Math.floor(opts.maxCalls));
15
+ const windowMs = opts.windowMs;
16
+ const applier = (fn, ctx) => {
17
+ const syncCtx = ctx;
18
+ return async (signal) => {
19
+ if (signal.aborted)
20
+ return Promise.reject(signal.reason);
21
+ const key = syncCtx.key;
22
+ const now = Date.now();
23
+ const state = getState(syncCtx.store, key);
24
+ const cutoff = now - windowMs;
25
+ const ts = state.timestamps;
26
+ if (ts.length > 0 && ts[0] <= cutoff) {
27
+ let i = 0;
28
+ while (i < ts.length && ts[i] <= cutoff)
29
+ i++;
30
+ if (i > 0) {
31
+ state.timestamps = i === ts.length ? [] : ts.slice(i);
32
+ }
33
+ }
34
+ if (state.timestamps.length >= maxCalls) {
35
+ setState(syncCtx.store, key, state, windowMs);
36
+ throw new errors_js_1.RateLimitError(key, maxCalls, windowMs);
37
+ }
38
+ state.timestamps.push(now);
39
+ setState(syncCtx.store, key, state, windowMs);
40
+ return fn(signal);
41
+ };
42
+ };
43
+ applier[executor_js_1.REQUIRES_SYNC_STORE] = true;
44
+ return applier;
45
+ }
@@ -0,0 +1,3 @@
1
+ import type { PolicyApplier, RateLimitOptions } from '../types/index.js';
2
+ export declare function rateLimitPolicy<T>(opts: RateLimitOptions): PolicyApplier<T>;
3
+ //# sourceMappingURL=rateLimit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rateLimit.d.ts","sourceRoot":"","sources":["../../src/policies/rateLimit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAmB9F,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,EAAE,gBAAgB,GAAG,aAAa,CAAC,CAAC,CAAC,CA6C3E"}
@@ -0,0 +1,42 @@
1
+ import { REQUIRES_SYNC_STORE } from '../core/executor.js';
2
+ import { RateLimitError } from '../errors.js';
3
+ const NS = 'rl:';
4
+ function getState(store, key) {
5
+ return store.get(NS + key) ?? { timestamps: [] };
6
+ }
7
+ function setState(store, key, state, ttlMs) {
8
+ store.set(NS + key, state, ttlMs);
9
+ }
10
+ export function rateLimitPolicy(opts) {
11
+ const maxCalls = Math.max(1, Math.floor(opts.maxCalls));
12
+ const windowMs = opts.windowMs;
13
+ const applier = (fn, ctx) => {
14
+ const syncCtx = ctx;
15
+ return async (signal) => {
16
+ if (signal.aborted)
17
+ return Promise.reject(signal.reason);
18
+ const key = syncCtx.key;
19
+ const now = Date.now();
20
+ const state = getState(syncCtx.store, key);
21
+ const cutoff = now - windowMs;
22
+ const ts = state.timestamps;
23
+ if (ts.length > 0 && ts[0] <= cutoff) {
24
+ let i = 0;
25
+ while (i < ts.length && ts[i] <= cutoff)
26
+ i++;
27
+ if (i > 0) {
28
+ state.timestamps = i === ts.length ? [] : ts.slice(i);
29
+ }
30
+ }
31
+ if (state.timestamps.length >= maxCalls) {
32
+ setState(syncCtx.store, key, state, windowMs);
33
+ throw new RateLimitError(key, maxCalls, windowMs);
34
+ }
35
+ state.timestamps.push(now);
36
+ setState(syncCtx.store, key, state, windowMs);
37
+ return fn(signal);
38
+ };
39
+ };
40
+ applier[REQUIRES_SYNC_STORE] = true;
41
+ return applier;
42
+ }