@rotorsoft/act 0.32.4 → 0.32.6

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 (63) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +65 -47
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/adapters/{ConsoleLogger.d.ts → console-logger.d.ts} +2 -2
  5. package/dist/@types/adapters/console-logger.d.ts.map +1 -0
  6. package/dist/@types/adapters/{InMemoryCache.d.ts → in-memory-cache.d.ts} +2 -3
  7. package/dist/@types/adapters/in-memory-cache.d.ts.map +1 -0
  8. package/dist/@types/adapters/{InMemoryStore.d.ts → in-memory-store.d.ts} +5 -1
  9. package/dist/@types/adapters/in-memory-store.d.ts.map +1 -0
  10. package/dist/@types/adapters/index.d.ts +3 -3
  11. package/dist/@types/adapters/index.d.ts.map +1 -1
  12. package/dist/@types/{act-builder.d.ts → builders/act-builder.d.ts} +5 -5
  13. package/dist/@types/builders/act-builder.d.ts.map +1 -0
  14. package/dist/@types/builders/index.d.ts +13 -0
  15. package/dist/@types/builders/index.d.ts.map +1 -0
  16. package/dist/@types/{projection-builder.d.ts → builders/projection-builder.d.ts} +3 -3
  17. package/dist/@types/builders/projection-builder.d.ts.map +1 -0
  18. package/dist/@types/{slice-builder.d.ts → builders/slice-builder.d.ts} +2 -2
  19. package/dist/@types/builders/slice-builder.d.ts.map +1 -0
  20. package/dist/@types/{state-builder.d.ts → builders/state-builder.d.ts} +1 -1
  21. package/dist/@types/builders/state-builder.d.ts.map +1 -0
  22. package/dist/@types/config.d.ts.map +1 -1
  23. package/dist/@types/index.d.ts +1 -4
  24. package/dist/@types/index.d.ts.map +1 -1
  25. package/dist/@types/internal/close-cycle.d.ts +38 -0
  26. package/dist/@types/internal/close-cycle.d.ts.map +1 -0
  27. package/dist/@types/internal/drain-cycle.d.ts +61 -0
  28. package/dist/@types/internal/drain-cycle.d.ts.map +1 -0
  29. package/dist/@types/internal/drain-ratio.d.ts +26 -0
  30. package/dist/@types/internal/drain-ratio.d.ts.map +1 -0
  31. package/dist/@types/internal/event-sourcing.d.ts +14 -0
  32. package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
  33. package/dist/@types/internal/index.d.ts +5 -1
  34. package/dist/@types/internal/index.d.ts.map +1 -1
  35. package/dist/@types/internal/lru-map.d.ts +50 -0
  36. package/dist/@types/internal/lru-map.d.ts.map +1 -0
  37. package/dist/@types/internal/merge.d.ts +13 -1
  38. package/dist/@types/internal/merge.d.ts.map +1 -1
  39. package/dist/@types/internal/tracing.d.ts.map +1 -1
  40. package/dist/@types/ports.d.ts +1 -1
  41. package/dist/@types/ports.d.ts.map +1 -1
  42. package/dist/@types/types/errors.d.ts.map +1 -1
  43. package/dist/@types/types/reaction.d.ts +7 -1
  44. package/dist/@types/types/reaction.d.ts.map +1 -1
  45. package/dist/@types/utils.d.ts +27 -296
  46. package/dist/@types/utils.d.ts.map +1 -1
  47. package/dist/{chunk-JBKZJXQZ.js → chunk-IDEYGKT4.js} +2 -2
  48. package/dist/{chunk-JBKZJXQZ.js.map → chunk-IDEYGKT4.js.map} +1 -1
  49. package/dist/index.cjs +628 -422
  50. package/dist/index.cjs.map +1 -1
  51. package/dist/index.js +627 -422
  52. package/dist/index.js.map +1 -1
  53. package/dist/types/index.cjs +1 -1
  54. package/dist/types/index.cjs.map +1 -1
  55. package/dist/types/index.js +1 -1
  56. package/package.json +1 -1
  57. package/dist/@types/act-builder.d.ts.map +0 -1
  58. package/dist/@types/adapters/ConsoleLogger.d.ts.map +0 -1
  59. package/dist/@types/adapters/InMemoryCache.d.ts.map +0 -1
  60. package/dist/@types/adapters/InMemoryStore.d.ts.map +0 -1
  61. package/dist/@types/projection-builder.d.ts.map +0 -1
  62. package/dist/@types/slice-builder.d.ts.map +0 -1
  63. package/dist/@types/state-builder.d.ts.map +0 -1
package/dist/index.cjs CHANGED
@@ -36,6 +36,7 @@ __export(index_exports, {
36
36
  CommittedMetaSchema: () => CommittedMetaSchema,
37
37
  ConcurrencyError: () => ConcurrencyError,
38
38
  ConsoleLogger: () => ConsoleLogger,
39
+ DEFAULT_MAX_SUBSCRIBED_STREAMS: () => DEFAULT_MAX_SUBSCRIBED_STREAMS,
39
40
  Environments: () => Environments,
40
41
  Errors: () => Errors,
41
42
  EventMetaSchema: () => EventMetaSchema,
@@ -69,7 +70,7 @@ __export(index_exports, {
69
70
  });
70
71
  module.exports = __toCommonJS(index_exports);
71
72
 
72
- // src/adapters/ConsoleLogger.ts
73
+ // src/adapters/console-logger.ts
73
74
  var LEVEL_VALUES = {
74
75
  fatal: 60,
75
76
  error: 50,
@@ -138,14 +139,25 @@ var ConsoleLogger = class _ConsoleLogger {
138
139
  obj = {};
139
140
  } else if (objOrMsg !== null && typeof objOrMsg === "object") {
140
141
  message = msg;
141
- obj = Object.fromEntries(Object.entries(objOrMsg));
142
+ obj = { ...objOrMsg };
142
143
  } else {
143
144
  message = msg;
144
145
  obj = { value: objOrMsg };
145
146
  }
146
147
  const entry = Object.assign({ level, time: Date.now() }, bindings, obj);
147
148
  if (message) entry.msg = message;
148
- process.stdout.write(JSON.stringify(entry) + "\n");
149
+ let line;
150
+ try {
151
+ line = JSON.stringify(entry);
152
+ } catch {
153
+ line = JSON.stringify({
154
+ level,
155
+ time: entry.time,
156
+ msg: message ?? "[unserializable]",
157
+ unserializable: true
158
+ });
159
+ }
160
+ process.stdout.write(line + "\n");
149
161
  }
150
162
  _prettyWrite(bindings, level, _num, objOrMsg, msg) {
151
163
  const color = LEVEL_COLORS[level];
@@ -171,26 +183,75 @@ var ConsoleLogger = class _ConsoleLogger {
171
183
  }
172
184
  };
173
185
 
174
- // src/adapters/InMemoryCache.ts
175
- var InMemoryCache = class {
186
+ // src/internal/lru-map.ts
187
+ var LruMap = class {
188
+ constructor(_maxSize) {
189
+ this._maxSize = _maxSize;
190
+ }
176
191
  _entries = /* @__PURE__ */ new Map();
177
- _maxSize;
192
+ get(key) {
193
+ const v = this._entries.get(key);
194
+ if (v === void 0) return void 0;
195
+ this._entries.delete(key);
196
+ this._entries.set(key, v);
197
+ return v;
198
+ }
199
+ has(key) {
200
+ return this._entries.has(key);
201
+ }
202
+ set(key, value) {
203
+ this._entries.delete(key);
204
+ if (this._entries.size >= this._maxSize) {
205
+ const oldest = this._entries.keys().next().value;
206
+ this._entries.delete(oldest);
207
+ }
208
+ this._entries.set(key, value);
209
+ }
210
+ delete(key) {
211
+ return this._entries.delete(key);
212
+ }
213
+ clear() {
214
+ this._entries.clear();
215
+ }
216
+ get size() {
217
+ return this._entries.size;
218
+ }
219
+ };
220
+ var LruSet = class {
221
+ _map;
222
+ constructor(maxSize) {
223
+ this._map = new LruMap(maxSize);
224
+ }
225
+ has(value) {
226
+ return this._map.has(value);
227
+ }
228
+ add(value) {
229
+ this._map.set(value, true);
230
+ }
231
+ delete(value) {
232
+ return this._map.delete(value);
233
+ }
234
+ clear() {
235
+ this._map.clear();
236
+ }
237
+ get size() {
238
+ return this._map.size;
239
+ }
240
+ };
241
+
242
+ // src/adapters/in-memory-cache.ts
243
+ var InMemoryCache = class {
244
+ // CacheEntry<any> lets `get<TState>` and `set<TState>` flow without casts:
245
+ // any is bidirectionally compatible with the per-call TState binding, while
246
+ // the public Cache interface still presents a typed surface to callers.
247
+ _entries;
178
248
  constructor(options) {
179
- this._maxSize = options?.maxSize ?? 1e3;
249
+ this._entries = new LruMap(options?.maxSize ?? 1e3);
180
250
  }
181
251
  async get(stream) {
182
- const entry = this._entries.get(stream);
183
- if (!entry) return void 0;
184
- this._entries.delete(stream);
185
- this._entries.set(stream, entry);
186
- return entry;
252
+ return this._entries.get(stream);
187
253
  }
188
254
  async set(stream, entry) {
189
- this._entries.delete(stream);
190
- if (this._entries.size >= this._maxSize) {
191
- const first = this._entries.keys().next().value;
192
- this._entries.delete(first);
193
- }
194
255
  this._entries.set(stream, entry);
195
256
  }
196
257
  async invalidate(stream) {
@@ -244,7 +305,7 @@ var StreamClosedError = class extends Error {
244
305
  var ConcurrencyError = class extends Error {
245
306
  constructor(stream, lastVersion, events, expectedVersion) {
246
307
  super(
247
- `Concurrency error committing "${events.map((e) => `${stream}.${e.name}.${JSON.stringify(e.data)}`).join(
308
+ `Concurrency error committing "${events.map((e) => `${stream}.${e.name}`).join(
248
309
  ", "
249
310
  )}". Expected version ${expectedVersion} but found version ${lastVersion}.`
250
311
  );
@@ -333,10 +394,21 @@ var PackageSchema = import_zod2.z.object({
333
394
  license: import_zod2.z.string().min(1).optional(),
334
395
  dependencies: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string()).optional()
335
396
  });
397
+ var FALLBACK_PACKAGE = {
398
+ name: "act-fallback",
399
+ version: "0.0.0-fallback",
400
+ description: "Synthetic fallback \u2014 package.json could not be loaded"
401
+ };
336
402
  var getPackage = () => {
337
- const pkg2 = fs.readFileSync("package.json");
338
- return JSON.parse(pkg2.toString());
403
+ try {
404
+ const raw = fs.readFileSync("package.json");
405
+ return JSON.parse(raw.toString());
406
+ } catch (err) {
407
+ pkgLoadError = err;
408
+ return FALLBACK_PACKAGE;
409
+ }
339
410
  };
411
+ var pkgLoadError;
340
412
  var BaseSchema = PackageSchema.extend({
341
413
  env: import_zod2.z.enum(Environments),
342
414
  logLevel: import_zod2.z.enum(LogLevels),
@@ -356,6 +428,13 @@ var config = () => {
356
428
  { ...pkg, env, logLevel, logSingleLine, sleepMs },
357
429
  BaseSchema
358
430
  );
431
+ if (pkgLoadError) {
432
+ const msg = pkgLoadError instanceof Error ? pkgLoadError.message : typeof pkgLoadError === "string" ? pkgLoadError : "unknown error";
433
+ log().warn(
434
+ `[act] Could not read package.json (${msg}); using synthetic name="${FALLBACK_PACKAGE.name}" version="${FALLBACK_PACKAGE.version}".`
435
+ );
436
+ pkgLoadError = void 0;
437
+ }
359
438
  }
360
439
  return _validated;
361
440
  };
@@ -379,7 +458,7 @@ async function sleep(ms) {
379
458
  return new Promise((resolve) => setTimeout(resolve, ms ?? config().sleepMs));
380
459
  }
381
460
 
382
- // src/adapters/InMemoryStore.ts
461
+ // src/adapters/in-memory-store.ts
383
462
  var InMemoryStream = class {
384
463
  constructor(stream, source) {
385
464
  this.stream = stream;
@@ -474,11 +553,13 @@ var InMemoryStream = class {
474
553
  }
475
554
  }
476
555
  /**
477
- * Reset this stream's watermark and state for replay.
556
+ * Reset this stream's watermark and state for replay. The retry counter
557
+ * resets to -1 to match the constructor + ack() invariant ("released
558
+ * stream"); the next claim() bumps it to 0 (first attempt).
478
559
  */
479
560
  reset() {
480
561
  this._at = -1;
481
- this._retry = 0;
562
+ this._retry = -1;
482
563
  this._blocked = false;
483
564
  this._error = "";
484
565
  this._leased_by = void 0;
@@ -490,13 +571,26 @@ var InMemoryStore = class {
490
571
  _events = [];
491
572
  // stored stream positions and other metadata
492
573
  _streams = /* @__PURE__ */ new Map();
574
+ // last committed version per stream — O(1) replacement for filter-on-commit
575
+ _streamVersions = /* @__PURE__ */ new Map();
576
+ // max non-snapshot event id per stream — drives the source-pattern probe in claim()
577
+ // without scanning the full event log.
578
+ _maxEventIdByStream = /* @__PURE__ */ new Map();
579
+ // global max non-snapshot event id — fast pre-check for source-less streams in claim()
580
+ _maxNonSnapEventId = -1;
581
+ _resetIndexes() {
582
+ this._events.length = 0;
583
+ this._streamVersions.clear();
584
+ this._maxEventIdByStream.clear();
585
+ this._maxNonSnapEventId = -1;
586
+ }
493
587
  /**
494
588
  * Dispose of the store and clear all events.
495
589
  * @returns Promise that resolves when disposal is complete.
496
590
  */
497
591
  async dispose() {
498
592
  await sleep();
499
- this._events.length = 0;
593
+ this._resetIndexes();
500
594
  }
501
595
  /**
502
596
  * Seed the store with initial data (no-op for in-memory).
@@ -511,7 +605,7 @@ var InMemoryStore = class {
511
605
  */
512
606
  async drop() {
513
607
  await sleep();
514
- this._events.length = 0;
608
+ this._resetIndexes();
515
609
  this._streams = /* @__PURE__ */ new Map();
516
610
  }
517
611
  in_query(query, e) {
@@ -574,18 +668,19 @@ var InMemoryStore = class {
574
668
  */
575
669
  async commit(stream, msgs, meta, expectedVersion) {
576
670
  await sleep();
577
- const instance = this._events.filter((e) => e.stream === stream);
578
- if (typeof expectedVersion === "number" && instance.length - 1 !== expectedVersion) {
671
+ const currentVersion = this._streamVersions.get(stream) ?? -1;
672
+ if (typeof expectedVersion === "number" && currentVersion !== expectedVersion) {
579
673
  throw new ConcurrencyError(
580
674
  stream,
581
- instance.length - 1,
675
+ currentVersion,
582
676
  msgs,
583
677
  expectedVersion
584
678
  );
585
679
  }
586
- let version = instance.length;
587
- return msgs.map(({ name, data }) => {
588
- const committed = {
680
+ let version = currentVersion + 1;
681
+ let lastNonSnapId = -1;
682
+ const committed = msgs.map(({ name, data }) => {
683
+ const c = {
589
684
  id: this._events.length,
590
685
  stream,
591
686
  version,
@@ -594,10 +689,17 @@ var InMemoryStore = class {
594
689
  data,
595
690
  meta
596
691
  };
597
- this._events.push(committed);
692
+ this._events.push(c);
693
+ if (name !== SNAP_EVENT) lastNonSnapId = c.id;
598
694
  version++;
599
- return committed;
695
+ return c;
600
696
  });
697
+ this._streamVersions.set(stream, version - 1);
698
+ if (lastNonSnapId >= 0) {
699
+ this._maxEventIdByStream.set(stream, lastNonSnapId);
700
+ this._maxNonSnapEventId = lastNonSnapId;
701
+ }
702
+ return committed;
601
703
  }
602
704
  /**
603
705
  * Atomically discovers and leases streams for processing.
@@ -610,10 +712,26 @@ var InMemoryStore = class {
610
712
  */
611
713
  async claim(lagging, leading, by, millis) {
612
714
  await sleep();
715
+ const sourceRegex = /* @__PURE__ */ new Map();
716
+ const getRegex = (source) => {
717
+ let re = sourceRegex.get(source);
718
+ if (!re) {
719
+ re = new RegExp(source);
720
+ sourceRegex.set(source, re);
721
+ }
722
+ return re;
723
+ };
724
+ const hasWork = (s) => {
725
+ if (s.at < 0) return true;
726
+ if (!s.source) return s.at < this._maxNonSnapEventId;
727
+ const re = getRegex(s.source);
728
+ for (const [streamName, maxId] of this._maxEventIdByStream) {
729
+ if (maxId > s.at && re.test(streamName)) return true;
730
+ }
731
+ return false;
732
+ };
613
733
  const available = [...this._streams.values()].filter(
614
- (s) => s.is_available && (s.at < 0 || this._events.some(
615
- (e) => e.id > s.at && e.name !== SNAP_EVENT && (!s.source || RegExp(s.source).test(e.stream))
616
- ))
734
+ (s) => s.is_available && hasWork(s)
617
735
  );
618
736
  const lag = available.sort((a, b) => a.at - b.at).slice(0, lagging).map((s) => ({
619
737
  stream: s.stream,
@@ -750,9 +868,13 @@ var InMemoryStore = class {
750
868
  }
751
869
  }
752
870
  this._events = this._events.filter((e) => !streamSet.has(e.stream));
871
+ for (const stream of streamSet) {
872
+ this._streams.delete(stream);
873
+ this._streamVersions.delete(stream);
874
+ this._maxEventIdByStream.delete(stream);
875
+ }
753
876
  const result = /* @__PURE__ */ new Map();
754
877
  for (const { stream, snapshot, meta } of targets) {
755
- this._streams.delete(stream);
756
878
  const event = {
757
879
  id: this._events.length,
758
880
  stream,
@@ -763,11 +885,18 @@ var InMemoryStore = class {
763
885
  meta: meta ?? { correlation: "", causation: {} }
764
886
  };
765
887
  this._events.push(event);
888
+ this._streamVersions.set(stream, 0);
889
+ if (event.name !== SNAP_EVENT) {
890
+ this._maxEventIdByStream.set(stream, event.id);
891
+ }
766
892
  result.set(stream, {
767
893
  deleted: deletedCounts.get(stream) ?? 0,
768
894
  committed: event
769
895
  });
770
896
  }
897
+ let max = -1;
898
+ for (const id of this._maxEventIdByStream.values()) if (id > max) max = id;
899
+ this._maxNonSnapEventId = max;
771
900
  return result;
772
901
  }
773
902
  };
@@ -800,7 +929,12 @@ var cache = port(function cache2(adapter) {
800
929
  });
801
930
  var disposers = [];
802
931
  async function disposeAndExit(code = "EXIT") {
803
- if (code === "ERROR" && config().env === "production") return;
932
+ if (code === "ERROR" && config().env === "production") {
933
+ log().warn(
934
+ "disposeAndExit('ERROR') ignored in production \u2014 process kept alive"
935
+ );
936
+ return;
937
+ }
804
938
  for (const disposer of [...disposers].reverse()) {
805
939
  await disposer();
806
940
  }
@@ -837,9 +971,225 @@ process.once("unhandledRejection", async (arg) => {
837
971
  });
838
972
 
839
973
  // src/act.ts
840
- var import_crypto2 = require("crypto");
841
974
  var import_events = __toESM(require("events"), 1);
842
975
 
976
+ // src/internal/close-cycle.ts
977
+ var import_crypto = require("crypto");
978
+ async function runCloseCycle(targets, deps) {
979
+ const targetMap = new Map(targets.map((t) => [t.stream, t]));
980
+ const streams = [...targetMap.keys()];
981
+ const skipped = [];
982
+ const streamInfo = await scanStreamHeads(streams);
983
+ const safe = await partitionBySafety(
984
+ streamInfo,
985
+ deps.reactiveEventsSize,
986
+ skipped
987
+ );
988
+ if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
989
+ const correlation = (0, import_crypto.randomUUID)();
990
+ const { guarded, guardEvents } = await guardWithTombstones(
991
+ safe,
992
+ streamInfo,
993
+ correlation,
994
+ deps.tombstone,
995
+ skipped
996
+ );
997
+ if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
998
+ const seedStates = await loadRestartSeeds(
999
+ guarded,
1000
+ targetMap,
1001
+ streamInfo,
1002
+ deps.eventToState,
1003
+ deps.load,
1004
+ deps.logger
1005
+ );
1006
+ await runArchiveCallbacks(guarded, targetMap);
1007
+ const truncated = await truncateAndWarmCache(
1008
+ guarded,
1009
+ seedStates,
1010
+ guardEvents,
1011
+ correlation
1012
+ );
1013
+ return { truncated, skipped };
1014
+ }
1015
+ async function scanStreamHeads(streams) {
1016
+ const out = /* @__PURE__ */ new Map();
1017
+ await Promise.all(
1018
+ streams.map(async (s) => {
1019
+ let maxId = -1;
1020
+ let version = -1;
1021
+ let lastEventName = "";
1022
+ await store().query(
1023
+ (e) => {
1024
+ if (e.name === TOMBSTONE_EVENT || maxId !== -1) return;
1025
+ maxId = e.id;
1026
+ version = e.version;
1027
+ lastEventName = e.name;
1028
+ },
1029
+ { stream: s, stream_exact: true, backward: true, limit: 1 }
1030
+ );
1031
+ if (maxId >= 0) out.set(s, { maxId, version, lastEventName });
1032
+ })
1033
+ );
1034
+ return out;
1035
+ }
1036
+ async function partitionBySafety(streamInfo, reactiveEventsSize, skipped) {
1037
+ if (reactiveEventsSize === 0) return [...streamInfo.keys()];
1038
+ const pendingSet = /* @__PURE__ */ new Set();
1039
+ await store().query_streams((position) => {
1040
+ const sourceRe = position.source ? RegExp(position.source) : void 0;
1041
+ for (const [stream, info] of streamInfo) {
1042
+ if ((!sourceRe || sourceRe.test(stream)) && position.at < info.maxId) {
1043
+ pendingSet.add(stream);
1044
+ }
1045
+ }
1046
+ });
1047
+ const safe = [];
1048
+ for (const [stream] of streamInfo) {
1049
+ if (pendingSet.has(stream)) skipped.push(stream);
1050
+ else safe.push(stream);
1051
+ }
1052
+ return safe;
1053
+ }
1054
+ async function guardWithTombstones(safe, streamInfo, correlation, tombstone2, skipped) {
1055
+ const guarded = [];
1056
+ const guardEvents = /* @__PURE__ */ new Map();
1057
+ await Promise.all(
1058
+ safe.map(async (stream) => {
1059
+ const info = streamInfo.get(stream);
1060
+ const committed = await tombstone2(stream, info.version, correlation);
1061
+ if (committed) {
1062
+ guarded.push(stream);
1063
+ guardEvents.set(stream, { id: committed.id, stream });
1064
+ } else {
1065
+ skipped.push(stream);
1066
+ }
1067
+ })
1068
+ );
1069
+ return { guarded, guardEvents };
1070
+ }
1071
+ async function loadRestartSeeds(guarded, targetMap, streamInfo, eventToState, load2, logger) {
1072
+ const seedStates = /* @__PURE__ */ new Map();
1073
+ await Promise.all(
1074
+ guarded.filter((s) => targetMap.get(s)?.restart).map(async (stream) => {
1075
+ const lastEventName = streamInfo.get(stream).lastEventName;
1076
+ const ownerState = eventToState.get(lastEventName);
1077
+ if (!ownerState) {
1078
+ logger.error(
1079
+ `Cannot seed restart for "${stream}": no registered state owns event "${lastEventName}". Stream will be tombstoned instead.`
1080
+ );
1081
+ return;
1082
+ }
1083
+ const snap2 = await load2(ownerState, stream);
1084
+ seedStates.set(stream, snap2.state);
1085
+ })
1086
+ );
1087
+ return seedStates;
1088
+ }
1089
+ async function runArchiveCallbacks(guarded, targetMap) {
1090
+ for (const stream of guarded) {
1091
+ const archiveFn = targetMap.get(stream)?.archive;
1092
+ if (archiveFn) await archiveFn();
1093
+ }
1094
+ }
1095
+ async function truncateAndWarmCache(guarded, seedStates, guardEvents, correlation) {
1096
+ const truncTargets = guarded.map((stream) => {
1097
+ const snapshot = seedStates.get(stream);
1098
+ const guard = guardEvents.get(stream);
1099
+ return {
1100
+ stream,
1101
+ snapshot,
1102
+ meta: {
1103
+ correlation,
1104
+ causation: {
1105
+ event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
1106
+ }
1107
+ }
1108
+ };
1109
+ });
1110
+ const truncated = await store().truncate(truncTargets);
1111
+ await Promise.all(
1112
+ guarded.map(async (stream) => {
1113
+ const entry = truncated.get(stream);
1114
+ const state2 = seedStates.get(stream);
1115
+ if (state2 && entry) {
1116
+ await cache().set(stream, {
1117
+ state: state2,
1118
+ version: entry.committed.version,
1119
+ event_id: entry.committed.id,
1120
+ patches: 0,
1121
+ snaps: 1
1122
+ });
1123
+ } else {
1124
+ await cache().invalidate(stream);
1125
+ }
1126
+ })
1127
+ );
1128
+ return truncated;
1129
+ }
1130
+
1131
+ // src/internal/drain-cycle.ts
1132
+ var import_crypto2 = require("crypto");
1133
+ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis) {
1134
+ const leased = await ops.claim(lagging, leading, (0, import_crypto2.randomUUID)(), leaseMillis);
1135
+ if (!leased.length) return void 0;
1136
+ const fetched = await ops.fetch(leased, eventLimit);
1137
+ const fetchMap = /* @__PURE__ */ new Map();
1138
+ const fetch_window_at = fetched.reduce(
1139
+ (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
1140
+ 0
1141
+ );
1142
+ for (const f of fetched) {
1143
+ const { stream, events } = f;
1144
+ const payloads = events.flatMap((event) => {
1145
+ const register = registry.events[event.name];
1146
+ if (!register) return [];
1147
+ return [...register.reactions.values()].filter((reaction) => {
1148
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1149
+ return resolved && resolved.target === stream;
1150
+ }).map((reaction) => ({ ...reaction, event }));
1151
+ });
1152
+ fetchMap.set(stream, { fetch: f, payloads });
1153
+ }
1154
+ const handled = await Promise.all(
1155
+ leased.map((lease) => {
1156
+ const entry = fetchMap.get(lease.stream);
1157
+ const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
1158
+ const { payloads } = entry;
1159
+ const batchHandler = batchHandlers.get(lease.stream);
1160
+ if (batchHandler && payloads.length > 0) {
1161
+ return handleBatch({ ...lease, at }, payloads, batchHandler);
1162
+ }
1163
+ return handle({ ...lease, at }, payloads);
1164
+ })
1165
+ );
1166
+ const acked = await ops.ack(
1167
+ handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
1168
+ );
1169
+ const blocked = await ops.block(
1170
+ handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1171
+ );
1172
+ return { leased, fetched, handled, acked, blocked };
1173
+ }
1174
+
1175
+ // src/internal/drain-ratio.ts
1176
+ var RATIO_MIN = 0.2;
1177
+ var RATIO_MAX = 0.8;
1178
+ var RATIO_DEFAULT = 0.5;
1179
+ function computeLagLeadRatio(handled, lagging, leading) {
1180
+ let lagging_handled = 0;
1181
+ let leading_handled = 0;
1182
+ for (const { lease, handled: count } of handled) {
1183
+ if (lease.lagging) lagging_handled += count;
1184
+ else leading_handled += count;
1185
+ }
1186
+ const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1187
+ const leading_avg = leading > 0 ? leading_handled / leading : 0;
1188
+ const total = lagging_avg + leading_avg;
1189
+ if (total === 0) return RATIO_DEFAULT;
1190
+ return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1191
+ }
1192
+
843
1193
  // src/internal/merge.ts
844
1194
  var import_zod4 = require("zod");
845
1195
  function baseTypeName(zodType) {
@@ -947,6 +1297,15 @@ function mergePatches(existing, incoming, stateName) {
947
1297
  }
948
1298
  return merged;
949
1299
  }
1300
+ function mergeEventRegister(target, source) {
1301
+ for (const [eventName, sourceReg] of Object.entries(source)) {
1302
+ const targetReg = target[eventName];
1303
+ if (!targetReg) continue;
1304
+ for (const [name, reaction] of sourceReg.reactions) {
1305
+ targetReg.reactions.set(name, reaction);
1306
+ }
1307
+ }
1308
+ }
950
1309
  function mergeProjection(proj, events) {
951
1310
  for (const eventName of Object.keys(proj.events)) {
952
1311
  const projRegister = proj.events[eventName];
@@ -991,7 +1350,7 @@ var subscribe = (streams) => store().subscribe(streams);
991
1350
 
992
1351
  // src/internal/event-sourcing.ts
993
1352
  var import_act_patch = require("@rotorsoft/act-patch");
994
- var import_crypto = require("crypto");
1353
+ var import_crypto3 = require("crypto");
995
1354
  async function snap(snapshot) {
996
1355
  try {
997
1356
  const { id, stream, name, meta, version } = snapshot.event;
@@ -1009,6 +1368,20 @@ async function snap(snapshot) {
1009
1368
  log().error(error);
1010
1369
  }
1011
1370
  }
1371
+ async function tombstone(stream, expectedVersion, correlation) {
1372
+ try {
1373
+ const [committed] = await store().commit(
1374
+ stream,
1375
+ [{ name: TOMBSTONE_EVENT, data: {} }],
1376
+ { correlation, causation: {} },
1377
+ expectedVersion
1378
+ );
1379
+ return committed;
1380
+ } catch (error) {
1381
+ if (error instanceof ConcurrencyError) return void 0;
1382
+ throw error;
1383
+ }
1384
+ }
1012
1385
  async function load(me, stream, callback, asOf) {
1013
1386
  const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
1014
1387
  const cached = timeTravel ? void 0 : await cache().get(stream);
@@ -1073,13 +1446,13 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
1073
1446
  data: skipValidation ? data : validate(name, data, me.events[name])
1074
1447
  }));
1075
1448
  const meta = {
1076
- correlation: reactingTo?.meta.correlation || (0, import_crypto.randomUUID)(),
1449
+ correlation: reactingTo?.meta.correlation || (0, import_crypto3.randomUUID)(),
1077
1450
  causation: {
1078
1451
  action: {
1079
1452
  name: action2,
1080
1453
  ...target
1081
- // payload: TODO: flag to include action payload in metadata
1082
- // not included by default to avoid large payloads
1454
+ // payload intentionally omitted: it can be large or contain PII,
1455
+ // and callers correlate via the correlation id when they need it.
1083
1456
  },
1084
1457
  event: reactingTo ? {
1085
1458
  id: reactingTo.id,
@@ -1094,7 +1467,10 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
1094
1467
  stream,
1095
1468
  emitted,
1096
1469
  meta,
1097
- // TODO: review reactions not enforcing expected version
1470
+ // Reactions skip optimistic concurrency: they always append against the
1471
+ // current head. Stream leasing already serializes concurrent reactions,
1472
+ // and forcing version checks here would turn ordinary catch-up into
1473
+ // spurious retries.
1098
1474
  reactingTo ? void 0 : expected
1099
1475
  );
1100
1476
  } catch (error) {
@@ -1144,7 +1520,12 @@ var traced = (inner, exit, entry) => (async (...args) => {
1144
1520
  });
1145
1521
  function buildEs(logger) {
1146
1522
  if (logger.level !== "trace") {
1147
- return { snap, load, action };
1523
+ return {
1524
+ snap,
1525
+ load,
1526
+ action,
1527
+ tombstone
1528
+ };
1148
1529
  }
1149
1530
  return {
1150
1531
  snap: traced(snap, void 0, (snapshot) => {
@@ -1182,7 +1563,13 @@ function buildEs(logger) {
1182
1563
  es_caption("action", C_BLUE, `${target.stream}.${action2}`)
1183
1564
  );
1184
1565
  }
1185
- )
1566
+ ),
1567
+ tombstone: traced(tombstone, (committed, stream) => {
1568
+ if (committed)
1569
+ logger.trace(
1570
+ es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
1571
+ );
1572
+ })
1186
1573
  };
1187
1574
  }
1188
1575
  function buildDrain(logger) {
@@ -1245,11 +1632,15 @@ function buildDrain(logger) {
1245
1632
  }
1246
1633
 
1247
1634
  // src/act.ts
1635
+ var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
1248
1636
  var Act = class {
1249
- constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map()) {
1637
+ constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
1250
1638
  this.registry = registry;
1251
1639
  this._states = _states;
1252
1640
  this._batch_handlers = batchHandlers;
1641
+ this._subscribed_streams = new LruSet(
1642
+ options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS
1643
+ );
1253
1644
  this._es = buildEs(this._logger);
1254
1645
  this._cd = buildDrain(this._logger);
1255
1646
  const statics = /* @__PURE__ */ new Map();
@@ -1287,20 +1678,42 @@ var Act = class {
1287
1678
  _settle_timer = void 0;
1288
1679
  _settling = false;
1289
1680
  _correlation_checkpoint = -1;
1290
- _subscribed_statics = /* @__PURE__ */ new Set();
1681
+ /**
1682
+ * Streams already subscribed via store.subscribe() — both the static
1683
+ * targets registered at init and dynamic targets discovered by
1684
+ * correlate(). correlate() consults this set to avoid re-subscribing
1685
+ * known streams.
1686
+ *
1687
+ * Bounded LRU so apps that mint millions of dynamic targets (one per
1688
+ * aggregate) don't grow this unbounded. Eviction costs at most one
1689
+ * redundant store.subscribe() call per evicted-but-still-active stream
1690
+ * (subscribe is idempotent). Cap configurable via {@link ActOptions}.
1691
+ */
1692
+ _subscribed_streams;
1291
1693
  _has_dynamic_resolvers = false;
1292
1694
  _correlation_initialized = false;
1293
1695
  /** Event names with at least one registered reaction (computed at build time) */
1294
1696
  _reactive_events = /* @__PURE__ */ new Set();
1295
1697
  /** Set in do() when a committed event has reactions — cleared by drain() */
1296
1698
  _needs_drain = false;
1699
+ /**
1700
+ * Emit a lifecycle event. The payload type is inferred from the event name
1701
+ * via {@link ActLifecycleEvents}.
1702
+ */
1297
1703
  emit(event, args) {
1298
1704
  return this._emitter.emit(event, args);
1299
1705
  }
1706
+ /**
1707
+ * Register a listener for a lifecycle event. The listener receives the
1708
+ * event-specific payload.
1709
+ */
1300
1710
  on(event, listener) {
1301
1711
  this._emitter.on(event, listener);
1302
1712
  return this;
1303
1713
  }
1714
+ /**
1715
+ * Remove a previously registered lifecycle listener.
1716
+ */
1304
1717
  off(event, listener) {
1305
1718
  this._emitter.off(event, listener);
1306
1719
  return this;
@@ -1334,6 +1747,9 @@ var Act = class {
1334
1747
  _bound_load = this.load.bind(this);
1335
1748
  _bound_query = this.query.bind(this);
1336
1749
  _bound_query_array = this.query_array.bind(this);
1750
+ /** Pre-bound dispatchers handed to runDrainCycle each cycle. */
1751
+ _bound_handle = this.handle.bind(this);
1752
+ _bound_handle_batch = this.handleBatch.bind(this);
1337
1753
  /**
1338
1754
  * Executes an action on a state instance, committing resulting events.
1339
1755
  *
@@ -1536,26 +1952,46 @@ var Act = class {
1536
1952
  return events;
1537
1953
  }
1538
1954
  /**
1539
- * Handles leased reactions.
1540
- *
1541
- * This is called by the main `drain` loop after fetching new events.
1542
- * It handles reactions, supporting retries, blocking, and error handling.
1955
+ * Shared finalization for the two reaction-runner shapes (per-event
1956
+ * `handle` and bulk `handleBatch`). Centralizes the error log, retry-vs-
1957
+ * block decision, and the "error reported only when nothing was handled"
1958
+ * rule that's true in both shapes (in batch mode, `handled` is always 0
1959
+ * on failure, so the rule degenerates to "always reported").
1960
+ */
1961
+ _finalize(lease, handled, at, error, options) {
1962
+ if (!error) return { lease, handled, at };
1963
+ this._logger.error(error);
1964
+ const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1965
+ if (block2)
1966
+ this._logger.error(
1967
+ `Blocking ${lease.stream} after ${lease.retry} retries.`
1968
+ );
1969
+ return {
1970
+ lease,
1971
+ handled,
1972
+ at,
1973
+ error: handled === 0 ? error.message : void 0,
1974
+ block: block2
1975
+ };
1976
+ }
1977
+ /**
1978
+ * Handles leased reactions one event at a time.
1543
1979
  *
1544
- * Each handler receives a scoped `IAct` proxy that auto-injects the
1545
- * triggering event as `reactingTo` when `do()` is called without it,
1546
- * maintaining correlation chains by default (#587). Handlers can still
1547
- * pass an explicit `reactingTo` to override this behavior.
1980
+ * Called by the main `drain` loop after fetching new events. Each handler
1981
+ * receives a scoped `IAct` proxy that auto-injects the triggering event
1982
+ * as `reactingTo` when `do()` is called without it, maintaining
1983
+ * correlation chains by default (#587). Handlers can still pass an
1984
+ * explicit `reactingTo` to override.
1548
1985
  *
1549
1986
  * @internal
1550
- * @param lease The lease to handle
1551
- * @param payloads The reactions to handle
1552
- * @returns The lease with results
1553
1987
  */
1554
1988
  async handle(lease, payloads) {
1555
1989
  if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
1556
1990
  const stream = lease.stream;
1557
- let at = payloads.at(0).event.id, handled = 0;
1558
- lease.retry > 0 && this._logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1991
+ let at = payloads.at(0).event.id;
1992
+ let handled = 0;
1993
+ if (lease.retry > 0)
1994
+ this._logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1559
1995
  const doAction = this._bound_do;
1560
1996
  const scopedApp = {
1561
1997
  do: doAction,
@@ -1564,7 +2000,7 @@ var Act = class {
1564
2000
  query_array: this._bound_query_array
1565
2001
  };
1566
2002
  for (const payload of payloads) {
1567
- const { event, handler, options } = payload;
2003
+ const { event, handler } = payload;
1568
2004
  scopedApp.do = (action2, target, payload2, reactingTo, skipValidation) => doAction(
1569
2005
  action2,
1570
2006
  target,
@@ -1577,22 +2013,16 @@ var Act = class {
1577
2013
  at = event.id;
1578
2014
  handled++;
1579
2015
  } catch (error) {
1580
- this._logger.error(error);
1581
- const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1582
- block2 && this._logger.error(
1583
- `Blocking ${stream} after ${lease.retry} retries.`
1584
- );
1585
- return {
2016
+ return this._finalize(
1586
2017
  lease,
1587
2018
  handled,
1588
2019
  at,
1589
- // only report error when nothing was handled
1590
- error: handled === 0 ? error.message : void 0,
1591
- block: block2
1592
- };
2020
+ error,
2021
+ payload.options
2022
+ );
1593
2023
  }
1594
2024
  }
1595
- return { lease, handled, at };
2025
+ return this._finalize(lease, handled, at, void 0, payloads[0].options);
1596
2026
  }
1597
2027
  /**
1598
2028
  * Handles a batch of events for a projection with a batch handler.
@@ -1602,33 +2032,26 @@ var Act = class {
1602
2032
  * in a single call, enabling bulk DB operations.
1603
2033
  *
1604
2034
  * @internal
1605
- * @param lease The lease to handle
1606
- * @param payloads The reactions to handle
1607
- * @param batchHandler The batch handler for this projection
1608
- * @returns The lease with results
1609
2035
  */
1610
2036
  async handleBatch(lease, payloads, batchHandler) {
1611
2037
  const stream = lease.stream;
1612
2038
  const events = payloads.map((p) => p.event);
1613
- const at = events.at(-1).id;
1614
- lease.retry > 0 && this._logger.warn(
1615
- `Retrying batch ${stream}@${events[0].id} (${lease.retry}).`
1616
- );
2039
+ const options = payloads[0].options;
2040
+ if (lease.retry > 0)
2041
+ this._logger.warn(
2042
+ `Retrying batch ${stream}@${events[0].id} (${lease.retry}).`
2043
+ );
1617
2044
  try {
1618
2045
  await batchHandler(events, stream);
1619
- return { lease, handled: events.length, at };
1620
- } catch (error) {
1621
- this._logger.error(error);
1622
- const { options } = payloads[0];
1623
- const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1624
- block2 && this._logger.error(`Blocking ${stream} after ${lease.retry} retries.`);
1625
- return {
2046
+ return this._finalize(
1626
2047
  lease,
1627
- handled: 0,
1628
- at: lease.at,
1629
- error: error.message,
1630
- block: block2
1631
- };
2048
+ events.length,
2049
+ events.at(-1).id,
2050
+ void 0,
2051
+ options
2052
+ );
2053
+ } catch (error) {
2054
+ return this._finalize(lease, 0, lease.at, error, options);
1632
2055
  }
1633
2056
  }
1634
2057
  /**
@@ -1678,82 +2101,46 @@ var Act = class {
1678
2101
  if (!this._needs_drain) {
1679
2102
  return { fetched: [], leased: [], acked: [], blocked: [] };
1680
2103
  }
1681
- if (!this._drain_locked) {
1682
- try {
1683
- this._drain_locked = true;
1684
- const lagging = Math.ceil(streamLimit * this._drain_lag2lead_ratio);
1685
- const leading = streamLimit - lagging;
1686
- const leased = await this._cd.claim(
1687
- lagging,
1688
- leading,
1689
- (0, import_crypto2.randomUUID)(),
1690
- leaseMillis
1691
- );
1692
- if (!leased.length) {
1693
- this._needs_drain = false;
1694
- return { fetched: [], leased: [], acked: [], blocked: [] };
1695
- }
1696
- const fetched = await this._cd.fetch(leased, eventLimit);
1697
- const fetchMap = /* @__PURE__ */ new Map();
1698
- const fetch_window_at = fetched.reduce(
1699
- (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
1700
- 0
1701
- );
1702
- for (const f of fetched) {
1703
- const { stream, events } = f;
1704
- const payloads = events.flatMap((event) => {
1705
- const register = this.registry.events[event.name];
1706
- if (!register) return [];
1707
- return [...register.reactions.values()].filter((reaction) => {
1708
- const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1709
- return resolved && resolved.target === stream;
1710
- }).map((reaction) => ({ ...reaction, event }));
1711
- });
1712
- fetchMap.set(stream, { fetch: f, payloads });
1713
- }
1714
- const handled = await Promise.all(
1715
- leased.map((lease) => {
1716
- const entry = fetchMap.get(lease.stream);
1717
- const at = entry?.fetch.events.at(-1)?.id || fetch_window_at;
1718
- const payloads = entry?.payloads ?? [];
1719
- const batchHandler = this._batch_handlers.get(lease.stream);
1720
- if (batchHandler && payloads.length > 0) {
1721
- return this.handleBatch({ ...lease, at }, payloads, batchHandler);
1722
- }
1723
- return this.handle({ ...lease, at }, payloads);
1724
- })
1725
- );
1726
- const [lagging_handled, leading_handled] = handled.reduce(
1727
- ([lagging_handled2, leading_handled2], { lease, handled: handled2 }) => [
1728
- lagging_handled2 + (lease.lagging ? handled2 : 0),
1729
- leading_handled2 + (lease.lagging ? 0 : handled2)
1730
- ],
1731
- [0, 0]
1732
- );
1733
- const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1734
- const leading_avg = leading > 0 ? leading_handled / leading : 0;
1735
- const total = lagging_avg + leading_avg;
1736
- this._drain_lag2lead_ratio = total > 0 ? Math.max(0.2, Math.min(0.8, lagging_avg / total)) : 0.5;
1737
- const acked = await this._cd.ack(
1738
- handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
1739
- );
1740
- if (acked.length) this.emit("acked", acked);
1741
- const blocked = await this._cd.block(
1742
- handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1743
- );
1744
- if (blocked.length) this.emit("blocked", blocked);
1745
- const result = { fetched, leased, acked, blocked };
1746
- const hasErrors = handled.some(({ error }) => error);
1747
- if (!acked.length && !blocked.length && !hasErrors)
1748
- this._needs_drain = false;
1749
- return result;
1750
- } catch (error) {
1751
- this._logger.error(error);
1752
- } finally {
1753
- this._drain_locked = false;
2104
+ if (this._drain_locked) {
2105
+ return { fetched: [], leased: [], acked: [], blocked: [] };
2106
+ }
2107
+ try {
2108
+ this._drain_locked = true;
2109
+ const lagging = Math.ceil(streamLimit * this._drain_lag2lead_ratio);
2110
+ const leading = streamLimit - lagging;
2111
+ const cycle = await runDrainCycle(
2112
+ this._cd,
2113
+ this.registry,
2114
+ this._batch_handlers,
2115
+ this._bound_handle,
2116
+ this._bound_handle_batch,
2117
+ lagging,
2118
+ leading,
2119
+ eventLimit,
2120
+ leaseMillis
2121
+ );
2122
+ if (!cycle) {
2123
+ this._needs_drain = false;
2124
+ return { fetched: [], leased: [], acked: [], blocked: [] };
1754
2125
  }
2126
+ const { leased, fetched, handled, acked, blocked } = cycle;
2127
+ this._drain_lag2lead_ratio = computeLagLeadRatio(
2128
+ handled,
2129
+ lagging,
2130
+ leading
2131
+ );
2132
+ if (acked.length) this.emit("acked", acked);
2133
+ if (blocked.length) this.emit("blocked", blocked);
2134
+ const hasErrors = handled.some(({ error }) => error);
2135
+ if (!acked.length && !blocked.length && !hasErrors)
2136
+ this._needs_drain = false;
2137
+ return { fetched, leased, acked, blocked };
2138
+ } catch (error) {
2139
+ this._logger.error(error);
2140
+ return { fetched: [], leased: [], acked: [], blocked: [] };
2141
+ } finally {
2142
+ this._drain_locked = false;
1755
2143
  }
1756
- return { fetched: [], leased: [], acked: [], blocked: [] };
1757
2144
  }
1758
2145
  /**
1759
2146
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -1814,7 +2201,7 @@ var Act = class {
1814
2201
  this._correlation_checkpoint = watermark;
1815
2202
  if (this._reactive_events.size > 0) this._needs_drain = true;
1816
2203
  for (const { stream } of this._static_targets) {
1817
- this._subscribed_statics.add(stream);
2204
+ this._subscribed_streams.add(stream);
1818
2205
  }
1819
2206
  }
1820
2207
  async correlate(query = { after: -1, limit: 10 }) {
@@ -1832,7 +2219,7 @@ var Act = class {
1832
2219
  for (const reaction of register.reactions.values()) {
1833
2220
  if (typeof reaction.resolver !== "function") continue;
1834
2221
  const resolved = reaction.resolver(event);
1835
- if (resolved && !this._subscribed_statics.has(resolved.target)) {
2222
+ if (resolved && !this._subscribed_streams.has(resolved.target)) {
1836
2223
  const entry = correlated.get(resolved.target) || {
1837
2224
  source: resolved.source,
1838
2225
  payloads: []
@@ -1858,7 +2245,7 @@ var Act = class {
1858
2245
  this._correlation_checkpoint = last_id;
1859
2246
  if (subscribed) {
1860
2247
  for (const { stream } of streams) {
1861
- this._subscribed_statics.add(stream);
2248
+ this._subscribed_streams.add(stream);
1862
2249
  }
1863
2250
  }
1864
2251
  return { subscribed, last_id };
@@ -2041,143 +2428,14 @@ var Act = class {
2041
2428
  */
2042
2429
  async close(targets) {
2043
2430
  if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
2044
- const targetMap = new Map(targets.map((t) => [t.stream, t]));
2045
- const streams = [...targetMap.keys()];
2046
2431
  await this.correlate({ limit: 1e3 });
2047
- const streamInfo = /* @__PURE__ */ new Map();
2048
- await Promise.all(
2049
- streams.map(async (s) => {
2050
- let maxId = -1;
2051
- let version = -1;
2052
- let lastEventName;
2053
- await store().query(
2054
- (e) => {
2055
- if (e.name === TOMBSTONE_EVENT) return;
2056
- if (maxId === -1) {
2057
- maxId = e.id;
2058
- version = e.version;
2059
- }
2060
- if (e.name !== SNAP_EVENT && lastEventName === void 0) {
2061
- lastEventName = e.name;
2062
- }
2063
- },
2064
- // limit: 2 covers the typical snapshot-at-head case (snapshot is
2065
- // always preceded by the domain event it captured). Streams with
2066
- // unusual layouts fall back to no-seed via the lookup miss path.
2067
- { stream: s, stream_exact: true, backward: true, limit: 2 }
2068
- );
2069
- if (maxId >= 0) streamInfo.set(s, { maxId, version, lastEventName });
2070
- })
2071
- );
2072
- const skipped = [];
2073
- let safe;
2074
- if (this._reactive_events.size === 0) {
2075
- safe = [...streamInfo.keys()];
2076
- } else {
2077
- const pendingSet = /* @__PURE__ */ new Set();
2078
- await store().query_streams((position) => {
2079
- const sourceRe = position.source ? RegExp(position.source) : void 0;
2080
- for (const [stream, info] of streamInfo) {
2081
- if ((!sourceRe || sourceRe.test(stream)) && position.at < info.maxId) {
2082
- pendingSet.add(stream);
2083
- }
2084
- }
2085
- });
2086
- safe = [];
2087
- for (const [stream] of streamInfo) {
2088
- if (pendingSet.has(stream)) {
2089
- skipped.push(stream);
2090
- } else {
2091
- safe.push(stream);
2092
- }
2093
- }
2094
- }
2095
- if (!safe.length) {
2096
- const result2 = { truncated: /* @__PURE__ */ new Map(), skipped };
2097
- this.emit("closed", result2);
2098
- return result2;
2099
- }
2100
- const correlation = (0, import_crypto2.randomUUID)();
2101
- const guarded = [];
2102
- const guardEvents = /* @__PURE__ */ new Map();
2103
- await Promise.all(
2104
- safe.map(async (stream) => {
2105
- try {
2106
- const info = streamInfo.get(stream);
2107
- const [committed] = await store().commit(
2108
- stream,
2109
- [{ name: TOMBSTONE_EVENT, data: {} }],
2110
- { correlation, causation: {} },
2111
- info.version
2112
- );
2113
- guarded.push(stream);
2114
- guardEvents.set(stream, { id: committed.id, stream });
2115
- } catch {
2116
- skipped.push(stream);
2117
- }
2118
- })
2119
- );
2120
- if (!guarded.length) {
2121
- const result2 = { truncated: /* @__PURE__ */ new Map(), skipped };
2122
- this.emit("closed", result2);
2123
- return result2;
2124
- }
2125
- const seedStates = /* @__PURE__ */ new Map();
2126
- await Promise.all(
2127
- guarded.filter((s) => targetMap.get(s)?.restart).map(async (stream) => {
2128
- const lastEventName = streamInfo.get(stream)?.lastEventName;
2129
- const ownerState = lastEventName ? this._event_to_state.get(lastEventName) : void 0;
2130
- if (!ownerState) {
2131
- this._logger.error(
2132
- `Cannot seed restart for "${stream}": no registered state owns event "${lastEventName ?? "<none>"}". Stream will be tombstoned instead.`
2133
- );
2134
- return;
2135
- }
2136
- const snap2 = await this._es.load(ownerState, stream);
2137
- seedStates.set(stream, snap2.state);
2138
- })
2139
- );
2140
- for (const stream of guarded) {
2141
- const archiveFn = targetMap.get(stream)?.archive;
2142
- if (archiveFn) await archiveFn();
2143
- }
2144
- const truncTargets = guarded.map((stream) => {
2145
- const snapshot = seedStates.get(stream);
2146
- const guard = guardEvents.get(stream);
2147
- return {
2148
- stream,
2149
- snapshot,
2150
- meta: {
2151
- correlation,
2152
- causation: {
2153
- event: {
2154
- id: guard.id,
2155
- name: TOMBSTONE_EVENT,
2156
- stream: guard.stream
2157
- }
2158
- }
2159
- }
2160
- };
2432
+ const result = await runCloseCycle(targets, {
2433
+ reactiveEventsSize: this._reactive_events.size,
2434
+ eventToState: this._event_to_state,
2435
+ load: this._es.load,
2436
+ tombstone: this._es.tombstone,
2437
+ logger: this._logger
2161
2438
  });
2162
- const truncated = await store().truncate(truncTargets);
2163
- await Promise.all(
2164
- guarded.map(async (stream) => {
2165
- const entry = truncated.get(stream);
2166
- const state2 = seedStates.get(stream);
2167
- if (state2 && entry) {
2168
- await cache().set(stream, {
2169
- state: state2,
2170
- version: entry.committed.version,
2171
- event_id: entry.committed.id,
2172
- patches: 0,
2173
- snaps: 1
2174
- });
2175
- } else {
2176
- await cache().invalidate(stream);
2177
- }
2178
- })
2179
- );
2180
- const result = { truncated, skipped };
2181
2439
  this.emit("closed", result);
2182
2440
  return result;
2183
2441
  }
@@ -2246,7 +2504,7 @@ var Act = class {
2246
2504
  }
2247
2505
  };
2248
2506
 
2249
- // src/act-builder.ts
2507
+ // src/builders/act-builder.ts
2250
2508
  function registerBatchHandler(proj, batchHandlers) {
2251
2509
  if (!proj.batchHandler || !proj.target) return;
2252
2510
  const existing = batchHandlers.get(proj.target);
@@ -2255,56 +2513,33 @@ function registerBatchHandler(proj, batchHandlers) {
2255
2513
  }
2256
2514
  batchHandlers.set(proj.target, proj.batchHandler);
2257
2515
  }
2258
- function act(states = /* @__PURE__ */ new Map(), registry = {
2259
- actions: {},
2260
- events: {}
2261
- }, pendingProjections = [], batchHandlers = /* @__PURE__ */ new Map()) {
2516
+ function act() {
2517
+ const states = /* @__PURE__ */ new Map();
2518
+ const registry = {
2519
+ actions: {},
2520
+ events: {}
2521
+ };
2522
+ const pendingProjections = [];
2523
+ const batchHandlers = /* @__PURE__ */ new Map();
2262
2524
  const builder = {
2263
2525
  withState: (state2) => {
2264
2526
  registerState(state2, states, registry.actions, registry.events);
2265
- return act(
2266
- states,
2267
- registry,
2268
- pendingProjections,
2269
- batchHandlers
2270
- );
2527
+ return builder;
2271
2528
  },
2272
2529
  withSlice: (input) => {
2273
2530
  for (const s of input.states.values()) {
2274
2531
  registerState(s, states, registry.actions, registry.events);
2275
2532
  }
2276
- for (const eventName of Object.keys(input.events)) {
2277
- const sliceRegister = input.events[eventName];
2278
- for (const [name, reaction] of sliceRegister.reactions) {
2279
- registry.events[eventName].reactions.set(name, reaction);
2280
- }
2281
- }
2533
+ mergeEventRegister(registry.events, input.events);
2282
2534
  pendingProjections.push(...input.projections);
2283
- return act(
2284
- states,
2285
- registry,
2286
- pendingProjections,
2287
- batchHandlers
2288
- );
2535
+ return builder;
2289
2536
  },
2290
2537
  withProjection: (proj) => {
2291
2538
  mergeProjection(proj, registry.events);
2292
2539
  registerBatchHandler(proj, batchHandlers);
2293
- return act(
2294
- states,
2295
- registry,
2296
- pendingProjections,
2297
- batchHandlers
2298
- );
2299
- },
2300
- withActor: () => {
2301
- return act(
2302
- states,
2303
- registry,
2304
- pendingProjections,
2305
- batchHandlers
2306
- );
2540
+ return builder;
2307
2541
  },
2542
+ withActor: () => builder,
2308
2543
  on: (event) => ({
2309
2544
  do: (handler, options) => {
2310
2545
  const reaction = {
@@ -2320,19 +2555,15 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2320
2555
  `Reaction handler for "${String(event)}" must be a named function`
2321
2556
  );
2322
2557
  registry.events[event].reactions.set(handler.name, reaction);
2323
- return {
2324
- ...builder,
2558
+ return Object.assign(builder, {
2325
2559
  to(resolver) {
2326
- registry.events[event].reactions.set(handler.name, {
2327
- ...reaction,
2328
- resolver: typeof resolver === "string" ? { target: resolver } : resolver
2329
- });
2560
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2330
2561
  return builder;
2331
2562
  }
2332
- };
2563
+ });
2333
2564
  }
2334
2565
  }),
2335
- build: () => {
2566
+ build: (options) => {
2336
2567
  for (const proj of pendingProjections) {
2337
2568
  mergeProjection(proj, registry.events);
2338
2569
  registerBatchHandler(proj, batchHandlers);
@@ -2340,7 +2571,8 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2340
2571
  return new Act(
2341
2572
  registry,
2342
2573
  states,
2343
- batchHandlers
2574
+ batchHandlers,
2575
+ options
2344
2576
  );
2345
2577
  },
2346
2578
  events: registry.events
@@ -2348,8 +2580,9 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2348
2580
  return builder;
2349
2581
  }
2350
2582
 
2351
- // src/projection-builder.ts
2352
- function _projection(target, events) {
2583
+ // src/builders/projection-builder.ts
2584
+ function _projection(target) {
2585
+ const events = {};
2353
2586
  const defaultResolver = typeof target === "string" ? { target } : void 0;
2354
2587
  const base = {
2355
2588
  on: (entry) => {
@@ -2379,17 +2612,13 @@ function _projection(target, events) {
2379
2612
  `Projection handler for "${event}" must be a named function`
2380
2613
  );
2381
2614
  register.reactions.set(handler.name, reaction);
2382
- const nextBuilder = _projection(target, events);
2383
- return {
2384
- ...nextBuilder,
2615
+ const widened = base;
2616
+ return Object.assign(widened, {
2385
2617
  to(resolver) {
2386
- register.reactions.set(handler.name, {
2387
- ...reaction,
2388
- resolver: typeof resolver === "string" ? { target: resolver } : resolver
2389
- });
2390
- return nextBuilder;
2618
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2619
+ return widened;
2391
2620
  }
2392
- };
2621
+ });
2393
2622
  }
2394
2623
  };
2395
2624
  },
@@ -2401,8 +2630,7 @@ function _projection(target, events) {
2401
2630
  events
2402
2631
  };
2403
2632
  if (typeof target === "string") {
2404
- return {
2405
- ...base,
2633
+ return Object.assign(base, {
2406
2634
  batch: (handler) => ({
2407
2635
  build: () => ({
2408
2636
  _tag: "Projection",
@@ -2411,34 +2639,28 @@ function _projection(target, events) {
2411
2639
  batchHandler: handler
2412
2640
  })
2413
2641
  })
2414
- };
2642
+ });
2415
2643
  }
2416
2644
  return base;
2417
2645
  }
2418
- function projection(target, events = {}) {
2419
- return _projection(target, events);
2646
+ function projection(target) {
2647
+ return _projection(target);
2420
2648
  }
2421
2649
 
2422
- // src/slice-builder.ts
2423
- function slice(states = /* @__PURE__ */ new Map(), actions = {}, events = {}, projections = []) {
2650
+ // src/builders/slice-builder.ts
2651
+ function slice() {
2652
+ const states = /* @__PURE__ */ new Map();
2653
+ const actions = {};
2654
+ const events = {};
2655
+ const projections = [];
2424
2656
  const builder = {
2425
2657
  withState: (state2) => {
2426
2658
  registerState(state2, states, actions, events);
2427
- return slice(
2428
- states,
2429
- actions,
2430
- events,
2431
- projections
2432
- );
2659
+ return builder;
2433
2660
  },
2434
2661
  withProjection: (proj) => {
2435
2662
  projections.push(proj);
2436
- return slice(
2437
- states,
2438
- actions,
2439
- events,
2440
- projections
2441
- );
2663
+ return builder;
2442
2664
  },
2443
2665
  on: (event) => ({
2444
2666
  do: (handler, options) => {
@@ -2455,16 +2677,12 @@ function slice(states = /* @__PURE__ */ new Map(), actions = {}, events = {}, pr
2455
2677
  `Reaction handler for "${String(event)}" must be a named function`
2456
2678
  );
2457
2679
  events[event].reactions.set(handler.name, reaction);
2458
- return {
2459
- ...builder,
2680
+ return Object.assign(builder, {
2460
2681
  to(resolver) {
2461
- events[event].reactions.set(handler.name, {
2462
- ...reaction,
2463
- resolver: typeof resolver === "string" ? { target: resolver } : resolver
2464
- });
2682
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2465
2683
  return builder;
2466
2684
  }
2467
- };
2685
+ });
2468
2686
  }
2469
2687
  }),
2470
2688
  build: () => ({
@@ -2478,7 +2696,7 @@ function slice(states = /* @__PURE__ */ new Map(), actions = {}, events = {}, pr
2478
2696
  return builder;
2479
2697
  }
2480
2698
 
2481
- // src/state-builder.ts
2699
+ // src/builders/state-builder.ts
2482
2700
  function state(entry) {
2483
2701
  const keys = Object.keys(entry);
2484
2702
  if (keys.length !== 1) throw new Error("state() requires exactly one key");
@@ -2496,7 +2714,7 @@ function state(entry) {
2496
2714
  return [k, fn];
2497
2715
  })
2498
2716
  );
2499
- const builder = action_builder({
2717
+ const internal = {
2500
2718
  events,
2501
2719
  actions: {},
2502
2720
  state: stateSchema,
@@ -2504,18 +2722,12 @@ function state(entry) {
2504
2722
  init,
2505
2723
  patch: defaultPatch,
2506
2724
  on: {}
2507
- });
2725
+ };
2726
+ const builder = action_builder(internal);
2508
2727
  return Object.assign(builder, {
2509
2728
  patch(customPatch) {
2510
- return action_builder({
2511
- events,
2512
- actions: {},
2513
- state: stateSchema,
2514
- name,
2515
- init,
2516
- patch: { ...defaultPatch, ...customPatch },
2517
- on: {}
2518
- });
2729
+ Object.assign(internal.patch, customPatch);
2730
+ return builder;
2519
2731
  }
2520
2732
  });
2521
2733
  }
@@ -2524,50 +2736,43 @@ function state(entry) {
2524
2736
  };
2525
2737
  }
2526
2738
  function action_builder(state2) {
2527
- return {
2739
+ const internal = state2;
2740
+ const builder = {
2528
2741
  on(entry) {
2529
2742
  const keys = Object.keys(entry);
2530
2743
  if (keys.length !== 1) throw new Error(".on() requires exactly one key");
2531
2744
  const action2 = keys[0];
2532
2745
  const schema = entry[action2];
2533
- if (action2 in state2.actions)
2746
+ if (action2 in internal.actions)
2534
2747
  throw new Error(`Duplicate action "${action2}"`);
2535
- const actions = {
2536
- ...state2.actions,
2537
- [action2]: schema
2538
- };
2539
- const on = { ...state2.on };
2540
- const _given = { ...state2.given };
2748
+ internal.actions[action2] = schema;
2541
2749
  function given(rules) {
2542
- _given[action2] = rules;
2750
+ (internal.given ??= {})[action2] = rules;
2543
2751
  return { emit };
2544
2752
  }
2545
2753
  function emit(handler) {
2546
2754
  if (typeof handler === "string") {
2547
2755
  const eventName = handler;
2548
- on[action2] = ((payload) => [eventName, payload]);
2756
+ internal.on[action2] = (payload) => [
2757
+ eventName,
2758
+ payload
2759
+ ];
2549
2760
  } else {
2550
- on[action2] = handler;
2761
+ internal.on[action2] = handler;
2551
2762
  }
2552
- return action_builder({
2553
- ...state2,
2554
- actions,
2555
- on,
2556
- given: _given
2557
- });
2763
+ return builder;
2558
2764
  }
2559
2765
  return { given, emit };
2560
2766
  },
2561
2767
  snap(snap2) {
2562
- return action_builder({
2563
- ...state2,
2564
- snap: snap2
2565
- });
2768
+ internal.snap = snap2;
2769
+ return builder;
2566
2770
  },
2567
2771
  build() {
2568
- return state2;
2772
+ return internal;
2569
2773
  }
2570
2774
  };
2775
+ return builder;
2571
2776
  }
2572
2777
  // Annotate the CommonJS export names for ESM import in node:
2573
2778
  0 && (module.exports = {
@@ -2577,6 +2782,7 @@ function action_builder(state2) {
2577
2782
  CommittedMetaSchema,
2578
2783
  ConcurrencyError,
2579
2784
  ConsoleLogger,
2785
+ DEFAULT_MAX_SUBSCRIBED_STREAMS,
2580
2786
  Environments,
2581
2787
  Errors,
2582
2788
  EventMetaSchema,