@rotorsoft/act 1.16.0 → 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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/act.d.ts +0 -9
- package/dist/@types/act.d.ts.map +1 -1
- package/dist/@types/adapters/in-memory-store.d.ts +10 -0
- package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
- package/dist/@types/builders/state-builder.d.ts +19 -26
- package/dist/@types/builders/state-builder.d.ts.map +1 -1
- package/dist/@types/internal/autoclose-policy.d.ts +16 -0
- package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
- package/dist/@types/internal/close-signal.d.ts +51 -0
- package/dist/@types/internal/close-signal.d.ts.map +1 -0
- package/dist/@types/internal/defer-signal.d.ts +27 -0
- package/dist/@types/internal/defer-signal.d.ts.map +1 -0
- package/dist/@types/internal/defer-timer.d.ts +67 -0
- package/dist/@types/internal/defer-timer.d.ts.map +1 -0
- package/dist/@types/internal/drain-cycle.d.ts +39 -17
- package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
- package/dist/@types/internal/drain.d.ts +3 -1
- package/dist/@types/internal/drain.d.ts.map +1 -1
- package/dist/@types/internal/index.d.ts +4 -3
- package/dist/@types/internal/index.d.ts.map +1 -1
- package/dist/@types/internal/reactions.d.ts.map +1 -1
- package/dist/@types/internal/tracing.d.ts.map +1 -1
- package/dist/@types/types/action.d.ts +9 -0
- package/dist/@types/types/action.d.ts.map +1 -1
- package/dist/@types/types/ports.d.ts +31 -0
- package/dist/@types/types/ports.d.ts.map +1 -1
- package/dist/{chunk-46OCUAVF.js → chunk-7XME4ZLK.js} +52 -2
- package/dist/chunk-7XME4ZLK.js.map +1 -0
- package/dist/index.cjs +592 -527
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +543 -528
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +55 -5
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.js +5 -5
- package/dist/test/index.js.map +1 -1
- package/package.json +7 -4
- package/dist/@types/internal/autoclose-cycle.d.ts +0 -185
- package/dist/@types/internal/autoclose-cycle.d.ts.map +0 -1
- 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
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
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
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
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/
|
|
2424
|
-
var
|
|
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
|
-
*
|
|
2457
|
-
*
|
|
2458
|
-
*
|
|
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
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
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
|
-
*
|
|
2476
|
-
*
|
|
2477
|
-
*
|
|
2478
|
-
*
|
|
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
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
this.
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
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
|
-
|
|
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
|
|
3274
|
-
*
|
|
3275
|
-
* ack or terminal block. Lives in
|
|
3276
|
-
* by design (see {@link BackoffOptions}
|
|
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
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
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.
|
|
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.
|
|
3394
|
-
for (const lease of blocked) this.
|
|
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
|
-
|
|
3397
|
-
|
|
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.
|
|
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(
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
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
|
-
}
|
|
5412
|
+
}
|
|
5413
|
+
if (policy === null || typeof policy !== "object") {
|
|
5352
5414
|
throw new Error(
|
|
5353
|
-
".autocloses(...) requires a
|
|
5415
|
+
".autocloses(...) requires a policy object; got " + typeof policy
|
|
5354
5416
|
);
|
|
5355
5417
|
}
|
|
5356
|
-
internal.autoclose =
|
|
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) {
|