@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.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;
@@ -1933,8 +1944,17 @@ async function scan_stream_heads(streams) {
1933
1944
  async function partition_by_safety(stream_info, reactive_events_size, skipped) {
1934
1945
  if (reactive_events_size === 0) return [...stream_info.keys()];
1935
1946
  const pending_set = /* @__PURE__ */ new Set();
1947
+ const source_regex = /* @__PURE__ */ new Map();
1948
+ const get_regex = (source) => {
1949
+ let re = source_regex.get(source);
1950
+ if (!re) {
1951
+ re = new RegExp(source);
1952
+ source_regex.set(source, re);
1953
+ }
1954
+ return re;
1955
+ };
1936
1956
  await store2().query_streams((position) => {
1937
- const source_re = position.source ? RegExp(position.source) : void 0;
1957
+ const source_re = position.source ? get_regex(position.source) : void 0;
1938
1958
  for (const [stream, info] of stream_info) {
1939
1959
  if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
1940
1960
  pending_set.add(stream);
@@ -2136,10 +2156,7 @@ var AutocloseController = class {
2136
2156
  start() {
2137
2157
  if (this._timer) return false;
2138
2158
  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
- );
2159
+ this.run_once().catch(() => {
2143
2160
  });
2144
2161
  }, this.deps.config.autocloseCycleMs);
2145
2162
  this._timer.unref();
@@ -2172,6 +2189,7 @@ var AutocloseController = class {
2172
2189
  */
2173
2190
  async run_once() {
2174
2191
  if (this._running) return null;
2192
+ if (this.deps.breaker.state(Date.now()) === "open") return null;
2175
2193
  this._running = true;
2176
2194
  try {
2177
2195
  const correlation = close_correlation(
@@ -2189,10 +2207,14 @@ var AutocloseController = class {
2189
2207
  config: this.deps.config,
2190
2208
  correlation
2191
2209
  });
2210
+ this.deps.breaker.passed();
2192
2211
  if (result.close_result.truncated.size > 0) {
2193
2212
  this.deps.on_closed(result.close_result);
2194
2213
  }
2195
2214
  return result;
2215
+ } catch (error) {
2216
+ this.deps.breaker.failed(Date.now(), error);
2217
+ throw error;
2196
2218
  } finally {
2197
2219
  this._running = false;
2198
2220
  }
@@ -2322,6 +2344,93 @@ function classify_registry(registry, states) {
2322
2344
  };
2323
2345
  }
2324
2346
 
2347
+ // src/internal/circuit-breaker.ts
2348
+ var import_zod7 = require("zod");
2349
+ var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
2350
+ var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
2351
+ var CircuitBreakerOptionsSchema = import_zod7.z.object({
2352
+ failureThreshold: import_zod7.z.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
2353
+ cooldownMs: import_zod7.z.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
2354
+ });
2355
+ var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
2356
+ var CircuitBreaker = class {
2357
+ _failures = 0;
2358
+ _opened_at;
2359
+ _wake;
2360
+ _threshold;
2361
+ _cooldown_ms;
2362
+ _hooks;
2363
+ constructor(config2, hooks = {}) {
2364
+ this._threshold = config2.failureThreshold;
2365
+ this._cooldown_ms = config2.cooldownMs;
2366
+ this._hooks = hooks;
2367
+ }
2368
+ /** Current state given the wall-clock `now`. */
2369
+ state(now) {
2370
+ if (this._opened_at === void 0) return "closed";
2371
+ return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
2372
+ }
2373
+ /** A store op passed — reset to closed and cancel any pending retry. */
2374
+ passed() {
2375
+ this._failures = 0;
2376
+ this._opened_at = void 0;
2377
+ this._clear_wake();
2378
+ }
2379
+ /**
2380
+ * A store op failed. Opens the breaker when the consecutive-failure
2381
+ * threshold is reached, or immediately re-opens (restarting the cooldown)
2382
+ * if a half-open trial just failed. Returns the resulting state.
2383
+ *
2384
+ * Callers gate on the state directly — `state(now) === "open"` means skip.
2385
+ * On opening, schedules the `on_retry` wake so recovery is automatic;
2386
+ * always surfaces the failure via `on_error`.
2387
+ */
2388
+ failed(now, error) {
2389
+ const circuit = this._advance(now);
2390
+ if (circuit === "open") this._schedule_wake();
2391
+ this._hooks.on_error?.(error, circuit);
2392
+ return circuit;
2393
+ }
2394
+ /** Cancel the pending retry timer. Idempotent; call on shutdown. */
2395
+ stop() {
2396
+ this._clear_wake();
2397
+ }
2398
+ /**
2399
+ * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
2400
+ * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
2401
+ * tests of the pure state machine). The timer is `unref`'d so it never
2402
+ * keeps the process alive.
2403
+ */
2404
+ _schedule_wake() {
2405
+ if (!this._hooks.on_retry) return;
2406
+ this._clear_wake();
2407
+ this._wake = setTimeout(() => {
2408
+ this._wake = void 0;
2409
+ this._hooks.on_retry?.();
2410
+ }, this._cooldown_ms);
2411
+ this._wake.unref();
2412
+ }
2413
+ _clear_wake() {
2414
+ if (this._wake) {
2415
+ clearTimeout(this._wake);
2416
+ this._wake = void 0;
2417
+ }
2418
+ }
2419
+ /** Pure state transition for a failure — no side effects. */
2420
+ _advance(now) {
2421
+ if (this._opened_at !== void 0) {
2422
+ this._opened_at = now;
2423
+ return "open";
2424
+ }
2425
+ this._failures += 1;
2426
+ if (this._failures >= this._threshold) {
2427
+ this._opened_at = now;
2428
+ return "open";
2429
+ }
2430
+ return "closed";
2431
+ }
2432
+ };
2433
+
2325
2434
  // src/internal/correlate-cycle.ts
2326
2435
  var CorrelateCycle = class {
2327
2436
  _checkpoint = -1;
@@ -3174,6 +3283,8 @@ var DrainController = class {
3174
3283
  async drain(options = {}) {
3175
3284
  if (!this._armed) return EMPTY_DRAIN;
3176
3285
  if (this._locked) return EMPTY_DRAIN;
3286
+ if (this._deps.breaker.state(Date.now()) === "open")
3287
+ return EMPTY_DRAIN;
3177
3288
  const d = this._deps.defaults ?? {};
3178
3289
  const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
3179
3290
  const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
@@ -3195,6 +3306,7 @@ var DrainController = class {
3195
3306
  this._backoff.size > 0 ? this.is_deferred : void 0,
3196
3307
  this._deps.lane
3197
3308
  );
3309
+ this._deps.breaker.passed();
3198
3310
  if (!cycle) {
3199
3311
  this._armed = false;
3200
3312
  return EMPTY_DRAIN;
@@ -3216,7 +3328,7 @@ var DrainController = class {
3216
3328
  if (!acked.length && !blocked.length && !has_errors) this._armed = false;
3217
3329
  return { fetched, leased, acked, blocked };
3218
3330
  } catch (error) {
3219
- this._deps.logger.error(error);
3331
+ this._deps.breaker.failed(Date.now(), error);
3220
3332
  return EMPTY_DRAIN;
3221
3333
  } finally {
3222
3334
  this._locked = false;
@@ -3225,7 +3337,7 @@ var DrainController = class {
3225
3337
  };
3226
3338
 
3227
3339
  // src/internal/merge.ts
3228
- var import_zod7 = require("zod");
3340
+ var import_zod8 = require("zod");
3229
3341
  function base_type_name(zodType) {
3230
3342
  let t = zodType;
3231
3343
  while (typeof t.unwrap === "function") {
@@ -3234,7 +3346,7 @@ function base_type_name(zodType) {
3234
3346
  return t.constructor.name;
3235
3347
  }
3236
3348
  function merge_schemas(existing, incoming, state_name) {
3237
- if (existing instanceof import_zod7.ZodObject && incoming instanceof import_zod7.ZodObject) {
3349
+ if (existing instanceof import_zod8.ZodObject && incoming instanceof import_zod8.ZodObject) {
3238
3350
  const existing_shape = existing.shape;
3239
3351
  const incoming_shape = incoming.shape;
3240
3352
  for (const key of Object.keys(incoming_shape)) {
@@ -3504,12 +3616,15 @@ var SettleLoop = class {
3504
3616
  ...correlate_query,
3505
3617
  after: this._deps.checkpoint()
3506
3618
  });
3619
+ this._deps.breaker.passed();
3507
3620
  last_drain = await this._deps.drain(drain_options);
3508
3621
  const made_progress = subscribed > 0 || last_drain.acked.length > 0 || last_drain.blocked.length > 0;
3509
3622
  if (!made_progress) break;
3510
3623
  }
3511
3624
  if (last_drain) this._deps.on_settled(last_drain);
3512
- })().catch((err) => this._deps.logger.error(err)).finally(() => {
3625
+ })().catch((err) => {
3626
+ this._deps.breaker.failed(Date.now(), err);
3627
+ }).finally(() => {
3513
3628
  this._running = false;
3514
3629
  });
3515
3630
  }, debounceMs);
@@ -3528,6 +3643,8 @@ var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
3528
3643
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
3529
3644
  var Act = class {
3530
3645
  _emitter = new import_node_events.default();
3646
+ /** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
3647
+ _breaker;
3531
3648
  /** #803: gate the `Store.notify` subscription side. */
3532
3649
  _listen;
3533
3650
  /** #803: gate the local reaction pipeline (drain controllers, settle, correlate). */
@@ -3581,6 +3698,22 @@ var Act = class {
3581
3698
  emit(event, args) {
3582
3699
  return this._emitter.emit(event, args);
3583
3700
  }
3701
+ /**
3702
+ * The single store-failure handler: log it, then emit the `error`
3703
+ * lifecycle event. Wired into the circuit breaker's `on_error`, so it
3704
+ * runs on every `failed()` — drain / settle / autoclose just call
3705
+ * `breaker.failed(now, error)` and never log or emit themselves.
3706
+ *
3707
+ * The emit is guarded against Node's `EventEmitter` contract that an
3708
+ * unhandled `"error"` emission is rethrown (which would crash the process
3709
+ * from inside the drain catch); logging is unconditional so failures are
3710
+ * never silent.
3711
+ */
3712
+ _emit_error(error, circuit) {
3713
+ this._logger.error(error);
3714
+ if (this._emitter.listenerCount("error") > 0)
3715
+ this.emit("error", { error, circuit });
3716
+ }
3584
3717
  /**
3585
3718
  * Register a listener for a lifecycle event. The listener receives the
3586
3719
  * event-specific payload.
@@ -3709,6 +3842,25 @@ var Act = class {
3709
3842
  this._listen = options.listen !== false;
3710
3843
  this._drain = options.drain !== false;
3711
3844
  this._autoclose_config = resolveAutocloseConfig(options);
3845
+ this._breaker = new CircuitBreaker(
3846
+ resolveCircuitBreakerConfig(options.circuitBreaker),
3847
+ {
3848
+ on_error: (error, circuit) => this._emit_error(error, circuit),
3849
+ // Re-probe the store when the cooldown elapses, so recovery is
3850
+ // automatic even on the default lane (which has no periodic poller).
3851
+ // The breaker is shared by every store-polling loop (drain, the
3852
+ // settle correlate, autoclose), so any of them can trip it — but a
3853
+ // bare `drain()` only touches the store when the controller is armed,
3854
+ // so a trip from a correlate failure could go unprobed. `settle()`
3855
+ // always runs a correlate (a store query) before draining, so it
3856
+ // re-probes regardless of which loop opened the breaker: one success
3857
+ // closes it and every loop resumes on its own cadence. A failed probe
3858
+ // re-opens the breaker and reschedules the wake.
3859
+ on_retry: () => {
3860
+ this.settle({ debounceMs: 0 });
3861
+ }
3862
+ }
3863
+ );
3712
3864
  this._event_to_state = event_to_state;
3713
3865
  this._event_to_lanes = event_to_lanes;
3714
3866
  const all_lanes = ["default", ...lanes.map((l) => l.name)];
@@ -3727,6 +3879,7 @@ var Act = class {
3727
3879
  handle_batch: this._handle_batch,
3728
3880
  on_acked: (acked) => this.emit("acked", acked),
3729
3881
  on_blocked: (blocked) => this.emit("blocked", blocked),
3882
+ breaker: this._breaker,
3730
3883
  // Pass lane only when a true per-lane controller is active.
3731
3884
  // The all-lanes (single default) case keeps lane=undefined so
3732
3885
  // adapter SQL collapses to the pre-1103 shape.
@@ -3763,12 +3916,12 @@ var Act = class {
3763
3916
  );
3764
3917
  this._settle = new SettleLoop(
3765
3918
  {
3766
- logger: this._logger,
3767
3919
  init: () => this._correlate.init(),
3768
3920
  checkpoint: () => this._correlate.checkpoint,
3769
3921
  correlate: (q) => this.correlate(q),
3770
3922
  drain: (o) => this.drain(o),
3771
- on_settled: (drain) => this.emit("settled", drain)
3923
+ on_settled: (drain) => this.emit("settled", drain),
3924
+ breaker: this._breaker
3772
3925
  },
3773
3926
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
3774
3927
  );
@@ -3790,7 +3943,8 @@ var Act = class {
3790
3943
  config: this._autoclose_config,
3791
3944
  correlator: this._correlator,
3792
3945
  close_actor: { id: "$autoclose", name: "autoclose" },
3793
- on_closed: (result) => this.emit("closed", result)
3946
+ on_closed: (result) => this.emit("closed", result),
3947
+ breaker: this._breaker
3794
3948
  }) : void 0;
3795
3949
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store2());
3796
3950
  dispose(() => this.shutdown());
@@ -3813,6 +3967,7 @@ var Act = class {
3813
3967
  this._emitter.removeAllListeners();
3814
3968
  this.stop_correlations();
3815
3969
  this.stop_settling();
3970
+ this._breaker.stop();
3816
3971
  for (const c of this._drain_controllers.values()) c.stop();
3817
3972
  const disposer = await this._notify_disposer;
3818
3973
  if (disposer) await disposer();
@@ -5330,6 +5485,7 @@ function write_line(writer, line) {
5330
5485
  REDACTED,
5331
5486
  SHREDDED,
5332
5487
  SNAP_EVENT,
5488
+ StoreError,
5333
5489
  StreamClosedError,
5334
5490
  TOMBSTONE_EVENT,
5335
5491
  TargetSchema,