@rotorsoft/act 0.32.7 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +22 -66
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/internal/build-classify.d.ts +44 -0
  5. package/dist/@types/internal/build-classify.d.ts.map +1 -0
  6. package/dist/@types/internal/correlate-cycle.d.ts +73 -0
  7. package/dist/@types/internal/correlate-cycle.d.ts.map +1 -0
  8. package/dist/@types/internal/drain-cycle.d.ts +57 -5
  9. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  10. package/dist/@types/internal/drain.d.ts +2 -0
  11. package/dist/@types/internal/drain.d.ts.map +1 -1
  12. package/dist/@types/internal/event-sourcing.d.ts +5 -2
  13. package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
  14. package/dist/@types/internal/index.d.ts +10 -7
  15. package/dist/@types/internal/index.d.ts.map +1 -1
  16. package/dist/@types/internal/merge.d.ts.map +1 -1
  17. package/dist/@types/internal/reactions.d.ts +54 -0
  18. package/dist/@types/internal/reactions.d.ts.map +1 -0
  19. package/dist/@types/internal/settle.d.ts +60 -0
  20. package/dist/@types/internal/settle.d.ts.map +1 -0
  21. package/dist/@types/internal/tracing.d.ts +2 -0
  22. package/dist/@types/internal/tracing.d.ts.map +1 -1
  23. package/dist/@types/lru-map.d.ts.map +1 -0
  24. package/dist/@types/types/action.d.ts +27 -0
  25. package/dist/@types/types/action.d.ts.map +1 -1
  26. package/dist/index.cjs +497 -342
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.js +496 -342
  29. package/dist/index.js.map +1 -1
  30. package/package.json +1 -1
  31. package/dist/@types/internal/lru-map.d.ts.map +0 -1
  32. /package/dist/@types/{internal/lru-map.d.ts → lru-map.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -128,7 +128,7 @@ var ConsoleLogger = class _ConsoleLogger {
128
128
  }
129
129
  };
130
130
 
131
- // src/internal/lru-map.ts
131
+ // src/lru-map.ts
132
132
  var LruMap = class {
133
133
  constructor(_maxSize) {
134
134
  this._maxSize = _maxSize;
@@ -806,6 +806,37 @@ process.once("unhandledRejection", async (arg) => {
806
806
  // src/act.ts
807
807
  import EventEmitter from "events";
808
808
 
809
+ // src/internal/build-classify.ts
810
+ function classifyRegistry(registry, states) {
811
+ const statics = /* @__PURE__ */ new Map();
812
+ const reactiveEvents = /* @__PURE__ */ new Set();
813
+ let hasDynamicResolvers = false;
814
+ for (const [name, register] of Object.entries(registry.events)) {
815
+ if (register.reactions.size > 0) reactiveEvents.add(name);
816
+ for (const reaction of register.reactions.values()) {
817
+ if (typeof reaction.resolver === "function") {
818
+ hasDynamicResolvers = true;
819
+ } else {
820
+ const { target, source } = reaction.resolver;
821
+ const key = `${target}|${source ?? ""}`;
822
+ if (!statics.has(key)) statics.set(key, { stream: target, source });
823
+ }
824
+ }
825
+ }
826
+ const eventToState = /* @__PURE__ */ new Map();
827
+ for (const merged of states.values()) {
828
+ for (const eventName of Object.keys(merged.events)) {
829
+ eventToState.set(eventName, merged);
830
+ }
831
+ }
832
+ return {
833
+ staticTargets: [...statics.values()],
834
+ hasDynamicResolvers,
835
+ reactiveEvents,
836
+ eventToState
837
+ };
838
+ }
839
+
809
840
  // src/internal/close-cycle.ts
810
841
  import { randomUUID } from "crypto";
811
842
  async function runCloseCycle(targets, deps) {
@@ -961,8 +992,142 @@ async function truncateAndWarmCache(guarded, seedStates, guardEvents, correlatio
961
992
  return truncated;
962
993
  }
963
994
 
995
+ // src/internal/correlate-cycle.ts
996
+ var CorrelateCycle = class {
997
+ constructor(registry, staticTargets, hasDynamicResolvers, cd, maxSubscribedStreams, onInit) {
998
+ this.registry = registry;
999
+ this.staticTargets = staticTargets;
1000
+ this.hasDynamicResolvers = hasDynamicResolvers;
1001
+ this.cd = cd;
1002
+ this.onInit = onInit;
1003
+ this._subscribed = new LruSet(maxSubscribedStreams);
1004
+ }
1005
+ _checkpoint = -1;
1006
+ _initialized = false;
1007
+ _timer = void 0;
1008
+ _subscribed;
1009
+ /** Last correlated event id. */
1010
+ get checkpoint() {
1011
+ return this._checkpoint;
1012
+ }
1013
+ /**
1014
+ * Initialize correlation state on first call.
1015
+ * - Reads max(at) from store as cold-start checkpoint
1016
+ * - Subscribes static resolver targets (idempotent upsert)
1017
+ * - Populates the subscribed-streams LRU
1018
+ * - Fires `onInit` once (Act uses this to flag a cold-start drain)
1019
+ */
1020
+ async init() {
1021
+ if (this._initialized) return;
1022
+ this._initialized = true;
1023
+ const { watermark } = await store().subscribe([...this.staticTargets]);
1024
+ this._checkpoint = watermark;
1025
+ this.onInit?.();
1026
+ for (const { stream } of this.staticTargets) {
1027
+ this._subscribed.add(stream);
1028
+ }
1029
+ }
1030
+ /**
1031
+ * Discover dynamic-resolver targets in the events past the checkpoint
1032
+ * and register any new streams via `cd.subscribe`. Static targets are
1033
+ * subscribed at init time, so this only walks dynamic resolvers.
1034
+ */
1035
+ async correlate(query = { after: -1, limit: 10 }) {
1036
+ await this.init();
1037
+ if (!this.hasDynamicResolvers)
1038
+ return { subscribed: 0, last_id: this._checkpoint };
1039
+ const after = Math.max(this._checkpoint, query.after || -1);
1040
+ const correlated = /* @__PURE__ */ new Map();
1041
+ let last_id = after;
1042
+ await store().query(
1043
+ (event) => {
1044
+ last_id = event.id;
1045
+ const register = this.registry.events[event.name];
1046
+ if (register) {
1047
+ for (const reaction of register.reactions.values()) {
1048
+ if (typeof reaction.resolver !== "function") continue;
1049
+ const resolved = reaction.resolver(event);
1050
+ if (resolved && !this._subscribed.has(resolved.target)) {
1051
+ const entry = correlated.get(resolved.target) || {
1052
+ source: resolved.source,
1053
+ payloads: []
1054
+ };
1055
+ entry.payloads.push({
1056
+ ...reaction,
1057
+ source: resolved.source,
1058
+ event
1059
+ });
1060
+ correlated.set(resolved.target, entry);
1061
+ }
1062
+ }
1063
+ }
1064
+ },
1065
+ { ...query, after }
1066
+ );
1067
+ if (correlated.size) {
1068
+ const streams = [...correlated.entries()].map(([stream, { source }]) => ({
1069
+ stream,
1070
+ source
1071
+ }));
1072
+ const { subscribed } = await this.cd.subscribe(streams);
1073
+ this._checkpoint = last_id;
1074
+ if (subscribed) {
1075
+ for (const { stream } of streams) {
1076
+ this._subscribed.add(stream);
1077
+ }
1078
+ }
1079
+ return { subscribed, last_id };
1080
+ }
1081
+ this._checkpoint = last_id;
1082
+ return { subscribed: 0, last_id };
1083
+ }
1084
+ /**
1085
+ * Start a periodic correlation worker. Returns false if one is already
1086
+ * running. Errors from `correlate()` are sent to `console.error` (matches
1087
+ * pre-extraction behavior; the timer keeps running on failure).
1088
+ */
1089
+ startPolling(query = {}, frequency = 1e4, callback) {
1090
+ if (this._timer) return false;
1091
+ const limit = query.limit || 100;
1092
+ this._timer = setInterval(
1093
+ () => this.correlate({ ...query, after: this._checkpoint, limit }).then((result) => {
1094
+ if (callback && result.subscribed) callback(result.subscribed);
1095
+ }).catch(console.error),
1096
+ frequency
1097
+ );
1098
+ return true;
1099
+ }
1100
+ /** Stop the periodic correlation worker. Idempotent. */
1101
+ stopPolling() {
1102
+ if (this._timer) {
1103
+ clearInterval(this._timer);
1104
+ this._timer = void 0;
1105
+ }
1106
+ }
1107
+ };
1108
+
964
1109
  // src/internal/drain-cycle.ts
965
1110
  import { randomUUID as randomUUID2 } from "crypto";
1111
+
1112
+ // src/internal/drain-ratio.ts
1113
+ var RATIO_MIN = 0.2;
1114
+ var RATIO_MAX = 0.8;
1115
+ var RATIO_DEFAULT = 0.5;
1116
+ function computeLagLeadRatio(handled, lagging, leading) {
1117
+ let lagging_handled = 0;
1118
+ let leading_handled = 0;
1119
+ for (const { lease, handled: count } of handled) {
1120
+ if (lease.lagging) lagging_handled += count;
1121
+ else leading_handled += count;
1122
+ }
1123
+ const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1124
+ const leading_avg = leading > 0 ? leading_handled / leading : 0;
1125
+ const total = lagging_avg + leading_avg;
1126
+ if (total === 0) return RATIO_DEFAULT;
1127
+ return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1128
+ }
1129
+
1130
+ // src/internal/drain-cycle.ts
966
1131
  async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis) {
967
1132
  const leased = await ops.claim(lagging, leading, randomUUID2(), leaseMillis);
968
1133
  if (!leased.length) return void 0;
@@ -1004,24 +1169,73 @@ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch,
1004
1169
  );
1005
1170
  return { leased, fetched, handled, acked, blocked };
1006
1171
  }
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;
1172
+ var EMPTY_DRAIN = {
1173
+ fetched: [],
1174
+ leased: [],
1175
+ acked: [],
1176
+ blocked: []
1177
+ };
1178
+ var DrainController = class {
1179
+ constructor(deps) {
1180
+ this.deps = deps;
1018
1181
  }
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
- }
1182
+ _armed = false;
1183
+ _locked = false;
1184
+ _ratio = 0.5;
1185
+ /**
1186
+ * Signal that a commit (or reset / cold-start) may have produced work.
1187
+ * Subsequent `drain()` calls will run the pipeline; once the pipeline
1188
+ * settles to no-progress, the controller disarms itself.
1189
+ */
1190
+ arm() {
1191
+ this._armed = true;
1192
+ }
1193
+ /** Read-only flag — true while a commit / reset is unprocessed. */
1194
+ get armed() {
1195
+ return this._armed;
1196
+ }
1197
+ /** Run one drain pass. Short-circuits when not armed or already running. */
1198
+ async drain({
1199
+ streamLimit = 10,
1200
+ eventLimit = 10,
1201
+ leaseMillis = 1e4
1202
+ } = {}) {
1203
+ if (!this._armed) return EMPTY_DRAIN;
1204
+ if (this._locked) return EMPTY_DRAIN;
1205
+ try {
1206
+ this._locked = true;
1207
+ const lagging = Math.ceil(streamLimit * this._ratio);
1208
+ const leading = streamLimit - lagging;
1209
+ const cycle = await runDrainCycle(
1210
+ this.deps.ops,
1211
+ this.deps.registry,
1212
+ this.deps.batchHandlers,
1213
+ this.deps.handle,
1214
+ this.deps.handleBatch,
1215
+ lagging,
1216
+ leading,
1217
+ eventLimit,
1218
+ leaseMillis
1219
+ );
1220
+ if (!cycle) {
1221
+ this._armed = false;
1222
+ return EMPTY_DRAIN;
1223
+ }
1224
+ const { leased, fetched, handled, acked, blocked } = cycle;
1225
+ this._ratio = computeLagLeadRatio(handled, lagging, leading);
1226
+ if (acked.length) this.deps.onAcked(acked);
1227
+ if (blocked.length) this.deps.onBlocked(blocked);
1228
+ const hasErrors = handled.some(({ error }) => error);
1229
+ if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
1230
+ return { fetched, leased, acked, blocked };
1231
+ } catch (error) {
1232
+ this.deps.logger.error(error);
1233
+ return EMPTY_DRAIN;
1234
+ } finally {
1235
+ this._locked = false;
1236
+ }
1237
+ }
1238
+ };
1025
1239
 
1026
1240
  // src/internal/merge.ts
1027
1241
  import { ZodObject } from "zod";
@@ -1162,6 +1376,140 @@ var _this_ = ({ stream }) => ({
1162
1376
  target: stream
1163
1377
  });
1164
1378
 
1379
+ // src/internal/reactions.ts
1380
+ function finalize(lease, handled, at, error, options, logger) {
1381
+ if (!error) return { lease, handled, at };
1382
+ logger.error(error);
1383
+ const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1384
+ if (block2)
1385
+ logger.error(`Blocking ${lease.stream} after ${lease.retry} retries.`);
1386
+ return {
1387
+ lease,
1388
+ handled,
1389
+ at,
1390
+ error: handled === 0 ? error.message : void 0,
1391
+ block: block2
1392
+ };
1393
+ }
1394
+ function buildHandle(deps) {
1395
+ const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
1396
+ return async (lease, payloads) => {
1397
+ if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
1398
+ const stream = lease.stream;
1399
+ let at = payloads.at(0).event.id;
1400
+ let handled = 0;
1401
+ if (lease.retry > 0)
1402
+ logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1403
+ const scopedApp = {
1404
+ do: boundDo,
1405
+ load: boundLoad,
1406
+ query: boundQuery,
1407
+ query_array: boundQueryArray
1408
+ };
1409
+ for (const payload of payloads) {
1410
+ const { event, handler } = payload;
1411
+ scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
1412
+ action2,
1413
+ target,
1414
+ actionPayload,
1415
+ reactingTo ?? event,
1416
+ skipValidation
1417
+ );
1418
+ try {
1419
+ await handler(event, stream, scopedApp);
1420
+ at = event.id;
1421
+ handled++;
1422
+ } catch (error) {
1423
+ return finalize(
1424
+ lease,
1425
+ handled,
1426
+ at,
1427
+ error,
1428
+ payload.options,
1429
+ logger
1430
+ );
1431
+ }
1432
+ }
1433
+ return finalize(lease, handled, at, void 0, payloads[0].options, logger);
1434
+ };
1435
+ }
1436
+ function buildHandleBatch(logger) {
1437
+ return async (lease, payloads, batchHandler) => {
1438
+ const stream = lease.stream;
1439
+ const events = payloads.map((p) => p.event);
1440
+ const options = payloads[0].options;
1441
+ if (lease.retry > 0)
1442
+ logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
1443
+ try {
1444
+ await batchHandler(events, stream);
1445
+ return finalize(
1446
+ lease,
1447
+ events.length,
1448
+ events.at(-1).id,
1449
+ void 0,
1450
+ options,
1451
+ logger
1452
+ );
1453
+ } catch (error) {
1454
+ return finalize(lease, 0, lease.at, error, options, logger);
1455
+ }
1456
+ };
1457
+ }
1458
+
1459
+ // src/internal/settle.ts
1460
+ var SettleLoop = class {
1461
+ constructor(deps, defaultDebounceMs) {
1462
+ this.deps = deps;
1463
+ this.defaultDebounceMs = defaultDebounceMs;
1464
+ }
1465
+ _timer = void 0;
1466
+ _running = false;
1467
+ /**
1468
+ * Schedule a settle pass. Multiple calls inside the debounce window
1469
+ * coalesce into one cycle. The cycle runs correlate→drain in a loop
1470
+ * until no progress is made (no new subscriptions, no acks, no blocks)
1471
+ * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
1472
+ * via {@link SettleDeps.onSettled}.
1473
+ */
1474
+ schedule(options = {}) {
1475
+ const {
1476
+ debounceMs = this.defaultDebounceMs,
1477
+ correlate: correlateQuery = { after: -1, limit: 100 },
1478
+ maxPasses = Infinity,
1479
+ ...drainOptions
1480
+ } = options;
1481
+ if (this._timer) clearTimeout(this._timer);
1482
+ this._timer = setTimeout(() => {
1483
+ this._timer = void 0;
1484
+ if (this._running) return;
1485
+ this._running = true;
1486
+ (async () => {
1487
+ await this.deps.init();
1488
+ let lastDrain;
1489
+ for (let i = 0; i < maxPasses; i++) {
1490
+ const { subscribed } = await this.deps.correlate({
1491
+ ...correlateQuery,
1492
+ after: this.deps.checkpoint()
1493
+ });
1494
+ lastDrain = await this.deps.drain(drainOptions);
1495
+ const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
1496
+ if (!made_progress) break;
1497
+ }
1498
+ if (lastDrain) this.deps.onSettled(lastDrain);
1499
+ })().catch((err) => this.deps.logger.error(err)).finally(() => {
1500
+ this._running = false;
1501
+ });
1502
+ }, debounceMs);
1503
+ }
1504
+ /** Cancel any pending or active settle cycle. Idempotent. */
1505
+ stop() {
1506
+ if (this._timer) {
1507
+ clearTimeout(this._timer);
1508
+ this._timer = void 0;
1509
+ }
1510
+ }
1511
+ };
1512
+
1165
1513
  // src/internal/drain.ts
1166
1514
  var claim = (lagging, leading, by, millis) => store().claim(lagging, leading, by, millis);
1167
1515
  async function fetch(leased, eventLimit) {
@@ -1218,26 +1566,40 @@ async function tombstone(stream, expectedVersion, correlation) {
1218
1566
  async function load(me, stream, callback, asOf) {
1219
1567
  const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
1220
1568
  const cached = timeTravel ? void 0 : await cache().get(stream);
1569
+ const cache_hit = !!cached;
1221
1570
  let state2 = cached?.state ?? (me.init ? me.init() : {});
1222
1571
  let patches = cached?.patches ?? 0;
1223
1572
  let snaps = cached?.snaps ?? 0;
1573
+ let version = cached?.version ?? -1;
1574
+ let replayed = 0;
1224
1575
  let event;
1225
1576
  await store().query(
1226
1577
  (e) => {
1227
1578
  event = e;
1579
+ version = e.version;
1228
1580
  if (e.name === SNAP_EVENT) {
1229
1581
  state2 = e.data;
1230
1582
  snaps++;
1231
1583
  patches = 0;
1584
+ replayed++;
1232
1585
  } else if (me.patch[e.name]) {
1233
1586
  state2 = patch(state2, me.patch[e.name](event, state2));
1234
1587
  patches++;
1588
+ replayed++;
1235
1589
  } else if (e.name !== TOMBSTONE_EVENT) {
1236
1590
  log().warn(
1237
1591
  `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
1238
1592
  );
1239
1593
  }
1240
- callback && callback({ event, state: state2, patches, snaps });
1594
+ callback && callback({
1595
+ event,
1596
+ state: state2,
1597
+ version,
1598
+ patches,
1599
+ snaps,
1600
+ cache_hit,
1601
+ replayed
1602
+ });
1241
1603
  },
1242
1604
  {
1243
1605
  stream,
@@ -1245,7 +1607,7 @@ async function load(me, stream, callback, asOf) {
1245
1607
  ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
1246
1608
  }
1247
1609
  );
1248
- return { event, state: state2, patches, snaps };
1610
+ return { event, state: state2, version, patches, snaps, cache_hit, replayed };
1249
1611
  }
1250
1612
  async function action(me, action2, target, payload, reactingTo, skipValidation = false) {
1251
1613
  const { stream, expectedVersion, actor } = target;
@@ -1317,7 +1679,16 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
1317
1679
  const p = me.patch[event.name](event, state2);
1318
1680
  state2 = patch(state2, p);
1319
1681
  patches++;
1320
- return { event, state: state2, patches, snaps: snapshot.snaps, patch: p };
1682
+ return {
1683
+ event,
1684
+ state: state2,
1685
+ version: event.version,
1686
+ patches,
1687
+ snaps: snapshot.snaps,
1688
+ patch: p,
1689
+ cache_hit: snapshot.cache_hit,
1690
+ replayed: snapshot.replayed
1691
+ };
1321
1692
  });
1322
1693
  const last = snapshots.at(-1);
1323
1694
  const snapped = me.snap && me.snap(last);
@@ -1339,12 +1710,35 @@ var C_ORANGE = "\x1B[38;5;208m";
1339
1710
  var C_GREEN = "\x1B[38;5;42m";
1340
1711
  var C_MAGENTA = "\x1B[38;5;165m";
1341
1712
  var C_DRAIN = "\x1B[38;5;244m";
1713
+ var C_HIT = "\x1B[38;5;82m";
1714
+ var C_MISS = "\x1B[38;5;220m";
1342
1715
  var C_RESET = "\x1B[0m";
1343
1716
  var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
1344
1717
  var drain_caption = (caption) => {
1345
1718
  const tag = `>> ${caption}`;
1346
1719
  return PRETTY ? `${C_DRAIN}${tag}${C_RESET}` : tag;
1347
1720
  };
1721
+ var cache_marker = (hit) => {
1722
+ const word = hit ? "hit" : "miss";
1723
+ if (!PRETTY) return word;
1724
+ return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
1725
+ };
1726
+ var stats_marker = (version, replayed, snaps, patches) => {
1727
+ const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
1728
+ if (!PRETTY) return text;
1729
+ return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
1730
+ };
1731
+ var as_of_marker = (asOf) => {
1732
+ if (!asOf) return "";
1733
+ const parts = [];
1734
+ if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
1735
+ if (asOf.created_before !== void 0)
1736
+ parts.push(`created_before=${asOf.created_before.toISOString()}`);
1737
+ if (asOf.created_after !== void 0)
1738
+ parts.push(`created_after=${asOf.created_after.toISOString()}`);
1739
+ if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
1740
+ return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
1741
+ };
1348
1742
  var traced = (inner, exit, entry) => (async (...args) => {
1349
1743
  entry?.(...args);
1350
1744
  const result = await inner(...args);
@@ -1370,9 +1764,19 @@ function buildEs(logger) {
1370
1764
  )
1371
1765
  );
1372
1766
  }),
1373
- load: traced(load, void 0, (_me, stream, _cb, asOf) => {
1767
+ load: traced(load, (result, _me, stream, _cb, asOf) => {
1768
+ const stats = stats_marker(
1769
+ result.version,
1770
+ result.replayed,
1771
+ result.snaps,
1772
+ result.patches
1773
+ );
1374
1774
  logger.trace(
1375
- es_caption("load", C_GREEN, `${stream}${asOf ? " (as-of)" : ""}`)
1775
+ es_caption(
1776
+ "load",
1777
+ C_GREEN,
1778
+ `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
1779
+ )
1376
1780
  );
1377
1781
  }),
1378
1782
  action: traced(
@@ -1466,37 +1870,57 @@ function buildDrain(logger) {
1466
1870
 
1467
1871
  // src/act.ts
1468
1872
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
1873
+ var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
1469
1874
  var Act = class {
1470
1875
  constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
1471
1876
  this.registry = registry;
1472
1877
  this._states = _states;
1473
1878
  this._batch_handlers = batchHandlers;
1474
- this._subscribed_streams = new LruSet(
1475
- options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS
1476
- );
1477
1879
  this._es = buildEs(this._logger);
1478
1880
  this._cd = buildDrain(this._logger);
1479
- const statics = /* @__PURE__ */ new Map();
1480
- for (const [name, register] of Object.entries(this.registry.events)) {
1481
- if (register.reactions.size > 0) {
1482
- this._reactive_events.add(name);
1483
- }
1484
- for (const reaction of register.reactions.values()) {
1485
- if (typeof reaction.resolver === "function") {
1486
- this._has_dynamic_resolvers = true;
1487
- } else {
1488
- const { target, source } = reaction.resolver;
1489
- const key = `${target}|${source ?? ""}`;
1490
- if (!statics.has(key)) statics.set(key, { stream: target, source });
1491
- }
1492
- }
1493
- }
1494
- this._static_targets = [...statics.values()];
1495
- for (const merged of this._states.values()) {
1496
- for (const eventName of Object.keys(merged.events)) {
1497
- this._event_to_state.set(eventName, merged);
1881
+ this._handle = buildHandle({
1882
+ logger: this._logger,
1883
+ boundDo: this._bound_do,
1884
+ boundLoad: this._bound_load,
1885
+ boundQuery: this._bound_query,
1886
+ boundQueryArray: this._bound_query_array
1887
+ });
1888
+ this._handle_batch = buildHandleBatch(this._logger);
1889
+ const { staticTargets, hasDynamicResolvers, reactiveEvents, eventToState } = classifyRegistry(this.registry, this._states);
1890
+ this._reactive_events = reactiveEvents;
1891
+ this._event_to_state = eventToState;
1892
+ this._drain = new DrainController({
1893
+ logger: this._logger,
1894
+ ops: this._cd,
1895
+ registry: this.registry,
1896
+ batchHandlers: this._batch_handlers,
1897
+ handle: this._handle,
1898
+ handleBatch: this._handle_batch,
1899
+ onAcked: (acked) => this.emit("acked", acked),
1900
+ onBlocked: (blocked) => this.emit("blocked", blocked)
1901
+ });
1902
+ this._correlate = new CorrelateCycle(
1903
+ this.registry,
1904
+ staticTargets,
1905
+ hasDynamicResolvers,
1906
+ this._cd,
1907
+ options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
1908
+ // Cold start: assume drain is needed (historical events may need processing)
1909
+ () => {
1910
+ if (this._reactive_events.size > 0) this._drain.arm();
1498
1911
  }
1499
- }
1912
+ );
1913
+ this._settle = new SettleLoop(
1914
+ {
1915
+ logger: this._logger,
1916
+ init: () => this._correlate.init(),
1917
+ checkpoint: () => this._correlate.checkpoint,
1918
+ correlate: (q) => this.correlate(q),
1919
+ drain: (o) => this.drain(o),
1920
+ onSettled: (drain) => this.emit("settled", drain)
1921
+ },
1922
+ options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
1923
+ );
1500
1924
  dispose(() => {
1501
1925
  this._emitter.removeAllListeners();
1502
1926
  this.stop_correlations();
@@ -1505,30 +1929,14 @@ var Act = class {
1505
1929
  });
1506
1930
  }
1507
1931
  _emitter = new EventEmitter();
1508
- _drain_locked = false;
1509
- _drain_lag2lead_ratio = 0.5;
1510
- _correlation_timer = void 0;
1511
- _settle_timer = void 0;
1512
- _settling = false;
1513
- _correlation_checkpoint = -1;
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;
1526
- _has_dynamic_resolvers = false;
1527
- _correlation_initialized = false;
1528
1932
  /** Event names with at least one registered reaction (computed at build time) */
1529
- _reactive_events = /* @__PURE__ */ new Set();
1530
- /** Set in do() when a committed event has reactions — cleared by drain() */
1531
- _needs_drain = false;
1933
+ _reactive_events;
1934
+ /** Drain pipeline driver: armed flag, concurrency lock, adaptive ratio. */
1935
+ _drain;
1936
+ /** Correlation state machine: lazy init, dynamic-resolver scan, periodic worker. */
1937
+ _correlate;
1938
+ /** Debounced correlate→drain catch-up loop. */
1939
+ _settle;
1532
1940
  /**
1533
1941
  * Emit a lifecycle event. The payload type is inferred from the event name
1534
1942
  * via {@link ActLifecycleEvents}.
@@ -1557,8 +1965,6 @@ var Act = class {
1557
1965
  * @param registry The registry of state, event, and action schemas
1558
1966
  * @param states Map of state names to their (potentially merged) state definitions
1559
1967
  */
1560
- /** Static resolver targets collected at build time */
1561
- _static_targets;
1562
1968
  /** Batch handlers for static-target projections (target → handler) */
1563
1969
  _batch_handlers;
1564
1970
  /** Event-sourcing handlers, optionally wrapped with trace decorators */
@@ -1571,7 +1977,7 @@ var Act = class {
1571
1977
  * this lookup is unambiguous. Used by `close()` to pick the right reducer
1572
1978
  * set when seeding a `restart` snapshot in multi-state apps.
1573
1979
  */
1574
- _event_to_state = /* @__PURE__ */ new Map();
1980
+ _event_to_state;
1575
1981
  /** Logger resolved at construction time (after user port configuration) */
1576
1982
  _logger = log();
1577
1983
  /** Pre-bound IAct methods reused across drain cycles. Only `do` varies per
@@ -1580,9 +1986,9 @@ var Act = class {
1580
1986
  _bound_load = this.load.bind(this);
1581
1987
  _bound_query = this.query.bind(this);
1582
1988
  _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);
1989
+ /** Reaction dispatchers built once and handed to runDrainCycle each cycle. */
1990
+ _handle;
1991
+ _handle_batch;
1586
1992
  /**
1587
1993
  * Executes an action on a state instance, committing resulting events.
1588
1994
  *
@@ -1673,10 +2079,12 @@ var Act = class {
1673
2079
  reactingTo,
1674
2080
  skipValidation
1675
2081
  );
1676
- for (const snap2 of snapshots) {
1677
- if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
1678
- this._needs_drain = true;
1679
- break;
2082
+ if (this._reactive_events.size > 0) {
2083
+ for (const snap2 of snapshots) {
2084
+ if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
2085
+ this._drain.arm();
2086
+ break;
2087
+ }
1680
2088
  }
1681
2089
  }
1682
2090
  this.emit("committed", snapshots);
@@ -1784,109 +2192,6 @@ var Act = class {
1784
2192
  await store().query((e) => events.push(e), query);
1785
2193
  return events;
1786
2194
  }
1787
- /**
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.
1812
- *
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.
1818
- *
1819
- * @internal
1820
- */
1821
- async handle(lease, payloads) {
1822
- if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
1823
- const stream = lease.stream;
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}).`);
1828
- const doAction = this._bound_do;
1829
- const scopedApp = {
1830
- do: doAction,
1831
- load: this._bound_load,
1832
- query: this._bound_query,
1833
- query_array: this._bound_query_array
1834
- };
1835
- for (const payload of payloads) {
1836
- const { event, handler } = payload;
1837
- scopedApp.do = (action2, target, payload2, reactingTo, skipValidation) => doAction(
1838
- action2,
1839
- target,
1840
- payload2,
1841
- reactingTo ?? event,
1842
- skipValidation
1843
- );
1844
- try {
1845
- await handler(event, stream, scopedApp);
1846
- at = event.id;
1847
- handled++;
1848
- } catch (error) {
1849
- return this._finalize(
1850
- lease,
1851
- handled,
1852
- at,
1853
- error,
1854
- payload.options
1855
- );
1856
- }
1857
- }
1858
- return this._finalize(lease, handled, at, void 0, payloads[0].options);
1859
- }
1860
- /**
1861
- * Handles a batch of events for a projection with a batch handler.
1862
- *
1863
- * Called by `drain()` when a leased stream is a static-target projection
1864
- * with a registered batch handler. All events are passed to the handler
1865
- * in a single call, enabling bulk DB operations.
1866
- *
1867
- * @internal
1868
- */
1869
- async handleBatch(lease, payloads, batchHandler) {
1870
- const stream = lease.stream;
1871
- const events = payloads.map((p) => p.event);
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
- );
1877
- try {
1878
- await batchHandler(events, stream);
1879
- return this._finalize(
1880
- lease,
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);
1888
- }
1889
- }
1890
2195
  /**
1891
2196
  * Processes pending reactions by draining uncommitted events from the event store.
1892
2197
  *
@@ -1926,54 +2231,8 @@ var Act = class {
1926
2231
  * @see {@link correlate} for dynamic stream discovery
1927
2232
  * @see {@link start_correlations} for automatic correlation
1928
2233
  */
1929
- async drain({
1930
- streamLimit = 10,
1931
- eventLimit = 10,
1932
- leaseMillis = 1e4
1933
- } = {}) {
1934
- if (!this._needs_drain) {
1935
- return { fetched: [], leased: [], acked: [], blocked: [] };
1936
- }
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: [] };
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;
1976
- }
2234
+ async drain(options = {}) {
2235
+ return this._drain.drain(options);
1977
2236
  }
1978
2237
  /**
1979
2238
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -2020,71 +2279,8 @@ var Act = class {
2020
2279
  * @see {@link start_correlations} for automatic periodic correlation
2021
2280
  * @see {@link stop_correlations} to stop automatic correlation
2022
2281
  */
2023
- /**
2024
- * Initialize correlation state on first call.
2025
- * - Reads max(at) from store as cold-start checkpoint
2026
- * - Subscribes static resolver targets (idempotent upsert)
2027
- * - Populates the subscribed statics set
2028
- * @internal
2029
- */
2030
- async _init_correlation() {
2031
- if (this._correlation_initialized) return;
2032
- this._correlation_initialized = true;
2033
- const { watermark } = await store().subscribe(this._static_targets);
2034
- this._correlation_checkpoint = watermark;
2035
- if (this._reactive_events.size > 0) this._needs_drain = true;
2036
- for (const { stream } of this._static_targets) {
2037
- this._subscribed_streams.add(stream);
2038
- }
2039
- }
2040
2282
  async correlate(query = { after: -1, limit: 10 }) {
2041
- await this._init_correlation();
2042
- if (!this._has_dynamic_resolvers)
2043
- return { subscribed: 0, last_id: this._correlation_checkpoint };
2044
- const after = Math.max(this._correlation_checkpoint, query.after || -1);
2045
- const correlated = /* @__PURE__ */ new Map();
2046
- let last_id = after;
2047
- await store().query(
2048
- (event) => {
2049
- last_id = event.id;
2050
- const register = this.registry.events[event.name];
2051
- if (register) {
2052
- for (const reaction of register.reactions.values()) {
2053
- if (typeof reaction.resolver !== "function") continue;
2054
- const resolved = reaction.resolver(event);
2055
- if (resolved && !this._subscribed_streams.has(resolved.target)) {
2056
- const entry = correlated.get(resolved.target) || {
2057
- source: resolved.source,
2058
- payloads: []
2059
- };
2060
- entry.payloads.push({
2061
- ...reaction,
2062
- source: resolved.source,
2063
- event
2064
- });
2065
- correlated.set(resolved.target, entry);
2066
- }
2067
- }
2068
- }
2069
- },
2070
- { ...query, after }
2071
- );
2072
- if (correlated.size) {
2073
- const streams = [...correlated.entries()].map(([stream, { source }]) => ({
2074
- stream,
2075
- source
2076
- }));
2077
- const { subscribed } = await this._cd.subscribe(streams);
2078
- this._correlation_checkpoint = last_id;
2079
- if (subscribed) {
2080
- for (const { stream } of streams) {
2081
- this._subscribed_streams.add(stream);
2082
- }
2083
- }
2084
- return { subscribed, last_id };
2085
- }
2086
- this._correlation_checkpoint = last_id;
2087
- return { subscribed: 0, last_id };
2283
+ return this._correlate.correlate(query);
2088
2284
  }
2089
2285
  /**
2090
2286
  * Starts automatic periodic correlation worker for discovering new streams.
@@ -2142,15 +2338,7 @@ var Act = class {
2142
2338
  * @see {@link stop_correlations} to stop the worker
2143
2339
  */
2144
2340
  start_correlations(query = {}, frequency = 1e4, callback) {
2145
- if (this._correlation_timer) return false;
2146
- const limit = query.limit || 100;
2147
- this._correlation_timer = setInterval(
2148
- () => this.correlate({ ...query, after: this._correlation_checkpoint, limit }).then((result) => {
2149
- if (callback && result.subscribed) callback(result.subscribed);
2150
- }).catch(console.error),
2151
- frequency
2152
- );
2153
- return true;
2341
+ return this._correlate.startPolling(query, frequency, callback);
2154
2342
  }
2155
2343
  /**
2156
2344
  * Stops the automatic correlation worker.
@@ -2170,10 +2358,7 @@ var Act = class {
2170
2358
  * @see {@link start_correlations}
2171
2359
  */
2172
2360
  stop_correlations() {
2173
- if (this._correlation_timer) {
2174
- clearInterval(this._correlation_timer);
2175
- this._correlation_timer = void 0;
2176
- }
2361
+ this._correlate.stopPolling();
2177
2362
  }
2178
2363
  /**
2179
2364
  * Cancels any pending or active settle cycle.
@@ -2181,10 +2366,7 @@ var Act = class {
2181
2366
  * @see {@link settle}
2182
2367
  */
2183
2368
  stop_settling() {
2184
- if (this._settle_timer) {
2185
- clearTimeout(this._settle_timer);
2186
- this._settle_timer = void 0;
2187
- }
2369
+ this._settle.stop();
2188
2370
  }
2189
2371
  /**
2190
2372
  * Reset reaction stream watermarks and request a drain on the next
@@ -2221,9 +2403,7 @@ var Act = class {
2221
2403
  */
2222
2404
  async reset(streams) {
2223
2405
  const count = await store().reset(streams);
2224
- if (count > 0 && this._reactive_events.size > 0) {
2225
- this._needs_drain = true;
2226
- }
2406
+ if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
2227
2407
  return count;
2228
2408
  }
2229
2409
  /**
@@ -2306,34 +2486,7 @@ var Act = class {
2306
2486
  * @see {@link correlate} for manual correlation
2307
2487
  */
2308
2488
  settle(options = {}) {
2309
- const {
2310
- debounceMs = 10,
2311
- correlate: correlateQuery = { after: -1, limit: 100 },
2312
- maxPasses = Infinity,
2313
- ...drainOptions
2314
- } = options;
2315
- if (this._settle_timer) clearTimeout(this._settle_timer);
2316
- this._settle_timer = setTimeout(() => {
2317
- this._settle_timer = void 0;
2318
- if (this._settling) return;
2319
- this._settling = true;
2320
- (async () => {
2321
- await this._init_correlation();
2322
- let lastDrain;
2323
- for (let i = 0; i < maxPasses; i++) {
2324
- const { subscribed } = await this.correlate({
2325
- ...correlateQuery,
2326
- after: this._correlation_checkpoint
2327
- });
2328
- lastDrain = await this.drain(drainOptions);
2329
- const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
2330
- if (!made_progress) break;
2331
- }
2332
- if (lastDrain) this.emit("settled", lastDrain);
2333
- })().catch((err) => this._logger.error(err)).finally(() => {
2334
- this._settling = false;
2335
- });
2336
- }, debounceMs);
2489
+ this._settle.schedule(options);
2337
2490
  }
2338
2491
  };
2339
2492
 
@@ -2615,6 +2768,7 @@ export {
2615
2768
  ConcurrencyError,
2616
2769
  ConsoleLogger,
2617
2770
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
2771
+ DEFAULT_SETTLE_DEBOUNCE_MS,
2618
2772
  Environments,
2619
2773
  Errors,
2620
2774
  EventMetaSchema,