@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.js CHANGED
@@ -13,9 +13,9 @@ import {
13
13
  TargetSchema,
14
14
  ValidationError,
15
15
  ZodEmpty
16
- } from "./chunk-JBKZJXQZ.js";
16
+ } from "./chunk-IDEYGKT4.js";
17
17
 
18
- // src/adapters/ConsoleLogger.ts
18
+ // src/adapters/console-logger.ts
19
19
  var LEVEL_VALUES = {
20
20
  fatal: 60,
21
21
  error: 50,
@@ -84,14 +84,25 @@ var ConsoleLogger = class _ConsoleLogger {
84
84
  obj = {};
85
85
  } else if (objOrMsg !== null && typeof objOrMsg === "object") {
86
86
  message = msg;
87
- obj = Object.fromEntries(Object.entries(objOrMsg));
87
+ obj = { ...objOrMsg };
88
88
  } else {
89
89
  message = msg;
90
90
  obj = { value: objOrMsg };
91
91
  }
92
92
  const entry = Object.assign({ level, time: Date.now() }, bindings, obj);
93
93
  if (message) entry.msg = message;
94
- process.stdout.write(JSON.stringify(entry) + "\n");
94
+ let line;
95
+ try {
96
+ line = JSON.stringify(entry);
97
+ } catch {
98
+ line = JSON.stringify({
99
+ level,
100
+ time: entry.time,
101
+ msg: message ?? "[unserializable]",
102
+ unserializable: true
103
+ });
104
+ }
105
+ process.stdout.write(line + "\n");
95
106
  }
96
107
  _prettyWrite(bindings, level, _num, objOrMsg, msg) {
97
108
  const color = LEVEL_COLORS[level];
@@ -117,26 +128,75 @@ var ConsoleLogger = class _ConsoleLogger {
117
128
  }
118
129
  };
119
130
 
120
- // src/adapters/InMemoryCache.ts
121
- var InMemoryCache = class {
131
+ // src/internal/lru-map.ts
132
+ var LruMap = class {
133
+ constructor(_maxSize) {
134
+ this._maxSize = _maxSize;
135
+ }
122
136
  _entries = /* @__PURE__ */ new Map();
123
- _maxSize;
137
+ get(key) {
138
+ const v = this._entries.get(key);
139
+ if (v === void 0) return void 0;
140
+ this._entries.delete(key);
141
+ this._entries.set(key, v);
142
+ return v;
143
+ }
144
+ has(key) {
145
+ return this._entries.has(key);
146
+ }
147
+ set(key, value) {
148
+ this._entries.delete(key);
149
+ if (this._entries.size >= this._maxSize) {
150
+ const oldest = this._entries.keys().next().value;
151
+ this._entries.delete(oldest);
152
+ }
153
+ this._entries.set(key, value);
154
+ }
155
+ delete(key) {
156
+ return this._entries.delete(key);
157
+ }
158
+ clear() {
159
+ this._entries.clear();
160
+ }
161
+ get size() {
162
+ return this._entries.size;
163
+ }
164
+ };
165
+ var LruSet = class {
166
+ _map;
167
+ constructor(maxSize) {
168
+ this._map = new LruMap(maxSize);
169
+ }
170
+ has(value) {
171
+ return this._map.has(value);
172
+ }
173
+ add(value) {
174
+ this._map.set(value, true);
175
+ }
176
+ delete(value) {
177
+ return this._map.delete(value);
178
+ }
179
+ clear() {
180
+ this._map.clear();
181
+ }
182
+ get size() {
183
+ return this._map.size;
184
+ }
185
+ };
186
+
187
+ // src/adapters/in-memory-cache.ts
188
+ var InMemoryCache = class {
189
+ // CacheEntry<any> lets `get<TState>` and `set<TState>` flow without casts:
190
+ // any is bidirectionally compatible with the per-call TState binding, while
191
+ // the public Cache interface still presents a typed surface to callers.
192
+ _entries;
124
193
  constructor(options) {
125
- this._maxSize = options?.maxSize ?? 1e3;
194
+ this._entries = new LruMap(options?.maxSize ?? 1e3);
126
195
  }
127
196
  async get(stream) {
128
- const entry = this._entries.get(stream);
129
- if (!entry) return void 0;
130
- this._entries.delete(stream);
131
- this._entries.set(stream, entry);
132
- return entry;
197
+ return this._entries.get(stream);
133
198
  }
134
199
  async set(stream, entry) {
135
- this._entries.delete(stream);
136
- if (this._entries.size >= this._maxSize) {
137
- const first = this._entries.keys().next().value;
138
- this._entries.delete(first);
139
- }
140
200
  this._entries.set(stream, entry);
141
201
  }
142
202
  async invalidate(stream) {
@@ -167,10 +227,21 @@ var PackageSchema = z.object({
167
227
  license: z.string().min(1).optional(),
168
228
  dependencies: z.record(z.string(), z.string()).optional()
169
229
  });
230
+ var FALLBACK_PACKAGE = {
231
+ name: "act-fallback",
232
+ version: "0.0.0-fallback",
233
+ description: "Synthetic fallback \u2014 package.json could not be loaded"
234
+ };
170
235
  var getPackage = () => {
171
- const pkg2 = fs.readFileSync("package.json");
172
- return JSON.parse(pkg2.toString());
236
+ try {
237
+ const raw = fs.readFileSync("package.json");
238
+ return JSON.parse(raw.toString());
239
+ } catch (err) {
240
+ pkgLoadError = err;
241
+ return FALLBACK_PACKAGE;
242
+ }
173
243
  };
244
+ var pkgLoadError;
174
245
  var BaseSchema = PackageSchema.extend({
175
246
  env: z.enum(Environments),
176
247
  logLevel: z.enum(LogLevels),
@@ -190,6 +261,13 @@ var config = () => {
190
261
  { ...pkg, env, logLevel, logSingleLine, sleepMs },
191
262
  BaseSchema
192
263
  );
264
+ if (pkgLoadError) {
265
+ const msg = pkgLoadError instanceof Error ? pkgLoadError.message : typeof pkgLoadError === "string" ? pkgLoadError : "unknown error";
266
+ log().warn(
267
+ `[act] Could not read package.json (${msg}); using synthetic name="${FALLBACK_PACKAGE.name}" version="${FALLBACK_PACKAGE.version}".`
268
+ );
269
+ pkgLoadError = void 0;
270
+ }
193
271
  }
194
272
  return _validated;
195
273
  };
@@ -213,7 +291,7 @@ async function sleep(ms) {
213
291
  return new Promise((resolve) => setTimeout(resolve, ms ?? config().sleepMs));
214
292
  }
215
293
 
216
- // src/adapters/InMemoryStore.ts
294
+ // src/adapters/in-memory-store.ts
217
295
  var InMemoryStream = class {
218
296
  constructor(stream, source) {
219
297
  this.stream = stream;
@@ -308,11 +386,13 @@ var InMemoryStream = class {
308
386
  }
309
387
  }
310
388
  /**
311
- * Reset this stream's watermark and state for replay.
389
+ * Reset this stream's watermark and state for replay. The retry counter
390
+ * resets to -1 to match the constructor + ack() invariant ("released
391
+ * stream"); the next claim() bumps it to 0 (first attempt).
312
392
  */
313
393
  reset() {
314
394
  this._at = -1;
315
- this._retry = 0;
395
+ this._retry = -1;
316
396
  this._blocked = false;
317
397
  this._error = "";
318
398
  this._leased_by = void 0;
@@ -324,13 +404,26 @@ var InMemoryStore = class {
324
404
  _events = [];
325
405
  // stored stream positions and other metadata
326
406
  _streams = /* @__PURE__ */ new Map();
407
+ // last committed version per stream — O(1) replacement for filter-on-commit
408
+ _streamVersions = /* @__PURE__ */ new Map();
409
+ // max non-snapshot event id per stream — drives the source-pattern probe in claim()
410
+ // without scanning the full event log.
411
+ _maxEventIdByStream = /* @__PURE__ */ new Map();
412
+ // global max non-snapshot event id — fast pre-check for source-less streams in claim()
413
+ _maxNonSnapEventId = -1;
414
+ _resetIndexes() {
415
+ this._events.length = 0;
416
+ this._streamVersions.clear();
417
+ this._maxEventIdByStream.clear();
418
+ this._maxNonSnapEventId = -1;
419
+ }
327
420
  /**
328
421
  * Dispose of the store and clear all events.
329
422
  * @returns Promise that resolves when disposal is complete.
330
423
  */
331
424
  async dispose() {
332
425
  await sleep();
333
- this._events.length = 0;
426
+ this._resetIndexes();
334
427
  }
335
428
  /**
336
429
  * Seed the store with initial data (no-op for in-memory).
@@ -345,7 +438,7 @@ var InMemoryStore = class {
345
438
  */
346
439
  async drop() {
347
440
  await sleep();
348
- this._events.length = 0;
441
+ this._resetIndexes();
349
442
  this._streams = /* @__PURE__ */ new Map();
350
443
  }
351
444
  in_query(query, e) {
@@ -408,18 +501,19 @@ var InMemoryStore = class {
408
501
  */
409
502
  async commit(stream, msgs, meta, expectedVersion) {
410
503
  await sleep();
411
- const instance = this._events.filter((e) => e.stream === stream);
412
- if (typeof expectedVersion === "number" && instance.length - 1 !== expectedVersion) {
504
+ const currentVersion = this._streamVersions.get(stream) ?? -1;
505
+ if (typeof expectedVersion === "number" && currentVersion !== expectedVersion) {
413
506
  throw new ConcurrencyError(
414
507
  stream,
415
- instance.length - 1,
508
+ currentVersion,
416
509
  msgs,
417
510
  expectedVersion
418
511
  );
419
512
  }
420
- let version = instance.length;
421
- return msgs.map(({ name, data }) => {
422
- const committed = {
513
+ let version = currentVersion + 1;
514
+ let lastNonSnapId = -1;
515
+ const committed = msgs.map(({ name, data }) => {
516
+ const c = {
423
517
  id: this._events.length,
424
518
  stream,
425
519
  version,
@@ -428,10 +522,17 @@ var InMemoryStore = class {
428
522
  data,
429
523
  meta
430
524
  };
431
- this._events.push(committed);
525
+ this._events.push(c);
526
+ if (name !== SNAP_EVENT) lastNonSnapId = c.id;
432
527
  version++;
433
- return committed;
528
+ return c;
434
529
  });
530
+ this._streamVersions.set(stream, version - 1);
531
+ if (lastNonSnapId >= 0) {
532
+ this._maxEventIdByStream.set(stream, lastNonSnapId);
533
+ this._maxNonSnapEventId = lastNonSnapId;
534
+ }
535
+ return committed;
435
536
  }
436
537
  /**
437
538
  * Atomically discovers and leases streams for processing.
@@ -444,10 +545,26 @@ var InMemoryStore = class {
444
545
  */
445
546
  async claim(lagging, leading, by, millis) {
446
547
  await sleep();
548
+ const sourceRegex = /* @__PURE__ */ new Map();
549
+ const getRegex = (source) => {
550
+ let re = sourceRegex.get(source);
551
+ if (!re) {
552
+ re = new RegExp(source);
553
+ sourceRegex.set(source, re);
554
+ }
555
+ return re;
556
+ };
557
+ const hasWork = (s) => {
558
+ if (s.at < 0) return true;
559
+ if (!s.source) return s.at < this._maxNonSnapEventId;
560
+ const re = getRegex(s.source);
561
+ for (const [streamName, maxId] of this._maxEventIdByStream) {
562
+ if (maxId > s.at && re.test(streamName)) return true;
563
+ }
564
+ return false;
565
+ };
447
566
  const available = [...this._streams.values()].filter(
448
- (s) => s.is_available && (s.at < 0 || this._events.some(
449
- (e) => e.id > s.at && e.name !== SNAP_EVENT && (!s.source || RegExp(s.source).test(e.stream))
450
- ))
567
+ (s) => s.is_available && hasWork(s)
451
568
  );
452
569
  const lag = available.sort((a, b) => a.at - b.at).slice(0, lagging).map((s) => ({
453
570
  stream: s.stream,
@@ -584,9 +701,13 @@ var InMemoryStore = class {
584
701
  }
585
702
  }
586
703
  this._events = this._events.filter((e) => !streamSet.has(e.stream));
704
+ for (const stream of streamSet) {
705
+ this._streams.delete(stream);
706
+ this._streamVersions.delete(stream);
707
+ this._maxEventIdByStream.delete(stream);
708
+ }
587
709
  const result = /* @__PURE__ */ new Map();
588
710
  for (const { stream, snapshot, meta } of targets) {
589
- this._streams.delete(stream);
590
711
  const event = {
591
712
  id: this._events.length,
592
713
  stream,
@@ -597,11 +718,18 @@ var InMemoryStore = class {
597
718
  meta: meta ?? { correlation: "", causation: {} }
598
719
  };
599
720
  this._events.push(event);
721
+ this._streamVersions.set(stream, 0);
722
+ if (event.name !== SNAP_EVENT) {
723
+ this._maxEventIdByStream.set(stream, event.id);
724
+ }
600
725
  result.set(stream, {
601
726
  deleted: deletedCounts.get(stream) ?? 0,
602
727
  committed: event
603
728
  });
604
729
  }
730
+ let max = -1;
731
+ for (const id of this._maxEventIdByStream.values()) if (id > max) max = id;
732
+ this._maxNonSnapEventId = max;
605
733
  return result;
606
734
  }
607
735
  };
@@ -634,7 +762,12 @@ var cache = port(function cache2(adapter) {
634
762
  });
635
763
  var disposers = [];
636
764
  async function disposeAndExit(code = "EXIT") {
637
- if (code === "ERROR" && config().env === "production") return;
765
+ if (code === "ERROR" && config().env === "production") {
766
+ log().warn(
767
+ "disposeAndExit('ERROR') ignored in production \u2014 process kept alive"
768
+ );
769
+ return;
770
+ }
638
771
  for (const disposer of [...disposers].reverse()) {
639
772
  await disposer();
640
773
  }
@@ -671,9 +804,225 @@ process.once("unhandledRejection", async (arg) => {
671
804
  });
672
805
 
673
806
  // src/act.ts
674
- import { randomUUID as randomUUID2 } from "crypto";
675
807
  import EventEmitter from "events";
676
808
 
809
+ // src/internal/close-cycle.ts
810
+ import { randomUUID } from "crypto";
811
+ async function runCloseCycle(targets, deps) {
812
+ const targetMap = new Map(targets.map((t) => [t.stream, t]));
813
+ const streams = [...targetMap.keys()];
814
+ const skipped = [];
815
+ const streamInfo = await scanStreamHeads(streams);
816
+ const safe = await partitionBySafety(
817
+ streamInfo,
818
+ deps.reactiveEventsSize,
819
+ skipped
820
+ );
821
+ if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
822
+ const correlation = randomUUID();
823
+ const { guarded, guardEvents } = await guardWithTombstones(
824
+ safe,
825
+ streamInfo,
826
+ correlation,
827
+ deps.tombstone,
828
+ skipped
829
+ );
830
+ if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
831
+ const seedStates = await loadRestartSeeds(
832
+ guarded,
833
+ targetMap,
834
+ streamInfo,
835
+ deps.eventToState,
836
+ deps.load,
837
+ deps.logger
838
+ );
839
+ await runArchiveCallbacks(guarded, targetMap);
840
+ const truncated = await truncateAndWarmCache(
841
+ guarded,
842
+ seedStates,
843
+ guardEvents,
844
+ correlation
845
+ );
846
+ return { truncated, skipped };
847
+ }
848
+ async function scanStreamHeads(streams) {
849
+ const out = /* @__PURE__ */ new Map();
850
+ await Promise.all(
851
+ streams.map(async (s) => {
852
+ let maxId = -1;
853
+ let version = -1;
854
+ let lastEventName = "";
855
+ await store().query(
856
+ (e) => {
857
+ if (e.name === TOMBSTONE_EVENT || maxId !== -1) return;
858
+ maxId = e.id;
859
+ version = e.version;
860
+ lastEventName = e.name;
861
+ },
862
+ { stream: s, stream_exact: true, backward: true, limit: 1 }
863
+ );
864
+ if (maxId >= 0) out.set(s, { maxId, version, lastEventName });
865
+ })
866
+ );
867
+ return out;
868
+ }
869
+ async function partitionBySafety(streamInfo, reactiveEventsSize, skipped) {
870
+ if (reactiveEventsSize === 0) return [...streamInfo.keys()];
871
+ const pendingSet = /* @__PURE__ */ new Set();
872
+ await store().query_streams((position) => {
873
+ const sourceRe = position.source ? RegExp(position.source) : void 0;
874
+ for (const [stream, info] of streamInfo) {
875
+ if ((!sourceRe || sourceRe.test(stream)) && position.at < info.maxId) {
876
+ pendingSet.add(stream);
877
+ }
878
+ }
879
+ });
880
+ const safe = [];
881
+ for (const [stream] of streamInfo) {
882
+ if (pendingSet.has(stream)) skipped.push(stream);
883
+ else safe.push(stream);
884
+ }
885
+ return safe;
886
+ }
887
+ async function guardWithTombstones(safe, streamInfo, correlation, tombstone2, skipped) {
888
+ const guarded = [];
889
+ const guardEvents = /* @__PURE__ */ new Map();
890
+ await Promise.all(
891
+ safe.map(async (stream) => {
892
+ const info = streamInfo.get(stream);
893
+ const committed = await tombstone2(stream, info.version, correlation);
894
+ if (committed) {
895
+ guarded.push(stream);
896
+ guardEvents.set(stream, { id: committed.id, stream });
897
+ } else {
898
+ skipped.push(stream);
899
+ }
900
+ })
901
+ );
902
+ return { guarded, guardEvents };
903
+ }
904
+ async function loadRestartSeeds(guarded, targetMap, streamInfo, eventToState, load2, logger) {
905
+ const seedStates = /* @__PURE__ */ new Map();
906
+ await Promise.all(
907
+ guarded.filter((s) => targetMap.get(s)?.restart).map(async (stream) => {
908
+ const lastEventName = streamInfo.get(stream).lastEventName;
909
+ const ownerState = eventToState.get(lastEventName);
910
+ if (!ownerState) {
911
+ logger.error(
912
+ `Cannot seed restart for "${stream}": no registered state owns event "${lastEventName}". Stream will be tombstoned instead.`
913
+ );
914
+ return;
915
+ }
916
+ const snap2 = await load2(ownerState, stream);
917
+ seedStates.set(stream, snap2.state);
918
+ })
919
+ );
920
+ return seedStates;
921
+ }
922
+ async function runArchiveCallbacks(guarded, targetMap) {
923
+ for (const stream of guarded) {
924
+ const archiveFn = targetMap.get(stream)?.archive;
925
+ if (archiveFn) await archiveFn();
926
+ }
927
+ }
928
+ async function truncateAndWarmCache(guarded, seedStates, guardEvents, correlation) {
929
+ const truncTargets = guarded.map((stream) => {
930
+ const snapshot = seedStates.get(stream);
931
+ const guard = guardEvents.get(stream);
932
+ return {
933
+ stream,
934
+ snapshot,
935
+ meta: {
936
+ correlation,
937
+ causation: {
938
+ event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
939
+ }
940
+ }
941
+ };
942
+ });
943
+ const truncated = await store().truncate(truncTargets);
944
+ await Promise.all(
945
+ guarded.map(async (stream) => {
946
+ const entry = truncated.get(stream);
947
+ const state2 = seedStates.get(stream);
948
+ if (state2 && entry) {
949
+ await cache().set(stream, {
950
+ state: state2,
951
+ version: entry.committed.version,
952
+ event_id: entry.committed.id,
953
+ patches: 0,
954
+ snaps: 1
955
+ });
956
+ } else {
957
+ await cache().invalidate(stream);
958
+ }
959
+ })
960
+ );
961
+ return truncated;
962
+ }
963
+
964
+ // src/internal/drain-cycle.ts
965
+ import { randomUUID as randomUUID2 } from "crypto";
966
+ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis) {
967
+ const leased = await ops.claim(lagging, leading, randomUUID2(), leaseMillis);
968
+ if (!leased.length) return void 0;
969
+ const fetched = await ops.fetch(leased, eventLimit);
970
+ const fetchMap = /* @__PURE__ */ new Map();
971
+ const fetch_window_at = fetched.reduce(
972
+ (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
973
+ 0
974
+ );
975
+ for (const f of fetched) {
976
+ const { stream, events } = f;
977
+ const payloads = events.flatMap((event) => {
978
+ const register = registry.events[event.name];
979
+ if (!register) return [];
980
+ return [...register.reactions.values()].filter((reaction) => {
981
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
982
+ return resolved && resolved.target === stream;
983
+ }).map((reaction) => ({ ...reaction, event }));
984
+ });
985
+ fetchMap.set(stream, { fetch: f, payloads });
986
+ }
987
+ const handled = await Promise.all(
988
+ leased.map((lease) => {
989
+ const entry = fetchMap.get(lease.stream);
990
+ const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
991
+ const { payloads } = entry;
992
+ const batchHandler = batchHandlers.get(lease.stream);
993
+ if (batchHandler && payloads.length > 0) {
994
+ return handleBatch({ ...lease, at }, payloads, batchHandler);
995
+ }
996
+ return handle({ ...lease, at }, payloads);
997
+ })
998
+ );
999
+ const acked = await ops.ack(
1000
+ handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
1001
+ );
1002
+ const blocked = await ops.block(
1003
+ handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1004
+ );
1005
+ return { leased, fetched, handled, acked, blocked };
1006
+ }
1007
+
1008
+ // src/internal/drain-ratio.ts
1009
+ var RATIO_MIN = 0.2;
1010
+ var RATIO_MAX = 0.8;
1011
+ var RATIO_DEFAULT = 0.5;
1012
+ function computeLagLeadRatio(handled, lagging, leading) {
1013
+ let lagging_handled = 0;
1014
+ let leading_handled = 0;
1015
+ for (const { lease, handled: count } of handled) {
1016
+ if (lease.lagging) lagging_handled += count;
1017
+ else leading_handled += count;
1018
+ }
1019
+ const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1020
+ const leading_avg = leading > 0 ? leading_handled / leading : 0;
1021
+ const total = lagging_avg + leading_avg;
1022
+ if (total === 0) return RATIO_DEFAULT;
1023
+ return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1024
+ }
1025
+
677
1026
  // src/internal/merge.ts
678
1027
  import { ZodObject } from "zod";
679
1028
  function baseTypeName(zodType) {
@@ -781,6 +1130,15 @@ function mergePatches(existing, incoming, stateName) {
781
1130
  }
782
1131
  return merged;
783
1132
  }
1133
+ function mergeEventRegister(target, source) {
1134
+ for (const [eventName, sourceReg] of Object.entries(source)) {
1135
+ const targetReg = target[eventName];
1136
+ if (!targetReg) continue;
1137
+ for (const [name, reaction] of sourceReg.reactions) {
1138
+ targetReg.reactions.set(name, reaction);
1139
+ }
1140
+ }
1141
+ }
784
1142
  function mergeProjection(proj, events) {
785
1143
  for (const eventName of Object.keys(proj.events)) {
786
1144
  const projRegister = proj.events[eventName];
@@ -825,7 +1183,7 @@ var subscribe = (streams) => store().subscribe(streams);
825
1183
 
826
1184
  // src/internal/event-sourcing.ts
827
1185
  import { patch } from "@rotorsoft/act-patch";
828
- import { randomUUID } from "crypto";
1186
+ import { randomUUID as randomUUID3 } from "crypto";
829
1187
  async function snap(snapshot) {
830
1188
  try {
831
1189
  const { id, stream, name, meta, version } = snapshot.event;
@@ -843,6 +1201,20 @@ async function snap(snapshot) {
843
1201
  log().error(error);
844
1202
  }
845
1203
  }
1204
+ async function tombstone(stream, expectedVersion, correlation) {
1205
+ try {
1206
+ const [committed] = await store().commit(
1207
+ stream,
1208
+ [{ name: TOMBSTONE_EVENT, data: {} }],
1209
+ { correlation, causation: {} },
1210
+ expectedVersion
1211
+ );
1212
+ return committed;
1213
+ } catch (error) {
1214
+ if (error instanceof ConcurrencyError) return void 0;
1215
+ throw error;
1216
+ }
1217
+ }
846
1218
  async function load(me, stream, callback, asOf) {
847
1219
  const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
848
1220
  const cached = timeTravel ? void 0 : await cache().get(stream);
@@ -907,13 +1279,13 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
907
1279
  data: skipValidation ? data : validate(name, data, me.events[name])
908
1280
  }));
909
1281
  const meta = {
910
- correlation: reactingTo?.meta.correlation || randomUUID(),
1282
+ correlation: reactingTo?.meta.correlation || randomUUID3(),
911
1283
  causation: {
912
1284
  action: {
913
1285
  name: action2,
914
1286
  ...target
915
- // payload: TODO: flag to include action payload in metadata
916
- // not included by default to avoid large payloads
1287
+ // payload intentionally omitted: it can be large or contain PII,
1288
+ // and callers correlate via the correlation id when they need it.
917
1289
  },
918
1290
  event: reactingTo ? {
919
1291
  id: reactingTo.id,
@@ -928,7 +1300,10 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
928
1300
  stream,
929
1301
  emitted,
930
1302
  meta,
931
- // TODO: review reactions not enforcing expected version
1303
+ // Reactions skip optimistic concurrency: they always append against the
1304
+ // current head. Stream leasing already serializes concurrent reactions,
1305
+ // and forcing version checks here would turn ordinary catch-up into
1306
+ // spurious retries.
932
1307
  reactingTo ? void 0 : expected
933
1308
  );
934
1309
  } catch (error) {
@@ -978,7 +1353,12 @@ var traced = (inner, exit, entry) => (async (...args) => {
978
1353
  });
979
1354
  function buildEs(logger) {
980
1355
  if (logger.level !== "trace") {
981
- return { snap, load, action };
1356
+ return {
1357
+ snap,
1358
+ load,
1359
+ action,
1360
+ tombstone
1361
+ };
982
1362
  }
983
1363
  return {
984
1364
  snap: traced(snap, void 0, (snapshot) => {
@@ -1016,7 +1396,13 @@ function buildEs(logger) {
1016
1396
  es_caption("action", C_BLUE, `${target.stream}.${action2}`)
1017
1397
  );
1018
1398
  }
1019
- )
1399
+ ),
1400
+ tombstone: traced(tombstone, (committed, stream) => {
1401
+ if (committed)
1402
+ logger.trace(
1403
+ es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
1404
+ );
1405
+ })
1020
1406
  };
1021
1407
  }
1022
1408
  function buildDrain(logger) {
@@ -1079,11 +1465,15 @@ function buildDrain(logger) {
1079
1465
  }
1080
1466
 
1081
1467
  // src/act.ts
1468
+ var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
1082
1469
  var Act = class {
1083
- constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map()) {
1470
+ constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
1084
1471
  this.registry = registry;
1085
1472
  this._states = _states;
1086
1473
  this._batch_handlers = batchHandlers;
1474
+ this._subscribed_streams = new LruSet(
1475
+ options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS
1476
+ );
1087
1477
  this._es = buildEs(this._logger);
1088
1478
  this._cd = buildDrain(this._logger);
1089
1479
  const statics = /* @__PURE__ */ new Map();
@@ -1121,20 +1511,42 @@ var Act = class {
1121
1511
  _settle_timer = void 0;
1122
1512
  _settling = false;
1123
1513
  _correlation_checkpoint = -1;
1124
- _subscribed_statics = /* @__PURE__ */ new Set();
1514
+ /**
1515
+ * Streams already subscribed via store.subscribe() — both the static
1516
+ * targets registered at init and dynamic targets discovered by
1517
+ * correlate(). correlate() consults this set to avoid re-subscribing
1518
+ * known streams.
1519
+ *
1520
+ * Bounded LRU so apps that mint millions of dynamic targets (one per
1521
+ * aggregate) don't grow this unbounded. Eviction costs at most one
1522
+ * redundant store.subscribe() call per evicted-but-still-active stream
1523
+ * (subscribe is idempotent). Cap configurable via {@link ActOptions}.
1524
+ */
1525
+ _subscribed_streams;
1125
1526
  _has_dynamic_resolvers = false;
1126
1527
  _correlation_initialized = false;
1127
1528
  /** Event names with at least one registered reaction (computed at build time) */
1128
1529
  _reactive_events = /* @__PURE__ */ new Set();
1129
1530
  /** Set in do() when a committed event has reactions — cleared by drain() */
1130
1531
  _needs_drain = false;
1532
+ /**
1533
+ * Emit a lifecycle event. The payload type is inferred from the event name
1534
+ * via {@link ActLifecycleEvents}.
1535
+ */
1131
1536
  emit(event, args) {
1132
1537
  return this._emitter.emit(event, args);
1133
1538
  }
1539
+ /**
1540
+ * Register a listener for a lifecycle event. The listener receives the
1541
+ * event-specific payload.
1542
+ */
1134
1543
  on(event, listener) {
1135
1544
  this._emitter.on(event, listener);
1136
1545
  return this;
1137
1546
  }
1547
+ /**
1548
+ * Remove a previously registered lifecycle listener.
1549
+ */
1138
1550
  off(event, listener) {
1139
1551
  this._emitter.off(event, listener);
1140
1552
  return this;
@@ -1168,6 +1580,9 @@ var Act = class {
1168
1580
  _bound_load = this.load.bind(this);
1169
1581
  _bound_query = this.query.bind(this);
1170
1582
  _bound_query_array = this.query_array.bind(this);
1583
+ /** Pre-bound dispatchers handed to runDrainCycle each cycle. */
1584
+ _bound_handle = this.handle.bind(this);
1585
+ _bound_handle_batch = this.handleBatch.bind(this);
1171
1586
  /**
1172
1587
  * Executes an action on a state instance, committing resulting events.
1173
1588
  *
@@ -1370,26 +1785,46 @@ var Act = class {
1370
1785
  return events;
1371
1786
  }
1372
1787
  /**
1373
- * Handles leased reactions.
1374
- *
1375
- * This is called by the main `drain` loop after fetching new events.
1376
- * It handles reactions, supporting retries, blocking, and error handling.
1788
+ * Shared finalization for the two reaction-runner shapes (per-event
1789
+ * `handle` and bulk `handleBatch`). Centralizes the error log, retry-vs-
1790
+ * block decision, and the "error reported only when nothing was handled"
1791
+ * rule that's true in both shapes (in batch mode, `handled` is always 0
1792
+ * on failure, so the rule degenerates to "always reported").
1793
+ */
1794
+ _finalize(lease, handled, at, error, options) {
1795
+ if (!error) return { lease, handled, at };
1796
+ this._logger.error(error);
1797
+ const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1798
+ if (block2)
1799
+ this._logger.error(
1800
+ `Blocking ${lease.stream} after ${lease.retry} retries.`
1801
+ );
1802
+ return {
1803
+ lease,
1804
+ handled,
1805
+ at,
1806
+ error: handled === 0 ? error.message : void 0,
1807
+ block: block2
1808
+ };
1809
+ }
1810
+ /**
1811
+ * Handles leased reactions one event at a time.
1377
1812
  *
1378
- * Each handler receives a scoped `IAct` proxy that auto-injects the
1379
- * triggering event as `reactingTo` when `do()` is called without it,
1380
- * maintaining correlation chains by default (#587). Handlers can still
1381
- * pass an explicit `reactingTo` to override this behavior.
1813
+ * Called by the main `drain` loop after fetching new events. Each handler
1814
+ * receives a scoped `IAct` proxy that auto-injects the triggering event
1815
+ * as `reactingTo` when `do()` is called without it, maintaining
1816
+ * correlation chains by default (#587). Handlers can still pass an
1817
+ * explicit `reactingTo` to override.
1382
1818
  *
1383
1819
  * @internal
1384
- * @param lease The lease to handle
1385
- * @param payloads The reactions to handle
1386
- * @returns The lease with results
1387
1820
  */
1388
1821
  async handle(lease, payloads) {
1389
1822
  if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
1390
1823
  const stream = lease.stream;
1391
- let at = payloads.at(0).event.id, handled = 0;
1392
- lease.retry > 0 && this._logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1824
+ let at = payloads.at(0).event.id;
1825
+ let handled = 0;
1826
+ if (lease.retry > 0)
1827
+ this._logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1393
1828
  const doAction = this._bound_do;
1394
1829
  const scopedApp = {
1395
1830
  do: doAction,
@@ -1398,7 +1833,7 @@ var Act = class {
1398
1833
  query_array: this._bound_query_array
1399
1834
  };
1400
1835
  for (const payload of payloads) {
1401
- const { event, handler, options } = payload;
1836
+ const { event, handler } = payload;
1402
1837
  scopedApp.do = (action2, target, payload2, reactingTo, skipValidation) => doAction(
1403
1838
  action2,
1404
1839
  target,
@@ -1411,22 +1846,16 @@ var Act = class {
1411
1846
  at = event.id;
1412
1847
  handled++;
1413
1848
  } catch (error) {
1414
- this._logger.error(error);
1415
- const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1416
- block2 && this._logger.error(
1417
- `Blocking ${stream} after ${lease.retry} retries.`
1418
- );
1419
- return {
1849
+ return this._finalize(
1420
1850
  lease,
1421
1851
  handled,
1422
1852
  at,
1423
- // only report error when nothing was handled
1424
- error: handled === 0 ? error.message : void 0,
1425
- block: block2
1426
- };
1853
+ error,
1854
+ payload.options
1855
+ );
1427
1856
  }
1428
1857
  }
1429
- return { lease, handled, at };
1858
+ return this._finalize(lease, handled, at, void 0, payloads[0].options);
1430
1859
  }
1431
1860
  /**
1432
1861
  * Handles a batch of events for a projection with a batch handler.
@@ -1436,33 +1865,26 @@ var Act = class {
1436
1865
  * in a single call, enabling bulk DB operations.
1437
1866
  *
1438
1867
  * @internal
1439
- * @param lease The lease to handle
1440
- * @param payloads The reactions to handle
1441
- * @param batchHandler The batch handler for this projection
1442
- * @returns The lease with results
1443
1868
  */
1444
1869
  async handleBatch(lease, payloads, batchHandler) {
1445
1870
  const stream = lease.stream;
1446
1871
  const events = payloads.map((p) => p.event);
1447
- const at = events.at(-1).id;
1448
- lease.retry > 0 && this._logger.warn(
1449
- `Retrying batch ${stream}@${events[0].id} (${lease.retry}).`
1450
- );
1872
+ const options = payloads[0].options;
1873
+ if (lease.retry > 0)
1874
+ this._logger.warn(
1875
+ `Retrying batch ${stream}@${events[0].id} (${lease.retry}).`
1876
+ );
1451
1877
  try {
1452
1878
  await batchHandler(events, stream);
1453
- return { lease, handled: events.length, at };
1454
- } catch (error) {
1455
- this._logger.error(error);
1456
- const { options } = payloads[0];
1457
- const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1458
- block2 && this._logger.error(`Blocking ${stream} after ${lease.retry} retries.`);
1459
- return {
1879
+ return this._finalize(
1460
1880
  lease,
1461
- handled: 0,
1462
- at: lease.at,
1463
- error: error.message,
1464
- block: block2
1465
- };
1881
+ events.length,
1882
+ events.at(-1).id,
1883
+ void 0,
1884
+ options
1885
+ );
1886
+ } catch (error) {
1887
+ return this._finalize(lease, 0, lease.at, error, options);
1466
1888
  }
1467
1889
  }
1468
1890
  /**
@@ -1512,82 +1934,46 @@ var Act = class {
1512
1934
  if (!this._needs_drain) {
1513
1935
  return { fetched: [], leased: [], acked: [], blocked: [] };
1514
1936
  }
1515
- if (!this._drain_locked) {
1516
- try {
1517
- this._drain_locked = true;
1518
- const lagging = Math.ceil(streamLimit * this._drain_lag2lead_ratio);
1519
- const leading = streamLimit - lagging;
1520
- const leased = await this._cd.claim(
1521
- lagging,
1522
- leading,
1523
- randomUUID2(),
1524
- leaseMillis
1525
- );
1526
- if (!leased.length) {
1527
- this._needs_drain = false;
1528
- return { fetched: [], leased: [], acked: [], blocked: [] };
1529
- }
1530
- const fetched = await this._cd.fetch(leased, eventLimit);
1531
- const fetchMap = /* @__PURE__ */ new Map();
1532
- const fetch_window_at = fetched.reduce(
1533
- (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
1534
- 0
1535
- );
1536
- for (const f of fetched) {
1537
- const { stream, events } = f;
1538
- const payloads = events.flatMap((event) => {
1539
- const register = this.registry.events[event.name];
1540
- if (!register) return [];
1541
- return [...register.reactions.values()].filter((reaction) => {
1542
- const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1543
- return resolved && resolved.target === stream;
1544
- }).map((reaction) => ({ ...reaction, event }));
1545
- });
1546
- fetchMap.set(stream, { fetch: f, payloads });
1547
- }
1548
- const handled = await Promise.all(
1549
- leased.map((lease) => {
1550
- const entry = fetchMap.get(lease.stream);
1551
- const at = entry?.fetch.events.at(-1)?.id || fetch_window_at;
1552
- const payloads = entry?.payloads ?? [];
1553
- const batchHandler = this._batch_handlers.get(lease.stream);
1554
- if (batchHandler && payloads.length > 0) {
1555
- return this.handleBatch({ ...lease, at }, payloads, batchHandler);
1556
- }
1557
- return this.handle({ ...lease, at }, payloads);
1558
- })
1559
- );
1560
- const [lagging_handled, leading_handled] = handled.reduce(
1561
- ([lagging_handled2, leading_handled2], { lease, handled: handled2 }) => [
1562
- lagging_handled2 + (lease.lagging ? handled2 : 0),
1563
- leading_handled2 + (lease.lagging ? 0 : handled2)
1564
- ],
1565
- [0, 0]
1566
- );
1567
- const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1568
- const leading_avg = leading > 0 ? leading_handled / leading : 0;
1569
- const total = lagging_avg + leading_avg;
1570
- this._drain_lag2lead_ratio = total > 0 ? Math.max(0.2, Math.min(0.8, lagging_avg / total)) : 0.5;
1571
- const acked = await this._cd.ack(
1572
- handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
1573
- );
1574
- if (acked.length) this.emit("acked", acked);
1575
- const blocked = await this._cd.block(
1576
- handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1577
- );
1578
- if (blocked.length) this.emit("blocked", blocked);
1579
- const result = { fetched, leased, acked, blocked };
1580
- const hasErrors = handled.some(({ error }) => error);
1581
- if (!acked.length && !blocked.length && !hasErrors)
1582
- this._needs_drain = false;
1583
- return result;
1584
- } catch (error) {
1585
- this._logger.error(error);
1586
- } finally {
1587
- this._drain_locked = false;
1937
+ if (this._drain_locked) {
1938
+ return { fetched: [], leased: [], acked: [], blocked: [] };
1939
+ }
1940
+ try {
1941
+ this._drain_locked = true;
1942
+ const lagging = Math.ceil(streamLimit * this._drain_lag2lead_ratio);
1943
+ const leading = streamLimit - lagging;
1944
+ const cycle = await runDrainCycle(
1945
+ this._cd,
1946
+ this.registry,
1947
+ this._batch_handlers,
1948
+ this._bound_handle,
1949
+ this._bound_handle_batch,
1950
+ lagging,
1951
+ leading,
1952
+ eventLimit,
1953
+ leaseMillis
1954
+ );
1955
+ if (!cycle) {
1956
+ this._needs_drain = false;
1957
+ return { fetched: [], leased: [], acked: [], blocked: [] };
1588
1958
  }
1959
+ const { leased, fetched, handled, acked, blocked } = cycle;
1960
+ this._drain_lag2lead_ratio = computeLagLeadRatio(
1961
+ handled,
1962
+ lagging,
1963
+ leading
1964
+ );
1965
+ if (acked.length) this.emit("acked", acked);
1966
+ if (blocked.length) this.emit("blocked", blocked);
1967
+ const hasErrors = handled.some(({ error }) => error);
1968
+ if (!acked.length && !blocked.length && !hasErrors)
1969
+ this._needs_drain = false;
1970
+ return { fetched, leased, acked, blocked };
1971
+ } catch (error) {
1972
+ this._logger.error(error);
1973
+ return { fetched: [], leased: [], acked: [], blocked: [] };
1974
+ } finally {
1975
+ this._drain_locked = false;
1589
1976
  }
1590
- return { fetched: [], leased: [], acked: [], blocked: [] };
1591
1977
  }
1592
1978
  /**
1593
1979
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -1648,7 +2034,7 @@ var Act = class {
1648
2034
  this._correlation_checkpoint = watermark;
1649
2035
  if (this._reactive_events.size > 0) this._needs_drain = true;
1650
2036
  for (const { stream } of this._static_targets) {
1651
- this._subscribed_statics.add(stream);
2037
+ this._subscribed_streams.add(stream);
1652
2038
  }
1653
2039
  }
1654
2040
  async correlate(query = { after: -1, limit: 10 }) {
@@ -1666,7 +2052,7 @@ var Act = class {
1666
2052
  for (const reaction of register.reactions.values()) {
1667
2053
  if (typeof reaction.resolver !== "function") continue;
1668
2054
  const resolved = reaction.resolver(event);
1669
- if (resolved && !this._subscribed_statics.has(resolved.target)) {
2055
+ if (resolved && !this._subscribed_streams.has(resolved.target)) {
1670
2056
  const entry = correlated.get(resolved.target) || {
1671
2057
  source: resolved.source,
1672
2058
  payloads: []
@@ -1692,7 +2078,7 @@ var Act = class {
1692
2078
  this._correlation_checkpoint = last_id;
1693
2079
  if (subscribed) {
1694
2080
  for (const { stream } of streams) {
1695
- this._subscribed_statics.add(stream);
2081
+ this._subscribed_streams.add(stream);
1696
2082
  }
1697
2083
  }
1698
2084
  return { subscribed, last_id };
@@ -1875,143 +2261,14 @@ var Act = class {
1875
2261
  */
1876
2262
  async close(targets) {
1877
2263
  if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
1878
- const targetMap = new Map(targets.map((t) => [t.stream, t]));
1879
- const streams = [...targetMap.keys()];
1880
2264
  await this.correlate({ limit: 1e3 });
1881
- const streamInfo = /* @__PURE__ */ new Map();
1882
- await Promise.all(
1883
- streams.map(async (s) => {
1884
- let maxId = -1;
1885
- let version = -1;
1886
- let lastEventName;
1887
- await store().query(
1888
- (e) => {
1889
- if (e.name === TOMBSTONE_EVENT) return;
1890
- if (maxId === -1) {
1891
- maxId = e.id;
1892
- version = e.version;
1893
- }
1894
- if (e.name !== SNAP_EVENT && lastEventName === void 0) {
1895
- lastEventName = e.name;
1896
- }
1897
- },
1898
- // limit: 2 covers the typical snapshot-at-head case (snapshot is
1899
- // always preceded by the domain event it captured). Streams with
1900
- // unusual layouts fall back to no-seed via the lookup miss path.
1901
- { stream: s, stream_exact: true, backward: true, limit: 2 }
1902
- );
1903
- if (maxId >= 0) streamInfo.set(s, { maxId, version, lastEventName });
1904
- })
1905
- );
1906
- const skipped = [];
1907
- let safe;
1908
- if (this._reactive_events.size === 0) {
1909
- safe = [...streamInfo.keys()];
1910
- } else {
1911
- const pendingSet = /* @__PURE__ */ new Set();
1912
- await store().query_streams((position) => {
1913
- const sourceRe = position.source ? RegExp(position.source) : void 0;
1914
- for (const [stream, info] of streamInfo) {
1915
- if ((!sourceRe || sourceRe.test(stream)) && position.at < info.maxId) {
1916
- pendingSet.add(stream);
1917
- }
1918
- }
1919
- });
1920
- safe = [];
1921
- for (const [stream] of streamInfo) {
1922
- if (pendingSet.has(stream)) {
1923
- skipped.push(stream);
1924
- } else {
1925
- safe.push(stream);
1926
- }
1927
- }
1928
- }
1929
- if (!safe.length) {
1930
- const result2 = { truncated: /* @__PURE__ */ new Map(), skipped };
1931
- this.emit("closed", result2);
1932
- return result2;
1933
- }
1934
- const correlation = randomUUID2();
1935
- const guarded = [];
1936
- const guardEvents = /* @__PURE__ */ new Map();
1937
- await Promise.all(
1938
- safe.map(async (stream) => {
1939
- try {
1940
- const info = streamInfo.get(stream);
1941
- const [committed] = await store().commit(
1942
- stream,
1943
- [{ name: TOMBSTONE_EVENT, data: {} }],
1944
- { correlation, causation: {} },
1945
- info.version
1946
- );
1947
- guarded.push(stream);
1948
- guardEvents.set(stream, { id: committed.id, stream });
1949
- } catch {
1950
- skipped.push(stream);
1951
- }
1952
- })
1953
- );
1954
- if (!guarded.length) {
1955
- const result2 = { truncated: /* @__PURE__ */ new Map(), skipped };
1956
- this.emit("closed", result2);
1957
- return result2;
1958
- }
1959
- const seedStates = /* @__PURE__ */ new Map();
1960
- await Promise.all(
1961
- guarded.filter((s) => targetMap.get(s)?.restart).map(async (stream) => {
1962
- const lastEventName = streamInfo.get(stream)?.lastEventName;
1963
- const ownerState = lastEventName ? this._event_to_state.get(lastEventName) : void 0;
1964
- if (!ownerState) {
1965
- this._logger.error(
1966
- `Cannot seed restart for "${stream}": no registered state owns event "${lastEventName ?? "<none>"}". Stream will be tombstoned instead.`
1967
- );
1968
- return;
1969
- }
1970
- const snap2 = await this._es.load(ownerState, stream);
1971
- seedStates.set(stream, snap2.state);
1972
- })
1973
- );
1974
- for (const stream of guarded) {
1975
- const archiveFn = targetMap.get(stream)?.archive;
1976
- if (archiveFn) await archiveFn();
1977
- }
1978
- const truncTargets = guarded.map((stream) => {
1979
- const snapshot = seedStates.get(stream);
1980
- const guard = guardEvents.get(stream);
1981
- return {
1982
- stream,
1983
- snapshot,
1984
- meta: {
1985
- correlation,
1986
- causation: {
1987
- event: {
1988
- id: guard.id,
1989
- name: TOMBSTONE_EVENT,
1990
- stream: guard.stream
1991
- }
1992
- }
1993
- }
1994
- };
2265
+ const result = await runCloseCycle(targets, {
2266
+ reactiveEventsSize: this._reactive_events.size,
2267
+ eventToState: this._event_to_state,
2268
+ load: this._es.load,
2269
+ tombstone: this._es.tombstone,
2270
+ logger: this._logger
1995
2271
  });
1996
- const truncated = await store().truncate(truncTargets);
1997
- await Promise.all(
1998
- guarded.map(async (stream) => {
1999
- const entry = truncated.get(stream);
2000
- const state2 = seedStates.get(stream);
2001
- if (state2 && entry) {
2002
- await cache().set(stream, {
2003
- state: state2,
2004
- version: entry.committed.version,
2005
- event_id: entry.committed.id,
2006
- patches: 0,
2007
- snaps: 1
2008
- });
2009
- } else {
2010
- await cache().invalidate(stream);
2011
- }
2012
- })
2013
- );
2014
- const result = { truncated, skipped };
2015
2272
  this.emit("closed", result);
2016
2273
  return result;
2017
2274
  }
@@ -2080,7 +2337,7 @@ var Act = class {
2080
2337
  }
2081
2338
  };
2082
2339
 
2083
- // src/act-builder.ts
2340
+ // src/builders/act-builder.ts
2084
2341
  function registerBatchHandler(proj, batchHandlers) {
2085
2342
  if (!proj.batchHandler || !proj.target) return;
2086
2343
  const existing = batchHandlers.get(proj.target);
@@ -2089,56 +2346,33 @@ function registerBatchHandler(proj, batchHandlers) {
2089
2346
  }
2090
2347
  batchHandlers.set(proj.target, proj.batchHandler);
2091
2348
  }
2092
- function act(states = /* @__PURE__ */ new Map(), registry = {
2093
- actions: {},
2094
- events: {}
2095
- }, pendingProjections = [], batchHandlers = /* @__PURE__ */ new Map()) {
2349
+ function act() {
2350
+ const states = /* @__PURE__ */ new Map();
2351
+ const registry = {
2352
+ actions: {},
2353
+ events: {}
2354
+ };
2355
+ const pendingProjections = [];
2356
+ const batchHandlers = /* @__PURE__ */ new Map();
2096
2357
  const builder = {
2097
2358
  withState: (state2) => {
2098
2359
  registerState(state2, states, registry.actions, registry.events);
2099
- return act(
2100
- states,
2101
- registry,
2102
- pendingProjections,
2103
- batchHandlers
2104
- );
2360
+ return builder;
2105
2361
  },
2106
2362
  withSlice: (input) => {
2107
2363
  for (const s of input.states.values()) {
2108
2364
  registerState(s, states, registry.actions, registry.events);
2109
2365
  }
2110
- for (const eventName of Object.keys(input.events)) {
2111
- const sliceRegister = input.events[eventName];
2112
- for (const [name, reaction] of sliceRegister.reactions) {
2113
- registry.events[eventName].reactions.set(name, reaction);
2114
- }
2115
- }
2366
+ mergeEventRegister(registry.events, input.events);
2116
2367
  pendingProjections.push(...input.projections);
2117
- return act(
2118
- states,
2119
- registry,
2120
- pendingProjections,
2121
- batchHandlers
2122
- );
2368
+ return builder;
2123
2369
  },
2124
2370
  withProjection: (proj) => {
2125
2371
  mergeProjection(proj, registry.events);
2126
2372
  registerBatchHandler(proj, batchHandlers);
2127
- return act(
2128
- states,
2129
- registry,
2130
- pendingProjections,
2131
- batchHandlers
2132
- );
2133
- },
2134
- withActor: () => {
2135
- return act(
2136
- states,
2137
- registry,
2138
- pendingProjections,
2139
- batchHandlers
2140
- );
2373
+ return builder;
2141
2374
  },
2375
+ withActor: () => builder,
2142
2376
  on: (event) => ({
2143
2377
  do: (handler, options) => {
2144
2378
  const reaction = {
@@ -2154,19 +2388,15 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2154
2388
  `Reaction handler for "${String(event)}" must be a named function`
2155
2389
  );
2156
2390
  registry.events[event].reactions.set(handler.name, reaction);
2157
- return {
2158
- ...builder,
2391
+ return Object.assign(builder, {
2159
2392
  to(resolver) {
2160
- registry.events[event].reactions.set(handler.name, {
2161
- ...reaction,
2162
- resolver: typeof resolver === "string" ? { target: resolver } : resolver
2163
- });
2393
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2164
2394
  return builder;
2165
2395
  }
2166
- };
2396
+ });
2167
2397
  }
2168
2398
  }),
2169
- build: () => {
2399
+ build: (options) => {
2170
2400
  for (const proj of pendingProjections) {
2171
2401
  mergeProjection(proj, registry.events);
2172
2402
  registerBatchHandler(proj, batchHandlers);
@@ -2174,7 +2404,8 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2174
2404
  return new Act(
2175
2405
  registry,
2176
2406
  states,
2177
- batchHandlers
2407
+ batchHandlers,
2408
+ options
2178
2409
  );
2179
2410
  },
2180
2411
  events: registry.events
@@ -2182,8 +2413,9 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2182
2413
  return builder;
2183
2414
  }
2184
2415
 
2185
- // src/projection-builder.ts
2186
- function _projection(target, events) {
2416
+ // src/builders/projection-builder.ts
2417
+ function _projection(target) {
2418
+ const events = {};
2187
2419
  const defaultResolver = typeof target === "string" ? { target } : void 0;
2188
2420
  const base = {
2189
2421
  on: (entry) => {
@@ -2213,17 +2445,13 @@ function _projection(target, events) {
2213
2445
  `Projection handler for "${event}" must be a named function`
2214
2446
  );
2215
2447
  register.reactions.set(handler.name, reaction);
2216
- const nextBuilder = _projection(target, events);
2217
- return {
2218
- ...nextBuilder,
2448
+ const widened = base;
2449
+ return Object.assign(widened, {
2219
2450
  to(resolver) {
2220
- register.reactions.set(handler.name, {
2221
- ...reaction,
2222
- resolver: typeof resolver === "string" ? { target: resolver } : resolver
2223
- });
2224
- return nextBuilder;
2451
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2452
+ return widened;
2225
2453
  }
2226
- };
2454
+ });
2227
2455
  }
2228
2456
  };
2229
2457
  },
@@ -2235,8 +2463,7 @@ function _projection(target, events) {
2235
2463
  events
2236
2464
  };
2237
2465
  if (typeof target === "string") {
2238
- return {
2239
- ...base,
2466
+ return Object.assign(base, {
2240
2467
  batch: (handler) => ({
2241
2468
  build: () => ({
2242
2469
  _tag: "Projection",
@@ -2245,34 +2472,28 @@ function _projection(target, events) {
2245
2472
  batchHandler: handler
2246
2473
  })
2247
2474
  })
2248
- };
2475
+ });
2249
2476
  }
2250
2477
  return base;
2251
2478
  }
2252
- function projection(target, events = {}) {
2253
- return _projection(target, events);
2479
+ function projection(target) {
2480
+ return _projection(target);
2254
2481
  }
2255
2482
 
2256
- // src/slice-builder.ts
2257
- function slice(states = /* @__PURE__ */ new Map(), actions = {}, events = {}, projections = []) {
2483
+ // src/builders/slice-builder.ts
2484
+ function slice() {
2485
+ const states = /* @__PURE__ */ new Map();
2486
+ const actions = {};
2487
+ const events = {};
2488
+ const projections = [];
2258
2489
  const builder = {
2259
2490
  withState: (state2) => {
2260
2491
  registerState(state2, states, actions, events);
2261
- return slice(
2262
- states,
2263
- actions,
2264
- events,
2265
- projections
2266
- );
2492
+ return builder;
2267
2493
  },
2268
2494
  withProjection: (proj) => {
2269
2495
  projections.push(proj);
2270
- return slice(
2271
- states,
2272
- actions,
2273
- events,
2274
- projections
2275
- );
2496
+ return builder;
2276
2497
  },
2277
2498
  on: (event) => ({
2278
2499
  do: (handler, options) => {
@@ -2289,16 +2510,12 @@ function slice(states = /* @__PURE__ */ new Map(), actions = {}, events = {}, pr
2289
2510
  `Reaction handler for "${String(event)}" must be a named function`
2290
2511
  );
2291
2512
  events[event].reactions.set(handler.name, reaction);
2292
- return {
2293
- ...builder,
2513
+ return Object.assign(builder, {
2294
2514
  to(resolver) {
2295
- events[event].reactions.set(handler.name, {
2296
- ...reaction,
2297
- resolver: typeof resolver === "string" ? { target: resolver } : resolver
2298
- });
2515
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2299
2516
  return builder;
2300
2517
  }
2301
- };
2518
+ });
2302
2519
  }
2303
2520
  }),
2304
2521
  build: () => ({
@@ -2312,7 +2529,7 @@ function slice(states = /* @__PURE__ */ new Map(), actions = {}, events = {}, pr
2312
2529
  return builder;
2313
2530
  }
2314
2531
 
2315
- // src/state-builder.ts
2532
+ // src/builders/state-builder.ts
2316
2533
  function state(entry) {
2317
2534
  const keys = Object.keys(entry);
2318
2535
  if (keys.length !== 1) throw new Error("state() requires exactly one key");
@@ -2330,7 +2547,7 @@ function state(entry) {
2330
2547
  return [k, fn];
2331
2548
  })
2332
2549
  );
2333
- const builder = action_builder({
2550
+ const internal = {
2334
2551
  events,
2335
2552
  actions: {},
2336
2553
  state: stateSchema,
@@ -2338,18 +2555,12 @@ function state(entry) {
2338
2555
  init,
2339
2556
  patch: defaultPatch,
2340
2557
  on: {}
2341
- });
2558
+ };
2559
+ const builder = action_builder(internal);
2342
2560
  return Object.assign(builder, {
2343
2561
  patch(customPatch) {
2344
- return action_builder({
2345
- events,
2346
- actions: {},
2347
- state: stateSchema,
2348
- name,
2349
- init,
2350
- patch: { ...defaultPatch, ...customPatch },
2351
- on: {}
2352
- });
2562
+ Object.assign(internal.patch, customPatch);
2563
+ return builder;
2353
2564
  }
2354
2565
  });
2355
2566
  }
@@ -2358,50 +2569,43 @@ function state(entry) {
2358
2569
  };
2359
2570
  }
2360
2571
  function action_builder(state2) {
2361
- return {
2572
+ const internal = state2;
2573
+ const builder = {
2362
2574
  on(entry) {
2363
2575
  const keys = Object.keys(entry);
2364
2576
  if (keys.length !== 1) throw new Error(".on() requires exactly one key");
2365
2577
  const action2 = keys[0];
2366
2578
  const schema = entry[action2];
2367
- if (action2 in state2.actions)
2579
+ if (action2 in internal.actions)
2368
2580
  throw new Error(`Duplicate action "${action2}"`);
2369
- const actions = {
2370
- ...state2.actions,
2371
- [action2]: schema
2372
- };
2373
- const on = { ...state2.on };
2374
- const _given = { ...state2.given };
2581
+ internal.actions[action2] = schema;
2375
2582
  function given(rules) {
2376
- _given[action2] = rules;
2583
+ (internal.given ??= {})[action2] = rules;
2377
2584
  return { emit };
2378
2585
  }
2379
2586
  function emit(handler) {
2380
2587
  if (typeof handler === "string") {
2381
2588
  const eventName = handler;
2382
- on[action2] = ((payload) => [eventName, payload]);
2589
+ internal.on[action2] = (payload) => [
2590
+ eventName,
2591
+ payload
2592
+ ];
2383
2593
  } else {
2384
- on[action2] = handler;
2594
+ internal.on[action2] = handler;
2385
2595
  }
2386
- return action_builder({
2387
- ...state2,
2388
- actions,
2389
- on,
2390
- given: _given
2391
- });
2596
+ return builder;
2392
2597
  }
2393
2598
  return { given, emit };
2394
2599
  },
2395
2600
  snap(snap2) {
2396
- return action_builder({
2397
- ...state2,
2398
- snap: snap2
2399
- });
2601
+ internal.snap = snap2;
2602
+ return builder;
2400
2603
  },
2401
2604
  build() {
2402
- return state2;
2605
+ return internal;
2403
2606
  }
2404
2607
  };
2608
+ return builder;
2405
2609
  }
2406
2610
  export {
2407
2611
  Act,
@@ -2410,6 +2614,7 @@ export {
2410
2614
  CommittedMetaSchema,
2411
2615
  ConcurrencyError,
2412
2616
  ConsoleLogger,
2617
+ DEFAULT_MAX_SUBSCRIBED_STREAMS,
2413
2618
  Environments,
2414
2619
  Errors,
2415
2620
  EventMetaSchema,