@rotorsoft/act 1.10.1 → 1.11.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 (38) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +71 -9
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/builders/act-builder.d.ts.map +1 -1
  5. package/dist/@types/builders/slice-builder.d.ts +1 -1
  6. package/dist/@types/builders/state-builder.d.ts +88 -1
  7. package/dist/@types/builders/state-builder.d.ts.map +1 -1
  8. package/dist/@types/internal/autoclose-config.d.ts +72 -0
  9. package/dist/@types/internal/autoclose-config.d.ts.map +1 -0
  10. package/dist/@types/internal/autoclose-cycle.d.ts +171 -0
  11. package/dist/@types/internal/autoclose-cycle.d.ts.map +1 -0
  12. package/dist/@types/internal/event-sourcing.d.ts +7 -7
  13. package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
  14. package/dist/@types/internal/index.d.ts +3 -1
  15. package/dist/@types/internal/index.d.ts.map +1 -1
  16. package/dist/@types/internal/reactions.d.ts.map +1 -1
  17. package/dist/@types/internal/sensitive.d.ts +6 -23
  18. package/dist/@types/internal/sensitive.d.ts.map +1 -1
  19. package/dist/@types/internal/tracing.d.ts.map +1 -1
  20. package/dist/@types/types/action.d.ts +141 -16
  21. package/dist/@types/types/action.d.ts.map +1 -1
  22. package/dist/@types/types/registry.d.ts +35 -3
  23. package/dist/@types/types/registry.d.ts.map +1 -1
  24. package/dist/{chunk-3Z2HU726.js → chunk-5PWLGL5E.js} +2 -2
  25. package/dist/{chunk-BY5JPOZR.js → chunk-NGVWJXEF.js} +9 -24
  26. package/dist/{chunk-BY5JPOZR.js.map → chunk-NGVWJXEF.js.map} +1 -1
  27. package/dist/index.cjs +411 -195
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.js +399 -174
  30. package/dist/index.js.map +1 -1
  31. package/dist/test/index.cjs +2 -2
  32. package/dist/test/index.cjs.map +1 -1
  33. package/dist/test/index.js +4 -4
  34. package/dist/test/index.js.map +1 -1
  35. package/dist/types/index.cjs.map +1 -1
  36. package/dist/types/index.js +1 -1
  37. package/package.json +2 -2
  38. /package/dist/{chunk-3Z2HU726.js.map → chunk-5PWLGL5E.js.map} +0 -0
package/dist/index.cjs CHANGED
@@ -37,6 +37,9 @@ __export(index_exports, {
37
37
  ConcurrencyError: () => ConcurrencyError,
38
38
  ConsoleLogger: () => ConsoleLogger,
39
39
  CsvFile: () => CsvFile,
40
+ DEFAULT_AUTOCLOSE_CYCLE_MS: () => DEFAULT_AUTOCLOSE_CYCLE_MS,
41
+ DEFAULT_CLOSE_BATCH_SIZE: () => DEFAULT_CLOSE_BATCH_SIZE,
42
+ DEFAULT_CLOSE_YIELD_MS: () => DEFAULT_CLOSE_YIELD_MS,
40
43
  DEFAULT_LANE: () => DEFAULT_LANE,
41
44
  DEFAULT_MAX_SUBSCRIBED_STREAMS: () => DEFAULT_MAX_SUBSCRIBED_STREAMS,
42
45
  DEFAULT_SETTLE_DEBOUNCE_MS: () => DEFAULT_SETTLE_DEBOUNCE_MS,
@@ -68,6 +71,7 @@ __export(index_exports, {
68
71
  log: () => log,
69
72
  port: () => port,
70
73
  projection: () => projection,
74
+ resolveAutocloseConfig: () => resolveAutocloseConfig,
71
75
  scoped: () => scoped,
72
76
  sensitive: () => sensitive,
73
77
  sleep: () => sleep,
@@ -416,32 +420,18 @@ function pii_fields(schema) {
416
420
  return fields;
417
421
  }
418
422
  function pii_split(emitted, fields) {
419
- const rec = emitted.data;
420
- const clean = {};
423
+ const data = { ...emitted.data };
421
424
  const pii = {};
422
- for (const k of Object.keys(rec)) {
423
- if (fields.includes(k)) pii[k] = rec[k];
424
- else clean[k] = rec[k];
425
+ for (const f of fields) {
426
+ if (f in data) {
427
+ pii[f] = data[f];
428
+ delete data[f];
429
+ }
425
430
  }
426
- return { name: emitted.name, data: clean, pii };
427
- }
428
- function pii_merge(event, fields) {
429
- const data = event.data;
430
- const pii = event.pii;
431
- if (pii != null) {
432
- return {
433
- ...event,
434
- data: { ...data, ...pii }
435
- };
436
- }
437
- const shredded = { ...data };
438
- for (const f of fields) shredded[f] = SHREDDED;
439
- return {
440
- ...event,
441
- data: shredded
442
- };
431
+ return { name: emitted.name, data, pii };
443
432
  }
444
433
  function pii_gate(event, fields, predicate, actor) {
434
+ if (fields.length === 0) return event;
445
435
  const data = event.data;
446
436
  if (event.pii == null) {
447
437
  const shredded = { ...data };
@@ -1868,57 +1858,24 @@ var PASS_FACTORIES = {
1868
1858
  "clock-anomalies": make_clock_anomalies_pass
1869
1859
  };
1870
1860
 
1871
- // src/internal/build-classify.ts
1872
- var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
1873
- function classify_registry(registry, states) {
1874
- const statics = /* @__PURE__ */ new Map();
1875
- const reactive_events = /* @__PURE__ */ new Set();
1876
- const event_to_lanes = /* @__PURE__ */ new Map();
1877
- let has_dynamic_resolvers = false;
1878
- for (const [name, register] of Object.entries(registry.events)) {
1879
- if (register.reactions.size > 0) reactive_events.add(name);
1880
- for (const reaction of register.reactions.values()) {
1881
- if (typeof reaction.resolver === "function") {
1882
- has_dynamic_resolvers = true;
1883
- event_to_lanes.set(name, ALL_LANES);
1884
- } else {
1885
- const { target, source, priority = 0, lane } = reaction.resolver;
1886
- const lane_name = lane ?? "default";
1887
- const existing_lanes = event_to_lanes.get(name);
1888
- if (existing_lanes !== ALL_LANES) {
1889
- const set = existing_lanes ?? /* @__PURE__ */ new Set();
1890
- set.add(lane_name);
1891
- event_to_lanes.set(name, set);
1892
- }
1893
- const key = `${target}|${source ?? ""}`;
1894
- const existing = statics.get(key);
1895
- if (!existing) {
1896
- statics.set(key, { stream: target, source, priority, lane });
1897
- } else {
1898
- if ((existing.lane ?? void 0) !== (lane ?? void 0))
1899
- throw new Error(
1900
- `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
1901
- );
1902
- if (priority > existing.priority) {
1903
- statics.set(key, { ...existing, priority });
1904
- }
1905
- }
1906
- }
1907
- }
1908
- }
1909
- const event_to_state = /* @__PURE__ */ new Map();
1910
- for (const merged of states.values()) {
1911
- for (const event_name of Object.keys(merged.events)) {
1912
- event_to_state.set(event_name, merged);
1913
- }
1914
- }
1915
- return {
1916
- static_targets: [...statics.values()],
1917
- has_dynamic_resolvers,
1918
- reactive_events,
1919
- event_to_state,
1920
- event_to_lanes
1921
- };
1861
+ // src/internal/autoclose-config.ts
1862
+ var import_zod5 = require("zod");
1863
+ var DEFAULT_AUTOCLOSE_CYCLE_MS = 6e4;
1864
+ var DEFAULT_CLOSE_BATCH_SIZE = 64;
1865
+ var DEFAULT_CLOSE_YIELD_MS = 0;
1866
+ var AutocloseOptionsSchema = import_zod5.z.object({
1867
+ autocloseCycleMs: import_zod5.z.number().min(1e4).max(36e5).default(DEFAULT_AUTOCLOSE_CYCLE_MS),
1868
+ closeBatchSize: import_zod5.z.number().int().min(1).max(1024).default(DEFAULT_CLOSE_BATCH_SIZE),
1869
+ closeYieldMs: import_zod5.z.number().min(0).max(1e3).default(DEFAULT_CLOSE_YIELD_MS),
1870
+ closeOnError: import_zod5.z.boolean().default(false)
1871
+ });
1872
+ function resolveAutocloseConfig(options) {
1873
+ return AutocloseOptionsSchema.parse({
1874
+ autocloseCycleMs: options?.autocloseCycleMs,
1875
+ closeBatchSize: options?.closeBatchSize,
1876
+ closeYieldMs: options?.closeYieldMs,
1877
+ closeOnError: options?.closeOnError
1878
+ });
1922
1879
  }
1923
1880
 
1924
1881
  // src/internal/close-cycle.ts
@@ -2020,7 +1977,7 @@ async function load_restart_seeds(guarded, target_map, stream_info, event_to_sta
2020
1977
  );
2021
1978
  return;
2022
1979
  }
2023
- const snap2 = await load2(owner_state, stream);
1980
+ const snap2 = await load2(owner_state, { stream });
2024
1981
  seed_states.set(stream, snap2.state);
2025
1982
  })
2026
1983
  );
@@ -2068,6 +2025,233 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
2068
2025
  return truncated;
2069
2026
  }
2070
2027
 
2028
+ // src/internal/correlator.ts
2029
+ var import_node_crypto = require("crypto");
2030
+ var BASE = 36;
2031
+ var SEG_WIDTH = 4;
2032
+ var SEG_SPACE = BASE ** SEG_WIDTH;
2033
+ function seg(n) {
2034
+ return n.toString(BASE).padStart(SEG_WIDTH, "0");
2035
+ }
2036
+ var default_correlator = ({ state: state2, action: action2 }) => {
2037
+ const s = state2.slice(0, SEG_WIDTH).toLowerCase();
2038
+ const a = action2.slice(0, SEG_WIDTH).toLowerCase();
2039
+ const ts = seg(Date.now() % SEG_SPACE);
2040
+ const rnd = seg((0, import_node_crypto.randomInt)(SEG_SPACE));
2041
+ return `${s}-${a}-${ts}${rnd}`;
2042
+ };
2043
+ function close_correlation(correlator, actor) {
2044
+ return correlator({
2045
+ state: "$close",
2046
+ action: "close",
2047
+ stream: "$close",
2048
+ actor
2049
+ });
2050
+ }
2051
+
2052
+ // src/internal/autoclose-cycle.ts
2053
+ function yield_between_batches(ms) {
2054
+ if (ms <= 0) return Promise.resolve();
2055
+ return new Promise((resolve) => setTimeout(resolve, ms));
2056
+ }
2057
+ async function run_autoclose_cycle(deps) {
2058
+ const s = store2();
2059
+ let inspected = 0;
2060
+ let evaluated = 0;
2061
+ let predicate_errors = 0;
2062
+ const truncated = /* @__PURE__ */ new Map();
2063
+ const skipped = [];
2064
+ const stats = await s.query_stats(
2065
+ {},
2066
+ { count: true, exclude: [TOMBSTONE_EVENT] }
2067
+ );
2068
+ const flush_batch = async (candidates2) => {
2069
+ if (candidates2.length === 0) return;
2070
+ const result = await run_close_cycle(candidates2, {
2071
+ reactive_events_size: deps.reactive_events_size,
2072
+ event_to_state: deps.event_to_state,
2073
+ load: deps.load,
2074
+ tombstone: deps.tombstone,
2075
+ logger: deps.logger,
2076
+ correlation: deps.correlation
2077
+ });
2078
+ for (const [stream, entry] of result.truncated) {
2079
+ truncated.set(stream, entry);
2080
+ }
2081
+ for (const stream of result.skipped) skipped.push(stream);
2082
+ await yield_between_batches(deps.config.closeYieldMs);
2083
+ };
2084
+ let candidates = [];
2085
+ for (const [stream, { head, count }] of stats) {
2086
+ inspected += 1;
2087
+ const owner = deps.event_to_state.get(head.name);
2088
+ if (!owner) continue;
2089
+ const predicate = deps.autoclose_policy(owner.name);
2090
+ if (!predicate) continue;
2091
+ let close = false;
2092
+ try {
2093
+ close = predicate(stream, head, count ?? 0);
2094
+ evaluated += 1;
2095
+ } catch (err) {
2096
+ predicate_errors += 1;
2097
+ deps.logger.error(
2098
+ `Autoclose predicate for state "${owner.name}" threw on stream "${stream}": ${err instanceof Error ? err.message : String(err)}`
2099
+ );
2100
+ close = deps.config.closeOnError;
2101
+ }
2102
+ if (!close) continue;
2103
+ const archiver = deps.autoclose_archiver(owner.name);
2104
+ const archive = archiver ? () => archiver(stream, head) : void 0;
2105
+ candidates.push({ stream, archive });
2106
+ if (candidates.length >= deps.config.closeBatchSize) {
2107
+ await flush_batch(candidates);
2108
+ candidates = [];
2109
+ }
2110
+ }
2111
+ await flush_batch(candidates);
2112
+ return {
2113
+ inspected,
2114
+ evaluated,
2115
+ predicate_errors,
2116
+ close_result: { truncated, skipped }
2117
+ };
2118
+ }
2119
+ var AutocloseController = class {
2120
+ deps;
2121
+ _timer;
2122
+ /**
2123
+ * Reentrancy guard. The interval fires the next tick on cadence
2124
+ * regardless of how long the previous one took; this flag drops
2125
+ * overlapping ticks so the cycle stays sequential per controller.
2126
+ */
2127
+ _running = false;
2128
+ constructor(deps) {
2129
+ this.deps = deps;
2130
+ }
2131
+ /**
2132
+ * Start the cycle ticker. Returns `false` if already running so
2133
+ * accidental double-start doesn't stack timers (matches
2134
+ * `_correlate.start_polling` semantics).
2135
+ */
2136
+ start() {
2137
+ if (this._timer) return false;
2138
+ this._timer = setInterval(() => {
2139
+ this.run_once().catch((err) => {
2140
+ this.deps.logger.error(
2141
+ `Autoclose cycle errored: ${err instanceof Error ? err.message : String(err)}`
2142
+ );
2143
+ });
2144
+ }, this.deps.config.autocloseCycleMs);
2145
+ this._timer.unref();
2146
+ return true;
2147
+ }
2148
+ /**
2149
+ * Stop the cycle ticker. Idempotent.
2150
+ */
2151
+ stop() {
2152
+ if (this._timer) {
2153
+ clearInterval(this._timer);
2154
+ this._timer = void 0;
2155
+ }
2156
+ }
2157
+ /**
2158
+ * Whether the ticker is currently running.
2159
+ */
2160
+ get is_running() {
2161
+ return this._timer !== void 0;
2162
+ }
2163
+ /**
2164
+ * Run one cycle synchronously. The controller's interval callback
2165
+ * delegates here; tests can invoke it directly to deterministically
2166
+ * exercise the cycle without spinning real timers.
2167
+ *
2168
+ * Overlapping invocations short-circuit: if a previous tick is
2169
+ * still running, this one drops and returns `null`. The orchestrator
2170
+ * never sees a queue of pending ticks regardless of how slow the
2171
+ * predicate / archive / truncate are.
2172
+ */
2173
+ async run_once() {
2174
+ if (this._running) return null;
2175
+ this._running = true;
2176
+ try {
2177
+ const correlation = close_correlation(
2178
+ this.deps.correlator,
2179
+ this.deps.close_actor
2180
+ );
2181
+ const result = await run_autoclose_cycle({
2182
+ autoclose_policy: this.deps.autoclose_policy,
2183
+ autoclose_archiver: this.deps.autoclose_archiver,
2184
+ event_to_state: this.deps.event_to_state,
2185
+ reactive_events_size: this.deps.reactive_events_size,
2186
+ load: this.deps.load,
2187
+ tombstone: this.deps.tombstone,
2188
+ logger: this.deps.logger,
2189
+ config: this.deps.config,
2190
+ correlation
2191
+ });
2192
+ if (result.close_result.truncated.size > 0) {
2193
+ this.deps.on_closed(result.close_result);
2194
+ }
2195
+ return result;
2196
+ } finally {
2197
+ this._running = false;
2198
+ }
2199
+ }
2200
+ };
2201
+
2202
+ // src/internal/build-classify.ts
2203
+ var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
2204
+ function classify_registry(registry, states) {
2205
+ const statics = /* @__PURE__ */ new Map();
2206
+ const reactive_events = /* @__PURE__ */ new Set();
2207
+ const event_to_lanes = /* @__PURE__ */ new Map();
2208
+ let has_dynamic_resolvers = false;
2209
+ for (const [name, register] of Object.entries(registry.events)) {
2210
+ if (register.reactions.size > 0) reactive_events.add(name);
2211
+ for (const reaction of register.reactions.values()) {
2212
+ if (typeof reaction.resolver === "function") {
2213
+ has_dynamic_resolvers = true;
2214
+ event_to_lanes.set(name, ALL_LANES);
2215
+ } else {
2216
+ const { target, source, priority = 0, lane } = reaction.resolver;
2217
+ const lane_name = lane ?? "default";
2218
+ const existing_lanes = event_to_lanes.get(name);
2219
+ if (existing_lanes !== ALL_LANES) {
2220
+ const set = existing_lanes ?? /* @__PURE__ */ new Set();
2221
+ set.add(lane_name);
2222
+ event_to_lanes.set(name, set);
2223
+ }
2224
+ const key = `${target}|${source ?? ""}`;
2225
+ const existing = statics.get(key);
2226
+ if (!existing) {
2227
+ statics.set(key, { stream: target, source, priority, lane });
2228
+ } else {
2229
+ if ((existing.lane ?? void 0) !== (lane ?? void 0))
2230
+ throw new Error(
2231
+ `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
2232
+ );
2233
+ if (priority > existing.priority) {
2234
+ statics.set(key, { ...existing, priority });
2235
+ }
2236
+ }
2237
+ }
2238
+ }
2239
+ }
2240
+ const event_to_state = /* @__PURE__ */ new Map();
2241
+ for (const merged of states.values()) {
2242
+ for (const event_name of Object.keys(merged.events)) {
2243
+ event_to_state.set(event_name, merged);
2244
+ }
2245
+ }
2246
+ return {
2247
+ static_targets: [...statics.values()],
2248
+ has_dynamic_resolvers,
2249
+ reactive_events,
2250
+ event_to_state,
2251
+ event_to_lanes
2252
+ };
2253
+ }
2254
+
2071
2255
  // src/internal/correlate-cycle.ts
2072
2256
  var CorrelateCycle = class {
2073
2257
  _checkpoint = -1;
@@ -2196,30 +2380,6 @@ var CorrelateCycle = class {
2196
2380
  }
2197
2381
  };
2198
2382
 
2199
- // src/internal/correlator.ts
2200
- var import_node_crypto = require("crypto");
2201
- var BASE = 36;
2202
- var SEG_WIDTH = 4;
2203
- var SEG_SPACE = BASE ** SEG_WIDTH;
2204
- function seg(n) {
2205
- return n.toString(BASE).padStart(SEG_WIDTH, "0");
2206
- }
2207
- var default_correlator = ({ state: state2, action: action2 }) => {
2208
- const s = state2.slice(0, SEG_WIDTH).toLowerCase();
2209
- const a = action2.slice(0, SEG_WIDTH).toLowerCase();
2210
- const ts = seg(Date.now() % SEG_SPACE);
2211
- const rnd = seg((0, import_node_crypto.randomInt)(SEG_SPACE));
2212
- return `${s}-${a}-${ts}${rnd}`;
2213
- };
2214
- function close_correlation(correlator, actor) {
2215
- return correlator({
2216
- state: "$close",
2217
- action: "close",
2218
- stream: "$close",
2219
- actor
2220
- });
2221
- }
2222
-
2223
2383
  // src/internal/drain-cycle.ts
2224
2384
  var import_node_crypto2 = require("crypto");
2225
2385
 
@@ -2384,7 +2544,6 @@ async function scan(source, opts = {}, callback) {
2384
2544
  migrated = {
2385
2545
  ...event,
2386
2546
  name: migration.to,
2387
- // biome-ignore lint/suspicious/noExplicitAny: migration target shape is caller-defined
2388
2547
  data: new_data
2389
2548
  };
2390
2549
  migrated_count++;
@@ -2433,39 +2592,37 @@ async function scan(source, opts = {}, callback) {
2433
2592
  }
2434
2593
  };
2435
2594
  }
2436
- async function load(me, stream, callback, asOf, actor) {
2595
+ async function load(me, target, callback) {
2596
+ const { stream, actor, asOf } = target;
2437
2597
  const time_travel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
2438
2598
  const cached = time_travel ? void 0 : await cache2().get(stream);
2439
2599
  const cache_hit = !!cached;
2440
2600
  let state2 = cached?.state ?? (me.init ? me.init() : {});
2441
2601
  let patches = cached?.patches ?? 0;
2442
2602
  let snaps = cached?.snaps ?? 0;
2443
- let version = cached?.version ?? -1;
2444
2603
  let replayed = 0;
2445
2604
  let event;
2446
2605
  await store2().query(
2447
- (e) => {
2448
- version = e.version;
2449
- const typed = e;
2450
- event = me.view(typed, actor);
2451
- if (e.name === SNAP_EVENT) {
2452
- state2 = e.data;
2606
+ (raw) => {
2607
+ event = me.view(raw, actor);
2608
+ if (event.name === SNAP_EVENT) {
2609
+ state2 = event.data;
2453
2610
  snaps++;
2454
2611
  patches = 0;
2455
2612
  replayed++;
2456
- } else if (me.patch[e.name]) {
2457
- state2 = (0, import_act_patch.patch)(state2, me.patch[e.name](typed, state2));
2613
+ } else if (me.patch[event.name]) {
2614
+ state2 = (0, import_act_patch.patch)(state2, me.patch[event.name](event, state2));
2458
2615
  patches++;
2459
2616
  replayed++;
2460
- } else if (e.name !== TOMBSTONE_EVENT) {
2617
+ } else if (event.name !== TOMBSTONE_EVENT) {
2461
2618
  log().warn(
2462
- `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
2619
+ `Skipping unknown event "${String(event.name)}" on stream "${stream}" (id=${event.id}) \u2014 no reducer in state "${me.name}"`
2463
2620
  );
2464
2621
  }
2465
2622
  callback?.({
2466
2623
  event,
2467
2624
  state: state2,
2468
- version,
2625
+ version: event.version,
2469
2626
  patches,
2470
2627
  snaps,
2471
2628
  cache_hit,
@@ -2478,39 +2635,43 @@ async function load(me, stream, callback, asOf, actor) {
2478
2635
  ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
2479
2636
  }
2480
2637
  );
2481
- if (replayed > 0 && !time_travel && event) {
2638
+ if (replayed > 0 && !time_travel && event && !me.pii_aware) {
2482
2639
  await cache2().set(stream, {
2483
2640
  state: state2,
2484
- version,
2641
+ version: event.version,
2485
2642
  event_id: event.id,
2486
2643
  patches,
2487
2644
  snaps
2488
2645
  });
2489
2646
  }
2490
- return { event, state: state2, version, patches, snaps, cache_hit, replayed };
2647
+ return {
2648
+ event,
2649
+ state: state2,
2650
+ version: event?.version ?? cached?.version ?? -1,
2651
+ patches,
2652
+ snaps,
2653
+ cache_hit,
2654
+ replayed
2655
+ };
2491
2656
  }
2492
- async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = default_correlator) {
2657
+ async function action(me, action2, target, payload, options) {
2493
2658
  const { stream, expectedVersion, actor } = target;
2494
2659
  if (!stream) throw new Error("Missing target stream");
2495
- const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
2660
+ const reactingTo = options?.reactingTo;
2661
+ const correlator = options?.correlator ?? default_correlator;
2662
+ const validated = validate(action2, payload, me.actions[action2]);
2496
2663
  const opts = me.options?.[action2];
2497
2664
  const max_retries = opts?.maxRetries ?? 0;
2498
2665
  for (let attempt = 0; ; attempt++) {
2499
2666
  try {
2500
- const snapshot = await load(
2501
- me,
2502
- stream,
2503
- void 0,
2504
- void 0,
2505
- target.actor
2506
- );
2667
+ const snapshot = await load(me, { stream, actor: target.actor });
2507
2668
  if (snapshot.event?.name === TOMBSTONE_EVENT)
2508
2669
  throw new StreamClosedError(stream);
2509
2670
  const expected = expectedVersion ?? snapshot.event?.version;
2510
2671
  if (me.given) {
2511
2672
  const invariants = me.given[action2] || [];
2512
- invariants.forEach(({ valid, description }) => {
2513
- if (!valid(snapshot.state, actor))
2673
+ invariants.forEach(({ valid: valid2, description }) => {
2674
+ if (!valid2(snapshot.state, actor))
2514
2675
  throw new InvariantError(
2515
2676
  action2,
2516
2677
  validated,
@@ -2526,24 +2687,11 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
2526
2687
  return [snapshot];
2527
2688
  }
2528
2689
  const tuples = Array.isArray(result[0]) ? result : [result];
2529
- const deprecated = me._deprecated;
2530
- if (deprecated && deprecated.size > 0) {
2531
- const me_ = me;
2532
- const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
2533
- for (const [name] of tuples) {
2534
- const evt = name;
2535
- if (deprecated.has(evt) && !warned.has(evt)) {
2536
- warned.add(evt);
2537
- log().warn(
2538
- `Action "${String(action2)}" emitted deprecated event "${evt}". A newer version exists in the registry \u2014 update the action's .emit() to target the current version. (warned once per process)`
2539
- );
2540
- }
2541
- }
2542
- }
2543
- const emitted = tuples.map(([name, data]) => {
2544
- const validated2 = skipValidation ? data : validate(name, data, me.events[name]);
2545
- return me.message({ name, data: validated2 });
2546
- });
2690
+ const valid = tuples.map(([name, data]) => ({
2691
+ name,
2692
+ data: validate(name, data, me.events[name])
2693
+ }));
2694
+ const emitted = valid.map((e) => me.message(e));
2547
2695
  const meta = {
2548
2696
  correlation: reactingTo?.meta.correlation || correlator({
2549
2697
  action: action2,
@@ -2584,12 +2732,13 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
2584
2732
  throw error;
2585
2733
  }
2586
2734
  let { state: state2, patches } = snapshot;
2587
- const snapshots = committed.map((event) => {
2735
+ const snapshots = committed.map((row, i) => {
2736
+ const event = { ...row, data: valid[i].data };
2588
2737
  const p = me.patch[event.name](event, state2);
2589
2738
  state2 = (0, import_act_patch.patch)(state2, p);
2590
2739
  patches++;
2591
2740
  return {
2592
- event: me.view(event, target.actor),
2741
+ event,
2593
2742
  state: state2,
2594
2743
  version: event.version,
2595
2744
  patches,
@@ -2601,13 +2750,15 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
2601
2750
  });
2602
2751
  const last = snapshots.at(-1);
2603
2752
  const snapped = me.snap?.(last);
2604
- cache2().set(stream, {
2605
- state: last.state,
2606
- version: last.event.version,
2607
- event_id: last.event.id,
2608
- patches: snapped ? 0 : last.patches,
2609
- snaps: snapped ? last.snaps + 1 : last.snaps
2610
- }).catch((err) => log().error(err));
2753
+ if (!me.pii_aware) {
2754
+ cache2().set(stream, {
2755
+ state: last.state,
2756
+ version: last.event.version,
2757
+ event_id: last.event.id,
2758
+ patches: snapped ? 0 : last.patches,
2759
+ snaps: snapped ? last.snaps + 1 : last.snaps
2760
+ }).catch((err) => log().error(err));
2761
+ }
2611
2762
  if (snapped) void snap(last);
2612
2763
  return snapshots;
2613
2764
  } catch (error) {
@@ -2674,15 +2825,7 @@ var traced = (inner, exit, entry) => (async (...args) => {
2674
2825
  return result;
2675
2826
  });
2676
2827
  function build_es(logger, correlator = default_correlator) {
2677
- const bound_action = (me, action_name, target, payload, reactingTo, skipValidation = false) => action(
2678
- me,
2679
- action_name,
2680
- target,
2681
- payload,
2682
- reactingTo,
2683
- skipValidation,
2684
- correlator
2685
- );
2828
+ const bound_action = (me, action_name, target, payload, options) => action(me, action_name, target, payload, { correlator, ...options });
2686
2829
  if (logger.level !== "trace") {
2687
2830
  return {
2688
2831
  snap,
@@ -2701,7 +2844,7 @@ function build_es(logger, correlator = default_correlator) {
2701
2844
  )
2702
2845
  );
2703
2846
  }),
2704
- load: traced(load, (result, _me, stream, _cb, asOf) => {
2847
+ load: traced(load, (result, _me, target) => {
2705
2848
  const stats = stats_marker(
2706
2849
  result.version,
2707
2850
  result.replayed,
@@ -2712,7 +2855,7 @@ function build_es(logger, correlator = default_correlator) {
2712
2855
  es_caption(
2713
2856
  "load",
2714
2857
  C_GREEN,
2715
- `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
2858
+ `${target.stream}${as_of_marker(target.asOf)} ${cache_marker(result.cache_hit)} ${stats}`
2716
2859
  )
2717
2860
  );
2718
2861
  }),
@@ -3012,7 +3155,7 @@ var DrainController = class {
3012
3155
  };
3013
3156
 
3014
3157
  // src/internal/merge.ts
3015
- var import_zod5 = require("zod");
3158
+ var import_zod6 = require("zod");
3016
3159
  function base_type_name(zodType) {
3017
3160
  let t = zodType;
3018
3161
  while (typeof t.unwrap === "function") {
@@ -3021,7 +3164,7 @@ function base_type_name(zodType) {
3021
3164
  return t.constructor.name;
3022
3165
  }
3023
3166
  function merge_schemas(existing, incoming, state_name) {
3024
- if (existing instanceof import_zod5.ZodObject && incoming instanceof import_zod5.ZodObject) {
3167
+ if (existing instanceof import_zod6.ZodObject && incoming instanceof import_zod6.ZodObject) {
3025
3168
  const existing_shape = existing.shape;
3026
3169
  const incoming_shape = incoming.shape;
3027
3170
  for (const key of Object.keys(incoming_shape)) {
@@ -3200,13 +3343,10 @@ function build_handle(deps) {
3200
3343
  };
3201
3344
  for (const payload of payloads) {
3202
3345
  const { event, handler } = payload;
3203
- scoped_app.do = (action2, target, action_payload, reactingTo, skipValidation) => bound_do(
3204
- action2,
3205
- target,
3206
- action_payload,
3207
- reactingTo ?? event,
3208
- skipValidation
3209
- );
3346
+ scoped_app.do = (action2, target, action_payload, options) => bound_do(action2, target, action_payload, {
3347
+ ...options,
3348
+ reactingTo: options?.reactingTo ?? event
3349
+ });
3210
3350
  try {
3211
3351
  await handler(event, stream, scoped_app);
3212
3352
  at = event.id;
@@ -3343,6 +3483,22 @@ var Act = class {
3343
3483
  registry;
3344
3484
  /** Map of state name → state definition; populated by the builder. */
3345
3485
  _states;
3486
+ /**
3487
+ * Resolved autoclose configuration (#837 / epic #802). Frozen at
3488
+ * `act().build()` via {@link resolveAutocloseConfig}.
3489
+ *
3490
+ * @internal
3491
+ */
3492
+ _autoclose_config;
3493
+ /**
3494
+ * App-level autoclose controller. `undefined` when no state declares
3495
+ * `.autocloses(...)` — the controller is never constructed, so
3496
+ * `start_correlations()` / `stop_correlations()` skip the autoclose
3497
+ * lifecycle and the orchestrator pays zero per-tick cost.
3498
+ *
3499
+ * @internal
3500
+ */
3501
+ _autoclose;
3346
3502
  /**
3347
3503
  * Emit a lifecycle event. The payload type is inferred from the event name
3348
3504
  * via {@link ActLifecycleEvents}.
@@ -3477,6 +3633,7 @@ var Act = class {
3477
3633
  this._reactive_events = reactive_events;
3478
3634
  this._listen = options.listen !== false;
3479
3635
  this._drain = options.drain !== false;
3636
+ this._autoclose_config = resolveAutocloseConfig(options);
3480
3637
  this._event_to_state = event_to_state;
3481
3638
  this._event_to_lanes = event_to_lanes;
3482
3639
  const all_lanes = ["default", ...lanes.map((l) => l.name)];
@@ -3540,6 +3697,26 @@ var Act = class {
3540
3697
  },
3541
3698
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
3542
3699
  );
3700
+ const states_with_policy = [...this._states.values()].filter(
3701
+ (s) => s.autoclose
3702
+ );
3703
+ this._autoclose = states_with_policy.length ? new AutocloseController({
3704
+ // Internal cycle takes the loose `(string) => …` signature; the
3705
+ // public `registry.autoclose_policy` narrows to
3706
+ // `keyof TStateMap & string`. Thin lambdas bridge across the
3707
+ // narrowing — runtime semantics are unchanged.
3708
+ autoclose_policy: (name) => this.registry.autoclose_policy(name),
3709
+ autoclose_archiver: (name) => this.registry.autoclose_archiver(name),
3710
+ event_to_state: this._event_to_state,
3711
+ reactive_events_size: this._reactive_events.size,
3712
+ load: this._es.load,
3713
+ tombstone: this._es.tombstone,
3714
+ logger: this._logger,
3715
+ config: this._autoclose_config,
3716
+ correlator: this._correlator,
3717
+ close_actor: { id: "$autoclose", name: "autoclose" },
3718
+ on_closed: (result) => this.emit("closed", result)
3719
+ }) : void 0;
3543
3720
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store2());
3544
3721
  dispose(() => this.shutdown());
3545
3722
  }
@@ -3612,8 +3789,9 @@ var Act = class {
3612
3789
  * @param action - The name of the action to execute
3613
3790
  * @param target - Target specification with stream ID and actor context
3614
3791
  * @param payload - Action payload matching the action's schema
3615
- * @param reactingTo - Optional event that triggered this action (for correlation)
3616
- * @param skipValidation - Skip schema validation (use carefully, for performance)
3792
+ * @param options - Per-call dispatch options ({@link DoOptions}) —
3793
+ * `reactingTo` to thread correlation, `correlator` to override the
3794
+ * framework or orchestrator-level correlator for this call only.
3617
3795
  * @returns Array of snapshots for all affected states (usually one)
3618
3796
  *
3619
3797
  * @throws {ValidationError} If payload doesn't match action schema
@@ -3668,7 +3846,7 @@ var Act = class {
3668
3846
  * { amount: event.data.items.length }
3669
3847
  * );
3670
3848
  * // To use a different correlation, pass reactingTo explicitly:
3671
- * // await app.do("reduceStock", target, payload, customEvent);
3849
+ * // await app.do("reduceStock", target, payload, { reactingTo: customEvent });
3672
3850
  * })
3673
3851
  * .to("inventory-1")
3674
3852
  * .build();
@@ -3678,15 +3856,14 @@ var Act = class {
3678
3856
  * @see {@link Snapshot} for return value structure
3679
3857
  * @see {@link ValidationError}, {@link InvariantError}, {@link ConcurrencyError}
3680
3858
  */
3681
- async do(action2, target, payload, reactingTo, skipValidation = false) {
3859
+ async do(action2, target, payload, options) {
3682
3860
  return this._scoped(async () => {
3683
3861
  const snapshots = await this._es.action(
3684
3862
  this.registry.actions[action2],
3685
3863
  action2,
3686
3864
  target,
3687
3865
  payload,
3688
- reactingTo,
3689
- skipValidation
3866
+ options
3690
3867
  );
3691
3868
  if (this._reactive_events.size > 0)
3692
3869
  this._arm_for_event_names(
@@ -3696,7 +3873,7 @@ var Act = class {
3696
3873
  return snapshots;
3697
3874
  });
3698
3875
  }
3699
- async load(stateOrName, stream, callback, asOf, actor) {
3876
+ async load(stateOrName, streamOrTarget, callback, asOf) {
3700
3877
  return this._scoped(async () => {
3701
3878
  let merged;
3702
3879
  if (typeof stateOrName === "string") {
@@ -3706,7 +3883,12 @@ var Act = class {
3706
3883
  } else {
3707
3884
  merged = this._states.get(stateOrName.name) || stateOrName;
3708
3885
  }
3709
- return await this._es.load(merged, stream, callback, asOf, actor);
3886
+ const target = typeof streamOrTarget === "string" ? {
3887
+ stream: streamOrTarget,
3888
+ actor: void 0,
3889
+ asOf
3890
+ } : streamOrTarget;
3891
+ return await this._es.load(merged, target, callback);
3710
3892
  });
3711
3893
  }
3712
3894
  /**
@@ -4026,7 +4208,9 @@ var Act = class {
4026
4208
  * @see {@link stop_correlations} to stop the worker
4027
4209
  */
4028
4210
  start_correlations(query = {}, frequency = 1e4, callback) {
4029
- return this._correlate.start_polling(query, frequency, callback);
4211
+ const started = this._correlate.start_polling(query, frequency, callback);
4212
+ this._autoclose?.start();
4213
+ return started;
4030
4214
  }
4031
4215
  /**
4032
4216
  * Stops the automatic correlation worker.
@@ -4047,6 +4231,7 @@ var Act = class {
4047
4231
  */
4048
4232
  stop_correlations() {
4049
4233
  this._correlate.stop_polling();
4234
+ this._autoclose?.stop();
4050
4235
  }
4051
4236
  /**
4052
4237
  * Cancels any pending or active settle cycle.
@@ -4430,11 +4615,18 @@ function act() {
4430
4615
  const states = /* @__PURE__ */ new Map();
4431
4616
  const _sf = /* @__PURE__ */ new Map();
4432
4617
  const _dp = /* @__PURE__ */ new Map();
4618
+ const _de = /* @__PURE__ */ new Map();
4619
+ const _ac = /* @__PURE__ */ new Map();
4620
+ const _aa = /* @__PURE__ */ new Map();
4621
+ const EMPTY_DEPRECATED = /* @__PURE__ */ new Set();
4433
4622
  const registry = {
4434
4623
  actions: {},
4435
4624
  events: {},
4436
4625
  sensitive_fields: (event_name) => _sf.get(event_name) ?? [],
4437
- disclosure_predicate: (state_name) => _dp.get(state_name) ?? null
4626
+ disclosure_predicate: (state_name) => _dp.get(state_name) ?? null,
4627
+ deprecated_events: (state_name) => _de.get(state_name) ?? EMPTY_DEPRECATED,
4628
+ autoclose_policy: (state_name) => _ac.get(state_name) ?? null,
4629
+ autoclose_archiver: (state_name) => _aa.get(state_name) ?? null
4438
4630
  };
4439
4631
  const pending_projections = [];
4440
4632
  const batch_handlers = /* @__PURE__ */ new Map();
@@ -4446,7 +4638,7 @@ function act() {
4446
4638
  const event_names = Object.keys(state2.events);
4447
4639
  const deprecated = deprecated_event_names(event_names);
4448
4640
  if (deprecated.size === 0) continue;
4449
- state2._deprecated = deprecated;
4641
+ _de.set(state2.name, deprecated);
4450
4642
  for (const name of deprecated) {
4451
4643
  const current = current_version_of(name, event_names);
4452
4644
  deprecation_summary.push({
@@ -4561,6 +4753,8 @@ function act() {
4561
4753
  }
4562
4754
  for (const state2 of states.values()) {
4563
4755
  if (state2.disclose) _dp.set(state2.name, state2.disclose);
4756
+ if (state2.autoclose) _ac.set(state2.name, state2.autoclose);
4757
+ if (state2.archive) _aa.set(state2.name, state2.archive);
4564
4758
  const fields_by_event = /* @__PURE__ */ new Map();
4565
4759
  for (const event_name of Object.keys(state2.events)) {
4566
4760
  const fields = _sf.get(event_name);
@@ -4574,18 +4768,17 @@ function act() {
4574
4768
  );
4575
4769
  }
4576
4770
  const disclose = state2.disclose ?? null;
4577
- state2.view = (event, actor) => {
4578
- const fields = fields_by_event.get(event.name);
4579
- return fields ? pii_gate(event, fields, disclose, actor) : event;
4580
- };
4771
+ state2.pii_aware = true;
4772
+ state2.view = (event, actor) => pii_gate(
4773
+ event,
4774
+ fields_by_event.get(event.name) ?? [],
4775
+ disclose,
4776
+ actor
4777
+ );
4581
4778
  state2.message = (validated) => {
4582
4779
  const fields = fields_by_event.get(validated.name);
4583
4780
  return fields ? pii_split(validated, fields) : validated;
4584
4781
  };
4585
- for (const [event_name, fields] of fields_by_event) {
4586
- const original = state2.patch[event_name];
4587
- state2.patch[event_name] = (event, s) => original(pii_merge(event, fields), s);
4588
- }
4589
4782
  }
4590
4783
  for (const [target, original] of batch_handlers) {
4591
4784
  const wrapped = async (events, stream) => {
@@ -4765,9 +4958,10 @@ function state(entry) {
4765
4958
  init,
4766
4959
  patch: default_patch,
4767
4960
  on: {},
4768
- // Step delegates initialized as identity. `act().build()`
4769
- // overrides on states with `sensitive(...)` events to bake in
4770
- // the gate / split.
4961
+ // Step delegates initialized as identity, `pii_aware` false.
4962
+ // `act().build()` flips both on states with `sensitive(...)`
4963
+ // events to bake in the gate / split and gate the cache write.
4964
+ pii_aware: false,
4771
4965
  view: (event) => event,
4772
4966
  message: (validated) => validated
4773
4967
  };
@@ -4828,6 +5022,24 @@ function action_builder(state2) {
4828
5022
  internal.disclose = disclose;
4829
5023
  return builder;
4830
5024
  },
5025
+ autocloses(predicate) {
5026
+ if (typeof predicate !== "function") {
5027
+ throw new Error(
5028
+ ".autocloses(predicate) requires a function; got " + typeof predicate
5029
+ );
5030
+ }
5031
+ internal.autoclose = predicate;
5032
+ return builder;
5033
+ },
5034
+ archives(archive) {
5035
+ if (typeof archive !== "function") {
5036
+ throw new Error(
5037
+ ".archives(archive) requires a function; got " + typeof archive
5038
+ );
5039
+ }
5040
+ internal.archive = archive;
5041
+ return builder;
5042
+ },
4831
5043
  build() {
4832
5044
  return internal;
4833
5045
  }
@@ -5004,6 +5216,9 @@ function write_line(writer, line) {
5004
5216
  ConcurrencyError,
5005
5217
  ConsoleLogger,
5006
5218
  CsvFile,
5219
+ DEFAULT_AUTOCLOSE_CYCLE_MS,
5220
+ DEFAULT_CLOSE_BATCH_SIZE,
5221
+ DEFAULT_CLOSE_YIELD_MS,
5007
5222
  DEFAULT_LANE,
5008
5223
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
5009
5224
  DEFAULT_SETTLE_DEBOUNCE_MS,
@@ -5035,6 +5250,7 @@ function write_line(writer, line) {
5035
5250
  log,
5036
5251
  port,
5037
5252
  projection,
5253
+ resolveAutocloseConfig,
5038
5254
  scoped,
5039
5255
  sensitive,
5040
5256
  sleep,