@rotorsoft/act 0.32.6 → 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 +2 -2
  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.cjs CHANGED
@@ -37,6 +37,7 @@ __export(index_exports, {
37
37
  ConcurrencyError: () => ConcurrencyError,
38
38
  ConsoleLogger: () => ConsoleLogger,
39
39
  DEFAULT_MAX_SUBSCRIBED_STREAMS: () => DEFAULT_MAX_SUBSCRIBED_STREAMS,
40
+ DEFAULT_SETTLE_DEBOUNCE_MS: () => DEFAULT_SETTLE_DEBOUNCE_MS,
40
41
  Environments: () => Environments,
41
42
  Errors: () => Errors,
42
43
  EventMetaSchema: () => EventMetaSchema,
@@ -183,7 +184,7 @@ var ConsoleLogger = class _ConsoleLogger {
183
184
  }
184
185
  };
185
186
 
186
- // src/internal/lru-map.ts
187
+ // src/lru-map.ts
187
188
  var LruMap = class {
188
189
  constructor(_maxSize) {
189
190
  this._maxSize = _maxSize;
@@ -973,6 +974,37 @@ process.once("unhandledRejection", async (arg) => {
973
974
  // src/act.ts
974
975
  var import_events = __toESM(require("events"), 1);
975
976
 
977
+ // src/internal/build-classify.ts
978
+ function classifyRegistry(registry, states) {
979
+ const statics = /* @__PURE__ */ new Map();
980
+ const reactiveEvents = /* @__PURE__ */ new Set();
981
+ let hasDynamicResolvers = false;
982
+ for (const [name, register] of Object.entries(registry.events)) {
983
+ if (register.reactions.size > 0) reactiveEvents.add(name);
984
+ for (const reaction of register.reactions.values()) {
985
+ if (typeof reaction.resolver === "function") {
986
+ hasDynamicResolvers = true;
987
+ } else {
988
+ const { target, source } = reaction.resolver;
989
+ const key = `${target}|${source ?? ""}`;
990
+ if (!statics.has(key)) statics.set(key, { stream: target, source });
991
+ }
992
+ }
993
+ }
994
+ const eventToState = /* @__PURE__ */ new Map();
995
+ for (const merged of states.values()) {
996
+ for (const eventName of Object.keys(merged.events)) {
997
+ eventToState.set(eventName, merged);
998
+ }
999
+ }
1000
+ return {
1001
+ staticTargets: [...statics.values()],
1002
+ hasDynamicResolvers,
1003
+ reactiveEvents,
1004
+ eventToState
1005
+ };
1006
+ }
1007
+
976
1008
  // src/internal/close-cycle.ts
977
1009
  var import_crypto = require("crypto");
978
1010
  async function runCloseCycle(targets, deps) {
@@ -1128,8 +1160,142 @@ async function truncateAndWarmCache(guarded, seedStates, guardEvents, correlatio
1128
1160
  return truncated;
1129
1161
  }
1130
1162
 
1163
+ // src/internal/correlate-cycle.ts
1164
+ var CorrelateCycle = class {
1165
+ constructor(registry, staticTargets, hasDynamicResolvers, cd, maxSubscribedStreams, onInit) {
1166
+ this.registry = registry;
1167
+ this.staticTargets = staticTargets;
1168
+ this.hasDynamicResolvers = hasDynamicResolvers;
1169
+ this.cd = cd;
1170
+ this.onInit = onInit;
1171
+ this._subscribed = new LruSet(maxSubscribedStreams);
1172
+ }
1173
+ _checkpoint = -1;
1174
+ _initialized = false;
1175
+ _timer = void 0;
1176
+ _subscribed;
1177
+ /** Last correlated event id. */
1178
+ get checkpoint() {
1179
+ return this._checkpoint;
1180
+ }
1181
+ /**
1182
+ * Initialize correlation state on first call.
1183
+ * - Reads max(at) from store as cold-start checkpoint
1184
+ * - Subscribes static resolver targets (idempotent upsert)
1185
+ * - Populates the subscribed-streams LRU
1186
+ * - Fires `onInit` once (Act uses this to flag a cold-start drain)
1187
+ */
1188
+ async init() {
1189
+ if (this._initialized) return;
1190
+ this._initialized = true;
1191
+ const { watermark } = await store().subscribe([...this.staticTargets]);
1192
+ this._checkpoint = watermark;
1193
+ this.onInit?.();
1194
+ for (const { stream } of this.staticTargets) {
1195
+ this._subscribed.add(stream);
1196
+ }
1197
+ }
1198
+ /**
1199
+ * Discover dynamic-resolver targets in the events past the checkpoint
1200
+ * and register any new streams via `cd.subscribe`. Static targets are
1201
+ * subscribed at init time, so this only walks dynamic resolvers.
1202
+ */
1203
+ async correlate(query = { after: -1, limit: 10 }) {
1204
+ await this.init();
1205
+ if (!this.hasDynamicResolvers)
1206
+ return { subscribed: 0, last_id: this._checkpoint };
1207
+ const after = Math.max(this._checkpoint, query.after || -1);
1208
+ const correlated = /* @__PURE__ */ new Map();
1209
+ let last_id = after;
1210
+ await store().query(
1211
+ (event) => {
1212
+ last_id = event.id;
1213
+ const register = this.registry.events[event.name];
1214
+ if (register) {
1215
+ for (const reaction of register.reactions.values()) {
1216
+ if (typeof reaction.resolver !== "function") continue;
1217
+ const resolved = reaction.resolver(event);
1218
+ if (resolved && !this._subscribed.has(resolved.target)) {
1219
+ const entry = correlated.get(resolved.target) || {
1220
+ source: resolved.source,
1221
+ payloads: []
1222
+ };
1223
+ entry.payloads.push({
1224
+ ...reaction,
1225
+ source: resolved.source,
1226
+ event
1227
+ });
1228
+ correlated.set(resolved.target, entry);
1229
+ }
1230
+ }
1231
+ }
1232
+ },
1233
+ { ...query, after }
1234
+ );
1235
+ if (correlated.size) {
1236
+ const streams = [...correlated.entries()].map(([stream, { source }]) => ({
1237
+ stream,
1238
+ source
1239
+ }));
1240
+ const { subscribed } = await this.cd.subscribe(streams);
1241
+ this._checkpoint = last_id;
1242
+ if (subscribed) {
1243
+ for (const { stream } of streams) {
1244
+ this._subscribed.add(stream);
1245
+ }
1246
+ }
1247
+ return { subscribed, last_id };
1248
+ }
1249
+ this._checkpoint = last_id;
1250
+ return { subscribed: 0, last_id };
1251
+ }
1252
+ /**
1253
+ * Start a periodic correlation worker. Returns false if one is already
1254
+ * running. Errors from `correlate()` are sent to `console.error` (matches
1255
+ * pre-extraction behavior; the timer keeps running on failure).
1256
+ */
1257
+ startPolling(query = {}, frequency = 1e4, callback) {
1258
+ if (this._timer) return false;
1259
+ const limit = query.limit || 100;
1260
+ this._timer = setInterval(
1261
+ () => this.correlate({ ...query, after: this._checkpoint, limit }).then((result) => {
1262
+ if (callback && result.subscribed) callback(result.subscribed);
1263
+ }).catch(console.error),
1264
+ frequency
1265
+ );
1266
+ return true;
1267
+ }
1268
+ /** Stop the periodic correlation worker. Idempotent. */
1269
+ stopPolling() {
1270
+ if (this._timer) {
1271
+ clearInterval(this._timer);
1272
+ this._timer = void 0;
1273
+ }
1274
+ }
1275
+ };
1276
+
1131
1277
  // src/internal/drain-cycle.ts
1132
1278
  var import_crypto2 = require("crypto");
1279
+
1280
+ // src/internal/drain-ratio.ts
1281
+ var RATIO_MIN = 0.2;
1282
+ var RATIO_MAX = 0.8;
1283
+ var RATIO_DEFAULT = 0.5;
1284
+ function computeLagLeadRatio(handled, lagging, leading) {
1285
+ let lagging_handled = 0;
1286
+ let leading_handled = 0;
1287
+ for (const { lease, handled: count } of handled) {
1288
+ if (lease.lagging) lagging_handled += count;
1289
+ else leading_handled += count;
1290
+ }
1291
+ const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1292
+ const leading_avg = leading > 0 ? leading_handled / leading : 0;
1293
+ const total = lagging_avg + leading_avg;
1294
+ if (total === 0) return RATIO_DEFAULT;
1295
+ return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1296
+ }
1297
+
1298
+ // src/internal/drain-cycle.ts
1133
1299
  async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis) {
1134
1300
  const leased = await ops.claim(lagging, leading, (0, import_crypto2.randomUUID)(), leaseMillis);
1135
1301
  if (!leased.length) return void 0;
@@ -1171,24 +1337,73 @@ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch,
1171
1337
  );
1172
1338
  return { leased, fetched, handled, acked, blocked };
1173
1339
  }
1174
-
1175
- // src/internal/drain-ratio.ts
1176
- var RATIO_MIN = 0.2;
1177
- var RATIO_MAX = 0.8;
1178
- var RATIO_DEFAULT = 0.5;
1179
- function computeLagLeadRatio(handled, lagging, leading) {
1180
- let lagging_handled = 0;
1181
- let leading_handled = 0;
1182
- for (const { lease, handled: count } of handled) {
1183
- if (lease.lagging) lagging_handled += count;
1184
- else leading_handled += count;
1340
+ var EMPTY_DRAIN = {
1341
+ fetched: [],
1342
+ leased: [],
1343
+ acked: [],
1344
+ blocked: []
1345
+ };
1346
+ var DrainController = class {
1347
+ constructor(deps) {
1348
+ this.deps = deps;
1185
1349
  }
1186
- const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1187
- const leading_avg = leading > 0 ? leading_handled / leading : 0;
1188
- const total = lagging_avg + leading_avg;
1189
- if (total === 0) return RATIO_DEFAULT;
1190
- return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1191
- }
1350
+ _armed = false;
1351
+ _locked = false;
1352
+ _ratio = 0.5;
1353
+ /**
1354
+ * Signal that a commit (or reset / cold-start) may have produced work.
1355
+ * Subsequent `drain()` calls will run the pipeline; once the pipeline
1356
+ * settles to no-progress, the controller disarms itself.
1357
+ */
1358
+ arm() {
1359
+ this._armed = true;
1360
+ }
1361
+ /** Read-only flag — true while a commit / reset is unprocessed. */
1362
+ get armed() {
1363
+ return this._armed;
1364
+ }
1365
+ /** Run one drain pass. Short-circuits when not armed or already running. */
1366
+ async drain({
1367
+ streamLimit = 10,
1368
+ eventLimit = 10,
1369
+ leaseMillis = 1e4
1370
+ } = {}) {
1371
+ if (!this._armed) return EMPTY_DRAIN;
1372
+ if (this._locked) return EMPTY_DRAIN;
1373
+ try {
1374
+ this._locked = true;
1375
+ const lagging = Math.ceil(streamLimit * this._ratio);
1376
+ const leading = streamLimit - lagging;
1377
+ const cycle = await runDrainCycle(
1378
+ this.deps.ops,
1379
+ this.deps.registry,
1380
+ this.deps.batchHandlers,
1381
+ this.deps.handle,
1382
+ this.deps.handleBatch,
1383
+ lagging,
1384
+ leading,
1385
+ eventLimit,
1386
+ leaseMillis
1387
+ );
1388
+ if (!cycle) {
1389
+ this._armed = false;
1390
+ return EMPTY_DRAIN;
1391
+ }
1392
+ const { leased, fetched, handled, acked, blocked } = cycle;
1393
+ this._ratio = computeLagLeadRatio(handled, lagging, leading);
1394
+ if (acked.length) this.deps.onAcked(acked);
1395
+ if (blocked.length) this.deps.onBlocked(blocked);
1396
+ const hasErrors = handled.some(({ error }) => error);
1397
+ if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
1398
+ return { fetched, leased, acked, blocked };
1399
+ } catch (error) {
1400
+ this.deps.logger.error(error);
1401
+ return EMPTY_DRAIN;
1402
+ } finally {
1403
+ this._locked = false;
1404
+ }
1405
+ }
1406
+ };
1192
1407
 
1193
1408
  // src/internal/merge.ts
1194
1409
  var import_zod4 = require("zod");
@@ -1329,6 +1544,140 @@ var _this_ = ({ stream }) => ({
1329
1544
  target: stream
1330
1545
  });
1331
1546
 
1547
+ // src/internal/reactions.ts
1548
+ function finalize(lease, handled, at, error, options, logger) {
1549
+ if (!error) return { lease, handled, at };
1550
+ logger.error(error);
1551
+ const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1552
+ if (block2)
1553
+ logger.error(`Blocking ${lease.stream} after ${lease.retry} retries.`);
1554
+ return {
1555
+ lease,
1556
+ handled,
1557
+ at,
1558
+ error: handled === 0 ? error.message : void 0,
1559
+ block: block2
1560
+ };
1561
+ }
1562
+ function buildHandle(deps) {
1563
+ const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
1564
+ return async (lease, payloads) => {
1565
+ if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
1566
+ const stream = lease.stream;
1567
+ let at = payloads.at(0).event.id;
1568
+ let handled = 0;
1569
+ if (lease.retry > 0)
1570
+ logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1571
+ const scopedApp = {
1572
+ do: boundDo,
1573
+ load: boundLoad,
1574
+ query: boundQuery,
1575
+ query_array: boundQueryArray
1576
+ };
1577
+ for (const payload of payloads) {
1578
+ const { event, handler } = payload;
1579
+ scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
1580
+ action2,
1581
+ target,
1582
+ actionPayload,
1583
+ reactingTo ?? event,
1584
+ skipValidation
1585
+ );
1586
+ try {
1587
+ await handler(event, stream, scopedApp);
1588
+ at = event.id;
1589
+ handled++;
1590
+ } catch (error) {
1591
+ return finalize(
1592
+ lease,
1593
+ handled,
1594
+ at,
1595
+ error,
1596
+ payload.options,
1597
+ logger
1598
+ );
1599
+ }
1600
+ }
1601
+ return finalize(lease, handled, at, void 0, payloads[0].options, logger);
1602
+ };
1603
+ }
1604
+ function buildHandleBatch(logger) {
1605
+ return async (lease, payloads, batchHandler) => {
1606
+ const stream = lease.stream;
1607
+ const events = payloads.map((p) => p.event);
1608
+ const options = payloads[0].options;
1609
+ if (lease.retry > 0)
1610
+ logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
1611
+ try {
1612
+ await batchHandler(events, stream);
1613
+ return finalize(
1614
+ lease,
1615
+ events.length,
1616
+ events.at(-1).id,
1617
+ void 0,
1618
+ options,
1619
+ logger
1620
+ );
1621
+ } catch (error) {
1622
+ return finalize(lease, 0, lease.at, error, options, logger);
1623
+ }
1624
+ };
1625
+ }
1626
+
1627
+ // src/internal/settle.ts
1628
+ var SettleLoop = class {
1629
+ constructor(deps, defaultDebounceMs) {
1630
+ this.deps = deps;
1631
+ this.defaultDebounceMs = defaultDebounceMs;
1632
+ }
1633
+ _timer = void 0;
1634
+ _running = false;
1635
+ /**
1636
+ * Schedule a settle pass. Multiple calls inside the debounce window
1637
+ * coalesce into one cycle. The cycle runs correlate→drain in a loop
1638
+ * until no progress is made (no new subscriptions, no acks, no blocks)
1639
+ * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
1640
+ * via {@link SettleDeps.onSettled}.
1641
+ */
1642
+ schedule(options = {}) {
1643
+ const {
1644
+ debounceMs = this.defaultDebounceMs,
1645
+ correlate: correlateQuery = { after: -1, limit: 100 },
1646
+ maxPasses = Infinity,
1647
+ ...drainOptions
1648
+ } = options;
1649
+ if (this._timer) clearTimeout(this._timer);
1650
+ this._timer = setTimeout(() => {
1651
+ this._timer = void 0;
1652
+ if (this._running) return;
1653
+ this._running = true;
1654
+ (async () => {
1655
+ await this.deps.init();
1656
+ let lastDrain;
1657
+ for (let i = 0; i < maxPasses; i++) {
1658
+ const { subscribed } = await this.deps.correlate({
1659
+ ...correlateQuery,
1660
+ after: this.deps.checkpoint()
1661
+ });
1662
+ lastDrain = await this.deps.drain(drainOptions);
1663
+ const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
1664
+ if (!made_progress) break;
1665
+ }
1666
+ if (lastDrain) this.deps.onSettled(lastDrain);
1667
+ })().catch((err) => this.deps.logger.error(err)).finally(() => {
1668
+ this._running = false;
1669
+ });
1670
+ }, debounceMs);
1671
+ }
1672
+ /** Cancel any pending or active settle cycle. Idempotent. */
1673
+ stop() {
1674
+ if (this._timer) {
1675
+ clearTimeout(this._timer);
1676
+ this._timer = void 0;
1677
+ }
1678
+ }
1679
+ };
1680
+
1332
1681
  // src/internal/drain.ts
1333
1682
  var claim = (lagging, leading, by, millis) => store().claim(lagging, leading, by, millis);
1334
1683
  async function fetch(leased, eventLimit) {
@@ -1385,26 +1734,40 @@ async function tombstone(stream, expectedVersion, correlation) {
1385
1734
  async function load(me, stream, callback, asOf) {
1386
1735
  const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
1387
1736
  const cached = timeTravel ? void 0 : await cache().get(stream);
1737
+ const cache_hit = !!cached;
1388
1738
  let state2 = cached?.state ?? (me.init ? me.init() : {});
1389
1739
  let patches = cached?.patches ?? 0;
1390
1740
  let snaps = cached?.snaps ?? 0;
1741
+ let version = cached?.version ?? -1;
1742
+ let replayed = 0;
1391
1743
  let event;
1392
1744
  await store().query(
1393
1745
  (e) => {
1394
1746
  event = e;
1747
+ version = e.version;
1395
1748
  if (e.name === SNAP_EVENT) {
1396
1749
  state2 = e.data;
1397
1750
  snaps++;
1398
1751
  patches = 0;
1752
+ replayed++;
1399
1753
  } else if (me.patch[e.name]) {
1400
1754
  state2 = (0, import_act_patch.patch)(state2, me.patch[e.name](event, state2));
1401
1755
  patches++;
1756
+ replayed++;
1402
1757
  } else if (e.name !== TOMBSTONE_EVENT) {
1403
1758
  log().warn(
1404
1759
  `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
1405
1760
  );
1406
1761
  }
1407
- callback && callback({ event, state: state2, patches, snaps });
1762
+ callback && callback({
1763
+ event,
1764
+ state: state2,
1765
+ version,
1766
+ patches,
1767
+ snaps,
1768
+ cache_hit,
1769
+ replayed
1770
+ });
1408
1771
  },
1409
1772
  {
1410
1773
  stream,
@@ -1412,7 +1775,7 @@ async function load(me, stream, callback, asOf) {
1412
1775
  ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
1413
1776
  }
1414
1777
  );
1415
- return { event, state: state2, patches, snaps };
1778
+ return { event, state: state2, version, patches, snaps, cache_hit, replayed };
1416
1779
  }
1417
1780
  async function action(me, action2, target, payload, reactingTo, skipValidation = false) {
1418
1781
  const { stream, expectedVersion, actor } = target;
@@ -1484,7 +1847,16 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
1484
1847
  const p = me.patch[event.name](event, state2);
1485
1848
  state2 = (0, import_act_patch.patch)(state2, p);
1486
1849
  patches++;
1487
- return { event, state: state2, patches, snaps: snapshot.snaps, patch: p };
1850
+ return {
1851
+ event,
1852
+ state: state2,
1853
+ version: event.version,
1854
+ patches,
1855
+ snaps: snapshot.snaps,
1856
+ patch: p,
1857
+ cache_hit: snapshot.cache_hit,
1858
+ replayed: snapshot.replayed
1859
+ };
1488
1860
  });
1489
1861
  const last = snapshots.at(-1);
1490
1862
  const snapped = me.snap && me.snap(last);
@@ -1506,12 +1878,35 @@ var C_ORANGE = "\x1B[38;5;208m";
1506
1878
  var C_GREEN = "\x1B[38;5;42m";
1507
1879
  var C_MAGENTA = "\x1B[38;5;165m";
1508
1880
  var C_DRAIN = "\x1B[38;5;244m";
1881
+ var C_HIT = "\x1B[38;5;82m";
1882
+ var C_MISS = "\x1B[38;5;220m";
1509
1883
  var C_RESET = "\x1B[0m";
1510
1884
  var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
1511
1885
  var drain_caption = (caption) => {
1512
1886
  const tag = `>> ${caption}`;
1513
1887
  return PRETTY ? `${C_DRAIN}${tag}${C_RESET}` : tag;
1514
1888
  };
1889
+ var cache_marker = (hit) => {
1890
+ const word = hit ? "hit" : "miss";
1891
+ if (!PRETTY) return word;
1892
+ return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
1893
+ };
1894
+ var stats_marker = (version, replayed, snaps, patches) => {
1895
+ const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
1896
+ if (!PRETTY) return text;
1897
+ return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
1898
+ };
1899
+ var as_of_marker = (asOf) => {
1900
+ if (!asOf) return "";
1901
+ const parts = [];
1902
+ if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
1903
+ if (asOf.created_before !== void 0)
1904
+ parts.push(`created_before=${asOf.created_before.toISOString()}`);
1905
+ if (asOf.created_after !== void 0)
1906
+ parts.push(`created_after=${asOf.created_after.toISOString()}`);
1907
+ if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
1908
+ return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
1909
+ };
1515
1910
  var traced = (inner, exit, entry) => (async (...args) => {
1516
1911
  entry?.(...args);
1517
1912
  const result = await inner(...args);
@@ -1537,9 +1932,19 @@ function buildEs(logger) {
1537
1932
  )
1538
1933
  );
1539
1934
  }),
1540
- load: traced(load, void 0, (_me, stream, _cb, asOf) => {
1935
+ load: traced(load, (result, _me, stream, _cb, asOf) => {
1936
+ const stats = stats_marker(
1937
+ result.version,
1938
+ result.replayed,
1939
+ result.snaps,
1940
+ result.patches
1941
+ );
1541
1942
  logger.trace(
1542
- es_caption("load", C_GREEN, `${stream}${asOf ? " (as-of)" : ""}`)
1943
+ es_caption(
1944
+ "load",
1945
+ C_GREEN,
1946
+ `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
1947
+ )
1543
1948
  );
1544
1949
  }),
1545
1950
  action: traced(
@@ -1633,37 +2038,57 @@ function buildDrain(logger) {
1633
2038
 
1634
2039
  // src/act.ts
1635
2040
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2041
+ var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
1636
2042
  var Act = class {
1637
2043
  constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
1638
2044
  this.registry = registry;
1639
2045
  this._states = _states;
1640
2046
  this._batch_handlers = batchHandlers;
1641
- this._subscribed_streams = new LruSet(
1642
- options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS
1643
- );
1644
2047
  this._es = buildEs(this._logger);
1645
2048
  this._cd = buildDrain(this._logger);
1646
- const statics = /* @__PURE__ */ new Map();
1647
- for (const [name, register] of Object.entries(this.registry.events)) {
1648
- if (register.reactions.size > 0) {
1649
- this._reactive_events.add(name);
1650
- }
1651
- for (const reaction of register.reactions.values()) {
1652
- if (typeof reaction.resolver === "function") {
1653
- this._has_dynamic_resolvers = true;
1654
- } else {
1655
- const { target, source } = reaction.resolver;
1656
- const key = `${target}|${source ?? ""}`;
1657
- if (!statics.has(key)) statics.set(key, { stream: target, source });
1658
- }
1659
- }
1660
- }
1661
- this._static_targets = [...statics.values()];
1662
- for (const merged of this._states.values()) {
1663
- for (const eventName of Object.keys(merged.events)) {
1664
- this._event_to_state.set(eventName, merged);
2049
+ this._handle = buildHandle({
2050
+ logger: this._logger,
2051
+ boundDo: this._bound_do,
2052
+ boundLoad: this._bound_load,
2053
+ boundQuery: this._bound_query,
2054
+ boundQueryArray: this._bound_query_array
2055
+ });
2056
+ this._handle_batch = buildHandleBatch(this._logger);
2057
+ const { staticTargets, hasDynamicResolvers, reactiveEvents, eventToState } = classifyRegistry(this.registry, this._states);
2058
+ this._reactive_events = reactiveEvents;
2059
+ this._event_to_state = eventToState;
2060
+ this._drain = new DrainController({
2061
+ logger: this._logger,
2062
+ ops: this._cd,
2063
+ registry: this.registry,
2064
+ batchHandlers: this._batch_handlers,
2065
+ handle: this._handle,
2066
+ handleBatch: this._handle_batch,
2067
+ onAcked: (acked) => this.emit("acked", acked),
2068
+ onBlocked: (blocked) => this.emit("blocked", blocked)
2069
+ });
2070
+ this._correlate = new CorrelateCycle(
2071
+ this.registry,
2072
+ staticTargets,
2073
+ hasDynamicResolvers,
2074
+ this._cd,
2075
+ options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
2076
+ // Cold start: assume drain is needed (historical events may need processing)
2077
+ () => {
2078
+ if (this._reactive_events.size > 0) this._drain.arm();
1665
2079
  }
1666
- }
2080
+ );
2081
+ this._settle = new SettleLoop(
2082
+ {
2083
+ logger: this._logger,
2084
+ init: () => this._correlate.init(),
2085
+ checkpoint: () => this._correlate.checkpoint,
2086
+ correlate: (q) => this.correlate(q),
2087
+ drain: (o) => this.drain(o),
2088
+ onSettled: (drain) => this.emit("settled", drain)
2089
+ },
2090
+ options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
2091
+ );
1667
2092
  dispose(() => {
1668
2093
  this._emitter.removeAllListeners();
1669
2094
  this.stop_correlations();
@@ -1672,30 +2097,14 @@ var Act = class {
1672
2097
  });
1673
2098
  }
1674
2099
  _emitter = new import_events.default();
1675
- _drain_locked = false;
1676
- _drain_lag2lead_ratio = 0.5;
1677
- _correlation_timer = void 0;
1678
- _settle_timer = void 0;
1679
- _settling = false;
1680
- _correlation_checkpoint = -1;
1681
- /**
1682
- * Streams already subscribed via store.subscribe() — both the static
1683
- * targets registered at init and dynamic targets discovered by
1684
- * correlate(). correlate() consults this set to avoid re-subscribing
1685
- * known streams.
1686
- *
1687
- * Bounded LRU so apps that mint millions of dynamic targets (one per
1688
- * aggregate) don't grow this unbounded. Eviction costs at most one
1689
- * redundant store.subscribe() call per evicted-but-still-active stream
1690
- * (subscribe is idempotent). Cap configurable via {@link ActOptions}.
1691
- */
1692
- _subscribed_streams;
1693
- _has_dynamic_resolvers = false;
1694
- _correlation_initialized = false;
1695
2100
  /** Event names with at least one registered reaction (computed at build time) */
1696
- _reactive_events = /* @__PURE__ */ new Set();
1697
- /** Set in do() when a committed event has reactions — cleared by drain() */
1698
- _needs_drain = false;
2101
+ _reactive_events;
2102
+ /** Drain pipeline driver: armed flag, concurrency lock, adaptive ratio. */
2103
+ _drain;
2104
+ /** Correlation state machine: lazy init, dynamic-resolver scan, periodic worker. */
2105
+ _correlate;
2106
+ /** Debounced correlate→drain catch-up loop. */
2107
+ _settle;
1699
2108
  /**
1700
2109
  * Emit a lifecycle event. The payload type is inferred from the event name
1701
2110
  * via {@link ActLifecycleEvents}.
@@ -1724,8 +2133,6 @@ var Act = class {
1724
2133
  * @param registry The registry of state, event, and action schemas
1725
2134
  * @param states Map of state names to their (potentially merged) state definitions
1726
2135
  */
1727
- /** Static resolver targets collected at build time */
1728
- _static_targets;
1729
2136
  /** Batch handlers for static-target projections (target → handler) */
1730
2137
  _batch_handlers;
1731
2138
  /** Event-sourcing handlers, optionally wrapped with trace decorators */
@@ -1738,7 +2145,7 @@ var Act = class {
1738
2145
  * this lookup is unambiguous. Used by `close()` to pick the right reducer
1739
2146
  * set when seeding a `restart` snapshot in multi-state apps.
1740
2147
  */
1741
- _event_to_state = /* @__PURE__ */ new Map();
2148
+ _event_to_state;
1742
2149
  /** Logger resolved at construction time (after user port configuration) */
1743
2150
  _logger = log();
1744
2151
  /** Pre-bound IAct methods reused across drain cycles. Only `do` varies per
@@ -1747,9 +2154,9 @@ var Act = class {
1747
2154
  _bound_load = this.load.bind(this);
1748
2155
  _bound_query = this.query.bind(this);
1749
2156
  _bound_query_array = this.query_array.bind(this);
1750
- /** Pre-bound dispatchers handed to runDrainCycle each cycle. */
1751
- _bound_handle = this.handle.bind(this);
1752
- _bound_handle_batch = this.handleBatch.bind(this);
2157
+ /** Reaction dispatchers built once and handed to runDrainCycle each cycle. */
2158
+ _handle;
2159
+ _handle_batch;
1753
2160
  /**
1754
2161
  * Executes an action on a state instance, committing resulting events.
1755
2162
  *
@@ -1840,10 +2247,12 @@ var Act = class {
1840
2247
  reactingTo,
1841
2248
  skipValidation
1842
2249
  );
1843
- for (const snap2 of snapshots) {
1844
- if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
1845
- this._needs_drain = true;
1846
- break;
2250
+ if (this._reactive_events.size > 0) {
2251
+ for (const snap2 of snapshots) {
2252
+ if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
2253
+ this._drain.arm();
2254
+ break;
2255
+ }
1847
2256
  }
1848
2257
  }
1849
2258
  this.emit("committed", snapshots);
@@ -1951,109 +2360,6 @@ var Act = class {
1951
2360
  await store().query((e) => events.push(e), query);
1952
2361
  return events;
1953
2362
  }
1954
- /**
1955
- * Shared finalization for the two reaction-runner shapes (per-event
1956
- * `handle` and bulk `handleBatch`). Centralizes the error log, retry-vs-
1957
- * block decision, and the "error reported only when nothing was handled"
1958
- * rule that's true in both shapes (in batch mode, `handled` is always 0
1959
- * on failure, so the rule degenerates to "always reported").
1960
- */
1961
- _finalize(lease, handled, at, error, options) {
1962
- if (!error) return { lease, handled, at };
1963
- this._logger.error(error);
1964
- const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1965
- if (block2)
1966
- this._logger.error(
1967
- `Blocking ${lease.stream} after ${lease.retry} retries.`
1968
- );
1969
- return {
1970
- lease,
1971
- handled,
1972
- at,
1973
- error: handled === 0 ? error.message : void 0,
1974
- block: block2
1975
- };
1976
- }
1977
- /**
1978
- * Handles leased reactions one event at a time.
1979
- *
1980
- * Called by the main `drain` loop after fetching new events. Each handler
1981
- * receives a scoped `IAct` proxy that auto-injects the triggering event
1982
- * as `reactingTo` when `do()` is called without it, maintaining
1983
- * correlation chains by default (#587). Handlers can still pass an
1984
- * explicit `reactingTo` to override.
1985
- *
1986
- * @internal
1987
- */
1988
- async handle(lease, payloads) {
1989
- if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
1990
- const stream = lease.stream;
1991
- let at = payloads.at(0).event.id;
1992
- let handled = 0;
1993
- if (lease.retry > 0)
1994
- this._logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1995
- const doAction = this._bound_do;
1996
- const scopedApp = {
1997
- do: doAction,
1998
- load: this._bound_load,
1999
- query: this._bound_query,
2000
- query_array: this._bound_query_array
2001
- };
2002
- for (const payload of payloads) {
2003
- const { event, handler } = payload;
2004
- scopedApp.do = (action2, target, payload2, reactingTo, skipValidation) => doAction(
2005
- action2,
2006
- target,
2007
- payload2,
2008
- reactingTo ?? event,
2009
- skipValidation
2010
- );
2011
- try {
2012
- await handler(event, stream, scopedApp);
2013
- at = event.id;
2014
- handled++;
2015
- } catch (error) {
2016
- return this._finalize(
2017
- lease,
2018
- handled,
2019
- at,
2020
- error,
2021
- payload.options
2022
- );
2023
- }
2024
- }
2025
- return this._finalize(lease, handled, at, void 0, payloads[0].options);
2026
- }
2027
- /**
2028
- * Handles a batch of events for a projection with a batch handler.
2029
- *
2030
- * Called by `drain()` when a leased stream is a static-target projection
2031
- * with a registered batch handler. All events are passed to the handler
2032
- * in a single call, enabling bulk DB operations.
2033
- *
2034
- * @internal
2035
- */
2036
- async handleBatch(lease, payloads, batchHandler) {
2037
- const stream = lease.stream;
2038
- const events = payloads.map((p) => p.event);
2039
- const options = payloads[0].options;
2040
- if (lease.retry > 0)
2041
- this._logger.warn(
2042
- `Retrying batch ${stream}@${events[0].id} (${lease.retry}).`
2043
- );
2044
- try {
2045
- await batchHandler(events, stream);
2046
- return this._finalize(
2047
- lease,
2048
- events.length,
2049
- events.at(-1).id,
2050
- void 0,
2051
- options
2052
- );
2053
- } catch (error) {
2054
- return this._finalize(lease, 0, lease.at, error, options);
2055
- }
2056
- }
2057
2363
  /**
2058
2364
  * Processes pending reactions by draining uncommitted events from the event store.
2059
2365
  *
@@ -2093,54 +2399,8 @@ var Act = class {
2093
2399
  * @see {@link correlate} for dynamic stream discovery
2094
2400
  * @see {@link start_correlations} for automatic correlation
2095
2401
  */
2096
- async drain({
2097
- streamLimit = 10,
2098
- eventLimit = 10,
2099
- leaseMillis = 1e4
2100
- } = {}) {
2101
- if (!this._needs_drain) {
2102
- return { fetched: [], leased: [], acked: [], blocked: [] };
2103
- }
2104
- if (this._drain_locked) {
2105
- return { fetched: [], leased: [], acked: [], blocked: [] };
2106
- }
2107
- try {
2108
- this._drain_locked = true;
2109
- const lagging = Math.ceil(streamLimit * this._drain_lag2lead_ratio);
2110
- const leading = streamLimit - lagging;
2111
- const cycle = await runDrainCycle(
2112
- this._cd,
2113
- this.registry,
2114
- this._batch_handlers,
2115
- this._bound_handle,
2116
- this._bound_handle_batch,
2117
- lagging,
2118
- leading,
2119
- eventLimit,
2120
- leaseMillis
2121
- );
2122
- if (!cycle) {
2123
- this._needs_drain = false;
2124
- return { fetched: [], leased: [], acked: [], blocked: [] };
2125
- }
2126
- const { leased, fetched, handled, acked, blocked } = cycle;
2127
- this._drain_lag2lead_ratio = computeLagLeadRatio(
2128
- handled,
2129
- lagging,
2130
- leading
2131
- );
2132
- if (acked.length) this.emit("acked", acked);
2133
- if (blocked.length) this.emit("blocked", blocked);
2134
- const hasErrors = handled.some(({ error }) => error);
2135
- if (!acked.length && !blocked.length && !hasErrors)
2136
- this._needs_drain = false;
2137
- return { fetched, leased, acked, blocked };
2138
- } catch (error) {
2139
- this._logger.error(error);
2140
- return { fetched: [], leased: [], acked: [], blocked: [] };
2141
- } finally {
2142
- this._drain_locked = false;
2143
- }
2402
+ async drain(options = {}) {
2403
+ return this._drain.drain(options);
2144
2404
  }
2145
2405
  /**
2146
2406
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -2187,71 +2447,8 @@ var Act = class {
2187
2447
  * @see {@link start_correlations} for automatic periodic correlation
2188
2448
  * @see {@link stop_correlations} to stop automatic correlation
2189
2449
  */
2190
- /**
2191
- * Initialize correlation state on first call.
2192
- * - Reads max(at) from store as cold-start checkpoint
2193
- * - Subscribes static resolver targets (idempotent upsert)
2194
- * - Populates the subscribed statics set
2195
- * @internal
2196
- */
2197
- async _init_correlation() {
2198
- if (this._correlation_initialized) return;
2199
- this._correlation_initialized = true;
2200
- const { watermark } = await store().subscribe(this._static_targets);
2201
- this._correlation_checkpoint = watermark;
2202
- if (this._reactive_events.size > 0) this._needs_drain = true;
2203
- for (const { stream } of this._static_targets) {
2204
- this._subscribed_streams.add(stream);
2205
- }
2206
- }
2207
2450
  async correlate(query = { after: -1, limit: 10 }) {
2208
- await this._init_correlation();
2209
- if (!this._has_dynamic_resolvers)
2210
- return { subscribed: 0, last_id: this._correlation_checkpoint };
2211
- const after = Math.max(this._correlation_checkpoint, query.after || -1);
2212
- const correlated = /* @__PURE__ */ new Map();
2213
- let last_id = after;
2214
- await store().query(
2215
- (event) => {
2216
- last_id = event.id;
2217
- const register = this.registry.events[event.name];
2218
- if (register) {
2219
- for (const reaction of register.reactions.values()) {
2220
- if (typeof reaction.resolver !== "function") continue;
2221
- const resolved = reaction.resolver(event);
2222
- if (resolved && !this._subscribed_streams.has(resolved.target)) {
2223
- const entry = correlated.get(resolved.target) || {
2224
- source: resolved.source,
2225
- payloads: []
2226
- };
2227
- entry.payloads.push({
2228
- ...reaction,
2229
- source: resolved.source,
2230
- event
2231
- });
2232
- correlated.set(resolved.target, entry);
2233
- }
2234
- }
2235
- }
2236
- },
2237
- { ...query, after }
2238
- );
2239
- if (correlated.size) {
2240
- const streams = [...correlated.entries()].map(([stream, { source }]) => ({
2241
- stream,
2242
- source
2243
- }));
2244
- const { subscribed } = await this._cd.subscribe(streams);
2245
- this._correlation_checkpoint = last_id;
2246
- if (subscribed) {
2247
- for (const { stream } of streams) {
2248
- this._subscribed_streams.add(stream);
2249
- }
2250
- }
2251
- return { subscribed, last_id };
2252
- }
2253
- this._correlation_checkpoint = last_id;
2254
- return { subscribed: 0, last_id };
2451
+ return this._correlate.correlate(query);
2255
2452
  }
2256
2453
  /**
2257
2454
  * Starts automatic periodic correlation worker for discovering new streams.
@@ -2309,15 +2506,7 @@ var Act = class {
2309
2506
  * @see {@link stop_correlations} to stop the worker
2310
2507
  */
2311
2508
  start_correlations(query = {}, frequency = 1e4, callback) {
2312
- if (this._correlation_timer) return false;
2313
- const limit = query.limit || 100;
2314
- this._correlation_timer = setInterval(
2315
- () => this.correlate({ ...query, after: this._correlation_checkpoint, limit }).then((result) => {
2316
- if (callback && result.subscribed) callback(result.subscribed);
2317
- }).catch(console.error),
2318
- frequency
2319
- );
2320
- return true;
2509
+ return this._correlate.startPolling(query, frequency, callback);
2321
2510
  }
2322
2511
  /**
2323
2512
  * Stops the automatic correlation worker.
@@ -2337,10 +2526,7 @@ var Act = class {
2337
2526
  * @see {@link start_correlations}
2338
2527
  */
2339
2528
  stop_correlations() {
2340
- if (this._correlation_timer) {
2341
- clearInterval(this._correlation_timer);
2342
- this._correlation_timer = void 0;
2343
- }
2529
+ this._correlate.stopPolling();
2344
2530
  }
2345
2531
  /**
2346
2532
  * Cancels any pending or active settle cycle.
@@ -2348,10 +2534,7 @@ var Act = class {
2348
2534
  * @see {@link settle}
2349
2535
  */
2350
2536
  stop_settling() {
2351
- if (this._settle_timer) {
2352
- clearTimeout(this._settle_timer);
2353
- this._settle_timer = void 0;
2354
- }
2537
+ this._settle.stop();
2355
2538
  }
2356
2539
  /**
2357
2540
  * Reset reaction stream watermarks and request a drain on the next
@@ -2388,9 +2571,7 @@ var Act = class {
2388
2571
  */
2389
2572
  async reset(streams) {
2390
2573
  const count = await store().reset(streams);
2391
- if (count > 0 && this._reactive_events.size > 0) {
2392
- this._needs_drain = true;
2393
- }
2574
+ if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
2394
2575
  return count;
2395
2576
  }
2396
2577
  /**
@@ -2473,34 +2654,7 @@ var Act = class {
2473
2654
  * @see {@link correlate} for manual correlation
2474
2655
  */
2475
2656
  settle(options = {}) {
2476
- const {
2477
- debounceMs = 10,
2478
- correlate: correlateQuery = { after: -1, limit: 100 },
2479
- maxPasses = Infinity,
2480
- ...drainOptions
2481
- } = options;
2482
- if (this._settle_timer) clearTimeout(this._settle_timer);
2483
- this._settle_timer = setTimeout(() => {
2484
- this._settle_timer = void 0;
2485
- if (this._settling) return;
2486
- this._settling = true;
2487
- (async () => {
2488
- await this._init_correlation();
2489
- let lastDrain;
2490
- for (let i = 0; i < maxPasses; i++) {
2491
- const { subscribed } = await this.correlate({
2492
- ...correlateQuery,
2493
- after: this._correlation_checkpoint
2494
- });
2495
- lastDrain = await this.drain(drainOptions);
2496
- const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
2497
- if (!made_progress) break;
2498
- }
2499
- if (lastDrain) this.emit("settled", lastDrain);
2500
- })().catch((err) => this._logger.error(err)).finally(() => {
2501
- this._settling = false;
2502
- });
2503
- }, debounceMs);
2657
+ this._settle.schedule(options);
2504
2658
  }
2505
2659
  };
2506
2660
 
@@ -2783,6 +2937,7 @@ function action_builder(state2) {
2783
2937
  ConcurrencyError,
2784
2938
  ConsoleLogger,
2785
2939
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
2940
+ DEFAULT_SETTLE_DEBOUNCE_MS,
2786
2941
  Environments,
2787
2942
  Errors,
2788
2943
  EventMetaSchema,