@rotorsoft/act 0.43.0 → 0.45.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 (44) hide show
  1. package/README.md +87 -379
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/act.d.ts +43 -5
  4. package/dist/@types/act.d.ts.map +1 -1
  5. package/dist/@types/adapters/console-logger.d.ts.map +1 -1
  6. package/dist/@types/adapters/in-memory-store.d.ts +22 -2
  7. package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
  8. package/dist/@types/builders/act-builder.d.ts +33 -9
  9. package/dist/@types/builders/act-builder.d.ts.map +1 -1
  10. package/dist/@types/builders/slice-builder.d.ts +23 -8
  11. package/dist/@types/builders/slice-builder.d.ts.map +1 -1
  12. package/dist/@types/internal/build-classify.d.ts +20 -0
  13. package/dist/@types/internal/build-classify.d.ts.map +1 -1
  14. package/dist/@types/internal/correlate-cycle.d.ts +1 -0
  15. package/dist/@types/internal/correlate-cycle.d.ts.map +1 -1
  16. package/dist/@types/internal/drain-cycle.d.ts +43 -3
  17. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  18. package/dist/@types/internal/drain.d.ts +3 -1
  19. package/dist/@types/internal/drain.d.ts.map +1 -1
  20. package/dist/@types/internal/index.d.ts +3 -2
  21. package/dist/@types/internal/index.d.ts.map +1 -1
  22. package/dist/@types/internal/reactions.d.ts.map +1 -1
  23. package/dist/@types/internal/tracing.d.ts +51 -0
  24. package/dist/@types/internal/tracing.d.ts.map +1 -1
  25. package/dist/@types/ports.d.ts +10 -0
  26. package/dist/@types/ports.d.ts.map +1 -1
  27. package/dist/@types/test/sandbox.d.ts +1 -1
  28. package/dist/@types/test/sandbox.d.ts.map +1 -1
  29. package/dist/@types/types/ports.d.ts +203 -2
  30. package/dist/@types/types/ports.d.ts.map +1 -1
  31. package/dist/@types/types/reaction.d.ts +20 -2
  32. package/dist/@types/types/reaction.d.ts.map +1 -1
  33. package/dist/{chunk-QAB4SDOS.js → chunk-PGTC7VOC.js} +117 -11
  34. package/dist/chunk-PGTC7VOC.js.map +1 -0
  35. package/dist/index.cjs +1217 -897
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.js +1108 -893
  38. package/dist/index.js.map +1 -1
  39. package/dist/test/index.cjs +116 -11
  40. package/dist/test/index.cjs.map +1 -1
  41. package/dist/test/index.js +3 -3
  42. package/dist/test/index.js.map +1 -1
  43. package/package.json +2 -2
  44. package/dist/chunk-QAB4SDOS.js.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_LANE: () => DEFAULT_LANE,
39
40
  DEFAULT_MAX_SUBSCRIBED_STREAMS: () => DEFAULT_MAX_SUBSCRIBED_STREAMS,
40
41
  DEFAULT_SETTLE_DEBOUNCE_MS: () => DEFAULT_SETTLE_DEBOUNCE_MS,
41
42
  Environments: () => Environments,
@@ -145,6 +146,12 @@ var ConsoleLogger = class _ConsoleLogger {
145
146
  if (typeof objOrMsg === "string") {
146
147
  message = objOrMsg;
147
148
  obj = {};
149
+ } else if (objOrMsg instanceof Error) {
150
+ message = msg ?? objOrMsg.message;
151
+ obj = {
152
+ error: { message: objOrMsg.message, name: objOrMsg.name },
153
+ stack: objOrMsg.stack
154
+ };
148
155
  } else if (objOrMsg !== null && typeof objOrMsg === "object") {
149
156
  message = msg;
150
157
  obj = { ...objOrMsg };
@@ -175,6 +182,9 @@ var ConsoleLogger = class _ConsoleLogger {
175
182
  let data;
176
183
  if (typeof objOrMsg === "string") {
177
184
  message = objOrMsg;
185
+ } else if (objOrMsg instanceof Error) {
186
+ message = msg ?? objOrMsg.message;
187
+ data = objOrMsg.stack;
178
188
  } else {
179
189
  message = msg ?? "";
180
190
  if (objOrMsg !== void 0 && objOrMsg !== null) {
@@ -484,10 +494,11 @@ async function sleep(ms) {
484
494
 
485
495
  // src/adapters/in-memory-store.ts
486
496
  var InMemoryStream = class {
487
- constructor(stream, source, priority = 0) {
497
+ constructor(stream, source, priority = 0, lane = DEFAULT_LANE) {
488
498
  this.stream = stream;
489
499
  this.source = source;
490
500
  this._priority = priority;
501
+ this._lane = lane;
491
502
  }
492
503
  _at = -1;
493
504
  _retry = -1;
@@ -496,9 +507,17 @@ var InMemoryStream = class {
496
507
  _leased_by = void 0;
497
508
  _leased_until = void 0;
498
509
  _priority = 0;
510
+ _lane = DEFAULT_LANE;
499
511
  get priority() {
500
512
  return this._priority;
501
513
  }
514
+ get lane() {
515
+ return this._lane;
516
+ }
517
+ /** Replace on every subscribe — current builder config wins on restart. */
518
+ set lane(value) {
519
+ this._lane = value;
520
+ }
502
521
  /**
503
522
  * Bump the priority via {@link subscribe}: keeps the maximum across
504
523
  * reactions so the highest-priority registrant wins.
@@ -552,7 +571,8 @@ var InMemoryStream = class {
552
571
  at: lease.at,
553
572
  by: lease.by,
554
573
  retry: this._retry,
555
- lagging: lease.lagging
574
+ lagging: lease.lagging,
575
+ lane: this._lane
556
576
  };
557
577
  }
558
578
  /**
@@ -571,7 +591,8 @@ var InMemoryStream = class {
571
591
  at: this._at,
572
592
  by: lease.by,
573
593
  retry: this._retry,
574
- lagging: lease.lagging
594
+ lagging: lease.lagging,
595
+ lane: this._lane
575
596
  };
576
597
  }
577
598
  }
@@ -591,7 +612,8 @@ var InMemoryStream = class {
591
612
  by: this._leased_by,
592
613
  retry: this._retry,
593
614
  error: this._error,
594
- lagging: lease.lagging
615
+ lagging: lease.lagging,
616
+ lane: this._lane
595
617
  };
596
618
  }
597
619
  }
@@ -767,7 +789,7 @@ var InMemoryStore = class {
767
789
  * @param millis - Lease duration in milliseconds.
768
790
  * @returns Granted leases.
769
791
  */
770
- async claim(lagging, leading, by, millis) {
792
+ async claim(lagging, leading, by, millis, lane) {
771
793
  await sleep();
772
794
  const sourceRegex = /* @__PURE__ */ new Map();
773
795
  const getRegex = (source) => {
@@ -788,7 +810,7 @@ var InMemoryStore = class {
788
810
  return false;
789
811
  };
790
812
  const available = [...this._streams.values()].filter(
791
- (s) => s.is_available && hasWork(s)
813
+ (s) => s.is_available && hasWork(s) && (lane === void 0 || s.lane === lane)
792
814
  );
793
815
  const lag = available.sort((a, b) => b.priority - a.priority || a.at - b.at).slice(0, lagging).map((s) => ({
794
816
  stream: s.stream,
@@ -824,12 +846,21 @@ var InMemoryStore = class {
824
846
  async subscribe(streams) {
825
847
  await sleep();
826
848
  let subscribed = 0;
827
- for (const { stream, source, priority = 0 } of streams) {
849
+ for (const {
850
+ stream,
851
+ source,
852
+ priority = 0,
853
+ lane = DEFAULT_LANE
854
+ } of streams) {
828
855
  const existing = this._streams.get(stream);
829
856
  if (existing) {
830
857
  existing.bumpPriority(priority);
858
+ existing.lane = lane;
831
859
  } else {
832
- this._streams.set(stream, new InMemoryStream(stream, source, priority));
860
+ this._streams.set(
861
+ stream,
862
+ new InMemoryStream(stream, source, priority, lane)
863
+ );
833
864
  subscribed++;
834
865
  }
835
866
  }
@@ -876,6 +907,7 @@ var InMemoryStore = class {
876
907
  }
877
908
  if (filter.blocked !== void 0 && s.blocked !== filter.blocked)
878
909
  return false;
910
+ if (filter.lane !== void 0 && s.lane !== filter.lane) return false;
879
911
  return true;
880
912
  };
881
913
  }
@@ -986,6 +1018,7 @@ var InMemoryStore = class {
986
1018
  continue;
987
1019
  }
988
1020
  if (blocked !== void 0 && s.blocked !== blocked) continue;
1021
+ if (query?.lane !== void 0 && s.lane !== query.lane) continue;
989
1022
  callback({
990
1023
  stream: s.stream,
991
1024
  source: s.source,
@@ -995,13 +1028,85 @@ var InMemoryStore = class {
995
1028
  error: s.error,
996
1029
  priority: s.priority,
997
1030
  leased_by: s.leased_by,
998
- leased_until: s.leased_until
1031
+ leased_until: s.leased_until,
1032
+ lane: s.lane
999
1033
  });
1000
1034
  count++;
1001
1035
  if (count >= limit) break;
1002
1036
  }
1003
1037
  return { maxEventId: this._events.length - 1, count };
1004
1038
  }
1039
+ /**
1040
+ * Per-stream aggregated stats — see {@link Store.query_stats}.
1041
+ *
1042
+ * Single forward scan over the in-memory event list, accumulating per
1043
+ * stream. The "cheap heads" cost tier from durable adapters doesn't
1044
+ * apply here (InMemory has no indexes); correctness is the goal, perf
1045
+ * is a non-issue.
1046
+ *
1047
+ * Scope rules:
1048
+ * - Array `input` — explicit stream names, regardless of subscription.
1049
+ * - Filter `input` — `stream`/`stream_exact` match against event-bearing
1050
+ * stream names; `source`/`source_exact`/`blocked` require a
1051
+ * corresponding subscription in `_streams` (those are subscription
1052
+ * concepts, not event concepts). Empty filter `{}` matches every
1053
+ * event-bearing stream.
1054
+ */
1055
+ async query_stats(input, options) {
1056
+ await sleep();
1057
+ const exclude = new Set(options?.exclude ?? []);
1058
+ const wantTail = options?.tail ?? false;
1059
+ const wantCount = options?.count ?? false;
1060
+ const wantNames = options?.names ?? false;
1061
+ const before = options?.before;
1062
+ const arrayTargets = Array.isArray(input) ? new Set(input) : null;
1063
+ const filter = Array.isArray(input) ? null : input;
1064
+ const streamRe = filter?.stream && !filter.stream_exact ? new RegExp(filter.stream) : void 0;
1065
+ const scopeCache = /* @__PURE__ */ new Map();
1066
+ const inScope = (stream) => {
1067
+ const cached = scopeCache.get(stream);
1068
+ if (cached !== void 0) return cached;
1069
+ let ok = true;
1070
+ if (arrayTargets) {
1071
+ ok = arrayTargets.has(stream);
1072
+ } else if (filter?.stream !== void 0) {
1073
+ ok = filter.stream_exact ? stream === filter.stream : (
1074
+ // biome-ignore lint/style/noNonNullAssertion: streamRe set when stream is regex
1075
+ streamRe.test(stream)
1076
+ );
1077
+ }
1078
+ scopeCache.set(stream, ok);
1079
+ return ok;
1080
+ };
1081
+ const acc = /* @__PURE__ */ new Map();
1082
+ for (const e of this._events) {
1083
+ if (before !== void 0 && e.id >= before) continue;
1084
+ if (!inScope(e.stream)) continue;
1085
+ if (exclude.has(e.name)) continue;
1086
+ let a = acc.get(e.stream);
1087
+ if (!a) {
1088
+ a = { head: e, count: 0 };
1089
+ if (wantTail) a.tail = e;
1090
+ if (wantNames) a.names = {};
1091
+ acc.set(e.stream, a);
1092
+ }
1093
+ a.head = e;
1094
+ a.count++;
1095
+ if (wantNames) {
1096
+ const n = String(e.name);
1097
+ a.names[n] = (a.names[n] ?? 0) + 1;
1098
+ }
1099
+ }
1100
+ const out = /* @__PURE__ */ new Map();
1101
+ for (const [stream, a] of acc) {
1102
+ const stats = { head: a.head };
1103
+ if (wantTail) stats.tail = a.tail;
1104
+ if (wantCount) stats.count = a.count;
1105
+ if (wantNames) stats.names = a.names;
1106
+ out.set(stream, stats);
1107
+ }
1108
+ return out;
1109
+ }
1005
1110
  /**
1006
1111
  * Atomically truncates streams and seeds each with a snapshot or tombstone.
1007
1112
  * @param targets - Streams to truncate with optional snapshot state and meta.
@@ -1107,6 +1212,7 @@ function dispose(disposer) {
1107
1212
  }
1108
1213
  var SNAP_EVENT = "__snapshot__";
1109
1214
  var TOMBSTONE_EVENT = "__tombstone__";
1215
+ var DEFAULT_LANE = "default";
1110
1216
 
1111
1217
  // src/signals.ts
1112
1218
  process.once("SIGINT", async (arg) => {
@@ -1130,23 +1236,39 @@ process.once("unhandledRejection", async (arg) => {
1130
1236
  var import_node_events = __toESM(require("events"), 1);
1131
1237
 
1132
1238
  // src/internal/build-classify.ts
1239
+ var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
1133
1240
  function classifyRegistry(registry, states) {
1134
1241
  const statics = /* @__PURE__ */ new Map();
1135
1242
  const reactiveEvents = /* @__PURE__ */ new Set();
1243
+ const eventToLanes = /* @__PURE__ */ new Map();
1136
1244
  let hasDynamicResolvers = false;
1137
1245
  for (const [name, register] of Object.entries(registry.events)) {
1138
1246
  if (register.reactions.size > 0) reactiveEvents.add(name);
1139
1247
  for (const reaction of register.reactions.values()) {
1140
1248
  if (typeof reaction.resolver === "function") {
1141
1249
  hasDynamicResolvers = true;
1250
+ eventToLanes.set(name, ALL_LANES);
1142
1251
  } else {
1143
- const { target, source, priority = 0 } = reaction.resolver;
1252
+ const { target, source, priority = 0, lane } = reaction.resolver;
1253
+ const lane_name = lane ?? "default";
1254
+ const existing_lanes = eventToLanes.get(name);
1255
+ if (existing_lanes !== ALL_LANES) {
1256
+ const set = existing_lanes ?? /* @__PURE__ */ new Set();
1257
+ set.add(lane_name);
1258
+ eventToLanes.set(name, set);
1259
+ }
1144
1260
  const key = `${target}|${source ?? ""}`;
1145
1261
  const existing = statics.get(key);
1146
1262
  if (!existing) {
1147
- statics.set(key, { stream: target, source, priority });
1148
- } else if (priority > existing.priority) {
1149
- statics.set(key, { ...existing, priority });
1263
+ statics.set(key, { stream: target, source, priority, lane });
1264
+ } else {
1265
+ if ((existing.lane ?? void 0) !== (lane ?? void 0))
1266
+ throw new Error(
1267
+ `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
1268
+ );
1269
+ if (priority > existing.priority) {
1270
+ statics.set(key, { ...existing, priority });
1271
+ }
1150
1272
  }
1151
1273
  }
1152
1274
  }
@@ -1161,7 +1283,8 @@ function classifyRegistry(registry, states) {
1161
1283
  staticTargets: [...statics.values()],
1162
1284
  hasDynamicResolvers,
1163
1285
  reactiveEvents,
1164
- eventToState
1286
+ eventToState,
1287
+ eventToLanes
1165
1288
  };
1166
1289
  }
1167
1290
 
@@ -1203,24 +1326,18 @@ async function runCloseCycle(targets, deps) {
1203
1326
  return { truncated, skipped };
1204
1327
  }
1205
1328
  async function scanStreamHeads(streams) {
1329
+ const stats = await store2().query_stats(streams, {
1330
+ exclude: [SNAP_EVENT]
1331
+ });
1206
1332
  const out = /* @__PURE__ */ new Map();
1207
- await Promise.all(
1208
- streams.map(async (s) => {
1209
- let maxId = -1;
1210
- let version = -1;
1211
- let lastEventName = "";
1212
- await store2().query(
1213
- (e) => {
1214
- if (e.name === TOMBSTONE_EVENT || maxId !== -1) return;
1215
- maxId = e.id;
1216
- version = e.version;
1217
- lastEventName = e.name;
1218
- },
1219
- { stream: s, stream_exact: true, backward: true, limit: 1 }
1220
- );
1221
- if (maxId >= 0) out.set(s, { maxId, version, lastEventName });
1222
- })
1223
- );
1333
+ for (const [stream, { head }] of stats) {
1334
+ if (head.name === TOMBSTONE_EVENT) continue;
1335
+ out.set(stream, {
1336
+ maxId: head.id,
1337
+ version: head.version,
1338
+ lastEventName: head.name
1339
+ });
1340
+ }
1224
1341
  return out;
1225
1342
  }
1226
1343
  async function partitionBySafety(streamInfo, reactiveEventsSize, skipped) {
@@ -1378,6 +1495,7 @@ var CorrelateCycle = class {
1378
1495
  const entry = correlated.get(resolved.target) || {
1379
1496
  source: resolved.source,
1380
1497
  priority: incomingPriority,
1498
+ lane: resolved.lane,
1381
1499
  payloads: []
1382
1500
  };
1383
1501
  if (incomingPriority > entry.priority)
@@ -1396,10 +1514,11 @@ var CorrelateCycle = class {
1396
1514
  );
1397
1515
  if (correlated.size) {
1398
1516
  const streams = [...correlated.entries()].map(
1399
- ([stream, { source, priority }]) => ({
1517
+ ([stream, { source, priority, lane }]) => ({
1400
1518
  stream,
1401
1519
  source,
1402
- priority
1520
+ priority,
1521
+ lane
1403
1522
  })
1404
1523
  );
1405
1524
  const { subscribed } = await this.cd.subscribe(streams);
@@ -1484,904 +1603,955 @@ function computeLagLeadRatio(handled, lagging, leading) {
1484
1603
  return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1485
1604
  }
1486
1605
 
1487
- // src/internal/drain-cycle.ts
1488
- async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred) {
1489
- const leased = await ops.claim(lagging, leading, (0, import_node_crypto2.randomUUID)(), leaseMillis);
1490
- if (!leased.length) return void 0;
1491
- const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
1492
- if (!active.length) {
1493
- return {
1494
- leased,
1495
- fetched: [],
1496
- handled: [],
1497
- acked: [],
1498
- blocked: []
1499
- };
1500
- }
1501
- const fetched = await ops.fetch(active, eventLimit);
1502
- const fetchMap = /* @__PURE__ */ new Map();
1503
- const fetch_window_at = fetched.reduce(
1504
- (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
1505
- 0
1506
- );
1507
- for (const f of fetched) {
1508
- const { stream, events } = f;
1509
- const payloads = events.flatMap((event) => {
1510
- const register = registry.events[event.name];
1511
- if (!register) return [];
1512
- return [...register.reactions.values()].filter((reaction) => {
1513
- const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1514
- return resolved && resolved.target === stream;
1515
- }).map((reaction) => ({ ...reaction, event }));
1516
- });
1517
- fetchMap.set(stream, { fetch: f, payloads });
1518
- }
1519
- const handled = await Promise.all(
1520
- active.map((lease) => {
1521
- const entry = fetchMap.get(lease.stream);
1522
- const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
1523
- const { payloads } = entry;
1524
- const batchHandler = batchHandlers.get(lease.stream);
1525
- if (batchHandler && payloads.length > 0) {
1526
- return handleBatch({ ...lease, at }, payloads, batchHandler);
1527
- }
1528
- return handle({ ...lease, at }, payloads);
1606
+ // src/internal/drain.ts
1607
+ var claim = (lagging, leading, by, millis, lane) => store2().claim(lagging, leading, by, millis, lane);
1608
+ async function fetch(leased, eventLimit) {
1609
+ return Promise.all(
1610
+ leased.map(async ({ stream, source, at, lagging }) => {
1611
+ const events = [];
1612
+ await store2().query((e) => events.push(e), {
1613
+ stream: source,
1614
+ after: at,
1615
+ limit: eventLimit
1616
+ });
1617
+ return { stream, source, at, lagging, events };
1529
1618
  })
1530
1619
  );
1531
- const acked = await ops.ack(
1532
- handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
1533
- );
1534
- const blocked = await ops.block(
1535
- handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1536
- );
1537
- return { leased, fetched, handled, acked, blocked };
1538
1620
  }
1539
- var EMPTY_DRAIN = {
1540
- fetched: [],
1541
- leased: [],
1542
- acked: [],
1543
- blocked: []
1544
- };
1545
- var DrainController = class {
1546
- constructor(deps) {
1547
- this.deps = deps;
1548
- }
1549
- _armed = false;
1550
- _locked = false;
1551
- _ratio = 0.5;
1552
- /**
1553
- * Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
1554
- * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
1555
- * ack or terminal block. Lives in process memory — per-worker pacing
1556
- * by design (see {@link BackoffOptions} for the multi-worker trade-off).
1557
- */
1558
- _backoff = /* @__PURE__ */ new Map();
1559
- /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
1560
- _backoffTimer;
1561
- /**
1562
- * Signal that a commit (or reset / cold-start) may have produced work.
1563
- * Subsequent `drain()` calls will run the pipeline; once the pipeline
1564
- * settles to no-progress, the controller disarms itself.
1565
- */
1566
- arm() {
1567
- this._armed = true;
1568
- }
1569
- /** Read-only flag — true while a commit / reset is unprocessed. */
1570
- get armed() {
1571
- return this._armed;
1572
- }
1573
- /** Returns true when `stream` is currently within a backoff window. */
1574
- isDeferred = (stream) => {
1575
- const next = this._backoff.get(stream);
1576
- return next !== void 0 && next > Date.now();
1577
- };
1578
- /**
1579
- * Schedule the next drain re-arm at the earliest pending backoff
1580
- * expiry. Called only when the backoff map is non-empty (caller guard).
1581
- * Idempotent — collapses many simultaneously deferred streams into a
1582
- * single timer.
1583
- */
1584
- scheduleBackoffWake() {
1585
- if (this._backoffTimer) clearTimeout(this._backoffTimer);
1586
- let earliest = Number.POSITIVE_INFINITY;
1587
- for (const t of this._backoff.values()) if (t < earliest) earliest = t;
1588
- const delay = Math.max(0, earliest - Date.now());
1589
- this._backoffTimer = setTimeout(() => {
1590
- this._backoffTimer = void 0;
1591
- const now = Date.now();
1592
- for (const [stream, at] of this._backoff) {
1593
- if (at <= now) this._backoff.delete(stream);
1594
- }
1595
- this._armed = true;
1596
- }, delay);
1597
- this._backoffTimer.unref();
1598
- }
1599
- /** Run one drain pass. Short-circuits when not armed or already running. */
1600
- async drain({
1601
- streamLimit = 10,
1602
- eventLimit = 10,
1603
- leaseMillis = 1e4
1604
- } = {}) {
1605
- if (!this._armed) return EMPTY_DRAIN;
1606
- if (this._locked) return EMPTY_DRAIN;
1607
- try {
1608
- this._locked = true;
1609
- const lagging = Math.ceil(streamLimit * this._ratio);
1610
- const leading = streamLimit - lagging;
1611
- const cycle = await runDrainCycle(
1612
- this.deps.ops,
1613
- this.deps.registry,
1614
- this.deps.batchHandlers,
1615
- this.deps.handle,
1616
- this.deps.handleBatch,
1617
- lagging,
1618
- leading,
1619
- eventLimit,
1620
- leaseMillis,
1621
- this._backoff.size > 0 ? this.isDeferred : void 0
1622
- );
1623
- if (!cycle) {
1624
- this._armed = false;
1625
- return EMPTY_DRAIN;
1626
- }
1627
- const { leased, fetched, handled, acked, blocked } = cycle;
1628
- this._ratio = computeLagLeadRatio(handled, lagging, leading);
1629
- for (const lease of acked) this._backoff.delete(lease.stream);
1630
- for (const lease of blocked) this._backoff.delete(lease.stream);
1631
- for (const h of handled) {
1632
- if (h.nextAttemptAt !== void 0 && !h.block) {
1633
- this._backoff.set(h.lease.stream, h.nextAttemptAt);
1634
- }
1635
- }
1636
- if (this._backoff.size > 0) this.scheduleBackoffWake();
1637
- if (acked.length) this.deps.onAcked(acked);
1638
- if (blocked.length) this.deps.onBlocked(blocked);
1639
- const hasErrors = handled.some(({ error }) => error);
1640
- if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
1641
- return { fetched, leased, acked, blocked };
1642
- } catch (error) {
1643
- this.deps.logger.error(error);
1644
- return EMPTY_DRAIN;
1645
- } finally {
1646
- this._locked = false;
1647
- }
1648
- }
1649
- };
1621
+ var ack = (leases) => store2().ack(leases);
1622
+ var block = (leases) => store2().block(leases);
1623
+ var subscribe = (streams) => store2().subscribe(streams);
1650
1624
 
1651
- // src/internal/event-versions.ts
1652
- var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
1653
- function parse(name) {
1654
- const m = name.match(VERSION_SUFFIX);
1655
- if (m) {
1656
- const v = Number.parseInt(m[2], 10);
1657
- if (v >= 2) return { base: m[1], version: v };
1625
+ // src/internal/event-sourcing.ts
1626
+ var import_act_patch = require("@rotorsoft/act-patch");
1627
+ async function snap(snapshot) {
1628
+ try {
1629
+ const { id, stream, name, meta, version } = snapshot.event;
1630
+ await store2().commit(
1631
+ stream,
1632
+ [{ name: SNAP_EVENT, data: snapshot.state }],
1633
+ {
1634
+ correlation: meta.correlation,
1635
+ causation: { event: { id, name, stream } }
1636
+ },
1637
+ version
1638
+ // IMPORTANT! - state events are committed right after the snapshot event
1639
+ );
1640
+ } catch (error) {
1641
+ log().error(error);
1658
1642
  }
1659
- return { base: name, version: 1 };
1660
1643
  }
1661
- function deprecatedEventNames(names) {
1662
- const groups = /* @__PURE__ */ new Map();
1663
- for (const name of names) {
1664
- const { base, version } = parse(name);
1665
- const list = groups.get(base);
1666
- if (list) list.push({ version, name });
1667
- else groups.set(base, [{ version, name }]);
1668
- }
1669
- const deprecated = /* @__PURE__ */ new Set();
1670
- for (const list of groups.values()) {
1671
- if (list.length < 2) continue;
1672
- list.sort((a, b) => b.version - a.version);
1673
- for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
1644
+ async function tombstone(stream, expectedVersion, correlation) {
1645
+ try {
1646
+ const [committed] = await store2().commit(
1647
+ stream,
1648
+ [{ name: TOMBSTONE_EVENT, data: {} }],
1649
+ { correlation, causation: {} },
1650
+ expectedVersion
1651
+ );
1652
+ return committed;
1653
+ } catch (error) {
1654
+ if (error instanceof ConcurrencyError) return void 0;
1655
+ throw error;
1674
1656
  }
1675
- return deprecated;
1676
1657
  }
1677
- function currentVersionOf(deprecatedName, allNames) {
1678
- const target = parse(deprecatedName);
1679
- let highest;
1680
- for (const name of allNames) {
1681
- const { base, version } = parse(name);
1682
- if (base !== target.base) continue;
1683
- if (!highest || version > highest.version) highest = { version, name };
1684
- }
1685
- return highest && highest.version > target.version ? highest.name : void 0;
1686
- }
1687
-
1688
- // src/internal/merge.ts
1689
- var import_zod4 = require("zod");
1690
- function baseTypeName(zodType) {
1691
- let t = zodType;
1692
- while (typeof t.unwrap === "function") {
1693
- t = t.unwrap();
1694
- }
1695
- return t.constructor.name;
1696
- }
1697
- function mergeSchemas(existing, incoming, stateName) {
1698
- if (existing instanceof import_zod4.ZodObject && incoming instanceof import_zod4.ZodObject) {
1699
- const existingShape = existing.shape;
1700
- const incomingShape = incoming.shape;
1701
- for (const key of Object.keys(incomingShape)) {
1702
- if (key in existingShape) {
1703
- const existingBase = baseTypeName(existingShape[key]);
1704
- const incomingBase = baseTypeName(incomingShape[key]);
1705
- if (existingBase !== incomingBase) {
1706
- throw new Error(
1707
- `Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
1708
- );
1709
- }
1658
+ async function load(me, stream, callback, asOf) {
1659
+ const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
1660
+ const cached = timeTravel ? void 0 : await cache2().get(stream);
1661
+ const cache_hit = !!cached;
1662
+ let state2 = cached?.state ?? (me.init ? me.init() : {});
1663
+ let patches = cached?.patches ?? 0;
1664
+ let snaps = cached?.snaps ?? 0;
1665
+ let version = cached?.version ?? -1;
1666
+ let replayed = 0;
1667
+ let event;
1668
+ await store2().query(
1669
+ (e) => {
1670
+ event = e;
1671
+ version = e.version;
1672
+ if (e.name === SNAP_EVENT) {
1673
+ state2 = e.data;
1674
+ snaps++;
1675
+ patches = 0;
1676
+ replayed++;
1677
+ } else if (me.patch[e.name]) {
1678
+ state2 = (0, import_act_patch.patch)(state2, me.patch[e.name](event, state2));
1679
+ patches++;
1680
+ replayed++;
1681
+ } else if (e.name !== TOMBSTONE_EVENT) {
1682
+ log().warn(
1683
+ `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
1684
+ );
1710
1685
  }
1686
+ callback?.({
1687
+ event,
1688
+ state: state2,
1689
+ version,
1690
+ patches,
1691
+ snaps,
1692
+ cache_hit,
1693
+ replayed
1694
+ });
1695
+ },
1696
+ {
1697
+ stream,
1698
+ stream_exact: true,
1699
+ ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
1711
1700
  }
1712
- return existing.extend(incomingShape);
1713
- }
1714
- return existing;
1715
- }
1716
- function mergeInits(existing, incoming) {
1717
- return () => ({ ...existing(), ...incoming() });
1718
- }
1719
- function registerState(state2, states, actions, events) {
1720
- const existing = states.get(state2.name);
1721
- if (existing) {
1722
- mergeIntoExisting(state2, existing, states, actions, events);
1723
- } else {
1724
- registerNewState(state2, states, actions, events);
1701
+ );
1702
+ if (replayed > 0 && !timeTravel && event) {
1703
+ await cache2().set(stream, {
1704
+ state: state2,
1705
+ version,
1706
+ event_id: event.id,
1707
+ patches,
1708
+ snaps
1709
+ });
1725
1710
  }
1711
+ return { event, state: state2, version, patches, snaps, cache_hit, replayed };
1726
1712
  }
1727
- function registerNewState(state2, states, actions, events) {
1728
- states.set(state2.name, state2);
1729
- for (const name of Object.keys(state2.actions)) {
1730
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
1731
- actions[name] = state2;
1732
- }
1733
- for (const name of Object.keys(state2.events)) {
1734
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
1735
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
1713
+ async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
1714
+ const { stream, expectedVersion, actor } = target;
1715
+ if (!stream) throw new Error("Missing target stream");
1716
+ const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
1717
+ const snapshot = await load(me, stream);
1718
+ if (snapshot.event?.name === TOMBSTONE_EVENT)
1719
+ throw new StreamClosedError(stream);
1720
+ const expected = expectedVersion ?? snapshot.event?.version;
1721
+ if (me.given) {
1722
+ const invariants = me.given[action2] || [];
1723
+ invariants.forEach(({ valid, description }) => {
1724
+ if (!valid(snapshot.state, actor))
1725
+ throw new InvariantError(
1726
+ action2,
1727
+ validated,
1728
+ target,
1729
+ snapshot,
1730
+ description
1731
+ );
1732
+ });
1736
1733
  }
1737
- }
1738
- function mergeIntoExisting(state2, existing, states, actions, events) {
1739
- for (const name of Object.keys(state2.actions)) {
1740
- if (existing.actions[name] === state2.actions[name]) continue;
1741
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
1734
+ const result = me.on[action2](validated, snapshot, target);
1735
+ if (!result) return [snapshot];
1736
+ if (Array.isArray(result) && result.length === 0) {
1737
+ return [snapshot];
1742
1738
  }
1743
- for (const name of Object.keys(state2.events)) {
1744
- if (existing.events[name] === state2.events[name]) continue;
1745
- if (existing.events[name]) {
1746
- throw new Error(
1747
- `Event "${name}" in state "${state2.name}" is declared with different Zod schemas across slices. Cross-slice event schemas must reference the same instance \u2014 extract a shared schema (e.g. \`export const ${name} = z.object({ ... })\` in a shared module) and import it in every slice that declares it.`
1748
- );
1739
+ const tuples = Array.isArray(result[0]) ? result : [result];
1740
+ const deprecated = me._deprecated;
1741
+ if (deprecated && deprecated.size > 0) {
1742
+ const me_ = me;
1743
+ const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
1744
+ for (const [name] of tuples) {
1745
+ const evt = name;
1746
+ if (deprecated.has(evt) && !warned.has(evt)) {
1747
+ warned.add(evt);
1748
+ log().warn(
1749
+ `Action "${String(action2)}" emitted deprecated event "${evt}". A newer version exists in the registry \u2014 update the action's .emit() to target the current version. (warned once per process)`
1750
+ );
1751
+ }
1749
1752
  }
1750
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
1751
- }
1752
- const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
1753
- const merged = {
1754
- ...existing,
1755
- state: mergeSchemas(existing.state, state2.state, state2.name),
1756
- init: mergeInits(existing.init, state2.init),
1757
- events: { ...existing.events, ...state2.events },
1758
- actions: { ...existing.actions, ...state2.actions },
1759
- patch: mergedPatch,
1760
- on: { ...existing.on, ...state2.on },
1761
- given: { ...existing.given, ...state2.given },
1762
- snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
1763
- throw new Error(
1764
- `Duplicate snap strategy for state "${state2.name}"`
1765
- );
1766
- })() : state2.snap || existing.snap
1767
- };
1768
- states.set(state2.name, merged);
1769
- for (const name of Object.keys(merged.actions)) {
1770
- actions[name] = merged;
1771
- }
1772
- for (const name of Object.keys(state2.events)) {
1773
- if (events[name]) continue;
1774
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
1775
1753
  }
1776
- }
1777
- function mergePatches(existing, incoming, stateName) {
1778
- const merged = { ...existing };
1779
- for (const name of Object.keys(incoming)) {
1780
- const existingP = existing[name];
1781
- const incomingP = incoming[name];
1782
- if (!existingP) {
1783
- merged[name] = incomingP;
1784
- continue;
1785
- }
1786
- const existingIsDefault = existingP._passthrough;
1787
- const incomingIsDefault = incomingP._passthrough;
1788
- if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
1789
- throw new Error(
1790
- `Duplicate custom patch for event "${name}" in state "${stateName}"`
1791
- );
1754
+ const emitted = tuples.map(([name, data]) => ({
1755
+ name,
1756
+ data: skipValidation ? data : validate(name, data, me.events[name])
1757
+ }));
1758
+ const meta = {
1759
+ correlation: reactingTo?.meta.correlation || correlator({
1760
+ action: action2,
1761
+ state: me.name,
1762
+ stream,
1763
+ actor: target.actor
1764
+ }),
1765
+ causation: {
1766
+ action: {
1767
+ name: action2,
1768
+ ...target
1769
+ // payload intentionally omitted: it can be large or contain PII,
1770
+ // and callers correlate via the correlation id when they need it.
1771
+ },
1772
+ event: reactingTo ? {
1773
+ id: reactingTo.id,
1774
+ name: reactingTo.name,
1775
+ stream: reactingTo.stream
1776
+ } : void 0
1792
1777
  }
1793
- if (existingIsDefault && !incomingIsDefault) {
1794
- merged[name] = incomingP;
1778
+ };
1779
+ let committed;
1780
+ try {
1781
+ committed = await store2().commit(
1782
+ stream,
1783
+ emitted,
1784
+ meta,
1785
+ // Reactions skip optimistic concurrency: they always append against the
1786
+ // current head. Stream leasing already serializes concurrent reactions,
1787
+ // and forcing version checks here would turn ordinary catch-up into
1788
+ // spurious retries.
1789
+ reactingTo ? void 0 : expected
1790
+ );
1791
+ } catch (error) {
1792
+ if (error instanceof ConcurrencyError) {
1793
+ await cache2().invalidate(stream);
1795
1794
  }
1795
+ throw error;
1796
1796
  }
1797
- return merged;
1797
+ let { state: state2, patches } = snapshot;
1798
+ const snapshots = committed.map((event) => {
1799
+ const p = me.patch[event.name](event, state2);
1800
+ state2 = (0, import_act_patch.patch)(state2, p);
1801
+ patches++;
1802
+ return {
1803
+ event,
1804
+ state: state2,
1805
+ version: event.version,
1806
+ patches,
1807
+ snaps: snapshot.snaps,
1808
+ patch: p,
1809
+ cache_hit: snapshot.cache_hit,
1810
+ replayed: snapshot.replayed
1811
+ };
1812
+ });
1813
+ const last = snapshots.at(-1);
1814
+ const snapped = me.snap?.(last);
1815
+ cache2().set(stream, {
1816
+ state: last.state,
1817
+ version: last.event.version,
1818
+ event_id: last.event.id,
1819
+ patches: snapped ? 0 : last.patches,
1820
+ snaps: snapped ? last.snaps + 1 : last.snaps
1821
+ }).catch((err) => log().error(err));
1822
+ if (snapped) void snap(last);
1823
+ return snapshots;
1798
1824
  }
1799
- function mergeEventRegister(target, source) {
1800
- for (const [eventName, sourceReg] of Object.entries(source)) {
1801
- const targetReg = target[eventName];
1802
- if (!targetReg) continue;
1803
- for (const [name, reaction] of sourceReg.reactions) {
1804
- targetReg.reactions.set(name, reaction);
1805
- }
1806
- }
1807
- }
1808
- function mergeProjection(proj, events) {
1809
- for (const eventName of Object.keys(proj.events)) {
1810
- const projRegister = proj.events[eventName];
1811
- const existing = events[eventName];
1812
- if (!existing) {
1813
- events[eventName] = {
1814
- schema: projRegister.schema,
1815
- reactions: new Map(projRegister.reactions)
1816
- };
1817
- } else {
1818
- for (const [name, reaction] of projRegister.reactions) {
1819
- let key = name;
1820
- while (existing.reactions.has(key)) key = `${key}_p`;
1821
- existing.reactions.set(key, reaction);
1822
- }
1823
- }
1824
- }
1825
- }
1826
- var _this_ = ({ stream }) => ({
1827
- source: stream,
1828
- target: stream
1829
- });
1830
1825
 
1831
- // src/internal/backoff.ts
1832
- function computeBackoffDelay(retry, opts) {
1833
- if (!opts || opts.baseMs <= 0) return 0;
1834
- const r = Math.max(0, retry);
1835
- let delay;
1836
- switch (opts.strategy) {
1837
- case "fixed":
1838
- delay = opts.baseMs;
1839
- break;
1840
- case "linear":
1841
- delay = opts.baseMs * (r + 1);
1842
- break;
1843
- case "exponential":
1844
- delay = opts.baseMs * 2 ** r;
1845
- if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
1846
- break;
1826
+ // src/internal/tracing.ts
1827
+ var PRETTY = config().env !== "production";
1828
+ var C_BLUE = "\x1B[38;5;39m";
1829
+ var C_ORANGE = "\x1B[38;5;208m";
1830
+ var C_GREEN = "\x1B[38;5;42m";
1831
+ var C_MAGENTA = "\x1B[38;5;165m";
1832
+ var C_DRAIN = "\x1B[38;5;244m";
1833
+ var C_HIT = "\x1B[38;5;82m";
1834
+ var C_MISS = "\x1B[38;5;220m";
1835
+ var C_RESET = "\x1B[0m";
1836
+ var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
1837
+ var C_LANE = "\x1B[38;5;183m";
1838
+ var C_DIM = "\x1B[38;5;240m";
1839
+ var C_ERR = "\x1B[38;5;196m";
1840
+ var C_STREAM = "\x1B[38;5;226m";
1841
+ var dim = (text) => PRETTY ? `${C_DIM}${text}${C_RESET}` : text;
1842
+ var hue = (color, text) => PRETTY ? `${color}${text}${C_RESET}` : text;
1843
+ var drain_caption = (caption, lane) => {
1844
+ const showLane = lane && lane !== "default";
1845
+ if (PRETTY) {
1846
+ const tag = `${C_DRAIN}>> ${caption}${C_RESET}`;
1847
+ return showLane ? `${tag} ${C_LANE}${lane}${C_RESET}` : tag;
1848
+ }
1849
+ return showLane ? `>> ${caption} ${lane}` : `>> ${caption}`;
1850
+ };
1851
+ var cache_marker = (hit) => {
1852
+ const word = hit ? "hit" : "miss";
1853
+ if (!PRETTY) return word;
1854
+ return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
1855
+ };
1856
+ var stats_marker = (version, replayed, snaps, patches) => {
1857
+ const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
1858
+ if (!PRETTY) return text;
1859
+ return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
1860
+ };
1861
+ var as_of_marker = (asOf) => {
1862
+ if (!asOf) return "";
1863
+ const parts = [];
1864
+ if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
1865
+ if (asOf.created_before !== void 0)
1866
+ parts.push(`created_before=${asOf.created_before.toISOString()}`);
1867
+ if (asOf.created_after !== void 0)
1868
+ parts.push(`created_after=${asOf.created_after.toISOString()}`);
1869
+ if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
1870
+ return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
1871
+ };
1872
+ var traced = (inner, exit, entry) => (async (...args) => {
1873
+ entry?.(...args);
1874
+ const result = await inner(...args);
1875
+ exit?.(result, ...args);
1876
+ return result;
1877
+ });
1878
+ function buildEs(logger, correlator = defaultCorrelator) {
1879
+ const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
1880
+ me,
1881
+ actionName,
1882
+ target,
1883
+ payload,
1884
+ reactingTo,
1885
+ skipValidation,
1886
+ correlator
1887
+ );
1888
+ if (logger.level !== "trace") {
1889
+ return {
1890
+ snap,
1891
+ load,
1892
+ action: boundAction,
1893
+ tombstone
1894
+ };
1847
1895
  }
1848
- if (opts.jitter) delay = delay * (0.5 + Math.random());
1849
- return Math.max(0, Math.floor(delay));
1850
- }
1851
-
1852
- // src/internal/reactions.ts
1853
- function finalize(lease, handled, at, error, options, logger) {
1854
- if (!error) return { lease, handled, at };
1855
- logger.error(error);
1856
- const nonRetryable = error instanceof NonRetryableError;
1857
- const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
1858
- if (block2)
1859
- logger.error(
1860
- nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
1861
- );
1862
- const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
1863
1896
  return {
1864
- lease,
1865
- handled,
1866
- at,
1867
- error: handled === 0 ? error.message : void 0,
1868
- block: block2,
1869
- nextAttemptAt
1870
- };
1871
- }
1872
- function buildHandle(deps) {
1873
- const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
1874
- return async (lease, payloads) => {
1875
- if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
1876
- const stream = lease.stream;
1877
- let at = payloads.at(0).event.id;
1878
- let handled = 0;
1879
- if (lease.retry > 0)
1880
- logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1881
- const scopedApp = {
1882
- do: boundDo,
1883
- load: boundLoad,
1884
- query: boundQuery,
1885
- query_array: boundQueryArray
1886
- };
1887
- for (const payload of payloads) {
1888
- const { event, handler } = payload;
1889
- scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
1890
- action2,
1891
- target,
1892
- actionPayload,
1893
- reactingTo ?? event,
1894
- skipValidation
1897
+ snap: traced(snap, void 0, (snapshot) => {
1898
+ logger.trace(
1899
+ es_caption(
1900
+ "snap",
1901
+ C_MAGENTA,
1902
+ `${snapshot.event.stream}@${snapshot.event.version}`
1903
+ )
1895
1904
  );
1896
- try {
1897
- await handler(event, stream, scopedApp);
1898
- at = event.id;
1899
- handled++;
1900
- } catch (error) {
1901
- return finalize(
1902
- lease,
1903
- handled,
1904
- at,
1905
- error,
1906
- payload.options,
1907
- logger
1905
+ }),
1906
+ load: traced(load, (result, _me, stream, _cb, asOf) => {
1907
+ const stats = stats_marker(
1908
+ result.version,
1909
+ result.replayed,
1910
+ result.snaps,
1911
+ result.patches
1912
+ );
1913
+ logger.trace(
1914
+ es_caption(
1915
+ "load",
1916
+ C_GREEN,
1917
+ `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
1918
+ )
1919
+ );
1920
+ }),
1921
+ action: traced(
1922
+ boundAction,
1923
+ (snapshots, _me, _action, target) => {
1924
+ const committed = snapshots.filter((s) => s.event);
1925
+ if (committed.length) {
1926
+ logger.trace(
1927
+ committed.map((s) => s.event.data),
1928
+ es_caption(
1929
+ "committed",
1930
+ C_ORANGE,
1931
+ `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
1932
+ )
1933
+ );
1934
+ }
1935
+ },
1936
+ (_me, action2, target, payload) => {
1937
+ logger.trace(
1938
+ payload,
1939
+ es_caption("action", C_BLUE, `${target.stream}.${action2}`)
1908
1940
  );
1909
1941
  }
1910
- }
1911
- return finalize(lease, handled, at, void 0, payloads[0].options, logger);
1942
+ ),
1943
+ tombstone: traced(tombstone, (committed, stream) => {
1944
+ if (committed)
1945
+ logger.trace(
1946
+ es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
1947
+ );
1948
+ })
1912
1949
  };
1913
1950
  }
1914
- function buildHandleBatch(logger) {
1915
- return async (lease, payloads, batchHandler) => {
1916
- const stream = lease.stream;
1917
- const events = payloads.map((p) => p.event);
1918
- const options = payloads[0].options;
1919
- if (lease.retry > 0)
1920
- logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
1921
- try {
1922
- await batchHandler(events, stream);
1923
- return finalize(
1924
- lease,
1925
- events.length,
1926
- events.at(-1).id,
1927
- void 0,
1928
- options,
1929
- logger
1930
- );
1931
- } catch (error) {
1932
- return finalize(lease, 0, lease.at, error, options, logger);
1933
- }
1951
+ function buildDrain(logger) {
1952
+ return {
1953
+ claim,
1954
+ fetch,
1955
+ ack,
1956
+ block,
1957
+ subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
1958
+ if (!result.subscribed) return;
1959
+ const lanes = new Set(streams.map((s) => s.lane ?? "default"));
1960
+ const uniformLane = lanes.size === 1 ? streams[0]?.lane : void 0;
1961
+ const data = streams.map(
1962
+ ({ stream, lane }) => uniformLane || !lane || lane === "default" ? hue(C_STREAM, stream) : `${hue(C_STREAM, stream)}${dim(`[${lane}]`)}`
1963
+ ).join(" ");
1964
+ logger.trace(`${drain_caption("correlated", uniformLane)} ${data}`);
1965
+ })
1934
1966
  };
1935
1967
  }
1968
+ function traceCycle(logger, leased, fetched, handled, acked, blocked) {
1969
+ if (logger.level !== "trace" || !leased.length) return;
1970
+ const lane = leased[0]?.lane;
1971
+ const fetchByStream = new Map(fetched.map((f) => [f.stream, f]));
1972
+ const ackedByStream = new Map(acked.map((a) => [a.stream, a.at]));
1973
+ const blockedByStream = new Map(blocked.map((b) => [b.stream, b.error]));
1974
+ const failedByStream = new Map(
1975
+ handled.filter((h) => h.error).map((h) => [h.lease.stream, h])
1976
+ );
1977
+ const detail = leased.map(({ stream, at, retry }) => {
1978
+ const f = fetchByStream.get(stream);
1979
+ const key = f?.source ? `${hue(C_STREAM, stream)}${dim(`<-${f.source}`)}` : hue(C_STREAM, stream);
1980
+ const events = f && f.events.length ? ` ${dim(
1981
+ `[${f.events.map(({ id, name }) => `#${id} ${String(name)}`).join(", ")}]`
1982
+ )}` : "";
1983
+ const ackedAt = ackedByStream.get(stream);
1984
+ const ackPart = ackedAt !== void 0 ? hue(C_HIT, `\u2713 @${ackedAt}`) : "";
1985
+ const failure = failedByStream.get(stream);
1986
+ let failPart = "";
1987
+ if (failure) {
1988
+ const failedAt = failure.failed_at ?? at;
1989
+ const blockedError = blockedByStream.get(stream);
1990
+ if (blockedError !== void 0) {
1991
+ failPart = `${hue(C_ERR, `\u2717 @${failedAt}/${retry}`)} ${dim(`(${blockedError})`)}`;
1992
+ } else {
1993
+ failPart = `${hue(C_MISS, `\u26A0 @${failedAt}/${retry}`)} ${dim(`(${failure.error})`)}`;
1994
+ }
1995
+ }
1996
+ let tail;
1997
+ if (ackPart && failPart) tail = ` ${ackPart} ${failPart}`;
1998
+ else if (ackPart) tail = ` ${ackPart}`;
1999
+ else if (failPart) tail = ` ${failPart}`;
2000
+ else tail = ` ${dim(`\u2298 @${at}/${retry}`)}`;
2001
+ return `${key}${events}${tail}`;
2002
+ }).join(", ");
2003
+ logger.trace(`${drain_caption("drained", lane)} ${detail}`);
2004
+ }
1936
2005
 
1937
- // src/internal/settle.ts
1938
- var SettleLoop = class {
1939
- constructor(deps, defaultDebounceMs) {
1940
- this.deps = deps;
1941
- this.defaultDebounceMs = defaultDebounceMs;
2006
+ // src/internal/drain-cycle.ts
2007
+ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred, lane) {
2008
+ const leased = await ops.claim(
2009
+ lagging,
2010
+ leading,
2011
+ (0, import_node_crypto2.randomUUID)(),
2012
+ leaseMillis,
2013
+ lane
2014
+ );
2015
+ if (!leased.length) return void 0;
2016
+ const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
2017
+ if (!active.length) {
2018
+ return {
2019
+ leased,
2020
+ fetched: [],
2021
+ handled: [],
2022
+ acked: [],
2023
+ blocked: []
2024
+ };
1942
2025
  }
1943
- _timer = void 0;
1944
- _running = false;
1945
- /**
1946
- * Schedule a settle pass. Multiple calls inside the debounce window
1947
- * coalesce into one cycle. The cycle runs correlate→drain in a loop
1948
- * until no progress is made (no new subscriptions, no acks, no blocks)
1949
- * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
1950
- * via {@link SettleDeps.onSettled}.
2026
+ const fetched = await ops.fetch(active, eventLimit);
2027
+ const fetchMap = /* @__PURE__ */ new Map();
2028
+ const fetch_window_at = fetched.reduce(
2029
+ (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
2030
+ 0
2031
+ );
2032
+ for (const f of fetched) {
2033
+ const { stream, events } = f;
2034
+ const payloads = events.flatMap((event) => {
2035
+ const register = registry.events[event.name];
2036
+ if (!register) return [];
2037
+ return [...register.reactions.values()].filter((reaction) => {
2038
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
2039
+ return resolved && resolved.target === stream;
2040
+ }).map((reaction) => ({ ...reaction, event }));
2041
+ });
2042
+ fetchMap.set(stream, { fetch: f, payloads });
2043
+ }
2044
+ const handled = await Promise.all(
2045
+ active.map((lease) => {
2046
+ const entry = fetchMap.get(lease.stream);
2047
+ const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
2048
+ const { payloads } = entry;
2049
+ const batchHandler = batchHandlers.get(lease.stream);
2050
+ if (batchHandler && payloads.length > 0) {
2051
+ return handleBatch({ ...lease, at }, payloads, batchHandler);
2052
+ }
2053
+ return handle({ ...lease, at }, payloads);
2054
+ })
2055
+ );
2056
+ const acked = await ops.ack(
2057
+ handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
2058
+ );
2059
+ const blocked = await ops.block(
2060
+ handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
2061
+ );
2062
+ return { leased, fetched, handled, acked, blocked };
2063
+ }
2064
+ var EMPTY_DRAIN = {
2065
+ fetched: [],
2066
+ leased: [],
2067
+ acked: [],
2068
+ blocked: []
2069
+ };
2070
+ var DrainController = class {
2071
+ constructor(deps) {
2072
+ this.deps = deps;
2073
+ }
2074
+ _armed = false;
2075
+ _locked = false;
2076
+ _ratio = 0.5;
2077
+ /**
2078
+ * Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
2079
+ * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
2080
+ * ack or terminal block. Lives in process memory — per-worker pacing
2081
+ * by design (see {@link BackoffOptions} for the multi-worker trade-off).
1951
2082
  */
1952
- schedule(options = {}) {
1953
- const {
1954
- debounceMs = this.defaultDebounceMs,
1955
- correlate: correlateQuery = { after: -1, limit: 100 },
1956
- maxPasses = Infinity,
1957
- ...drainOptions
1958
- } = options;
1959
- if (this._timer) clearTimeout(this._timer);
1960
- this._timer = setTimeout(() => {
1961
- this._timer = void 0;
1962
- if (this._running) return;
1963
- this._running = true;
1964
- (async () => {
1965
- await this.deps.init();
1966
- let lastDrain;
1967
- for (let i = 0; i < maxPasses; i++) {
1968
- const { subscribed } = await this.deps.correlate({
1969
- ...correlateQuery,
1970
- after: this.deps.checkpoint()
1971
- });
1972
- lastDrain = await this.deps.drain(drainOptions);
1973
- const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
1974
- if (!made_progress) break;
1975
- }
1976
- if (lastDrain) this.deps.onSettled(lastDrain);
1977
- })().catch((err) => this.deps.logger.error(err)).finally(() => {
1978
- this._running = false;
1979
- });
1980
- }, debounceMs);
2083
+ _backoff = /* @__PURE__ */ new Map();
2084
+ /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
2085
+ _backoffTimer;
2086
+ /** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
2087
+ _worker;
2088
+ _stopped = false;
2089
+ /**
2090
+ * Signal that a commit (or reset / cold-start) may have produced work.
2091
+ * Subsequent `drain()` calls will run the pipeline; once the pipeline
2092
+ * settles to no-progress, the controller disarms itself.
2093
+ */
2094
+ arm() {
2095
+ this._armed = true;
1981
2096
  }
1982
- /** Cancel any pending or active settle cycle. Idempotent. */
2097
+ /** Read-only flag true while a commit / reset is unprocessed. */
2098
+ get armed() {
2099
+ return this._armed;
2100
+ }
2101
+ /** Returns true when `stream` is currently within a backoff window. */
2102
+ isDeferred = (stream) => {
2103
+ const next = this._backoff.get(stream);
2104
+ return next !== void 0 && next > Date.now();
2105
+ };
2106
+ /**
2107
+ * Schedule the next drain re-arm at the earliest pending backoff
2108
+ * expiry. Called only when the backoff map is non-empty (caller guard).
2109
+ * Idempotent — collapses many simultaneously deferred streams into a
2110
+ * single timer.
2111
+ */
2112
+ scheduleBackoffWake() {
2113
+ if (this._backoffTimer) clearTimeout(this._backoffTimer);
2114
+ let earliest = Number.POSITIVE_INFINITY;
2115
+ for (const t of this._backoff.values()) if (t < earliest) earliest = t;
2116
+ const delay = Math.max(0, earliest - Date.now());
2117
+ this._backoffTimer = setTimeout(() => {
2118
+ this._backoffTimer = void 0;
2119
+ const now = Date.now();
2120
+ for (const [stream, at] of this._backoff) {
2121
+ if (at <= now) this._backoff.delete(stream);
2122
+ }
2123
+ this._armed = true;
2124
+ }, delay);
2125
+ this._backoffTimer.unref();
2126
+ }
2127
+ /** Lane this controller drains (undefined = legacy single-lane span). */
2128
+ get lane() {
2129
+ return this.deps.lane;
2130
+ }
2131
+ /**
2132
+ * Start a per-lane worker that drains at the lane's `cycleMs`
2133
+ * cadence (ACT-1103). When armed, the worker calls `drain()` on every
2134
+ * tick and re-schedules; when not armed, it still re-schedules at
2135
+ * `cycleMs` so a future `arm()` is picked up on the next tick.
2136
+ *
2137
+ * The setTimeout chain uses `unref()` so it doesn't keep the process
2138
+ * alive on its own.
2139
+ */
2140
+ start(cycleMs) {
2141
+ if (this._worker || this._stopped) return;
2142
+ const tick = async () => {
2143
+ if (this._armed) await this.drain();
2144
+ if (this._stopped) return;
2145
+ this._worker = setTimeout(tick, cycleMs);
2146
+ this._worker.unref();
2147
+ };
2148
+ this._worker = setTimeout(tick, cycleMs);
2149
+ this._worker.unref();
2150
+ }
2151
+ /** Stop the per-lane worker. Idempotent. */
1983
2152
  stop() {
1984
- if (this._timer) {
1985
- clearTimeout(this._timer);
1986
- this._timer = void 0;
2153
+ this._stopped = true;
2154
+ if (this._worker) {
2155
+ clearTimeout(this._worker);
2156
+ this._worker = void 0;
2157
+ }
2158
+ }
2159
+ /** Run one drain pass. Short-circuits when not armed or already running. */
2160
+ async drain(options = {}) {
2161
+ if (!this._armed) return EMPTY_DRAIN;
2162
+ if (this._locked) return EMPTY_DRAIN;
2163
+ const d = this.deps.defaults ?? {};
2164
+ const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
2165
+ const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
2166
+ const leaseMillis = d.leaseMillis ?? options.leaseMillis ?? 1e4;
2167
+ try {
2168
+ this._locked = true;
2169
+ const lagging = Math.ceil(streamLimit * this._ratio);
2170
+ const leading = streamLimit - lagging;
2171
+ const cycle = await runDrainCycle(
2172
+ this.deps.ops,
2173
+ this.deps.registry,
2174
+ this.deps.batchHandlers,
2175
+ this.deps.handle,
2176
+ this.deps.handleBatch,
2177
+ lagging,
2178
+ leading,
2179
+ eventLimit,
2180
+ leaseMillis,
2181
+ this._backoff.size > 0 ? this.isDeferred : void 0,
2182
+ this.deps.lane
2183
+ );
2184
+ if (!cycle) {
2185
+ this._armed = false;
2186
+ return EMPTY_DRAIN;
2187
+ }
2188
+ const { leased, fetched, handled, acked, blocked } = cycle;
2189
+ traceCycle(this.deps.logger, leased, fetched, handled, acked, blocked);
2190
+ this._ratio = computeLagLeadRatio(handled, lagging, leading);
2191
+ for (const lease of acked) this._backoff.delete(lease.stream);
2192
+ for (const lease of blocked) this._backoff.delete(lease.stream);
2193
+ for (const h of handled) {
2194
+ if (h.nextAttemptAt !== void 0 && !h.block) {
2195
+ this._backoff.set(h.lease.stream, h.nextAttemptAt);
2196
+ }
2197
+ }
2198
+ if (this._backoff.size > 0) this.scheduleBackoffWake();
2199
+ if (acked.length) this.deps.onAcked(acked);
2200
+ if (blocked.length) this.deps.onBlocked(blocked);
2201
+ const hasErrors = handled.some(({ error }) => error);
2202
+ if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
2203
+ return { fetched, leased, acked, blocked };
2204
+ } catch (error) {
2205
+ this.deps.logger.error(error);
2206
+ return EMPTY_DRAIN;
2207
+ } finally {
2208
+ this._locked = false;
1987
2209
  }
1988
2210
  }
1989
2211
  };
1990
2212
 
1991
- // src/internal/drain.ts
1992
- var claim = (lagging, leading, by, millis) => store2().claim(lagging, leading, by, millis);
1993
- async function fetch(leased, eventLimit) {
1994
- return Promise.all(
1995
- leased.map(async ({ stream, source, at, lagging }) => {
1996
- const events = [];
1997
- await store2().query((e) => events.push(e), {
1998
- stream: source,
1999
- after: at,
2000
- limit: eventLimit
2001
- });
2002
- return { stream, source, at, lagging, events };
2003
- })
2004
- );
2213
+ // src/internal/event-versions.ts
2214
+ var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
2215
+ function parse(name) {
2216
+ const m = name.match(VERSION_SUFFIX);
2217
+ if (m) {
2218
+ const v = Number.parseInt(m[2], 10);
2219
+ if (v >= 2) return { base: m[1], version: v };
2220
+ }
2221
+ return { base: name, version: 1 };
2005
2222
  }
2006
- var ack = (leases) => store2().ack(leases);
2007
- var block = (leases) => store2().block(leases);
2008
- var subscribe = (streams) => store2().subscribe(streams);
2009
-
2010
- // src/internal/event-sourcing.ts
2011
- var import_act_patch = require("@rotorsoft/act-patch");
2012
- async function snap(snapshot) {
2013
- try {
2014
- const { id, stream, name, meta, version } = snapshot.event;
2015
- await store2().commit(
2016
- stream,
2017
- [{ name: SNAP_EVENT, data: snapshot.state }],
2018
- {
2019
- correlation: meta.correlation,
2020
- causation: { event: { id, name, stream } }
2021
- },
2022
- version
2023
- // IMPORTANT! - state events are committed right after the snapshot event
2024
- );
2025
- } catch (error) {
2026
- log().error(error);
2223
+ function deprecatedEventNames(names) {
2224
+ const groups = /* @__PURE__ */ new Map();
2225
+ for (const name of names) {
2226
+ const { base, version } = parse(name);
2227
+ const list = groups.get(base);
2228
+ if (list) list.push({ version, name });
2229
+ else groups.set(base, [{ version, name }]);
2027
2230
  }
2231
+ const deprecated = /* @__PURE__ */ new Set();
2232
+ for (const list of groups.values()) {
2233
+ if (list.length < 2) continue;
2234
+ list.sort((a, b) => b.version - a.version);
2235
+ for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
2236
+ }
2237
+ return deprecated;
2028
2238
  }
2029
- async function tombstone(stream, expectedVersion, correlation) {
2030
- try {
2031
- const [committed] = await store2().commit(
2032
- stream,
2033
- [{ name: TOMBSTONE_EVENT, data: {} }],
2034
- { correlation, causation: {} },
2035
- expectedVersion
2036
- );
2037
- return committed;
2038
- } catch (error) {
2039
- if (error instanceof ConcurrencyError) return void 0;
2040
- throw error;
2239
+ function currentVersionOf(deprecatedName, allNames) {
2240
+ const target = parse(deprecatedName);
2241
+ let highest;
2242
+ for (const name of allNames) {
2243
+ const { base, version } = parse(name);
2244
+ if (base !== target.base) continue;
2245
+ if (!highest || version > highest.version) highest = { version, name };
2041
2246
  }
2247
+ return highest && highest.version > target.version ? highest.name : void 0;
2042
2248
  }
2043
- async function load(me, stream, callback, asOf) {
2044
- const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
2045
- const cached = timeTravel ? void 0 : await cache2().get(stream);
2046
- const cache_hit = !!cached;
2047
- let state2 = cached?.state ?? (me.init ? me.init() : {});
2048
- let patches = cached?.patches ?? 0;
2049
- let snaps = cached?.snaps ?? 0;
2050
- let version = cached?.version ?? -1;
2051
- let replayed = 0;
2052
- let event;
2053
- await store2().query(
2054
- (e) => {
2055
- event = e;
2056
- version = e.version;
2057
- if (e.name === SNAP_EVENT) {
2058
- state2 = e.data;
2059
- snaps++;
2060
- patches = 0;
2061
- replayed++;
2062
- } else if (me.patch[e.name]) {
2063
- state2 = (0, import_act_patch.patch)(state2, me.patch[e.name](event, state2));
2064
- patches++;
2065
- replayed++;
2066
- } else if (e.name !== TOMBSTONE_EVENT) {
2067
- log().warn(
2068
- `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
2069
- );
2249
+
2250
+ // src/internal/merge.ts
2251
+ var import_zod4 = require("zod");
2252
+ function baseTypeName(zodType) {
2253
+ let t = zodType;
2254
+ while (typeof t.unwrap === "function") {
2255
+ t = t.unwrap();
2256
+ }
2257
+ return t.constructor.name;
2258
+ }
2259
+ function mergeSchemas(existing, incoming, stateName) {
2260
+ if (existing instanceof import_zod4.ZodObject && incoming instanceof import_zod4.ZodObject) {
2261
+ const existingShape = existing.shape;
2262
+ const incomingShape = incoming.shape;
2263
+ for (const key of Object.keys(incomingShape)) {
2264
+ if (key in existingShape) {
2265
+ const existingBase = baseTypeName(existingShape[key]);
2266
+ const incomingBase = baseTypeName(incomingShape[key]);
2267
+ if (existingBase !== incomingBase) {
2268
+ throw new Error(
2269
+ `Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
2270
+ );
2271
+ }
2070
2272
  }
2071
- callback?.({
2072
- event,
2073
- state: state2,
2074
- version,
2075
- patches,
2076
- snaps,
2077
- cache_hit,
2078
- replayed
2079
- });
2080
- },
2081
- {
2082
- stream,
2083
- stream_exact: true,
2084
- ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
2085
2273
  }
2086
- );
2087
- if (replayed > 0 && !timeTravel && event) {
2088
- await cache2().set(stream, {
2089
- state: state2,
2090
- version,
2091
- event_id: event.id,
2092
- patches,
2093
- snaps
2094
- });
2274
+ return existing.extend(incomingShape);
2275
+ }
2276
+ return existing;
2277
+ }
2278
+ function mergeInits(existing, incoming) {
2279
+ return () => ({ ...existing(), ...incoming() });
2280
+ }
2281
+ function registerState(state2, states, actions, events) {
2282
+ const existing = states.get(state2.name);
2283
+ if (existing) {
2284
+ mergeIntoExisting(state2, existing, states, actions, events);
2285
+ } else {
2286
+ registerNewState(state2, states, actions, events);
2287
+ }
2288
+ }
2289
+ function registerNewState(state2, states, actions, events) {
2290
+ states.set(state2.name, state2);
2291
+ for (const name of Object.keys(state2.actions)) {
2292
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
2293
+ actions[name] = state2;
2294
+ }
2295
+ for (const name of Object.keys(state2.events)) {
2296
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
2297
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
2095
2298
  }
2096
- return { event, state: state2, version, patches, snaps, cache_hit, replayed };
2097
2299
  }
2098
- async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
2099
- const { stream, expectedVersion, actor } = target;
2100
- if (!stream) throw new Error("Missing target stream");
2101
- const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
2102
- const snapshot = await load(me, stream);
2103
- if (snapshot.event?.name === TOMBSTONE_EVENT)
2104
- throw new StreamClosedError(stream);
2105
- const expected = expectedVersion ?? snapshot.event?.version;
2106
- if (me.given) {
2107
- const invariants = me.given[action2] || [];
2108
- invariants.forEach(({ valid, description }) => {
2109
- if (!valid(snapshot.state, actor))
2110
- throw new InvariantError(
2111
- action2,
2112
- validated,
2113
- target,
2114
- snapshot,
2115
- description
2116
- );
2117
- });
2300
+ function mergeIntoExisting(state2, existing, states, actions, events) {
2301
+ for (const name of Object.keys(state2.actions)) {
2302
+ if (existing.actions[name] === state2.actions[name]) continue;
2303
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
2118
2304
  }
2119
- const result = me.on[action2](validated, snapshot, target);
2120
- if (!result) return [snapshot];
2121
- if (Array.isArray(result) && result.length === 0) {
2122
- return [snapshot];
2305
+ for (const name of Object.keys(state2.events)) {
2306
+ if (existing.events[name] === state2.events[name]) continue;
2307
+ if (existing.events[name]) {
2308
+ throw new Error(
2309
+ `Event "${name}" in state "${state2.name}" is declared with different Zod schemas across slices. Cross-slice event schemas must reference the same instance \u2014 extract a shared schema (e.g. \`export const ${name} = z.object({ ... })\` in a shared module) and import it in every slice that declares it.`
2310
+ );
2311
+ }
2312
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
2123
2313
  }
2124
- const tuples = Array.isArray(result[0]) ? result : [result];
2125
- const deprecated = me._deprecated;
2126
- if (deprecated && deprecated.size > 0) {
2127
- const me_ = me;
2128
- const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
2129
- for (const [name] of tuples) {
2130
- const evt = name;
2131
- if (deprecated.has(evt) && !warned.has(evt)) {
2132
- warned.add(evt);
2133
- log().warn(
2134
- `Action "${String(action2)}" emitted deprecated event "${evt}". A newer version exists in the registry \u2014 update the action's .emit() to target the current version. (warned once per process)`
2135
- );
2136
- }
2314
+ const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
2315
+ const merged = {
2316
+ ...existing,
2317
+ state: mergeSchemas(existing.state, state2.state, state2.name),
2318
+ init: mergeInits(existing.init, state2.init),
2319
+ events: { ...existing.events, ...state2.events },
2320
+ actions: { ...existing.actions, ...state2.actions },
2321
+ patch: mergedPatch,
2322
+ on: { ...existing.on, ...state2.on },
2323
+ given: { ...existing.given, ...state2.given },
2324
+ snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
2325
+ throw new Error(
2326
+ `Duplicate snap strategy for state "${state2.name}"`
2327
+ );
2328
+ })() : state2.snap || existing.snap
2329
+ };
2330
+ states.set(state2.name, merged);
2331
+ for (const name of Object.keys(merged.actions)) {
2332
+ actions[name] = merged;
2333
+ }
2334
+ for (const name of Object.keys(state2.events)) {
2335
+ if (events[name]) continue;
2336
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
2337
+ }
2338
+ }
2339
+ function mergePatches(existing, incoming, stateName) {
2340
+ const merged = { ...existing };
2341
+ for (const name of Object.keys(incoming)) {
2342
+ const existingP = existing[name];
2343
+ const incomingP = incoming[name];
2344
+ if (!existingP) {
2345
+ merged[name] = incomingP;
2346
+ continue;
2347
+ }
2348
+ const existingIsDefault = existingP._passthrough;
2349
+ const incomingIsDefault = incomingP._passthrough;
2350
+ if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
2351
+ throw new Error(
2352
+ `Duplicate custom patch for event "${name}" in state "${stateName}"`
2353
+ );
2354
+ }
2355
+ if (existingIsDefault && !incomingIsDefault) {
2356
+ merged[name] = incomingP;
2137
2357
  }
2138
2358
  }
2139
- const emitted = tuples.map(([name, data]) => ({
2140
- name,
2141
- data: skipValidation ? data : validate(name, data, me.events[name])
2142
- }));
2143
- const meta = {
2144
- correlation: reactingTo?.meta.correlation || correlator({
2145
- action: action2,
2146
- state: me.name,
2147
- stream,
2148
- actor: target.actor
2149
- }),
2150
- causation: {
2151
- action: {
2152
- name: action2,
2153
- ...target
2154
- // payload intentionally omitted: it can be large or contain PII,
2155
- // and callers correlate via the correlation id when they need it.
2156
- },
2157
- event: reactingTo ? {
2158
- id: reactingTo.id,
2159
- name: reactingTo.name,
2160
- stream: reactingTo.stream
2161
- } : void 0
2359
+ return merged;
2360
+ }
2361
+ function mergeEventRegister(target, source) {
2362
+ for (const [eventName, sourceReg] of Object.entries(source)) {
2363
+ const targetReg = target[eventName];
2364
+ if (!targetReg) continue;
2365
+ for (const [name, reaction] of sourceReg.reactions) {
2366
+ targetReg.reactions.set(name, reaction);
2162
2367
  }
2163
- };
2164
- let committed;
2165
- try {
2166
- committed = await store2().commit(
2167
- stream,
2168
- emitted,
2169
- meta,
2170
- // Reactions skip optimistic concurrency: they always append against the
2171
- // current head. Stream leasing already serializes concurrent reactions,
2172
- // and forcing version checks here would turn ordinary catch-up into
2173
- // spurious retries.
2174
- reactingTo ? void 0 : expected
2175
- );
2176
- } catch (error) {
2177
- if (error instanceof ConcurrencyError) {
2178
- await cache2().invalidate(stream);
2368
+ }
2369
+ }
2370
+ function mergeProjection(proj, events) {
2371
+ for (const eventName of Object.keys(proj.events)) {
2372
+ const projRegister = proj.events[eventName];
2373
+ const existing = events[eventName];
2374
+ if (!existing) {
2375
+ events[eventName] = {
2376
+ schema: projRegister.schema,
2377
+ reactions: new Map(projRegister.reactions)
2378
+ };
2379
+ } else {
2380
+ for (const [name, reaction] of projRegister.reactions) {
2381
+ let key = name;
2382
+ while (existing.reactions.has(key)) key = `${key}_p`;
2383
+ existing.reactions.set(key, reaction);
2384
+ }
2179
2385
  }
2180
- throw error;
2181
2386
  }
2182
- let { state: state2, patches } = snapshot;
2183
- const snapshots = committed.map((event) => {
2184
- const p = me.patch[event.name](event, state2);
2185
- state2 = (0, import_act_patch.patch)(state2, p);
2186
- patches++;
2187
- return {
2188
- event,
2189
- state: state2,
2190
- version: event.version,
2191
- patches,
2192
- snaps: snapshot.snaps,
2193
- patch: p,
2194
- cache_hit: snapshot.cache_hit,
2195
- replayed: snapshot.replayed
2196
- };
2197
- });
2198
- const last = snapshots.at(-1);
2199
- const snapped = me.snap?.(last);
2200
- cache2().set(stream, {
2201
- state: last.state,
2202
- version: last.event.version,
2203
- event_id: last.event.id,
2204
- patches: snapped ? 0 : last.patches,
2205
- snaps: snapped ? last.snaps + 1 : last.snaps
2206
- }).catch((err) => log().error(err));
2207
- if (snapped) void snap(last);
2208
- return snapshots;
2209
2387
  }
2210
-
2211
- // src/internal/tracing.ts
2212
- var PRETTY = config().env !== "production";
2213
- var C_BLUE = "\x1B[38;5;39m";
2214
- var C_ORANGE = "\x1B[38;5;208m";
2215
- var C_GREEN = "\x1B[38;5;42m";
2216
- var C_MAGENTA = "\x1B[38;5;165m";
2217
- var C_DRAIN = "\x1B[38;5;244m";
2218
- var C_HIT = "\x1B[38;5;82m";
2219
- var C_MISS = "\x1B[38;5;220m";
2220
- var C_RESET = "\x1B[0m";
2221
- var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
2222
- var drain_caption = (caption) => {
2223
- const tag = `>> ${caption}`;
2224
- return PRETTY ? `${C_DRAIN}${tag}${C_RESET}` : tag;
2225
- };
2226
- var cache_marker = (hit) => {
2227
- const word = hit ? "hit" : "miss";
2228
- if (!PRETTY) return word;
2229
- return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
2230
- };
2231
- var stats_marker = (version, replayed, snaps, patches) => {
2232
- const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
2233
- if (!PRETTY) return text;
2234
- return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
2235
- };
2236
- var as_of_marker = (asOf) => {
2237
- if (!asOf) return "";
2238
- const parts = [];
2239
- if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
2240
- if (asOf.created_before !== void 0)
2241
- parts.push(`created_before=${asOf.created_before.toISOString()}`);
2242
- if (asOf.created_after !== void 0)
2243
- parts.push(`created_after=${asOf.created_after.toISOString()}`);
2244
- if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
2245
- return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
2246
- };
2247
- var traced = (inner, exit, entry) => (async (...args) => {
2248
- entry?.(...args);
2249
- const result = await inner(...args);
2250
- exit?.(result, ...args);
2251
- return result;
2388
+ var _this_ = ({ stream }) => ({
2389
+ source: stream,
2390
+ target: stream
2252
2391
  });
2253
- function buildEs(logger, correlator = defaultCorrelator) {
2254
- const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
2255
- me,
2256
- actionName,
2257
- target,
2258
- payload,
2259
- reactingTo,
2260
- skipValidation,
2261
- correlator
2262
- );
2263
- if (logger.level !== "trace") {
2264
- return {
2265
- snap,
2266
- load,
2267
- action: boundAction,
2268
- tombstone
2269
- };
2392
+
2393
+ // src/internal/backoff.ts
2394
+ function computeBackoffDelay(retry, opts) {
2395
+ if (!opts || opts.baseMs <= 0) return 0;
2396
+ const r = Math.max(0, retry);
2397
+ let delay;
2398
+ switch (opts.strategy) {
2399
+ case "fixed":
2400
+ delay = opts.baseMs;
2401
+ break;
2402
+ case "linear":
2403
+ delay = opts.baseMs * (r + 1);
2404
+ break;
2405
+ case "exponential":
2406
+ delay = opts.baseMs * 2 ** r;
2407
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
2408
+ break;
2270
2409
  }
2410
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
2411
+ return Math.max(0, Math.floor(delay));
2412
+ }
2413
+
2414
+ // src/internal/reactions.ts
2415
+ function finalize(lease, handled, at, error, options, logger, failed_at) {
2416
+ if (!error) return { lease, handled, acked_at: at };
2417
+ logger.error(error);
2418
+ const nonRetryable = error instanceof NonRetryableError;
2419
+ const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
2420
+ if (block2)
2421
+ logger.error(
2422
+ nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
2423
+ );
2424
+ const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
2271
2425
  return {
2272
- snap: traced(snap, void 0, (snapshot) => {
2273
- logger.trace(
2274
- es_caption(
2275
- "snap",
2276
- C_MAGENTA,
2277
- `${snapshot.event.stream}@${snapshot.event.version}`
2278
- )
2279
- );
2280
- }),
2281
- load: traced(load, (result, _me, stream, _cb, asOf) => {
2282
- const stats = stats_marker(
2283
- result.version,
2284
- result.replayed,
2285
- result.snaps,
2286
- result.patches
2287
- );
2288
- logger.trace(
2289
- es_caption(
2290
- "load",
2291
- C_GREEN,
2292
- `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
2293
- )
2294
- );
2295
- }),
2296
- action: traced(
2297
- boundAction,
2298
- (snapshots, _me, _action, target) => {
2299
- const committed = snapshots.filter((s) => s.event);
2300
- if (committed.length) {
2301
- logger.trace(
2302
- committed.map((s) => s.event.data),
2303
- es_caption(
2304
- "committed",
2305
- C_ORANGE,
2306
- `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
2307
- )
2308
- );
2309
- }
2310
- },
2311
- (_me, action2, target, payload) => {
2312
- logger.trace(
2313
- payload,
2314
- es_caption("action", C_BLUE, `${target.stream}.${action2}`)
2315
- );
2316
- }
2317
- ),
2318
- tombstone: traced(tombstone, (committed, stream) => {
2319
- if (committed)
2320
- logger.trace(
2321
- es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
2322
- );
2323
- })
2426
+ lease,
2427
+ handled,
2428
+ acked_at: at,
2429
+ error: error.message,
2430
+ block: block2,
2431
+ nextAttemptAt,
2432
+ failed_at
2324
2433
  };
2325
2434
  }
2326
- function buildDrain(logger) {
2327
- if (logger.level !== "trace") {
2328
- return {
2329
- claim,
2330
- fetch,
2331
- ack,
2332
- block,
2333
- subscribe
2435
+ function buildHandle(deps) {
2436
+ const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
2437
+ return async (lease, payloads) => {
2438
+ if (payloads.length === 0) return { lease, handled: 0, acked_at: lease.at };
2439
+ const stream = lease.stream;
2440
+ let at = payloads.at(0).event.id;
2441
+ let handled = 0;
2442
+ if (lease.retry > 0)
2443
+ logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
2444
+ const scopedApp = {
2445
+ do: boundDo,
2446
+ load: boundLoad,
2447
+ query: boundQuery,
2448
+ query_array: boundQueryArray
2334
2449
  };
2335
- }
2336
- return {
2337
- claim: traced(claim, (leased) => {
2338
- if (leased.length) {
2339
- const data = Object.fromEntries(
2340
- leased.map(({ stream, at, retry }) => [stream, { at, retry }])
2341
- );
2342
- logger.trace(data, drain_caption("claimed"));
2343
- }
2344
- }),
2345
- fetch: traced(fetch, (fetched) => {
2346
- const data = Object.fromEntries(
2347
- fetched.map(({ stream, source, events }) => {
2348
- const key = source ? `${stream}<-${source}` : stream;
2349
- const value = Object.fromEntries(
2350
- events.map(({ id, stream: stream2, name }) => [id, { [stream2]: name }])
2351
- );
2352
- return [key, value];
2353
- })
2450
+ for (const payload of payloads) {
2451
+ const { event, handler } = payload;
2452
+ scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
2453
+ action2,
2454
+ target,
2455
+ actionPayload,
2456
+ reactingTo ?? event,
2457
+ skipValidation
2354
2458
  );
2355
- logger.trace(data, drain_caption("fetched"));
2356
- }),
2357
- ack: traced(ack, (acked) => {
2358
- if (acked.length) {
2359
- const data = Object.fromEntries(
2360
- acked.map(({ stream, at, retry }) => [stream, { at, retry }])
2361
- );
2362
- logger.trace(data, drain_caption("acked"));
2363
- }
2364
- }),
2365
- block: traced(block, (blocked) => {
2366
- if (blocked.length) {
2367
- const data = Object.fromEntries(
2368
- blocked.map(({ stream, at, retry, error }) => [
2369
- stream,
2370
- { at, retry, error }
2371
- ])
2459
+ try {
2460
+ await handler(event, stream, scopedApp);
2461
+ at = event.id;
2462
+ handled++;
2463
+ } catch (error) {
2464
+ return finalize(
2465
+ lease,
2466
+ handled,
2467
+ at,
2468
+ error,
2469
+ payload.options,
2470
+ logger,
2471
+ event.id
2372
2472
  );
2373
- logger.trace(data, drain_caption("blocked"));
2374
- }
2375
- }),
2376
- subscribe: traced(subscribe, (result, streams) => {
2377
- if (result.subscribed) {
2378
- const data = streams.map(({ stream }) => stream).join(" ");
2379
- logger.trace(`${drain_caption("correlated")} ${data}`);
2380
2473
  }
2381
- })
2474
+ }
2475
+ return finalize(lease, handled, at, void 0, payloads[0].options, logger);
2476
+ };
2477
+ }
2478
+ function buildHandleBatch(logger) {
2479
+ return async (lease, payloads, batchHandler) => {
2480
+ const stream = lease.stream;
2481
+ const events = payloads.map((p) => p.event);
2482
+ const options = payloads[0].options;
2483
+ if (lease.retry > 0)
2484
+ logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
2485
+ try {
2486
+ await batchHandler(events, stream);
2487
+ return finalize(
2488
+ lease,
2489
+ events.length,
2490
+ events.at(-1).id,
2491
+ void 0,
2492
+ options,
2493
+ logger
2494
+ );
2495
+ } catch (error) {
2496
+ return finalize(lease, 0, lease.at, error, options, logger);
2497
+ }
2382
2498
  };
2383
2499
  }
2384
2500
 
2501
+ // src/internal/settle.ts
2502
+ var SettleLoop = class {
2503
+ constructor(deps, defaultDebounceMs) {
2504
+ this.deps = deps;
2505
+ this.defaultDebounceMs = defaultDebounceMs;
2506
+ }
2507
+ _timer = void 0;
2508
+ _running = false;
2509
+ /**
2510
+ * Schedule a settle pass. Multiple calls inside the debounce window
2511
+ * coalesce into one cycle. The cycle runs correlate→drain in a loop
2512
+ * until no progress is made (no new subscriptions, no acks, no blocks)
2513
+ * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
2514
+ * via {@link SettleDeps.onSettled}.
2515
+ */
2516
+ schedule(options = {}) {
2517
+ const {
2518
+ debounceMs = this.defaultDebounceMs,
2519
+ correlate: correlateQuery = { after: -1, limit: 100 },
2520
+ maxPasses = Infinity,
2521
+ ...drainOptions
2522
+ } = options;
2523
+ if (this._timer) clearTimeout(this._timer);
2524
+ this._timer = setTimeout(() => {
2525
+ this._timer = void 0;
2526
+ if (this._running) return;
2527
+ this._running = true;
2528
+ (async () => {
2529
+ await this.deps.init();
2530
+ let lastDrain;
2531
+ for (let i = 0; i < maxPasses; i++) {
2532
+ const { subscribed } = await this.deps.correlate({
2533
+ ...correlateQuery,
2534
+ after: this.deps.checkpoint()
2535
+ });
2536
+ lastDrain = await this.deps.drain(drainOptions);
2537
+ const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
2538
+ if (!made_progress) break;
2539
+ }
2540
+ if (lastDrain) this.deps.onSettled(lastDrain);
2541
+ })().catch((err) => this.deps.logger.error(err)).finally(() => {
2542
+ this._running = false;
2543
+ });
2544
+ }, debounceMs);
2545
+ }
2546
+ /** Cancel any pending or active settle cycle. Idempotent. */
2547
+ stop() {
2548
+ if (this._timer) {
2549
+ clearTimeout(this._timer);
2550
+ this._timer = void 0;
2551
+ }
2552
+ }
2553
+ };
2554
+
2385
2555
  // src/act.ts
2386
2556
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2387
2557
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
@@ -2396,11 +2566,26 @@ var Act = class {
2396
2566
  * @param _states Merged map of state name → state definition
2397
2567
  * @param batchHandlers Static-target projection batch handlers (target → handler)
2398
2568
  * @param options Tuning knobs — see {@link ActOptions}
2569
+ * @param lanes Declared drain lanes (ACT-1103). The builder collects
2570
+ * these from `.withLane(...)` calls. Slice 1 records them on the
2571
+ * instance; later slices fan out one `DrainController` per lane.
2399
2572
  */
2400
- constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
2573
+ constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}, lanes = []) {
2401
2574
  this.registry = registry;
2402
2575
  this._states = _states;
2403
2576
  this._batch_handlers = batchHandlers;
2577
+ this._lanes = lanes;
2578
+ if (options.onlyLanes && options.onlyLanes.length > 0) {
2579
+ const declared = /* @__PURE__ */ new Set([
2580
+ "default",
2581
+ ...lanes.map((l) => l.name)
2582
+ ]);
2583
+ const unknown = options.onlyLanes.filter((l) => !declared.has(l));
2584
+ if (unknown.length > 0)
2585
+ throw new Error(
2586
+ `ActOptions.onlyLanes references undeclared lane(s): ${unknown.map((l) => `"${l}"`).join(", ")}`
2587
+ );
2588
+ }
2404
2589
  this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
2405
2590
  this._correlator = options.correlator ?? defaultCorrelator;
2406
2591
  this._es = buildEs(this._logger, this._correlator);
@@ -2413,19 +2598,44 @@ var Act = class {
2413
2598
  boundQueryArray: this._bound_query_array
2414
2599
  });
2415
2600
  this._handle_batch = buildHandleBatch(this._logger);
2416
- const { staticTargets, hasDynamicResolvers, reactiveEvents, eventToState } = classifyRegistry(this.registry, this._states);
2601
+ const {
2602
+ staticTargets,
2603
+ hasDynamicResolvers,
2604
+ reactiveEvents,
2605
+ eventToState,
2606
+ eventToLanes
2607
+ } = classifyRegistry(this.registry, this._states);
2417
2608
  this._reactive_events = reactiveEvents;
2418
2609
  this._event_to_state = eventToState;
2419
- this._drain = new DrainController({
2420
- logger: this._logger,
2421
- ops: this._cd,
2422
- registry: this.registry,
2423
- batchHandlers: this._batch_handlers,
2424
- handle: this._handle,
2425
- handleBatch: this._handle_batch,
2426
- onAcked: (acked) => this.emit("acked", acked),
2427
- onBlocked: (blocked) => this.emit("blocked", blocked)
2428
- });
2610
+ this._event_to_lanes = eventToLanes;
2611
+ const allLanes = ["default", ...lanes.map((l) => l.name)];
2612
+ const onlySet = options.onlyLanes && options.onlyLanes.length > 0 ? new Set(options.onlyLanes) : void 0;
2613
+ const activeLanes = onlySet ? allLanes.filter((n) => onlySet.has(n)) : allLanes;
2614
+ const singleDefaultLane = activeLanes.length === 1 && activeLanes[0] === "default";
2615
+ this._drain_controllers = /* @__PURE__ */ new Map();
2616
+ for (const name of activeLanes) {
2617
+ const cfg = lanes.find((l) => l.name === name);
2618
+ const controller = new DrainController({
2619
+ logger: this._logger,
2620
+ ops: this._cd,
2621
+ registry: this.registry,
2622
+ batchHandlers: this._batch_handlers,
2623
+ handle: this._handle,
2624
+ handleBatch: this._handle_batch,
2625
+ onAcked: (acked) => this.emit("acked", acked),
2626
+ onBlocked: (blocked) => this.emit("blocked", blocked),
2627
+ // Pass lane only when a true per-lane controller is active.
2628
+ // The all-lanes (single default) case keeps lane=undefined so
2629
+ // adapter SQL collapses to the pre-1103 shape.
2630
+ lane: singleDefaultLane ? void 0 : name,
2631
+ defaults: cfg && {
2632
+ streamLimit: cfg.streamLimit,
2633
+ leaseMillis: cfg.leaseMillis
2634
+ }
2635
+ });
2636
+ if (cfg?.cycleMs !== void 0) controller.start(cfg.cycleMs);
2637
+ this._drain_controllers.set(name, controller);
2638
+ }
2429
2639
  this._correlate = new CorrelateCycle(
2430
2640
  this.registry,
2431
2641
  staticTargets,
@@ -2434,7 +2644,7 @@ var Act = class {
2434
2644
  options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
2435
2645
  // Cold start: assume drain is needed (historical events may need processing)
2436
2646
  () => {
2437
- if (this._reactive_events.size > 0) this._drain.arm();
2647
+ if (this._reactive_events.size > 0) this._armAll();
2438
2648
  }
2439
2649
  );
2440
2650
  this._settle = new SettleLoop(
@@ -2454,8 +2664,8 @@ var Act = class {
2454
2664
  _emitter = new import_node_events.default();
2455
2665
  /** Event names with at least one registered reaction (computed at build time) */
2456
2666
  _reactive_events;
2457
- /** Drain pipeline driver: armed flag, concurrency lock, adaptive ratio. */
2458
- _drain;
2667
+ /** One DrainController per active lane, keyed by lane name. */
2668
+ _drain_controllers;
2459
2669
  /** Correlation state machine: lazy init, dynamic-resolver scan, periodic worker. */
2460
2670
  _correlate;
2461
2671
  /** Debounced correlate→drain catch-up loop. */
@@ -2509,6 +2719,14 @@ var Act = class {
2509
2719
  * set when seeding a `restart` snapshot in multi-state apps.
2510
2720
  */
2511
2721
  _event_to_state;
2722
+ /**
2723
+ * Event-name → lane fan-in for selective arming (ACT-1103). Built by
2724
+ * `classifyRegistry` once per build. `"all"` means at least one of
2725
+ * the event's reactions is a dynamic resolver (lane opaque until
2726
+ * runtime); a `Set<string>` lists the static lanes only that event's
2727
+ * reactions target.
2728
+ */
2729
+ _event_to_lanes;
2512
2730
  /** Logger resolved at construction time (after user port configuration) */
2513
2731
  _logger = log();
2514
2732
  /** Wraps a public-method body so internal `store()`/`cache()` resolve to the
@@ -2532,6 +2750,12 @@ var Act = class {
2532
2750
  /** Reaction dispatchers built once and handed to runDrainCycle each cycle. */
2533
2751
  _handle;
2534
2752
  _handle_batch;
2753
+ /** Declared drain lanes (ACT-1103). */
2754
+ _lanes;
2755
+ /** Drain lanes declared via `.withLane(...)`. Implicit default not included. */
2756
+ get lanes() {
2757
+ return this._lanes;
2758
+ }
2535
2759
  /** True after the first `shutdown()` call. Guards idempotency. */
2536
2760
  _shutdown_promise;
2537
2761
  /**
@@ -2550,6 +2774,7 @@ var Act = class {
2550
2774
  this._emitter.removeAllListeners();
2551
2775
  this.stop_correlations();
2552
2776
  this.stop_settling();
2777
+ for (const c of this._drain_controllers.values()) c.stop();
2553
2778
  const disposer = await this._notify_disposer;
2554
2779
  if (disposer) await disposer();
2555
2780
  })();
@@ -2569,13 +2794,10 @@ var Act = class {
2569
2794
  return await s.notify((notification) => {
2570
2795
  try {
2571
2796
  this.emit("notified", notification);
2572
- const hasReactive = notification.events.some(
2573
- (e) => this._reactive_events.has(e.name)
2797
+ const armed = this._armForEventNames(
2798
+ notification.events.map((e) => e.name)
2574
2799
  );
2575
- if (hasReactive) {
2576
- this._drain.arm();
2577
- this._settle.schedule({ debounceMs: 0 });
2578
- }
2800
+ if (armed) this._settle.schedule({ debounceMs: 0 });
2579
2801
  } catch (err) {
2580
2802
  this._logger.error(err, "notified handler threw");
2581
2803
  }
@@ -2676,14 +2898,10 @@ var Act = class {
2676
2898
  reactingTo,
2677
2899
  skipValidation
2678
2900
  );
2679
- if (this._reactive_events.size > 0) {
2680
- for (const snap2 of snapshots) {
2681
- if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
2682
- this._drain.arm();
2683
- break;
2684
- }
2685
- }
2686
- }
2901
+ if (this._reactive_events.size > 0)
2902
+ this._armForEventNames(
2903
+ snapshots.map((s) => s.event.name)
2904
+ );
2687
2905
  this.emit("committed", snapshots);
2688
2906
  return snapshots;
2689
2907
  });
@@ -2830,7 +3048,59 @@ var Act = class {
2830
3048
  * @see {@link start_correlations} for automatic correlation
2831
3049
  */
2832
3050
  async drain(options = {}) {
2833
- return this._scoped(() => this._drain.drain(options));
3051
+ return this._scoped(() => this._drainAll(options));
3052
+ }
3053
+ /** Arm every active lane controller (ACT-1103). */
3054
+ _armAll() {
3055
+ for (const c of this._drain_controllers.values()) c.arm();
3056
+ }
3057
+ /**
3058
+ * Arm only the lane controllers whose reactions match the supplied
3059
+ * event names (ACT-1103 selective arming). Events with any dynamic
3060
+ * resolver fall back to `_armAll()` via the `"all"` sentinel — the
3061
+ * resolver's lane isn't known until correlate runs the function.
3062
+ * Events with no reactions are skipped; `_event_to_lanes` doesn't
3063
+ * carry them. Returns true when any controller was armed (used by
3064
+ * the notify handler to decide whether to schedule a settle).
3065
+ */
3066
+ _armForEventNames(names) {
3067
+ const to_arm = /* @__PURE__ */ new Set();
3068
+ for (const name of names) {
3069
+ const set = this._event_to_lanes.get(name);
3070
+ if (set === void 0) continue;
3071
+ if (set === ALL_LANES) {
3072
+ this._armAll();
3073
+ return true;
3074
+ }
3075
+ for (const lane of set) to_arm.add(lane);
3076
+ }
3077
+ if (to_arm.size === 0) return false;
3078
+ for (const lane of to_arm) this._drain_controllers.get(lane)?.arm();
3079
+ return true;
3080
+ }
3081
+ /** Drain every active lane controller in parallel and aggregate.
3082
+ *
3083
+ * Parallel — not sequential — so a slow lane's in-flight handler does
3084
+ * not block a fast lane's claim/dispatch/ack cycle. Each controller's
3085
+ * `claim()` is independent (filtered by lane); the store's
3086
+ * `SKIP LOCKED` keeps cross-controller races safe. Lifecycle events
3087
+ * (`acked`, `blocked`) may interleave by lane — listeners filter via
3088
+ * `lease.lane`. */
3089
+ async _drainAll(options) {
3090
+ const results = await Promise.all(
3091
+ [...this._drain_controllers.values()].map((c) => c.drain(options))
3092
+ );
3093
+ const fetched = [];
3094
+ const leased = [];
3095
+ const acked = [];
3096
+ const blocked = [];
3097
+ for (const r of results) {
3098
+ fetched.push(...r.fetched);
3099
+ leased.push(...r.leased);
3100
+ acked.push(...r.acked);
3101
+ blocked.push(...r.blocked);
3102
+ }
3103
+ return { fetched, leased, acked, blocked };
2834
3104
  }
2835
3105
  /**
2836
3106
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -3001,7 +3271,7 @@ var Act = class {
3001
3271
  async reset(input) {
3002
3272
  return this._scoped(async () => {
3003
3273
  const count = await store2().reset(input);
3004
- if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
3274
+ if (count > 0 && this._reactive_events.size > 0) this._armAll();
3005
3275
  return count;
3006
3276
  });
3007
3277
  }
@@ -3035,7 +3305,7 @@ var Act = class {
3035
3305
  async unblock(input) {
3036
3306
  return this._scoped(async () => {
3037
3307
  const count = await store2().unblock(input);
3038
- if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
3308
+ if (count > 0 && this._reactive_events.size > 0) this._armAll();
3039
3309
  return count;
3040
3310
  });
3041
3311
  }
@@ -3208,6 +3478,22 @@ function registerBatchHandler(proj, batchHandlers) {
3208
3478
  }
3209
3479
  batchHandlers.set(proj.target, proj.batchHandler);
3210
3480
  }
3481
+ function validateLaneReferences(registry, lanes) {
3482
+ const declared = /* @__PURE__ */ new Set([DEFAULT_LANE, ...lanes.map((l) => l.name)]);
3483
+ for (const [eventName, def] of Object.entries(registry.events)) {
3484
+ const entry = def;
3485
+ for (const [handlerName, reaction] of entry.reactions) {
3486
+ const resolver = reaction.resolver;
3487
+ if (typeof resolver === "function") continue;
3488
+ const lane = resolver.lane;
3489
+ if (lane && !declared.has(lane)) {
3490
+ throw new Error(
3491
+ `Reaction "${handlerName}" on "${eventName}" targets undeclared lane "${lane}". Declared lanes: ${[...declared].map((l) => `"${l}"`).join(", ")}. Add \`.withLane({ name: "${lane}", ... })\` to act() or correct the .to() declaration.`
3492
+ );
3493
+ }
3494
+ }
3495
+ }
3496
+ }
3211
3497
  function act() {
3212
3498
  const states = /* @__PURE__ */ new Map();
3213
3499
  const registry = {
@@ -3216,6 +3502,7 @@ function act() {
3216
3502
  };
3217
3503
  const pendingProjections = [];
3218
3504
  const batchHandlers = /* @__PURE__ */ new Map();
3505
+ const lanes = [];
3219
3506
  let _built = false;
3220
3507
  const finalizeDeprecations = () => {
3221
3508
  const deprecationSummary = [];
@@ -3262,6 +3549,18 @@ function act() {
3262
3549
  }
3263
3550
  mergeEventRegister(registry.events, input.events);
3264
3551
  pendingProjections.push(...input.projections);
3552
+ for (const sliceLane of input.lanes) {
3553
+ const existing = lanes.find((l) => l.name === sliceLane.name);
3554
+ if (!existing) {
3555
+ lanes.push(sliceLane);
3556
+ continue;
3557
+ }
3558
+ if (existing.leaseMillis !== sliceLane.leaseMillis || existing.streamLimit !== sliceLane.streamLimit || existing.cycleMs !== sliceLane.cycleMs) {
3559
+ throw new Error(
3560
+ `Lane "${sliceLane.name}" was already declared with a different config`
3561
+ );
3562
+ }
3563
+ }
3265
3564
  return builder;
3266
3565
  },
3267
3566
  withProjection: (proj) => {
@@ -3270,6 +3569,14 @@ function act() {
3270
3569
  return builder;
3271
3570
  },
3272
3571
  withActor: () => builder,
3572
+ withLane: (config2) => {
3573
+ if (config2.name === DEFAULT_LANE)
3574
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
3575
+ if (lanes.some((l) => l.name === config2.name))
3576
+ throw new Error(`Lane "${config2.name}" was already declared`);
3577
+ lanes.push(config2);
3578
+ return builder;
3579
+ },
3273
3580
  on: (event) => ({
3274
3581
  do: (handler, options) => {
3275
3582
  const reaction = {
@@ -3301,13 +3608,15 @@ function act() {
3301
3608
  registerBatchHandler(proj, batchHandlers);
3302
3609
  }
3303
3610
  finalizeDeprecations();
3611
+ validateLaneReferences(registry, lanes);
3304
3612
  _built = true;
3305
3613
  }
3306
3614
  return new Act(
3307
3615
  registry,
3308
3616
  states,
3309
3617
  batchHandlers,
3310
- options
3618
+ options,
3619
+ lanes
3311
3620
  );
3312
3621
  },
3313
3622
  events: registry.events
@@ -3388,6 +3697,7 @@ function slice() {
3388
3697
  const actions = {};
3389
3698
  const events = {};
3390
3699
  const projections = [];
3700
+ const lanes = [];
3391
3701
  const builder = {
3392
3702
  withState: (state2) => {
3393
3703
  registerState(state2, states, actions, events);
@@ -3397,6 +3707,14 @@ function slice() {
3397
3707
  projections.push(proj);
3398
3708
  return builder;
3399
3709
  },
3710
+ withLane: (config2) => {
3711
+ if (config2.name === DEFAULT_LANE)
3712
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
3713
+ if (lanes.some((l) => l.name === config2.name))
3714
+ throw new Error(`Lane "${config2.name}" was already declared`);
3715
+ lanes.push(config2);
3716
+ return builder;
3717
+ },
3400
3718
  on: (event) => ({
3401
3719
  do: (handler, options) => {
3402
3720
  const reaction = {
@@ -3425,7 +3743,8 @@ function slice() {
3425
3743
  _tag: "Slice",
3426
3744
  states,
3427
3745
  events,
3428
- projections
3746
+ projections,
3747
+ lanes
3429
3748
  }),
3430
3749
  events
3431
3750
  };
@@ -3519,6 +3838,7 @@ function action_builder(state2) {
3519
3838
  CommittedMetaSchema,
3520
3839
  ConcurrencyError,
3521
3840
  ConsoleLogger,
3841
+ DEFAULT_LANE,
3522
3842
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
3523
3843
  DEFAULT_SETTLE_DEBOUNCE_MS,
3524
3844
  Environments,