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
@@ -3,31 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.REQUIRES_SYNC_STORE = void 0;
4
4
  exports.execute = execute;
5
5
  const base_js_1 = require("../stores/base.js");
6
- /**
7
- * Symbol stamped onto `PolicyApplier` functions by `dedupePolicy`.
8
- *
9
- * Lets `execute()` detect a dedupe policy without importing the policy
10
- * module (which would create a circular dep) or doing fragile name-sniffing.
11
- */
12
6
  exports.REQUIRES_SYNC_STORE = Symbol('actly.requiresSyncStore');
13
- /**
14
- * Pure execution engine.
15
- *
16
- * This file imports nothing from `/policies`. It operates on `PolicyApplier<T>`
17
- * — a type alias defined in `/types`. Policy implementations live in
18
- * `/policies` and are wired in `core/act.ts`.
19
- *
20
- * # Public API
21
- *
22
- * Exported so consumers can build custom policy chains with explicit stores
23
- * (e.g. for SSR request isolation or multi-tenant scenarios where the
24
- * module-level default store is wrong).
25
- */
26
7
  async function execute(input) {
27
- // Guard: if any policy in the chain requires a sync store, the provided
28
- // store must be synchronous. An async store + dedupePolicy is a silent
29
- // correctness failure — catch it here rather than letting it produce
30
- // subtly wrong dedupe behaviour at runtime.
31
8
  const needsSync = input.policies.some(p => p[exports.REQUIRES_SYNC_STORE]);
32
9
  if (needsSync && !(0, base_js_1.isSyncStore)(input.store)) {
33
10
  throw new Error('Actly: dedupePolicy requires a SyncStateStore (store._sync === true). ' +
@@ -38,12 +15,8 @@ async function execute(input) {
38
15
  key: input.key,
39
16
  store: input.store,
40
17
  meta: input.meta,
41
- // Thread observability through. Policies read this lazily.
42
18
  observability: input.observability,
43
19
  };
44
- // Build the call chain from inside out.
45
- // reduceRight ensures policies[0] becomes the outermost wrapper (runs first).
46
20
  const wrapped = input.policies.reduceRight((inner, applyPolicy) => applyPolicy(inner, ctx), input.fn);
47
- // Call the outermost wrapper with the root signal.
48
21
  return wrapped(input.signal);
49
22
  }
@@ -1,54 +1,13 @@
1
1
  import type { ActFn, PolicyApplier, AnyStateStore, RunMeta, ObservabilityContext } from '../types/index.js';
2
- /**
3
- * Symbol stamped onto `PolicyApplier` functions by `dedupePolicy`.
4
- *
5
- * Lets `execute()` detect a dedupe policy without importing the policy
6
- * module (which would create a circular dep) or doing fragile name-sniffing.
7
- */
8
2
  export declare const REQUIRES_SYNC_STORE: unique symbol;
9
3
  export interface ExecutorInput<T> {
10
4
  key: string;
11
5
  fn: ActFn<T>;
12
- /**
13
- * Policies ordered outermost -> innermost.
14
- * `policies[0]` intercepts first; `policies[last]` is closest to `fn`.
15
- *
16
- * Canonical order: `[totalTimeout, cache, dedupe, retry, timeout]`
17
- * totalTimeout -> hard wall-clock budget over the entire operation
18
- * cache -> a hit skips everything below it
19
- * dedupe -> collapses concurrent callers before retry fires
20
- * retry -> owns the attempt loop
21
- * timeout -> each individual attempt races against the clock
22
- */
23
6
  policies: ReadonlyArray<PolicyApplier<T>>;
24
7
  store: AnyStateStore;
25
8
  meta: RunMeta;
26
- /**
27
- * Root AbortSignal for the operation. Propagated inward through the
28
- * policy chain: each policy receives it as the `signal` argument to
29
- * its wrapped `ActFn`. The outermost policy may layer its own signal
30
- * (e.g. `totalTimeoutPolicy` arms a timer) and pass the composite
31
- * inward.
32
- */
33
9
  signal: AbortSignal;
34
- /**
35
- * Observability context. When present, policies emit events
36
- * via the hooks. When absent (the common case), zero overhead.
37
- */
38
10
  observability?: ObservabilityContext;
39
11
  }
40
- /**
41
- * Pure execution engine.
42
- *
43
- * This file imports nothing from `/policies`. It operates on `PolicyApplier<T>`
44
- * — a type alias defined in `/types`. Policy implementations live in
45
- * `/policies` and are wired in `core/act.ts`.
46
- *
47
- * # Public API
48
- *
49
- * Exported so consumers can build custom policy chains with explicit stores
50
- * (e.g. for SSR request isolation or multi-tenant scenarios where the
51
- * module-level default store is wrong).
52
- */
53
12
  export declare function execute<T>(input: ExecutorInput<T>): Promise<T>;
54
13
  //# sourceMappingURL=executor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/core/executor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,KAAK,EACL,aAAa,EAEb,aAAa,EACb,OAAO,EACP,oBAAoB,EACrB,MAAM,mBAAmB,CAAA;AAG1B;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,eAAoC,CAAA;AAEpE,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B,GAAG,EAAO,MAAM,CAAA;IAChB,EAAE,EAAQ,KAAK,CAAC,CAAC,CAAC,CAAA;IAClB;;;;;;;;;;OAUG;IACH,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAA;IACzC,KAAK,EAAK,aAAa,CAAA;IACvB,IAAI,EAAM,OAAO,CAAA;IACjB;;;;;;OAMG;IACH,MAAM,EAAI,WAAW,CAAA;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,oBAAoB,CAAA;CACrC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,OAAO,CAAC,CAAC,EAAE,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAiCpE"}
1
+ {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/core/executor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,KAAK,EACL,aAAa,EAEb,aAAa,EACb,OAAO,EACP,oBAAoB,EACrB,MAAM,mBAAmB,CAAA;AAQ1B,eAAO,MAAM,mBAAmB,eAAoC,CAAA;AAEpE,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B,GAAG,EAAO,MAAM,CAAA;IAChB,EAAE,EAAQ,KAAK,CAAC,CAAC,CAAC,CAAA;IAYlB,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAA;IACzC,KAAK,EAAK,aAAa,CAAA;IACvB,IAAI,EAAM,OAAO,CAAA;IAOjB,MAAM,EAAI,WAAW,CAAA;IAKrB,aAAa,CAAC,EAAE,oBAAoB,CAAA;CACrC;AAWD,wBAAsB,OAAO,CAAC,CAAC,EAAE,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CA+BpE"}
@@ -1,29 +1,6 @@
1
1
  import { isSyncStore } from '../stores/base.js';
2
- /**
3
- * Symbol stamped onto `PolicyApplier` functions by `dedupePolicy`.
4
- *
5
- * Lets `execute()` detect a dedupe policy without importing the policy
6
- * module (which would create a circular dep) or doing fragile name-sniffing.
7
- */
8
2
  export const REQUIRES_SYNC_STORE = Symbol('actly.requiresSyncStore');
9
- /**
10
- * Pure execution engine.
11
- *
12
- * This file imports nothing from `/policies`. It operates on `PolicyApplier<T>`
13
- * — a type alias defined in `/types`. Policy implementations live in
14
- * `/policies` and are wired in `core/act.ts`.
15
- *
16
- * # Public API
17
- *
18
- * Exported so consumers can build custom policy chains with explicit stores
19
- * (e.g. for SSR request isolation or multi-tenant scenarios where the
20
- * module-level default store is wrong).
21
- */
22
3
  export async function execute(input) {
23
- // Guard: if any policy in the chain requires a sync store, the provided
24
- // store must be synchronous. An async store + dedupePolicy is a silent
25
- // correctness failure — catch it here rather than letting it produce
26
- // subtly wrong dedupe behaviour at runtime.
27
4
  const needsSync = input.policies.some(p => p[REQUIRES_SYNC_STORE]);
28
5
  if (needsSync && !isSyncStore(input.store)) {
29
6
  throw new Error('Actly: dedupePolicy requires a SyncStateStore (store._sync === true). ' +
@@ -34,13 +11,8 @@ export async function execute(input) {
34
11
  key: input.key,
35
12
  store: input.store,
36
13
  meta: input.meta,
37
- // Thread observability through. Policies read this lazily.
38
14
  observability: input.observability,
39
15
  };
40
- // Build the call chain from inside out.
41
- // reduceRight ensures policies[0] becomes the outermost wrapper (runs first).
42
16
  const wrapped = input.policies.reduceRight((inner, applyPolicy) => applyPolicy(inner, ctx), input.fn);
43
- // Call the outermost wrapper with the root signal.
44
17
  return wrapped(input.signal);
45
18
  }
46
- //# sourceMappingURL=executor.js.map
@@ -1,34 +1,163 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerStoreScope = registerStoreScope;
4
+ exports.resolveStoreScope = resolveStoreScope;
3
5
  exports.registerInflight = registerInflight;
4
6
  exports.unregisterInflight = unregisterInflight;
7
+ exports.enableWatchdog = enableWatchdog;
8
+ exports.registerWatchdogHooks = registerWatchdogHooks;
9
+ exports.unregisterWatchdogHooks = unregisterWatchdogHooks;
10
+ exports.disableWatchdog = disableWatchdog;
5
11
  exports.recordError = recordError;
6
12
  exports.recordSuccess = recordSuccess;
7
13
  exports.createHealthCheck = createHealthCheck;
8
- let globalInflight = 0;
9
- let startTime = Date.now();
10
- let globalLastError;
11
- let globalLastSuccessAt;
12
- function registerInflight(_scope) {
13
- globalInflight++;
14
+ const limits_js_1 = require("../utils/limits.js");
15
+ const errors_js_1 = require("../errors.js");
16
+ const safeCall_js_1 = require("../utils/safeCall.js");
17
+ const storeScopes = new WeakMap();
18
+ function registerStoreScope(store, scope) {
19
+ storeScopes.set(store, scope);
14
20
  }
15
- function unregisterInflight(_scope) {
16
- globalInflight = Math.max(0, globalInflight - 1);
21
+ function resolveStoreScope(store) {
22
+ return storeScopes.get(store);
17
23
  }
18
- function recordError(_scope, code, message) {
19
- globalLastError = { code, message, timestamp: Date.now() };
24
+ const healthStates = new Map();
25
+ const startTime = Date.now();
26
+ const INFLIGHT_LIMIT_DISABLED = process.env.ACTLY_NO_INFLIGHT_LIMIT === '1' ||
27
+ process.env.ACTLY_NO_INFLIGHT_LIMIT === 'true';
28
+ const INFLIGHT_LIMIT = limits_js_1.LIMITS.MAX_GLOBAL_INFLIGHT;
29
+ let globalInflightCount = 0;
30
+ let inflightBusySince;
31
+ let watchdogTimer;
32
+ let watchdogThresholdMs = 60_000;
33
+ const watchdogHooks = new Set();
34
+ let watchdogFiredForBusySince;
35
+ function noteInflightUp() {
36
+ if (inflightBusySince === undefined) {
37
+ inflightBusySince = Date.now();
38
+ }
20
39
  }
21
- function recordSuccess(_scope) {
22
- globalLastSuccessAt = Date.now();
40
+ function noteInflightDown() {
41
+ if (globalInflightCount === 0) {
42
+ inflightBusySince = undefined;
43
+ watchdogFiredForBusySince = undefined;
44
+ }
23
45
  }
24
- function createHealthCheck(store) {
25
- return () => {
46
+ function getState(scope) {
47
+ let s = healthStates.get(scope);
48
+ if (!s) {
49
+ s = { inflight: 0 };
50
+ healthStates.set(scope, s);
51
+ }
52
+ return s;
53
+ }
54
+ function registerInflight(scope) {
55
+ if (!INFLIGHT_LIMIT_DISABLED && globalInflightCount >= INFLIGHT_LIMIT) {
56
+ throw new errors_js_1.ResourceExhaustedError(globalInflightCount, INFLIGHT_LIMIT);
57
+ }
58
+ globalInflightCount++;
59
+ noteInflightUp();
60
+ getState(scope).inflight++;
61
+ }
62
+ function unregisterInflight(scope) {
63
+ globalInflightCount = Math.max(0, globalInflightCount - 1);
64
+ noteInflightDown();
65
+ const s = getState(scope);
66
+ s.inflight = Math.max(0, s.inflight - 1);
67
+ if (s.inflight === 0 && s.lastError === undefined && scope !== 'default') {
68
+ healthStates.delete(scope);
69
+ }
70
+ }
71
+ function enableWatchdog(thresholdMs = 60_000, hooks) {
72
+ const prevThreshold = watchdogThresholdMs;
73
+ watchdogThresholdMs = thresholdMs;
74
+ if (hooks)
75
+ watchdogHooks.add(hooks);
76
+ if (watchdogTimer && thresholdMs === prevThreshold)
77
+ return;
78
+ if (watchdogTimer) {
79
+ clearInterval(watchdogTimer);
80
+ watchdogTimer = undefined;
81
+ }
82
+ const intervalMs = Math.max(50, Math.floor(thresholdMs / 4));
83
+ watchdogTimer = setInterval(() => {
84
+ if (globalInflightCount === 0)
85
+ return;
86
+ if (inflightBusySince === undefined)
87
+ return;
88
+ const elapsed = Date.now() - inflightBusySince;
89
+ if (elapsed < watchdogThresholdMs)
90
+ return;
91
+ if (watchdogFiredForBusySince === inflightBusySince)
92
+ return;
93
+ watchdogFiredForBusySince = inflightBusySince;
94
+ const event = {
95
+ type: 'watchdog',
96
+ key: '<unknown>',
97
+ traceId: '<watchdog>',
98
+ timestamp: Date.now(),
99
+ elapsedMs: elapsed,
100
+ scope: '<process>',
101
+ };
102
+ for (const h of watchdogHooks) {
103
+ (0, safeCall_js_1.safeCall)(h.onWatchdog, event);
104
+ }
105
+ }, intervalMs);
106
+ const t = watchdogTimer;
107
+ if (typeof t.unref === 'function')
108
+ t.unref();
109
+ }
110
+ function registerWatchdogHooks(hooks) {
111
+ watchdogHooks.add(hooks);
112
+ }
113
+ function unregisterWatchdogHooks(hooks) {
114
+ watchdogHooks.delete(hooks);
115
+ }
116
+ function disableWatchdog() {
117
+ if (watchdogTimer) {
118
+ clearInterval(watchdogTimer);
119
+ watchdogTimer = undefined;
120
+ }
121
+ watchdogHooks.clear();
122
+ }
123
+ function recordError(scope, code, message) {
124
+ const s = getState(scope);
125
+ s.lastError = { code, message, timestamp: Date.now() };
126
+ }
127
+ function recordSuccess(scope) {
128
+ getState(scope).lastSuccessAt = Date.now();
129
+ }
130
+ function createHealthCheck(store, options) {
131
+ const scope = options?.scope ?? resolveStoreScope(store) ?? 'default';
132
+ const probeIntervalMs = options?.probeIntervalMs;
133
+ let probeTimer;
134
+ if (probeIntervalMs && probeIntervalMs > 0) {
135
+ probeTimer = setInterval(() => {
136
+ const s = healthStates.get(scope);
137
+ if (s && s.inflight > 0) {
138
+ console.warn(`Actly: health probe detected ${s.inflight} in-flight calls in scope "${scope}" ` +
139
+ `at ${new Date().toISOString()}. If this persists, a fn may be hung.`);
140
+ }
141
+ }, probeIntervalMs);
142
+ const t = probeTimer;
143
+ if (typeof t.unref === 'function')
144
+ t.unref();
145
+ }
146
+ const checkFn = () => {
147
+ const s = healthStates.get(scope);
26
148
  return {
27
149
  storeSize: store.size(),
28
- pendingInflight: globalInflight,
150
+ pendingInflight: s?.inflight ?? 0,
29
151
  uptimeMs: Date.now() - startTime,
30
- lastError: globalLastError,
31
- lastSuccessAt: globalLastSuccessAt,
152
+ lastError: s?.lastError,
153
+ lastSuccessAt: s?.lastSuccessAt,
32
154
  };
33
155
  };
156
+ checkFn.dispose = () => {
157
+ if (probeTimer !== undefined) {
158
+ clearInterval(probeTimer);
159
+ probeTimer = undefined;
160
+ }
161
+ };
162
+ return checkFn;
34
163
  }
@@ -1,4 +1,8 @@
1
1
  import type { InMemoryStore } from '../stores/memory.js';
2
+ import type { AnyStateStore } from '../types/index.js';
3
+ import type { ObservabilityHooks } from '../observability.js';
4
+ export declare function registerStoreScope(store: AnyStateStore, scope: string): void;
5
+ export declare function resolveStoreScope(store: AnyStateStore): string | undefined;
2
6
  export interface HealthStatus {
3
7
  storeSize: number;
4
8
  pendingInflight: number;
@@ -10,9 +14,20 @@ export interface HealthStatus {
10
14
  };
11
15
  lastSuccessAt?: number;
12
16
  }
13
- export declare function registerInflight(_scope: string): void;
14
- export declare function unregisterInflight(_scope: string): void;
15
- export declare function recordError(_scope: string, code: string, message: string): void;
16
- export declare function recordSuccess(_scope: string): void;
17
- export declare function createHealthCheck(store: InMemoryStore): () => HealthStatus;
17
+ export declare function registerInflight(scope: string): void;
18
+ export declare function unregisterInflight(scope: string): void;
19
+ export declare function enableWatchdog(thresholdMs?: number, hooks?: ObservabilityHooks): void;
20
+ export declare function registerWatchdogHooks(hooks: ObservabilityHooks): void;
21
+ export declare function unregisterWatchdogHooks(hooks: ObservabilityHooks): void;
22
+ export declare function disableWatchdog(): void;
23
+ export declare function recordError(scope: string, code: string, message: string): void;
24
+ export declare function recordSuccess(scope: string): void;
25
+ export interface HealthCheckFn {
26
+ (): HealthStatus;
27
+ dispose(): void;
28
+ }
29
+ export declare function createHealthCheck(store: InMemoryStore, options?: {
30
+ scope?: string;
31
+ probeIntervalMs?: number;
32
+ }): HealthCheckFn;
18
33
  //# sourceMappingURL=health.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../../src/core/health.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAExD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,CAAA;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;IAChE,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAOD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAErD;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAEvD;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAE/E;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAElD;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,YAAY,CAU1E"}
1
+ {"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../../src/core/health.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACxD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAGtD,OAAO,KAAK,EAAE,kBAAkB,EAAiB,MAAM,qBAAqB,CAAA;AAkB5E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAE5E;AAMD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,CAE1E;AAOD,MAAM,WAAW,YAAY;IAE3B,SAAS,EAAE,MAAM,CAAA;IAEjB,eAAe,EAAE,MAAM,CAAA;IAEvB,QAAQ,EAAE,MAAM,CAAA;IAEhB,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;IAEhE,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAwED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAUpD;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAYtD;AAgBD,wBAAgB,cAAc,CAC5B,WAAW,SAAS,EACpB,KAAK,CAAC,EAAE,kBAAkB,GACzB,IAAI,CAgDN;AAOD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI,CAErE;AAUD,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI,CAEvE;AAMD,wBAAgB,eAAe,IAAI,IAAI,CAMtC;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAG9E;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEjD;AAMD,MAAM,WAAW,aAAa;IAC5B,IAAI,YAAY,CAAA;IAEhB,OAAO,IAAI,IAAI,CAAA;CAChB;AAmBD,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,aAAa,EACpB,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,GACrD,aAAa,CA2Cf"}
@@ -1,28 +1,150 @@
1
- let globalInflight = 0;
2
- let startTime = Date.now();
3
- let globalLastError;
4
- let globalLastSuccessAt;
5
- export function registerInflight(_scope) {
6
- globalInflight++;
1
+ import { LIMITS } from '../utils/limits.js';
2
+ import { ResourceExhaustedError } from '../errors.js';
3
+ import { safeCall } from '../utils/safeCall.js';
4
+ const storeScopes = new WeakMap();
5
+ export function registerStoreScope(store, scope) {
6
+ storeScopes.set(store, scope);
7
7
  }
8
- export function unregisterInflight(_scope) {
9
- globalInflight = Math.max(0, globalInflight - 1);
8
+ export function resolveStoreScope(store) {
9
+ return storeScopes.get(store);
10
10
  }
11
- export function recordError(_scope, code, message) {
12
- globalLastError = { code, message, timestamp: Date.now() };
11
+ const healthStates = new Map();
12
+ const startTime = Date.now();
13
+ const INFLIGHT_LIMIT_DISABLED = process.env.ACTLY_NO_INFLIGHT_LIMIT === '1' ||
14
+ process.env.ACTLY_NO_INFLIGHT_LIMIT === 'true';
15
+ const INFLIGHT_LIMIT = LIMITS.MAX_GLOBAL_INFLIGHT;
16
+ let globalInflightCount = 0;
17
+ let inflightBusySince;
18
+ let watchdogTimer;
19
+ let watchdogThresholdMs = 60_000;
20
+ const watchdogHooks = new Set();
21
+ let watchdogFiredForBusySince;
22
+ function noteInflightUp() {
23
+ if (inflightBusySince === undefined) {
24
+ inflightBusySince = Date.now();
25
+ }
13
26
  }
14
- export function recordSuccess(_scope) {
15
- globalLastSuccessAt = Date.now();
27
+ function noteInflightDown() {
28
+ if (globalInflightCount === 0) {
29
+ inflightBusySince = undefined;
30
+ watchdogFiredForBusySince = undefined;
31
+ }
16
32
  }
17
- export function createHealthCheck(store) {
18
- return () => {
33
+ function getState(scope) {
34
+ let s = healthStates.get(scope);
35
+ if (!s) {
36
+ s = { inflight: 0 };
37
+ healthStates.set(scope, s);
38
+ }
39
+ return s;
40
+ }
41
+ export function registerInflight(scope) {
42
+ if (!INFLIGHT_LIMIT_DISABLED && globalInflightCount >= INFLIGHT_LIMIT) {
43
+ throw new ResourceExhaustedError(globalInflightCount, INFLIGHT_LIMIT);
44
+ }
45
+ globalInflightCount++;
46
+ noteInflightUp();
47
+ getState(scope).inflight++;
48
+ }
49
+ export function unregisterInflight(scope) {
50
+ globalInflightCount = Math.max(0, globalInflightCount - 1);
51
+ noteInflightDown();
52
+ const s = getState(scope);
53
+ s.inflight = Math.max(0, s.inflight - 1);
54
+ if (s.inflight === 0 && s.lastError === undefined && scope !== 'default') {
55
+ healthStates.delete(scope);
56
+ }
57
+ }
58
+ export function enableWatchdog(thresholdMs = 60_000, hooks) {
59
+ const prevThreshold = watchdogThresholdMs;
60
+ watchdogThresholdMs = thresholdMs;
61
+ if (hooks)
62
+ watchdogHooks.add(hooks);
63
+ if (watchdogTimer && thresholdMs === prevThreshold)
64
+ return;
65
+ if (watchdogTimer) {
66
+ clearInterval(watchdogTimer);
67
+ watchdogTimer = undefined;
68
+ }
69
+ const intervalMs = Math.max(50, Math.floor(thresholdMs / 4));
70
+ watchdogTimer = setInterval(() => {
71
+ if (globalInflightCount === 0)
72
+ return;
73
+ if (inflightBusySince === undefined)
74
+ return;
75
+ const elapsed = Date.now() - inflightBusySince;
76
+ if (elapsed < watchdogThresholdMs)
77
+ return;
78
+ if (watchdogFiredForBusySince === inflightBusySince)
79
+ return;
80
+ watchdogFiredForBusySince = inflightBusySince;
81
+ const event = {
82
+ type: 'watchdog',
83
+ key: '<unknown>',
84
+ traceId: '<watchdog>',
85
+ timestamp: Date.now(),
86
+ elapsedMs: elapsed,
87
+ scope: '<process>',
88
+ };
89
+ for (const h of watchdogHooks) {
90
+ safeCall(h.onWatchdog, event);
91
+ }
92
+ }, intervalMs);
93
+ const t = watchdogTimer;
94
+ if (typeof t.unref === 'function')
95
+ t.unref();
96
+ }
97
+ export function registerWatchdogHooks(hooks) {
98
+ watchdogHooks.add(hooks);
99
+ }
100
+ export function unregisterWatchdogHooks(hooks) {
101
+ watchdogHooks.delete(hooks);
102
+ }
103
+ export function disableWatchdog() {
104
+ if (watchdogTimer) {
105
+ clearInterval(watchdogTimer);
106
+ watchdogTimer = undefined;
107
+ }
108
+ watchdogHooks.clear();
109
+ }
110
+ export function recordError(scope, code, message) {
111
+ const s = getState(scope);
112
+ s.lastError = { code, message, timestamp: Date.now() };
113
+ }
114
+ export function recordSuccess(scope) {
115
+ getState(scope).lastSuccessAt = Date.now();
116
+ }
117
+ export function createHealthCheck(store, options) {
118
+ const scope = options?.scope ?? resolveStoreScope(store) ?? 'default';
119
+ const probeIntervalMs = options?.probeIntervalMs;
120
+ let probeTimer;
121
+ if (probeIntervalMs && probeIntervalMs > 0) {
122
+ probeTimer = setInterval(() => {
123
+ const s = healthStates.get(scope);
124
+ if (s && s.inflight > 0) {
125
+ console.warn(`Actly: health probe detected ${s.inflight} in-flight calls in scope "${scope}" ` +
126
+ `at ${new Date().toISOString()}. If this persists, a fn may be hung.`);
127
+ }
128
+ }, probeIntervalMs);
129
+ const t = probeTimer;
130
+ if (typeof t.unref === 'function')
131
+ t.unref();
132
+ }
133
+ const checkFn = () => {
134
+ const s = healthStates.get(scope);
19
135
  return {
20
136
  storeSize: store.size(),
21
- pendingInflight: globalInflight,
137
+ pendingInflight: s?.inflight ?? 0,
22
138
  uptimeMs: Date.now() - startTime,
23
- lastError: globalLastError,
24
- lastSuccessAt: globalLastSuccessAt,
139
+ lastError: s?.lastError,
140
+ lastSuccessAt: s?.lastSuccessAt,
25
141
  };
26
142
  };
143
+ checkFn.dispose = () => {
144
+ if (probeTimer !== undefined) {
145
+ clearInterval(probeTimer);
146
+ probeTimer = undefined;
147
+ }
148
+ };
149
+ return checkFn;
27
150
  }
28
- //# sourceMappingURL=health.js.map
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerDrainable = registerDrainable;
4
4
  exports.unregisterDrainable = unregisterDrainable;
5
5
  exports.drain = drain;
6
+ exports.drainAll = drainAll;
6
7
  const drainStates = new Map();
7
8
  function getState(scope) {
8
9
  let s = drainStates.get(scope);
@@ -22,16 +23,21 @@ function unregisterDrainable(scope = 'default') {
22
23
  for (const r of s.resolvers)
23
24
  r();
24
25
  s.resolvers = [];
26
+ if (scope !== 'default') {
27
+ drainStates.delete(scope);
28
+ }
25
29
  }
26
30
  }
27
- /**
28
- * Wait for all in-flight act() calls in this scope to settle.
29
- * Returns true if all settled within timeoutMs, false if timed out.
30
- */
31
31
  async function drain(timeoutMs, scope = 'default') {
32
- const s = getState(scope);
33
- if (s.inflight === 0)
32
+ const s = drainStates.get(scope);
33
+ if (!s)
34
+ return true;
35
+ if (s.inflight === 0) {
36
+ if (scope !== 'default' && s.resolvers.length === 0) {
37
+ drainStates.delete(scope);
38
+ }
34
39
  return true;
40
+ }
35
41
  return new Promise((resolve) => {
36
42
  let resolved = false;
37
43
  const resolver = () => {
@@ -39,7 +45,6 @@ async function drain(timeoutMs, scope = 'default') {
39
45
  return;
40
46
  resolved = true;
41
47
  clearTimeout(timer);
42
- // Remove this resolver from the array
43
48
  const idx = s.resolvers.indexOf(resolver);
44
49
  if (idx >= 0)
45
50
  s.resolvers.splice(idx, 1);
@@ -49,7 +54,6 @@ async function drain(timeoutMs, scope = 'default') {
49
54
  if (resolved)
50
55
  return;
51
56
  resolved = true;
52
- // Remove this resolver from the array on timeout
53
57
  const idx = s.resolvers.indexOf(resolver);
54
58
  if (idx >= 0)
55
59
  s.resolvers.splice(idx, 1);
@@ -58,3 +62,10 @@ async function drain(timeoutMs, scope = 'default') {
58
62
  s.resolvers.push(resolver);
59
63
  });
60
64
  }
65
+ async function drainAll(timeoutMs) {
66
+ const scopes = Array.from(drainStates.keys());
67
+ if (scopes.length === 0)
68
+ return true;
69
+ const results = await Promise.all(scopes.map(scope => drain(timeoutMs, scope)));
70
+ return results.every(r => r === true);
71
+ }
@@ -1,8 +1,5 @@
1
1
  export declare function registerDrainable(scope?: string): void;
2
2
  export declare function unregisterDrainable(scope?: string): void;
3
- /**
4
- * Wait for all in-flight act() calls in this scope to settle.
5
- * Returns true if all settled within timeoutMs, false if timed out.
6
- */
7
3
  export declare function drain(timeoutMs: number, scope?: string): Promise<boolean>;
4
+ export declare function drainAll(timeoutMs: number): Promise<boolean>;
8
5
  //# sourceMappingURL=shutdown.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"shutdown.d.ts","sourceRoot":"","sources":["../../src/core/shutdown.ts"],"names":[],"mappings":"AAaA,wBAAgB,iBAAiB,CAAC,KAAK,SAAY,GAAG,IAAI,CAEzD;AAED,wBAAgB,mBAAmB,CAAC,KAAK,SAAY,GAAG,IAAI,CAO3D;AAED;;;GAGG;AACH,wBAAsB,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,SAAY,GAAG,OAAO,CAAC,OAAO,CAAC,CA0BlF"}
1
+ {"version":3,"file":"shutdown.d.ts","sourceRoot":"","sources":["../../src/core/shutdown.ts"],"names":[],"mappings":"AAWA,wBAAgB,iBAAiB,CAAC,KAAK,SAAY,GAAG,IAAI,CAEzD;AAED,wBAAgB,mBAAmB,CAAC,KAAK,SAAY,GAAG,IAAI,CAgB3D;AAUD,wBAAsB,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,SAAY,GAAG,OAAO,CAAC,OAAO,CAAC,CAiClF;AAsBD,wBAAsB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAWlE"}