@rotorsoft/act 0.32.3 → 0.32.5

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 (46) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +71 -47
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/adapters/InMemoryCache.d.ts +1 -2
  5. package/dist/@types/adapters/InMemoryCache.d.ts.map +1 -1
  6. package/dist/@types/{act-builder.d.ts → builders/act-builder.d.ts} +5 -5
  7. package/dist/@types/builders/act-builder.d.ts.map +1 -0
  8. package/dist/@types/builders/index.d.ts +13 -0
  9. package/dist/@types/builders/index.d.ts.map +1 -0
  10. package/dist/@types/{projection-builder.d.ts → builders/projection-builder.d.ts} +3 -3
  11. package/dist/@types/builders/projection-builder.d.ts.map +1 -0
  12. package/dist/@types/{slice-builder.d.ts → builders/slice-builder.d.ts} +2 -2
  13. package/dist/@types/builders/slice-builder.d.ts.map +1 -0
  14. package/dist/@types/{state-builder.d.ts → builders/state-builder.d.ts} +1 -1
  15. package/dist/@types/builders/state-builder.d.ts.map +1 -0
  16. package/dist/@types/config.d.ts.map +1 -1
  17. package/dist/@types/index.d.ts +1 -4
  18. package/dist/@types/index.d.ts.map +1 -1
  19. package/dist/@types/internal/close-cycle.d.ts +38 -0
  20. package/dist/@types/internal/close-cycle.d.ts.map +1 -0
  21. package/dist/@types/internal/drain-cycle.d.ts +61 -0
  22. package/dist/@types/internal/drain-cycle.d.ts.map +1 -0
  23. package/dist/@types/internal/drain-ratio.d.ts +26 -0
  24. package/dist/@types/internal/drain-ratio.d.ts.map +1 -0
  25. package/dist/@types/internal/event-sourcing.d.ts +14 -0
  26. package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
  27. package/dist/@types/internal/index.d.ts +5 -1
  28. package/dist/@types/internal/index.d.ts.map +1 -1
  29. package/dist/@types/internal/lru-map.d.ts +50 -0
  30. package/dist/@types/internal/lru-map.d.ts.map +1 -0
  31. package/dist/@types/internal/merge.d.ts +13 -1
  32. package/dist/@types/internal/merge.d.ts.map +1 -1
  33. package/dist/@types/internal/tracing.d.ts +9 -2
  34. package/dist/@types/internal/tracing.d.ts.map +1 -1
  35. package/dist/@types/ports.d.ts.map +1 -1
  36. package/dist/@types/types/reaction.d.ts +7 -1
  37. package/dist/@types/types/reaction.d.ts.map +1 -1
  38. package/dist/index.cjs +581 -416
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.js +580 -416
  41. package/dist/index.js.map +1 -1
  42. package/package.json +1 -1
  43. package/dist/@types/act-builder.d.ts.map +0 -1
  44. package/dist/@types/projection-builder.d.ts.map +0 -1
  45. package/dist/@types/slice-builder.d.ts.map +0 -1
  46. 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,
@@ -171,26 +172,75 @@ var ConsoleLogger = class _ConsoleLogger {
171
172
  }
172
173
  };
173
174
 
175
+ // src/internal/lru-map.ts
176
+ var LruMap = class {
177
+ constructor(_maxSize) {
178
+ this._maxSize = _maxSize;
179
+ }
180
+ _entries = /* @__PURE__ */ new Map();
181
+ get(key) {
182
+ const v = this._entries.get(key);
183
+ if (v === void 0) return void 0;
184
+ this._entries.delete(key);
185
+ this._entries.set(key, v);
186
+ return v;
187
+ }
188
+ has(key) {
189
+ return this._entries.has(key);
190
+ }
191
+ set(key, value) {
192
+ this._entries.delete(key);
193
+ if (this._entries.size >= this._maxSize) {
194
+ const oldest = this._entries.keys().next().value;
195
+ if (oldest !== void 0) this._entries.delete(oldest);
196
+ }
197
+ this._entries.set(key, value);
198
+ }
199
+ delete(key) {
200
+ return this._entries.delete(key);
201
+ }
202
+ clear() {
203
+ this._entries.clear();
204
+ }
205
+ get size() {
206
+ return this._entries.size;
207
+ }
208
+ };
209
+ var LruSet = class {
210
+ _map;
211
+ constructor(maxSize) {
212
+ this._map = new LruMap(maxSize);
213
+ }
214
+ has(value) {
215
+ return this._map.has(value);
216
+ }
217
+ add(value) {
218
+ this._map.set(value, true);
219
+ }
220
+ delete(value) {
221
+ return this._map.delete(value);
222
+ }
223
+ clear() {
224
+ this._map.clear();
225
+ }
226
+ get size() {
227
+ return this._map.size;
228
+ }
229
+ };
230
+
174
231
  // src/adapters/InMemoryCache.ts
175
232
  var InMemoryCache = class {
176
- _entries = /* @__PURE__ */ new Map();
177
- _maxSize;
233
+ // CacheEntry<any> lets `get<TState>` and `set<TState>` flow without casts:
234
+ // any is bidirectionally compatible with the per-call TState binding, while
235
+ // the public Cache interface still presents a typed surface to callers.
236
+ _entries;
178
237
  constructor(options) {
179
- this._maxSize = options?.maxSize ?? 1e3;
238
+ this._entries = new LruMap(options?.maxSize ?? 1e3);
180
239
  }
181
240
  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;
241
+ return this._entries.get(stream);
187
242
  }
188
243
  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
244
  this._entries.set(stream, entry);
195
245
  }
196
246
  async invalidate(stream) {
@@ -345,12 +395,19 @@ var BaseSchema = PackageSchema.extend({
345
395
  });
346
396
  var { NODE_ENV, LOG_LEVEL, LOG_SINGLE_LINE, SLEEP_MS } = process.env;
347
397
  var env = NODE_ENV || "development";
348
- var logLevel = LOG_LEVEL || (NODE_ENV === "test" ? "error" : NODE_ENV === "production" ? "info" : "trace");
398
+ var logLevel = LOG_LEVEL || (NODE_ENV === "test" ? "fatal" : NODE_ENV === "production" ? "info" : "trace");
349
399
  var logSingleLine = (LOG_SINGLE_LINE || "true") === "true";
350
400
  var sleepMs = parseInt(NODE_ENV === "test" ? "0" : SLEEP_MS ?? "100", 10);
351
401
  var pkg = getPackage();
402
+ var _validated;
352
403
  var config = () => {
353
- return extend({ ...pkg, env, logLevel, logSingleLine, sleepMs }, BaseSchema);
404
+ if (!_validated) {
405
+ _validated = extend(
406
+ { ...pkg, env, logLevel, logSingleLine, sleepMs },
407
+ BaseSchema
408
+ );
409
+ }
410
+ return _validated;
354
411
  };
355
412
 
356
413
  // src/utils.ts
@@ -773,7 +830,7 @@ function port(injector) {
773
830
  if (!adapters.has(injector.name)) {
774
831
  const injected = injector(adapter);
775
832
  adapters.set(injector.name, injected);
776
- console.log(`[act] + ${injector.name}:${injected.constructor.name}`);
833
+ log().info(`[act] + ${injector.name}:${injected.constructor.name}`);
777
834
  }
778
835
  return adapters.get(injector.name);
779
836
  };
@@ -799,7 +856,7 @@ async function disposeAndExit(code = "EXIT") {
799
856
  }
800
857
  for (const adapter of [...adapters.values()].reverse()) {
801
858
  await adapter.dispose();
802
- console.log(`[act] - ${adapter.constructor.name}`);
859
+ log().info(`[act] - ${adapter.constructor.name}`);
803
860
  }
804
861
  adapters.clear();
805
862
  config().env !== "test" && process.exit(code === "ERROR" ? 1 : 0);
@@ -830,9 +887,233 @@ process.once("unhandledRejection", async (arg) => {
830
887
  });
831
888
 
832
889
  // src/act.ts
833
- var import_crypto2 = require("crypto");
834
890
  var import_events = __toESM(require("events"), 1);
835
891
 
892
+ // src/internal/close-cycle.ts
893
+ var import_crypto = require("crypto");
894
+ async function runCloseCycle(targets, deps) {
895
+ if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
896
+ const targetMap = new Map(targets.map((t) => [t.stream, t]));
897
+ const streams = [...targetMap.keys()];
898
+ const skipped = [];
899
+ const streamInfo = await scanStreamHeads(streams);
900
+ const safe = await partitionBySafety(
901
+ streamInfo,
902
+ deps.reactiveEventsSize,
903
+ skipped
904
+ );
905
+ if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
906
+ const correlation = (0, import_crypto.randomUUID)();
907
+ const { guarded, guardEvents } = await guardWithTombstones(
908
+ safe,
909
+ streamInfo,
910
+ correlation,
911
+ deps.tombstone,
912
+ skipped
913
+ );
914
+ if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
915
+ const seedStates = await loadRestartSeeds(
916
+ guarded,
917
+ targetMap,
918
+ streamInfo,
919
+ deps.eventToState,
920
+ deps.load,
921
+ deps.logger
922
+ );
923
+ await runArchiveCallbacks(guarded, targetMap);
924
+ const truncated = await truncateAndWarmCache(
925
+ guarded,
926
+ seedStates,
927
+ guardEvents,
928
+ correlation
929
+ );
930
+ return { truncated, skipped };
931
+ }
932
+ async function scanStreamHeads(streams) {
933
+ const out = /* @__PURE__ */ new Map();
934
+ await Promise.all(
935
+ streams.map(async (s) => {
936
+ let maxId = -1;
937
+ let version = -1;
938
+ let lastEventName;
939
+ await store().query(
940
+ (e) => {
941
+ if (e.name === TOMBSTONE_EVENT) return;
942
+ if (maxId === -1) {
943
+ maxId = e.id;
944
+ version = e.version;
945
+ }
946
+ if (e.name !== SNAP_EVENT && lastEventName === void 0) {
947
+ lastEventName = e.name;
948
+ }
949
+ },
950
+ // limit: 2 covers the typical snapshot-at-head case (snapshot is
951
+ // always preceded by the domain event it captured). Streams with
952
+ // unusual layouts fall back to no-seed via the lookup miss path.
953
+ { stream: s, stream_exact: true, backward: true, limit: 2 }
954
+ );
955
+ if (maxId >= 0) out.set(s, { maxId, version, lastEventName });
956
+ })
957
+ );
958
+ return out;
959
+ }
960
+ async function partitionBySafety(streamInfo, reactiveEventsSize, skipped) {
961
+ if (reactiveEventsSize === 0) return [...streamInfo.keys()];
962
+ const pendingSet = /* @__PURE__ */ new Set();
963
+ await store().query_streams((position) => {
964
+ const sourceRe = position.source ? RegExp(position.source) : void 0;
965
+ for (const [stream, info] of streamInfo) {
966
+ if ((!sourceRe || sourceRe.test(stream)) && position.at < info.maxId) {
967
+ pendingSet.add(stream);
968
+ }
969
+ }
970
+ });
971
+ const safe = [];
972
+ for (const [stream] of streamInfo) {
973
+ if (pendingSet.has(stream)) skipped.push(stream);
974
+ else safe.push(stream);
975
+ }
976
+ return safe;
977
+ }
978
+ async function guardWithTombstones(safe, streamInfo, correlation, tombstone2, skipped) {
979
+ const guarded = [];
980
+ const guardEvents = /* @__PURE__ */ new Map();
981
+ await Promise.all(
982
+ safe.map(async (stream) => {
983
+ const info = streamInfo.get(stream);
984
+ const committed = await tombstone2(stream, info.version, correlation);
985
+ if (committed) {
986
+ guarded.push(stream);
987
+ guardEvents.set(stream, { id: committed.id, stream });
988
+ } else {
989
+ skipped.push(stream);
990
+ }
991
+ })
992
+ );
993
+ return { guarded, guardEvents };
994
+ }
995
+ async function loadRestartSeeds(guarded, targetMap, streamInfo, eventToState, load2, logger) {
996
+ const seedStates = /* @__PURE__ */ new Map();
997
+ await Promise.all(
998
+ guarded.filter((s) => targetMap.get(s)?.restart).map(async (stream) => {
999
+ const lastEventName = streamInfo.get(stream)?.lastEventName;
1000
+ const ownerState = lastEventName ? eventToState.get(lastEventName) : void 0;
1001
+ if (!ownerState) {
1002
+ logger.error(
1003
+ `Cannot seed restart for "${stream}": no registered state owns event "${lastEventName ?? "<none>"}". Stream will be tombstoned instead.`
1004
+ );
1005
+ return;
1006
+ }
1007
+ const snap2 = await load2(ownerState, stream);
1008
+ seedStates.set(stream, snap2.state);
1009
+ })
1010
+ );
1011
+ return seedStates;
1012
+ }
1013
+ async function runArchiveCallbacks(guarded, targetMap) {
1014
+ for (const stream of guarded) {
1015
+ const archiveFn = targetMap.get(stream)?.archive;
1016
+ if (archiveFn) await archiveFn();
1017
+ }
1018
+ }
1019
+ async function truncateAndWarmCache(guarded, seedStates, guardEvents, correlation) {
1020
+ const truncTargets = guarded.map((stream) => {
1021
+ const snapshot = seedStates.get(stream);
1022
+ const guard = guardEvents.get(stream);
1023
+ return {
1024
+ stream,
1025
+ snapshot,
1026
+ meta: {
1027
+ correlation,
1028
+ causation: {
1029
+ event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
1030
+ }
1031
+ }
1032
+ };
1033
+ });
1034
+ const truncated = await store().truncate(truncTargets);
1035
+ await Promise.all(
1036
+ guarded.map(async (stream) => {
1037
+ const entry = truncated.get(stream);
1038
+ const state2 = seedStates.get(stream);
1039
+ if (state2 && entry) {
1040
+ await cache().set(stream, {
1041
+ state: state2,
1042
+ version: entry.committed.version,
1043
+ event_id: entry.committed.id,
1044
+ patches: 0,
1045
+ snaps: 1
1046
+ });
1047
+ } else {
1048
+ await cache().invalidate(stream);
1049
+ }
1050
+ })
1051
+ );
1052
+ return truncated;
1053
+ }
1054
+
1055
+ // src/internal/drain-cycle.ts
1056
+ var import_crypto2 = require("crypto");
1057
+ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis) {
1058
+ const leased = await ops.claim(lagging, leading, (0, import_crypto2.randomUUID)(), leaseMillis);
1059
+ if (!leased.length) return void 0;
1060
+ const fetched = await ops.fetch(leased, eventLimit);
1061
+ const fetchMap = /* @__PURE__ */ new Map();
1062
+ const fetch_window_at = fetched.reduce(
1063
+ (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
1064
+ 0
1065
+ );
1066
+ for (const f of fetched) {
1067
+ const { stream, events } = f;
1068
+ const payloads = events.flatMap((event) => {
1069
+ const register = registry.events[event.name];
1070
+ if (!register) return [];
1071
+ return [...register.reactions.values()].filter((reaction) => {
1072
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1073
+ return resolved && resolved.target === stream;
1074
+ }).map((reaction) => ({ ...reaction, event }));
1075
+ });
1076
+ fetchMap.set(stream, { fetch: f, payloads });
1077
+ }
1078
+ const handled = await Promise.all(
1079
+ leased.map((lease) => {
1080
+ const entry = fetchMap.get(lease.stream);
1081
+ const at = entry?.fetch.events.at(-1)?.id || fetch_window_at;
1082
+ const payloads = entry?.payloads ?? [];
1083
+ const batchHandler = batchHandlers.get(lease.stream);
1084
+ if (batchHandler && payloads.length > 0) {
1085
+ return handleBatch({ ...lease, at }, payloads, batchHandler);
1086
+ }
1087
+ return handle({ ...lease, at }, payloads);
1088
+ })
1089
+ );
1090
+ const acked = await ops.ack(
1091
+ handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
1092
+ );
1093
+ const blocked = await ops.block(
1094
+ handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1095
+ );
1096
+ return { leased, fetched, handled, acked, blocked };
1097
+ }
1098
+
1099
+ // src/internal/drain-ratio.ts
1100
+ var RATIO_MIN = 0.2;
1101
+ var RATIO_MAX = 0.8;
1102
+ var RATIO_DEFAULT = 0.5;
1103
+ function computeLagLeadRatio(handled, lagging, leading) {
1104
+ let lagging_handled = 0;
1105
+ let leading_handled = 0;
1106
+ for (const { lease, handled: count } of handled) {
1107
+ if (lease.lagging) lagging_handled += count;
1108
+ else leading_handled += count;
1109
+ }
1110
+ const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1111
+ const leading_avg = leading > 0 ? leading_handled / leading : 0;
1112
+ const total = lagging_avg + leading_avg;
1113
+ if (total === 0) return RATIO_DEFAULT;
1114
+ return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1115
+ }
1116
+
836
1117
  // src/internal/merge.ts
837
1118
  var import_zod4 = require("zod");
838
1119
  function baseTypeName(zodType) {
@@ -940,6 +1221,15 @@ function mergePatches(existing, incoming, stateName) {
940
1221
  }
941
1222
  return merged;
942
1223
  }
1224
+ function mergeEventRegister(target, source) {
1225
+ for (const [eventName, sourceReg] of Object.entries(source)) {
1226
+ const targetReg = target[eventName];
1227
+ if (!targetReg) continue;
1228
+ for (const [name, reaction] of sourceReg.reactions) {
1229
+ targetReg.reactions.set(name, reaction);
1230
+ }
1231
+ }
1232
+ }
943
1233
  function mergeProjection(proj, events) {
944
1234
  for (const eventName of Object.keys(proj.events)) {
945
1235
  const projRegister = proj.events[eventName];
@@ -984,7 +1274,7 @@ var subscribe = (streams) => store().subscribe(streams);
984
1274
 
985
1275
  // src/internal/event-sourcing.ts
986
1276
  var import_act_patch = require("@rotorsoft/act-patch");
987
- var import_crypto = require("crypto");
1277
+ var import_crypto3 = require("crypto");
988
1278
  async function snap(snapshot) {
989
1279
  try {
990
1280
  const { id, stream, name, meta, version } = snapshot.event;
@@ -1002,6 +1292,20 @@ async function snap(snapshot) {
1002
1292
  log().error(error);
1003
1293
  }
1004
1294
  }
1295
+ async function tombstone(stream, expectedVersion, correlation) {
1296
+ try {
1297
+ const [committed] = await store().commit(
1298
+ stream,
1299
+ [{ name: TOMBSTONE_EVENT, data: {} }],
1300
+ { correlation, causation: {} },
1301
+ expectedVersion
1302
+ );
1303
+ return committed;
1304
+ } catch (error) {
1305
+ if (error instanceof ConcurrencyError) return void 0;
1306
+ throw error;
1307
+ }
1308
+ }
1005
1309
  async function load(me, stream, callback, asOf) {
1006
1310
  const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
1007
1311
  const cached = timeTravel ? void 0 : await cache().get(stream);
@@ -1066,7 +1370,7 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
1066
1370
  data: skipValidation ? data : validate(name, data, me.events[name])
1067
1371
  }));
1068
1372
  const meta = {
1069
- correlation: reactingTo?.meta.correlation || (0, import_crypto.randomUUID)(),
1373
+ correlation: reactingTo?.meta.correlation || (0, import_crypto3.randomUUID)(),
1070
1374
  causation: {
1071
1375
  action: {
1072
1376
  name: action2,
@@ -1117,6 +1421,18 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
1117
1421
  }
1118
1422
 
1119
1423
  // src/internal/tracing.ts
1424
+ var PRETTY = config().env !== "production";
1425
+ var C_BLUE = "\x1B[38;5;39m";
1426
+ var C_ORANGE = "\x1B[38;5;208m";
1427
+ var C_GREEN = "\x1B[38;5;42m";
1428
+ var C_MAGENTA = "\x1B[38;5;165m";
1429
+ var C_DRAIN = "\x1B[38;5;244m";
1430
+ var C_RESET = "\x1B[0m";
1431
+ var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
1432
+ var drain_caption = (caption) => {
1433
+ const tag = `>> ${caption}`;
1434
+ return PRETTY ? `${C_DRAIN}${tag}${C_RESET}` : tag;
1435
+ };
1120
1436
  var traced = (inner, exit, entry) => (async (...args) => {
1121
1437
  entry?.(...args);
1122
1438
  const result = await inner(...args);
@@ -1125,16 +1441,27 @@ var traced = (inner, exit, entry) => (async (...args) => {
1125
1441
  });
1126
1442
  function buildEs(logger) {
1127
1443
  if (logger.level !== "trace") {
1128
- return { snap, load, action };
1444
+ return {
1445
+ snap,
1446
+ load,
1447
+ action,
1448
+ tombstone
1449
+ };
1129
1450
  }
1130
1451
  return {
1131
1452
  snap: traced(snap, void 0, (snapshot) => {
1132
1453
  logger.trace(
1133
- `\u{1F7E0} snap ${snapshot.event.stream}@${snapshot.event.version}`
1454
+ es_caption(
1455
+ "snap",
1456
+ C_MAGENTA,
1457
+ `${snapshot.event.stream}@${snapshot.event.version}`
1458
+ )
1134
1459
  );
1135
1460
  }),
1136
1461
  load: traced(load, void 0, (_me, stream, _cb, asOf) => {
1137
- logger.trace(`\u{1F7E2} load ${stream}${asOf ? " (as-of)" : ""}`);
1462
+ logger.trace(
1463
+ es_caption("load", C_GREEN, `${stream}${asOf ? " (as-of)" : ""}`)
1464
+ );
1138
1465
  }),
1139
1466
  action: traced(
1140
1467
  action,
@@ -1143,14 +1470,27 @@ function buildEs(logger) {
1143
1470
  if (committed.length) {
1144
1471
  logger.trace(
1145
1472
  committed.map((s) => s.event.data),
1146
- `\u{1F534} commit ${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
1473
+ es_caption(
1474
+ "committed",
1475
+ C_ORANGE,
1476
+ `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
1477
+ )
1147
1478
  );
1148
1479
  }
1149
1480
  },
1150
1481
  (_me, action2, target, payload) => {
1151
- logger.trace(payload, `\u{1F535} ${target.stream}.${action2}`);
1482
+ logger.trace(
1483
+ payload,
1484
+ es_caption("action", C_BLUE, `${target.stream}.${action2}`)
1485
+ );
1152
1486
  }
1153
- )
1487
+ ),
1488
+ tombstone: traced(tombstone, (committed, stream) => {
1489
+ if (committed)
1490
+ logger.trace(
1491
+ es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
1492
+ );
1493
+ })
1154
1494
  };
1155
1495
  }
1156
1496
  function buildDrain(logger) {
@@ -1169,7 +1509,7 @@ function buildDrain(logger) {
1169
1509
  const data = Object.fromEntries(
1170
1510
  leased.map(({ stream, at, retry }) => [stream, { at, retry }])
1171
1511
  );
1172
- logger.trace(data, ">> lease");
1512
+ logger.trace(data, drain_caption("claimed"));
1173
1513
  }
1174
1514
  }),
1175
1515
  fetch: traced(fetch, (fetched) => {
@@ -1182,14 +1522,14 @@ function buildDrain(logger) {
1182
1522
  return [key, value];
1183
1523
  })
1184
1524
  );
1185
- logger.trace(data, ">> fetch");
1525
+ logger.trace(data, drain_caption("fetched"));
1186
1526
  }),
1187
1527
  ack: traced(ack, (acked) => {
1188
1528
  if (acked.length) {
1189
1529
  const data = Object.fromEntries(
1190
1530
  acked.map(({ stream, at, retry }) => [stream, { at, retry }])
1191
1531
  );
1192
- logger.trace(data, ">> ack");
1532
+ logger.trace(data, drain_caption("acked"));
1193
1533
  }
1194
1534
  }),
1195
1535
  block: traced(block, (blocked) => {
@@ -1200,27 +1540,31 @@ function buildDrain(logger) {
1200
1540
  { at, retry, error }
1201
1541
  ])
1202
1542
  );
1203
- logger.trace(data, ">> block");
1543
+ logger.trace(data, drain_caption("blocked"));
1204
1544
  }
1205
1545
  }),
1206
1546
  subscribe: traced(subscribe, (result, streams) => {
1207
1547
  if (result.subscribed) {
1208
1548
  const data = streams.map(({ stream }) => stream).join(" ");
1209
- logger.trace(`>> correlate ${data}`);
1549
+ logger.trace(`${drain_caption("correlated")} ${data}`);
1210
1550
  }
1211
1551
  })
1212
1552
  };
1213
1553
  }
1214
1554
 
1215
1555
  // src/act.ts
1556
+ var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
1216
1557
  var Act = class {
1217
- constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map()) {
1558
+ constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
1218
1559
  this.registry = registry;
1219
1560
  this._states = _states;
1220
1561
  this._batch_handlers = batchHandlers;
1562
+ this._subscribed_streams = new LruSet(
1563
+ options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS
1564
+ );
1221
1565
  this._es = buildEs(this._logger);
1222
1566
  this._cd = buildDrain(this._logger);
1223
- const statics = [];
1567
+ const statics = /* @__PURE__ */ new Map();
1224
1568
  for (const [name, register] of Object.entries(this.registry.events)) {
1225
1569
  if (register.reactions.size > 0) {
1226
1570
  this._reactive_events.add(name);
@@ -1229,14 +1573,13 @@ var Act = class {
1229
1573
  if (typeof reaction.resolver === "function") {
1230
1574
  this._has_dynamic_resolvers = true;
1231
1575
  } else {
1232
- statics.push({
1233
- stream: reaction.resolver.target,
1234
- source: reaction.resolver.source
1235
- });
1576
+ const { target, source } = reaction.resolver;
1577
+ const key = `${target}|${source ?? ""}`;
1578
+ if (!statics.has(key)) statics.set(key, { stream: target, source });
1236
1579
  }
1237
1580
  }
1238
1581
  }
1239
- this._static_targets = statics;
1582
+ this._static_targets = [...statics.values()];
1240
1583
  for (const merged of this._states.values()) {
1241
1584
  for (const eventName of Object.keys(merged.events)) {
1242
1585
  this._event_to_state.set(eventName, merged);
@@ -1256,20 +1599,42 @@ var Act = class {
1256
1599
  _settle_timer = void 0;
1257
1600
  _settling = false;
1258
1601
  _correlation_checkpoint = -1;
1259
- _subscribed_statics = /* @__PURE__ */ new Set();
1602
+ /**
1603
+ * Streams already subscribed via store.subscribe() — both the static
1604
+ * targets registered at init and dynamic targets discovered by
1605
+ * correlate(). correlate() consults this set to avoid re-subscribing
1606
+ * known streams.
1607
+ *
1608
+ * Bounded LRU so apps that mint millions of dynamic targets (one per
1609
+ * aggregate) don't grow this unbounded. Eviction costs at most one
1610
+ * redundant store.subscribe() call per evicted-but-still-active stream
1611
+ * (subscribe is idempotent). Cap configurable via {@link ActOptions}.
1612
+ */
1613
+ _subscribed_streams;
1260
1614
  _has_dynamic_resolvers = false;
1261
1615
  _correlation_initialized = false;
1262
1616
  /** Event names with at least one registered reaction (computed at build time) */
1263
1617
  _reactive_events = /* @__PURE__ */ new Set();
1264
1618
  /** Set in do() when a committed event has reactions — cleared by drain() */
1265
1619
  _needs_drain = false;
1620
+ /**
1621
+ * Emit a lifecycle event. The payload type is inferred from the event name
1622
+ * via {@link ActLifecycleEvents}.
1623
+ */
1266
1624
  emit(event, args) {
1267
1625
  return this._emitter.emit(event, args);
1268
1626
  }
1627
+ /**
1628
+ * Register a listener for a lifecycle event. The listener receives the
1629
+ * event-specific payload.
1630
+ */
1269
1631
  on(event, listener) {
1270
1632
  this._emitter.on(event, listener);
1271
1633
  return this;
1272
1634
  }
1635
+ /**
1636
+ * Remove a previously registered lifecycle listener.
1637
+ */
1273
1638
  off(event, listener) {
1274
1639
  this._emitter.off(event, listener);
1275
1640
  return this;
@@ -1297,6 +1662,15 @@ var Act = class {
1297
1662
  _event_to_state = /* @__PURE__ */ new Map();
1298
1663
  /** Logger resolved at construction time (after user port configuration) */
1299
1664
  _logger = log();
1665
+ /** Pre-bound IAct methods reused across drain cycles. Only `do` varies per
1666
+ * payload (it captures the triggering event for reactingTo auto-inject). */
1667
+ _bound_do = this.do.bind(this);
1668
+ _bound_load = this.load.bind(this);
1669
+ _bound_query = this.query.bind(this);
1670
+ _bound_query_array = this.query_array.bind(this);
1671
+ /** Pre-bound dispatchers handed to runDrainCycle each cycle. */
1672
+ _bound_handle = this.handle.bind(this);
1673
+ _bound_handle_batch = this.handleBatch.bind(this);
1300
1674
  /**
1301
1675
  * Executes an action on a state instance, committing resulting events.
1302
1676
  *
@@ -1499,35 +1873,55 @@ var Act = class {
1499
1873
  return events;
1500
1874
  }
1501
1875
  /**
1502
- * Handles leased reactions.
1503
- *
1504
- * This is called by the main `drain` loop after fetching new events.
1505
- * It handles reactions, supporting retries, blocking, and error handling.
1876
+ * Shared finalization for the two reaction-runner shapes (per-event
1877
+ * `handle` and bulk `handleBatch`). Centralizes the error log, retry-vs-
1878
+ * block decision, and the "error reported only when nothing was handled"
1879
+ * rule that's true in both shapes (in batch mode, `handled` is always 0
1880
+ * on failure, so the rule degenerates to "always reported").
1881
+ */
1882
+ _finalize(lease, handled, at, error, options) {
1883
+ if (!error) return { lease, handled, at };
1884
+ this._logger.error(error);
1885
+ const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1886
+ if (block2)
1887
+ this._logger.error(
1888
+ `Blocking ${lease.stream} after ${lease.retry} retries.`
1889
+ );
1890
+ return {
1891
+ lease,
1892
+ handled,
1893
+ at,
1894
+ error: handled === 0 ? error.message : void 0,
1895
+ block: block2
1896
+ };
1897
+ }
1898
+ /**
1899
+ * Handles leased reactions one event at a time.
1506
1900
  *
1507
- * Each handler receives a scoped `IAct` proxy that auto-injects the
1508
- * triggering event as `reactingTo` when `do()` is called without it,
1509
- * maintaining correlation chains by default (#587). Handlers can still
1510
- * pass an explicit `reactingTo` to override this behavior.
1901
+ * Called by the main `drain` loop after fetching new events. Each handler
1902
+ * receives a scoped `IAct` proxy that auto-injects the triggering event
1903
+ * as `reactingTo` when `do()` is called without it, maintaining
1904
+ * correlation chains by default (#587). Handlers can still pass an
1905
+ * explicit `reactingTo` to override.
1511
1906
  *
1512
1907
  * @internal
1513
- * @param lease The lease to handle
1514
- * @param payloads The reactions to handle
1515
- * @returns The lease with results
1516
1908
  */
1517
1909
  async handle(lease, payloads) {
1518
1910
  if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
1519
1911
  const stream = lease.stream;
1520
- let at = payloads.at(0).event.id, handled = 0;
1521
- lease.retry > 0 && this._logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1522
- const doAction = this.do.bind(this);
1912
+ let at = payloads.at(0).event.id;
1913
+ let handled = 0;
1914
+ if (lease.retry > 0)
1915
+ this._logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1916
+ const doAction = this._bound_do;
1523
1917
  const scopedApp = {
1524
1918
  do: doAction,
1525
- load: this.load.bind(this),
1526
- query: this.query.bind(this),
1527
- query_array: this.query_array.bind(this)
1919
+ load: this._bound_load,
1920
+ query: this._bound_query,
1921
+ query_array: this._bound_query_array
1528
1922
  };
1529
1923
  for (const payload of payloads) {
1530
- const { event, handler, options } = payload;
1924
+ const { event, handler } = payload;
1531
1925
  scopedApp.do = (action2, target, payload2, reactingTo, skipValidation) => doAction(
1532
1926
  action2,
1533
1927
  target,
@@ -1540,22 +1934,16 @@ var Act = class {
1540
1934
  at = event.id;
1541
1935
  handled++;
1542
1936
  } catch (error) {
1543
- this._logger.error(error);
1544
- const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1545
- block2 && this._logger.error(
1546
- `Blocking ${stream} after ${lease.retry} retries.`
1547
- );
1548
- return {
1937
+ return this._finalize(
1549
1938
  lease,
1550
1939
  handled,
1551
1940
  at,
1552
- // only report error when nothing was handled
1553
- error: handled === 0 ? error.message : void 0,
1554
- block: block2
1555
- };
1941
+ error,
1942
+ payload.options
1943
+ );
1556
1944
  }
1557
1945
  }
1558
- return { lease, handled, at };
1946
+ return this._finalize(lease, handled, at, void 0, payloads[0].options);
1559
1947
  }
1560
1948
  /**
1561
1949
  * Handles a batch of events for a projection with a batch handler.
@@ -1565,33 +1953,26 @@ var Act = class {
1565
1953
  * in a single call, enabling bulk DB operations.
1566
1954
  *
1567
1955
  * @internal
1568
- * @param lease The lease to handle
1569
- * @param payloads The reactions to handle
1570
- * @param batchHandler The batch handler for this projection
1571
- * @returns The lease with results
1572
1956
  */
1573
1957
  async handleBatch(lease, payloads, batchHandler) {
1574
1958
  const stream = lease.stream;
1575
1959
  const events = payloads.map((p) => p.event);
1576
- const at = events.at(-1).id;
1577
- lease.retry > 0 && this._logger.warn(
1578
- `Retrying batch ${stream}@${events[0].id} (${lease.retry}).`
1579
- );
1960
+ const options = payloads[0].options;
1961
+ if (lease.retry > 0)
1962
+ this._logger.warn(
1963
+ `Retrying batch ${stream}@${events[0].id} (${lease.retry}).`
1964
+ );
1580
1965
  try {
1581
1966
  await batchHandler(events, stream);
1582
- return { lease, handled: events.length, at };
1583
- } catch (error) {
1584
- this._logger.error(error);
1585
- const { options } = payloads[0];
1586
- const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1587
- block2 && this._logger.error(`Blocking ${stream} after ${lease.retry} retries.`);
1588
- return {
1967
+ return this._finalize(
1589
1968
  lease,
1590
- handled: 0,
1591
- at: lease.at,
1592
- error: error.message,
1593
- block: block2
1594
- };
1969
+ events.length,
1970
+ events.at(-1).id,
1971
+ void 0,
1972
+ options
1973
+ );
1974
+ } catch (error) {
1975
+ return this._finalize(lease, 0, lease.at, error, options);
1595
1976
  }
1596
1977
  }
1597
1978
  /**
@@ -1641,81 +2022,46 @@ var Act = class {
1641
2022
  if (!this._needs_drain) {
1642
2023
  return { fetched: [], leased: [], acked: [], blocked: [] };
1643
2024
  }
1644
- if (!this._drain_locked) {
1645
- try {
1646
- this._drain_locked = true;
1647
- const lagging = Math.ceil(streamLimit * this._drain_lag2lead_ratio);
1648
- const leading = streamLimit - lagging;
1649
- const leased = await this._cd.claim(
1650
- lagging,
1651
- leading,
1652
- (0, import_crypto2.randomUUID)(),
1653
- leaseMillis
1654
- );
1655
- if (!leased.length) {
1656
- this._needs_drain = false;
1657
- return { fetched: [], leased: [], acked: [], blocked: [] };
1658
- }
1659
- const fetched = await this._cd.fetch(leased, eventLimit);
1660
- const payloadsMap = /* @__PURE__ */ new Map();
1661
- const fetch_window_at = fetched.reduce(
1662
- (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
1663
- 0
1664
- );
1665
- fetched.forEach(({ stream, events }) => {
1666
- const payloads = events.flatMap((event) => {
1667
- const register = this.registry.events[event.name];
1668
- if (!register) return [];
1669
- return [...register.reactions.values()].filter((reaction) => {
1670
- const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1671
- return resolved && resolved.target === stream;
1672
- }).map((reaction) => ({ ...reaction, event }));
1673
- });
1674
- payloadsMap.set(stream, payloads);
1675
- });
1676
- const handled = await Promise.all(
1677
- leased.map((lease) => {
1678
- const streamFetch = fetched.find((f) => f.stream === lease.stream);
1679
- const at = streamFetch?.events.at(-1)?.id || fetch_window_at;
1680
- const payloads = payloadsMap.get(lease.stream);
1681
- const batchHandler = this._batch_handlers.get(lease.stream);
1682
- if (batchHandler && payloads.length > 0) {
1683
- return this.handleBatch({ ...lease, at }, payloads, batchHandler);
1684
- }
1685
- return this.handle({ ...lease, at }, payloads);
1686
- })
1687
- );
1688
- const [lagging_handled, leading_handled] = handled.reduce(
1689
- ([lagging_handled2, leading_handled2], { lease, handled: handled2 }) => [
1690
- lagging_handled2 + (lease.lagging ? handled2 : 0),
1691
- leading_handled2 + (lease.lagging ? 0 : handled2)
1692
- ],
1693
- [0, 0]
1694
- );
1695
- const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1696
- const leading_avg = leading > 0 ? leading_handled / leading : 0;
1697
- const total = lagging_avg + leading_avg;
1698
- this._drain_lag2lead_ratio = total > 0 ? Math.max(0.2, Math.min(0.8, lagging_avg / total)) : 0.5;
1699
- const acked = await this._cd.ack(
1700
- handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
1701
- );
1702
- if (acked.length) this.emit("acked", acked);
1703
- const blocked = await this._cd.block(
1704
- handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1705
- );
1706
- if (blocked.length) this.emit("blocked", blocked);
1707
- const result = { fetched, leased, acked, blocked };
1708
- const hasErrors = handled.some(({ error }) => error);
1709
- if (!acked.length && !blocked.length && !hasErrors)
1710
- this._needs_drain = false;
1711
- return result;
1712
- } catch (error) {
1713
- this._logger.error(error);
1714
- } finally {
1715
- this._drain_locked = false;
2025
+ if (this._drain_locked) {
2026
+ return { fetched: [], leased: [], acked: [], blocked: [] };
2027
+ }
2028
+ try {
2029
+ this._drain_locked = true;
2030
+ const lagging = Math.ceil(streamLimit * this._drain_lag2lead_ratio);
2031
+ const leading = streamLimit - lagging;
2032
+ const cycle = await runDrainCycle(
2033
+ this._cd,
2034
+ this.registry,
2035
+ this._batch_handlers,
2036
+ this._bound_handle,
2037
+ this._bound_handle_batch,
2038
+ lagging,
2039
+ leading,
2040
+ eventLimit,
2041
+ leaseMillis
2042
+ );
2043
+ if (!cycle) {
2044
+ this._needs_drain = false;
2045
+ return { fetched: [], leased: [], acked: [], blocked: [] };
1716
2046
  }
2047
+ const { leased, fetched, handled, acked, blocked } = cycle;
2048
+ this._drain_lag2lead_ratio = computeLagLeadRatio(
2049
+ handled,
2050
+ lagging,
2051
+ leading
2052
+ );
2053
+ if (acked.length) this.emit("acked", acked);
2054
+ if (blocked.length) this.emit("blocked", blocked);
2055
+ const hasErrors = handled.some(({ error }) => error);
2056
+ if (!acked.length && !blocked.length && !hasErrors)
2057
+ this._needs_drain = false;
2058
+ return { fetched, leased, acked, blocked };
2059
+ } catch (error) {
2060
+ this._logger.error(error);
2061
+ return { fetched: [], leased: [], acked: [], blocked: [] };
2062
+ } finally {
2063
+ this._drain_locked = false;
1717
2064
  }
1718
- return { fetched: [], leased: [], acked: [], blocked: [] };
1719
2065
  }
1720
2066
  /**
1721
2067
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -1776,7 +2122,7 @@ var Act = class {
1776
2122
  this._correlation_checkpoint = watermark;
1777
2123
  if (this._reactive_events.size > 0) this._needs_drain = true;
1778
2124
  for (const { stream } of this._static_targets) {
1779
- this._subscribed_statics.add(stream);
2125
+ this._subscribed_streams.add(stream);
1780
2126
  }
1781
2127
  }
1782
2128
  async correlate(query = { after: -1, limit: 10 }) {
@@ -1794,7 +2140,7 @@ var Act = class {
1794
2140
  for (const reaction of register.reactions.values()) {
1795
2141
  if (typeof reaction.resolver !== "function") continue;
1796
2142
  const resolved = reaction.resolver(event);
1797
- if (resolved && !this._subscribed_statics.has(resolved.target)) {
2143
+ if (resolved && !this._subscribed_streams.has(resolved.target)) {
1798
2144
  const entry = correlated.get(resolved.target) || {
1799
2145
  source: resolved.source,
1800
2146
  payloads: []
@@ -1820,7 +2166,7 @@ var Act = class {
1820
2166
  this._correlation_checkpoint = last_id;
1821
2167
  if (subscribed) {
1822
2168
  for (const { stream } of streams) {
1823
- this._subscribed_statics.add(stream);
2169
+ this._subscribed_streams.add(stream);
1824
2170
  }
1825
2171
  }
1826
2172
  return { subscribed, last_id };
@@ -2003,143 +2349,14 @@ var Act = class {
2003
2349
  */
2004
2350
  async close(targets) {
2005
2351
  if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
2006
- const targetMap = new Map(targets.map((t) => [t.stream, t]));
2007
- const streams = [...targetMap.keys()];
2008
2352
  await this.correlate({ limit: 1e3 });
2009
- const streamInfo = /* @__PURE__ */ new Map();
2010
- await Promise.all(
2011
- streams.map(async (s) => {
2012
- let maxId = -1;
2013
- let version = -1;
2014
- let lastEventName;
2015
- await store().query(
2016
- (e) => {
2017
- if (e.name === TOMBSTONE_EVENT) return;
2018
- if (maxId === -1) {
2019
- maxId = e.id;
2020
- version = e.version;
2021
- }
2022
- if (e.name !== SNAP_EVENT && lastEventName === void 0) {
2023
- lastEventName = e.name;
2024
- }
2025
- },
2026
- // limit: 2 covers the typical snapshot-at-head case (snapshot is
2027
- // always preceded by the domain event it captured). Streams with
2028
- // unusual layouts fall back to no-seed via the lookup miss path.
2029
- { stream: s, stream_exact: true, backward: true, limit: 2 }
2030
- );
2031
- if (maxId >= 0) streamInfo.set(s, { maxId, version, lastEventName });
2032
- })
2033
- );
2034
- const skipped = [];
2035
- let safe;
2036
- if (this._reactive_events.size === 0) {
2037
- safe = [...streamInfo.keys()];
2038
- } else {
2039
- const pendingSet = /* @__PURE__ */ new Set();
2040
- await store().query_streams((position) => {
2041
- const sourceRe = position.source ? RegExp(position.source) : void 0;
2042
- for (const [stream, info] of streamInfo) {
2043
- if ((!sourceRe || sourceRe.test(stream)) && position.at < info.maxId) {
2044
- pendingSet.add(stream);
2045
- }
2046
- }
2047
- });
2048
- safe = [];
2049
- for (const [stream] of streamInfo) {
2050
- if (pendingSet.has(stream)) {
2051
- skipped.push(stream);
2052
- } else {
2053
- safe.push(stream);
2054
- }
2055
- }
2056
- }
2057
- if (!safe.length) {
2058
- const result2 = { truncated: /* @__PURE__ */ new Map(), skipped };
2059
- this.emit("closed", result2);
2060
- return result2;
2061
- }
2062
- const correlation = (0, import_crypto2.randomUUID)();
2063
- const guarded = [];
2064
- const guardEvents = /* @__PURE__ */ new Map();
2065
- await Promise.all(
2066
- safe.map(async (stream) => {
2067
- try {
2068
- const info = streamInfo.get(stream);
2069
- const [committed] = await store().commit(
2070
- stream,
2071
- [{ name: TOMBSTONE_EVENT, data: {} }],
2072
- { correlation, causation: {} },
2073
- info.version
2074
- );
2075
- guarded.push(stream);
2076
- guardEvents.set(stream, { id: committed.id, stream });
2077
- } catch {
2078
- skipped.push(stream);
2079
- }
2080
- })
2081
- );
2082
- if (!guarded.length) {
2083
- const result2 = { truncated: /* @__PURE__ */ new Map(), skipped };
2084
- this.emit("closed", result2);
2085
- return result2;
2086
- }
2087
- const seedStates = /* @__PURE__ */ new Map();
2088
- await Promise.all(
2089
- guarded.filter((s) => targetMap.get(s)?.restart).map(async (stream) => {
2090
- const lastEventName = streamInfo.get(stream)?.lastEventName;
2091
- const ownerState = lastEventName ? this._event_to_state.get(lastEventName) : void 0;
2092
- if (!ownerState) {
2093
- this._logger.error(
2094
- `Cannot seed restart for "${stream}": no registered state owns event "${lastEventName ?? "<none>"}". Stream will be tombstoned instead.`
2095
- );
2096
- return;
2097
- }
2098
- const snap2 = await this._es.load(ownerState, stream);
2099
- seedStates.set(stream, snap2.state);
2100
- })
2101
- );
2102
- for (const stream of guarded) {
2103
- const archiveFn = targetMap.get(stream)?.archive;
2104
- if (archiveFn) await archiveFn();
2105
- }
2106
- const truncTargets = guarded.map((stream) => {
2107
- const snapshot = seedStates.get(stream);
2108
- const guard = guardEvents.get(stream);
2109
- return {
2110
- stream,
2111
- snapshot,
2112
- meta: {
2113
- correlation,
2114
- causation: {
2115
- event: {
2116
- id: guard.id,
2117
- name: TOMBSTONE_EVENT,
2118
- stream: guard.stream
2119
- }
2120
- }
2121
- }
2122
- };
2353
+ const result = await runCloseCycle(targets, {
2354
+ reactiveEventsSize: this._reactive_events.size,
2355
+ eventToState: this._event_to_state,
2356
+ load: this._es.load,
2357
+ tombstone: this._es.tombstone,
2358
+ logger: this._logger
2123
2359
  });
2124
- const truncated = await store().truncate(truncTargets);
2125
- await Promise.all(
2126
- guarded.map(async (stream) => {
2127
- const entry = truncated.get(stream);
2128
- const state2 = seedStates.get(stream);
2129
- if (state2 && entry) {
2130
- await cache().set(stream, {
2131
- state: state2,
2132
- version: entry.committed.version,
2133
- event_id: entry.committed.id,
2134
- patches: 0,
2135
- snaps: 1
2136
- });
2137
- } else {
2138
- await cache().invalidate(stream);
2139
- }
2140
- })
2141
- );
2142
- const result = { truncated, skipped };
2143
2360
  this.emit("closed", result);
2144
2361
  return result;
2145
2362
  }
@@ -2208,7 +2425,7 @@ var Act = class {
2208
2425
  }
2209
2426
  };
2210
2427
 
2211
- // src/act-builder.ts
2428
+ // src/builders/act-builder.ts
2212
2429
  function registerBatchHandler(proj, batchHandlers) {
2213
2430
  if (!proj.batchHandler || !proj.target) return;
2214
2431
  const existing = batchHandlers.get(proj.target);
@@ -2217,56 +2434,33 @@ function registerBatchHandler(proj, batchHandlers) {
2217
2434
  }
2218
2435
  batchHandlers.set(proj.target, proj.batchHandler);
2219
2436
  }
2220
- function act(states = /* @__PURE__ */ new Map(), registry = {
2221
- actions: {},
2222
- events: {}
2223
- }, pendingProjections = [], batchHandlers = /* @__PURE__ */ new Map()) {
2437
+ function act() {
2438
+ const states = /* @__PURE__ */ new Map();
2439
+ const registry = {
2440
+ actions: {},
2441
+ events: {}
2442
+ };
2443
+ const pendingProjections = [];
2444
+ const batchHandlers = /* @__PURE__ */ new Map();
2224
2445
  const builder = {
2225
2446
  withState: (state2) => {
2226
2447
  registerState(state2, states, registry.actions, registry.events);
2227
- return act(
2228
- states,
2229
- registry,
2230
- pendingProjections,
2231
- batchHandlers
2232
- );
2448
+ return builder;
2233
2449
  },
2234
2450
  withSlice: (input) => {
2235
2451
  for (const s of input.states.values()) {
2236
2452
  registerState(s, states, registry.actions, registry.events);
2237
2453
  }
2238
- for (const eventName of Object.keys(input.events)) {
2239
- const sliceRegister = input.events[eventName];
2240
- for (const [name, reaction] of sliceRegister.reactions) {
2241
- registry.events[eventName].reactions.set(name, reaction);
2242
- }
2243
- }
2454
+ mergeEventRegister(registry.events, input.events);
2244
2455
  pendingProjections.push(...input.projections);
2245
- return act(
2246
- states,
2247
- registry,
2248
- pendingProjections,
2249
- batchHandlers
2250
- );
2456
+ return builder;
2251
2457
  },
2252
2458
  withProjection: (proj) => {
2253
2459
  mergeProjection(proj, registry.events);
2254
2460
  registerBatchHandler(proj, batchHandlers);
2255
- return act(
2256
- states,
2257
- registry,
2258
- pendingProjections,
2259
- batchHandlers
2260
- );
2261
- },
2262
- withActor: () => {
2263
- return act(
2264
- states,
2265
- registry,
2266
- pendingProjections,
2267
- batchHandlers
2268
- );
2461
+ return builder;
2269
2462
  },
2463
+ withActor: () => builder,
2270
2464
  on: (event) => ({
2271
2465
  do: (handler, options) => {
2272
2466
  const reaction = {
@@ -2282,19 +2476,15 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2282
2476
  `Reaction handler for "${String(event)}" must be a named function`
2283
2477
  );
2284
2478
  registry.events[event].reactions.set(handler.name, reaction);
2285
- return {
2286
- ...builder,
2479
+ return Object.assign(builder, {
2287
2480
  to(resolver) {
2288
- registry.events[event].reactions.set(handler.name, {
2289
- ...reaction,
2290
- resolver: typeof resolver === "string" ? { target: resolver } : resolver
2291
- });
2481
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2292
2482
  return builder;
2293
2483
  }
2294
- };
2484
+ });
2295
2485
  }
2296
2486
  }),
2297
- build: () => {
2487
+ build: (options) => {
2298
2488
  for (const proj of pendingProjections) {
2299
2489
  mergeProjection(proj, registry.events);
2300
2490
  registerBatchHandler(proj, batchHandlers);
@@ -2302,7 +2492,8 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2302
2492
  return new Act(
2303
2493
  registry,
2304
2494
  states,
2305
- batchHandlers
2495
+ batchHandlers,
2496
+ options
2306
2497
  );
2307
2498
  },
2308
2499
  events: registry.events
@@ -2310,8 +2501,9 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2310
2501
  return builder;
2311
2502
  }
2312
2503
 
2313
- // src/projection-builder.ts
2314
- function _projection(target, events) {
2504
+ // src/builders/projection-builder.ts
2505
+ function _projection(target) {
2506
+ const events = {};
2315
2507
  const defaultResolver = typeof target === "string" ? { target } : void 0;
2316
2508
  const base = {
2317
2509
  on: (entry) => {
@@ -2341,17 +2533,13 @@ function _projection(target, events) {
2341
2533
  `Projection handler for "${event}" must be a named function`
2342
2534
  );
2343
2535
  register.reactions.set(handler.name, reaction);
2344
- const nextBuilder = _projection(target, events);
2345
- return {
2346
- ...nextBuilder,
2536
+ const widened = base;
2537
+ return Object.assign(widened, {
2347
2538
  to(resolver) {
2348
- register.reactions.set(handler.name, {
2349
- ...reaction,
2350
- resolver: typeof resolver === "string" ? { target: resolver } : resolver
2351
- });
2352
- return nextBuilder;
2539
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2540
+ return widened;
2353
2541
  }
2354
- };
2542
+ });
2355
2543
  }
2356
2544
  };
2357
2545
  },
@@ -2363,8 +2551,7 @@ function _projection(target, events) {
2363
2551
  events
2364
2552
  };
2365
2553
  if (typeof target === "string") {
2366
- return {
2367
- ...base,
2554
+ return Object.assign(base, {
2368
2555
  batch: (handler) => ({
2369
2556
  build: () => ({
2370
2557
  _tag: "Projection",
@@ -2373,34 +2560,28 @@ function _projection(target, events) {
2373
2560
  batchHandler: handler
2374
2561
  })
2375
2562
  })
2376
- };
2563
+ });
2377
2564
  }
2378
2565
  return base;
2379
2566
  }
2380
- function projection(target, events = {}) {
2381
- return _projection(target, events);
2567
+ function projection(target) {
2568
+ return _projection(target);
2382
2569
  }
2383
2570
 
2384
- // src/slice-builder.ts
2385
- function slice(states = /* @__PURE__ */ new Map(), actions = {}, events = {}, projections = []) {
2571
+ // src/builders/slice-builder.ts
2572
+ function slice() {
2573
+ const states = /* @__PURE__ */ new Map();
2574
+ const actions = {};
2575
+ const events = {};
2576
+ const projections = [];
2386
2577
  const builder = {
2387
2578
  withState: (state2) => {
2388
2579
  registerState(state2, states, actions, events);
2389
- return slice(
2390
- states,
2391
- actions,
2392
- events,
2393
- projections
2394
- );
2580
+ return builder;
2395
2581
  },
2396
2582
  withProjection: (proj) => {
2397
2583
  projections.push(proj);
2398
- return slice(
2399
- states,
2400
- actions,
2401
- events,
2402
- projections
2403
- );
2584
+ return builder;
2404
2585
  },
2405
2586
  on: (event) => ({
2406
2587
  do: (handler, options) => {
@@ -2417,16 +2598,12 @@ function slice(states = /* @__PURE__ */ new Map(), actions = {}, events = {}, pr
2417
2598
  `Reaction handler for "${String(event)}" must be a named function`
2418
2599
  );
2419
2600
  events[event].reactions.set(handler.name, reaction);
2420
- return {
2421
- ...builder,
2601
+ return Object.assign(builder, {
2422
2602
  to(resolver) {
2423
- events[event].reactions.set(handler.name, {
2424
- ...reaction,
2425
- resolver: typeof resolver === "string" ? { target: resolver } : resolver
2426
- });
2603
+ reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
2427
2604
  return builder;
2428
2605
  }
2429
- };
2606
+ });
2430
2607
  }
2431
2608
  }),
2432
2609
  build: () => ({
@@ -2440,7 +2617,7 @@ function slice(states = /* @__PURE__ */ new Map(), actions = {}, events = {}, pr
2440
2617
  return builder;
2441
2618
  }
2442
2619
 
2443
- // src/state-builder.ts
2620
+ // src/builders/state-builder.ts
2444
2621
  function state(entry) {
2445
2622
  const keys = Object.keys(entry);
2446
2623
  if (keys.length !== 1) throw new Error("state() requires exactly one key");
@@ -2458,7 +2635,7 @@ function state(entry) {
2458
2635
  return [k, fn];
2459
2636
  })
2460
2637
  );
2461
- const builder = action_builder({
2638
+ const internal = {
2462
2639
  events,
2463
2640
  actions: {},
2464
2641
  state: stateSchema,
@@ -2466,18 +2643,12 @@ function state(entry) {
2466
2643
  init,
2467
2644
  patch: defaultPatch,
2468
2645
  on: {}
2469
- });
2646
+ };
2647
+ const builder = action_builder(internal);
2470
2648
  return Object.assign(builder, {
2471
2649
  patch(customPatch) {
2472
- return action_builder({
2473
- events,
2474
- actions: {},
2475
- state: stateSchema,
2476
- name,
2477
- init,
2478
- patch: { ...defaultPatch, ...customPatch },
2479
- on: {}
2480
- });
2650
+ Object.assign(internal.patch, customPatch);
2651
+ return builder;
2481
2652
  }
2482
2653
  });
2483
2654
  }
@@ -2486,50 +2657,43 @@ function state(entry) {
2486
2657
  };
2487
2658
  }
2488
2659
  function action_builder(state2) {
2489
- return {
2660
+ const internal = state2;
2661
+ const builder = {
2490
2662
  on(entry) {
2491
2663
  const keys = Object.keys(entry);
2492
2664
  if (keys.length !== 1) throw new Error(".on() requires exactly one key");
2493
2665
  const action2 = keys[0];
2494
2666
  const schema = entry[action2];
2495
- if (action2 in state2.actions)
2667
+ if (action2 in internal.actions)
2496
2668
  throw new Error(`Duplicate action "${action2}"`);
2497
- const actions = {
2498
- ...state2.actions,
2499
- [action2]: schema
2500
- };
2501
- const on = { ...state2.on };
2502
- const _given = { ...state2.given };
2669
+ internal.actions[action2] = schema;
2503
2670
  function given(rules) {
2504
- _given[action2] = rules;
2671
+ (internal.given ??= {})[action2] = rules;
2505
2672
  return { emit };
2506
2673
  }
2507
2674
  function emit(handler) {
2508
2675
  if (typeof handler === "string") {
2509
2676
  const eventName = handler;
2510
- on[action2] = ((payload) => [eventName, payload]);
2677
+ internal.on[action2] = (payload) => [
2678
+ eventName,
2679
+ payload
2680
+ ];
2511
2681
  } else {
2512
- on[action2] = handler;
2682
+ internal.on[action2] = handler;
2513
2683
  }
2514
- return action_builder({
2515
- ...state2,
2516
- actions,
2517
- on,
2518
- given: _given
2519
- });
2684
+ return builder;
2520
2685
  }
2521
2686
  return { given, emit };
2522
2687
  },
2523
2688
  snap(snap2) {
2524
- return action_builder({
2525
- ...state2,
2526
- snap: snap2
2527
- });
2689
+ internal.snap = snap2;
2690
+ return builder;
2528
2691
  },
2529
2692
  build() {
2530
- return state2;
2693
+ return internal;
2531
2694
  }
2532
2695
  };
2696
+ return builder;
2533
2697
  }
2534
2698
  // Annotate the CommonJS export names for ESM import in node:
2535
2699
  0 && (module.exports = {
@@ -2539,6 +2703,7 @@ function action_builder(state2) {
2539
2703
  CommittedMetaSchema,
2540
2704
  ConcurrencyError,
2541
2705
  ConsoleLogger,
2706
+ DEFAULT_MAX_SUBSCRIBED_STREAMS,
2542
2707
  Environments,
2543
2708
  Errors,
2544
2709
  EventMetaSchema,