@rotorsoft/act 1.16.1 → 1.17.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 (41) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +0 -9
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/adapters/in-memory-store.d.ts +10 -0
  5. package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
  6. package/dist/@types/builders/state-builder.d.ts +19 -26
  7. package/dist/@types/builders/state-builder.d.ts.map +1 -1
  8. package/dist/@types/internal/autoclose-policy.d.ts +16 -0
  9. package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
  10. package/dist/@types/internal/close-signal.d.ts +51 -0
  11. package/dist/@types/internal/close-signal.d.ts.map +1 -0
  12. package/dist/@types/internal/defer-signal.d.ts +27 -0
  13. package/dist/@types/internal/defer-signal.d.ts.map +1 -0
  14. package/dist/@types/internal/defer-timer.d.ts +67 -0
  15. package/dist/@types/internal/defer-timer.d.ts.map +1 -0
  16. package/dist/@types/internal/drain-cycle.d.ts +39 -17
  17. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  18. package/dist/@types/internal/drain.d.ts +3 -1
  19. package/dist/@types/internal/drain.d.ts.map +1 -1
  20. package/dist/@types/internal/index.d.ts +4 -3
  21. package/dist/@types/internal/index.d.ts.map +1 -1
  22. package/dist/@types/internal/reactions.d.ts.map +1 -1
  23. package/dist/@types/internal/tracing.d.ts.map +1 -1
  24. package/dist/@types/types/action.d.ts +9 -0
  25. package/dist/@types/types/action.d.ts.map +1 -1
  26. package/dist/@types/types/ports.d.ts +31 -0
  27. package/dist/@types/types/ports.d.ts.map +1 -1
  28. package/dist/{chunk-46OCUAVF.js → chunk-7XME4ZLK.js} +52 -2
  29. package/dist/chunk-7XME4ZLK.js.map +1 -0
  30. package/dist/index.cjs +592 -527
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.js +543 -528
  33. package/dist/index.js.map +1 -1
  34. package/dist/test/index.cjs +55 -5
  35. package/dist/test/index.cjs.map +1 -1
  36. package/dist/test/index.js +5 -5
  37. package/dist/test/index.js.map +1 -1
  38. package/package.json +2 -2
  39. package/dist/@types/internal/autoclose-cycle.d.ts +0 -185
  40. package/dist/@types/internal/autoclose-cycle.d.ts.map +0 -1
  41. package/dist/chunk-46OCUAVF.js.map +0 -1
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  sleep,
20
20
  store,
21
21
  validate
22
- } from "./chunk-46OCUAVF.js";
22
+ } from "./chunk-7XME4ZLK.js";
23
23
  import {
24
24
  ActorSchema,
25
25
  CausationEventSchema,
@@ -540,353 +540,6 @@ function in_autoclose_window(window, now) {
540
540
  return window.start < window.end ? hour >= window.start && hour < window.end : hour >= window.start || hour < window.end;
541
541
  }
542
542
 
543
- // src/internal/close-cycle.ts
544
- var SAFETY_PROBE_PAGE_SIZE = 1e3;
545
- async function run_close_cycle(targets, deps) {
546
- const target_map = new Map(targets.map((t) => [t.stream, t]));
547
- const streams = [...target_map.keys()];
548
- const skipped = [];
549
- const stream_info = await scan_stream_heads(streams);
550
- const safe = await partition_by_safety(
551
- stream_info,
552
- deps.reactive_events_size,
553
- skipped,
554
- deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
555
- );
556
- if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
557
- const { guarded, guard_events } = await guard_with_tombstones(
558
- safe,
559
- stream_info,
560
- deps.correlation,
561
- deps.tombstone,
562
- skipped
563
- );
564
- if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
565
- const seed_states = await load_restart_seeds(
566
- guarded,
567
- target_map,
568
- stream_info,
569
- deps.event_to_state,
570
- deps.load,
571
- deps.logger
572
- );
573
- await run_archive_callbacks(guarded, target_map);
574
- const truncated = await truncate_and_warm_cache(
575
- guarded,
576
- seed_states,
577
- guard_events,
578
- deps.correlation
579
- );
580
- return { truncated, skipped };
581
- }
582
- async function scan_stream_heads(streams) {
583
- const stats = await store().query_stats(streams, {
584
- exclude: [SNAP_EVENT]
585
- });
586
- const out = /* @__PURE__ */ new Map();
587
- for (const [stream, { head }] of stats) {
588
- if (head.name === TOMBSTONE_EVENT) continue;
589
- out.set(stream, {
590
- max_id: head.id,
591
- version: head.version,
592
- last_event_name: head.name
593
- });
594
- }
595
- return out;
596
- }
597
- async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
598
- if (reactive_events_size === 0) return [...stream_info.keys()];
599
- const pending_set = /* @__PURE__ */ new Set();
600
- const source_regex = /* @__PURE__ */ new Map();
601
- const get_regex = (source) => {
602
- let re = source_regex.get(source);
603
- if (!re) {
604
- re = new RegExp(source);
605
- source_regex.set(source, re);
606
- }
607
- return re;
608
- };
609
- const targets = [...stream_info.keys()];
610
- let after;
611
- for (; ; ) {
612
- let last;
613
- const { count } = await store().query_streams(
614
- (position) => {
615
- last = position.stream;
616
- const source_re = position.source ? get_regex(position.source) : void 0;
617
- for (const [stream, info] of stream_info) {
618
- if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
619
- pending_set.add(stream);
620
- }
621
- }
622
- },
623
- { after, limit: page_size, source_matches: targets }
624
- );
625
- if (count < page_size) break;
626
- after = last;
627
- }
628
- const safe = [];
629
- for (const [stream] of stream_info) {
630
- if (pending_set.has(stream)) skipped.push(stream);
631
- else safe.push(stream);
632
- }
633
- return safe;
634
- }
635
- async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
636
- const guarded = [];
637
- const guard_events = /* @__PURE__ */ new Map();
638
- await Promise.all(
639
- safe.map(async (stream) => {
640
- const info = stream_info.get(stream);
641
- const committed = await tombstone2(stream, info.version, correlation);
642
- if (committed) {
643
- guarded.push(stream);
644
- guard_events.set(stream, { id: committed.id, stream });
645
- } else {
646
- skipped.push(stream);
647
- }
648
- })
649
- );
650
- return { guarded, guard_events };
651
- }
652
- async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
653
- const seed_states = /* @__PURE__ */ new Map();
654
- await Promise.all(
655
- guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
656
- const last_event_name = stream_info.get(stream).last_event_name;
657
- const owner_state = event_to_state.get(last_event_name);
658
- if (!owner_state) {
659
- logger.error(
660
- `Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
661
- );
662
- return;
663
- }
664
- const snap2 = await load2(owner_state, { stream });
665
- seed_states.set(stream, snap2.state);
666
- })
667
- );
668
- return seed_states;
669
- }
670
- async function run_archive_callbacks(guarded, target_map) {
671
- for (const stream of guarded) {
672
- const archive_fn = target_map.get(stream)?.archive;
673
- if (archive_fn) await archive_fn();
674
- }
675
- }
676
- async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
677
- const trunc_targets = guarded.map((stream) => {
678
- const snapshot = seed_states.get(stream);
679
- const guard = guard_events.get(stream);
680
- return {
681
- stream,
682
- snapshot,
683
- meta: {
684
- correlation,
685
- causation: {
686
- event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
687
- }
688
- }
689
- };
690
- });
691
- const truncated = await store().truncate(trunc_targets);
692
- await Promise.all(
693
- guarded.map(async (stream) => {
694
- const entry = truncated.get(stream);
695
- const state2 = seed_states.get(stream);
696
- if (state2 && entry) {
697
- await cache().set(stream, {
698
- state: state2,
699
- version: entry.committed.version,
700
- event_id: entry.committed.id,
701
- patches: 0,
702
- snaps: 1
703
- });
704
- } else {
705
- await cache().invalidate(stream);
706
- }
707
- })
708
- );
709
- return truncated;
710
- }
711
-
712
- // src/internal/correlator.ts
713
- import { randomInt } from "crypto";
714
- var BASE = 36;
715
- var SEG_WIDTH = 4;
716
- var SEG_SPACE = BASE ** SEG_WIDTH;
717
- function seg(n) {
718
- return n.toString(BASE).padStart(SEG_WIDTH, "0");
719
- }
720
- var default_correlator = ({ state: state2, action: action2 }) => {
721
- const s = state2.slice(0, SEG_WIDTH).toLowerCase();
722
- const a = action2.slice(0, SEG_WIDTH).toLowerCase();
723
- const ts = seg(Date.now() % SEG_SPACE);
724
- const rnd = seg(randomInt(SEG_SPACE));
725
- return `${s}-${a}-${ts}${rnd}`;
726
- };
727
- function close_correlation(correlator, actor) {
728
- return correlator({
729
- state: "$close",
730
- action: "close",
731
- stream: "$close",
732
- actor
733
- });
734
- }
735
-
736
- // src/internal/autoclose-cycle.ts
737
- function yield_between_batches(ms) {
738
- if (ms <= 0) return Promise.resolve();
739
- return new Promise((resolve) => setTimeout(resolve, ms));
740
- }
741
- async function run_autoclose_cycle(deps) {
742
- const s = store();
743
- let inspected = 0;
744
- let evaluated = 0;
745
- let predicate_errors = 0;
746
- const truncated = /* @__PURE__ */ new Map();
747
- const skipped = [];
748
- const page = deps.config.closeBatchSize;
749
- let after;
750
- for (; ; ) {
751
- const stats = await s.query_stats(
752
- {},
753
- { count: true, exclude: [TOMBSTONE_EVENT], after, limit: page }
754
- );
755
- if (stats.size === 0) break;
756
- inspected += stats.size;
757
- const candidates = [];
758
- for (const [stream, { head, count }] of stats) {
759
- const owner = deps.event_to_state.get(head.name);
760
- if (!owner) continue;
761
- const predicate = deps.autoclose_policy(owner.name);
762
- if (!predicate) continue;
763
- let close = false;
764
- try {
765
- close = predicate(stream, head, count ?? 0);
766
- evaluated += 1;
767
- } catch (err) {
768
- predicate_errors += 1;
769
- deps.logger.error(
770
- `Autoclose predicate for state "${owner.name}" threw on stream "${stream}": ${err instanceof Error ? err.message : String(err)}`
771
- );
772
- close = deps.config.closeOnError;
773
- }
774
- if (!close) continue;
775
- const archiver = deps.autoclose_archiver(owner.name);
776
- const archive = archiver ? () => archiver(stream, head) : void 0;
777
- candidates.push({ stream, archive });
778
- }
779
- if (candidates.length > 0) {
780
- const result = await run_close_cycle(candidates, {
781
- reactive_events_size: deps.reactive_events_size,
782
- event_to_state: deps.event_to_state,
783
- load: deps.load,
784
- tombstone: deps.tombstone,
785
- logger: deps.logger,
786
- correlation: deps.correlation
787
- });
788
- for (const [stream, entry] of result.truncated)
789
- truncated.set(stream, entry);
790
- for (const stream of result.skipped) skipped.push(stream);
791
- await yield_between_batches(deps.config.closeYieldMs);
792
- }
793
- if (stats.size < page) break;
794
- after = [...stats.keys()].at(-1);
795
- }
796
- return {
797
- inspected,
798
- evaluated,
799
- predicate_errors,
800
- close_result: { truncated, skipped }
801
- };
802
- }
803
- var AutocloseController = class {
804
- deps;
805
- _timer;
806
- /**
807
- * Reentrancy guard. The interval fires the next tick on cadence
808
- * regardless of how long the previous one took; this flag drops
809
- * overlapping ticks so the cycle stays sequential per controller.
810
- */
811
- _running = false;
812
- constructor(deps) {
813
- this.deps = deps;
814
- }
815
- /**
816
- * Start the cycle ticker. Returns `false` if already running so
817
- * accidental double-start doesn't stack timers (matches
818
- * `_correlate.start_polling` semantics).
819
- */
820
- start() {
821
- if (this._timer) return false;
822
- this._timer = setInterval(() => {
823
- this.run_once().catch(() => {
824
- });
825
- }, this.deps.config.autocloseCycleMinutes * 6e4);
826
- this._timer.unref();
827
- return true;
828
- }
829
- /**
830
- * Stop the cycle ticker. Idempotent.
831
- */
832
- stop() {
833
- if (this._timer) {
834
- clearInterval(this._timer);
835
- this._timer = void 0;
836
- }
837
- }
838
- /**
839
- * Whether the ticker is currently running.
840
- */
841
- get is_running() {
842
- return this._timer !== void 0;
843
- }
844
- /**
845
- * Run one cycle synchronously. The controller's interval callback
846
- * delegates here; tests can invoke it directly to deterministically
847
- * exercise the cycle without spinning real timers.
848
- *
849
- * Overlapping invocations short-circuit: if a previous tick is
850
- * still running, this one drops and returns `null`. The orchestrator
851
- * never sees a queue of pending ticks regardless of how slow the
852
- * predicate / archive / truncate are.
853
- */
854
- async run_once() {
855
- if (this._running) return null;
856
- if (this.deps.breaker.state(Date.now()) === "open") return null;
857
- if (!in_autoclose_window(this.deps.config.autocloseWindow, /* @__PURE__ */ new Date()))
858
- return null;
859
- this._running = true;
860
- try {
861
- const correlation = close_correlation(
862
- this.deps.correlator,
863
- this.deps.close_actor
864
- );
865
- const result = await run_autoclose_cycle({
866
- autoclose_policy: this.deps.autoclose_policy,
867
- autoclose_archiver: this.deps.autoclose_archiver,
868
- event_to_state: this.deps.event_to_state,
869
- reactive_events_size: this.deps.reactive_events_size,
870
- load: this.deps.load,
871
- tombstone: this.deps.tombstone,
872
- logger: this.deps.logger,
873
- config: this.deps.config,
874
- correlation
875
- });
876
- this.deps.breaker.passed();
877
- if (result.close_result.truncated.size > 0) {
878
- this.deps.on_closed(result.close_result);
879
- }
880
- return result;
881
- } catch (error) {
882
- this.deps.breaker.failed(Date.now(), error);
883
- throw error;
884
- } finally {
885
- this._running = false;
886
- }
887
- }
888
- };
889
-
890
543
  // src/internal/autoclose-policy.ts
891
544
  import { z as z2 } from "zod";
892
545
  var MIN_AFTER_MS = 6e4;
@@ -934,6 +587,13 @@ function compile_is(is) {
934
587
  function compile_reaches(reaches) {
935
588
  return (_stream, _head, count) => count >= reaches;
936
589
  }
590
+ function policy_min_after_ms(options) {
591
+ const parsed = AutoclosePolicySchema.parse(options);
592
+ const windows = [];
593
+ if (parsed.after) windows.push(parsed.after.days * 864e5);
594
+ if (parsed.or?.after) windows.push(parsed.or.after.days * 864e5);
595
+ return windows.length ? Math.min(...windows) : void 0;
596
+ }
937
597
  function compile_autoclose_policy(options) {
938
598
  const parsed = AutoclosePolicySchema.parse(options);
939
599
  const and_preds = [];
@@ -993,107 +653,304 @@ function classify_registry(registry, states) {
993
653
  }
994
654
  }
995
655
  }
996
- }
997
- }
998
- const event_to_state = /* @__PURE__ */ new Map();
999
- for (const merged of states.values()) {
1000
- for (const event_name of Object.keys(merged.events)) {
1001
- event_to_state.set(event_name, merged);
1002
- }
656
+ }
657
+ }
658
+ const event_to_state = /* @__PURE__ */ new Map();
659
+ for (const merged of states.values()) {
660
+ for (const event_name of Object.keys(merged.events)) {
661
+ event_to_state.set(event_name, merged);
662
+ }
663
+ }
664
+ return {
665
+ static_targets: [...statics.values()],
666
+ has_dynamic_resolvers,
667
+ reactive_events,
668
+ event_to_state,
669
+ event_to_lanes
670
+ };
671
+ }
672
+
673
+ // src/internal/circuit-breaker.ts
674
+ import { z as z3 } from "zod";
675
+ var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
676
+ var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
677
+ var CircuitBreakerOptionsSchema = z3.object({
678
+ failureThreshold: z3.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
679
+ cooldownMs: z3.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
680
+ });
681
+ var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
682
+ var CircuitBreaker = class {
683
+ _failures = 0;
684
+ _opened_at;
685
+ _wake;
686
+ _threshold;
687
+ _cooldown_ms;
688
+ _hooks;
689
+ constructor(config2, hooks = {}) {
690
+ this._threshold = config2.failureThreshold;
691
+ this._cooldown_ms = config2.cooldownMs;
692
+ this._hooks = hooks;
693
+ }
694
+ /** Current state given the wall-clock `now`. */
695
+ state(now) {
696
+ if (this._opened_at === void 0) return "closed";
697
+ return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
698
+ }
699
+ /** A store op passed — reset to closed and cancel any pending retry. */
700
+ passed() {
701
+ this._failures = 0;
702
+ this._opened_at = void 0;
703
+ this._clear_wake();
704
+ }
705
+ /**
706
+ * A store op failed. Opens the breaker when the consecutive-failure
707
+ * threshold is reached, or immediately re-opens (restarting the cooldown)
708
+ * if a half-open trial just failed. Returns the resulting state.
709
+ *
710
+ * Callers gate on the state directly — `state(now) === "open"` means skip.
711
+ * On opening, schedules the `on_retry` wake so recovery is automatic;
712
+ * always surfaces the failure via `on_error`.
713
+ */
714
+ failed(now, error) {
715
+ const circuit = this._advance(now);
716
+ if (circuit === "open") this._schedule_wake();
717
+ this._hooks.on_error?.(error, circuit);
718
+ return circuit;
719
+ }
720
+ /** Cancel the pending retry timer. Idempotent; call on shutdown. */
721
+ stop() {
722
+ this._clear_wake();
723
+ }
724
+ /**
725
+ * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
726
+ * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
727
+ * tests of the pure state machine). The timer is `unref`'d so it never
728
+ * keeps the process alive.
729
+ */
730
+ _schedule_wake() {
731
+ if (!this._hooks.on_retry) return;
732
+ this._clear_wake();
733
+ this._wake = setTimeout(() => {
734
+ this._wake = void 0;
735
+ this._hooks.on_retry?.();
736
+ }, this._cooldown_ms);
737
+ this._wake.unref();
738
+ }
739
+ _clear_wake() {
740
+ if (this._wake) {
741
+ clearTimeout(this._wake);
742
+ this._wake = void 0;
743
+ }
744
+ }
745
+ /** Pure state transition for a failure — no side effects. */
746
+ _advance(now) {
747
+ if (this._opened_at !== void 0) {
748
+ this._opened_at = now;
749
+ return "open";
750
+ }
751
+ this._failures += 1;
752
+ if (this._failures >= this._threshold) {
753
+ this._opened_at = now;
754
+ return "open";
755
+ }
756
+ return "closed";
757
+ }
758
+ };
759
+
760
+ // src/internal/close-cycle.ts
761
+ var SAFETY_PROBE_PAGE_SIZE = 1e3;
762
+ async function run_close_cycle(targets, deps) {
763
+ const target_map = new Map(targets.map((t) => [t.stream, t]));
764
+ const streams = [...target_map.keys()];
765
+ const skipped = [];
766
+ const stream_info = await scan_stream_heads(streams);
767
+ const safe = await partition_by_safety(
768
+ stream_info,
769
+ deps.reactive_events_size,
770
+ skipped,
771
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
772
+ );
773
+ if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
774
+ const { guarded, guard_events } = await guard_with_tombstones(
775
+ safe,
776
+ stream_info,
777
+ deps.correlation,
778
+ deps.tombstone,
779
+ skipped
780
+ );
781
+ if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
782
+ const seed_states = await load_restart_seeds(
783
+ guarded,
784
+ target_map,
785
+ stream_info,
786
+ deps.event_to_state,
787
+ deps.load,
788
+ deps.logger
789
+ );
790
+ await run_archive_callbacks(guarded, target_map);
791
+ const truncated = await truncate_and_warm_cache(
792
+ guarded,
793
+ seed_states,
794
+ guard_events,
795
+ deps.correlation
796
+ );
797
+ return { truncated, skipped };
798
+ }
799
+ async function scan_stream_heads(streams) {
800
+ const stats = await store().query_stats(streams, {
801
+ exclude: [SNAP_EVENT]
802
+ });
803
+ const out = /* @__PURE__ */ new Map();
804
+ for (const [stream, { head }] of stats) {
805
+ if (head.name === TOMBSTONE_EVENT) continue;
806
+ out.set(stream, {
807
+ max_id: head.id,
808
+ version: head.version,
809
+ last_event_name: head.name
810
+ });
811
+ }
812
+ return out;
813
+ }
814
+ async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
815
+ if (reactive_events_size === 0) return [...stream_info.keys()];
816
+ const pending_set = /* @__PURE__ */ new Set();
817
+ const source_regex = /* @__PURE__ */ new Map();
818
+ const get_regex = (source) => {
819
+ let re = source_regex.get(source);
820
+ if (!re) {
821
+ re = new RegExp(source);
822
+ source_regex.set(source, re);
823
+ }
824
+ return re;
825
+ };
826
+ const targets = [...stream_info.keys()];
827
+ let after;
828
+ for (; ; ) {
829
+ let last;
830
+ const { count } = await store().query_streams(
831
+ (position) => {
832
+ last = position.stream;
833
+ const source_re = position.source ? get_regex(position.source) : void 0;
834
+ for (const [stream, info] of stream_info) {
835
+ if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
836
+ pending_set.add(stream);
837
+ }
838
+ }
839
+ },
840
+ { after, limit: page_size, source_matches: targets }
841
+ );
842
+ if (count < page_size) break;
843
+ after = last;
844
+ }
845
+ const safe = [];
846
+ for (const [stream] of stream_info) {
847
+ if (pending_set.has(stream)) skipped.push(stream);
848
+ else safe.push(stream);
849
+ }
850
+ return safe;
851
+ }
852
+ async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
853
+ const guarded = [];
854
+ const guard_events = /* @__PURE__ */ new Map();
855
+ await Promise.all(
856
+ safe.map(async (stream) => {
857
+ const info = stream_info.get(stream);
858
+ const committed = await tombstone2(stream, info.version, correlation);
859
+ if (committed) {
860
+ guarded.push(stream);
861
+ guard_events.set(stream, { id: committed.id, stream });
862
+ } else {
863
+ skipped.push(stream);
864
+ }
865
+ })
866
+ );
867
+ return { guarded, guard_events };
868
+ }
869
+ async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
870
+ const seed_states = /* @__PURE__ */ new Map();
871
+ await Promise.all(
872
+ guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
873
+ const last_event_name = stream_info.get(stream).last_event_name;
874
+ const owner_state = event_to_state.get(last_event_name);
875
+ if (!owner_state) {
876
+ logger.error(
877
+ `Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
878
+ );
879
+ return;
880
+ }
881
+ const snap2 = await load2(owner_state, { stream });
882
+ seed_states.set(stream, snap2.state);
883
+ })
884
+ );
885
+ return seed_states;
886
+ }
887
+ async function run_archive_callbacks(guarded, target_map) {
888
+ for (const stream of guarded) {
889
+ const archive_fn = target_map.get(stream)?.archive;
890
+ if (archive_fn) await archive_fn();
1003
891
  }
1004
- return {
1005
- static_targets: [...statics.values()],
1006
- has_dynamic_resolvers,
1007
- reactive_events,
1008
- event_to_state,
1009
- event_to_lanes
1010
- };
892
+ }
893
+ async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
894
+ const trunc_targets = guarded.map((stream) => {
895
+ const snapshot = seed_states.get(stream);
896
+ const guard = guard_events.get(stream);
897
+ return {
898
+ stream,
899
+ snapshot,
900
+ meta: {
901
+ correlation,
902
+ causation: {
903
+ event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
904
+ }
905
+ }
906
+ };
907
+ });
908
+ const truncated = await store().truncate(trunc_targets);
909
+ await Promise.all(
910
+ guarded.map(async (stream) => {
911
+ const entry = truncated.get(stream);
912
+ const state2 = seed_states.get(stream);
913
+ if (state2 && entry) {
914
+ await cache().set(stream, {
915
+ state: state2,
916
+ version: entry.committed.version,
917
+ event_id: entry.committed.id,
918
+ patches: 0,
919
+ snaps: 1
920
+ });
921
+ } else {
922
+ await cache().invalidate(stream);
923
+ }
924
+ })
925
+ );
926
+ return truncated;
1011
927
  }
1012
928
 
1013
- // src/internal/circuit-breaker.ts
1014
- import { z as z3 } from "zod";
1015
- var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
1016
- var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
1017
- var CircuitBreakerOptionsSchema = z3.object({
1018
- failureThreshold: z3.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
1019
- cooldownMs: z3.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
1020
- });
1021
- var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
1022
- var CircuitBreaker = class {
1023
- _failures = 0;
1024
- _opened_at;
1025
- _wake;
1026
- _threshold;
1027
- _cooldown_ms;
1028
- _hooks;
1029
- constructor(config2, hooks = {}) {
1030
- this._threshold = config2.failureThreshold;
1031
- this._cooldown_ms = config2.cooldownMs;
1032
- this._hooks = hooks;
1033
- }
1034
- /** Current state given the wall-clock `now`. */
1035
- state(now) {
1036
- if (this._opened_at === void 0) return "closed";
1037
- return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
1038
- }
1039
- /** A store op passed — reset to closed and cancel any pending retry. */
1040
- passed() {
1041
- this._failures = 0;
1042
- this._opened_at = void 0;
1043
- this._clear_wake();
1044
- }
929
+ // src/internal/close-signal.ts
930
+ var CloseSignal = class extends Error {
1045
931
  /**
1046
- * A store op failed. Opens the breaker when the consecutive-failure
1047
- * threshold is reached, or immediately re-opens (restarting the cooldown)
1048
- * if a half-open trial just failed. Returns the resulting state.
1049
- *
1050
- * Callers gate on the state directly — `state(now) === "open"` means skip.
1051
- * On opening, schedules the `on_retry` wake so recovery is automatic;
1052
- * always surfaces the failure via `on_error`.
932
+ * Stream to close. Omitted → the reaction's own lease stream (a self-close).
933
+ * The synthesized autoclose reaction sets it to the aggregate stream, since
934
+ * its lease runs on a synthetic `__autoclose__` target.
1053
935
  */
1054
- failed(now, error) {
1055
- const circuit = this._advance(now);
1056
- if (circuit === "open") this._schedule_wake();
1057
- this._hooks.on_error?.(error, circuit);
1058
- return circuit;
1059
- }
1060
- /** Cancel the pending retry timer. Idempotent; call on shutdown. */
1061
- stop() {
1062
- this._clear_wake();
1063
- }
936
+ stream;
937
+ /** Optional archive callback to run while the stream is guarded. */
938
+ archive;
1064
939
  /**
1065
- * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
1066
- * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
1067
- * tests of the pure state machine). The timer is `unref`'d so it never
1068
- * keeps the process alive.
940
+ * Watermark to ack the requesting reaction to before the close runs. The
941
+ * close-cycle safety guard skips a stream whose subscriptions (matched by
942
+ * source) lag the head; the autoclose reaction's `source` is the aggregate,
943
+ * so it must advance its own watermark to the live head id it evaluated
944
+ * against — otherwise it blocks its own close. Defaults to the triggering
945
+ * event id when omitted.
1069
946
  */
1070
- _schedule_wake() {
1071
- if (!this._hooks.on_retry) return;
1072
- this._clear_wake();
1073
- this._wake = setTimeout(() => {
1074
- this._wake = void 0;
1075
- this._hooks.on_retry?.();
1076
- }, this._cooldown_ms);
1077
- this._wake.unref();
1078
- }
1079
- _clear_wake() {
1080
- if (this._wake) {
1081
- clearTimeout(this._wake);
1082
- this._wake = void 0;
1083
- }
1084
- }
1085
- /** Pure state transition for a failure — no side effects. */
1086
- _advance(now) {
1087
- if (this._opened_at !== void 0) {
1088
- this._opened_at = now;
1089
- return "open";
1090
- }
1091
- this._failures += 1;
1092
- if (this._failures >= this._threshold) {
1093
- this._opened_at = now;
1094
- return "open";
1095
- }
1096
- return "closed";
947
+ at;
948
+ constructor(opts) {
949
+ super("reaction requested close");
950
+ this.name = "CloseSignal";
951
+ this.stream = opts?.stream;
952
+ this.archive = opts?.archive;
953
+ this.at = opts?.at;
1097
954
  }
1098
955
  };
1099
956
 
@@ -1225,9 +1082,122 @@ var CorrelateCycle = class {
1225
1082
  }
1226
1083
  };
1227
1084
 
1085
+ // src/internal/correlator.ts
1086
+ import { randomInt } from "crypto";
1087
+ var BASE = 36;
1088
+ var SEG_WIDTH = 4;
1089
+ var SEG_SPACE = BASE ** SEG_WIDTH;
1090
+ function seg(n) {
1091
+ return n.toString(BASE).padStart(SEG_WIDTH, "0");
1092
+ }
1093
+ var default_correlator = ({ state: state2, action: action2 }) => {
1094
+ const s = state2.slice(0, SEG_WIDTH).toLowerCase();
1095
+ const a = action2.slice(0, SEG_WIDTH).toLowerCase();
1096
+ const ts = seg(Date.now() % SEG_SPACE);
1097
+ const rnd = seg(randomInt(SEG_SPACE));
1098
+ return `${s}-${a}-${ts}${rnd}`;
1099
+ };
1100
+ function close_correlation(correlator, actor) {
1101
+ return correlator({
1102
+ state: "$close",
1103
+ action: "close",
1104
+ stream: "$close",
1105
+ actor
1106
+ });
1107
+ }
1108
+
1109
+ // src/internal/defer-signal.ts
1110
+ var DeferSignal = class extends Error {
1111
+ /** Wall-clock time (ms since epoch) to re-visit the stream. */
1112
+ defer_at;
1113
+ constructor(defer_at) {
1114
+ super("reaction deferred");
1115
+ this.name = "DeferSignal";
1116
+ this.defer_at = defer_at;
1117
+ }
1118
+ };
1119
+
1228
1120
  // src/internal/drain-cycle.ts
1229
1121
  import { randomUUID } from "crypto";
1230
1122
 
1123
+ // src/internal/defer-timer.ts
1124
+ var MAX_TIMER_DELAY_MS = 2147483647;
1125
+ var DeferTimer = class {
1126
+ _due = /* @__PURE__ */ new Map();
1127
+ _timer;
1128
+ _on_wake;
1129
+ /**
1130
+ * @param on_wake - invoked once each time the earliest due-time elapses,
1131
+ * after the come-due entries have been removed. Consumers use it to
1132
+ * re-arm their loop (the drain sets its `armed` flag; autoclose runs a
1133
+ * tick).
1134
+ */
1135
+ constructor(on_wake) {
1136
+ this._on_wake = on_wake;
1137
+ }
1138
+ /** Number of currently parked streams. */
1139
+ get size() {
1140
+ return this._due.size;
1141
+ }
1142
+ /**
1143
+ * True while `stream` is parked with a due-time still in the future.
1144
+ * Consumers skip work for deferred streams until their window elapses.
1145
+ */
1146
+ is_deferred = (stream) => {
1147
+ const next = this._due.get(stream);
1148
+ return next !== void 0 && next > Date.now();
1149
+ };
1150
+ /**
1151
+ * Park `stream` for a re-visit at `at` (ms since epoch). A plain
1152
+ * overwrite: the caller computes the authoritative next-visit for the
1153
+ * stream (the drain's `handle` already reconciles a stream's reactions
1154
+ * into one result per cycle), so there is no stale value to merge against.
1155
+ */
1156
+ set(stream, at) {
1157
+ this._due.set(stream, at);
1158
+ }
1159
+ /** Drop `stream` from the parked set (e.g. on a successful ack or block). */
1160
+ delete(stream) {
1161
+ this._due.delete(stream);
1162
+ }
1163
+ /**
1164
+ * (Re)schedule the wake timer at the earliest pending due-time. Idempotent
1165
+ * — collapses many parked streams into a single timer. A no-op clears any
1166
+ * pending timer when the map is empty.
1167
+ *
1168
+ * The timer is `unref()`-ed so pending re-visits never keep the process
1169
+ * alive on their own.
1170
+ */
1171
+ schedule() {
1172
+ if (this._timer) clearTimeout(this._timer);
1173
+ if (this._due.size === 0) {
1174
+ this._timer = void 0;
1175
+ return;
1176
+ }
1177
+ let earliest = Number.POSITIVE_INFINITY;
1178
+ for (const t of this._due.values()) if (t < earliest) earliest = t;
1179
+ const delay = Math.min(
1180
+ Math.max(0, earliest - Date.now()),
1181
+ MAX_TIMER_DELAY_MS
1182
+ );
1183
+ this._timer = setTimeout(() => {
1184
+ this._timer = void 0;
1185
+ const now = Date.now();
1186
+ for (const [stream, at] of this._due)
1187
+ if (at <= now) this._due.delete(stream);
1188
+ this._on_wake();
1189
+ }, delay);
1190
+ this._timer.unref();
1191
+ }
1192
+ /** Cancel any pending wake timer. Idempotent. Leaves the parked set intact. */
1193
+ stop() {
1194
+ if (this._timer) {
1195
+ clearTimeout(this._timer);
1196
+ this._timer = void 0;
1197
+ }
1198
+ }
1199
+ };
1200
+
1231
1201
  // src/internal/drain-ratio.ts
1232
1202
  var RATIO_MIN = 0.2;
1233
1203
  var RATIO_MAX = 0.8;
@@ -1263,6 +1233,7 @@ async function fetch(leased, eventLimit) {
1263
1233
  }
1264
1234
  var ack = (leases) => store().ack(leases);
1265
1235
  var block = (leases) => store().block(leases);
1236
+ var defer = (input, deferred_at) => store().defer(input, deferred_at);
1266
1237
  var subscribe = (streams) => store().subscribe(streams);
1267
1238
 
1268
1239
  // src/internal/event-sourcing.ts
@@ -1740,6 +1711,7 @@ function build_drain(logger) {
1740
1711
  fetch,
1741
1712
  ack,
1742
1713
  block,
1714
+ defer,
1743
1715
  subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
1744
1716
  if (!result.subscribed) return;
1745
1717
  const lanes = new Set(streams.map((s) => s.lane ?? "default"));
@@ -1808,7 +1780,8 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
1808
1780
  fetched: [],
1809
1781
  handled: [],
1810
1782
  acked: [],
1811
- blocked: []
1783
+ blocked: [],
1784
+ closeable: []
1812
1785
  };
1813
1786
  }
1814
1787
  const fetched = await ops.fetch(active, eventLimit);
@@ -1842,12 +1815,23 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
1842
1815
  })
1843
1816
  );
1844
1817
  const acked = await ops.ack(
1845
- handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
1818
+ handled.filter((h) => h.defer === void 0 && (h.handled > 0 || !h.error)).map((h) => ({ ...h.lease, at: h.acked_at }))
1846
1819
  );
1847
1820
  const blocked = await ops.block(
1848
1821
  handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1849
1822
  );
1850
- return { leased, fetched, handled, acked, blocked };
1823
+ const deferrals = handled.filter((h) => h.defer !== void 0);
1824
+ if (deferrals.length) {
1825
+ const by_time = /* @__PURE__ */ new Map();
1826
+ for (const h of deferrals) {
1827
+ const list = by_time.get(h.defer);
1828
+ if (list) list.push(h.lease.stream);
1829
+ else by_time.set(h.defer, [h.lease.stream]);
1830
+ }
1831
+ for (const [at, streams] of by_time) await ops.defer(streams, at);
1832
+ }
1833
+ const closeable = handled.filter((h) => h.close !== void 0).map((h) => h.close);
1834
+ return { leased, fetched, handled, acked, blocked, closeable };
1851
1835
  }
1852
1836
  var EMPTY_DRAIN = {
1853
1837
  fetched: [],
@@ -1860,14 +1844,16 @@ var DrainController = class {
1860
1844
  _locked = false;
1861
1845
  _ratio = 0.5;
1862
1846
  /**
1863
- * Per-stream backoff: `stream → next_attempt_at` (ms since epoch). Set by
1864
- * `_finalize` via `HandleResult.next_attempt_at`; cleared on successful
1865
- * ack or terminal block. Lives in process memory — per-worker pacing
1866
- * by design (see {@link BackoffOptions} for the multi-worker trade-off).
1847
+ * Per-stream re-visit schedule (#1090): `stream → next visit` (ms since
1848
+ * epoch). Holds both retry backoff (`HandleResult.next_attempt_at`) and the
1849
+ * `defer` outcome; cleared on successful ack or terminal block. Lives in
1850
+ * process memory — per-worker pacing by design (see {@link BackoffOptions}
1851
+ * for the multi-worker trade-off). Its wake re-arms drain at the earliest
1852
+ * pending visit.
1867
1853
  */
1868
- _backoff = /* @__PURE__ */ new Map();
1869
- /** Timer re-arming drain at the earliest pending `next_attempt_at`. */
1870
- _backoff_timer;
1854
+ _defer = new DeferTimer(() => {
1855
+ this._armed = true;
1856
+ });
1871
1857
  /** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
1872
1858
  _worker;
1873
1859
  _stopped = false;
@@ -1887,32 +1873,6 @@ var DrainController = class {
1887
1873
  get armed() {
1888
1874
  return this._armed;
1889
1875
  }
1890
- /** Returns true when `stream` is currently within a backoff window. */
1891
- is_deferred = (stream) => {
1892
- const next = this._backoff.get(stream);
1893
- return next !== void 0 && next > Date.now();
1894
- };
1895
- /**
1896
- * Schedule the next drain re-arm at the earliest pending backoff
1897
- * expiry. Called only when the backoff map is non-empty (caller guard).
1898
- * Idempotent — collapses many simultaneously deferred streams into a
1899
- * single timer.
1900
- */
1901
- schedule_backoff_wake() {
1902
- if (this._backoff_timer) clearTimeout(this._backoff_timer);
1903
- let earliest = Number.POSITIVE_INFINITY;
1904
- for (const t of this._backoff.values()) if (t < earliest) earliest = t;
1905
- const delay = Math.max(0, earliest - Date.now());
1906
- this._backoff_timer = setTimeout(() => {
1907
- this._backoff_timer = void 0;
1908
- const now = Date.now();
1909
- for (const [stream, at] of this._backoff) {
1910
- if (at <= now) this._backoff.delete(stream);
1911
- }
1912
- this._armed = true;
1913
- }, delay);
1914
- this._backoff_timer.unref();
1915
- }
1916
1876
  /** Lane this controller drains (undefined = legacy single-lane span). */
1917
1877
  get lane() {
1918
1878
  return this._deps.lane;
@@ -1944,6 +1904,7 @@ var DrainController = class {
1944
1904
  clearTimeout(this._worker);
1945
1905
  this._worker = void 0;
1946
1906
  }
1907
+ this._defer.stop();
1947
1908
  }
1948
1909
  /** Run one drain pass. Short-circuits when not armed or already running. */
1949
1910
  async drain(options = {}) {
@@ -1969,7 +1930,7 @@ var DrainController = class {
1969
1930
  leading,
1970
1931
  eventLimit,
1971
1932
  leaseMillis,
1972
- this._backoff.size > 0 ? this.is_deferred : void 0,
1933
+ this._defer.size > 0 ? this._defer.is_deferred : void 0,
1973
1934
  this._deps.lane
1974
1935
  );
1975
1936
  this._deps.breaker.passed();
@@ -1977,19 +1938,19 @@ var DrainController = class {
1977
1938
  this._armed = false;
1978
1939
  return EMPTY_DRAIN;
1979
1940
  }
1980
- const { leased, fetched, handled, acked, blocked } = cycle;
1941
+ const { leased, fetched, handled, acked, blocked, closeable } = cycle;
1981
1942
  trace_cycle(this._deps.logger, leased, fetched, handled, acked, blocked);
1982
1943
  this._ratio = compute_lag_lead_ratio(handled, lagging, leading);
1983
- for (const lease of acked) this._backoff.delete(lease.stream);
1984
- for (const lease of blocked) this._backoff.delete(lease.stream);
1944
+ for (const lease of acked) this._defer.delete(lease.stream);
1945
+ for (const lease of blocked) this._defer.delete(lease.stream);
1985
1946
  for (const h of handled) {
1986
- if (h.next_attempt_at !== void 0 && !h.block) {
1987
- this._backoff.set(h.lease.stream, h.next_attempt_at);
1988
- }
1947
+ const next = h.defer ?? (h.block ? void 0 : h.next_attempt_at);
1948
+ if (next !== void 0) this._defer.set(h.lease.stream, next);
1989
1949
  }
1990
- if (this._backoff.size > 0) this.schedule_backoff_wake();
1950
+ if (this._defer.size > 0) this._defer.schedule();
1991
1951
  if (acked.length) this._deps.on_acked(acked);
1992
1952
  if (blocked.length) this._deps.on_blocked(blocked);
1953
+ if (closeable.length) await this._deps.on_close(closeable);
1993
1954
  const has_errors = handled.some(({ error }) => error);
1994
1955
  if (!acked.length && !blocked.length && !has_errors) this._armed = false;
1995
1956
  return { fetched, leased, acked, blocked };
@@ -2205,6 +2166,20 @@ function build_handle(deps) {
2205
2166
  at = event.id;
2206
2167
  handled++;
2207
2168
  } catch (error) {
2169
+ if (error instanceof DeferSignal)
2170
+ return { lease, handled, acked_at: at, defer: error.defer_at };
2171
+ if (error instanceof CloseSignal)
2172
+ return {
2173
+ lease,
2174
+ handled: handled + 1,
2175
+ // Advance to the live head the handler evaluated against (when
2176
+ // provided) so the close-cycle guard sees this reaction caught up.
2177
+ acked_at: error.at ?? event.id,
2178
+ // Close the signalled stream (the autoclose reaction's aggregate,
2179
+ // which differs from its synthetic lease stream); a self-closing
2180
+ // user reaction omits it and closes its own lease stream.
2181
+ close: { stream: error.stream ?? stream, archive: error.archive }
2182
+ };
2208
2183
  return finalize(
2209
2184
  lease,
2210
2185
  handled,
@@ -2307,6 +2282,7 @@ var SettleLoop = class {
2307
2282
  // src/act.ts
2308
2283
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2309
2284
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
2285
+ var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
2310
2286
  var Act = class {
2311
2287
  _emitter = new EventEmitter();
2312
2288
  /** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
@@ -2348,15 +2324,6 @@ var Act = class {
2348
2324
  * @internal
2349
2325
  */
2350
2326
  _autoclose_config;
2351
- /**
2352
- * App-level autoclose controller. `undefined` when no state declares
2353
- * `.autocloses(...)` — the controller is never constructed, so
2354
- * `start_correlations()` / `stop_correlations()` skip the autoclose
2355
- * lifecycle and the orchestrator pays zero per-tick cost.
2356
- *
2357
- * @internal
2358
- */
2359
- _autoclose;
2360
2327
  /**
2361
2328
  * Emit a lifecycle event. The payload type is inferred from the event name
2362
2329
  * via {@link ActLifecycleEvents}.
@@ -2497,6 +2464,60 @@ var Act = class {
2497
2464
  bound_forget: this._bound_forget
2498
2465
  });
2499
2466
  this._handle_batch = build_handle_batch(this._logger);
2467
+ for (const st of this._states.values()) {
2468
+ const predicate = st.autoclose;
2469
+ if (!predicate) continue;
2470
+ const after_ms = st.autoclose_after_ms;
2471
+ const archiver = st.archive;
2472
+ const reaction = {
2473
+ // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
2474
+ // per-aggregate `__autoclose__` key — so the autoclose reaction never
2475
+ // shares a watermark with the aggregate's own reactions. A shared
2476
+ // watermark would let autoclose's defer short-circuit the aggregate's
2477
+ // other reactions (the "a defer affects all reactions on a stream"
2478
+ // hazard). The close still targets the aggregate (`source`).
2479
+ resolver: (e) => ({
2480
+ target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
2481
+ source: e.stream
2482
+ }),
2483
+ // Never block on autoclose: a transient query/store error should retry,
2484
+ // not quarantine the synthetic stream.
2485
+ options: { blockOnError: false, maxRetries: 3 },
2486
+ handler: async (event) => {
2487
+ const aggregate = event.stream;
2488
+ const config2 = this._autoclose_config;
2489
+ if (!in_autoclose_window(config2.autocloseWindow, /* @__PURE__ */ new Date()))
2490
+ throw new DeferSignal(
2491
+ Date.now() + config2.autocloseCycleMinutes * 6e4
2492
+ );
2493
+ const stats = await store().query_stats([aggregate], {
2494
+ count: true,
2495
+ exclude: [TOMBSTONE_EVENT]
2496
+ });
2497
+ const entry = stats.get(aggregate);
2498
+ if (!entry) return;
2499
+ const head = entry.head;
2500
+ if (predicate(aggregate, head, entry.count))
2501
+ throw new CloseSignal({
2502
+ stream: aggregate,
2503
+ archive: archiver ? () => archiver(aggregate, head) : void 0,
2504
+ // Ack this reaction's own watermark to the live head so the
2505
+ // close-cycle guard (which matches subscriptions by source =
2506
+ // aggregate) sees it caught up instead of blocking its own close.
2507
+ at: head.id
2508
+ });
2509
+ if (after_ms !== void 0)
2510
+ throw new DeferSignal(head.created.getTime() + after_ms);
2511
+ }
2512
+ };
2513
+ const key = `__autoclose_${st.name}`;
2514
+ for (const event_name of Object.keys(st.events)) {
2515
+ this.registry.events[event_name]?.reactions.set(
2516
+ key,
2517
+ reaction
2518
+ );
2519
+ }
2520
+ }
2500
2521
  const {
2501
2522
  static_targets,
2502
2523
  has_dynamic_resolvers,
@@ -2545,6 +2566,22 @@ var Act = class {
2545
2566
  handle_batch: this._handle_batch,
2546
2567
  on_acked: (acked) => this.emit("acked", acked),
2547
2568
  on_blocked: (blocked) => this.emit("blocked", blocked),
2569
+ // Reaction-requested close (#1090). Runs the same close machinery as
2570
+ // `app.close` (tombstone guard + archive + atomic truncate) for the
2571
+ // targets a handler signalled via `CloseSignal`. No `correlate()` here
2572
+ // — the drain that produced these targets has already correlated.
2573
+ on_close: async (targets) => {
2574
+ const close_actor = { id: "$close", name: "close" };
2575
+ const result = await run_close_cycle(targets, {
2576
+ reactive_events_size: this._reactive_events.size,
2577
+ event_to_state: this._event_to_state,
2578
+ load: this._es.load,
2579
+ tombstone: this._es.tombstone,
2580
+ logger: this._logger,
2581
+ correlation: close_correlation(this._correlator, close_actor)
2582
+ });
2583
+ this.emit("closed", result);
2584
+ },
2548
2585
  breaker: this._breaker,
2549
2586
  // Pass lane only when a true per-lane controller is active.
2550
2587
  // The all-lanes (single default) case keeps lane=undefined so
@@ -2591,27 +2628,6 @@ var Act = class {
2591
2628
  },
2592
2629
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
2593
2630
  );
2594
- const states_with_policy = [...this._states.values()].filter(
2595
- (s) => s.autoclose
2596
- );
2597
- this._autoclose = states_with_policy.length ? new AutocloseController({
2598
- // Internal cycle takes the loose `(string) => …` signature; the
2599
- // public `registry.autoclose_policy` narrows to
2600
- // `keyof TStateMap & string`. Thin lambdas bridge across the
2601
- // narrowing — runtime semantics are unchanged.
2602
- autoclose_policy: (name) => this.registry.autoclose_policy(name),
2603
- autoclose_archiver: (name) => this.registry.autoclose_archiver(name),
2604
- event_to_state: this._event_to_state,
2605
- reactive_events_size: this._reactive_events.size,
2606
- load: this._es.load,
2607
- tombstone: this._es.tombstone,
2608
- logger: this._logger,
2609
- config: this._autoclose_config,
2610
- correlator: this._correlator,
2611
- close_actor: { id: "$autoclose", name: "autoclose" },
2612
- on_closed: (result) => this.emit("closed", result),
2613
- breaker: this._breaker
2614
- }) : void 0;
2615
2631
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store());
2616
2632
  dispose(() => this.shutdown());
2617
2633
  }
@@ -3105,7 +3121,6 @@ var Act = class {
3105
3121
  */
3106
3122
  start_correlations(query = {}, frequency = 1e4, callback) {
3107
3123
  const started = this._correlate.start_polling(query, frequency, callback);
3108
- this._autoclose?.start();
3109
3124
  return started;
3110
3125
  }
3111
3126
  /**
@@ -3127,7 +3142,6 @@ var Act = class {
3127
3142
  */
3128
3143
  stop_correlations() {
3129
3144
  this._correlate.stop_polling();
3130
- this._autoclose?.stop();
3131
3145
  }
3132
3146
  /**
3133
3147
  * Cancels any pending or active settle cycle.
@@ -3930,20 +3944,21 @@ function action_builder(state2) {
3930
3944
  internal.disclose = disclose;
3931
3945
  return builder;
3932
3946
  },
3933
- autocloses(predicate_or_policy) {
3934
- let predicate;
3935
- if (typeof predicate_or_policy === "function") {
3936
- predicate = predicate_or_policy;
3937
- } else if (predicate_or_policy !== null && typeof predicate_or_policy === "object") {
3938
- predicate = compile_autoclose_policy(
3939
- predicate_or_policy
3947
+ autocloses(policy) {
3948
+ if (typeof policy === "function") {
3949
+ throw new Error(
3950
+ ".autocloses(fn) is no longer supported \u2014 pass a declarative policy ({ after, is, reaches, or }) or call app.close(...) from your own reaction for custom logic."
3940
3951
  );
3941
- } else {
3952
+ }
3953
+ if (policy === null || typeof policy !== "object") {
3942
3954
  throw new Error(
3943
- ".autocloses(...) requires a function or a policy object; got " + typeof predicate_or_policy
3955
+ ".autocloses(...) requires a policy object; got " + typeof policy
3944
3956
  );
3945
3957
  }
3946
- internal.autoclose = predicate;
3958
+ internal.autoclose = compile_autoclose_policy(
3959
+ policy
3960
+ );
3961
+ internal.autoclose_after_ms = policy_min_after_ms(policy);
3947
3962
  return builder;
3948
3963
  },
3949
3964
  archives(archive) {