@rotorsoft/act 1.13.0 → 1.14.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.
package/dist/index.cjs CHANGED
@@ -57,6 +57,7 @@ __export(index_exports, {
57
57
  REDACTED: () => REDACTED,
58
58
  SHREDDED: () => SHREDDED,
59
59
  SNAP_EVENT: () => SNAP_EVENT,
60
+ StoreError: () => StoreError,
60
61
  StreamClosedError: () => StreamClosedError,
61
62
  TOMBSTONE_EVENT: () => TOMBSTONE_EVENT,
62
63
  TargetSchema: () => TargetSchema,
@@ -307,7 +308,8 @@ var Errors = {
307
308
  InvariantError: "ERR_INVARIANT",
308
309
  ConcurrencyError: "ERR_CONCURRENCY",
309
310
  StreamClosedError: "ERR_STREAM_CLOSED",
310
- NonRetryableError: "ERR_NON_RETRYABLE"
311
+ NonRetryableError: "ERR_NON_RETRYABLE",
312
+ StoreError: "ERR_STORE"
311
313
  };
312
314
  var ValidationError = class extends Error {
313
315
  /** The type of target being validated (e.g., "action", "event") */
@@ -376,6 +378,15 @@ var StreamClosedError = class extends Error {
376
378
  this.stream = stream;
377
379
  }
378
380
  };
381
+ var StoreError = class extends Error {
382
+ /** The store operation that failed (e.g. `"claim"`, `"ack"`, `"commit"`). */
383
+ operation;
384
+ constructor(operation, options) {
385
+ super(`Store operation "${operation}" failed`, options);
386
+ this.name = Errors.StoreError;
387
+ this.operation = operation;
388
+ }
389
+ };
379
390
  var NonRetryableError = class extends Error {
380
391
  /** The original failure, if any. Mirrors the standard `Error.cause` shape. */
381
392
  cause;
@@ -2136,10 +2147,7 @@ var AutocloseController = class {
2136
2147
  start() {
2137
2148
  if (this._timer) return false;
2138
2149
  this._timer = setInterval(() => {
2139
- this.run_once().catch((err) => {
2140
- this.deps.logger.error(
2141
- `Autoclose cycle errored: ${err instanceof Error ? err.message : String(err)}`
2142
- );
2150
+ this.run_once().catch(() => {
2143
2151
  });
2144
2152
  }, this.deps.config.autocloseCycleMs);
2145
2153
  this._timer.unref();
@@ -2172,6 +2180,7 @@ var AutocloseController = class {
2172
2180
  */
2173
2181
  async run_once() {
2174
2182
  if (this._running) return null;
2183
+ if (this.deps.breaker.state(Date.now()) === "open") return null;
2175
2184
  this._running = true;
2176
2185
  try {
2177
2186
  const correlation = close_correlation(
@@ -2189,10 +2198,14 @@ var AutocloseController = class {
2189
2198
  config: this.deps.config,
2190
2199
  correlation
2191
2200
  });
2201
+ this.deps.breaker.passed();
2192
2202
  if (result.close_result.truncated.size > 0) {
2193
2203
  this.deps.on_closed(result.close_result);
2194
2204
  }
2195
2205
  return result;
2206
+ } catch (error) {
2207
+ this.deps.breaker.failed(Date.now(), error);
2208
+ throw error;
2196
2209
  } finally {
2197
2210
  this._running = false;
2198
2211
  }
@@ -2322,6 +2335,93 @@ function classify_registry(registry, states) {
2322
2335
  };
2323
2336
  }
2324
2337
 
2338
+ // src/internal/circuit-breaker.ts
2339
+ var import_zod7 = require("zod");
2340
+ var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
2341
+ var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
2342
+ var CircuitBreakerOptionsSchema = import_zod7.z.object({
2343
+ failureThreshold: import_zod7.z.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
2344
+ cooldownMs: import_zod7.z.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
2345
+ });
2346
+ var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
2347
+ var CircuitBreaker = class {
2348
+ _failures = 0;
2349
+ _opened_at;
2350
+ _wake;
2351
+ _threshold;
2352
+ _cooldown_ms;
2353
+ _hooks;
2354
+ constructor(config2, hooks = {}) {
2355
+ this._threshold = config2.failureThreshold;
2356
+ this._cooldown_ms = config2.cooldownMs;
2357
+ this._hooks = hooks;
2358
+ }
2359
+ /** Current state given the wall-clock `now`. */
2360
+ state(now) {
2361
+ if (this._opened_at === void 0) return "closed";
2362
+ return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
2363
+ }
2364
+ /** A store op passed — reset to closed and cancel any pending retry. */
2365
+ passed() {
2366
+ this._failures = 0;
2367
+ this._opened_at = void 0;
2368
+ this._clear_wake();
2369
+ }
2370
+ /**
2371
+ * A store op failed. Opens the breaker when the consecutive-failure
2372
+ * threshold is reached, or immediately re-opens (restarting the cooldown)
2373
+ * if a half-open trial just failed. Returns the resulting state.
2374
+ *
2375
+ * Callers gate on the state directly — `state(now) === "open"` means skip.
2376
+ * On opening, schedules the `on_retry` wake so recovery is automatic;
2377
+ * always surfaces the failure via `on_error`.
2378
+ */
2379
+ failed(now, error) {
2380
+ const circuit = this._advance(now);
2381
+ if (circuit === "open") this._schedule_wake();
2382
+ this._hooks.on_error?.(error, circuit);
2383
+ return circuit;
2384
+ }
2385
+ /** Cancel the pending retry timer. Idempotent; call on shutdown. */
2386
+ stop() {
2387
+ this._clear_wake();
2388
+ }
2389
+ /**
2390
+ * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
2391
+ * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
2392
+ * tests of the pure state machine). The timer is `unref`'d so it never
2393
+ * keeps the process alive.
2394
+ */
2395
+ _schedule_wake() {
2396
+ if (!this._hooks.on_retry) return;
2397
+ this._clear_wake();
2398
+ this._wake = setTimeout(() => {
2399
+ this._wake = void 0;
2400
+ this._hooks.on_retry?.();
2401
+ }, this._cooldown_ms);
2402
+ this._wake.unref();
2403
+ }
2404
+ _clear_wake() {
2405
+ if (this._wake) {
2406
+ clearTimeout(this._wake);
2407
+ this._wake = void 0;
2408
+ }
2409
+ }
2410
+ /** Pure state transition for a failure — no side effects. */
2411
+ _advance(now) {
2412
+ if (this._opened_at !== void 0) {
2413
+ this._opened_at = now;
2414
+ return "open";
2415
+ }
2416
+ this._failures += 1;
2417
+ if (this._failures >= this._threshold) {
2418
+ this._opened_at = now;
2419
+ return "open";
2420
+ }
2421
+ return "closed";
2422
+ }
2423
+ };
2424
+
2325
2425
  // src/internal/correlate-cycle.ts
2326
2426
  var CorrelateCycle = class {
2327
2427
  _checkpoint = -1;
@@ -3174,6 +3274,8 @@ var DrainController = class {
3174
3274
  async drain(options = {}) {
3175
3275
  if (!this._armed) return EMPTY_DRAIN;
3176
3276
  if (this._locked) return EMPTY_DRAIN;
3277
+ if (this._deps.breaker.state(Date.now()) === "open")
3278
+ return EMPTY_DRAIN;
3177
3279
  const d = this._deps.defaults ?? {};
3178
3280
  const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
3179
3281
  const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
@@ -3195,6 +3297,7 @@ var DrainController = class {
3195
3297
  this._backoff.size > 0 ? this.is_deferred : void 0,
3196
3298
  this._deps.lane
3197
3299
  );
3300
+ this._deps.breaker.passed();
3198
3301
  if (!cycle) {
3199
3302
  this._armed = false;
3200
3303
  return EMPTY_DRAIN;
@@ -3216,7 +3319,7 @@ var DrainController = class {
3216
3319
  if (!acked.length && !blocked.length && !has_errors) this._armed = false;
3217
3320
  return { fetched, leased, acked, blocked };
3218
3321
  } catch (error) {
3219
- this._deps.logger.error(error);
3322
+ this._deps.breaker.failed(Date.now(), error);
3220
3323
  return EMPTY_DRAIN;
3221
3324
  } finally {
3222
3325
  this._locked = false;
@@ -3225,7 +3328,7 @@ var DrainController = class {
3225
3328
  };
3226
3329
 
3227
3330
  // src/internal/merge.ts
3228
- var import_zod7 = require("zod");
3331
+ var import_zod8 = require("zod");
3229
3332
  function base_type_name(zodType) {
3230
3333
  let t = zodType;
3231
3334
  while (typeof t.unwrap === "function") {
@@ -3234,7 +3337,7 @@ function base_type_name(zodType) {
3234
3337
  return t.constructor.name;
3235
3338
  }
3236
3339
  function merge_schemas(existing, incoming, state_name) {
3237
- if (existing instanceof import_zod7.ZodObject && incoming instanceof import_zod7.ZodObject) {
3340
+ if (existing instanceof import_zod8.ZodObject && incoming instanceof import_zod8.ZodObject) {
3238
3341
  const existing_shape = existing.shape;
3239
3342
  const incoming_shape = incoming.shape;
3240
3343
  for (const key of Object.keys(incoming_shape)) {
@@ -3504,12 +3607,15 @@ var SettleLoop = class {
3504
3607
  ...correlate_query,
3505
3608
  after: this._deps.checkpoint()
3506
3609
  });
3610
+ this._deps.breaker.passed();
3507
3611
  last_drain = await this._deps.drain(drain_options);
3508
3612
  const made_progress = subscribed > 0 || last_drain.acked.length > 0 || last_drain.blocked.length > 0;
3509
3613
  if (!made_progress) break;
3510
3614
  }
3511
3615
  if (last_drain) this._deps.on_settled(last_drain);
3512
- })().catch((err) => this._deps.logger.error(err)).finally(() => {
3616
+ })().catch((err) => {
3617
+ this._deps.breaker.failed(Date.now(), err);
3618
+ }).finally(() => {
3513
3619
  this._running = false;
3514
3620
  });
3515
3621
  }, debounceMs);
@@ -3528,6 +3634,8 @@ var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
3528
3634
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
3529
3635
  var Act = class {
3530
3636
  _emitter = new import_node_events.default();
3637
+ /** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
3638
+ _breaker;
3531
3639
  /** #803: gate the `Store.notify` subscription side. */
3532
3640
  _listen;
3533
3641
  /** #803: gate the local reaction pipeline (drain controllers, settle, correlate). */
@@ -3581,6 +3689,22 @@ var Act = class {
3581
3689
  emit(event, args) {
3582
3690
  return this._emitter.emit(event, args);
3583
3691
  }
3692
+ /**
3693
+ * The single store-failure handler: log it, then emit the `error`
3694
+ * lifecycle event. Wired into the circuit breaker's `on_error`, so it
3695
+ * runs on every `failed()` — drain / settle / autoclose just call
3696
+ * `breaker.failed(now, error)` and never log or emit themselves.
3697
+ *
3698
+ * The emit is guarded against Node's `EventEmitter` contract that an
3699
+ * unhandled `"error"` emission is rethrown (which would crash the process
3700
+ * from inside the drain catch); logging is unconditional so failures are
3701
+ * never silent.
3702
+ */
3703
+ _emit_error(error, circuit) {
3704
+ this._logger.error(error);
3705
+ if (this._emitter.listenerCount("error") > 0)
3706
+ this.emit("error", { error, circuit });
3707
+ }
3584
3708
  /**
3585
3709
  * Register a listener for a lifecycle event. The listener receives the
3586
3710
  * event-specific payload.
@@ -3709,6 +3833,25 @@ var Act = class {
3709
3833
  this._listen = options.listen !== false;
3710
3834
  this._drain = options.drain !== false;
3711
3835
  this._autoclose_config = resolveAutocloseConfig(options);
3836
+ this._breaker = new CircuitBreaker(
3837
+ resolveCircuitBreakerConfig(options.circuitBreaker),
3838
+ {
3839
+ on_error: (error, circuit) => this._emit_error(error, circuit),
3840
+ // Re-probe the store when the cooldown elapses, so recovery is
3841
+ // automatic even on the default lane (which has no periodic poller).
3842
+ // The breaker is shared by every store-polling loop (drain, the
3843
+ // settle correlate, autoclose), so any of them can trip it — but a
3844
+ // bare `drain()` only touches the store when the controller is armed,
3845
+ // so a trip from a correlate failure could go unprobed. `settle()`
3846
+ // always runs a correlate (a store query) before draining, so it
3847
+ // re-probes regardless of which loop opened the breaker: one success
3848
+ // closes it and every loop resumes on its own cadence. A failed probe
3849
+ // re-opens the breaker and reschedules the wake.
3850
+ on_retry: () => {
3851
+ this.settle({ debounceMs: 0 });
3852
+ }
3853
+ }
3854
+ );
3712
3855
  this._event_to_state = event_to_state;
3713
3856
  this._event_to_lanes = event_to_lanes;
3714
3857
  const all_lanes = ["default", ...lanes.map((l) => l.name)];
@@ -3727,6 +3870,7 @@ var Act = class {
3727
3870
  handle_batch: this._handle_batch,
3728
3871
  on_acked: (acked) => this.emit("acked", acked),
3729
3872
  on_blocked: (blocked) => this.emit("blocked", blocked),
3873
+ breaker: this._breaker,
3730
3874
  // Pass lane only when a true per-lane controller is active.
3731
3875
  // The all-lanes (single default) case keeps lane=undefined so
3732
3876
  // adapter SQL collapses to the pre-1103 shape.
@@ -3763,12 +3907,12 @@ var Act = class {
3763
3907
  );
3764
3908
  this._settle = new SettleLoop(
3765
3909
  {
3766
- logger: this._logger,
3767
3910
  init: () => this._correlate.init(),
3768
3911
  checkpoint: () => this._correlate.checkpoint,
3769
3912
  correlate: (q) => this.correlate(q),
3770
3913
  drain: (o) => this.drain(o),
3771
- on_settled: (drain) => this.emit("settled", drain)
3914
+ on_settled: (drain) => this.emit("settled", drain),
3915
+ breaker: this._breaker
3772
3916
  },
3773
3917
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
3774
3918
  );
@@ -3790,7 +3934,8 @@ var Act = class {
3790
3934
  config: this._autoclose_config,
3791
3935
  correlator: this._correlator,
3792
3936
  close_actor: { id: "$autoclose", name: "autoclose" },
3793
- on_closed: (result) => this.emit("closed", result)
3937
+ on_closed: (result) => this.emit("closed", result),
3938
+ breaker: this._breaker
3794
3939
  }) : void 0;
3795
3940
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store2());
3796
3941
  dispose(() => this.shutdown());
@@ -3813,6 +3958,7 @@ var Act = class {
3813
3958
  this._emitter.removeAllListeners();
3814
3959
  this.stop_correlations();
3815
3960
  this.stop_settling();
3961
+ this._breaker.stop();
3816
3962
  for (const c of this._drain_controllers.values()) c.stop();
3817
3963
  const disposer = await this._notify_disposer;
3818
3964
  if (disposer) await disposer();
@@ -5330,6 +5476,7 @@ function write_line(writer, line) {
5330
5476
  REDACTED,
5331
5477
  SHREDDED,
5332
5478
  SNAP_EVENT,
5479
+ StoreError,
5333
5480
  StreamClosedError,
5334
5481
  TOMBSTONE_EVENT,
5335
5482
  TargetSchema,