@rotorsoft/act 1.16.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +0 -9
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/adapters/in-memory-store.d.ts +10 -0
  5. package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
  6. package/dist/@types/builders/state-builder.d.ts +19 -26
  7. package/dist/@types/builders/state-builder.d.ts.map +1 -1
  8. package/dist/@types/internal/autoclose-policy.d.ts +16 -0
  9. package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
  10. package/dist/@types/internal/close-signal.d.ts +51 -0
  11. package/dist/@types/internal/close-signal.d.ts.map +1 -0
  12. package/dist/@types/internal/defer-signal.d.ts +27 -0
  13. package/dist/@types/internal/defer-signal.d.ts.map +1 -0
  14. package/dist/@types/internal/defer-timer.d.ts +67 -0
  15. package/dist/@types/internal/defer-timer.d.ts.map +1 -0
  16. package/dist/@types/internal/drain-cycle.d.ts +39 -17
  17. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  18. package/dist/@types/internal/drain.d.ts +3 -1
  19. package/dist/@types/internal/drain.d.ts.map +1 -1
  20. package/dist/@types/internal/index.d.ts +4 -3
  21. package/dist/@types/internal/index.d.ts.map +1 -1
  22. package/dist/@types/internal/reactions.d.ts.map +1 -1
  23. package/dist/@types/internal/tracing.d.ts.map +1 -1
  24. package/dist/@types/types/action.d.ts +9 -0
  25. package/dist/@types/types/action.d.ts.map +1 -1
  26. package/dist/@types/types/ports.d.ts +31 -0
  27. package/dist/@types/types/ports.d.ts.map +1 -1
  28. package/dist/{chunk-46OCUAVF.js → chunk-7XME4ZLK.js} +52 -2
  29. package/dist/chunk-7XME4ZLK.js.map +1 -0
  30. package/dist/index.cjs +592 -527
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.js +543 -528
  33. package/dist/index.js.map +1 -1
  34. package/dist/test/index.cjs +55 -5
  35. package/dist/test/index.cjs.map +1 -1
  36. package/dist/test/index.js +5 -5
  37. package/dist/test/index.js.map +1 -1
  38. package/package.json +2 -2
  39. package/dist/@types/internal/autoclose-cycle.d.ts +0 -185
  40. package/dist/@types/internal/autoclose-cycle.d.ts.map +0 -1
  41. package/dist/chunk-46OCUAVF.js.map +0 -1
package/dist/index.cjs CHANGED
@@ -631,6 +631,11 @@ var InMemoryStream = class {
631
631
  _leased_until = void 0;
632
632
  _priority = 0;
633
633
  _lane = DEFAULT_LANE;
634
+ // Persisted next-visit time (#1090). When set and still in the future, the
635
+ // stream is held out of `claim` entirely — so a deferred reaction is not
636
+ // re-claimed (and `retry` is never bumped) until its due-time passes. Unlike
637
+ // in-process backoff, this is durable store state shared across workers.
638
+ _deferred_at = void 0;
634
639
  constructor(stream, source, priority = 0, lane = DEFAULT_LANE) {
635
640
  this.stream = stream;
636
641
  this.source = source;
@@ -662,7 +667,17 @@ var InMemoryStream = class {
662
667
  this._priority = priority;
663
668
  }
664
669
  get is_available() {
665
- return !this._blocked && (!this._leased_until || this._leased_until <= /* @__PURE__ */ new Date());
670
+ 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).
671
+ (!this._deferred_at || this._deferred_at <= Date.now());
672
+ }
673
+ /**
674
+ * Hold this stream out of `claim` until `deferred_at` (ms since epoch).
675
+ * Set by a deliberate `defer` outcome — not a failure, so retry/blocked
676
+ * state is untouched. Cleared by ack/block/reset/unblock.
677
+ */
678
+ defer(deferred_at) {
679
+ this._deferred_at = deferred_at;
680
+ this._retry = -1;
666
681
  }
667
682
  get at() {
668
683
  return this._at;
@@ -714,6 +729,7 @@ var InMemoryStream = class {
714
729
  this._leased_until = void 0;
715
730
  this._at = lease.at;
716
731
  this._retry = -1;
732
+ this._deferred_at = void 0;
717
733
  return {
718
734
  stream: this.stream,
719
735
  source: this.source,
@@ -734,6 +750,7 @@ var InMemoryStream = class {
734
750
  if (this._leased_by === lease.by) {
735
751
  this._blocked = true;
736
752
  this._error = error;
753
+ this._deferred_at = void 0;
737
754
  return {
738
755
  stream: this.stream,
739
756
  source: this.source,
@@ -758,6 +775,7 @@ var InMemoryStream = class {
758
775
  this._error = "";
759
776
  this._leased_by = void 0;
760
777
  this._leased_until = void 0;
778
+ this._deferred_at = void 0;
761
779
  }
762
780
  /**
763
781
  * Clear the blocked flag and lease bookkeeping without touching the
@@ -771,6 +789,7 @@ var InMemoryStream = class {
771
789
  this._error = "";
772
790
  this._leased_by = void 0;
773
791
  this._leased_until = void 0;
792
+ this._deferred_at = void 0;
774
793
  return true;
775
794
  }
776
795
  };
@@ -1050,6 +1069,37 @@ var InMemoryStore = class {
1050
1069
  await sleep();
1051
1070
  return leases.map((l) => this._streams.get(l.stream)?.block(l, l.error)).filter((l) => !!l);
1052
1071
  }
1072
+ /**
1073
+ * Hold the matched streams out of {@link claim} until `deferred_at`
1074
+ * (ms since epoch) — see {@link Store.defer}. Accepts an explicit list
1075
+ * of names or a {@link StreamFilter}, mirroring {@link reset}/{@link unblock}.
1076
+ * Persisted store state (unlike in-process backoff), so the skip is honored
1077
+ * by every competing worker. Unknown names are silently skipped.
1078
+ *
1079
+ * @returns Count of streams whose `deferred_at` was set.
1080
+ */
1081
+ async defer(input, deferred_at) {
1082
+ await sleep();
1083
+ let count = 0;
1084
+ if (Array.isArray(input)) {
1085
+ for (const name of input) {
1086
+ const s = this._streams.get(name);
1087
+ if (s) {
1088
+ s.defer(deferred_at);
1089
+ count++;
1090
+ }
1091
+ }
1092
+ } else {
1093
+ const matches = this._filter_predicate(input);
1094
+ for (const s of this._streams.values()) {
1095
+ if (matches(s)) {
1096
+ s.defer(deferred_at);
1097
+ count++;
1098
+ }
1099
+ }
1100
+ }
1101
+ return count;
1102
+ }
1053
1103
  /**
1054
1104
  * Build a predicate from a {@link StreamFilter}. Compiled regexes are
1055
1105
  * cached in the closure so callers can apply it across the streams
@@ -1950,6 +2000,223 @@ function in_autoclose_window(window, now) {
1950
2000
  return window.start < window.end ? hour >= window.start && hour < window.end : hour >= window.start || hour < window.end;
1951
2001
  }
1952
2002
 
2003
+ // src/internal/autoclose-policy.ts
2004
+ var import_zod6 = require("zod");
2005
+ var MIN_AFTER_MS = 6e4;
2006
+ var AfterSchema = import_zod6.z.object({
2007
+ days: import_zod6.z.number({ message: "autocloses: after.days must be a number" }).positive("autocloses: after.days must be > 0")
2008
+ }).refine((o) => o.days * 864e5 >= MIN_AFTER_MS, {
2009
+ message: `autocloses: after resolves to < ${MIN_AFTER_MS}ms (one minute), which is too short to be a meaningful retention window`
2010
+ });
2011
+ var IsSchema = import_zod6.z.union([
2012
+ import_zod6.z.string().min(1, "autocloses: is must be a non-empty event name"),
2013
+ import_zod6.z.array(
2014
+ import_zod6.z.string().min(1, "autocloses: is entries must be non-empty event names")
2015
+ ).min(1, "autocloses: is must include at least one event name").readonly()
2016
+ ]);
2017
+ 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");
2018
+ var OrBlockSchema = import_zod6.z.object({
2019
+ after: AfterSchema.optional(),
2020
+ is: IsSchema.optional(),
2021
+ reaches: ReachesSchema.optional()
2022
+ }).strict().refine(
2023
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0,
2024
+ {
2025
+ message: "autocloses: `or` block must include at least one of after / is / reaches"
2026
+ }
2027
+ );
2028
+ var AutoclosePolicySchema = import_zod6.z.object({
2029
+ after: AfterSchema.optional(),
2030
+ is: IsSchema.optional(),
2031
+ reaches: ReachesSchema.optional(),
2032
+ or: OrBlockSchema.optional()
2033
+ }).strict().refine(
2034
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0,
2035
+ {
2036
+ message: "autocloses: at least one of after / is / reaches / or must be specified \u2014 empty `{}` is a misconfiguration"
2037
+ }
2038
+ );
2039
+ function compile_after(after) {
2040
+ const after_ms = after.days * 864e5;
2041
+ return (_stream, head) => Date.now() - head.created.getTime() >= after_ms;
2042
+ }
2043
+ function compile_is(is) {
2044
+ const set = new Set(typeof is === "string" ? [is] : is);
2045
+ return (_stream, head) => set.has(head.name);
2046
+ }
2047
+ function compile_reaches(reaches) {
2048
+ return (_stream, _head, count) => count >= reaches;
2049
+ }
2050
+ function policy_min_after_ms(options) {
2051
+ const parsed = AutoclosePolicySchema.parse(options);
2052
+ const windows = [];
2053
+ if (parsed.after) windows.push(parsed.after.days * 864e5);
2054
+ if (parsed.or?.after) windows.push(parsed.or.after.days * 864e5);
2055
+ return windows.length ? Math.min(...windows) : void 0;
2056
+ }
2057
+ function compile_autoclose_policy(options) {
2058
+ const parsed = AutoclosePolicySchema.parse(options);
2059
+ const and_preds = [];
2060
+ if (parsed.after) and_preds.push(compile_after(parsed.after));
2061
+ if (parsed.is) and_preds.push(compile_is(parsed.is));
2062
+ if (parsed.reaches) and_preds.push(compile_reaches(parsed.reaches));
2063
+ const or_preds = [];
2064
+ if (parsed.or) {
2065
+ if (parsed.or.after) or_preds.push(compile_after(parsed.or.after));
2066
+ if (parsed.or.is) or_preds.push(compile_is(parsed.or.is));
2067
+ if (parsed.or.reaches) or_preds.push(compile_reaches(parsed.or.reaches));
2068
+ }
2069
+ return (stream, head, count) => {
2070
+ if (and_preds.length > 0 && and_preds.every((p) => p(stream, head, count))) {
2071
+ return true;
2072
+ }
2073
+ if (or_preds.some((p) => p(stream, head, count))) {
2074
+ return true;
2075
+ }
2076
+ return false;
2077
+ };
2078
+ }
2079
+
2080
+ // src/internal/build-classify.ts
2081
+ var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
2082
+ function classify_registry(registry, states) {
2083
+ const statics = /* @__PURE__ */ new Map();
2084
+ const reactive_events = /* @__PURE__ */ new Set();
2085
+ const event_to_lanes = /* @__PURE__ */ new Map();
2086
+ let has_dynamic_resolvers = false;
2087
+ for (const [name, register] of Object.entries(registry.events)) {
2088
+ if (register.reactions.size > 0) reactive_events.add(name);
2089
+ for (const reaction of register.reactions.values()) {
2090
+ if (typeof reaction.resolver === "function") {
2091
+ has_dynamic_resolvers = true;
2092
+ event_to_lanes.set(name, ALL_LANES);
2093
+ } else {
2094
+ const { target, source, priority = 0, lane } = reaction.resolver;
2095
+ const lane_name = lane ?? "default";
2096
+ const existing_lanes = event_to_lanes.get(name);
2097
+ if (existing_lanes !== ALL_LANES) {
2098
+ const set = existing_lanes ?? /* @__PURE__ */ new Set();
2099
+ set.add(lane_name);
2100
+ event_to_lanes.set(name, set);
2101
+ }
2102
+ const key = `${target}|${source ?? ""}`;
2103
+ const existing = statics.get(key);
2104
+ if (!existing) {
2105
+ statics.set(key, { stream: target, source, priority, lane });
2106
+ } else {
2107
+ if ((existing.lane ?? void 0) !== (lane ?? void 0))
2108
+ throw new Error(
2109
+ `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
2110
+ );
2111
+ if (priority > existing.priority) {
2112
+ statics.set(key, { ...existing, priority });
2113
+ }
2114
+ }
2115
+ }
2116
+ }
2117
+ }
2118
+ const event_to_state = /* @__PURE__ */ new Map();
2119
+ for (const merged of states.values()) {
2120
+ for (const event_name of Object.keys(merged.events)) {
2121
+ event_to_state.set(event_name, merged);
2122
+ }
2123
+ }
2124
+ return {
2125
+ static_targets: [...statics.values()],
2126
+ has_dynamic_resolvers,
2127
+ reactive_events,
2128
+ event_to_state,
2129
+ event_to_lanes
2130
+ };
2131
+ }
2132
+
2133
+ // src/internal/circuit-breaker.ts
2134
+ var import_zod7 = require("zod");
2135
+ var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
2136
+ var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
2137
+ var CircuitBreakerOptionsSchema = import_zod7.z.object({
2138
+ failureThreshold: import_zod7.z.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
2139
+ cooldownMs: import_zod7.z.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
2140
+ });
2141
+ var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
2142
+ var CircuitBreaker = class {
2143
+ _failures = 0;
2144
+ _opened_at;
2145
+ _wake;
2146
+ _threshold;
2147
+ _cooldown_ms;
2148
+ _hooks;
2149
+ constructor(config2, hooks = {}) {
2150
+ this._threshold = config2.failureThreshold;
2151
+ this._cooldown_ms = config2.cooldownMs;
2152
+ this._hooks = hooks;
2153
+ }
2154
+ /** Current state given the wall-clock `now`. */
2155
+ state(now) {
2156
+ if (this._opened_at === void 0) return "closed";
2157
+ return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
2158
+ }
2159
+ /** A store op passed — reset to closed and cancel any pending retry. */
2160
+ passed() {
2161
+ this._failures = 0;
2162
+ this._opened_at = void 0;
2163
+ this._clear_wake();
2164
+ }
2165
+ /**
2166
+ * A store op failed. Opens the breaker when the consecutive-failure
2167
+ * threshold is reached, or immediately re-opens (restarting the cooldown)
2168
+ * if a half-open trial just failed. Returns the resulting state.
2169
+ *
2170
+ * Callers gate on the state directly — `state(now) === "open"` means skip.
2171
+ * On opening, schedules the `on_retry` wake so recovery is automatic;
2172
+ * always surfaces the failure via `on_error`.
2173
+ */
2174
+ failed(now, error) {
2175
+ const circuit = this._advance(now);
2176
+ if (circuit === "open") this._schedule_wake();
2177
+ this._hooks.on_error?.(error, circuit);
2178
+ return circuit;
2179
+ }
2180
+ /** Cancel the pending retry timer. Idempotent; call on shutdown. */
2181
+ stop() {
2182
+ this._clear_wake();
2183
+ }
2184
+ /**
2185
+ * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
2186
+ * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
2187
+ * tests of the pure state machine). The timer is `unref`'d so it never
2188
+ * keeps the process alive.
2189
+ */
2190
+ _schedule_wake() {
2191
+ if (!this._hooks.on_retry) return;
2192
+ this._clear_wake();
2193
+ this._wake = setTimeout(() => {
2194
+ this._wake = void 0;
2195
+ this._hooks.on_retry?.();
2196
+ }, this._cooldown_ms);
2197
+ this._wake.unref();
2198
+ }
2199
+ _clear_wake() {
2200
+ if (this._wake) {
2201
+ clearTimeout(this._wake);
2202
+ this._wake = void 0;
2203
+ }
2204
+ }
2205
+ /** Pure state transition for a failure — no side effects. */
2206
+ _advance(now) {
2207
+ if (this._opened_at !== void 0) {
2208
+ this._opened_at = now;
2209
+ return "open";
2210
+ }
2211
+ this._failures += 1;
2212
+ if (this._failures >= this._threshold) {
2213
+ this._opened_at = now;
2214
+ return "open";
2215
+ }
2216
+ return "closed";
2217
+ }
2218
+ };
2219
+
1953
2220
  // src/internal/close-cycle.ts
1954
2221
  var SAFETY_PROBE_PAGE_SIZE = 1e3;
1955
2222
  async function run_close_cycle(targets, deps) {
@@ -2056,454 +2323,94 @@ async function guard_with_tombstones(safe, stream_info, correlation, tombstone2,
2056
2323
  skipped.push(stream);
2057
2324
  }
2058
2325
  })
2059
- );
2060
- return { guarded, guard_events };
2061
- }
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;
2079
- }
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
- }
2085
- }
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
- });
2144
- }
2145
-
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));
2150
- }
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;
2183
- }
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
- }
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);
2205
- }
2206
- return {
2207
- inspected,
2208
- evaluated,
2209
- predicate_errors,
2210
- close_result: { truncated, skipped }
2211
- };
2212
- }
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;
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;
2238
- }
2239
- /**
2240
- * Stop the cycle ticker. Idempotent.
2241
- */
2242
- stop() {
2243
- if (this._timer) {
2244
- clearInterval(this._timer);
2245
- this._timer = void 0;
2246
- }
2247
- }
2248
- /**
2249
- * Whether the ticker is currently running.
2250
- */
2251
- get is_running() {
2252
- return this._timer !== void 0;
2253
- }
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
2274
- );
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
- }
2297
- }
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"
2323
- }
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"
2334
- }
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
- }
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;
2362
- }
2363
- if (or_preds.some((p) => p(stream, head, count))) {
2364
- return true;
2365
- }
2366
- return false;
2367
- };
2368
- }
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
- }
2326
+ );
2327
+ return { guarded, guard_events };
2328
+ }
2329
+ async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
2330
+ const seed_states = /* @__PURE__ */ new Map();
2331
+ await Promise.all(
2332
+ guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
2333
+ const last_event_name = stream_info.get(stream).last_event_name;
2334
+ const owner_state = event_to_state.get(last_event_name);
2335
+ if (!owner_state) {
2336
+ logger.error(
2337
+ `Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
2338
+ );
2339
+ return;
2405
2340
  }
2406
- }
2407
- }
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
- }
2341
+ const snap2 = await load2(owner_state, { stream });
2342
+ seed_states.set(stream, snap2.state);
2343
+ })
2344
+ );
2345
+ return seed_states;
2346
+ }
2347
+ async function run_archive_callbacks(guarded, target_map) {
2348
+ for (const stream of guarded) {
2349
+ const archive_fn = target_map.get(stream)?.archive;
2350
+ if (archive_fn) await archive_fn();
2413
2351
  }
2414
- return {
2415
- static_targets: [...statics.values()],
2416
- has_dynamic_resolvers,
2417
- reactive_events,
2418
- event_to_state,
2419
- event_to_lanes
2420
- };
2352
+ }
2353
+ async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
2354
+ const trunc_targets = guarded.map((stream) => {
2355
+ const snapshot = seed_states.get(stream);
2356
+ const guard = guard_events.get(stream);
2357
+ return {
2358
+ stream,
2359
+ snapshot,
2360
+ meta: {
2361
+ correlation,
2362
+ causation: {
2363
+ event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
2364
+ }
2365
+ }
2366
+ };
2367
+ });
2368
+ const truncated = await store2().truncate(trunc_targets);
2369
+ await Promise.all(
2370
+ guarded.map(async (stream) => {
2371
+ const entry = truncated.get(stream);
2372
+ const state2 = seed_states.get(stream);
2373
+ if (state2 && entry) {
2374
+ await cache2().set(stream, {
2375
+ state: state2,
2376
+ version: entry.committed.version,
2377
+ event_id: entry.committed.id,
2378
+ patches: 0,
2379
+ snaps: 1
2380
+ });
2381
+ } else {
2382
+ await cache2().invalidate(stream);
2383
+ }
2384
+ })
2385
+ );
2386
+ return truncated;
2421
2387
  }
2422
2388
 
2423
- // src/internal/circuit-breaker.ts
2424
- var import_zod7 = require("zod");
2425
- var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
2426
- 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)
2430
- });
2431
- var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
2432
- var CircuitBreaker = class {
2433
- _failures = 0;
2434
- _opened_at;
2435
- _wake;
2436
- _threshold;
2437
- _cooldown_ms;
2438
- _hooks;
2439
- constructor(config2, hooks = {}) {
2440
- this._threshold = config2.failureThreshold;
2441
- this._cooldown_ms = config2.cooldownMs;
2442
- this._hooks = hooks;
2443
- }
2444
- /** Current state given the wall-clock `now`. */
2445
- state(now) {
2446
- if (this._opened_at === void 0) return "closed";
2447
- return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
2448
- }
2449
- /** A store op passed — reset to closed and cancel any pending retry. */
2450
- passed() {
2451
- this._failures = 0;
2452
- this._opened_at = void 0;
2453
- this._clear_wake();
2454
- }
2389
+ // src/internal/close-signal.ts
2390
+ var CloseSignal = class extends Error {
2455
2391
  /**
2456
- * A store op failed. Opens the breaker when the consecutive-failure
2457
- * threshold is reached, or immediately re-opens (restarting the cooldown)
2458
- * if a half-open trial just failed. Returns the resulting state.
2459
- *
2460
- * Callers gate on the state directly — `state(now) === "open"` means skip.
2461
- * On opening, schedules the `on_retry` wake so recovery is automatic;
2462
- * always surfaces the failure via `on_error`.
2392
+ * Stream to close. Omitted → the reaction's own lease stream (a self-close).
2393
+ * The synthesized autoclose reaction sets it to the aggregate stream, since
2394
+ * its lease runs on a synthetic `__autoclose__` target.
2463
2395
  */
2464
- failed(now, error) {
2465
- const circuit = this._advance(now);
2466
- if (circuit === "open") this._schedule_wake();
2467
- this._hooks.on_error?.(error, circuit);
2468
- return circuit;
2469
- }
2470
- /** Cancel the pending retry timer. Idempotent; call on shutdown. */
2471
- stop() {
2472
- this._clear_wake();
2473
- }
2396
+ stream;
2397
+ /** Optional archive callback to run while the stream is guarded. */
2398
+ archive;
2474
2399
  /**
2475
- * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
2476
- * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
2477
- * tests of the pure state machine). The timer is `unref`'d so it never
2478
- * keeps the process alive.
2400
+ * Watermark to ack the requesting reaction to before the close runs. The
2401
+ * close-cycle safety guard skips a stream whose subscriptions (matched by
2402
+ * source) lag the head; the autoclose reaction's `source` is the aggregate,
2403
+ * so it must advance its own watermark to the live head id it evaluated
2404
+ * against — otherwise it blocks its own close. Defaults to the triggering
2405
+ * event id when omitted.
2479
2406
  */
2480
- _schedule_wake() {
2481
- if (!this._hooks.on_retry) return;
2482
- this._clear_wake();
2483
- this._wake = setTimeout(() => {
2484
- this._wake = void 0;
2485
- this._hooks.on_retry?.();
2486
- }, this._cooldown_ms);
2487
- this._wake.unref();
2488
- }
2489
- _clear_wake() {
2490
- if (this._wake) {
2491
- clearTimeout(this._wake);
2492
- this._wake = void 0;
2493
- }
2494
- }
2495
- /** Pure state transition for a failure — no side effects. */
2496
- _advance(now) {
2497
- if (this._opened_at !== void 0) {
2498
- this._opened_at = now;
2499
- return "open";
2500
- }
2501
- this._failures += 1;
2502
- if (this._failures >= this._threshold) {
2503
- this._opened_at = now;
2504
- return "open";
2505
- }
2506
- return "closed";
2407
+ at;
2408
+ constructor(opts) {
2409
+ super("reaction requested close");
2410
+ this.name = "CloseSignal";
2411
+ this.stream = opts?.stream;
2412
+ this.archive = opts?.archive;
2413
+ this.at = opts?.at;
2507
2414
  }
2508
2415
  };
2509
2416
 
@@ -2635,9 +2542,122 @@ var CorrelateCycle = class {
2635
2542
  }
2636
2543
  };
2637
2544
 
2545
+ // src/internal/correlator.ts
2546
+ var import_node_crypto = require("crypto");
2547
+ var BASE = 36;
2548
+ var SEG_WIDTH = 4;
2549
+ var SEG_SPACE = BASE ** SEG_WIDTH;
2550
+ function seg(n) {
2551
+ return n.toString(BASE).padStart(SEG_WIDTH, "0");
2552
+ }
2553
+ var default_correlator = ({ state: state2, action: action2 }) => {
2554
+ const s = state2.slice(0, SEG_WIDTH).toLowerCase();
2555
+ const a = action2.slice(0, SEG_WIDTH).toLowerCase();
2556
+ const ts = seg(Date.now() % SEG_SPACE);
2557
+ const rnd = seg((0, import_node_crypto.randomInt)(SEG_SPACE));
2558
+ return `${s}-${a}-${ts}${rnd}`;
2559
+ };
2560
+ function close_correlation(correlator, actor) {
2561
+ return correlator({
2562
+ state: "$close",
2563
+ action: "close",
2564
+ stream: "$close",
2565
+ actor
2566
+ });
2567
+ }
2568
+
2569
+ // src/internal/defer-signal.ts
2570
+ var DeferSignal = class extends Error {
2571
+ /** Wall-clock time (ms since epoch) to re-visit the stream. */
2572
+ defer_at;
2573
+ constructor(defer_at) {
2574
+ super("reaction deferred");
2575
+ this.name = "DeferSignal";
2576
+ this.defer_at = defer_at;
2577
+ }
2578
+ };
2579
+
2638
2580
  // src/internal/drain-cycle.ts
2639
2581
  var import_node_crypto2 = require("crypto");
2640
2582
 
2583
+ // src/internal/defer-timer.ts
2584
+ var MAX_TIMER_DELAY_MS = 2147483647;
2585
+ var DeferTimer = class {
2586
+ _due = /* @__PURE__ */ new Map();
2587
+ _timer;
2588
+ _on_wake;
2589
+ /**
2590
+ * @param on_wake - invoked once each time the earliest due-time elapses,
2591
+ * after the come-due entries have been removed. Consumers use it to
2592
+ * re-arm their loop (the drain sets its `armed` flag; autoclose runs a
2593
+ * tick).
2594
+ */
2595
+ constructor(on_wake) {
2596
+ this._on_wake = on_wake;
2597
+ }
2598
+ /** Number of currently parked streams. */
2599
+ get size() {
2600
+ return this._due.size;
2601
+ }
2602
+ /**
2603
+ * True while `stream` is parked with a due-time still in the future.
2604
+ * Consumers skip work for deferred streams until their window elapses.
2605
+ */
2606
+ is_deferred = (stream) => {
2607
+ const next = this._due.get(stream);
2608
+ return next !== void 0 && next > Date.now();
2609
+ };
2610
+ /**
2611
+ * Park `stream` for a re-visit at `at` (ms since epoch). A plain
2612
+ * overwrite: the caller computes the authoritative next-visit for the
2613
+ * stream (the drain's `handle` already reconciles a stream's reactions
2614
+ * into one result per cycle), so there is no stale value to merge against.
2615
+ */
2616
+ set(stream, at) {
2617
+ this._due.set(stream, at);
2618
+ }
2619
+ /** Drop `stream` from the parked set (e.g. on a successful ack or block). */
2620
+ delete(stream) {
2621
+ this._due.delete(stream);
2622
+ }
2623
+ /**
2624
+ * (Re)schedule the wake timer at the earliest pending due-time. Idempotent
2625
+ * — collapses many parked streams into a single timer. A no-op clears any
2626
+ * pending timer when the map is empty.
2627
+ *
2628
+ * The timer is `unref()`-ed so pending re-visits never keep the process
2629
+ * alive on their own.
2630
+ */
2631
+ schedule() {
2632
+ if (this._timer) clearTimeout(this._timer);
2633
+ if (this._due.size === 0) {
2634
+ this._timer = void 0;
2635
+ return;
2636
+ }
2637
+ let earliest = Number.POSITIVE_INFINITY;
2638
+ for (const t of this._due.values()) if (t < earliest) earliest = t;
2639
+ const delay = Math.min(
2640
+ Math.max(0, earliest - Date.now()),
2641
+ MAX_TIMER_DELAY_MS
2642
+ );
2643
+ this._timer = setTimeout(() => {
2644
+ this._timer = void 0;
2645
+ const now = Date.now();
2646
+ for (const [stream, at] of this._due)
2647
+ if (at <= now) this._due.delete(stream);
2648
+ this._on_wake();
2649
+ }, delay);
2650
+ this._timer.unref();
2651
+ }
2652
+ /** Cancel any pending wake timer. Idempotent. Leaves the parked set intact. */
2653
+ stop() {
2654
+ if (this._timer) {
2655
+ clearTimeout(this._timer);
2656
+ this._timer = void 0;
2657
+ }
2658
+ }
2659
+ };
2660
+
2641
2661
  // src/internal/drain-ratio.ts
2642
2662
  var RATIO_MIN = 0.2;
2643
2663
  var RATIO_MAX = 0.8;
@@ -2673,6 +2693,7 @@ async function fetch(leased, eventLimit) {
2673
2693
  }
2674
2694
  var ack = (leases) => store2().ack(leases);
2675
2695
  var block = (leases) => store2().block(leases);
2696
+ var defer = (input, deferred_at) => store2().defer(input, deferred_at);
2676
2697
  var subscribe = (streams) => store2().subscribe(streams);
2677
2698
 
2678
2699
  // src/internal/event-sourcing.ts
@@ -3150,6 +3171,7 @@ function build_drain(logger) {
3150
3171
  fetch,
3151
3172
  ack,
3152
3173
  block,
3174
+ defer,
3153
3175
  subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
3154
3176
  if (!result.subscribed) return;
3155
3177
  const lanes = new Set(streams.map((s) => s.lane ?? "default"));
@@ -3218,7 +3240,8 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
3218
3240
  fetched: [],
3219
3241
  handled: [],
3220
3242
  acked: [],
3221
- blocked: []
3243
+ blocked: [],
3244
+ closeable: []
3222
3245
  };
3223
3246
  }
3224
3247
  const fetched = await ops.fetch(active, eventLimit);
@@ -3252,12 +3275,23 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
3252
3275
  })
3253
3276
  );
3254
3277
  const acked = await ops.ack(
3255
- handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
3278
+ handled.filter((h) => h.defer === void 0 && (h.handled > 0 || !h.error)).map((h) => ({ ...h.lease, at: h.acked_at }))
3256
3279
  );
3257
3280
  const blocked = await ops.block(
3258
3281
  handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
3259
3282
  );
3260
- return { leased, fetched, handled, acked, blocked };
3283
+ const deferrals = handled.filter((h) => h.defer !== void 0);
3284
+ if (deferrals.length) {
3285
+ const by_time = /* @__PURE__ */ new Map();
3286
+ for (const h of deferrals) {
3287
+ const list = by_time.get(h.defer);
3288
+ if (list) list.push(h.lease.stream);
3289
+ else by_time.set(h.defer, [h.lease.stream]);
3290
+ }
3291
+ for (const [at, streams] of by_time) await ops.defer(streams, at);
3292
+ }
3293
+ const closeable = handled.filter((h) => h.close !== void 0).map((h) => h.close);
3294
+ return { leased, fetched, handled, acked, blocked, closeable };
3261
3295
  }
3262
3296
  var EMPTY_DRAIN = {
3263
3297
  fetched: [],
@@ -3270,14 +3304,16 @@ var DrainController = class {
3270
3304
  _locked = false;
3271
3305
  _ratio = 0.5;
3272
3306
  /**
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).
3307
+ * Per-stream re-visit schedule (#1090): `stream → next visit` (ms since
3308
+ * epoch). Holds both retry backoff (`HandleResult.next_attempt_at`) and the
3309
+ * `defer` outcome; cleared on successful ack or terminal block. Lives in
3310
+ * process memory — per-worker pacing by design (see {@link BackoffOptions}
3311
+ * for the multi-worker trade-off). Its wake re-arms drain at the earliest
3312
+ * pending visit.
3277
3313
  */
3278
- _backoff = /* @__PURE__ */ new Map();
3279
- /** Timer re-arming drain at the earliest pending `next_attempt_at`. */
3280
- _backoff_timer;
3314
+ _defer = new DeferTimer(() => {
3315
+ this._armed = true;
3316
+ });
3281
3317
  /** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
3282
3318
  _worker;
3283
3319
  _stopped = false;
@@ -3297,32 +3333,6 @@ var DrainController = class {
3297
3333
  get armed() {
3298
3334
  return this._armed;
3299
3335
  }
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
3336
  /** Lane this controller drains (undefined = legacy single-lane span). */
3327
3337
  get lane() {
3328
3338
  return this._deps.lane;
@@ -3354,6 +3364,7 @@ var DrainController = class {
3354
3364
  clearTimeout(this._worker);
3355
3365
  this._worker = void 0;
3356
3366
  }
3367
+ this._defer.stop();
3357
3368
  }
3358
3369
  /** Run one drain pass. Short-circuits when not armed or already running. */
3359
3370
  async drain(options = {}) {
@@ -3379,7 +3390,7 @@ var DrainController = class {
3379
3390
  leading,
3380
3391
  eventLimit,
3381
3392
  leaseMillis,
3382
- this._backoff.size > 0 ? this.is_deferred : void 0,
3393
+ this._defer.size > 0 ? this._defer.is_deferred : void 0,
3383
3394
  this._deps.lane
3384
3395
  );
3385
3396
  this._deps.breaker.passed();
@@ -3387,19 +3398,19 @@ var DrainController = class {
3387
3398
  this._armed = false;
3388
3399
  return EMPTY_DRAIN;
3389
3400
  }
3390
- const { leased, fetched, handled, acked, blocked } = cycle;
3401
+ const { leased, fetched, handled, acked, blocked, closeable } = cycle;
3391
3402
  trace_cycle(this._deps.logger, leased, fetched, handled, acked, blocked);
3392
3403
  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);
3404
+ for (const lease of acked) this._defer.delete(lease.stream);
3405
+ for (const lease of blocked) this._defer.delete(lease.stream);
3395
3406
  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
- }
3407
+ const next = h.defer ?? (h.block ? void 0 : h.next_attempt_at);
3408
+ if (next !== void 0) this._defer.set(h.lease.stream, next);
3399
3409
  }
3400
- if (this._backoff.size > 0) this.schedule_backoff_wake();
3410
+ if (this._defer.size > 0) this._defer.schedule();
3401
3411
  if (acked.length) this._deps.on_acked(acked);
3402
3412
  if (blocked.length) this._deps.on_blocked(blocked);
3413
+ if (closeable.length) await this._deps.on_close(closeable);
3403
3414
  const has_errors = handled.some(({ error }) => error);
3404
3415
  if (!acked.length && !blocked.length && !has_errors) this._armed = false;
3405
3416
  return { fetched, leased, acked, blocked };
@@ -3615,6 +3626,20 @@ function build_handle(deps) {
3615
3626
  at = event.id;
3616
3627
  handled++;
3617
3628
  } catch (error) {
3629
+ if (error instanceof DeferSignal)
3630
+ return { lease, handled, acked_at: at, defer: error.defer_at };
3631
+ if (error instanceof CloseSignal)
3632
+ return {
3633
+ lease,
3634
+ handled: handled + 1,
3635
+ // Advance to the live head the handler evaluated against (when
3636
+ // provided) so the close-cycle guard sees this reaction caught up.
3637
+ acked_at: error.at ?? event.id,
3638
+ // Close the signalled stream (the autoclose reaction's aggregate,
3639
+ // which differs from its synthetic lease stream); a self-closing
3640
+ // user reaction omits it and closes its own lease stream.
3641
+ close: { stream: error.stream ?? stream, archive: error.archive }
3642
+ };
3618
3643
  return finalize(
3619
3644
  lease,
3620
3645
  handled,
@@ -3717,6 +3742,7 @@ var SettleLoop = class {
3717
3742
  // src/act.ts
3718
3743
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
3719
3744
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
3745
+ var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
3720
3746
  var Act = class {
3721
3747
  _emitter = new import_node_events.default();
3722
3748
  /** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
@@ -3758,15 +3784,6 @@ var Act = class {
3758
3784
  * @internal
3759
3785
  */
3760
3786
  _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
3787
  /**
3771
3788
  * Emit a lifecycle event. The payload type is inferred from the event name
3772
3789
  * via {@link ActLifecycleEvents}.
@@ -3907,6 +3924,60 @@ var Act = class {
3907
3924
  bound_forget: this._bound_forget
3908
3925
  });
3909
3926
  this._handle_batch = build_handle_batch(this._logger);
3927
+ for (const st of this._states.values()) {
3928
+ const predicate = st.autoclose;
3929
+ if (!predicate) continue;
3930
+ const after_ms = st.autoclose_after_ms;
3931
+ const archiver = st.archive;
3932
+ const reaction = {
3933
+ // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
3934
+ // per-aggregate `__autoclose__` key — so the autoclose reaction never
3935
+ // shares a watermark with the aggregate's own reactions. A shared
3936
+ // watermark would let autoclose's defer short-circuit the aggregate's
3937
+ // other reactions (the "a defer affects all reactions on a stream"
3938
+ // hazard). The close still targets the aggregate (`source`).
3939
+ resolver: (e) => ({
3940
+ target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
3941
+ source: e.stream
3942
+ }),
3943
+ // Never block on autoclose: a transient query/store error should retry,
3944
+ // not quarantine the synthetic stream.
3945
+ options: { blockOnError: false, maxRetries: 3 },
3946
+ handler: async (event) => {
3947
+ const aggregate = event.stream;
3948
+ const config2 = this._autoclose_config;
3949
+ if (!in_autoclose_window(config2.autocloseWindow, /* @__PURE__ */ new Date()))
3950
+ throw new DeferSignal(
3951
+ Date.now() + config2.autocloseCycleMinutes * 6e4
3952
+ );
3953
+ const stats = await store2().query_stats([aggregate], {
3954
+ count: true,
3955
+ exclude: [TOMBSTONE_EVENT]
3956
+ });
3957
+ const entry = stats.get(aggregate);
3958
+ if (!entry) return;
3959
+ const head = entry.head;
3960
+ if (predicate(aggregate, head, entry.count))
3961
+ throw new CloseSignal({
3962
+ stream: aggregate,
3963
+ archive: archiver ? () => archiver(aggregate, head) : void 0,
3964
+ // Ack this reaction's own watermark to the live head so the
3965
+ // close-cycle guard (which matches subscriptions by source =
3966
+ // aggregate) sees it caught up instead of blocking its own close.
3967
+ at: head.id
3968
+ });
3969
+ if (after_ms !== void 0)
3970
+ throw new DeferSignal(head.created.getTime() + after_ms);
3971
+ }
3972
+ };
3973
+ const key = `__autoclose_${st.name}`;
3974
+ for (const event_name of Object.keys(st.events)) {
3975
+ this.registry.events[event_name]?.reactions.set(
3976
+ key,
3977
+ reaction
3978
+ );
3979
+ }
3980
+ }
3910
3981
  const {
3911
3982
  static_targets,
3912
3983
  has_dynamic_resolvers,
@@ -3955,6 +4026,22 @@ var Act = class {
3955
4026
  handle_batch: this._handle_batch,
3956
4027
  on_acked: (acked) => this.emit("acked", acked),
3957
4028
  on_blocked: (blocked) => this.emit("blocked", blocked),
4029
+ // Reaction-requested close (#1090). Runs the same close machinery as
4030
+ // `app.close` (tombstone guard + archive + atomic truncate) for the
4031
+ // targets a handler signalled via `CloseSignal`. No `correlate()` here
4032
+ // — the drain that produced these targets has already correlated.
4033
+ on_close: async (targets) => {
4034
+ const close_actor = { id: "$close", name: "close" };
4035
+ const result = await run_close_cycle(targets, {
4036
+ reactive_events_size: this._reactive_events.size,
4037
+ event_to_state: this._event_to_state,
4038
+ load: this._es.load,
4039
+ tombstone: this._es.tombstone,
4040
+ logger: this._logger,
4041
+ correlation: close_correlation(this._correlator, close_actor)
4042
+ });
4043
+ this.emit("closed", result);
4044
+ },
3958
4045
  breaker: this._breaker,
3959
4046
  // Pass lane only when a true per-lane controller is active.
3960
4047
  // The all-lanes (single default) case keeps lane=undefined so
@@ -4001,27 +4088,6 @@ var Act = class {
4001
4088
  },
4002
4089
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
4003
4090
  );
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
4091
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store2());
4026
4092
  dispose(() => this.shutdown());
4027
4093
  }
@@ -4515,7 +4581,6 @@ var Act = class {
4515
4581
  */
4516
4582
  start_correlations(query = {}, frequency = 1e4, callback) {
4517
4583
  const started = this._correlate.start_polling(query, frequency, callback);
4518
- this._autoclose?.start();
4519
4584
  return started;
4520
4585
  }
4521
4586
  /**
@@ -4537,7 +4602,6 @@ var Act = class {
4537
4602
  */
4538
4603
  stop_correlations() {
4539
4604
  this._correlate.stop_polling();
4540
- this._autoclose?.stop();
4541
4605
  }
4542
4606
  /**
4543
4607
  * Cancels any pending or active settle cycle.
@@ -5340,20 +5404,21 @@ function action_builder(state2) {
5340
5404
  internal.disclose = disclose;
5341
5405
  return builder;
5342
5406
  },
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
5407
+ autocloses(policy) {
5408
+ if (typeof policy === "function") {
5409
+ throw new Error(
5410
+ ".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
5411
  );
5351
- } else {
5412
+ }
5413
+ if (policy === null || typeof policy !== "object") {
5352
5414
  throw new Error(
5353
- ".autocloses(...) requires a function or a policy object; got " + typeof predicate_or_policy
5415
+ ".autocloses(...) requires a policy object; got " + typeof policy
5354
5416
  );
5355
5417
  }
5356
- internal.autoclose = predicate;
5418
+ internal.autoclose = compile_autoclose_policy(
5419
+ policy
5420
+ );
5421
+ internal.autoclose_after_ms = policy_min_after_ms(policy);
5357
5422
  return builder;
5358
5423
  },
5359
5424
  archives(archive) {