@rotorsoft/act 1.18.4 → 1.18.6

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.js CHANGED
@@ -616,6 +616,108 @@ function compile_autoclose_policy(options) {
616
616
  };
617
617
  }
618
618
 
619
+ // src/internal/close-signal.ts
620
+ var CloseSignal = class extends Error {
621
+ /**
622
+ * Stream to close. Omitted → the reaction's own lease stream (a self-close).
623
+ * The synthesized autoclose reaction sets it to the aggregate stream, since
624
+ * its lease runs on a synthetic `__autoclose__` target.
625
+ */
626
+ stream;
627
+ /** Optional archive callback to run while the stream is guarded. */
628
+ archive;
629
+ /**
630
+ * Watermark to ack the requesting reaction to before the close runs. The
631
+ * close-cycle safety guard skips a stream whose subscriptions (matched by
632
+ * source) lag the head; the autoclose reaction's `source` is the aggregate,
633
+ * so it must advance its own watermark to the live head id it evaluated
634
+ * against — otherwise it blocks its own close. Defaults to the triggering
635
+ * event id when omitted.
636
+ */
637
+ at;
638
+ constructor(opts) {
639
+ super("reaction requested close");
640
+ this.name = "CloseSignal";
641
+ this.stream = opts?.stream;
642
+ this.archive = opts?.archive;
643
+ this.at = opts?.at;
644
+ }
645
+ };
646
+
647
+ // src/internal/defer-signal.ts
648
+ var DeferSignal = class extends Error {
649
+ /**
650
+ * The unresolved schedule. The dispatcher turns this into an absolute
651
+ * due-time by resolving it against the triggering event.
652
+ */
653
+ when;
654
+ constructor(when) {
655
+ super("reaction deferred");
656
+ this.name = "DeferSignal";
657
+ this.when = when;
658
+ }
659
+ };
660
+
661
+ // src/internal/autoclose-reaction.ts
662
+ var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
663
+ function synthesize_autoclose_reactions(registry, states, config2) {
664
+ for (const st of states.values()) {
665
+ const predicate = st.autoclose;
666
+ if (!predicate) continue;
667
+ const after_ms = st.autoclose_after_ms;
668
+ const archiver = st.archive;
669
+ const reaction = {
670
+ // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
671
+ // per-aggregate `__autoclose__` key — so the autoclose reaction never
672
+ // shares a watermark with the aggregate's own reactions. A shared
673
+ // watermark would let autoclose's defer short-circuit the aggregate's
674
+ // other reactions (the "a defer affects all reactions on a stream"
675
+ // hazard). The close still targets the aggregate (`source`).
676
+ resolver: (e) => ({
677
+ target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
678
+ source: e.stream
679
+ }),
680
+ // Never block on autoclose: a transient query/store error should retry,
681
+ // not quarantine the synthetic stream.
682
+ options: { blockOnError: false, maxRetries: 3 },
683
+ handler: async (event) => {
684
+ const aggregate = event.stream;
685
+ if (!in_autoclose_window(config2.autocloseWindow, /* @__PURE__ */ new Date()))
686
+ throw new DeferSignal({
687
+ at: new Date(Date.now() + config2.autocloseCycleMinutes * 6e4)
688
+ });
689
+ const stats = await store().query_stats([aggregate], {
690
+ count: true,
691
+ exclude: [TOMBSTONE_EVENT]
692
+ });
693
+ const entry = stats.get(aggregate);
694
+ if (!entry) return;
695
+ const head = entry.head;
696
+ if (predicate(aggregate, head, entry.count))
697
+ throw new CloseSignal({
698
+ stream: aggregate,
699
+ archive: archiver ? () => archiver(aggregate, head) : void 0,
700
+ // Ack this reaction's own watermark to the live head so the
701
+ // close-cycle guard (which matches subscriptions by source =
702
+ // aggregate) sees it caught up instead of blocking its own close.
703
+ at: head.id
704
+ });
705
+ if (after_ms !== void 0)
706
+ throw new DeferSignal({
707
+ at: new Date(head.created.getTime() + after_ms)
708
+ });
709
+ }
710
+ };
711
+ const key = `__autoclose_${st.name}`;
712
+ for (const event_name of Object.keys(st.events)) {
713
+ registry.events[event_name]?.reactions.set(
714
+ key,
715
+ reaction
716
+ );
717
+ }
718
+ }
719
+ }
720
+
619
721
  // src/internal/build-classify.ts
620
722
  var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
621
723
  function classify_registry(registry, states) {
@@ -671,22 +773,6 @@ function classify_registry(registry, states) {
671
773
 
672
774
  // src/internal/defer-config.ts
673
775
  import { z as z3 } from "zod";
674
-
675
- // src/internal/defer-signal.ts
676
- var DeferSignal = class extends Error {
677
- /**
678
- * The unresolved schedule. The dispatcher turns this into an absolute
679
- * due-time by resolving it against the triggering event.
680
- */
681
- when;
682
- constructor(when) {
683
- super("reaction deferred");
684
- this.name = "DeferSignal";
685
- this.when = when;
686
- }
687
- };
688
-
689
- // src/internal/defer-config.ts
690
776
  var DeferDurationSchema = z3.object({
691
777
  days: z3.number().positive().optional(),
692
778
  hours: z3.number().positive().optional(),
@@ -1170,34 +1256,6 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
1170
1256
  return truncated;
1171
1257
  }
1172
1258
 
1173
- // src/internal/close-signal.ts
1174
- var CloseSignal = class extends Error {
1175
- /**
1176
- * Stream to close. Omitted → the reaction's own lease stream (a self-close).
1177
- * The synthesized autoclose reaction sets it to the aggregate stream, since
1178
- * its lease runs on a synthetic `__autoclose__` target.
1179
- */
1180
- stream;
1181
- /** Optional archive callback to run while the stream is guarded. */
1182
- archive;
1183
- /**
1184
- * Watermark to ack the requesting reaction to before the close runs. The
1185
- * close-cycle safety guard skips a stream whose subscriptions (matched by
1186
- * source) lag the head; the autoclose reaction's `source` is the aggregate,
1187
- * so it must advance its own watermark to the live head id it evaluated
1188
- * against — otherwise it blocks its own close. Defaults to the triggering
1189
- * event id when omitted.
1190
- */
1191
- at;
1192
- constructor(opts) {
1193
- super("reaction requested close");
1194
- this.name = "CloseSignal";
1195
- this.stream = opts?.stream;
1196
- this.archive = opts?.archive;
1197
- this.at = opts?.at;
1198
- }
1199
- };
1200
-
1201
1259
  // src/internal/correlate-cycle.ts
1202
1260
  var CorrelateCycle = class {
1203
1261
  _checkpoint = -1;
@@ -2365,7 +2423,15 @@ var SettleLoop = class {
2365
2423
  // src/act.ts
2366
2424
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2367
2425
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
2368
- var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
2426
+ function validate_only_lanes(options, lanes) {
2427
+ if (!options.onlyLanes || options.onlyLanes.length === 0) return;
2428
+ const declared = /* @__PURE__ */ new Set(["default", ...lanes.map((l) => l.name)]);
2429
+ const unknown = options.onlyLanes.filter((l) => !declared.has(l));
2430
+ if (unknown.length > 0)
2431
+ throw new Error(
2432
+ `ActOptions.onlyLanes references undeclared lane(s): ${unknown.map((l) => `"${l}"`).join(", ")}`
2433
+ );
2434
+ }
2369
2435
  var Act = class {
2370
2436
  _emitter = new EventEmitter();
2371
2437
  /** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
@@ -2400,13 +2466,6 @@ var Act = class {
2400
2466
  registry;
2401
2467
  /** Map of state name → state definition; populated by the builder. */
2402
2468
  _states;
2403
- /**
2404
- * Resolved autoclose configuration (#837 / epic #802). Frozen at
2405
- * `act().build()` via {@link resolveAutocloseConfig}.
2406
- *
2407
- * @internal
2408
- */
2409
- _autoclose_config;
2410
2469
  /**
2411
2470
  * Emit a lifecycle event. The payload type is inferred from the event name
2412
2471
  * via {@link ActLifecycleEvents}.
@@ -2523,17 +2582,7 @@ var Act = class {
2523
2582
  this._states = states;
2524
2583
  this._batch_handlers = batch_handlers;
2525
2584
  this._lanes = lanes;
2526
- if (options.onlyLanes && options.onlyLanes.length > 0) {
2527
- const declared = /* @__PURE__ */ new Set([
2528
- "default",
2529
- ...lanes.map((l) => l.name)
2530
- ]);
2531
- const unknown = options.onlyLanes.filter((l) => !declared.has(l));
2532
- if (unknown.length > 0)
2533
- throw new Error(
2534
- `ActOptions.onlyLanes references undeclared lane(s): ${unknown.map((l) => `"${l}"`).join(", ")}`
2535
- );
2536
- }
2585
+ validate_only_lanes(options, lanes);
2537
2586
  this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
2538
2587
  this._correlator = options.correlator ?? default_correlator;
2539
2588
  this._es = build_es(this._logger, this._correlator);
@@ -2547,99 +2596,59 @@ var Act = class {
2547
2596
  bound_forget: this._bound_forget
2548
2597
  });
2549
2598
  this._handle_batch = build_handle_batch(this._logger);
2550
- for (const st of this._states.values()) {
2551
- const predicate = st.autoclose;
2552
- if (!predicate) continue;
2553
- const after_ms = st.autoclose_after_ms;
2554
- const archiver = st.archive;
2555
- const reaction = {
2556
- // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
2557
- // per-aggregate `__autoclose__` key — so the autoclose reaction never
2558
- // shares a watermark with the aggregate's own reactions. A shared
2559
- // watermark would let autoclose's defer short-circuit the aggregate's
2560
- // other reactions (the "a defer affects all reactions on a stream"
2561
- // hazard). The close still targets the aggregate (`source`).
2562
- resolver: (e) => ({
2563
- target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
2564
- source: e.stream
2565
- }),
2566
- // Never block on autoclose: a transient query/store error should retry,
2567
- // not quarantine the synthetic stream.
2568
- options: { blockOnError: false, maxRetries: 3 },
2569
- handler: async (event) => {
2570
- const aggregate = event.stream;
2571
- const config2 = this._autoclose_config;
2572
- if (!in_autoclose_window(config2.autocloseWindow, /* @__PURE__ */ new Date()))
2573
- throw new DeferSignal({
2574
- at: new Date(Date.now() + config2.autocloseCycleMinutes * 6e4)
2575
- });
2576
- const stats = await store().query_stats([aggregate], {
2577
- count: true,
2578
- exclude: [TOMBSTONE_EVENT]
2579
- });
2580
- const entry = stats.get(aggregate);
2581
- if (!entry) return;
2582
- const head = entry.head;
2583
- if (predicate(aggregate, head, entry.count))
2584
- throw new CloseSignal({
2585
- stream: aggregate,
2586
- archive: archiver ? () => archiver(aggregate, head) : void 0,
2587
- // Ack this reaction's own watermark to the live head so the
2588
- // close-cycle guard (which matches subscriptions by source =
2589
- // aggregate) sees it caught up instead of blocking its own close.
2590
- at: head.id
2591
- });
2592
- if (after_ms !== void 0)
2593
- throw new DeferSignal({
2594
- at: new Date(head.created.getTime() + after_ms)
2595
- });
2596
- }
2597
- };
2598
- const key = `__autoclose_${st.name}`;
2599
- for (const event_name of Object.keys(st.events)) {
2600
- this.registry.events[event_name]?.reactions.set(
2601
- key,
2602
- reaction
2603
- );
2604
- }
2605
- }
2606
- const {
2607
- static_targets,
2608
- has_dynamic_resolvers,
2609
- reactive_events,
2610
- event_to_state,
2611
- event_to_lanes
2612
- } = classify_registry(this.registry, this._states);
2613
- this._reactive_events = reactive_events;
2599
+ const classification = classify_registry(this.registry, this._states);
2600
+ this._reactive_events = classification.reactive_events;
2601
+ this._event_to_state = classification.event_to_state;
2602
+ this._event_to_lanes = classification.event_to_lanes;
2614
2603
  this._listen = options.listen !== false;
2615
2604
  this._drain = options.drain !== false;
2616
- this._autoclose_config = resolveAutocloseConfig(options);
2617
- this._breaker = new CircuitBreaker(
2605
+ this._breaker = this._build_breaker(options);
2606
+ this._drain_controllers = this._build_drain_controllers(options, lanes);
2607
+ this._audit_deps = this._build_audit_deps();
2608
+ this._correlate = this._build_correlate(options, classification);
2609
+ this._settle = this._build_settle(options);
2610
+ this._notify_disposer = this._wire_notify(options.scoped?.store ?? store());
2611
+ dispose(() => this.shutdown());
2612
+ }
2613
+ /**
2614
+ * Circuit breaker shared by every store-polling loop (drain, the settle
2615
+ * correlate, autoclose). Validates the knobs eagerly so out-of-range
2616
+ * values throw at build time, not on the first cycle tick.
2617
+ */
2618
+ _build_breaker(options) {
2619
+ return new CircuitBreaker(
2618
2620
  resolveCircuitBreakerConfig(options.circuitBreaker),
2619
2621
  {
2620
2622
  on_error: (error, circuit) => this._emit_error(error, circuit),
2621
2623
  // Re-probe the store when the cooldown elapses, so recovery is
2622
2624
  // automatic even on the default lane (which has no periodic poller).
2623
- // The breaker is shared by every store-polling loop (drain, the
2624
- // settle correlate, autoclose), so any of them can trip it — but a
2625
- // bare `drain()` only touches the store when the controller is armed,
2626
- // so a trip from a correlate failure could go unprobed. `settle()`
2627
- // always runs a correlate (a store query) before draining, so it
2628
- // re-probes regardless of which loop opened the breaker: one success
2629
- // closes it and every loop resumes on its own cadence. A failed probe
2630
- // re-opens the breaker and reschedules the wake.
2625
+ // Any polling loop can trip the shared breaker — but a bare `drain()`
2626
+ // only touches the store when the controller is armed, so a trip
2627
+ // from a correlate failure could go unprobed. `settle()` always runs
2628
+ // a correlate (a store query) before draining, so it re-probes
2629
+ // regardless of which loop opened the breaker: one success closes it
2630
+ // and every loop resumes on its own cadence. A failed probe re-opens
2631
+ // the breaker and reschedules the wake.
2631
2632
  on_retry: () => {
2632
2633
  this.settle({ debounceMs: 0 });
2633
2634
  }
2634
2635
  }
2635
2636
  );
2636
- this._event_to_state = event_to_state;
2637
- this._event_to_lanes = event_to_lanes;
2637
+ }
2638
+ /**
2639
+ * One DrainController per active lane. The implicit "default" lane is
2640
+ * always present unless onlyLanes excludes it. Each controller filters
2641
+ * its claim() by its lane name; the legacy single-controller path is the
2642
+ * active set === { "default" } case with `lane: undefined` deps so
2643
+ * claim() doesn't filter (preserves the single-lane SQL planner shape
2644
+ * for apps that never call withLane).
2645
+ */
2646
+ _build_drain_controllers(options, lanes) {
2638
2647
  const all_lanes = ["default", ...lanes.map((l) => l.name)];
2639
2648
  const only_set = options.onlyLanes && options.onlyLanes.length > 0 ? new Set(options.onlyLanes) : void 0;
2640
2649
  const active_lanes = only_set ? all_lanes.filter((n) => only_set.has(n)) : all_lanes;
2641
2650
  const single_default_lane = active_lanes.length === 1 && active_lanes[0] === "default";
2642
- this._drain_controllers = /* @__PURE__ */ new Map();
2651
+ const controllers = /* @__PURE__ */ new Map();
2643
2652
  for (const name of active_lanes) {
2644
2653
  const cfg = lanes.find((l) => l.name === name);
2645
2654
  const controller = new DrainController({
@@ -2651,10 +2660,11 @@ var Act = class {
2651
2660
  handle_batch: this._handle_batch,
2652
2661
  on_acked: (acked) => this.emit("acked", acked),
2653
2662
  on_blocked: (blocked) => this.emit("blocked", blocked),
2654
- // Reaction-requested close (#1090). Runs the same close machinery as
2663
+ // Reaction-requested close. Runs the same close machinery as
2655
2664
  // `app.close` (tombstone guard + archive + atomic truncate) for the
2656
- // targets a handler signalled via `CloseSignal`. No `correlate()` here
2657
- // — the drain that produced these targets has already correlated.
2665
+ // targets a handler signalled via `CloseSignal`. No `correlate()`
2666
+ // here — the drain that produced these targets has already
2667
+ // correlated.
2658
2668
  on_close: async (targets) => {
2659
2669
  const close_actor = { id: "$close", name: "close" };
2660
2670
  const result = await run_close_cycle(targets, {
@@ -2670,7 +2680,7 @@ var Act = class {
2670
2680
  breaker: this._breaker,
2671
2681
  // Pass lane only when a true per-lane controller is active.
2672
2682
  // The all-lanes (single default) case keeps lane=undefined so
2673
- // adapter SQL collapses to the pre-1103 shape.
2683
+ // adapter SQL collapses to the single-lane shape.
2674
2684
  lane: single_default_lane ? void 0 : name,
2675
2685
  defaults: cfg && {
2676
2686
  streamLimit: cfg.streamLimit,
@@ -2679,30 +2689,47 @@ var Act = class {
2679
2689
  });
2680
2690
  if (cfg?.cycleMs !== void 0 && options.drain !== false)
2681
2691
  controller.start(cfg.cycleMs);
2682
- this._drain_controllers.set(name, controller);
2692
+ controllers.set(name, controller);
2683
2693
  }
2684
- this._audit_deps = {
2694
+ return controllers;
2695
+ }
2696
+ /**
2697
+ * Audit deps bag. Snapshotted after registry classification and
2698
+ * drain-controller build so the audit module sees the finalized lane
2699
+ * set. Held as an immutable bag — the orchestrator never carries audit
2700
+ * logic itself, only this typed contract.
2701
+ */
2702
+ _build_audit_deps() {
2703
+ return {
2685
2704
  store,
2686
2705
  logger: this._logger,
2687
- event_to_state,
2706
+ event_to_state: this._event_to_state,
2688
2707
  states: this._states,
2689
- known_events: new Set(event_to_state.keys()),
2708
+ known_events: new Set(this._event_to_state.keys()),
2690
2709
  declared_lanes: new Set(this._drain_controllers.keys()),
2691
- routed_events: new Set(event_to_lanes.keys())
2710
+ routed_events: new Set(this._event_to_lanes.keys())
2692
2711
  };
2693
- this._correlate = new CorrelateCycle(
2712
+ }
2713
+ /**
2714
+ * Correlate cycle over the classified registry. The cold-start callback
2715
+ * arms every controller — historical events may need processing —
2716
+ * except on writer-only instances (`drain: false`).
2717
+ */
2718
+ _build_correlate(options, classification) {
2719
+ return new CorrelateCycle(
2694
2720
  this.registry,
2695
- static_targets,
2696
- has_dynamic_resolvers,
2721
+ classification.static_targets,
2722
+ classification.has_dynamic_resolvers,
2697
2723
  this._cd,
2698
2724
  options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
2699
- // Cold start: assume drain is needed (historical events may need processing).
2700
- // #803: writer-only instances skip the cold-start arm.
2701
2725
  () => {
2702
2726
  if (this._drain && this._reactive_events.size > 0) this._arm_all();
2703
2727
  }
2704
2728
  );
2705
- this._settle = new SettleLoop(
2729
+ }
2730
+ /** Settle loop driving correlate + drain to quiescence. */
2731
+ _build_settle(options) {
2732
+ return new SettleLoop(
2706
2733
  {
2707
2734
  init: () => this._correlate.init(),
2708
2735
  checkpoint: () => this._correlate.checkpoint,
@@ -2713,8 +2740,6 @@ var Act = class {
2713
2740
  },
2714
2741
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
2715
2742
  );
2716
- this._notify_disposer = this._wire_notify(options.scoped?.store ?? store());
2717
- dispose(() => this.shutdown());
2718
2743
  }
2719
2744
  /** True after the first `shutdown()` call. Guards idempotency. */
2720
2745
  _shutdown_promise;
@@ -3148,7 +3173,12 @@ var Act = class {
3148
3173
  */
3149
3174
  async correlate(query = { after: -1, limit: 10 }) {
3150
3175
  if (!this._drain) return { subscribed: 0, last_id: -1 };
3151
- return this._scoped(() => this._correlate.correlate(query));
3176
+ return this._scoped(async () => {
3177
+ const result = await this._correlate.correlate(query);
3178
+ if (result.subscribed > 0 && this._reactive_events.size > 0)
3179
+ this._arm_all();
3180
+ return result;
3181
+ });
3152
3182
  }
3153
3183
  /**
3154
3184
  * Starts automatic periodic correlation worker for discovering new streams.
@@ -3758,6 +3788,14 @@ function act() {
3758
3788
  };
3759
3789
  batch_handlers.set(target, wrapped);
3760
3790
  }
3791
+ synthesize_autoclose_reactions(
3792
+ registry,
3793
+ states,
3794
+ resolveAutocloseConfig(options ?? {})
3795
+ );
3796
+ Object.freeze(registry.actions);
3797
+ Object.freeze(registry.events);
3798
+ Object.freeze(registry);
3761
3799
  _built = true;
3762
3800
  }
3763
3801
  return new Act(