@rotorsoft/act 1.13.0 → 1.14.1

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 (38) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +37 -0
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/internal/autoclose-cycle.d.ts +9 -0
  5. package/dist/@types/internal/autoclose-cycle.d.ts.map +1 -1
  6. package/dist/@types/internal/circuit-breaker.d.ts +90 -0
  7. package/dist/@types/internal/circuit-breaker.d.ts.map +1 -0
  8. package/dist/@types/internal/drain-cycle.d.ts +9 -0
  9. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  10. package/dist/@types/internal/index.d.ts +1 -0
  11. package/dist/@types/internal/index.d.ts.map +1 -1
  12. package/dist/@types/internal/settle.d.ts +10 -2
  13. package/dist/@types/internal/settle.d.ts.map +1 -1
  14. package/dist/@types/types/errors.d.ts +31 -0
  15. package/dist/@types/types/errors.d.ts.map +1 -1
  16. package/dist/{chunk-NGVWJXEF.js → chunk-4TJN4G7L.js} +13 -2
  17. package/dist/chunk-4TJN4G7L.js.map +1 -0
  18. package/dist/{chunk-QCSXT5MB.js → chunk-QKVY5QAQ.js} +2 -2
  19. package/dist/{chunk-5WRI5ZAA.js → chunk-YLJ4XMA6.js} +6 -2
  20. package/dist/{dist-INXH4GUE.js → dist-EOG547W7.js} +2 -2
  21. package/dist/index.cjs +169 -13
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.js +159 -13
  24. package/dist/index.js.map +1 -1
  25. package/dist/{magic-string.es-ZGO77SPS.js → magic-string.es-5F2IVYTG.js} +2 -2
  26. package/dist/test/index.cjs +9 -4
  27. package/dist/test/index.cjs.map +1 -1
  28. package/dist/test/index.js +5 -5
  29. package/dist/test/index.js.map +1 -1
  30. package/dist/types/index.cjs +13 -1
  31. package/dist/types/index.cjs.map +1 -1
  32. package/dist/types/index.js +4 -2
  33. package/package.json +2 -2
  34. package/dist/chunk-NGVWJXEF.js.map +0 -1
  35. /package/dist/{chunk-QCSXT5MB.js.map → chunk-QKVY5QAQ.js.map} +0 -0
  36. /package/dist/{chunk-5WRI5ZAA.js.map → chunk-YLJ4XMA6.js.map} +0 -0
  37. /package/dist/{dist-INXH4GUE.js.map → dist-EOG547W7.js.map} +0 -0
  38. /package/dist/{magic-string.es-ZGO77SPS.js.map → magic-string.es-5F2IVYTG.js.map} +0 -0
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  sleep,
20
20
  store,
21
21
  validate
22
- } from "./chunk-QCSXT5MB.js";
22
+ } from "./chunk-QKVY5QAQ.js";
23
23
  import {
24
24
  ActorSchema,
25
25
  CausationEventSchema,
@@ -34,6 +34,7 @@ import {
34
34
  QuerySchema,
35
35
  REDACTED,
36
36
  SHREDDED,
37
+ StoreError,
37
38
  StreamClosedError,
38
39
  TargetSchema,
39
40
  ValidationError,
@@ -43,8 +44,8 @@ import {
43
44
  pii_split,
44
45
  pii_strip,
45
46
  sensitive
46
- } from "./chunk-NGVWJXEF.js";
47
- import "./chunk-5WRI5ZAA.js";
47
+ } from "./chunk-4TJN4G7L.js";
48
+ import "./chunk-YLJ4XMA6.js";
48
49
 
49
50
  // src/signals.ts
50
51
  process.once("SIGINT", async (arg) => {
@@ -561,8 +562,17 @@ async function scan_stream_heads(streams) {
561
562
  async function partition_by_safety(stream_info, reactive_events_size, skipped) {
562
563
  if (reactive_events_size === 0) return [...stream_info.keys()];
563
564
  const pending_set = /* @__PURE__ */ new Set();
565
+ const source_regex = /* @__PURE__ */ new Map();
566
+ const get_regex = (source) => {
567
+ let re = source_regex.get(source);
568
+ if (!re) {
569
+ re = new RegExp(source);
570
+ source_regex.set(source, re);
571
+ }
572
+ return re;
573
+ };
564
574
  await store().query_streams((position) => {
565
- const source_re = position.source ? RegExp(position.source) : void 0;
575
+ const source_re = position.source ? get_regex(position.source) : void 0;
566
576
  for (const [stream, info] of stream_info) {
567
577
  if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
568
578
  pending_set.add(stream);
@@ -764,10 +774,7 @@ var AutocloseController = class {
764
774
  start() {
765
775
  if (this._timer) return false;
766
776
  this._timer = setInterval(() => {
767
- this.run_once().catch((err) => {
768
- this.deps.logger.error(
769
- `Autoclose cycle errored: ${err instanceof Error ? err.message : String(err)}`
770
- );
777
+ this.run_once().catch(() => {
771
778
  });
772
779
  }, this.deps.config.autocloseCycleMs);
773
780
  this._timer.unref();
@@ -800,6 +807,7 @@ var AutocloseController = class {
800
807
  */
801
808
  async run_once() {
802
809
  if (this._running) return null;
810
+ if (this.deps.breaker.state(Date.now()) === "open") return null;
803
811
  this._running = true;
804
812
  try {
805
813
  const correlation = close_correlation(
@@ -817,10 +825,14 @@ var AutocloseController = class {
817
825
  config: this.deps.config,
818
826
  correlation
819
827
  });
828
+ this.deps.breaker.passed();
820
829
  if (result.close_result.truncated.size > 0) {
821
830
  this.deps.on_closed(result.close_result);
822
831
  }
823
832
  return result;
833
+ } catch (error) {
834
+ this.deps.breaker.failed(Date.now(), error);
835
+ throw error;
824
836
  } finally {
825
837
  this._running = false;
826
838
  }
@@ -950,6 +962,93 @@ function classify_registry(registry, states) {
950
962
  };
951
963
  }
952
964
 
965
+ // src/internal/circuit-breaker.ts
966
+ import { z as z3 } from "zod";
967
+ var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
968
+ var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
969
+ var CircuitBreakerOptionsSchema = z3.object({
970
+ failureThreshold: z3.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
971
+ cooldownMs: z3.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
972
+ });
973
+ var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
974
+ var CircuitBreaker = class {
975
+ _failures = 0;
976
+ _opened_at;
977
+ _wake;
978
+ _threshold;
979
+ _cooldown_ms;
980
+ _hooks;
981
+ constructor(config2, hooks = {}) {
982
+ this._threshold = config2.failureThreshold;
983
+ this._cooldown_ms = config2.cooldownMs;
984
+ this._hooks = hooks;
985
+ }
986
+ /** Current state given the wall-clock `now`. */
987
+ state(now) {
988
+ if (this._opened_at === void 0) return "closed";
989
+ return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
990
+ }
991
+ /** A store op passed — reset to closed and cancel any pending retry. */
992
+ passed() {
993
+ this._failures = 0;
994
+ this._opened_at = void 0;
995
+ this._clear_wake();
996
+ }
997
+ /**
998
+ * A store op failed. Opens the breaker when the consecutive-failure
999
+ * threshold is reached, or immediately re-opens (restarting the cooldown)
1000
+ * if a half-open trial just failed. Returns the resulting state.
1001
+ *
1002
+ * Callers gate on the state directly — `state(now) === "open"` means skip.
1003
+ * On opening, schedules the `on_retry` wake so recovery is automatic;
1004
+ * always surfaces the failure via `on_error`.
1005
+ */
1006
+ failed(now, error) {
1007
+ const circuit = this._advance(now);
1008
+ if (circuit === "open") this._schedule_wake();
1009
+ this._hooks.on_error?.(error, circuit);
1010
+ return circuit;
1011
+ }
1012
+ /** Cancel the pending retry timer. Idempotent; call on shutdown. */
1013
+ stop() {
1014
+ this._clear_wake();
1015
+ }
1016
+ /**
1017
+ * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
1018
+ * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
1019
+ * tests of the pure state machine). The timer is `unref`'d so it never
1020
+ * keeps the process alive.
1021
+ */
1022
+ _schedule_wake() {
1023
+ if (!this._hooks.on_retry) return;
1024
+ this._clear_wake();
1025
+ this._wake = setTimeout(() => {
1026
+ this._wake = void 0;
1027
+ this._hooks.on_retry?.();
1028
+ }, this._cooldown_ms);
1029
+ this._wake.unref();
1030
+ }
1031
+ _clear_wake() {
1032
+ if (this._wake) {
1033
+ clearTimeout(this._wake);
1034
+ this._wake = void 0;
1035
+ }
1036
+ }
1037
+ /** Pure state transition for a failure — no side effects. */
1038
+ _advance(now) {
1039
+ if (this._opened_at !== void 0) {
1040
+ this._opened_at = now;
1041
+ return "open";
1042
+ }
1043
+ this._failures += 1;
1044
+ if (this._failures >= this._threshold) {
1045
+ this._opened_at = now;
1046
+ return "open";
1047
+ }
1048
+ return "closed";
1049
+ }
1050
+ };
1051
+
953
1052
  // src/internal/correlate-cycle.ts
954
1053
  var CorrelateCycle = class {
955
1054
  _checkpoint = -1;
@@ -1802,6 +1901,8 @@ var DrainController = class {
1802
1901
  async drain(options = {}) {
1803
1902
  if (!this._armed) return EMPTY_DRAIN;
1804
1903
  if (this._locked) return EMPTY_DRAIN;
1904
+ if (this._deps.breaker.state(Date.now()) === "open")
1905
+ return EMPTY_DRAIN;
1805
1906
  const d = this._deps.defaults ?? {};
1806
1907
  const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
1807
1908
  const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
@@ -1823,6 +1924,7 @@ var DrainController = class {
1823
1924
  this._backoff.size > 0 ? this.is_deferred : void 0,
1824
1925
  this._deps.lane
1825
1926
  );
1927
+ this._deps.breaker.passed();
1826
1928
  if (!cycle) {
1827
1929
  this._armed = false;
1828
1930
  return EMPTY_DRAIN;
@@ -1844,7 +1946,7 @@ var DrainController = class {
1844
1946
  if (!acked.length && !blocked.length && !has_errors) this._armed = false;
1845
1947
  return { fetched, leased, acked, blocked };
1846
1948
  } catch (error) {
1847
- this._deps.logger.error(error);
1949
+ this._deps.breaker.failed(Date.now(), error);
1848
1950
  return EMPTY_DRAIN;
1849
1951
  } finally {
1850
1952
  this._locked = false;
@@ -2132,12 +2234,15 @@ var SettleLoop = class {
2132
2234
  ...correlate_query,
2133
2235
  after: this._deps.checkpoint()
2134
2236
  });
2237
+ this._deps.breaker.passed();
2135
2238
  last_drain = await this._deps.drain(drain_options);
2136
2239
  const made_progress = subscribed > 0 || last_drain.acked.length > 0 || last_drain.blocked.length > 0;
2137
2240
  if (!made_progress) break;
2138
2241
  }
2139
2242
  if (last_drain) this._deps.on_settled(last_drain);
2140
- })().catch((err) => this._deps.logger.error(err)).finally(() => {
2243
+ })().catch((err) => {
2244
+ this._deps.breaker.failed(Date.now(), err);
2245
+ }).finally(() => {
2141
2246
  this._running = false;
2142
2247
  });
2143
2248
  }, debounceMs);
@@ -2156,6 +2261,8 @@ var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2156
2261
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
2157
2262
  var Act = class {
2158
2263
  _emitter = new EventEmitter();
2264
+ /** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
2265
+ _breaker;
2159
2266
  /** #803: gate the `Store.notify` subscription side. */
2160
2267
  _listen;
2161
2268
  /** #803: gate the local reaction pipeline (drain controllers, settle, correlate). */
@@ -2209,6 +2316,22 @@ var Act = class {
2209
2316
  emit(event, args) {
2210
2317
  return this._emitter.emit(event, args);
2211
2318
  }
2319
+ /**
2320
+ * The single store-failure handler: log it, then emit the `error`
2321
+ * lifecycle event. Wired into the circuit breaker's `on_error`, so it
2322
+ * runs on every `failed()` — drain / settle / autoclose just call
2323
+ * `breaker.failed(now, error)` and never log or emit themselves.
2324
+ *
2325
+ * The emit is guarded against Node's `EventEmitter` contract that an
2326
+ * unhandled `"error"` emission is rethrown (which would crash the process
2327
+ * from inside the drain catch); logging is unconditional so failures are
2328
+ * never silent.
2329
+ */
2330
+ _emit_error(error, circuit) {
2331
+ this._logger.error(error);
2332
+ if (this._emitter.listenerCount("error") > 0)
2333
+ this.emit("error", { error, circuit });
2334
+ }
2212
2335
  /**
2213
2336
  * Register a listener for a lifecycle event. The listener receives the
2214
2337
  * event-specific payload.
@@ -2337,6 +2460,25 @@ var Act = class {
2337
2460
  this._listen = options.listen !== false;
2338
2461
  this._drain = options.drain !== false;
2339
2462
  this._autoclose_config = resolveAutocloseConfig(options);
2463
+ this._breaker = new CircuitBreaker(
2464
+ resolveCircuitBreakerConfig(options.circuitBreaker),
2465
+ {
2466
+ on_error: (error, circuit) => this._emit_error(error, circuit),
2467
+ // Re-probe the store when the cooldown elapses, so recovery is
2468
+ // automatic even on the default lane (which has no periodic poller).
2469
+ // The breaker is shared by every store-polling loop (drain, the
2470
+ // settle correlate, autoclose), so any of them can trip it — but a
2471
+ // bare `drain()` only touches the store when the controller is armed,
2472
+ // so a trip from a correlate failure could go unprobed. `settle()`
2473
+ // always runs a correlate (a store query) before draining, so it
2474
+ // re-probes regardless of which loop opened the breaker: one success
2475
+ // closes it and every loop resumes on its own cadence. A failed probe
2476
+ // re-opens the breaker and reschedules the wake.
2477
+ on_retry: () => {
2478
+ this.settle({ debounceMs: 0 });
2479
+ }
2480
+ }
2481
+ );
2340
2482
  this._event_to_state = event_to_state;
2341
2483
  this._event_to_lanes = event_to_lanes;
2342
2484
  const all_lanes = ["default", ...lanes.map((l) => l.name)];
@@ -2355,6 +2497,7 @@ var Act = class {
2355
2497
  handle_batch: this._handle_batch,
2356
2498
  on_acked: (acked) => this.emit("acked", acked),
2357
2499
  on_blocked: (blocked) => this.emit("blocked", blocked),
2500
+ breaker: this._breaker,
2358
2501
  // Pass lane only when a true per-lane controller is active.
2359
2502
  // The all-lanes (single default) case keeps lane=undefined so
2360
2503
  // adapter SQL collapses to the pre-1103 shape.
@@ -2391,12 +2534,12 @@ var Act = class {
2391
2534
  );
2392
2535
  this._settle = new SettleLoop(
2393
2536
  {
2394
- logger: this._logger,
2395
2537
  init: () => this._correlate.init(),
2396
2538
  checkpoint: () => this._correlate.checkpoint,
2397
2539
  correlate: (q) => this.correlate(q),
2398
2540
  drain: (o) => this.drain(o),
2399
- on_settled: (drain) => this.emit("settled", drain)
2541
+ on_settled: (drain) => this.emit("settled", drain),
2542
+ breaker: this._breaker
2400
2543
  },
2401
2544
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
2402
2545
  );
@@ -2418,7 +2561,8 @@ var Act = class {
2418
2561
  config: this._autoclose_config,
2419
2562
  correlator: this._correlator,
2420
2563
  close_actor: { id: "$autoclose", name: "autoclose" },
2421
- on_closed: (result) => this.emit("closed", result)
2564
+ on_closed: (result) => this.emit("closed", result),
2565
+ breaker: this._breaker
2422
2566
  }) : void 0;
2423
2567
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store());
2424
2568
  dispose(() => this.shutdown());
@@ -2441,6 +2585,7 @@ var Act = class {
2441
2585
  this._emitter.removeAllListeners();
2442
2586
  this.stop_correlations();
2443
2587
  this.stop_settling();
2588
+ this._breaker.stop();
2444
2589
  for (const c of this._drain_controllers.values()) c.stop();
2445
2590
  const disposer = await this._notify_disposer;
2446
2591
  if (disposer) await disposer();
@@ -3957,6 +4102,7 @@ export {
3957
4102
  REDACTED,
3958
4103
  SHREDDED,
3959
4104
  SNAP_EVENT,
4105
+ StoreError,
3960
4106
  StreamClosedError,
3961
4107
  TOMBSTONE_EVENT,
3962
4108
  TargetSchema,