@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.cjs CHANGED
@@ -43,6 +43,7 @@ __export(index_exports, {
43
43
  DEFAULT_LANE: () => DEFAULT_LANE,
44
44
  DEFAULT_MAX_SUBSCRIBED_STREAMS: () => DEFAULT_MAX_SUBSCRIBED_STREAMS,
45
45
  DEFAULT_SETTLE_DEBOUNCE_MS: () => DEFAULT_SETTLE_DEBOUNCE_MS,
46
+ DeferSignal: () => DeferSignal,
46
47
  Environments: () => Environments,
47
48
  Errors: () => Errors,
48
49
  EventMetaSchema: () => EventMetaSchema,
@@ -631,6 +632,11 @@ var InMemoryStream = class {
631
632
  _leased_until = void 0;
632
633
  _priority = 0;
633
634
  _lane = DEFAULT_LANE;
635
+ // Persisted next-visit time (#1090). When set and still in the future, the
636
+ // stream is held out of `claim` entirely — so a deferred reaction is not
637
+ // re-claimed (and `retry` is never bumped) until its due-time passes. Unlike
638
+ // in-process backoff, this is durable store state shared across workers.
639
+ _deferred_at = void 0;
634
640
  constructor(stream, source, priority = 0, lane = DEFAULT_LANE) {
635
641
  this.stream = stream;
636
642
  this.source = source;
@@ -662,7 +668,17 @@ var InMemoryStream = class {
662
668
  this._priority = priority;
663
669
  }
664
670
  get is_available() {
665
- return !this._blocked && (!this._leased_until || this._leased_until <= /* @__PURE__ */ new Date());
671
+ return !this._blocked && (!this._leased_until || this._leased_until <= /* @__PURE__ */ new Date()) && // A stream deferred to a future time is not claimable until due (#1090).
672
+ (!this._deferred_at || this._deferred_at <= Date.now());
673
+ }
674
+ /**
675
+ * Hold this stream out of `claim` until `deferred_at` (ms since epoch).
676
+ * Set by a deliberate `defer` outcome — not a failure, so retry/blocked
677
+ * state is untouched. Cleared by ack/block/reset/unblock.
678
+ */
679
+ defer(deferred_at) {
680
+ this._deferred_at = deferred_at;
681
+ this._retry = -1;
666
682
  }
667
683
  get at() {
668
684
  return this._at;
@@ -714,6 +730,7 @@ var InMemoryStream = class {
714
730
  this._leased_until = void 0;
715
731
  this._at = lease.at;
716
732
  this._retry = -1;
733
+ this._deferred_at = void 0;
717
734
  return {
718
735
  stream: this.stream,
719
736
  source: this.source,
@@ -734,6 +751,7 @@ var InMemoryStream = class {
734
751
  if (this._leased_by === lease.by) {
735
752
  this._blocked = true;
736
753
  this._error = error;
754
+ this._deferred_at = void 0;
737
755
  return {
738
756
  stream: this.stream,
739
757
  source: this.source,
@@ -758,6 +776,7 @@ var InMemoryStream = class {
758
776
  this._error = "";
759
777
  this._leased_by = void 0;
760
778
  this._leased_until = void 0;
779
+ this._deferred_at = void 0;
761
780
  }
762
781
  /**
763
782
  * Clear the blocked flag and lease bookkeeping without touching the
@@ -771,6 +790,7 @@ var InMemoryStream = class {
771
790
  this._error = "";
772
791
  this._leased_by = void 0;
773
792
  this._leased_until = void 0;
793
+ this._deferred_at = void 0;
774
794
  return true;
775
795
  }
776
796
  };
@@ -1050,6 +1070,37 @@ var InMemoryStore = class {
1050
1070
  await sleep();
1051
1071
  return leases.map((l) => this._streams.get(l.stream)?.block(l, l.error)).filter((l) => !!l);
1052
1072
  }
1073
+ /**
1074
+ * Hold the matched streams out of {@link claim} until `deferred_at`
1075
+ * (ms since epoch) — see {@link Store.defer}. Accepts an explicit list
1076
+ * of names or a {@link StreamFilter}, mirroring {@link reset}/{@link unblock}.
1077
+ * Persisted store state (unlike in-process backoff), so the skip is honored
1078
+ * by every competing worker. Unknown names are silently skipped.
1079
+ *
1080
+ * @returns Count of streams whose `deferred_at` was set.
1081
+ */
1082
+ async defer(input, deferred_at) {
1083
+ await sleep();
1084
+ let count = 0;
1085
+ if (Array.isArray(input)) {
1086
+ for (const name of input) {
1087
+ const s = this._streams.get(name);
1088
+ if (s) {
1089
+ s.defer(deferred_at);
1090
+ count++;
1091
+ }
1092
+ }
1093
+ } else {
1094
+ const matches = this._filter_predicate(input);
1095
+ for (const s of this._streams.values()) {
1096
+ if (matches(s)) {
1097
+ s.defer(deferred_at);
1098
+ count++;
1099
+ }
1100
+ }
1101
+ }
1102
+ return count;
1103
+ }
1053
1104
  /**
1054
1105
  * Build a predicate from a {@link StreamFilter}. Compiled regexes are
1055
1106
  * cached in the closure so callers can apply it across the streams
@@ -1950,483 +2001,388 @@ function in_autoclose_window(window, now) {
1950
2001
  return window.start < window.end ? hour >= window.start && hour < window.end : hour >= window.start || hour < window.end;
1951
2002
  }
1952
2003
 
1953
- // src/internal/close-cycle.ts
1954
- var SAFETY_PROBE_PAGE_SIZE = 1e3;
1955
- async function run_close_cycle(targets, deps) {
1956
- const target_map = new Map(targets.map((t) => [t.stream, t]));
1957
- const streams = [...target_map.keys()];
1958
- const skipped = [];
1959
- const stream_info = await scan_stream_heads(streams);
1960
- const safe = await partition_by_safety(
1961
- stream_info,
1962
- deps.reactive_events_size,
1963
- skipped,
1964
- deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
1965
- );
1966
- if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
1967
- const { guarded, guard_events } = await guard_with_tombstones(
1968
- safe,
1969
- stream_info,
1970
- deps.correlation,
1971
- deps.tombstone,
1972
- skipped
1973
- );
1974
- if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
1975
- const seed_states = await load_restart_seeds(
1976
- guarded,
1977
- target_map,
1978
- stream_info,
1979
- deps.event_to_state,
1980
- deps.load,
1981
- deps.logger
1982
- );
1983
- await run_archive_callbacks(guarded, target_map);
1984
- const truncated = await truncate_and_warm_cache(
1985
- guarded,
1986
- seed_states,
1987
- guard_events,
1988
- deps.correlation
1989
- );
1990
- return { truncated, skipped };
1991
- }
1992
- async function scan_stream_heads(streams) {
1993
- const stats = await store2().query_stats(streams, {
1994
- exclude: [SNAP_EVENT]
1995
- });
1996
- const out = /* @__PURE__ */ new Map();
1997
- for (const [stream, { head }] of stats) {
1998
- if (head.name === TOMBSTONE_EVENT) continue;
1999
- out.set(stream, {
2000
- max_id: head.id,
2001
- version: head.version,
2002
- last_event_name: head.name
2003
- });
2004
+ // src/internal/autoclose-policy.ts
2005
+ var import_zod6 = require("zod");
2006
+ var MIN_AFTER_MS = 6e4;
2007
+ var AfterSchema = import_zod6.z.object({
2008
+ days: import_zod6.z.number({ message: "autocloses: after.days must be a number" }).positive("autocloses: after.days must be > 0")
2009
+ }).refine((o) => o.days * 864e5 >= MIN_AFTER_MS, {
2010
+ message: `autocloses: after resolves to < ${MIN_AFTER_MS}ms (one minute), which is too short to be a meaningful retention window`
2011
+ });
2012
+ var IsSchema = import_zod6.z.union([
2013
+ import_zod6.z.string().min(1, "autocloses: is must be a non-empty event name"),
2014
+ import_zod6.z.array(
2015
+ import_zod6.z.string().min(1, "autocloses: is entries must be non-empty event names")
2016
+ ).min(1, "autocloses: is must include at least one event name").readonly()
2017
+ ]);
2018
+ var ReachesSchema = import_zod6.z.number({ message: "autocloses: reaches must be a number" }).int("autocloses: reaches must be an integer").min(1, "autocloses: reaches must be >= 1");
2019
+ var OrBlockSchema = import_zod6.z.object({
2020
+ after: AfterSchema.optional(),
2021
+ is: IsSchema.optional(),
2022
+ reaches: ReachesSchema.optional()
2023
+ }).strict().refine(
2024
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0,
2025
+ {
2026
+ message: "autocloses: `or` block must include at least one of after / is / reaches"
2004
2027
  }
2005
- return out;
2028
+ );
2029
+ var AutoclosePolicySchema = import_zod6.z.object({
2030
+ after: AfterSchema.optional(),
2031
+ is: IsSchema.optional(),
2032
+ reaches: ReachesSchema.optional(),
2033
+ or: OrBlockSchema.optional()
2034
+ }).strict().refine(
2035
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0,
2036
+ {
2037
+ message: "autocloses: at least one of after / is / reaches / or must be specified \u2014 empty `{}` is a misconfiguration"
2038
+ }
2039
+ );
2040
+ function compile_after(after) {
2041
+ const after_ms = after.days * 864e5;
2042
+ return (_stream, head) => Date.now() - head.created.getTime() >= after_ms;
2006
2043
  }
2007
- async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
2008
- if (reactive_events_size === 0) return [...stream_info.keys()];
2009
- const pending_set = /* @__PURE__ */ new Set();
2010
- const source_regex = /* @__PURE__ */ new Map();
2011
- const get_regex = (source) => {
2012
- let re = source_regex.get(source);
2013
- if (!re) {
2014
- re = new RegExp(source);
2015
- source_regex.set(source, re);
2044
+ function compile_is(is) {
2045
+ const set = new Set(typeof is === "string" ? [is] : is);
2046
+ return (_stream, head) => set.has(head.name);
2047
+ }
2048
+ function compile_reaches(reaches) {
2049
+ return (_stream, _head, count) => count >= reaches;
2050
+ }
2051
+ function policy_min_after_ms(options) {
2052
+ const parsed = AutoclosePolicySchema.parse(options);
2053
+ const windows = [];
2054
+ if (parsed.after) windows.push(parsed.after.days * 864e5);
2055
+ if (parsed.or?.after) windows.push(parsed.or.after.days * 864e5);
2056
+ return windows.length ? Math.min(...windows) : void 0;
2057
+ }
2058
+ function compile_autoclose_policy(options) {
2059
+ const parsed = AutoclosePolicySchema.parse(options);
2060
+ const and_preds = [];
2061
+ if (parsed.after) and_preds.push(compile_after(parsed.after));
2062
+ if (parsed.is) and_preds.push(compile_is(parsed.is));
2063
+ if (parsed.reaches) and_preds.push(compile_reaches(parsed.reaches));
2064
+ const or_preds = [];
2065
+ if (parsed.or) {
2066
+ if (parsed.or.after) or_preds.push(compile_after(parsed.or.after));
2067
+ if (parsed.or.is) or_preds.push(compile_is(parsed.or.is));
2068
+ if (parsed.or.reaches) or_preds.push(compile_reaches(parsed.or.reaches));
2069
+ }
2070
+ return (stream, head, count) => {
2071
+ if (and_preds.length > 0 && and_preds.every((p) => p(stream, head, count))) {
2072
+ return true;
2016
2073
  }
2017
- return re;
2074
+ if (or_preds.some((p) => p(stream, head, count))) {
2075
+ return true;
2076
+ }
2077
+ return false;
2018
2078
  };
2019
- const targets = [...stream_info.keys()];
2020
- let after;
2021
- for (; ; ) {
2022
- let last;
2023
- const { count } = await store2().query_streams(
2024
- (position) => {
2025
- last = position.stream;
2026
- const source_re = position.source ? get_regex(position.source) : void 0;
2027
- for (const [stream, info] of stream_info) {
2028
- if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
2029
- pending_set.add(stream);
2079
+ }
2080
+
2081
+ // src/internal/build-classify.ts
2082
+ var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
2083
+ function classify_registry(registry, states) {
2084
+ const statics = /* @__PURE__ */ new Map();
2085
+ const reactive_events = /* @__PURE__ */ new Set();
2086
+ const event_to_lanes = /* @__PURE__ */ new Map();
2087
+ let has_dynamic_resolvers = false;
2088
+ for (const [name, register] of Object.entries(registry.events)) {
2089
+ if (register.reactions.size > 0) reactive_events.add(name);
2090
+ for (const reaction of register.reactions.values()) {
2091
+ if (typeof reaction.resolver === "function") {
2092
+ has_dynamic_resolvers = true;
2093
+ event_to_lanes.set(name, ALL_LANES);
2094
+ } else {
2095
+ const { target, source, priority = 0, lane } = reaction.resolver;
2096
+ const lane_name = lane ?? "default";
2097
+ const existing_lanes = event_to_lanes.get(name);
2098
+ if (existing_lanes !== ALL_LANES) {
2099
+ const set = existing_lanes ?? /* @__PURE__ */ new Set();
2100
+ set.add(lane_name);
2101
+ event_to_lanes.set(name, set);
2102
+ }
2103
+ const key = `${target}|${source ?? ""}`;
2104
+ const existing = statics.get(key);
2105
+ if (!existing) {
2106
+ statics.set(key, { stream: target, source, priority, lane });
2107
+ } else {
2108
+ if ((existing.lane ?? void 0) !== (lane ?? void 0))
2109
+ throw new Error(
2110
+ `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
2111
+ );
2112
+ if (priority > existing.priority) {
2113
+ statics.set(key, { ...existing, priority });
2030
2114
  }
2031
2115
  }
2032
- },
2033
- { after, limit: page_size, source_matches: targets }
2034
- );
2035
- if (count < page_size) break;
2036
- after = last;
2116
+ }
2117
+ }
2037
2118
  }
2038
- const safe = [];
2039
- for (const [stream] of stream_info) {
2040
- if (pending_set.has(stream)) skipped.push(stream);
2041
- else safe.push(stream);
2119
+ const event_to_state = /* @__PURE__ */ new Map();
2120
+ for (const merged of states.values()) {
2121
+ for (const event_name of Object.keys(merged.events)) {
2122
+ event_to_state.set(event_name, merged);
2123
+ }
2042
2124
  }
2043
- return safe;
2125
+ return {
2126
+ static_targets: [...statics.values()],
2127
+ has_dynamic_resolvers,
2128
+ reactive_events,
2129
+ event_to_state,
2130
+ event_to_lanes
2131
+ };
2044
2132
  }
2045
- async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
2046
- const guarded = [];
2047
- const guard_events = /* @__PURE__ */ new Map();
2048
- await Promise.all(
2049
- safe.map(async (stream) => {
2050
- const info = stream_info.get(stream);
2051
- const committed = await tombstone2(stream, info.version, correlation);
2052
- if (committed) {
2053
- guarded.push(stream);
2054
- guard_events.set(stream, { id: committed.id, stream });
2055
- } else {
2056
- skipped.push(stream);
2057
- }
2058
- })
2059
- );
2060
- return { guarded, guard_events };
2133
+
2134
+ // src/internal/defer-config.ts
2135
+ var import_zod7 = require("zod");
2136
+
2137
+ // src/internal/defer-signal.ts
2138
+ var DeferSignal = class extends Error {
2139
+ /**
2140
+ * The unresolved schedule. The dispatcher turns this into an absolute
2141
+ * due-time by resolving it against the triggering event.
2142
+ */
2143
+ when;
2144
+ constructor(when) {
2145
+ super("reaction deferred");
2146
+ this.name = "DeferSignal";
2147
+ this.when = when;
2148
+ }
2149
+ };
2150
+
2151
+ // src/internal/defer-config.ts
2152
+ var DeferDurationSchema = import_zod7.z.object({
2153
+ days: import_zod7.z.number().positive().optional(),
2154
+ hours: import_zod7.z.number().positive().optional(),
2155
+ minutes: import_zod7.z.number().positive().optional()
2156
+ }).strict().refine((d) => d.days != null || d.hours != null || d.minutes != null, {
2157
+ message: "defer: a duration needs at least one of days, hours, or minutes"
2158
+ });
2159
+ var DeferWhenSchema = import_zod7.z.object({
2160
+ after: DeferDurationSchema.optional(),
2161
+ at: import_zod7.z.date().optional()
2162
+ }).strict().refine((w) => w.after === void 0 !== (w.at === void 0), {
2163
+ message: "defer: specify exactly one of `after` or `at`"
2164
+ });
2165
+ function duration_ms(d) {
2166
+ return (d.days ?? 0) * 864e5 + (d.hours ?? 0) * 36e5 + (d.minutes ?? 0) * 6e4;
2061
2167
  }
2062
- async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
2063
- const seed_states = /* @__PURE__ */ new Map();
2064
- await Promise.all(
2065
- guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
2066
- const last_event_name = stream_info.get(stream).last_event_name;
2067
- const owner_state = event_to_state.get(last_event_name);
2068
- if (!owner_state) {
2069
- logger.error(
2070
- `Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
2071
- );
2072
- return;
2073
- }
2074
- const snap2 = await load2(owner_state, { stream });
2075
- seed_states.set(stream, snap2.state);
2076
- })
2077
- );
2078
- return seed_states;
2168
+ function resolve_defer_at(when, event) {
2169
+ const parsed = DeferWhenSchema.parse(when);
2170
+ if (parsed.after) return event.created.getTime() + duration_ms(parsed.after);
2171
+ return parsed.at.getTime();
2079
2172
  }
2080
- async function run_archive_callbacks(guarded, target_map) {
2081
- for (const stream of guarded) {
2082
- const archive_fn = target_map.get(stream)?.archive;
2083
- if (archive_fn) await archive_fn();
2084
- }
2173
+ function assert_defer_when(when) {
2174
+ DeferWhenSchema.parse(when);
2085
2175
  }
2086
- async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
2087
- const trunc_targets = guarded.map((stream) => {
2088
- const snapshot = seed_states.get(stream);
2089
- const guard = guard_events.get(stream);
2090
- return {
2091
- stream,
2092
- snapshot,
2093
- meta: {
2094
- correlation,
2095
- causation: {
2096
- event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
2097
- }
2098
- }
2099
- };
2100
- });
2101
- const truncated = await store2().truncate(trunc_targets);
2102
- await Promise.all(
2103
- guarded.map(async (stream) => {
2104
- const entry = truncated.get(stream);
2105
- const state2 = seed_states.get(stream);
2106
- if (state2 && entry) {
2107
- await cache2().set(stream, {
2108
- state: state2,
2109
- version: entry.committed.version,
2110
- event_id: entry.committed.id,
2111
- patches: 0,
2112
- snaps: 1
2113
- });
2114
- } else {
2115
- await cache2().invalidate(stream);
2116
- }
2117
- })
2118
- );
2119
- return truncated;
2120
- }
2121
-
2122
- // src/internal/correlator.ts
2123
- var import_node_crypto = require("crypto");
2124
- var BASE = 36;
2125
- var SEG_WIDTH = 4;
2126
- var SEG_SPACE = BASE ** SEG_WIDTH;
2127
- function seg(n) {
2128
- return n.toString(BASE).padStart(SEG_WIDTH, "0");
2129
- }
2130
- var default_correlator = ({ state: state2, action: action2 }) => {
2131
- const s = state2.slice(0, SEG_WIDTH).toLowerCase();
2132
- const a = action2.slice(0, SEG_WIDTH).toLowerCase();
2133
- const ts = seg(Date.now() % SEG_SPACE);
2134
- const rnd = seg((0, import_node_crypto.randomInt)(SEG_SPACE));
2135
- return `${s}-${a}-${ts}${rnd}`;
2136
- };
2137
- function close_correlation(correlator, actor) {
2138
- return correlator({
2139
- state: "$close",
2140
- action: "close",
2141
- stream: "$close",
2142
- actor
2143
- });
2176
+ function make_deferred(handler, schedule) {
2177
+ const call = handler;
2178
+ const deferred = async (event, stream, app) => {
2179
+ const when = typeof schedule === "function" ? schedule(event) : schedule;
2180
+ if (Date.now() < resolve_defer_at(when, event)) throw new DeferSignal(when);
2181
+ return call(event, stream, app);
2182
+ };
2183
+ Object.defineProperty(deferred, "name", { value: handler.name });
2184
+ return deferred;
2144
2185
  }
2145
2186
 
2146
- // src/internal/autoclose-cycle.ts
2147
- function yield_between_batches(ms) {
2148
- if (ms <= 0) return Promise.resolve();
2149
- return new Promise((resolve) => setTimeout(resolve, ms));
2187
+ // src/internal/merge.ts
2188
+ var import_zod8 = require("zod");
2189
+ function base_type_name(zodType) {
2190
+ let t = zodType;
2191
+ while (typeof t.unwrap === "function") {
2192
+ t = t.unwrap();
2193
+ }
2194
+ return t.constructor.name;
2150
2195
  }
2151
- async function run_autoclose_cycle(deps) {
2152
- const s = store2();
2153
- let inspected = 0;
2154
- let evaluated = 0;
2155
- let predicate_errors = 0;
2156
- const truncated = /* @__PURE__ */ new Map();
2157
- const skipped = [];
2158
- const page = deps.config.closeBatchSize;
2159
- let after;
2160
- for (; ; ) {
2161
- const stats = await s.query_stats(
2162
- {},
2163
- { count: true, exclude: [TOMBSTONE_EVENT], after, limit: page }
2164
- );
2165
- if (stats.size === 0) break;
2166
- inspected += stats.size;
2167
- const candidates = [];
2168
- for (const [stream, { head, count }] of stats) {
2169
- const owner = deps.event_to_state.get(head.name);
2170
- if (!owner) continue;
2171
- const predicate = deps.autoclose_policy(owner.name);
2172
- if (!predicate) continue;
2173
- let close = false;
2174
- try {
2175
- close = predicate(stream, head, count ?? 0);
2176
- evaluated += 1;
2177
- } catch (err) {
2178
- predicate_errors += 1;
2179
- deps.logger.error(
2180
- `Autoclose predicate for state "${owner.name}" threw on stream "${stream}": ${err instanceof Error ? err.message : String(err)}`
2181
- );
2182
- close = deps.config.closeOnError;
2196
+ function merge_schemas(existing, incoming, state_name) {
2197
+ if (existing instanceof import_zod8.ZodObject && incoming instanceof import_zod8.ZodObject) {
2198
+ const existing_shape = existing.shape;
2199
+ const incoming_shape = incoming.shape;
2200
+ for (const key of Object.keys(incoming_shape)) {
2201
+ if (key in existing_shape) {
2202
+ const existing_base = base_type_name(existing_shape[key]);
2203
+ const incoming_base = base_type_name(incoming_shape[key]);
2204
+ if (existing_base !== incoming_base) {
2205
+ throw new Error(
2206
+ `Schema conflict in "${state_name}": key "${key}" has type "${existing_base}" but incoming partial declares "${incoming_base}"`
2207
+ );
2208
+ }
2183
2209
  }
2184
- if (!close) continue;
2185
- const archiver = deps.autoclose_archiver(owner.name);
2186
- const archive = archiver ? () => archiver(stream, head) : void 0;
2187
- candidates.push({ stream, archive });
2188
2210
  }
2189
- if (candidates.length > 0) {
2190
- const result = await run_close_cycle(candidates, {
2191
- reactive_events_size: deps.reactive_events_size,
2192
- event_to_state: deps.event_to_state,
2193
- load: deps.load,
2194
- tombstone: deps.tombstone,
2195
- logger: deps.logger,
2196
- correlation: deps.correlation
2197
- });
2198
- for (const [stream, entry] of result.truncated)
2199
- truncated.set(stream, entry);
2200
- for (const stream of result.skipped) skipped.push(stream);
2201
- await yield_between_batches(deps.config.closeYieldMs);
2202
- }
2203
- if (stats.size < page) break;
2204
- after = [...stats.keys()].at(-1);
2211
+ return existing.extend(incoming_shape);
2205
2212
  }
2206
- return {
2207
- inspected,
2208
- evaluated,
2209
- predicate_errors,
2210
- close_result: { truncated, skipped }
2211
- };
2213
+ return existing;
2212
2214
  }
2213
- var AutocloseController = class {
2214
- deps;
2215
- _timer;
2216
- /**
2217
- * Reentrancy guard. The interval fires the next tick on cadence
2218
- * regardless of how long the previous one took; this flag drops
2219
- * overlapping ticks so the cycle stays sequential per controller.
2220
- */
2221
- _running = false;
2222
- constructor(deps) {
2223
- this.deps = deps;
2215
+ function merge_inits(existing, incoming) {
2216
+ return () => ({ ...existing(), ...incoming() });
2217
+ }
2218
+ function register_state(state2, states, actions, events) {
2219
+ const existing = states.get(state2.name);
2220
+ if (existing) {
2221
+ merge_into_existing(state2, existing, states, actions, events);
2222
+ } else {
2223
+ register_new_state(state2, states, actions, events);
2224
2224
  }
2225
- /**
2226
- * Start the cycle ticker. Returns `false` if already running so
2227
- * accidental double-start doesn't stack timers (matches
2228
- * `_correlate.start_polling` semantics).
2229
- */
2230
- start() {
2231
- if (this._timer) return false;
2232
- this._timer = setInterval(() => {
2233
- this.run_once().catch(() => {
2234
- });
2235
- }, this.deps.config.autocloseCycleMinutes * 6e4);
2236
- this._timer.unref();
2237
- return true;
2225
+ }
2226
+ function register_new_state(state2, states, actions, events) {
2227
+ states.set(state2.name, state2);
2228
+ for (const name of Object.keys(state2.actions)) {
2229
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
2230
+ actions[name] = state2;
2238
2231
  }
2239
- /**
2240
- * Stop the cycle ticker. Idempotent.
2241
- */
2242
- stop() {
2243
- if (this._timer) {
2244
- clearInterval(this._timer);
2245
- this._timer = void 0;
2246
- }
2232
+ for (const name of Object.keys(state2.events)) {
2233
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
2234
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
2247
2235
  }
2248
- /**
2249
- * Whether the ticker is currently running.
2250
- */
2251
- get is_running() {
2252
- return this._timer !== void 0;
2236
+ }
2237
+ function merge_into_existing(state2, existing, states, actions, events) {
2238
+ for (const name of Object.keys(state2.actions)) {
2239
+ if (existing.actions[name] === state2.actions[name]) continue;
2240
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
2253
2241
  }
2254
- /**
2255
- * Run one cycle synchronously. The controller's interval callback
2256
- * delegates here; tests can invoke it directly to deterministically
2257
- * exercise the cycle without spinning real timers.
2258
- *
2259
- * Overlapping invocations short-circuit: if a previous tick is
2260
- * still running, this one drops and returns `null`. The orchestrator
2261
- * never sees a queue of pending ticks regardless of how slow the
2262
- * predicate / archive / truncate are.
2263
- */
2264
- async run_once() {
2265
- if (this._running) return null;
2266
- if (this.deps.breaker.state(Date.now()) === "open") return null;
2267
- if (!in_autoclose_window(this.deps.config.autocloseWindow, /* @__PURE__ */ new Date()))
2268
- return null;
2269
- this._running = true;
2270
- try {
2271
- const correlation = close_correlation(
2272
- this.deps.correlator,
2273
- this.deps.close_actor
2242
+ for (const name of Object.keys(state2.events)) {
2243
+ if (existing.events[name] === state2.events[name]) continue;
2244
+ if (existing.events[name]) {
2245
+ throw new Error(
2246
+ `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.`
2274
2247
  );
2275
- const result = await run_autoclose_cycle({
2276
- autoclose_policy: this.deps.autoclose_policy,
2277
- autoclose_archiver: this.deps.autoclose_archiver,
2278
- event_to_state: this.deps.event_to_state,
2279
- reactive_events_size: this.deps.reactive_events_size,
2280
- load: this.deps.load,
2281
- tombstone: this.deps.tombstone,
2282
- logger: this.deps.logger,
2283
- config: this.deps.config,
2284
- correlation
2285
- });
2286
- this.deps.breaker.passed();
2287
- if (result.close_result.truncated.size > 0) {
2288
- this.deps.on_closed(result.close_result);
2289
- }
2290
- return result;
2291
- } catch (error) {
2292
- this.deps.breaker.failed(Date.now(), error);
2293
- throw error;
2294
- } finally {
2295
- this._running = false;
2296
2248
  }
2249
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
2297
2250
  }
2298
- };
2299
-
2300
- // src/internal/autoclose-policy.ts
2301
- var import_zod6 = require("zod");
2302
- var MIN_AFTER_MS = 6e4;
2303
- var AfterSchema = import_zod6.z.object({
2304
- days: import_zod6.z.number({ message: "autocloses: after.days must be a number" }).positive("autocloses: after.days must be > 0")
2305
- }).refine((o) => o.days * 864e5 >= MIN_AFTER_MS, {
2306
- message: `autocloses: after resolves to < ${MIN_AFTER_MS}ms (one minute), which is too short to be a meaningful retention window`
2307
- });
2308
- var IsSchema = import_zod6.z.union([
2309
- import_zod6.z.string().min(1, "autocloses: is must be a non-empty event name"),
2310
- import_zod6.z.array(
2311
- import_zod6.z.string().min(1, "autocloses: is entries must be non-empty event names")
2312
- ).min(1, "autocloses: is must include at least one event name").readonly()
2313
- ]);
2314
- var ReachesSchema = import_zod6.z.number({ message: "autocloses: reaches must be a number" }).int("autocloses: reaches must be an integer").min(1, "autocloses: reaches must be >= 1");
2315
- var OrBlockSchema = import_zod6.z.object({
2316
- after: AfterSchema.optional(),
2317
- is: IsSchema.optional(),
2318
- reaches: ReachesSchema.optional()
2319
- }).strict().refine(
2320
- (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0,
2321
- {
2322
- message: "autocloses: `or` block must include at least one of after / is / reaches"
2251
+ const merged_patch = merge_patches(existing.patch, state2.patch, state2.name);
2252
+ const merged = {
2253
+ ...existing,
2254
+ state: merge_schemas(existing.state, state2.state, state2.name),
2255
+ init: merge_inits(existing.init, state2.init),
2256
+ events: { ...existing.events, ...state2.events },
2257
+ actions: { ...existing.actions, ...state2.actions },
2258
+ patch: merged_patch,
2259
+ on: { ...existing.on, ...state2.on },
2260
+ given: { ...existing.given, ...state2.given },
2261
+ snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
2262
+ throw new Error(
2263
+ `Duplicate snap strategy for state "${state2.name}"`
2264
+ );
2265
+ })() : state2.snap || existing.snap
2266
+ };
2267
+ states.set(state2.name, merged);
2268
+ for (const name of Object.keys(merged.actions)) {
2269
+ actions[name] = merged;
2323
2270
  }
2324
- );
2325
- var AutoclosePolicySchema = import_zod6.z.object({
2326
- after: AfterSchema.optional(),
2327
- is: IsSchema.optional(),
2328
- reaches: ReachesSchema.optional(),
2329
- or: OrBlockSchema.optional()
2330
- }).strict().refine(
2331
- (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0,
2332
- {
2333
- message: "autocloses: at least one of after / is / reaches / or must be specified \u2014 empty `{}` is a misconfiguration"
2271
+ for (const name of Object.keys(state2.events)) {
2272
+ if (events[name]) continue;
2273
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
2334
2274
  }
2335
- );
2336
- function compile_after(after) {
2337
- const after_ms = after.days * 864e5;
2338
- return (_stream, head) => Date.now() - head.created.getTime() >= after_ms;
2339
- }
2340
- function compile_is(is) {
2341
- const set = new Set(typeof is === "string" ? [is] : is);
2342
- return (_stream, head) => set.has(head.name);
2343
2275
  }
2344
- function compile_reaches(reaches) {
2345
- return (_stream, _head, count) => count >= reaches;
2346
- }
2347
- function compile_autoclose_policy(options) {
2348
- const parsed = AutoclosePolicySchema.parse(options);
2349
- const and_preds = [];
2350
- if (parsed.after) and_preds.push(compile_after(parsed.after));
2351
- if (parsed.is) and_preds.push(compile_is(parsed.is));
2352
- if (parsed.reaches) and_preds.push(compile_reaches(parsed.reaches));
2353
- const or_preds = [];
2354
- if (parsed.or) {
2355
- if (parsed.or.after) or_preds.push(compile_after(parsed.or.after));
2356
- if (parsed.or.is) or_preds.push(compile_is(parsed.or.is));
2357
- if (parsed.or.reaches) or_preds.push(compile_reaches(parsed.or.reaches));
2358
- }
2359
- return (stream, head, count) => {
2360
- if (and_preds.length > 0 && and_preds.every((p) => p(stream, head, count))) {
2361
- return true;
2276
+ function merge_patches(existing, incoming, state_name) {
2277
+ const merged = { ...existing };
2278
+ for (const name of Object.keys(incoming)) {
2279
+ const existing_p = existing[name];
2280
+ const incoming_p = incoming[name];
2281
+ if (!existing_p) {
2282
+ merged[name] = incoming_p;
2283
+ continue;
2362
2284
  }
2363
- if (or_preds.some((p) => p(stream, head, count))) {
2364
- return true;
2285
+ const existing_is_default = existing_p._passthrough;
2286
+ const incoming_is_default = incoming_p._passthrough;
2287
+ if (!existing_is_default && !incoming_is_default && existing_p !== incoming_p) {
2288
+ throw new Error(
2289
+ `Duplicate custom patch for event "${name}" in state "${state_name}"`
2290
+ );
2365
2291
  }
2366
- return false;
2367
- };
2292
+ if (existing_is_default && !incoming_is_default) {
2293
+ merged[name] = incoming_p;
2294
+ }
2295
+ }
2296
+ return merged;
2368
2297
  }
2369
-
2370
- // src/internal/build-classify.ts
2371
- var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
2372
- function classify_registry(registry, states) {
2373
- const statics = /* @__PURE__ */ new Map();
2374
- const reactive_events = /* @__PURE__ */ new Set();
2375
- const event_to_lanes = /* @__PURE__ */ new Map();
2376
- let has_dynamic_resolvers = false;
2377
- for (const [name, register] of Object.entries(registry.events)) {
2378
- if (register.reactions.size > 0) reactive_events.add(name);
2379
- for (const reaction of register.reactions.values()) {
2380
- if (typeof reaction.resolver === "function") {
2381
- has_dynamic_resolvers = true;
2382
- event_to_lanes.set(name, ALL_LANES);
2383
- } else {
2384
- const { target, source, priority = 0, lane } = reaction.resolver;
2385
- const lane_name = lane ?? "default";
2386
- const existing_lanes = event_to_lanes.get(name);
2387
- if (existing_lanes !== ALL_LANES) {
2388
- const set = existing_lanes ?? /* @__PURE__ */ new Set();
2389
- set.add(lane_name);
2390
- event_to_lanes.set(name, set);
2391
- }
2392
- const key = `${target}|${source ?? ""}`;
2393
- const existing = statics.get(key);
2394
- if (!existing) {
2395
- statics.set(key, { stream: target, source, priority, lane });
2396
- } else {
2397
- if ((existing.lane ?? void 0) !== (lane ?? void 0))
2398
- throw new Error(
2399
- `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
2400
- );
2401
- if (priority > existing.priority) {
2402
- statics.set(key, { ...existing, priority });
2403
- }
2404
- }
2298
+ function merge_event_register(target, source) {
2299
+ for (const [event_name, source_reg] of Object.entries(source)) {
2300
+ const target_reg = target[event_name];
2301
+ if (!target_reg) continue;
2302
+ for (const [name, reaction] of source_reg.reactions) {
2303
+ const existing = target_reg.reactions.get(name);
2304
+ if (existing !== void 0 && existing !== reaction)
2305
+ throw new Error(
2306
+ `Duplicate reaction "${name}" for event "${event_name}". Reaction handlers are keyed by function name; rename one of them.`
2307
+ );
2308
+ target_reg.reactions.set(name, reaction);
2309
+ }
2310
+ }
2311
+ }
2312
+ function merge_projection(proj, events) {
2313
+ for (const event_name of Object.keys(proj.events)) {
2314
+ const proj_register = proj.events[event_name];
2315
+ const existing = events[event_name];
2316
+ if (!existing) {
2317
+ events[event_name] = {
2318
+ schema: proj_register.schema,
2319
+ reactions: new Map(proj_register.reactions)
2320
+ };
2321
+ } else {
2322
+ for (const [name, reaction] of proj_register.reactions) {
2323
+ let key = name;
2324
+ while (existing.reactions.has(key)) key = `${key}_p`;
2325
+ existing.reactions.set(key, reaction);
2405
2326
  }
2406
2327
  }
2407
2328
  }
2408
- const event_to_state = /* @__PURE__ */ new Map();
2409
- for (const merged of states.values()) {
2410
- for (const event_name of Object.keys(merged.events)) {
2411
- event_to_state.set(event_name, merged);
2412
- }
2413
- }
2329
+ }
2330
+ var _this_ = ({ stream }) => ({
2331
+ source: stream,
2332
+ target: stream
2333
+ });
2334
+
2335
+ // src/internal/builder-utils.ts
2336
+ function register_lane(config2, lanes) {
2337
+ if (config2.name === DEFAULT_LANE)
2338
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
2339
+ if (lanes.some((l) => l.name === config2.name))
2340
+ throw new Error(`Lane "${config2.name}" was already declared`);
2341
+ lanes.push(config2);
2342
+ }
2343
+ function reaction_on(event, events, builder) {
2344
+ const register = (handler, options, schedule) => {
2345
+ if (!handler.name)
2346
+ throw new Error(
2347
+ `Reaction handler for "${String(event)}" must be a named function`
2348
+ );
2349
+ if (events[event].reactions.has(handler.name))
2350
+ throw new Error(
2351
+ `Duplicate reaction "${handler.name}" for event "${String(event)}". Reaction handlers are keyed by function name; rename one of them.`
2352
+ );
2353
+ if (schedule && typeof schedule !== "function") assert_defer_when(schedule);
2354
+ const reaction = {
2355
+ handler: schedule ? make_deferred(handler, schedule) : handler,
2356
+ resolver: _this_,
2357
+ options: {
2358
+ blockOnError: options?.blockOnError ?? true,
2359
+ maxRetries: options?.maxRetries ?? 3,
2360
+ backoff: options?.backoff
2361
+ }
2362
+ };
2363
+ events[event].reactions.set(handler.name, reaction);
2364
+ return Object.assign(builder, {
2365
+ to(resolver) {
2366
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2367
+ return builder;
2368
+ }
2369
+ });
2370
+ };
2414
2371
  return {
2415
- static_targets: [...statics.values()],
2416
- has_dynamic_resolvers,
2417
- reactive_events,
2418
- event_to_state,
2419
- event_to_lanes
2372
+ do: (handler, options) => register(handler, options),
2373
+ defer: (schedule) => ({
2374
+ do: (handler, options) => register(handler, options, schedule)
2375
+ })
2420
2376
  };
2421
2377
  }
2422
2378
 
2423
2379
  // src/internal/circuit-breaker.ts
2424
- var import_zod7 = require("zod");
2380
+ var import_zod9 = require("zod");
2425
2381
  var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
2426
2382
  var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
2427
- var CircuitBreakerOptionsSchema = import_zod7.z.object({
2428
- failureThreshold: import_zod7.z.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
2429
- cooldownMs: import_zod7.z.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
2383
+ var CircuitBreakerOptionsSchema = import_zod9.z.object({
2384
+ failureThreshold: import_zod9.z.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
2385
+ cooldownMs: import_zod9.z.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
2430
2386
  });
2431
2387
  var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
2432
2388
  var CircuitBreaker = class {
@@ -2507,6 +2463,203 @@ var CircuitBreaker = class {
2507
2463
  }
2508
2464
  };
2509
2465
 
2466
+ // src/internal/close-cycle.ts
2467
+ var SAFETY_PROBE_PAGE_SIZE = 1e3;
2468
+ async function run_close_cycle(targets, deps) {
2469
+ const target_map = new Map(targets.map((t) => [t.stream, t]));
2470
+ const streams = [...target_map.keys()];
2471
+ const skipped = [];
2472
+ const stream_info = await scan_stream_heads(streams);
2473
+ const safe = await partition_by_safety(
2474
+ stream_info,
2475
+ deps.reactive_events_size,
2476
+ skipped,
2477
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
2478
+ );
2479
+ if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
2480
+ const { guarded, guard_events } = await guard_with_tombstones(
2481
+ safe,
2482
+ stream_info,
2483
+ deps.correlation,
2484
+ deps.tombstone,
2485
+ skipped
2486
+ );
2487
+ if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
2488
+ const seed_states = await load_restart_seeds(
2489
+ guarded,
2490
+ target_map,
2491
+ stream_info,
2492
+ deps.event_to_state,
2493
+ deps.load,
2494
+ deps.logger
2495
+ );
2496
+ await run_archive_callbacks(guarded, target_map);
2497
+ const truncated = await truncate_and_warm_cache(
2498
+ guarded,
2499
+ seed_states,
2500
+ guard_events,
2501
+ deps.correlation
2502
+ );
2503
+ return { truncated, skipped };
2504
+ }
2505
+ async function scan_stream_heads(streams) {
2506
+ const stats = await store2().query_stats(streams, {
2507
+ exclude: [SNAP_EVENT]
2508
+ });
2509
+ const out = /* @__PURE__ */ new Map();
2510
+ for (const [stream, { head }] of stats) {
2511
+ if (head.name === TOMBSTONE_EVENT) continue;
2512
+ out.set(stream, {
2513
+ max_id: head.id,
2514
+ version: head.version,
2515
+ last_event_name: head.name
2516
+ });
2517
+ }
2518
+ return out;
2519
+ }
2520
+ async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
2521
+ if (reactive_events_size === 0) return [...stream_info.keys()];
2522
+ const pending_set = /* @__PURE__ */ new Set();
2523
+ const source_regex = /* @__PURE__ */ new Map();
2524
+ const get_regex = (source) => {
2525
+ let re = source_regex.get(source);
2526
+ if (!re) {
2527
+ re = new RegExp(source);
2528
+ source_regex.set(source, re);
2529
+ }
2530
+ return re;
2531
+ };
2532
+ const targets = [...stream_info.keys()];
2533
+ let after;
2534
+ for (; ; ) {
2535
+ let last;
2536
+ const { count } = await store2().query_streams(
2537
+ (position) => {
2538
+ last = position.stream;
2539
+ const source_re = position.source ? get_regex(position.source) : void 0;
2540
+ for (const [stream, info] of stream_info) {
2541
+ if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
2542
+ pending_set.add(stream);
2543
+ }
2544
+ }
2545
+ },
2546
+ { after, limit: page_size, source_matches: targets }
2547
+ );
2548
+ if (count < page_size) break;
2549
+ after = last;
2550
+ }
2551
+ const safe = [];
2552
+ for (const [stream] of stream_info) {
2553
+ if (pending_set.has(stream)) skipped.push(stream);
2554
+ else safe.push(stream);
2555
+ }
2556
+ return safe;
2557
+ }
2558
+ async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
2559
+ const guarded = [];
2560
+ const guard_events = /* @__PURE__ */ new Map();
2561
+ await Promise.all(
2562
+ safe.map(async (stream) => {
2563
+ const info = stream_info.get(stream);
2564
+ const committed = await tombstone2(stream, info.version, correlation);
2565
+ if (committed) {
2566
+ guarded.push(stream);
2567
+ guard_events.set(stream, { id: committed.id, stream });
2568
+ } else {
2569
+ skipped.push(stream);
2570
+ }
2571
+ })
2572
+ );
2573
+ return { guarded, guard_events };
2574
+ }
2575
+ async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
2576
+ const seed_states = /* @__PURE__ */ new Map();
2577
+ await Promise.all(
2578
+ guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
2579
+ const last_event_name = stream_info.get(stream).last_event_name;
2580
+ const owner_state = event_to_state.get(last_event_name);
2581
+ if (!owner_state) {
2582
+ logger.error(
2583
+ `Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
2584
+ );
2585
+ return;
2586
+ }
2587
+ const snap2 = await load2(owner_state, { stream });
2588
+ seed_states.set(stream, snap2.state);
2589
+ })
2590
+ );
2591
+ return seed_states;
2592
+ }
2593
+ async function run_archive_callbacks(guarded, target_map) {
2594
+ for (const stream of guarded) {
2595
+ const archive_fn = target_map.get(stream)?.archive;
2596
+ if (archive_fn) await archive_fn();
2597
+ }
2598
+ }
2599
+ async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
2600
+ const trunc_targets = guarded.map((stream) => {
2601
+ const snapshot = seed_states.get(stream);
2602
+ const guard = guard_events.get(stream);
2603
+ return {
2604
+ stream,
2605
+ snapshot,
2606
+ meta: {
2607
+ correlation,
2608
+ causation: {
2609
+ event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
2610
+ }
2611
+ }
2612
+ };
2613
+ });
2614
+ const truncated = await store2().truncate(trunc_targets);
2615
+ await Promise.all(
2616
+ guarded.map(async (stream) => {
2617
+ const entry = truncated.get(stream);
2618
+ const state2 = seed_states.get(stream);
2619
+ if (state2 && entry) {
2620
+ await cache2().set(stream, {
2621
+ state: state2,
2622
+ version: entry.committed.version,
2623
+ event_id: entry.committed.id,
2624
+ patches: 0,
2625
+ snaps: 1
2626
+ });
2627
+ } else {
2628
+ await cache2().invalidate(stream);
2629
+ }
2630
+ })
2631
+ );
2632
+ return truncated;
2633
+ }
2634
+
2635
+ // src/internal/close-signal.ts
2636
+ var CloseSignal = class extends Error {
2637
+ /**
2638
+ * Stream to close. Omitted → the reaction's own lease stream (a self-close).
2639
+ * The synthesized autoclose reaction sets it to the aggregate stream, since
2640
+ * its lease runs on a synthetic `__autoclose__` target.
2641
+ */
2642
+ stream;
2643
+ /** Optional archive callback to run while the stream is guarded. */
2644
+ archive;
2645
+ /**
2646
+ * Watermark to ack the requesting reaction to before the close runs. The
2647
+ * close-cycle safety guard skips a stream whose subscriptions (matched by
2648
+ * source) lag the head; the autoclose reaction's `source` is the aggregate,
2649
+ * so it must advance its own watermark to the live head id it evaluated
2650
+ * against — otherwise it blocks its own close. Defaults to the triggering
2651
+ * event id when omitted.
2652
+ */
2653
+ at;
2654
+ constructor(opts) {
2655
+ super("reaction requested close");
2656
+ this.name = "CloseSignal";
2657
+ this.stream = opts?.stream;
2658
+ this.archive = opts?.archive;
2659
+ this.at = opts?.at;
2660
+ }
2661
+ };
2662
+
2510
2663
  // src/internal/correlate-cycle.ts
2511
2664
  var CorrelateCycle = class {
2512
2665
  _checkpoint = -1;
@@ -2611,33 +2764,135 @@ var CorrelateCycle = class {
2611
2764
  return { subscribed: 0, last_id };
2612
2765
  }
2613
2766
  /**
2614
- * Start a periodic correlation worker. Returns false if one is already
2615
- * running. Errors from `correlate()` are routed through `log()` so they
2616
- * land in the configured logger (the timer keeps running on failure).
2767
+ * Start a periodic correlation worker. Returns false if one is already
2768
+ * running. Errors from `correlate()` are routed through `log()` so they
2769
+ * land in the configured logger (the timer keeps running on failure).
2770
+ */
2771
+ start_polling(query = {}, frequency = 1e4, callback) {
2772
+ if (this._timer) return false;
2773
+ const limit = query.limit || 100;
2774
+ this._timer = setInterval(
2775
+ () => this.correlate({ ...query, after: this._checkpoint, limit }).then((result) => {
2776
+ if (callback && result.subscribed) callback(result.subscribed);
2777
+ }).catch((err) => log().error(err)),
2778
+ frequency
2779
+ );
2780
+ return true;
2781
+ }
2782
+ /** Stop the periodic correlation worker. Idempotent. */
2783
+ stop_polling() {
2784
+ if (this._timer) {
2785
+ clearInterval(this._timer);
2786
+ this._timer = void 0;
2787
+ }
2788
+ }
2789
+ };
2790
+
2791
+ // src/internal/correlator.ts
2792
+ var import_node_crypto = require("crypto");
2793
+ var BASE = 36;
2794
+ var SEG_WIDTH = 4;
2795
+ var SEG_SPACE = BASE ** SEG_WIDTH;
2796
+ function seg(n) {
2797
+ return n.toString(BASE).padStart(SEG_WIDTH, "0");
2798
+ }
2799
+ var default_correlator = ({ state: state2, action: action2 }) => {
2800
+ const s = state2.slice(0, SEG_WIDTH).toLowerCase();
2801
+ const a = action2.slice(0, SEG_WIDTH).toLowerCase();
2802
+ const ts = seg(Date.now() % SEG_SPACE);
2803
+ const rnd = seg((0, import_node_crypto.randomInt)(SEG_SPACE));
2804
+ return `${s}-${a}-${ts}${rnd}`;
2805
+ };
2806
+ function close_correlation(correlator, actor) {
2807
+ return correlator({
2808
+ state: "$close",
2809
+ action: "close",
2810
+ stream: "$close",
2811
+ actor
2812
+ });
2813
+ }
2814
+
2815
+ // src/internal/drain-cycle.ts
2816
+ var import_node_crypto2 = require("crypto");
2817
+
2818
+ // src/internal/defer-timer.ts
2819
+ var MAX_TIMER_DELAY_MS = 2147483647;
2820
+ var DeferTimer = class {
2821
+ _due = /* @__PURE__ */ new Map();
2822
+ _timer;
2823
+ _on_wake;
2824
+ /**
2825
+ * @param on_wake - invoked once each time the earliest due-time elapses,
2826
+ * after the come-due entries have been removed. Consumers use it to
2827
+ * re-arm their loop (the drain sets its `armed` flag; autoclose runs a
2828
+ * tick).
2829
+ */
2830
+ constructor(on_wake) {
2831
+ this._on_wake = on_wake;
2832
+ }
2833
+ /** Number of currently parked streams. */
2834
+ get size() {
2835
+ return this._due.size;
2836
+ }
2837
+ /**
2838
+ * True while `stream` is parked with a due-time still in the future.
2839
+ * Consumers skip work for deferred streams until their window elapses.
2617
2840
  */
2618
- start_polling(query = {}, frequency = 1e4, callback) {
2619
- if (this._timer) return false;
2620
- const limit = query.limit || 100;
2621
- this._timer = setInterval(
2622
- () => this.correlate({ ...query, after: this._checkpoint, limit }).then((result) => {
2623
- if (callback && result.subscribed) callback(result.subscribed);
2624
- }).catch((err) => log().error(err)),
2625
- frequency
2841
+ is_deferred = (stream) => {
2842
+ const next = this._due.get(stream);
2843
+ return next !== void 0 && next > Date.now();
2844
+ };
2845
+ /**
2846
+ * Park `stream` for a re-visit at `at` (ms since epoch). A plain
2847
+ * overwrite: the caller computes the authoritative next-visit for the
2848
+ * stream (the drain's `handle` already reconciles a stream's reactions
2849
+ * into one result per cycle), so there is no stale value to merge against.
2850
+ */
2851
+ set(stream, at) {
2852
+ this._due.set(stream, at);
2853
+ }
2854
+ /** Drop `stream` from the parked set (e.g. on a successful ack or block). */
2855
+ delete(stream) {
2856
+ this._due.delete(stream);
2857
+ }
2858
+ /**
2859
+ * (Re)schedule the wake timer at the earliest pending due-time. Idempotent
2860
+ * — collapses many parked streams into a single timer. A no-op clears any
2861
+ * pending timer when the map is empty.
2862
+ *
2863
+ * The timer is `unref()`-ed so pending re-visits never keep the process
2864
+ * alive on their own.
2865
+ */
2866
+ schedule() {
2867
+ if (this._timer) clearTimeout(this._timer);
2868
+ if (this._due.size === 0) {
2869
+ this._timer = void 0;
2870
+ return;
2871
+ }
2872
+ let earliest = Number.POSITIVE_INFINITY;
2873
+ for (const t of this._due.values()) if (t < earliest) earliest = t;
2874
+ const delay = Math.min(
2875
+ Math.max(0, earliest - Date.now()),
2876
+ MAX_TIMER_DELAY_MS
2626
2877
  );
2627
- return true;
2878
+ this._timer = setTimeout(() => {
2879
+ this._timer = void 0;
2880
+ const now = Date.now();
2881
+ for (const [stream, at] of this._due)
2882
+ if (at <= now) this._due.delete(stream);
2883
+ this._on_wake();
2884
+ }, delay);
2885
+ this._timer.unref();
2628
2886
  }
2629
- /** Stop the periodic correlation worker. Idempotent. */
2630
- stop_polling() {
2887
+ /** Cancel any pending wake timer. Idempotent. Leaves the parked set intact. */
2888
+ stop() {
2631
2889
  if (this._timer) {
2632
- clearInterval(this._timer);
2890
+ clearTimeout(this._timer);
2633
2891
  this._timer = void 0;
2634
2892
  }
2635
2893
  }
2636
2894
  };
2637
2895
 
2638
- // src/internal/drain-cycle.ts
2639
- var import_node_crypto2 = require("crypto");
2640
-
2641
2896
  // src/internal/drain-ratio.ts
2642
2897
  var RATIO_MIN = 0.2;
2643
2898
  var RATIO_MAX = 0.8;
@@ -2673,6 +2928,7 @@ async function fetch(leased, eventLimit) {
2673
2928
  }
2674
2929
  var ack = (leases) => store2().ack(leases);
2675
2930
  var block = (leases) => store2().block(leases);
2931
+ var defer = (input, deferred_at) => store2().defer(input, deferred_at);
2676
2932
  var subscribe = (streams) => store2().subscribe(streams);
2677
2933
 
2678
2934
  // src/internal/event-sourcing.ts
@@ -3150,6 +3406,7 @@ function build_drain(logger) {
3150
3406
  fetch,
3151
3407
  ack,
3152
3408
  block,
3409
+ defer,
3153
3410
  subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
3154
3411
  if (!result.subscribed) return;
3155
3412
  const lanes = new Set(streams.map((s) => s.lane ?? "default"));
@@ -3218,7 +3475,8 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
3218
3475
  fetched: [],
3219
3476
  handled: [],
3220
3477
  acked: [],
3221
- blocked: []
3478
+ blocked: [],
3479
+ closeable: []
3222
3480
  };
3223
3481
  }
3224
3482
  const fetched = await ops.fetch(active, eventLimit);
@@ -3252,12 +3510,23 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
3252
3510
  })
3253
3511
  );
3254
3512
  const acked = await ops.ack(
3255
- handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
3513
+ handled.filter((h) => h.defer === void 0 && (h.handled > 0 || !h.error)).map((h) => ({ ...h.lease, at: h.acked_at }))
3256
3514
  );
3257
3515
  const blocked = await ops.block(
3258
3516
  handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
3259
3517
  );
3260
- return { leased, fetched, handled, acked, blocked };
3518
+ const deferrals = handled.filter((h) => h.defer !== void 0);
3519
+ if (deferrals.length) {
3520
+ const by_time = /* @__PURE__ */ new Map();
3521
+ for (const h of deferrals) {
3522
+ const list = by_time.get(h.defer);
3523
+ if (list) list.push(h.lease.stream);
3524
+ else by_time.set(h.defer, [h.lease.stream]);
3525
+ }
3526
+ for (const [at, streams] of by_time) await ops.defer(streams, at);
3527
+ }
3528
+ const closeable = handled.filter((h) => h.close !== void 0).map((h) => h.close);
3529
+ return { leased, fetched, handled, acked, blocked, closeable };
3261
3530
  }
3262
3531
  var EMPTY_DRAIN = {
3263
3532
  fetched: [],
@@ -3270,14 +3539,16 @@ var DrainController = class {
3270
3539
  _locked = false;
3271
3540
  _ratio = 0.5;
3272
3541
  /**
3273
- * Per-stream backoff: `stream → next_attempt_at` (ms since epoch). Set by
3274
- * `_finalize` via `HandleResult.next_attempt_at`; cleared on successful
3275
- * ack or terminal block. Lives in process memory — per-worker pacing
3276
- * by design (see {@link BackoffOptions} for the multi-worker trade-off).
3542
+ * Per-stream re-visit schedule (#1090): `stream → next visit` (ms since
3543
+ * epoch). Holds both retry backoff (`HandleResult.next_attempt_at`) and the
3544
+ * `defer` outcome; cleared on successful ack or terminal block. Lives in
3545
+ * process memory — per-worker pacing by design (see {@link BackoffOptions}
3546
+ * for the multi-worker trade-off). Its wake re-arms drain at the earliest
3547
+ * pending visit.
3277
3548
  */
3278
- _backoff = /* @__PURE__ */ new Map();
3279
- /** Timer re-arming drain at the earliest pending `next_attempt_at`. */
3280
- _backoff_timer;
3549
+ _defer = new DeferTimer(() => {
3550
+ this._armed = true;
3551
+ });
3281
3552
  /** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
3282
3553
  _worker;
3283
3554
  _stopped = false;
@@ -3297,32 +3568,6 @@ var DrainController = class {
3297
3568
  get armed() {
3298
3569
  return this._armed;
3299
3570
  }
3300
- /** Returns true when `stream` is currently within a backoff window. */
3301
- is_deferred = (stream) => {
3302
- const next = this._backoff.get(stream);
3303
- return next !== void 0 && next > Date.now();
3304
- };
3305
- /**
3306
- * Schedule the next drain re-arm at the earliest pending backoff
3307
- * expiry. Called only when the backoff map is non-empty (caller guard).
3308
- * Idempotent — collapses many simultaneously deferred streams into a
3309
- * single timer.
3310
- */
3311
- schedule_backoff_wake() {
3312
- if (this._backoff_timer) clearTimeout(this._backoff_timer);
3313
- let earliest = Number.POSITIVE_INFINITY;
3314
- for (const t of this._backoff.values()) if (t < earliest) earliest = t;
3315
- const delay = Math.max(0, earliest - Date.now());
3316
- this._backoff_timer = setTimeout(() => {
3317
- this._backoff_timer = void 0;
3318
- const now = Date.now();
3319
- for (const [stream, at] of this._backoff) {
3320
- if (at <= now) this._backoff.delete(stream);
3321
- }
3322
- this._armed = true;
3323
- }, delay);
3324
- this._backoff_timer.unref();
3325
- }
3326
3571
  /** Lane this controller drains (undefined = legacy single-lane span). */
3327
3572
  get lane() {
3328
3573
  return this._deps.lane;
@@ -3354,6 +3599,7 @@ var DrainController = class {
3354
3599
  clearTimeout(this._worker);
3355
3600
  this._worker = void 0;
3356
3601
  }
3602
+ this._defer.stop();
3357
3603
  }
3358
3604
  /** Run one drain pass. Short-circuits when not armed or already running. */
3359
3605
  async drain(options = {}) {
@@ -3379,7 +3625,7 @@ var DrainController = class {
3379
3625
  leading,
3380
3626
  eventLimit,
3381
3627
  leaseMillis,
3382
- this._backoff.size > 0 ? this.is_deferred : void 0,
3628
+ this._defer.size > 0 ? this._defer.is_deferred : void 0,
3383
3629
  this._deps.lane
3384
3630
  );
3385
3631
  this._deps.breaker.passed();
@@ -3387,19 +3633,19 @@ var DrainController = class {
3387
3633
  this._armed = false;
3388
3634
  return EMPTY_DRAIN;
3389
3635
  }
3390
- const { leased, fetched, handled, acked, blocked } = cycle;
3636
+ const { leased, fetched, handled, acked, blocked, closeable } = cycle;
3391
3637
  trace_cycle(this._deps.logger, leased, fetched, handled, acked, blocked);
3392
3638
  this._ratio = compute_lag_lead_ratio(handled, lagging, leading);
3393
- for (const lease of acked) this._backoff.delete(lease.stream);
3394
- for (const lease of blocked) this._backoff.delete(lease.stream);
3639
+ for (const lease of acked) this._defer.delete(lease.stream);
3640
+ for (const lease of blocked) this._defer.delete(lease.stream);
3395
3641
  for (const h of handled) {
3396
- if (h.next_attempt_at !== void 0 && !h.block) {
3397
- this._backoff.set(h.lease.stream, h.next_attempt_at);
3398
- }
3642
+ const next = h.defer ?? (h.block ? void 0 : h.next_attempt_at);
3643
+ if (next !== void 0) this._defer.set(h.lease.stream, next);
3399
3644
  }
3400
- if (this._backoff.size > 0) this.schedule_backoff_wake();
3645
+ if (this._defer.size > 0) this._defer.schedule();
3401
3646
  if (acked.length) this._deps.on_acked(acked);
3402
3647
  if (blocked.length) this._deps.on_blocked(blocked);
3648
+ if (closeable.length) await this._deps.on_close(closeable);
3403
3649
  const has_errors = handled.some(({ error }) => error);
3404
3650
  if (!acked.length && !blocked.length && !has_errors) this._armed = false;
3405
3651
  return { fetched, leased, acked, blocked };
@@ -3412,154 +3658,6 @@ var DrainController = class {
3412
3658
  }
3413
3659
  };
3414
3660
 
3415
- // src/internal/merge.ts
3416
- var import_zod8 = require("zod");
3417
- function base_type_name(zodType) {
3418
- let t = zodType;
3419
- while (typeof t.unwrap === "function") {
3420
- t = t.unwrap();
3421
- }
3422
- return t.constructor.name;
3423
- }
3424
- function merge_schemas(existing, incoming, state_name) {
3425
- if (existing instanceof import_zod8.ZodObject && incoming instanceof import_zod8.ZodObject) {
3426
- const existing_shape = existing.shape;
3427
- const incoming_shape = incoming.shape;
3428
- for (const key of Object.keys(incoming_shape)) {
3429
- if (key in existing_shape) {
3430
- const existing_base = base_type_name(existing_shape[key]);
3431
- const incoming_base = base_type_name(incoming_shape[key]);
3432
- if (existing_base !== incoming_base) {
3433
- throw new Error(
3434
- `Schema conflict in "${state_name}": key "${key}" has type "${existing_base}" but incoming partial declares "${incoming_base}"`
3435
- );
3436
- }
3437
- }
3438
- }
3439
- return existing.extend(incoming_shape);
3440
- }
3441
- return existing;
3442
- }
3443
- function merge_inits(existing, incoming) {
3444
- return () => ({ ...existing(), ...incoming() });
3445
- }
3446
- function register_state(state2, states, actions, events) {
3447
- const existing = states.get(state2.name);
3448
- if (existing) {
3449
- merge_into_existing(state2, existing, states, actions, events);
3450
- } else {
3451
- register_new_state(state2, states, actions, events);
3452
- }
3453
- }
3454
- function register_new_state(state2, states, actions, events) {
3455
- states.set(state2.name, state2);
3456
- for (const name of Object.keys(state2.actions)) {
3457
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
3458
- actions[name] = state2;
3459
- }
3460
- for (const name of Object.keys(state2.events)) {
3461
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
3462
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
3463
- }
3464
- }
3465
- function merge_into_existing(state2, existing, states, actions, events) {
3466
- for (const name of Object.keys(state2.actions)) {
3467
- if (existing.actions[name] === state2.actions[name]) continue;
3468
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
3469
- }
3470
- for (const name of Object.keys(state2.events)) {
3471
- if (existing.events[name] === state2.events[name]) continue;
3472
- if (existing.events[name]) {
3473
- throw new Error(
3474
- `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.`
3475
- );
3476
- }
3477
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
3478
- }
3479
- const merged_patch = merge_patches(existing.patch, state2.patch, state2.name);
3480
- const merged = {
3481
- ...existing,
3482
- state: merge_schemas(existing.state, state2.state, state2.name),
3483
- init: merge_inits(existing.init, state2.init),
3484
- events: { ...existing.events, ...state2.events },
3485
- actions: { ...existing.actions, ...state2.actions },
3486
- patch: merged_patch,
3487
- on: { ...existing.on, ...state2.on },
3488
- given: { ...existing.given, ...state2.given },
3489
- snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
3490
- throw new Error(
3491
- `Duplicate snap strategy for state "${state2.name}"`
3492
- );
3493
- })() : state2.snap || existing.snap
3494
- };
3495
- states.set(state2.name, merged);
3496
- for (const name of Object.keys(merged.actions)) {
3497
- actions[name] = merged;
3498
- }
3499
- for (const name of Object.keys(state2.events)) {
3500
- if (events[name]) continue;
3501
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
3502
- }
3503
- }
3504
- function merge_patches(existing, incoming, state_name) {
3505
- const merged = { ...existing };
3506
- for (const name of Object.keys(incoming)) {
3507
- const existing_p = existing[name];
3508
- const incoming_p = incoming[name];
3509
- if (!existing_p) {
3510
- merged[name] = incoming_p;
3511
- continue;
3512
- }
3513
- const existing_is_default = existing_p._passthrough;
3514
- const incoming_is_default = incoming_p._passthrough;
3515
- if (!existing_is_default && !incoming_is_default && existing_p !== incoming_p) {
3516
- throw new Error(
3517
- `Duplicate custom patch for event "${name}" in state "${state_name}"`
3518
- );
3519
- }
3520
- if (existing_is_default && !incoming_is_default) {
3521
- merged[name] = incoming_p;
3522
- }
3523
- }
3524
- return merged;
3525
- }
3526
- function merge_event_register(target, source) {
3527
- for (const [event_name, source_reg] of Object.entries(source)) {
3528
- const target_reg = target[event_name];
3529
- if (!target_reg) continue;
3530
- for (const [name, reaction] of source_reg.reactions) {
3531
- const existing = target_reg.reactions.get(name);
3532
- if (existing !== void 0 && existing !== reaction)
3533
- throw new Error(
3534
- `Duplicate reaction "${name}" for event "${event_name}". Reaction handlers are keyed by function name; rename one of them.`
3535
- );
3536
- target_reg.reactions.set(name, reaction);
3537
- }
3538
- }
3539
- }
3540
- function merge_projection(proj, events) {
3541
- for (const event_name of Object.keys(proj.events)) {
3542
- const proj_register = proj.events[event_name];
3543
- const existing = events[event_name];
3544
- if (!existing) {
3545
- events[event_name] = {
3546
- schema: proj_register.schema,
3547
- reactions: new Map(proj_register.reactions)
3548
- };
3549
- } else {
3550
- for (const [name, reaction] of proj_register.reactions) {
3551
- let key = name;
3552
- while (existing.reactions.has(key)) key = `${key}_p`;
3553
- existing.reactions.set(key, reaction);
3554
- }
3555
- }
3556
- }
3557
- }
3558
- var _this_ = ({ stream }) => ({
3559
- source: stream,
3560
- target: stream
3561
- });
3562
-
3563
3661
  // src/internal/reactions.ts
3564
3662
  function finalize(lease, handled, at, error, options, logger, failed_at) {
3565
3663
  if (!error) return { lease, handled, acked_at: at };
@@ -3615,6 +3713,25 @@ function build_handle(deps) {
3615
3713
  at = event.id;
3616
3714
  handled++;
3617
3715
  } catch (error) {
3716
+ if (error instanceof DeferSignal)
3717
+ return {
3718
+ lease,
3719
+ handled,
3720
+ acked_at: at,
3721
+ defer: resolve_defer_at(error.when, event)
3722
+ };
3723
+ if (error instanceof CloseSignal)
3724
+ return {
3725
+ lease,
3726
+ handled: handled + 1,
3727
+ // Advance to the live head the handler evaluated against (when
3728
+ // provided) so the close-cycle guard sees this reaction caught up.
3729
+ acked_at: error.at ?? event.id,
3730
+ // Close the signalled stream (the autoclose reaction's aggregate,
3731
+ // which differs from its synthetic lease stream); a self-closing
3732
+ // user reaction omits it and closes its own lease stream.
3733
+ close: { stream: error.stream ?? stream, archive: error.archive }
3734
+ };
3618
3735
  return finalize(
3619
3736
  lease,
3620
3737
  handled,
@@ -3717,6 +3834,7 @@ var SettleLoop = class {
3717
3834
  // src/act.ts
3718
3835
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
3719
3836
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
3837
+ var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
3720
3838
  var Act = class {
3721
3839
  _emitter = new import_node_events.default();
3722
3840
  /** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
@@ -3758,15 +3876,6 @@ var Act = class {
3758
3876
  * @internal
3759
3877
  */
3760
3878
  _autoclose_config;
3761
- /**
3762
- * App-level autoclose controller. `undefined` when no state declares
3763
- * `.autocloses(...)` — the controller is never constructed, so
3764
- * `start_correlations()` / `stop_correlations()` skip the autoclose
3765
- * lifecycle and the orchestrator pays zero per-tick cost.
3766
- *
3767
- * @internal
3768
- */
3769
- _autoclose;
3770
3879
  /**
3771
3880
  * Emit a lifecycle event. The payload type is inferred from the event name
3772
3881
  * via {@link ActLifecycleEvents}.
@@ -3907,6 +4016,62 @@ var Act = class {
3907
4016
  bound_forget: this._bound_forget
3908
4017
  });
3909
4018
  this._handle_batch = build_handle_batch(this._logger);
4019
+ for (const st of this._states.values()) {
4020
+ const predicate = st.autoclose;
4021
+ if (!predicate) continue;
4022
+ const after_ms = st.autoclose_after_ms;
4023
+ const archiver = st.archive;
4024
+ const reaction = {
4025
+ // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
4026
+ // per-aggregate `__autoclose__` key — so the autoclose reaction never
4027
+ // shares a watermark with the aggregate's own reactions. A shared
4028
+ // watermark would let autoclose's defer short-circuit the aggregate's
4029
+ // other reactions (the "a defer affects all reactions on a stream"
4030
+ // hazard). The close still targets the aggregate (`source`).
4031
+ resolver: (e) => ({
4032
+ target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
4033
+ source: e.stream
4034
+ }),
4035
+ // Never block on autoclose: a transient query/store error should retry,
4036
+ // not quarantine the synthetic stream.
4037
+ options: { blockOnError: false, maxRetries: 3 },
4038
+ handler: async (event) => {
4039
+ const aggregate = event.stream;
4040
+ const config2 = this._autoclose_config;
4041
+ if (!in_autoclose_window(config2.autocloseWindow, /* @__PURE__ */ new Date()))
4042
+ throw new DeferSignal({
4043
+ at: new Date(Date.now() + config2.autocloseCycleMinutes * 6e4)
4044
+ });
4045
+ const stats = await store2().query_stats([aggregate], {
4046
+ count: true,
4047
+ exclude: [TOMBSTONE_EVENT]
4048
+ });
4049
+ const entry = stats.get(aggregate);
4050
+ if (!entry) return;
4051
+ const head = entry.head;
4052
+ if (predicate(aggregate, head, entry.count))
4053
+ throw new CloseSignal({
4054
+ stream: aggregate,
4055
+ archive: archiver ? () => archiver(aggregate, head) : void 0,
4056
+ // Ack this reaction's own watermark to the live head so the
4057
+ // close-cycle guard (which matches subscriptions by source =
4058
+ // aggregate) sees it caught up instead of blocking its own close.
4059
+ at: head.id
4060
+ });
4061
+ if (after_ms !== void 0)
4062
+ throw new DeferSignal({
4063
+ at: new Date(head.created.getTime() + after_ms)
4064
+ });
4065
+ }
4066
+ };
4067
+ const key = `__autoclose_${st.name}`;
4068
+ for (const event_name of Object.keys(st.events)) {
4069
+ this.registry.events[event_name]?.reactions.set(
4070
+ key,
4071
+ reaction
4072
+ );
4073
+ }
4074
+ }
3910
4075
  const {
3911
4076
  static_targets,
3912
4077
  has_dynamic_resolvers,
@@ -3955,6 +4120,22 @@ var Act = class {
3955
4120
  handle_batch: this._handle_batch,
3956
4121
  on_acked: (acked) => this.emit("acked", acked),
3957
4122
  on_blocked: (blocked) => this.emit("blocked", blocked),
4123
+ // Reaction-requested close (#1090). Runs the same close machinery as
4124
+ // `app.close` (tombstone guard + archive + atomic truncate) for the
4125
+ // targets a handler signalled via `CloseSignal`. No `correlate()` here
4126
+ // — the drain that produced these targets has already correlated.
4127
+ on_close: async (targets) => {
4128
+ const close_actor = { id: "$close", name: "close" };
4129
+ const result = await run_close_cycle(targets, {
4130
+ reactive_events_size: this._reactive_events.size,
4131
+ event_to_state: this._event_to_state,
4132
+ load: this._es.load,
4133
+ tombstone: this._es.tombstone,
4134
+ logger: this._logger,
4135
+ correlation: close_correlation(this._correlator, close_actor)
4136
+ });
4137
+ this.emit("closed", result);
4138
+ },
3958
4139
  breaker: this._breaker,
3959
4140
  // Pass lane only when a true per-lane controller is active.
3960
4141
  // The all-lanes (single default) case keeps lane=undefined so
@@ -4001,27 +4182,6 @@ var Act = class {
4001
4182
  },
4002
4183
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
4003
4184
  );
4004
- const states_with_policy = [...this._states.values()].filter(
4005
- (s) => s.autoclose
4006
- );
4007
- this._autoclose = states_with_policy.length ? new AutocloseController({
4008
- // Internal cycle takes the loose `(string) => …` signature; the
4009
- // public `registry.autoclose_policy` narrows to
4010
- // `keyof TStateMap & string`. Thin lambdas bridge across the
4011
- // narrowing — runtime semantics are unchanged.
4012
- autoclose_policy: (name) => this.registry.autoclose_policy(name),
4013
- autoclose_archiver: (name) => this.registry.autoclose_archiver(name),
4014
- event_to_state: this._event_to_state,
4015
- reactive_events_size: this._reactive_events.size,
4016
- load: this._es.load,
4017
- tombstone: this._es.tombstone,
4018
- logger: this._logger,
4019
- config: this._autoclose_config,
4020
- correlator: this._correlator,
4021
- close_actor: { id: "$autoclose", name: "autoclose" },
4022
- on_closed: (result) => this.emit("closed", result),
4023
- breaker: this._breaker
4024
- }) : void 0;
4025
4185
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store2());
4026
4186
  dispose(() => this.shutdown());
4027
4187
  }
@@ -4515,7 +4675,6 @@ var Act = class {
4515
4675
  */
4516
4676
  start_correlations(query = {}, frequency = 1e4, callback) {
4517
4677
  const started = this._correlate.start_polling(query, frequency, callback);
4518
- this._autoclose?.start();
4519
4678
  return started;
4520
4679
  }
4521
4680
  /**
@@ -4537,7 +4696,6 @@ var Act = class {
4537
4696
  */
4538
4697
  stop_correlations() {
4539
4698
  this._correlate.stop_polling();
4540
- this._autoclose?.stop();
4541
4699
  }
4542
4700
  /**
4543
4701
  * Cancels any pending or active settle cycle.
@@ -5004,41 +5162,10 @@ function act() {
5004
5162
  },
5005
5163
  withActor: () => builder,
5006
5164
  withLane: (config2) => {
5007
- if (config2.name === DEFAULT_LANE)
5008
- throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
5009
- if (lanes.some((l) => l.name === config2.name))
5010
- throw new Error(`Lane "${config2.name}" was already declared`);
5011
- lanes.push(config2);
5165
+ register_lane(config2, lanes);
5012
5166
  return builder;
5013
5167
  },
5014
- on: (event) => ({
5015
- do: (handler, options) => {
5016
- const reaction = {
5017
- handler,
5018
- resolver: _this_,
5019
- options: {
5020
- blockOnError: options?.blockOnError ?? true,
5021
- maxRetries: options?.maxRetries ?? 3,
5022
- backoff: options?.backoff
5023
- }
5024
- };
5025
- if (!handler.name)
5026
- throw new Error(
5027
- `Reaction handler for "${String(event)}" must be a named function`
5028
- );
5029
- if (registry.events[event].reactions.has(handler.name))
5030
- throw new Error(
5031
- `Duplicate reaction "${handler.name}" for event "${String(event)}". Reaction handlers are keyed by function name; rename one of them.`
5032
- );
5033
- registry.events[event].reactions.set(handler.name, reaction);
5034
- return Object.assign(builder, {
5035
- to(resolver) {
5036
- reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
5037
- return builder;
5038
- }
5039
- });
5040
- }
5041
- }),
5168
+ on: (event) => reaction_on(event, registry.events, builder),
5042
5169
  build: (options) => {
5043
5170
  if (!_built) {
5044
5171
  for (const proj of pending_projections) {
@@ -5203,41 +5330,10 @@ function slice() {
5203
5330
  return builder;
5204
5331
  },
5205
5332
  withLane: (config2) => {
5206
- if (config2.name === DEFAULT_LANE)
5207
- throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
5208
- if (lanes.some((l) => l.name === config2.name))
5209
- throw new Error(`Lane "${config2.name}" was already declared`);
5210
- lanes.push(config2);
5333
+ register_lane(config2, lanes);
5211
5334
  return builder;
5212
5335
  },
5213
- on: (event) => ({
5214
- do: (handler, options) => {
5215
- const reaction = {
5216
- handler,
5217
- resolver: _this_,
5218
- options: {
5219
- blockOnError: options?.blockOnError ?? true,
5220
- maxRetries: options?.maxRetries ?? 3,
5221
- backoff: options?.backoff
5222
- }
5223
- };
5224
- if (!handler.name)
5225
- throw new Error(
5226
- `Reaction handler for "${String(event)}" must be a named function`
5227
- );
5228
- if (events[event].reactions.has(handler.name))
5229
- throw new Error(
5230
- `Duplicate reaction "${handler.name}" for event "${String(event)}". Reaction handlers are keyed by function name; rename one of them.`
5231
- );
5232
- events[event].reactions.set(handler.name, reaction);
5233
- return Object.assign(builder, {
5234
- to(resolver) {
5235
- reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
5236
- return builder;
5237
- }
5238
- });
5239
- }
5240
- }),
5336
+ on: (event) => reaction_on(event, events, builder),
5241
5337
  build: () => ({
5242
5338
  _tag: "Slice",
5243
5339
  states,
@@ -5340,20 +5436,21 @@ function action_builder(state2) {
5340
5436
  internal.disclose = disclose;
5341
5437
  return builder;
5342
5438
  },
5343
- autocloses(predicate_or_policy) {
5344
- let predicate;
5345
- if (typeof predicate_or_policy === "function") {
5346
- predicate = predicate_or_policy;
5347
- } else if (predicate_or_policy !== null && typeof predicate_or_policy === "object") {
5348
- predicate = compile_autoclose_policy(
5349
- predicate_or_policy
5439
+ autocloses(policy) {
5440
+ if (typeof policy === "function") {
5441
+ throw new Error(
5442
+ ".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."
5350
5443
  );
5351
- } else {
5444
+ }
5445
+ if (policy === null || typeof policy !== "object") {
5352
5446
  throw new Error(
5353
- ".autocloses(...) requires a function or a policy object; got " + typeof predicate_or_policy
5447
+ ".autocloses(...) requires a policy object; got " + typeof policy
5354
5448
  );
5355
5449
  }
5356
- internal.autoclose = predicate;
5450
+ internal.autoclose = compile_autoclose_policy(
5451
+ policy
5452
+ );
5453
+ internal.autoclose_after_ms = policy_min_after_ms(policy);
5357
5454
  return builder;
5358
5455
  },
5359
5456
  archives(archive) {
@@ -5547,6 +5644,7 @@ function write_line(writer, line) {
5547
5644
  DEFAULT_LANE,
5548
5645
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
5549
5646
  DEFAULT_SETTLE_DEBOUNCE_MS,
5647
+ DeferSignal,
5550
5648
  Environments,
5551
5649
  Errors,
5552
5650
  EventMetaSchema,