@rotorsoft/act 1.16.1 → 1.18.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 (51) 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/act-builder.d.ts +3 -6
  7. package/dist/@types/builders/act-builder.d.ts.map +1 -1
  8. package/dist/@types/builders/slice-builder.d.ts +16 -8
  9. package/dist/@types/builders/slice-builder.d.ts.map +1 -1
  10. package/dist/@types/builders/state-builder.d.ts +19 -26
  11. package/dist/@types/builders/state-builder.d.ts.map +1 -1
  12. package/dist/@types/index.d.ts +1 -0
  13. package/dist/@types/index.d.ts.map +1 -1
  14. package/dist/@types/internal/autoclose-policy.d.ts +16 -0
  15. package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
  16. package/dist/@types/internal/builder-utils.d.ts +65 -0
  17. package/dist/@types/internal/builder-utils.d.ts.map +1 -0
  18. package/dist/@types/internal/close-signal.d.ts +51 -0
  19. package/dist/@types/internal/close-signal.d.ts.map +1 -0
  20. package/dist/@types/internal/defer-config.d.ts +64 -0
  21. package/dist/@types/internal/defer-config.d.ts.map +1 -0
  22. package/dist/@types/internal/defer-signal.d.ts +35 -0
  23. package/dist/@types/internal/defer-signal.d.ts.map +1 -0
  24. package/dist/@types/internal/defer-timer.d.ts +67 -0
  25. package/dist/@types/internal/defer-timer.d.ts.map +1 -0
  26. package/dist/@types/internal/drain-cycle.d.ts +39 -17
  27. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  28. package/dist/@types/internal/drain.d.ts +3 -1
  29. package/dist/@types/internal/drain.d.ts.map +1 -1
  30. package/dist/@types/internal/index.d.ts +7 -5
  31. package/dist/@types/internal/index.d.ts.map +1 -1
  32. package/dist/@types/internal/reactions.d.ts.map +1 -1
  33. package/dist/@types/internal/tracing.d.ts.map +1 -1
  34. package/dist/@types/types/action.d.ts +36 -0
  35. package/dist/@types/types/action.d.ts.map +1 -1
  36. package/dist/@types/types/ports.d.ts +31 -0
  37. package/dist/@types/types/ports.d.ts.map +1 -1
  38. package/dist/{chunk-46OCUAVF.js → chunk-7XME4ZLK.js} +52 -2
  39. package/dist/chunk-7XME4ZLK.js.map +1 -0
  40. package/dist/index.cjs +852 -754
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.js +728 -681
  43. package/dist/index.js.map +1 -1
  44. package/dist/test/index.cjs +55 -5
  45. package/dist/test/index.cjs.map +1 -1
  46. package/dist/test/index.js +5 -5
  47. package/dist/test/index.js.map +1 -1
  48. package/package.json +2 -2
  49. package/dist/@types/internal/autoclose-cycle.d.ts +0 -185
  50. package/dist/@types/internal/autoclose-cycle.d.ts.map +0 -1
  51. 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 = [];
@@ -995,28 +655,273 @@ function classify_registry(registry, states) {
995
655
  }
996
656
  }
997
657
  }
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
- }
1003
- }
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/defer-config.ts
674
+ import { z as z3 } from "zod";
675
+
676
+ // src/internal/defer-signal.ts
677
+ var DeferSignal = class extends Error {
678
+ /**
679
+ * The unresolved schedule. The dispatcher turns this into an absolute
680
+ * due-time by resolving it against the triggering event.
681
+ */
682
+ when;
683
+ constructor(when) {
684
+ super("reaction deferred");
685
+ this.name = "DeferSignal";
686
+ this.when = when;
687
+ }
688
+ };
689
+
690
+ // src/internal/defer-config.ts
691
+ var DeferDurationSchema = z3.object({
692
+ days: z3.number().positive().optional(),
693
+ hours: z3.number().positive().optional(),
694
+ minutes: z3.number().positive().optional()
695
+ }).strict().refine((d) => d.days != null || d.hours != null || d.minutes != null, {
696
+ message: "defer: a duration needs at least one of days, hours, or minutes"
697
+ });
698
+ var DeferWhenSchema = z3.object({
699
+ after: DeferDurationSchema.optional(),
700
+ at: z3.date().optional()
701
+ }).strict().refine((w) => w.after === void 0 !== (w.at === void 0), {
702
+ message: "defer: specify exactly one of `after` or `at`"
703
+ });
704
+ function duration_ms(d) {
705
+ return (d.days ?? 0) * 864e5 + (d.hours ?? 0) * 36e5 + (d.minutes ?? 0) * 6e4;
706
+ }
707
+ function resolve_defer_at(when, event) {
708
+ const parsed = DeferWhenSchema.parse(when);
709
+ if (parsed.after) return event.created.getTime() + duration_ms(parsed.after);
710
+ return parsed.at.getTime();
711
+ }
712
+ function assert_defer_when(when) {
713
+ DeferWhenSchema.parse(when);
714
+ }
715
+ function make_deferred(handler, schedule) {
716
+ const call = handler;
717
+ const deferred = async (event, stream, app) => {
718
+ const when = typeof schedule === "function" ? schedule(event) : schedule;
719
+ if (Date.now() < resolve_defer_at(when, event)) throw new DeferSignal(when);
720
+ return call(event, stream, app);
721
+ };
722
+ Object.defineProperty(deferred, "name", { value: handler.name });
723
+ return deferred;
724
+ }
725
+
726
+ // src/internal/merge.ts
727
+ import { ZodObject } from "zod";
728
+ function base_type_name(zodType) {
729
+ let t = zodType;
730
+ while (typeof t.unwrap === "function") {
731
+ t = t.unwrap();
732
+ }
733
+ return t.constructor.name;
734
+ }
735
+ function merge_schemas(existing, incoming, state_name) {
736
+ if (existing instanceof ZodObject && incoming instanceof ZodObject) {
737
+ const existing_shape = existing.shape;
738
+ const incoming_shape = incoming.shape;
739
+ for (const key of Object.keys(incoming_shape)) {
740
+ if (key in existing_shape) {
741
+ const existing_base = base_type_name(existing_shape[key]);
742
+ const incoming_base = base_type_name(incoming_shape[key]);
743
+ if (existing_base !== incoming_base) {
744
+ throw new Error(
745
+ `Schema conflict in "${state_name}": key "${key}" has type "${existing_base}" but incoming partial declares "${incoming_base}"`
746
+ );
747
+ }
748
+ }
749
+ }
750
+ return existing.extend(incoming_shape);
751
+ }
752
+ return existing;
753
+ }
754
+ function merge_inits(existing, incoming) {
755
+ return () => ({ ...existing(), ...incoming() });
756
+ }
757
+ function register_state(state2, states, actions, events) {
758
+ const existing = states.get(state2.name);
759
+ if (existing) {
760
+ merge_into_existing(state2, existing, states, actions, events);
761
+ } else {
762
+ register_new_state(state2, states, actions, events);
763
+ }
764
+ }
765
+ function register_new_state(state2, states, actions, events) {
766
+ states.set(state2.name, state2);
767
+ for (const name of Object.keys(state2.actions)) {
768
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
769
+ actions[name] = state2;
770
+ }
771
+ for (const name of Object.keys(state2.events)) {
772
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
773
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
774
+ }
775
+ }
776
+ function merge_into_existing(state2, existing, states, actions, events) {
777
+ for (const name of Object.keys(state2.actions)) {
778
+ if (existing.actions[name] === state2.actions[name]) continue;
779
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
780
+ }
781
+ for (const name of Object.keys(state2.events)) {
782
+ if (existing.events[name] === state2.events[name]) continue;
783
+ if (existing.events[name]) {
784
+ throw new Error(
785
+ `Event "${name}" in state "${state2.name}" is declared with different Zod schemas across slices. Cross-slice event schemas must reference the same instance \u2014 extract a shared schema (e.g. \`export const ${name} = z.object({ ... })\` in a shared module) and import it in every slice that declares it.`
786
+ );
787
+ }
788
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
789
+ }
790
+ const merged_patch = merge_patches(existing.patch, state2.patch, state2.name);
791
+ const merged = {
792
+ ...existing,
793
+ state: merge_schemas(existing.state, state2.state, state2.name),
794
+ init: merge_inits(existing.init, state2.init),
795
+ events: { ...existing.events, ...state2.events },
796
+ actions: { ...existing.actions, ...state2.actions },
797
+ patch: merged_patch,
798
+ on: { ...existing.on, ...state2.on },
799
+ given: { ...existing.given, ...state2.given },
800
+ snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
801
+ throw new Error(
802
+ `Duplicate snap strategy for state "${state2.name}"`
803
+ );
804
+ })() : state2.snap || existing.snap
805
+ };
806
+ states.set(state2.name, merged);
807
+ for (const name of Object.keys(merged.actions)) {
808
+ actions[name] = merged;
809
+ }
810
+ for (const name of Object.keys(state2.events)) {
811
+ if (events[name]) continue;
812
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
813
+ }
814
+ }
815
+ function merge_patches(existing, incoming, state_name) {
816
+ const merged = { ...existing };
817
+ for (const name of Object.keys(incoming)) {
818
+ const existing_p = existing[name];
819
+ const incoming_p = incoming[name];
820
+ if (!existing_p) {
821
+ merged[name] = incoming_p;
822
+ continue;
823
+ }
824
+ const existing_is_default = existing_p._passthrough;
825
+ const incoming_is_default = incoming_p._passthrough;
826
+ if (!existing_is_default && !incoming_is_default && existing_p !== incoming_p) {
827
+ throw new Error(
828
+ `Duplicate custom patch for event "${name}" in state "${state_name}"`
829
+ );
830
+ }
831
+ if (existing_is_default && !incoming_is_default) {
832
+ merged[name] = incoming_p;
833
+ }
834
+ }
835
+ return merged;
836
+ }
837
+ function merge_event_register(target, source) {
838
+ for (const [event_name, source_reg] of Object.entries(source)) {
839
+ const target_reg = target[event_name];
840
+ if (!target_reg) continue;
841
+ for (const [name, reaction] of source_reg.reactions) {
842
+ const existing = target_reg.reactions.get(name);
843
+ if (existing !== void 0 && existing !== reaction)
844
+ throw new Error(
845
+ `Duplicate reaction "${name}" for event "${event_name}". Reaction handlers are keyed by function name; rename one of them.`
846
+ );
847
+ target_reg.reactions.set(name, reaction);
848
+ }
849
+ }
850
+ }
851
+ function merge_projection(proj, events) {
852
+ for (const event_name of Object.keys(proj.events)) {
853
+ const proj_register = proj.events[event_name];
854
+ const existing = events[event_name];
855
+ if (!existing) {
856
+ events[event_name] = {
857
+ schema: proj_register.schema,
858
+ reactions: new Map(proj_register.reactions)
859
+ };
860
+ } else {
861
+ for (const [name, reaction] of proj_register.reactions) {
862
+ let key = name;
863
+ while (existing.reactions.has(key)) key = `${key}_p`;
864
+ existing.reactions.set(key, reaction);
865
+ }
866
+ }
867
+ }
868
+ }
869
+ var _this_ = ({ stream }) => ({
870
+ source: stream,
871
+ target: stream
872
+ });
873
+
874
+ // src/internal/builder-utils.ts
875
+ function register_lane(config2, lanes) {
876
+ if (config2.name === DEFAULT_LANE)
877
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
878
+ if (lanes.some((l) => l.name === config2.name))
879
+ throw new Error(`Lane "${config2.name}" was already declared`);
880
+ lanes.push(config2);
881
+ }
882
+ function reaction_on(event, events, builder) {
883
+ const register = (handler, options, schedule) => {
884
+ if (!handler.name)
885
+ throw new Error(
886
+ `Reaction handler for "${String(event)}" must be a named function`
887
+ );
888
+ if (events[event].reactions.has(handler.name))
889
+ throw new Error(
890
+ `Duplicate reaction "${handler.name}" for event "${String(event)}". Reaction handlers are keyed by function name; rename one of them.`
891
+ );
892
+ if (schedule && typeof schedule !== "function") assert_defer_when(schedule);
893
+ const reaction = {
894
+ handler: schedule ? make_deferred(handler, schedule) : handler,
895
+ resolver: _this_,
896
+ options: {
897
+ blockOnError: options?.blockOnError ?? true,
898
+ maxRetries: options?.maxRetries ?? 3,
899
+ backoff: options?.backoff
900
+ }
901
+ };
902
+ events[event].reactions.set(handler.name, reaction);
903
+ return Object.assign(builder, {
904
+ to(resolver) {
905
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
906
+ return builder;
907
+ }
908
+ });
909
+ };
1004
910
  return {
1005
- static_targets: [...statics.values()],
1006
- has_dynamic_resolvers,
1007
- reactive_events,
1008
- event_to_state,
1009
- event_to_lanes
911
+ do: (handler, options) => register(handler, options),
912
+ defer: (schedule) => ({
913
+ do: (handler, options) => register(handler, options, schedule)
914
+ })
1010
915
  };
1011
916
  }
1012
917
 
1013
918
  // src/internal/circuit-breaker.ts
1014
- import { z as z3 } from "zod";
919
+ import { z as z4 } from "zod";
1015
920
  var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
1016
921
  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)
922
+ var CircuitBreakerOptionsSchema = z4.object({
923
+ failureThreshold: z4.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
924
+ cooldownMs: z4.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
1020
925
  });
1021
926
  var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
1022
927
  var CircuitBreaker = class {
@@ -1097,6 +1002,203 @@ var CircuitBreaker = class {
1097
1002
  }
1098
1003
  };
1099
1004
 
1005
+ // src/internal/close-cycle.ts
1006
+ var SAFETY_PROBE_PAGE_SIZE = 1e3;
1007
+ async function run_close_cycle(targets, deps) {
1008
+ const target_map = new Map(targets.map((t) => [t.stream, t]));
1009
+ const streams = [...target_map.keys()];
1010
+ const skipped = [];
1011
+ const stream_info = await scan_stream_heads(streams);
1012
+ const safe = await partition_by_safety(
1013
+ stream_info,
1014
+ deps.reactive_events_size,
1015
+ skipped,
1016
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
1017
+ );
1018
+ if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
1019
+ const { guarded, guard_events } = await guard_with_tombstones(
1020
+ safe,
1021
+ stream_info,
1022
+ deps.correlation,
1023
+ deps.tombstone,
1024
+ skipped
1025
+ );
1026
+ if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
1027
+ const seed_states = await load_restart_seeds(
1028
+ guarded,
1029
+ target_map,
1030
+ stream_info,
1031
+ deps.event_to_state,
1032
+ deps.load,
1033
+ deps.logger
1034
+ );
1035
+ await run_archive_callbacks(guarded, target_map);
1036
+ const truncated = await truncate_and_warm_cache(
1037
+ guarded,
1038
+ seed_states,
1039
+ guard_events,
1040
+ deps.correlation
1041
+ );
1042
+ return { truncated, skipped };
1043
+ }
1044
+ async function scan_stream_heads(streams) {
1045
+ const stats = await store().query_stats(streams, {
1046
+ exclude: [SNAP_EVENT]
1047
+ });
1048
+ const out = /* @__PURE__ */ new Map();
1049
+ for (const [stream, { head }] of stats) {
1050
+ if (head.name === TOMBSTONE_EVENT) continue;
1051
+ out.set(stream, {
1052
+ max_id: head.id,
1053
+ version: head.version,
1054
+ last_event_name: head.name
1055
+ });
1056
+ }
1057
+ return out;
1058
+ }
1059
+ async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
1060
+ if (reactive_events_size === 0) return [...stream_info.keys()];
1061
+ const pending_set = /* @__PURE__ */ new Set();
1062
+ const source_regex = /* @__PURE__ */ new Map();
1063
+ const get_regex = (source) => {
1064
+ let re = source_regex.get(source);
1065
+ if (!re) {
1066
+ re = new RegExp(source);
1067
+ source_regex.set(source, re);
1068
+ }
1069
+ return re;
1070
+ };
1071
+ const targets = [...stream_info.keys()];
1072
+ let after;
1073
+ for (; ; ) {
1074
+ let last;
1075
+ const { count } = await store().query_streams(
1076
+ (position) => {
1077
+ last = position.stream;
1078
+ const source_re = position.source ? get_regex(position.source) : void 0;
1079
+ for (const [stream, info] of stream_info) {
1080
+ if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
1081
+ pending_set.add(stream);
1082
+ }
1083
+ }
1084
+ },
1085
+ { after, limit: page_size, source_matches: targets }
1086
+ );
1087
+ if (count < page_size) break;
1088
+ after = last;
1089
+ }
1090
+ const safe = [];
1091
+ for (const [stream] of stream_info) {
1092
+ if (pending_set.has(stream)) skipped.push(stream);
1093
+ else safe.push(stream);
1094
+ }
1095
+ return safe;
1096
+ }
1097
+ async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
1098
+ const guarded = [];
1099
+ const guard_events = /* @__PURE__ */ new Map();
1100
+ await Promise.all(
1101
+ safe.map(async (stream) => {
1102
+ const info = stream_info.get(stream);
1103
+ const committed = await tombstone2(stream, info.version, correlation);
1104
+ if (committed) {
1105
+ guarded.push(stream);
1106
+ guard_events.set(stream, { id: committed.id, stream });
1107
+ } else {
1108
+ skipped.push(stream);
1109
+ }
1110
+ })
1111
+ );
1112
+ return { guarded, guard_events };
1113
+ }
1114
+ async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
1115
+ const seed_states = /* @__PURE__ */ new Map();
1116
+ await Promise.all(
1117
+ guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
1118
+ const last_event_name = stream_info.get(stream).last_event_name;
1119
+ const owner_state = event_to_state.get(last_event_name);
1120
+ if (!owner_state) {
1121
+ logger.error(
1122
+ `Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
1123
+ );
1124
+ return;
1125
+ }
1126
+ const snap2 = await load2(owner_state, { stream });
1127
+ seed_states.set(stream, snap2.state);
1128
+ })
1129
+ );
1130
+ return seed_states;
1131
+ }
1132
+ async function run_archive_callbacks(guarded, target_map) {
1133
+ for (const stream of guarded) {
1134
+ const archive_fn = target_map.get(stream)?.archive;
1135
+ if (archive_fn) await archive_fn();
1136
+ }
1137
+ }
1138
+ async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
1139
+ const trunc_targets = guarded.map((stream) => {
1140
+ const snapshot = seed_states.get(stream);
1141
+ const guard = guard_events.get(stream);
1142
+ return {
1143
+ stream,
1144
+ snapshot,
1145
+ meta: {
1146
+ correlation,
1147
+ causation: {
1148
+ event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
1149
+ }
1150
+ }
1151
+ };
1152
+ });
1153
+ const truncated = await store().truncate(trunc_targets);
1154
+ await Promise.all(
1155
+ guarded.map(async (stream) => {
1156
+ const entry = truncated.get(stream);
1157
+ const state2 = seed_states.get(stream);
1158
+ if (state2 && entry) {
1159
+ await cache().set(stream, {
1160
+ state: state2,
1161
+ version: entry.committed.version,
1162
+ event_id: entry.committed.id,
1163
+ patches: 0,
1164
+ snaps: 1
1165
+ });
1166
+ } else {
1167
+ await cache().invalidate(stream);
1168
+ }
1169
+ })
1170
+ );
1171
+ return truncated;
1172
+ }
1173
+
1174
+ // src/internal/close-signal.ts
1175
+ var CloseSignal = class extends Error {
1176
+ /**
1177
+ * Stream to close. Omitted → the reaction's own lease stream (a self-close).
1178
+ * The synthesized autoclose reaction sets it to the aggregate stream, since
1179
+ * its lease runs on a synthetic `__autoclose__` target.
1180
+ */
1181
+ stream;
1182
+ /** Optional archive callback to run while the stream is guarded. */
1183
+ archive;
1184
+ /**
1185
+ * Watermark to ack the requesting reaction to before the close runs. The
1186
+ * close-cycle safety guard skips a stream whose subscriptions (matched by
1187
+ * source) lag the head; the autoclose reaction's `source` is the aggregate,
1188
+ * so it must advance its own watermark to the live head id it evaluated
1189
+ * against — otherwise it blocks its own close. Defaults to the triggering
1190
+ * event id when omitted.
1191
+ */
1192
+ at;
1193
+ constructor(opts) {
1194
+ super("reaction requested close");
1195
+ this.name = "CloseSignal";
1196
+ this.stream = opts?.stream;
1197
+ this.archive = opts?.archive;
1198
+ this.at = opts?.at;
1199
+ }
1200
+ };
1201
+
1100
1202
  // src/internal/correlate-cycle.ts
1101
1203
  var CorrelateCycle = class {
1102
1204
  _checkpoint = -1;
@@ -1201,33 +1303,135 @@ var CorrelateCycle = class {
1201
1303
  return { subscribed: 0, last_id };
1202
1304
  }
1203
1305
  /**
1204
- * Start a periodic correlation worker. Returns false if one is already
1205
- * running. Errors from `correlate()` are routed through `log()` so they
1206
- * land in the configured logger (the timer keeps running on failure).
1306
+ * Start a periodic correlation worker. Returns false if one is already
1307
+ * running. Errors from `correlate()` are routed through `log()` so they
1308
+ * land in the configured logger (the timer keeps running on failure).
1309
+ */
1310
+ start_polling(query = {}, frequency = 1e4, callback) {
1311
+ if (this._timer) return false;
1312
+ const limit = query.limit || 100;
1313
+ this._timer = setInterval(
1314
+ () => this.correlate({ ...query, after: this._checkpoint, limit }).then((result) => {
1315
+ if (callback && result.subscribed) callback(result.subscribed);
1316
+ }).catch((err) => log().error(err)),
1317
+ frequency
1318
+ );
1319
+ return true;
1320
+ }
1321
+ /** Stop the periodic correlation worker. Idempotent. */
1322
+ stop_polling() {
1323
+ if (this._timer) {
1324
+ clearInterval(this._timer);
1325
+ this._timer = void 0;
1326
+ }
1327
+ }
1328
+ };
1329
+
1330
+ // src/internal/correlator.ts
1331
+ import { randomInt } from "crypto";
1332
+ var BASE = 36;
1333
+ var SEG_WIDTH = 4;
1334
+ var SEG_SPACE = BASE ** SEG_WIDTH;
1335
+ function seg(n) {
1336
+ return n.toString(BASE).padStart(SEG_WIDTH, "0");
1337
+ }
1338
+ var default_correlator = ({ state: state2, action: action2 }) => {
1339
+ const s = state2.slice(0, SEG_WIDTH).toLowerCase();
1340
+ const a = action2.slice(0, SEG_WIDTH).toLowerCase();
1341
+ const ts = seg(Date.now() % SEG_SPACE);
1342
+ const rnd = seg(randomInt(SEG_SPACE));
1343
+ return `${s}-${a}-${ts}${rnd}`;
1344
+ };
1345
+ function close_correlation(correlator, actor) {
1346
+ return correlator({
1347
+ state: "$close",
1348
+ action: "close",
1349
+ stream: "$close",
1350
+ actor
1351
+ });
1352
+ }
1353
+
1354
+ // src/internal/drain-cycle.ts
1355
+ import { randomUUID } from "crypto";
1356
+
1357
+ // src/internal/defer-timer.ts
1358
+ var MAX_TIMER_DELAY_MS = 2147483647;
1359
+ var DeferTimer = class {
1360
+ _due = /* @__PURE__ */ new Map();
1361
+ _timer;
1362
+ _on_wake;
1363
+ /**
1364
+ * @param on_wake - invoked once each time the earliest due-time elapses,
1365
+ * after the come-due entries have been removed. Consumers use it to
1366
+ * re-arm their loop (the drain sets its `armed` flag; autoclose runs a
1367
+ * tick).
1368
+ */
1369
+ constructor(on_wake) {
1370
+ this._on_wake = on_wake;
1371
+ }
1372
+ /** Number of currently parked streams. */
1373
+ get size() {
1374
+ return this._due.size;
1375
+ }
1376
+ /**
1377
+ * True while `stream` is parked with a due-time still in the future.
1378
+ * Consumers skip work for deferred streams until their window elapses.
1207
1379
  */
1208
- start_polling(query = {}, frequency = 1e4, callback) {
1209
- if (this._timer) return false;
1210
- const limit = query.limit || 100;
1211
- this._timer = setInterval(
1212
- () => this.correlate({ ...query, after: this._checkpoint, limit }).then((result) => {
1213
- if (callback && result.subscribed) callback(result.subscribed);
1214
- }).catch((err) => log().error(err)),
1215
- frequency
1380
+ is_deferred = (stream) => {
1381
+ const next = this._due.get(stream);
1382
+ return next !== void 0 && next > Date.now();
1383
+ };
1384
+ /**
1385
+ * Park `stream` for a re-visit at `at` (ms since epoch). A plain
1386
+ * overwrite: the caller computes the authoritative next-visit for the
1387
+ * stream (the drain's `handle` already reconciles a stream's reactions
1388
+ * into one result per cycle), so there is no stale value to merge against.
1389
+ */
1390
+ set(stream, at) {
1391
+ this._due.set(stream, at);
1392
+ }
1393
+ /** Drop `stream` from the parked set (e.g. on a successful ack or block). */
1394
+ delete(stream) {
1395
+ this._due.delete(stream);
1396
+ }
1397
+ /**
1398
+ * (Re)schedule the wake timer at the earliest pending due-time. Idempotent
1399
+ * — collapses many parked streams into a single timer. A no-op clears any
1400
+ * pending timer when the map is empty.
1401
+ *
1402
+ * The timer is `unref()`-ed so pending re-visits never keep the process
1403
+ * alive on their own.
1404
+ */
1405
+ schedule() {
1406
+ if (this._timer) clearTimeout(this._timer);
1407
+ if (this._due.size === 0) {
1408
+ this._timer = void 0;
1409
+ return;
1410
+ }
1411
+ let earliest = Number.POSITIVE_INFINITY;
1412
+ for (const t of this._due.values()) if (t < earliest) earliest = t;
1413
+ const delay = Math.min(
1414
+ Math.max(0, earliest - Date.now()),
1415
+ MAX_TIMER_DELAY_MS
1216
1416
  );
1217
- return true;
1417
+ this._timer = setTimeout(() => {
1418
+ this._timer = void 0;
1419
+ const now = Date.now();
1420
+ for (const [stream, at] of this._due)
1421
+ if (at <= now) this._due.delete(stream);
1422
+ this._on_wake();
1423
+ }, delay);
1424
+ this._timer.unref();
1218
1425
  }
1219
- /** Stop the periodic correlation worker. Idempotent. */
1220
- stop_polling() {
1426
+ /** Cancel any pending wake timer. Idempotent. Leaves the parked set intact. */
1427
+ stop() {
1221
1428
  if (this._timer) {
1222
- clearInterval(this._timer);
1429
+ clearTimeout(this._timer);
1223
1430
  this._timer = void 0;
1224
1431
  }
1225
1432
  }
1226
1433
  };
1227
1434
 
1228
- // src/internal/drain-cycle.ts
1229
- import { randomUUID } from "crypto";
1230
-
1231
1435
  // src/internal/drain-ratio.ts
1232
1436
  var RATIO_MIN = 0.2;
1233
1437
  var RATIO_MAX = 0.8;
@@ -1263,6 +1467,7 @@ async function fetch(leased, eventLimit) {
1263
1467
  }
1264
1468
  var ack = (leases) => store().ack(leases);
1265
1469
  var block = (leases) => store().block(leases);
1470
+ var defer = (input, deferred_at) => store().defer(input, deferred_at);
1266
1471
  var subscribe = (streams) => store().subscribe(streams);
1267
1472
 
1268
1473
  // src/internal/event-sourcing.ts
@@ -1740,6 +1945,7 @@ function build_drain(logger) {
1740
1945
  fetch,
1741
1946
  ack,
1742
1947
  block,
1948
+ defer,
1743
1949
  subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
1744
1950
  if (!result.subscribed) return;
1745
1951
  const lanes = new Set(streams.map((s) => s.lane ?? "default"));
@@ -1808,7 +2014,8 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
1808
2014
  fetched: [],
1809
2015
  handled: [],
1810
2016
  acked: [],
1811
- blocked: []
2017
+ blocked: [],
2018
+ closeable: []
1812
2019
  };
1813
2020
  }
1814
2021
  const fetched = await ops.fetch(active, eventLimit);
@@ -1842,12 +2049,23 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
1842
2049
  })
1843
2050
  );
1844
2051
  const acked = await ops.ack(
1845
- handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
2052
+ handled.filter((h) => h.defer === void 0 && (h.handled > 0 || !h.error)).map((h) => ({ ...h.lease, at: h.acked_at }))
1846
2053
  );
1847
2054
  const blocked = await ops.block(
1848
2055
  handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1849
2056
  );
1850
- return { leased, fetched, handled, acked, blocked };
2057
+ const deferrals = handled.filter((h) => h.defer !== void 0);
2058
+ if (deferrals.length) {
2059
+ const by_time = /* @__PURE__ */ new Map();
2060
+ for (const h of deferrals) {
2061
+ const list = by_time.get(h.defer);
2062
+ if (list) list.push(h.lease.stream);
2063
+ else by_time.set(h.defer, [h.lease.stream]);
2064
+ }
2065
+ for (const [at, streams] of by_time) await ops.defer(streams, at);
2066
+ }
2067
+ const closeable = handled.filter((h) => h.close !== void 0).map((h) => h.close);
2068
+ return { leased, fetched, handled, acked, blocked, closeable };
1851
2069
  }
1852
2070
  var EMPTY_DRAIN = {
1853
2071
  fetched: [],
@@ -1860,14 +2078,16 @@ var DrainController = class {
1860
2078
  _locked = false;
1861
2079
  _ratio = 0.5;
1862
2080
  /**
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).
2081
+ * Per-stream re-visit schedule (#1090): `stream → next visit` (ms since
2082
+ * epoch). Holds both retry backoff (`HandleResult.next_attempt_at`) and the
2083
+ * `defer` outcome; cleared on successful ack or terminal block. Lives in
2084
+ * process memory — per-worker pacing by design (see {@link BackoffOptions}
2085
+ * for the multi-worker trade-off). Its wake re-arms drain at the earliest
2086
+ * pending visit.
1867
2087
  */
1868
- _backoff = /* @__PURE__ */ new Map();
1869
- /** Timer re-arming drain at the earliest pending `next_attempt_at`. */
1870
- _backoff_timer;
2088
+ _defer = new DeferTimer(() => {
2089
+ this._armed = true;
2090
+ });
1871
2091
  /** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
1872
2092
  _worker;
1873
2093
  _stopped = false;
@@ -1887,32 +2107,6 @@ var DrainController = class {
1887
2107
  get armed() {
1888
2108
  return this._armed;
1889
2109
  }
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
2110
  /** Lane this controller drains (undefined = legacy single-lane span). */
1917
2111
  get lane() {
1918
2112
  return this._deps.lane;
@@ -1944,6 +2138,7 @@ var DrainController = class {
1944
2138
  clearTimeout(this._worker);
1945
2139
  this._worker = void 0;
1946
2140
  }
2141
+ this._defer.stop();
1947
2142
  }
1948
2143
  /** Run one drain pass. Short-circuits when not armed or already running. */
1949
2144
  async drain(options = {}) {
@@ -1969,7 +2164,7 @@ var DrainController = class {
1969
2164
  leading,
1970
2165
  eventLimit,
1971
2166
  leaseMillis,
1972
- this._backoff.size > 0 ? this.is_deferred : void 0,
2167
+ this._defer.size > 0 ? this._defer.is_deferred : void 0,
1973
2168
  this._deps.lane
1974
2169
  );
1975
2170
  this._deps.breaker.passed();
@@ -1977,19 +2172,19 @@ var DrainController = class {
1977
2172
  this._armed = false;
1978
2173
  return EMPTY_DRAIN;
1979
2174
  }
1980
- const { leased, fetched, handled, acked, blocked } = cycle;
2175
+ const { leased, fetched, handled, acked, blocked, closeable } = cycle;
1981
2176
  trace_cycle(this._deps.logger, leased, fetched, handled, acked, blocked);
1982
2177
  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);
2178
+ for (const lease of acked) this._defer.delete(lease.stream);
2179
+ for (const lease of blocked) this._defer.delete(lease.stream);
1985
2180
  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
- }
2181
+ const next = h.defer ?? (h.block ? void 0 : h.next_attempt_at);
2182
+ if (next !== void 0) this._defer.set(h.lease.stream, next);
1989
2183
  }
1990
- if (this._backoff.size > 0) this.schedule_backoff_wake();
2184
+ if (this._defer.size > 0) this._defer.schedule();
1991
2185
  if (acked.length) this._deps.on_acked(acked);
1992
2186
  if (blocked.length) this._deps.on_blocked(blocked);
2187
+ if (closeable.length) await this._deps.on_close(closeable);
1993
2188
  const has_errors = handled.some(({ error }) => error);
1994
2189
  if (!acked.length && !blocked.length && !has_errors) this._armed = false;
1995
2190
  return { fetched, leased, acked, blocked };
@@ -2002,154 +2197,6 @@ var DrainController = class {
2002
2197
  }
2003
2198
  };
2004
2199
 
2005
- // src/internal/merge.ts
2006
- import { ZodObject } from "zod";
2007
- function base_type_name(zodType) {
2008
- let t = zodType;
2009
- while (typeof t.unwrap === "function") {
2010
- t = t.unwrap();
2011
- }
2012
- return t.constructor.name;
2013
- }
2014
- function merge_schemas(existing, incoming, state_name) {
2015
- if (existing instanceof ZodObject && incoming instanceof ZodObject) {
2016
- const existing_shape = existing.shape;
2017
- const incoming_shape = incoming.shape;
2018
- for (const key of Object.keys(incoming_shape)) {
2019
- if (key in existing_shape) {
2020
- const existing_base = base_type_name(existing_shape[key]);
2021
- const incoming_base = base_type_name(incoming_shape[key]);
2022
- if (existing_base !== incoming_base) {
2023
- throw new Error(
2024
- `Schema conflict in "${state_name}": key "${key}" has type "${existing_base}" but incoming partial declares "${incoming_base}"`
2025
- );
2026
- }
2027
- }
2028
- }
2029
- return existing.extend(incoming_shape);
2030
- }
2031
- return existing;
2032
- }
2033
- function merge_inits(existing, incoming) {
2034
- return () => ({ ...existing(), ...incoming() });
2035
- }
2036
- function register_state(state2, states, actions, events) {
2037
- const existing = states.get(state2.name);
2038
- if (existing) {
2039
- merge_into_existing(state2, existing, states, actions, events);
2040
- } else {
2041
- register_new_state(state2, states, actions, events);
2042
- }
2043
- }
2044
- function register_new_state(state2, states, actions, events) {
2045
- states.set(state2.name, state2);
2046
- for (const name of Object.keys(state2.actions)) {
2047
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
2048
- actions[name] = state2;
2049
- }
2050
- for (const name of Object.keys(state2.events)) {
2051
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
2052
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
2053
- }
2054
- }
2055
- function merge_into_existing(state2, existing, states, actions, events) {
2056
- for (const name of Object.keys(state2.actions)) {
2057
- if (existing.actions[name] === state2.actions[name]) continue;
2058
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
2059
- }
2060
- for (const name of Object.keys(state2.events)) {
2061
- if (existing.events[name] === state2.events[name]) continue;
2062
- if (existing.events[name]) {
2063
- throw new Error(
2064
- `Event "${name}" in state "${state2.name}" is declared with different Zod schemas across slices. Cross-slice event schemas must reference the same instance \u2014 extract a shared schema (e.g. \`export const ${name} = z.object({ ... })\` in a shared module) and import it in every slice that declares it.`
2065
- );
2066
- }
2067
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
2068
- }
2069
- const merged_patch = merge_patches(existing.patch, state2.patch, state2.name);
2070
- const merged = {
2071
- ...existing,
2072
- state: merge_schemas(existing.state, state2.state, state2.name),
2073
- init: merge_inits(existing.init, state2.init),
2074
- events: { ...existing.events, ...state2.events },
2075
- actions: { ...existing.actions, ...state2.actions },
2076
- patch: merged_patch,
2077
- on: { ...existing.on, ...state2.on },
2078
- given: { ...existing.given, ...state2.given },
2079
- snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
2080
- throw new Error(
2081
- `Duplicate snap strategy for state "${state2.name}"`
2082
- );
2083
- })() : state2.snap || existing.snap
2084
- };
2085
- states.set(state2.name, merged);
2086
- for (const name of Object.keys(merged.actions)) {
2087
- actions[name] = merged;
2088
- }
2089
- for (const name of Object.keys(state2.events)) {
2090
- if (events[name]) continue;
2091
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
2092
- }
2093
- }
2094
- function merge_patches(existing, incoming, state_name) {
2095
- const merged = { ...existing };
2096
- for (const name of Object.keys(incoming)) {
2097
- const existing_p = existing[name];
2098
- const incoming_p = incoming[name];
2099
- if (!existing_p) {
2100
- merged[name] = incoming_p;
2101
- continue;
2102
- }
2103
- const existing_is_default = existing_p._passthrough;
2104
- const incoming_is_default = incoming_p._passthrough;
2105
- if (!existing_is_default && !incoming_is_default && existing_p !== incoming_p) {
2106
- throw new Error(
2107
- `Duplicate custom patch for event "${name}" in state "${state_name}"`
2108
- );
2109
- }
2110
- if (existing_is_default && !incoming_is_default) {
2111
- merged[name] = incoming_p;
2112
- }
2113
- }
2114
- return merged;
2115
- }
2116
- function merge_event_register(target, source) {
2117
- for (const [event_name, source_reg] of Object.entries(source)) {
2118
- const target_reg = target[event_name];
2119
- if (!target_reg) continue;
2120
- for (const [name, reaction] of source_reg.reactions) {
2121
- const existing = target_reg.reactions.get(name);
2122
- if (existing !== void 0 && existing !== reaction)
2123
- throw new Error(
2124
- `Duplicate reaction "${name}" for event "${event_name}". Reaction handlers are keyed by function name; rename one of them.`
2125
- );
2126
- target_reg.reactions.set(name, reaction);
2127
- }
2128
- }
2129
- }
2130
- function merge_projection(proj, events) {
2131
- for (const event_name of Object.keys(proj.events)) {
2132
- const proj_register = proj.events[event_name];
2133
- const existing = events[event_name];
2134
- if (!existing) {
2135
- events[event_name] = {
2136
- schema: proj_register.schema,
2137
- reactions: new Map(proj_register.reactions)
2138
- };
2139
- } else {
2140
- for (const [name, reaction] of proj_register.reactions) {
2141
- let key = name;
2142
- while (existing.reactions.has(key)) key = `${key}_p`;
2143
- existing.reactions.set(key, reaction);
2144
- }
2145
- }
2146
- }
2147
- }
2148
- var _this_ = ({ stream }) => ({
2149
- source: stream,
2150
- target: stream
2151
- });
2152
-
2153
2200
  // src/internal/reactions.ts
2154
2201
  function finalize(lease, handled, at, error, options, logger, failed_at) {
2155
2202
  if (!error) return { lease, handled, acked_at: at };
@@ -2205,6 +2252,25 @@ function build_handle(deps) {
2205
2252
  at = event.id;
2206
2253
  handled++;
2207
2254
  } catch (error) {
2255
+ if (error instanceof DeferSignal)
2256
+ return {
2257
+ lease,
2258
+ handled,
2259
+ acked_at: at,
2260
+ defer: resolve_defer_at(error.when, event)
2261
+ };
2262
+ if (error instanceof CloseSignal)
2263
+ return {
2264
+ lease,
2265
+ handled: handled + 1,
2266
+ // Advance to the live head the handler evaluated against (when
2267
+ // provided) so the close-cycle guard sees this reaction caught up.
2268
+ acked_at: error.at ?? event.id,
2269
+ // Close the signalled stream (the autoclose reaction's aggregate,
2270
+ // which differs from its synthetic lease stream); a self-closing
2271
+ // user reaction omits it and closes its own lease stream.
2272
+ close: { stream: error.stream ?? stream, archive: error.archive }
2273
+ };
2208
2274
  return finalize(
2209
2275
  lease,
2210
2276
  handled,
@@ -2307,6 +2373,7 @@ var SettleLoop = class {
2307
2373
  // src/act.ts
2308
2374
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2309
2375
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
2376
+ var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
2310
2377
  var Act = class {
2311
2378
  _emitter = new EventEmitter();
2312
2379
  /** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
@@ -2348,15 +2415,6 @@ var Act = class {
2348
2415
  * @internal
2349
2416
  */
2350
2417
  _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
2418
  /**
2361
2419
  * Emit a lifecycle event. The payload type is inferred from the event name
2362
2420
  * via {@link ActLifecycleEvents}.
@@ -2497,6 +2555,62 @@ var Act = class {
2497
2555
  bound_forget: this._bound_forget
2498
2556
  });
2499
2557
  this._handle_batch = build_handle_batch(this._logger);
2558
+ for (const st of this._states.values()) {
2559
+ const predicate = st.autoclose;
2560
+ if (!predicate) continue;
2561
+ const after_ms = st.autoclose_after_ms;
2562
+ const archiver = st.archive;
2563
+ const reaction = {
2564
+ // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
2565
+ // per-aggregate `__autoclose__` key — so the autoclose reaction never
2566
+ // shares a watermark with the aggregate's own reactions. A shared
2567
+ // watermark would let autoclose's defer short-circuit the aggregate's
2568
+ // other reactions (the "a defer affects all reactions on a stream"
2569
+ // hazard). The close still targets the aggregate (`source`).
2570
+ resolver: (e) => ({
2571
+ target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
2572
+ source: e.stream
2573
+ }),
2574
+ // Never block on autoclose: a transient query/store error should retry,
2575
+ // not quarantine the synthetic stream.
2576
+ options: { blockOnError: false, maxRetries: 3 },
2577
+ handler: async (event) => {
2578
+ const aggregate = event.stream;
2579
+ const config2 = this._autoclose_config;
2580
+ if (!in_autoclose_window(config2.autocloseWindow, /* @__PURE__ */ new Date()))
2581
+ throw new DeferSignal({
2582
+ at: new Date(Date.now() + config2.autocloseCycleMinutes * 6e4)
2583
+ });
2584
+ const stats = await store().query_stats([aggregate], {
2585
+ count: true,
2586
+ exclude: [TOMBSTONE_EVENT]
2587
+ });
2588
+ const entry = stats.get(aggregate);
2589
+ if (!entry) return;
2590
+ const head = entry.head;
2591
+ if (predicate(aggregate, head, entry.count))
2592
+ throw new CloseSignal({
2593
+ stream: aggregate,
2594
+ archive: archiver ? () => archiver(aggregate, head) : void 0,
2595
+ // Ack this reaction's own watermark to the live head so the
2596
+ // close-cycle guard (which matches subscriptions by source =
2597
+ // aggregate) sees it caught up instead of blocking its own close.
2598
+ at: head.id
2599
+ });
2600
+ if (after_ms !== void 0)
2601
+ throw new DeferSignal({
2602
+ at: new Date(head.created.getTime() + after_ms)
2603
+ });
2604
+ }
2605
+ };
2606
+ const key = `__autoclose_${st.name}`;
2607
+ for (const event_name of Object.keys(st.events)) {
2608
+ this.registry.events[event_name]?.reactions.set(
2609
+ key,
2610
+ reaction
2611
+ );
2612
+ }
2613
+ }
2500
2614
  const {
2501
2615
  static_targets,
2502
2616
  has_dynamic_resolvers,
@@ -2545,6 +2659,22 @@ var Act = class {
2545
2659
  handle_batch: this._handle_batch,
2546
2660
  on_acked: (acked) => this.emit("acked", acked),
2547
2661
  on_blocked: (blocked) => this.emit("blocked", blocked),
2662
+ // Reaction-requested close (#1090). Runs the same close machinery as
2663
+ // `app.close` (tombstone guard + archive + atomic truncate) for the
2664
+ // targets a handler signalled via `CloseSignal`. No `correlate()` here
2665
+ // — the drain that produced these targets has already correlated.
2666
+ on_close: async (targets) => {
2667
+ const close_actor = { id: "$close", name: "close" };
2668
+ const result = await run_close_cycle(targets, {
2669
+ reactive_events_size: this._reactive_events.size,
2670
+ event_to_state: this._event_to_state,
2671
+ load: this._es.load,
2672
+ tombstone: this._es.tombstone,
2673
+ logger: this._logger,
2674
+ correlation: close_correlation(this._correlator, close_actor)
2675
+ });
2676
+ this.emit("closed", result);
2677
+ },
2548
2678
  breaker: this._breaker,
2549
2679
  // Pass lane only when a true per-lane controller is active.
2550
2680
  // The all-lanes (single default) case keeps lane=undefined so
@@ -2591,27 +2721,6 @@ var Act = class {
2591
2721
  },
2592
2722
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
2593
2723
  );
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
2724
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store());
2616
2725
  dispose(() => this.shutdown());
2617
2726
  }
@@ -3105,7 +3214,6 @@ var Act = class {
3105
3214
  */
3106
3215
  start_correlations(query = {}, frequency = 1e4, callback) {
3107
3216
  const started = this._correlate.start_polling(query, frequency, callback);
3108
- this._autoclose?.start();
3109
3217
  return started;
3110
3218
  }
3111
3219
  /**
@@ -3127,7 +3235,6 @@ var Act = class {
3127
3235
  */
3128
3236
  stop_correlations() {
3129
3237
  this._correlate.stop_polling();
3130
- this._autoclose?.stop();
3131
3238
  }
3132
3239
  /**
3133
3240
  * Cancels any pending or active settle cycle.
@@ -3594,41 +3701,10 @@ function act() {
3594
3701
  },
3595
3702
  withActor: () => builder,
3596
3703
  withLane: (config2) => {
3597
- if (config2.name === DEFAULT_LANE)
3598
- throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
3599
- if (lanes.some((l) => l.name === config2.name))
3600
- throw new Error(`Lane "${config2.name}" was already declared`);
3601
- lanes.push(config2);
3704
+ register_lane(config2, lanes);
3602
3705
  return builder;
3603
3706
  },
3604
- on: (event) => ({
3605
- do: (handler, options) => {
3606
- const reaction = {
3607
- handler,
3608
- resolver: _this_,
3609
- options: {
3610
- blockOnError: options?.blockOnError ?? true,
3611
- maxRetries: options?.maxRetries ?? 3,
3612
- backoff: options?.backoff
3613
- }
3614
- };
3615
- if (!handler.name)
3616
- throw new Error(
3617
- `Reaction handler for "${String(event)}" must be a named function`
3618
- );
3619
- if (registry.events[event].reactions.has(handler.name))
3620
- throw new Error(
3621
- `Duplicate reaction "${handler.name}" for event "${String(event)}". Reaction handlers are keyed by function name; rename one of them.`
3622
- );
3623
- registry.events[event].reactions.set(handler.name, reaction);
3624
- return Object.assign(builder, {
3625
- to(resolver) {
3626
- reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
3627
- return builder;
3628
- }
3629
- });
3630
- }
3631
- }),
3707
+ on: (event) => reaction_on(event, registry.events, builder),
3632
3708
  build: (options) => {
3633
3709
  if (!_built) {
3634
3710
  for (const proj of pending_projections) {
@@ -3793,41 +3869,10 @@ function slice() {
3793
3869
  return builder;
3794
3870
  },
3795
3871
  withLane: (config2) => {
3796
- if (config2.name === DEFAULT_LANE)
3797
- throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
3798
- if (lanes.some((l) => l.name === config2.name))
3799
- throw new Error(`Lane "${config2.name}" was already declared`);
3800
- lanes.push(config2);
3872
+ register_lane(config2, lanes);
3801
3873
  return builder;
3802
3874
  },
3803
- on: (event) => ({
3804
- do: (handler, options) => {
3805
- const reaction = {
3806
- handler,
3807
- resolver: _this_,
3808
- options: {
3809
- blockOnError: options?.blockOnError ?? true,
3810
- maxRetries: options?.maxRetries ?? 3,
3811
- backoff: options?.backoff
3812
- }
3813
- };
3814
- if (!handler.name)
3815
- throw new Error(
3816
- `Reaction handler for "${String(event)}" must be a named function`
3817
- );
3818
- if (events[event].reactions.has(handler.name))
3819
- throw new Error(
3820
- `Duplicate reaction "${handler.name}" for event "${String(event)}". Reaction handlers are keyed by function name; rename one of them.`
3821
- );
3822
- events[event].reactions.set(handler.name, reaction);
3823
- return Object.assign(builder, {
3824
- to(resolver) {
3825
- reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
3826
- return builder;
3827
- }
3828
- });
3829
- }
3830
- }),
3875
+ on: (event) => reaction_on(event, events, builder),
3831
3876
  build: () => ({
3832
3877
  _tag: "Slice",
3833
3878
  states,
@@ -3930,20 +3975,21 @@ function action_builder(state2) {
3930
3975
  internal.disclose = disclose;
3931
3976
  return builder;
3932
3977
  },
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
3978
+ autocloses(policy) {
3979
+ if (typeof policy === "function") {
3980
+ throw new Error(
3981
+ ".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
3982
  );
3941
- } else {
3983
+ }
3984
+ if (policy === null || typeof policy !== "object") {
3942
3985
  throw new Error(
3943
- ".autocloses(...) requires a function or a policy object; got " + typeof predicate_or_policy
3986
+ ".autocloses(...) requires a policy object; got " + typeof policy
3944
3987
  );
3945
3988
  }
3946
- internal.autoclose = predicate;
3989
+ internal.autoclose = compile_autoclose_policy(
3990
+ policy
3991
+ );
3992
+ internal.autoclose_after_ms = policy_min_after_ms(policy);
3947
3993
  return builder;
3948
3994
  },
3949
3995
  archives(archive) {
@@ -4136,6 +4182,7 @@ export {
4136
4182
  DEFAULT_LANE,
4137
4183
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
4138
4184
  DEFAULT_SETTLE_DEBOUNCE_MS,
4185
+ DeferSignal,
4139
4186
  Environments,
4140
4187
  Errors,
4141
4188
  EventMetaSchema,