@rotorsoft/act 0.44.0 → 0.46.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 (54) hide show
  1. package/README.md +87 -379
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/act.d.ts +93 -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 +4 -1
  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/audit.d.ts +95 -0
  13. package/dist/@types/internal/audit.d.ts.map +1 -0
  14. package/dist/@types/internal/build-classify.d.ts +20 -0
  15. package/dist/@types/internal/build-classify.d.ts.map +1 -1
  16. package/dist/@types/internal/correlate-cycle.d.ts +1 -0
  17. package/dist/@types/internal/correlate-cycle.d.ts.map +1 -1
  18. package/dist/@types/internal/drain-cycle.d.ts +43 -3
  19. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  20. package/dist/@types/internal/drain.d.ts +3 -1
  21. package/dist/@types/internal/drain.d.ts.map +1 -1
  22. package/dist/@types/internal/index.d.ts +4 -2
  23. package/dist/@types/internal/index.d.ts.map +1 -1
  24. package/dist/@types/internal/reactions.d.ts.map +1 -1
  25. package/dist/@types/internal/tracing.d.ts +51 -0
  26. package/dist/@types/internal/tracing.d.ts.map +1 -1
  27. package/dist/@types/ports.d.ts +10 -0
  28. package/dist/@types/ports.d.ts.map +1 -1
  29. package/dist/@types/test/sandbox.d.ts +1 -1
  30. package/dist/@types/test/sandbox.d.ts.map +1 -1
  31. package/dist/@types/types/audit.d.ts +126 -0
  32. package/dist/@types/types/audit.d.ts.map +1 -0
  33. package/dist/@types/types/index.d.ts +1 -0
  34. package/dist/@types/types/index.d.ts.map +1 -1
  35. package/dist/@types/types/ports.d.ts +9 -2
  36. package/dist/@types/types/ports.d.ts.map +1 -1
  37. package/dist/@types/types/reaction.d.ts +20 -2
  38. package/dist/@types/types/reaction.d.ts.map +1 -1
  39. package/dist/{chunk-VMX7RPTC.js → chunk-TZWDSNSN.js} +1 -1
  40. package/dist/{chunk-VMX7RPTC.js.map → chunk-TZWDSNSN.js.map} +1 -1
  41. package/dist/{chunk-LKRNWD7C.js → chunk-VC6MSVC3.js} +47 -12
  42. package/dist/chunk-VC6MSVC3.js.map +1 -0
  43. package/dist/index.cjs +1584 -886
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.js +1538 -874
  46. package/dist/index.js.map +1 -1
  47. package/dist/test/index.cjs +52 -18
  48. package/dist/test/index.cjs.map +1 -1
  49. package/dist/test/index.js +11 -11
  50. package/dist/test/index.js.map +1 -1
  51. package/dist/types/index.cjs.map +1 -1
  52. package/dist/types/index.js +1 -1
  53. package/package.json +2 -2
  54. package/dist/chunk-LKRNWD7C.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,7 +1028,8 @@ 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;
@@ -1178,6 +1212,7 @@ function dispose(disposer) {
1178
1212
  }
1179
1213
  var SNAP_EVENT = "__snapshot__";
1180
1214
  var TOMBSTONE_EVENT = "__tombstone__";
1215
+ var DEFAULT_LANE = "default";
1181
1216
 
1182
1217
  // src/signals.ts
1183
1218
  process.once("SIGINT", async (arg) => {
@@ -1200,24 +1235,459 @@ process.once("unhandledRejection", async (arg) => {
1200
1235
  // src/act.ts
1201
1236
  var import_node_events = __toESM(require("events"), 1);
1202
1237
 
1238
+ // src/internal/event-versions.ts
1239
+ var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
1240
+ function parse(name) {
1241
+ const m = name.match(VERSION_SUFFIX);
1242
+ if (m) {
1243
+ const v = Number.parseInt(m[2], 10);
1244
+ if (v >= 2) return { base: m[1], version: v };
1245
+ }
1246
+ return { base: name, version: 1 };
1247
+ }
1248
+ function deprecatedEventNames(names) {
1249
+ const groups = /* @__PURE__ */ new Map();
1250
+ for (const name of names) {
1251
+ const { base, version } = parse(name);
1252
+ const list = groups.get(base);
1253
+ if (list) list.push({ version, name });
1254
+ else groups.set(base, [{ version, name }]);
1255
+ }
1256
+ const deprecated = /* @__PURE__ */ new Set();
1257
+ for (const list of groups.values()) {
1258
+ if (list.length < 2) continue;
1259
+ list.sort((a, b) => b.version - a.version);
1260
+ for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
1261
+ }
1262
+ return deprecated;
1263
+ }
1264
+ function currentVersionOf(deprecatedName, allNames) {
1265
+ const target = parse(deprecatedName);
1266
+ let highest;
1267
+ for (const name of allNames) {
1268
+ const { base, version } = parse(name);
1269
+ if (base !== target.base) continue;
1270
+ if (!highest || version > highest.version) highest = { version, name };
1271
+ }
1272
+ return highest && highest.version > target.version ? highest.name : void 0;
1273
+ }
1274
+
1275
+ // src/internal/audit.ts
1276
+ var DEFAULTS = {
1277
+ idle_days: 90,
1278
+ restart_min: 1e4,
1279
+ stuck_minutes: 30,
1280
+ deprecated_min: 0.1,
1281
+ drift_min: 500,
1282
+ near_block: 3
1283
+ };
1284
+ var ALL_CATEGORIES = [
1285
+ "schema",
1286
+ "close-candidate",
1287
+ "restart-candidate",
1288
+ "deprecated-load",
1289
+ "reaction-health",
1290
+ "snapshot-drift",
1291
+ "routing-health",
1292
+ "correlation-gaps",
1293
+ "clock-anomalies"
1294
+ ];
1295
+ async function* audit(deps, categories, options = {}) {
1296
+ const requested = new Set(categories ?? [...ALL_CATEGORIES]);
1297
+ const orderedCategories = ALL_CATEGORIES.filter((c) => requested.has(c));
1298
+ const passes = orderedCategories.map(
1299
+ (c) => PASS_FACTORIES[c](deps, options)
1300
+ );
1301
+ const needStats = passes.some((p) => p.onStat !== void 0);
1302
+ const needStreams = passes.some((p) => p.onStream !== void 0);
1303
+ const needEvents = passes.some((p) => p.onEvent !== void 0);
1304
+ if (needStats) {
1305
+ const stats = await deps.store().query_stats({}, { count: true, names: true });
1306
+ for (const [stream, s] of stats) {
1307
+ for (const p of passes) p.onStat?.(stream, s);
1308
+ }
1309
+ }
1310
+ if (needStreams) {
1311
+ await deps.store().query_streams((pos) => {
1312
+ for (const p of passes) p.onStream?.(pos);
1313
+ });
1314
+ }
1315
+ if (needEvents) {
1316
+ await deps.store().query((event) => {
1317
+ for (const p of passes) p.onEvent?.(event);
1318
+ }, options.query);
1319
+ }
1320
+ for (const p of passes) await p.finalize?.(deps);
1321
+ for (const p of passes) {
1322
+ for (const f of p.drain()) yield f;
1323
+ }
1324
+ }
1325
+ var makeSchemaPass = (deps) => {
1326
+ const findings = [];
1327
+ return {
1328
+ category: "schema",
1329
+ onEvent(event) {
1330
+ const name = String(event.name);
1331
+ const state2 = deps.event_to_state.get(name);
1332
+ if (!state2) {
1333
+ if (name.startsWith("__")) return;
1334
+ findings.push({
1335
+ category: "schema",
1336
+ stream: event.stream,
1337
+ event_id: event.id,
1338
+ name,
1339
+ reason: "unknown_event_name"
1340
+ });
1341
+ return;
1342
+ }
1343
+ const schema = state2.events[name];
1344
+ const parsed = schema.safeParse(event.data);
1345
+ if (!parsed.success) {
1346
+ findings.push({
1347
+ category: "schema",
1348
+ stream: event.stream,
1349
+ event_id: event.id,
1350
+ name,
1351
+ reason: "schema_validation_failed",
1352
+ zod_error: parsed.error
1353
+ });
1354
+ }
1355
+ },
1356
+ drain: () => findings
1357
+ };
1358
+ };
1359
+ var makeDeprecatedLoadPass = (deps, options) => {
1360
+ const share_min = options.thresholds?.deprecated_min ?? DEFAULTS.deprecated_min;
1361
+ const totals = /* @__PURE__ */ new Map();
1362
+ const perStream = /* @__PURE__ */ new Map();
1363
+ return {
1364
+ category: "deprecated-load",
1365
+ onStat(stream, { names }) {
1366
+ for (const [name, count] of Object.entries(names)) {
1367
+ totals.set(name, (totals.get(name) ?? 0) + count);
1368
+ let m = perStream.get(name);
1369
+ if (!m) {
1370
+ m = /* @__PURE__ */ new Map();
1371
+ perStream.set(name, m);
1372
+ }
1373
+ m.set(stream, count);
1374
+ }
1375
+ },
1376
+ drain() {
1377
+ const findings = [];
1378
+ const grand = [...totals.values()].reduce((s, n) => s + n, 0);
1379
+ if (grand === 0) return findings;
1380
+ const deprecated = deprecatedEventNames(deps.known_events);
1381
+ const sorted = [...deprecated].map((name) => ({ name, count: totals.get(name) ?? 0 })).sort((a, b) => b.count - a.count);
1382
+ for (const { name, count } of sorted) {
1383
+ if (count === 0) continue;
1384
+ if (count / grand < share_min) continue;
1385
+ const currentVersion = currentVersionOf(name, deps.known_events);
1386
+ const topStreams = [...perStream.get(name).entries()].map(([stream, c]) => ({ stream, count: c })).sort((a, b) => b.count - a.count).slice(0, 10);
1387
+ findings.push({
1388
+ category: "deprecated-load",
1389
+ name,
1390
+ current_version: currentVersion,
1391
+ total: count,
1392
+ top_streams: topStreams
1393
+ });
1394
+ }
1395
+ return findings;
1396
+ }
1397
+ };
1398
+ };
1399
+ var makeCloseCandidatePass = (deps, options) => {
1400
+ const idle_days = options.thresholds?.idle_days ?? DEFAULTS.idle_days;
1401
+ const terminal_events = new Set(options.thresholds?.terminal_events ?? []);
1402
+ const idle_cutoff = Date.now() - idle_days * 24 * 60 * 60 * 1e3;
1403
+ const findings = [];
1404
+ return {
1405
+ category: "close-candidate",
1406
+ onStat(stream, { head }) {
1407
+ const head_name = String(head.name);
1408
+ if (head_name.startsWith("__")) return;
1409
+ const head_time = head.created.getTime();
1410
+ const is_idle = head_time < idle_cutoff;
1411
+ const is_terminal = terminal_events.has(head_name);
1412
+ if (!is_idle && !is_terminal) return;
1413
+ findings.push({
1414
+ category: "close-candidate",
1415
+ stream,
1416
+ last_event_at: head.created.toISOString(),
1417
+ reason: is_terminal ? "terminal" : "idle",
1418
+ idle_days: is_idle ? Math.floor((Date.now() - head_time) / (24 * 60 * 60 * 1e3)) : void 0,
1419
+ restart_supported: restartIsSupported(deps, head_name)
1420
+ });
1421
+ },
1422
+ drain: () => findings
1423
+ };
1424
+ };
1425
+ var makeRestartCandidatePass = (deps, options) => {
1426
+ const threshold = options.thresholds?.restart_min ?? DEFAULTS.restart_min;
1427
+ const findings = [];
1428
+ return {
1429
+ category: "restart-candidate",
1430
+ onStat(stream, { head, count, names }) {
1431
+ if (count < threshold) return;
1432
+ const head_name = String(head.name);
1433
+ if (head_name.startsWith("__")) return;
1434
+ if (!restartIsSupported(deps, head_name)) return;
1435
+ findings.push({
1436
+ category: "restart-candidate",
1437
+ stream,
1438
+ count,
1439
+ // names map is sparse — `__snapshot__` key absent when the
1440
+ // stream has never been snapshotted (a common case for the
1441
+ // restart-candidate signal).
1442
+ snaps: names["__snapshot__"] ?? 0
1443
+ });
1444
+ },
1445
+ drain: () => findings
1446
+ };
1447
+ };
1448
+ var makeReactionHealthPass = (_deps, options) => {
1449
+ const near_block = options.thresholds?.near_block ?? DEFAULTS.near_block;
1450
+ const stuck_minutes = options.thresholds?.stuck_minutes ?? DEFAULTS.stuck_minutes;
1451
+ const stuck_cutoff = Date.now() - stuck_minutes * 60 * 1e3;
1452
+ const findings = [];
1453
+ return {
1454
+ category: "reaction-health",
1455
+ onStream(p) {
1456
+ if (p.blocked) {
1457
+ findings.push({
1458
+ category: "reaction-health",
1459
+ stream: p.stream,
1460
+ status: "blocked",
1461
+ retry: p.retry,
1462
+ reason: p.error || "blocked without recorded error"
1463
+ });
1464
+ return;
1465
+ }
1466
+ if (p.retry >= near_block) {
1467
+ findings.push({
1468
+ category: "reaction-health",
1469
+ stream: p.stream,
1470
+ status: "near-block",
1471
+ retry: p.retry,
1472
+ reason: `retry ${p.retry} \u2265 near-block threshold ${near_block}`
1473
+ });
1474
+ return;
1475
+ }
1476
+ if (p.leased_by && p.leased_until && p.leased_until.getTime() < stuck_cutoff) {
1477
+ const minutes = Math.floor(
1478
+ (Date.now() - p.leased_until.getTime()) / (60 * 1e3)
1479
+ );
1480
+ findings.push({
1481
+ category: "reaction-health",
1482
+ stream: p.stream,
1483
+ status: "stuck-backoff",
1484
+ retry: p.retry,
1485
+ reason: `lease expired ${minutes}m ago without release`
1486
+ });
1487
+ }
1488
+ },
1489
+ drain: () => findings
1490
+ };
1491
+ };
1492
+ var makeSnapshotDriftPass = (deps, options) => {
1493
+ const drift_min = options.thresholds?.drift_min ?? DEFAULTS.drift_min;
1494
+ const candidates = [];
1495
+ const findings = [];
1496
+ return {
1497
+ category: "snapshot-drift",
1498
+ onStat(stream, { head, count, names }) {
1499
+ if (!restartIsSupported(deps, String(head.name))) return;
1500
+ if (count < drift_min) return;
1501
+ candidates.push({
1502
+ stream,
1503
+ total: count,
1504
+ snaps: names["__snapshot__"] ?? 0
1505
+ });
1506
+ },
1507
+ async finalize(deps2) {
1508
+ for (const { stream, total, snaps } of candidates) {
1509
+ let events_since_snap = total;
1510
+ let snap_at;
1511
+ if (snaps > 0) {
1512
+ const collected = [];
1513
+ await deps2.store().query(
1514
+ (e) => {
1515
+ collected.push({ id: e.id });
1516
+ },
1517
+ {
1518
+ stream,
1519
+ stream_exact: true,
1520
+ names: ["__snapshot__"],
1521
+ backward: true,
1522
+ limit: 1,
1523
+ with_snaps: true
1524
+ }
1525
+ );
1526
+ snap_at = collected[0].id;
1527
+ let after = 0;
1528
+ await deps2.store().query(
1529
+ () => {
1530
+ after++;
1531
+ },
1532
+ { stream, stream_exact: true, after: snap_at }
1533
+ );
1534
+ events_since_snap = after;
1535
+ }
1536
+ if (events_since_snap < drift_min) continue;
1537
+ findings.push({
1538
+ category: "snapshot-drift",
1539
+ stream,
1540
+ events_since_snap,
1541
+ snap_at
1542
+ });
1543
+ }
1544
+ },
1545
+ drain: () => findings
1546
+ };
1547
+ };
1548
+ var makeRoutingHealthPass = (deps) => {
1549
+ const findings = [];
1550
+ const seenEventNames = /* @__PURE__ */ new Set();
1551
+ return {
1552
+ category: "routing-health",
1553
+ onStream(p) {
1554
+ if (!p.lane) return;
1555
+ if (deps.declared_lanes.has(p.lane)) return;
1556
+ findings.push({
1557
+ category: "routing-health",
1558
+ stream: p.stream,
1559
+ reason: "unknown-lane",
1560
+ lane: p.lane
1561
+ });
1562
+ },
1563
+ onStat(_stream, { names }) {
1564
+ for (const name of Object.keys(names)) {
1565
+ seenEventNames.add(name);
1566
+ }
1567
+ },
1568
+ finalize() {
1569
+ for (const name of seenEventNames) {
1570
+ if (name.startsWith("__")) continue;
1571
+ if (deps.routed_events.has(name)) continue;
1572
+ findings.push({
1573
+ category: "routing-health",
1574
+ stream: "*",
1575
+ reason: "unrouted"
1576
+ });
1577
+ }
1578
+ return Promise.resolve();
1579
+ },
1580
+ drain: () => findings
1581
+ };
1582
+ };
1583
+ var makeCorrelationGapsPass = () => {
1584
+ const seenIds = /* @__PURE__ */ new Set();
1585
+ const checks = [];
1586
+ return {
1587
+ category: "correlation-gaps",
1588
+ onEvent(e) {
1589
+ seenIds.add(e.id);
1590
+ const causation = e.meta?.causation;
1591
+ const parentId = causation?.event?.id;
1592
+ if (parentId !== void 0) {
1593
+ checks.push({ stream: e.stream, id: e.id, parentId });
1594
+ }
1595
+ },
1596
+ drain() {
1597
+ const findings = [];
1598
+ for (const { stream, id, parentId } of checks) {
1599
+ if (!seenIds.has(parentId)) {
1600
+ findings.push({
1601
+ category: "correlation-gaps",
1602
+ stream,
1603
+ event_id: id,
1604
+ reason: "orphan-parent"
1605
+ });
1606
+ }
1607
+ }
1608
+ return findings;
1609
+ }
1610
+ };
1611
+ };
1612
+ var makeClockAnomaliesPass = () => {
1613
+ const findings = [];
1614
+ const lastPerStream = /* @__PURE__ */ new Map();
1615
+ return {
1616
+ category: "clock-anomalies",
1617
+ onEvent(e) {
1618
+ const created = e.created.getTime();
1619
+ if (created > Date.now()) {
1620
+ findings.push({
1621
+ category: "clock-anomalies",
1622
+ stream: e.stream,
1623
+ event_id: e.id,
1624
+ reason: "future-created"
1625
+ });
1626
+ }
1627
+ const prev = lastPerStream.get(e.stream);
1628
+ if (prev !== void 0 && created < prev) {
1629
+ findings.push({
1630
+ category: "clock-anomalies",
1631
+ stream: e.stream,
1632
+ event_id: e.id,
1633
+ reason: "out-of-order"
1634
+ });
1635
+ }
1636
+ lastPerStream.set(e.stream, created);
1637
+ },
1638
+ drain: () => findings
1639
+ };
1640
+ };
1641
+ function restartIsSupported(deps, headEventName) {
1642
+ const state2 = deps.event_to_state.get(headEventName);
1643
+ return state2?.snap !== void 0;
1644
+ }
1645
+ var PASS_FACTORIES = {
1646
+ schema: makeSchemaPass,
1647
+ "deprecated-load": makeDeprecatedLoadPass,
1648
+ "close-candidate": makeCloseCandidatePass,
1649
+ "restart-candidate": makeRestartCandidatePass,
1650
+ "reaction-health": makeReactionHealthPass,
1651
+ "snapshot-drift": makeSnapshotDriftPass,
1652
+ "routing-health": makeRoutingHealthPass,
1653
+ "correlation-gaps": makeCorrelationGapsPass,
1654
+ "clock-anomalies": makeClockAnomaliesPass
1655
+ };
1656
+
1203
1657
  // src/internal/build-classify.ts
1658
+ var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
1204
1659
  function classifyRegistry(registry, states) {
1205
1660
  const statics = /* @__PURE__ */ new Map();
1206
1661
  const reactiveEvents = /* @__PURE__ */ new Set();
1662
+ const eventToLanes = /* @__PURE__ */ new Map();
1207
1663
  let hasDynamicResolvers = false;
1208
1664
  for (const [name, register] of Object.entries(registry.events)) {
1209
1665
  if (register.reactions.size > 0) reactiveEvents.add(name);
1210
1666
  for (const reaction of register.reactions.values()) {
1211
1667
  if (typeof reaction.resolver === "function") {
1212
1668
  hasDynamicResolvers = true;
1669
+ eventToLanes.set(name, ALL_LANES);
1213
1670
  } else {
1214
- const { target, source, priority = 0 } = reaction.resolver;
1671
+ const { target, source, priority = 0, lane } = reaction.resolver;
1672
+ const lane_name = lane ?? "default";
1673
+ const existing_lanes = eventToLanes.get(name);
1674
+ if (existing_lanes !== ALL_LANES) {
1675
+ const set = existing_lanes ?? /* @__PURE__ */ new Set();
1676
+ set.add(lane_name);
1677
+ eventToLanes.set(name, set);
1678
+ }
1215
1679
  const key = `${target}|${source ?? ""}`;
1216
1680
  const existing = statics.get(key);
1217
1681
  if (!existing) {
1218
- statics.set(key, { stream: target, source, priority });
1219
- } else if (priority > existing.priority) {
1220
- statics.set(key, { ...existing, priority });
1682
+ statics.set(key, { stream: target, source, priority, lane });
1683
+ } else {
1684
+ if ((existing.lane ?? void 0) !== (lane ?? void 0))
1685
+ throw new Error(
1686
+ `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
1687
+ );
1688
+ if (priority > existing.priority) {
1689
+ statics.set(key, { ...existing, priority });
1690
+ }
1221
1691
  }
1222
1692
  }
1223
1693
  }
@@ -1232,7 +1702,8 @@ function classifyRegistry(registry, states) {
1232
1702
  staticTargets: [...statics.values()],
1233
1703
  hasDynamicResolvers,
1234
1704
  reactiveEvents,
1235
- eventToState
1705
+ eventToState,
1706
+ eventToLanes
1236
1707
  };
1237
1708
  }
1238
1709
 
@@ -1443,6 +1914,7 @@ var CorrelateCycle = class {
1443
1914
  const entry = correlated.get(resolved.target) || {
1444
1915
  source: resolved.source,
1445
1916
  priority: incomingPriority,
1917
+ lane: resolved.lane,
1446
1918
  payloads: []
1447
1919
  };
1448
1920
  if (incomingPriority > entry.priority)
@@ -1461,10 +1933,11 @@ var CorrelateCycle = class {
1461
1933
  );
1462
1934
  if (correlated.size) {
1463
1935
  const streams = [...correlated.entries()].map(
1464
- ([stream, { source, priority }]) => ({
1936
+ ([stream, { source, priority, lane }]) => ({
1465
1937
  stream,
1466
1938
  source,
1467
- priority
1939
+ priority,
1940
+ lane
1468
1941
  })
1469
1942
  );
1470
1943
  const { subscribed } = await this.cd.subscribe(streams);
@@ -1549,904 +2022,918 @@ function computeLagLeadRatio(handled, lagging, leading) {
1549
2022
  return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1550
2023
  }
1551
2024
 
1552
- // src/internal/drain-cycle.ts
1553
- async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred) {
1554
- const leased = await ops.claim(lagging, leading, (0, import_node_crypto2.randomUUID)(), leaseMillis);
1555
- if (!leased.length) return void 0;
1556
- const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
1557
- if (!active.length) {
1558
- return {
1559
- leased,
1560
- fetched: [],
1561
- handled: [],
1562
- acked: [],
1563
- blocked: []
1564
- };
1565
- }
1566
- const fetched = await ops.fetch(active, eventLimit);
1567
- const fetchMap = /* @__PURE__ */ new Map();
1568
- const fetch_window_at = fetched.reduce(
1569
- (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
1570
- 0
1571
- );
1572
- for (const f of fetched) {
1573
- const { stream, events } = f;
1574
- const payloads = events.flatMap((event) => {
1575
- const register = registry.events[event.name];
1576
- if (!register) return [];
1577
- return [...register.reactions.values()].filter((reaction) => {
1578
- const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1579
- return resolved && resolved.target === stream;
1580
- }).map((reaction) => ({ ...reaction, event }));
1581
- });
1582
- fetchMap.set(stream, { fetch: f, payloads });
1583
- }
1584
- const handled = await Promise.all(
1585
- active.map((lease) => {
1586
- const entry = fetchMap.get(lease.stream);
1587
- const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
1588
- const { payloads } = entry;
1589
- const batchHandler = batchHandlers.get(lease.stream);
1590
- if (batchHandler && payloads.length > 0) {
1591
- return handleBatch({ ...lease, at }, payloads, batchHandler);
1592
- }
1593
- return handle({ ...lease, at }, payloads);
2025
+ // src/internal/drain.ts
2026
+ var claim = (lagging, leading, by, millis, lane) => store2().claim(lagging, leading, by, millis, lane);
2027
+ async function fetch(leased, eventLimit) {
2028
+ return Promise.all(
2029
+ leased.map(async ({ stream, source, at, lagging }) => {
2030
+ const events = [];
2031
+ await store2().query((e) => events.push(e), {
2032
+ stream: source,
2033
+ after: at,
2034
+ limit: eventLimit
2035
+ });
2036
+ return { stream, source, at, lagging, events };
1594
2037
  })
1595
2038
  );
1596
- const acked = await ops.ack(
1597
- handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
1598
- );
1599
- const blocked = await ops.block(
1600
- handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1601
- );
1602
- return { leased, fetched, handled, acked, blocked };
1603
2039
  }
1604
- var EMPTY_DRAIN = {
1605
- fetched: [],
1606
- leased: [],
1607
- acked: [],
1608
- blocked: []
1609
- };
1610
- var DrainController = class {
1611
- constructor(deps) {
1612
- this.deps = deps;
1613
- }
1614
- _armed = false;
1615
- _locked = false;
1616
- _ratio = 0.5;
1617
- /**
1618
- * Per-stream backoff: `stream nextAttemptAt` (ms since epoch). Set by
1619
- * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
1620
- * ack or terminal block. Lives in process memory — per-worker pacing
1621
- * by design (see {@link BackoffOptions} for the multi-worker trade-off).
1622
- */
1623
- _backoff = /* @__PURE__ */ new Map();
1624
- /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
1625
- _backoffTimer;
1626
- /**
1627
- * Signal that a commit (or reset / cold-start) may have produced work.
1628
- * Subsequent `drain()` calls will run the pipeline; once the pipeline
1629
- * settles to no-progress, the controller disarms itself.
1630
- */
1631
- arm() {
1632
- this._armed = true;
2040
+ var ack = (leases) => store2().ack(leases);
2041
+ var block = (leases) => store2().block(leases);
2042
+ var subscribe = (streams) => store2().subscribe(streams);
2043
+
2044
+ // src/internal/event-sourcing.ts
2045
+ var import_act_patch = require("@rotorsoft/act-patch");
2046
+ async function snap(snapshot) {
2047
+ try {
2048
+ const { id, stream, name, meta, version } = snapshot.event;
2049
+ await store2().commit(
2050
+ stream,
2051
+ [{ name: SNAP_EVENT, data: snapshot.state }],
2052
+ {
2053
+ correlation: meta.correlation,
2054
+ causation: { event: { id, name, stream } }
2055
+ },
2056
+ version
2057
+ // IMPORTANT! - state events are committed right after the snapshot event
2058
+ );
2059
+ } catch (error) {
2060
+ log().error(error);
1633
2061
  }
1634
- /** Read-only flag — true while a commit / reset is unprocessed. */
1635
- get armed() {
1636
- return this._armed;
1637
- }
1638
- /** Returns true when `stream` is currently within a backoff window. */
1639
- isDeferred = (stream) => {
1640
- const next = this._backoff.get(stream);
1641
- return next !== void 0 && next > Date.now();
1642
- };
1643
- /**
1644
- * Schedule the next drain re-arm at the earliest pending backoff
1645
- * expiry. Called only when the backoff map is non-empty (caller guard).
1646
- * Idempotent — collapses many simultaneously deferred streams into a
1647
- * single timer.
1648
- */
1649
- scheduleBackoffWake() {
1650
- if (this._backoffTimer) clearTimeout(this._backoffTimer);
1651
- let earliest = Number.POSITIVE_INFINITY;
1652
- for (const t of this._backoff.values()) if (t < earliest) earliest = t;
1653
- const delay = Math.max(0, earliest - Date.now());
1654
- this._backoffTimer = setTimeout(() => {
1655
- this._backoffTimer = void 0;
1656
- const now = Date.now();
1657
- for (const [stream, at] of this._backoff) {
1658
- if (at <= now) this._backoff.delete(stream);
1659
- }
1660
- this._armed = true;
1661
- }, delay);
1662
- this._backoffTimer.unref();
1663
- }
1664
- /** Run one drain pass. Short-circuits when not armed or already running. */
1665
- async drain({
1666
- streamLimit = 10,
1667
- eventLimit = 10,
1668
- leaseMillis = 1e4
1669
- } = {}) {
1670
- if (!this._armed) return EMPTY_DRAIN;
1671
- if (this._locked) return EMPTY_DRAIN;
1672
- try {
1673
- this._locked = true;
1674
- const lagging = Math.ceil(streamLimit * this._ratio);
1675
- const leading = streamLimit - lagging;
1676
- const cycle = await runDrainCycle(
1677
- this.deps.ops,
1678
- this.deps.registry,
1679
- this.deps.batchHandlers,
1680
- this.deps.handle,
1681
- this.deps.handleBatch,
1682
- lagging,
1683
- leading,
1684
- eventLimit,
1685
- leaseMillis,
1686
- this._backoff.size > 0 ? this.isDeferred : void 0
1687
- );
1688
- if (!cycle) {
1689
- this._armed = false;
1690
- return EMPTY_DRAIN;
1691
- }
1692
- const { leased, fetched, handled, acked, blocked } = cycle;
1693
- this._ratio = computeLagLeadRatio(handled, lagging, leading);
1694
- for (const lease of acked) this._backoff.delete(lease.stream);
1695
- for (const lease of blocked) this._backoff.delete(lease.stream);
1696
- for (const h of handled) {
1697
- if (h.nextAttemptAt !== void 0 && !h.block) {
1698
- this._backoff.set(h.lease.stream, h.nextAttemptAt);
1699
- }
1700
- }
1701
- if (this._backoff.size > 0) this.scheduleBackoffWake();
1702
- if (acked.length) this.deps.onAcked(acked);
1703
- if (blocked.length) this.deps.onBlocked(blocked);
1704
- const hasErrors = handled.some(({ error }) => error);
1705
- if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
1706
- return { fetched, leased, acked, blocked };
1707
- } catch (error) {
1708
- this.deps.logger.error(error);
1709
- return EMPTY_DRAIN;
1710
- } finally {
1711
- this._locked = false;
1712
- }
1713
- }
1714
- };
1715
-
1716
- // src/internal/event-versions.ts
1717
- var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
1718
- function parse(name) {
1719
- const m = name.match(VERSION_SUFFIX);
1720
- if (m) {
1721
- const v = Number.parseInt(m[2], 10);
1722
- if (v >= 2) return { base: m[1], version: v };
1723
- }
1724
- return { base: name, version: 1 };
1725
- }
1726
- function deprecatedEventNames(names) {
1727
- const groups = /* @__PURE__ */ new Map();
1728
- for (const name of names) {
1729
- const { base, version } = parse(name);
1730
- const list = groups.get(base);
1731
- if (list) list.push({ version, name });
1732
- else groups.set(base, [{ version, name }]);
1733
- }
1734
- const deprecated = /* @__PURE__ */ new Set();
1735
- for (const list of groups.values()) {
1736
- if (list.length < 2) continue;
1737
- list.sort((a, b) => b.version - a.version);
1738
- for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
1739
- }
1740
- return deprecated;
1741
- }
1742
- function currentVersionOf(deprecatedName, allNames) {
1743
- const target = parse(deprecatedName);
1744
- let highest;
1745
- for (const name of allNames) {
1746
- const { base, version } = parse(name);
1747
- if (base !== target.base) continue;
1748
- if (!highest || version > highest.version) highest = { version, name };
1749
- }
1750
- return highest && highest.version > target.version ? highest.name : void 0;
1751
2062
  }
1752
-
1753
- // src/internal/merge.ts
1754
- var import_zod4 = require("zod");
1755
- function baseTypeName(zodType) {
1756
- let t = zodType;
1757
- while (typeof t.unwrap === "function") {
1758
- t = t.unwrap();
2063
+ async function tombstone(stream, expectedVersion, correlation) {
2064
+ try {
2065
+ const [committed] = await store2().commit(
2066
+ stream,
2067
+ [{ name: TOMBSTONE_EVENT, data: {} }],
2068
+ { correlation, causation: {} },
2069
+ expectedVersion
2070
+ );
2071
+ return committed;
2072
+ } catch (error) {
2073
+ if (error instanceof ConcurrencyError) return void 0;
2074
+ throw error;
1759
2075
  }
1760
- return t.constructor.name;
1761
2076
  }
1762
- function mergeSchemas(existing, incoming, stateName) {
1763
- if (existing instanceof import_zod4.ZodObject && incoming instanceof import_zod4.ZodObject) {
1764
- const existingShape = existing.shape;
1765
- const incomingShape = incoming.shape;
1766
- for (const key of Object.keys(incomingShape)) {
1767
- if (key in existingShape) {
1768
- const existingBase = baseTypeName(existingShape[key]);
1769
- const incomingBase = baseTypeName(incomingShape[key]);
1770
- if (existingBase !== incomingBase) {
1771
- throw new Error(
1772
- `Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
1773
- );
1774
- }
2077
+ async function load(me, stream, callback, asOf) {
2078
+ const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
2079
+ const cached = timeTravel ? void 0 : await cache2().get(stream);
2080
+ const cache_hit = !!cached;
2081
+ let state2 = cached?.state ?? (me.init ? me.init() : {});
2082
+ let patches = cached?.patches ?? 0;
2083
+ let snaps = cached?.snaps ?? 0;
2084
+ let version = cached?.version ?? -1;
2085
+ let replayed = 0;
2086
+ let event;
2087
+ await store2().query(
2088
+ (e) => {
2089
+ event = e;
2090
+ version = e.version;
2091
+ if (e.name === SNAP_EVENT) {
2092
+ state2 = e.data;
2093
+ snaps++;
2094
+ patches = 0;
2095
+ replayed++;
2096
+ } else if (me.patch[e.name]) {
2097
+ state2 = (0, import_act_patch.patch)(state2, me.patch[e.name](event, state2));
2098
+ patches++;
2099
+ replayed++;
2100
+ } else if (e.name !== TOMBSTONE_EVENT) {
2101
+ log().warn(
2102
+ `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
2103
+ );
1775
2104
  }
2105
+ callback?.({
2106
+ event,
2107
+ state: state2,
2108
+ version,
2109
+ patches,
2110
+ snaps,
2111
+ cache_hit,
2112
+ replayed
2113
+ });
2114
+ },
2115
+ {
2116
+ stream,
2117
+ stream_exact: true,
2118
+ ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
1776
2119
  }
1777
- return existing.extend(incomingShape);
1778
- }
1779
- return existing;
1780
- }
1781
- function mergeInits(existing, incoming) {
1782
- return () => ({ ...existing(), ...incoming() });
1783
- }
1784
- function registerState(state2, states, actions, events) {
1785
- const existing = states.get(state2.name);
1786
- if (existing) {
1787
- mergeIntoExisting(state2, existing, states, actions, events);
1788
- } else {
1789
- registerNewState(state2, states, actions, events);
2120
+ );
2121
+ if (replayed > 0 && !timeTravel && event) {
2122
+ await cache2().set(stream, {
2123
+ state: state2,
2124
+ version,
2125
+ event_id: event.id,
2126
+ patches,
2127
+ snaps
2128
+ });
1790
2129
  }
2130
+ return { event, state: state2, version, patches, snaps, cache_hit, replayed };
1791
2131
  }
1792
- function registerNewState(state2, states, actions, events) {
1793
- states.set(state2.name, state2);
1794
- for (const name of Object.keys(state2.actions)) {
1795
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
1796
- actions[name] = state2;
2132
+ async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
2133
+ const { stream, expectedVersion, actor } = target;
2134
+ if (!stream) throw new Error("Missing target stream");
2135
+ const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
2136
+ const snapshot = await load(me, stream);
2137
+ if (snapshot.event?.name === TOMBSTONE_EVENT)
2138
+ throw new StreamClosedError(stream);
2139
+ const expected = expectedVersion ?? snapshot.event?.version;
2140
+ if (me.given) {
2141
+ const invariants = me.given[action2] || [];
2142
+ invariants.forEach(({ valid, description }) => {
2143
+ if (!valid(snapshot.state, actor))
2144
+ throw new InvariantError(
2145
+ action2,
2146
+ validated,
2147
+ target,
2148
+ snapshot,
2149
+ description
2150
+ );
2151
+ });
1797
2152
  }
1798
- for (const name of Object.keys(state2.events)) {
1799
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
1800
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
2153
+ const result = me.on[action2](validated, snapshot, target);
2154
+ if (!result) return [snapshot];
2155
+ if (Array.isArray(result) && result.length === 0) {
2156
+ return [snapshot];
1801
2157
  }
1802
- }
1803
- function mergeIntoExisting(state2, existing, states, actions, events) {
1804
- for (const name of Object.keys(state2.actions)) {
1805
- if (existing.actions[name] === state2.actions[name]) continue;
1806
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
2158
+ const tuples = Array.isArray(result[0]) ? result : [result];
2159
+ const deprecated = me._deprecated;
2160
+ if (deprecated && deprecated.size > 0) {
2161
+ const me_ = me;
2162
+ const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
2163
+ for (const [name] of tuples) {
2164
+ const evt = name;
2165
+ if (deprecated.has(evt) && !warned.has(evt)) {
2166
+ warned.add(evt);
2167
+ log().warn(
2168
+ `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)`
2169
+ );
2170
+ }
2171
+ }
1807
2172
  }
1808
- for (const name of Object.keys(state2.events)) {
1809
- if (existing.events[name] === state2.events[name]) continue;
1810
- if (existing.events[name]) {
1811
- throw new Error(
1812
- `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.`
1813
- );
2173
+ const emitted = tuples.map(([name, data]) => ({
2174
+ name,
2175
+ data: skipValidation ? data : validate(name, data, me.events[name])
2176
+ }));
2177
+ const meta = {
2178
+ correlation: reactingTo?.meta.correlation || correlator({
2179
+ action: action2,
2180
+ state: me.name,
2181
+ stream,
2182
+ actor: target.actor
2183
+ }),
2184
+ causation: {
2185
+ action: {
2186
+ name: action2,
2187
+ ...target
2188
+ // payload intentionally omitted: it can be large or contain PII,
2189
+ // and callers correlate via the correlation id when they need it.
2190
+ },
2191
+ event: reactingTo ? {
2192
+ id: reactingTo.id,
2193
+ name: reactingTo.name,
2194
+ stream: reactingTo.stream
2195
+ } : void 0
1814
2196
  }
1815
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
1816
- }
1817
- const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
1818
- const merged = {
1819
- ...existing,
1820
- state: mergeSchemas(existing.state, state2.state, state2.name),
1821
- init: mergeInits(existing.init, state2.init),
1822
- events: { ...existing.events, ...state2.events },
1823
- actions: { ...existing.actions, ...state2.actions },
1824
- patch: mergedPatch,
1825
- on: { ...existing.on, ...state2.on },
1826
- given: { ...existing.given, ...state2.given },
1827
- snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
1828
- throw new Error(
1829
- `Duplicate snap strategy for state "${state2.name}"`
1830
- );
1831
- })() : state2.snap || existing.snap
1832
2197
  };
1833
- states.set(state2.name, merged);
1834
- for (const name of Object.keys(merged.actions)) {
1835
- actions[name] = merged;
1836
- }
1837
- for (const name of Object.keys(state2.events)) {
1838
- if (events[name]) continue;
1839
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
1840
- }
1841
- }
1842
- function mergePatches(existing, incoming, stateName) {
1843
- const merged = { ...existing };
1844
- for (const name of Object.keys(incoming)) {
1845
- const existingP = existing[name];
1846
- const incomingP = incoming[name];
1847
- if (!existingP) {
1848
- merged[name] = incomingP;
1849
- continue;
1850
- }
1851
- const existingIsDefault = existingP._passthrough;
1852
- const incomingIsDefault = incomingP._passthrough;
1853
- if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
1854
- throw new Error(
1855
- `Duplicate custom patch for event "${name}" in state "${stateName}"`
1856
- );
1857
- }
1858
- if (existingIsDefault && !incomingIsDefault) {
1859
- merged[name] = incomingP;
1860
- }
1861
- }
1862
- return merged;
1863
- }
1864
- function mergeEventRegister(target, source) {
1865
- for (const [eventName, sourceReg] of Object.entries(source)) {
1866
- const targetReg = target[eventName];
1867
- if (!targetReg) continue;
1868
- for (const [name, reaction] of sourceReg.reactions) {
1869
- targetReg.reactions.set(name, reaction);
1870
- }
1871
- }
1872
- }
1873
- function mergeProjection(proj, events) {
1874
- for (const eventName of Object.keys(proj.events)) {
1875
- const projRegister = proj.events[eventName];
1876
- const existing = events[eventName];
1877
- if (!existing) {
1878
- events[eventName] = {
1879
- schema: projRegister.schema,
1880
- reactions: new Map(projRegister.reactions)
1881
- };
1882
- } else {
1883
- for (const [name, reaction] of projRegister.reactions) {
1884
- let key = name;
1885
- while (existing.reactions.has(key)) key = `${key}_p`;
1886
- existing.reactions.set(key, reaction);
1887
- }
2198
+ let committed;
2199
+ try {
2200
+ committed = await store2().commit(
2201
+ stream,
2202
+ emitted,
2203
+ meta,
2204
+ // Reactions skip optimistic concurrency: they always append against the
2205
+ // current head. Stream leasing already serializes concurrent reactions,
2206
+ // and forcing version checks here would turn ordinary catch-up into
2207
+ // spurious retries.
2208
+ reactingTo ? void 0 : expected
2209
+ );
2210
+ } catch (error) {
2211
+ if (error instanceof ConcurrencyError) {
2212
+ await cache2().invalidate(stream);
1888
2213
  }
2214
+ throw error;
1889
2215
  }
2216
+ let { state: state2, patches } = snapshot;
2217
+ const snapshots = committed.map((event) => {
2218
+ const p = me.patch[event.name](event, state2);
2219
+ state2 = (0, import_act_patch.patch)(state2, p);
2220
+ patches++;
2221
+ return {
2222
+ event,
2223
+ state: state2,
2224
+ version: event.version,
2225
+ patches,
2226
+ snaps: snapshot.snaps,
2227
+ patch: p,
2228
+ cache_hit: snapshot.cache_hit,
2229
+ replayed: snapshot.replayed
2230
+ };
2231
+ });
2232
+ const last = snapshots.at(-1);
2233
+ const snapped = me.snap?.(last);
2234
+ cache2().set(stream, {
2235
+ state: last.state,
2236
+ version: last.event.version,
2237
+ event_id: last.event.id,
2238
+ patches: snapped ? 0 : last.patches,
2239
+ snaps: snapped ? last.snaps + 1 : last.snaps
2240
+ }).catch((err) => log().error(err));
2241
+ if (snapped) void snap(last);
2242
+ return snapshots;
1890
2243
  }
1891
- var _this_ = ({ stream }) => ({
1892
- source: stream,
1893
- target: stream
1894
- });
1895
2244
 
1896
- // src/internal/backoff.ts
1897
- function computeBackoffDelay(retry, opts) {
1898
- if (!opts || opts.baseMs <= 0) return 0;
1899
- const r = Math.max(0, retry);
1900
- let delay;
1901
- switch (opts.strategy) {
1902
- case "fixed":
1903
- delay = opts.baseMs;
1904
- break;
1905
- case "linear":
1906
- delay = opts.baseMs * (r + 1);
1907
- break;
1908
- case "exponential":
1909
- delay = opts.baseMs * 2 ** r;
1910
- if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
1911
- break;
2245
+ // src/internal/tracing.ts
2246
+ var PRETTY = config().env !== "production";
2247
+ var C_BLUE = "\x1B[38;5;39m";
2248
+ var C_ORANGE = "\x1B[38;5;208m";
2249
+ var C_GREEN = "\x1B[38;5;42m";
2250
+ var C_MAGENTA = "\x1B[38;5;165m";
2251
+ var C_DRAIN = "\x1B[38;5;244m";
2252
+ var C_HIT = "\x1B[38;5;82m";
2253
+ var C_MISS = "\x1B[38;5;220m";
2254
+ var C_RESET = "\x1B[0m";
2255
+ var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
2256
+ var C_LANE = "\x1B[38;5;183m";
2257
+ var C_DIM = "\x1B[38;5;240m";
2258
+ var C_ERR = "\x1B[38;5;196m";
2259
+ var C_STREAM = "\x1B[38;5;226m";
2260
+ var dim = (text) => PRETTY ? `${C_DIM}${text}${C_RESET}` : text;
2261
+ var hue = (color, text) => PRETTY ? `${color}${text}${C_RESET}` : text;
2262
+ var drain_caption = (caption, lane) => {
2263
+ const showLane = lane && lane !== "default";
2264
+ if (PRETTY) {
2265
+ const tag = `${C_DRAIN}>> ${caption}${C_RESET}`;
2266
+ return showLane ? `${tag} ${C_LANE}${lane}${C_RESET}` : tag;
2267
+ }
2268
+ return showLane ? `>> ${caption} ${lane}` : `>> ${caption}`;
2269
+ };
2270
+ var cache_marker = (hit) => {
2271
+ const word = hit ? "hit" : "miss";
2272
+ if (!PRETTY) return word;
2273
+ return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
2274
+ };
2275
+ var stats_marker = (version, replayed, snaps, patches) => {
2276
+ const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
2277
+ if (!PRETTY) return text;
2278
+ return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
2279
+ };
2280
+ var as_of_marker = (asOf) => {
2281
+ if (!asOf) return "";
2282
+ const parts = [];
2283
+ if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
2284
+ if (asOf.created_before !== void 0)
2285
+ parts.push(`created_before=${asOf.created_before.toISOString()}`);
2286
+ if (asOf.created_after !== void 0)
2287
+ parts.push(`created_after=${asOf.created_after.toISOString()}`);
2288
+ if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
2289
+ return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
2290
+ };
2291
+ var traced = (inner, exit, entry) => (async (...args) => {
2292
+ entry?.(...args);
2293
+ const result = await inner(...args);
2294
+ exit?.(result, ...args);
2295
+ return result;
2296
+ });
2297
+ function buildEs(logger, correlator = defaultCorrelator) {
2298
+ const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
2299
+ me,
2300
+ actionName,
2301
+ target,
2302
+ payload,
2303
+ reactingTo,
2304
+ skipValidation,
2305
+ correlator
2306
+ );
2307
+ if (logger.level !== "trace") {
2308
+ return {
2309
+ snap,
2310
+ load,
2311
+ action: boundAction,
2312
+ tombstone
2313
+ };
1912
2314
  }
1913
- if (opts.jitter) delay = delay * (0.5 + Math.random());
1914
- return Math.max(0, Math.floor(delay));
1915
- }
1916
-
1917
- // src/internal/reactions.ts
1918
- function finalize(lease, handled, at, error, options, logger) {
1919
- if (!error) return { lease, handled, at };
1920
- logger.error(error);
1921
- const nonRetryable = error instanceof NonRetryableError;
1922
- const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
1923
- if (block2)
1924
- logger.error(
1925
- nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
1926
- );
1927
- const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
1928
2315
  return {
1929
- lease,
1930
- handled,
1931
- at,
1932
- error: handled === 0 ? error.message : void 0,
1933
- block: block2,
1934
- nextAttemptAt
1935
- };
1936
- }
1937
- function buildHandle(deps) {
1938
- const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
1939
- return async (lease, payloads) => {
1940
- if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
1941
- const stream = lease.stream;
1942
- let at = payloads.at(0).event.id;
1943
- let handled = 0;
1944
- if (lease.retry > 0)
1945
- logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1946
- const scopedApp = {
1947
- do: boundDo,
1948
- load: boundLoad,
1949
- query: boundQuery,
1950
- query_array: boundQueryArray
1951
- };
1952
- for (const payload of payloads) {
1953
- const { event, handler } = payload;
1954
- scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
1955
- action2,
1956
- target,
1957
- actionPayload,
1958
- reactingTo ?? event,
1959
- skipValidation
2316
+ snap: traced(snap, void 0, (snapshot) => {
2317
+ logger.trace(
2318
+ es_caption(
2319
+ "snap",
2320
+ C_MAGENTA,
2321
+ `${snapshot.event.stream}@${snapshot.event.version}`
2322
+ )
1960
2323
  );
1961
- try {
1962
- await handler(event, stream, scopedApp);
1963
- at = event.id;
1964
- handled++;
1965
- } catch (error) {
1966
- return finalize(
1967
- lease,
1968
- handled,
1969
- at,
1970
- error,
1971
- payload.options,
1972
- logger
2324
+ }),
2325
+ load: traced(load, (result, _me, stream, _cb, asOf) => {
2326
+ const stats = stats_marker(
2327
+ result.version,
2328
+ result.replayed,
2329
+ result.snaps,
2330
+ result.patches
2331
+ );
2332
+ logger.trace(
2333
+ es_caption(
2334
+ "load",
2335
+ C_GREEN,
2336
+ `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
2337
+ )
2338
+ );
2339
+ }),
2340
+ action: traced(
2341
+ boundAction,
2342
+ (snapshots, _me, _action, target) => {
2343
+ const committed = snapshots.filter((s) => s.event);
2344
+ if (committed.length) {
2345
+ logger.trace(
2346
+ committed.map((s) => s.event.data),
2347
+ es_caption(
2348
+ "committed",
2349
+ C_ORANGE,
2350
+ `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
2351
+ )
2352
+ );
2353
+ }
2354
+ },
2355
+ (_me, action2, target, payload) => {
2356
+ logger.trace(
2357
+ payload,
2358
+ es_caption("action", C_BLUE, `${target.stream}.${action2}`)
1973
2359
  );
1974
2360
  }
1975
- }
1976
- return finalize(lease, handled, at, void 0, payloads[0].options, logger);
2361
+ ),
2362
+ tombstone: traced(tombstone, (committed, stream) => {
2363
+ if (committed)
2364
+ logger.trace(
2365
+ es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
2366
+ );
2367
+ })
1977
2368
  };
1978
2369
  }
1979
- function buildHandleBatch(logger) {
1980
- return async (lease, payloads, batchHandler) => {
1981
- const stream = lease.stream;
1982
- const events = payloads.map((p) => p.event);
1983
- const options = payloads[0].options;
1984
- if (lease.retry > 0)
1985
- logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
1986
- try {
1987
- await batchHandler(events, stream);
1988
- return finalize(
1989
- lease,
1990
- events.length,
1991
- events.at(-1).id,
1992
- void 0,
1993
- options,
1994
- logger
1995
- );
1996
- } catch (error) {
1997
- return finalize(lease, 0, lease.at, error, options, logger);
1998
- }
2370
+ function buildDrain(logger) {
2371
+ return {
2372
+ claim,
2373
+ fetch,
2374
+ ack,
2375
+ block,
2376
+ subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
2377
+ if (!result.subscribed) return;
2378
+ const lanes = new Set(streams.map((s) => s.lane ?? "default"));
2379
+ const uniformLane = lanes.size === 1 ? streams[0]?.lane : void 0;
2380
+ const data = streams.map(
2381
+ ({ stream, lane }) => uniformLane || !lane || lane === "default" ? hue(C_STREAM, stream) : `${hue(C_STREAM, stream)}${dim(`[${lane}]`)}`
2382
+ ).join(" ");
2383
+ logger.trace(`${drain_caption("correlated", uniformLane)} ${data}`);
2384
+ })
1999
2385
  };
2000
2386
  }
2387
+ function traceCycle(logger, leased, fetched, handled, acked, blocked) {
2388
+ if (logger.level !== "trace" || !leased.length) return;
2389
+ const lane = leased[0]?.lane;
2390
+ const fetchByStream = new Map(fetched.map((f) => [f.stream, f]));
2391
+ const ackedByStream = new Map(acked.map((a) => [a.stream, a.at]));
2392
+ const blockedByStream = new Map(blocked.map((b) => [b.stream, b.error]));
2393
+ const failedByStream = new Map(
2394
+ handled.filter((h) => h.error).map((h) => [h.lease.stream, h])
2395
+ );
2396
+ const detail = leased.map(({ stream, at, retry }) => {
2397
+ const f = fetchByStream.get(stream);
2398
+ const key = f?.source ? `${hue(C_STREAM, stream)}${dim(`<-${f.source}`)}` : hue(C_STREAM, stream);
2399
+ const events = f && f.events.length ? ` ${dim(
2400
+ `[${f.events.map(({ id, name }) => `#${id} ${String(name)}`).join(", ")}]`
2401
+ )}` : "";
2402
+ const ackedAt = ackedByStream.get(stream);
2403
+ const ackPart = ackedAt !== void 0 ? hue(C_HIT, `\u2713 @${ackedAt}`) : "";
2404
+ const failure = failedByStream.get(stream);
2405
+ let failPart = "";
2406
+ if (failure) {
2407
+ const failedAt = failure.failed_at ?? at;
2408
+ const blockedError = blockedByStream.get(stream);
2409
+ if (blockedError !== void 0) {
2410
+ failPart = `${hue(C_ERR, `\u2717 @${failedAt}/${retry}`)} ${dim(`(${blockedError})`)}`;
2411
+ } else {
2412
+ failPart = `${hue(C_MISS, `\u26A0 @${failedAt}/${retry}`)} ${dim(`(${failure.error})`)}`;
2413
+ }
2414
+ }
2415
+ let tail;
2416
+ if (ackPart && failPart) tail = ` ${ackPart} ${failPart}`;
2417
+ else if (ackPart) tail = ` ${ackPart}`;
2418
+ else if (failPart) tail = ` ${failPart}`;
2419
+ else tail = ` ${dim(`\u2298 @${at}/${retry}`)}`;
2420
+ return `${key}${events}${tail}`;
2421
+ }).join(", ");
2422
+ logger.trace(`${drain_caption("drained", lane)} ${detail}`);
2423
+ }
2001
2424
 
2002
- // src/internal/settle.ts
2003
- var SettleLoop = class {
2004
- constructor(deps, defaultDebounceMs) {
2425
+ // src/internal/drain-cycle.ts
2426
+ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred, lane) {
2427
+ const leased = await ops.claim(
2428
+ lagging,
2429
+ leading,
2430
+ (0, import_node_crypto2.randomUUID)(),
2431
+ leaseMillis,
2432
+ lane
2433
+ );
2434
+ if (!leased.length) return void 0;
2435
+ const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
2436
+ if (!active.length) {
2437
+ return {
2438
+ leased,
2439
+ fetched: [],
2440
+ handled: [],
2441
+ acked: [],
2442
+ blocked: []
2443
+ };
2444
+ }
2445
+ const fetched = await ops.fetch(active, eventLimit);
2446
+ const fetchMap = /* @__PURE__ */ new Map();
2447
+ const fetch_window_at = fetched.reduce(
2448
+ (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
2449
+ 0
2450
+ );
2451
+ for (const f of fetched) {
2452
+ const { stream, events } = f;
2453
+ const payloads = events.flatMap((event) => {
2454
+ const register = registry.events[event.name];
2455
+ if (!register) return [];
2456
+ return [...register.reactions.values()].filter((reaction) => {
2457
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
2458
+ return resolved && resolved.target === stream;
2459
+ }).map((reaction) => ({ ...reaction, event }));
2460
+ });
2461
+ fetchMap.set(stream, { fetch: f, payloads });
2462
+ }
2463
+ const handled = await Promise.all(
2464
+ active.map((lease) => {
2465
+ const entry = fetchMap.get(lease.stream);
2466
+ const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
2467
+ const { payloads } = entry;
2468
+ const batchHandler = batchHandlers.get(lease.stream);
2469
+ if (batchHandler && payloads.length > 0) {
2470
+ return handleBatch({ ...lease, at }, payloads, batchHandler);
2471
+ }
2472
+ return handle({ ...lease, at }, payloads);
2473
+ })
2474
+ );
2475
+ const acked = await ops.ack(
2476
+ handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
2477
+ );
2478
+ const blocked = await ops.block(
2479
+ handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
2480
+ );
2481
+ return { leased, fetched, handled, acked, blocked };
2482
+ }
2483
+ var EMPTY_DRAIN = {
2484
+ fetched: [],
2485
+ leased: [],
2486
+ acked: [],
2487
+ blocked: []
2488
+ };
2489
+ var DrainController = class {
2490
+ constructor(deps) {
2005
2491
  this.deps = deps;
2006
- this.defaultDebounceMs = defaultDebounceMs;
2007
2492
  }
2008
- _timer = void 0;
2009
- _running = false;
2493
+ _armed = false;
2494
+ _locked = false;
2495
+ _ratio = 0.5;
2496
+ /**
2497
+ * Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
2498
+ * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
2499
+ * ack or terminal block. Lives in process memory — per-worker pacing
2500
+ * by design (see {@link BackoffOptions} for the multi-worker trade-off).
2501
+ */
2502
+ _backoff = /* @__PURE__ */ new Map();
2503
+ /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
2504
+ _backoffTimer;
2505
+ /** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
2506
+ _worker;
2507
+ _stopped = false;
2508
+ /**
2509
+ * Signal that a commit (or reset / cold-start) may have produced work.
2510
+ * Subsequent `drain()` calls will run the pipeline; once the pipeline
2511
+ * settles to no-progress, the controller disarms itself.
2512
+ */
2513
+ arm() {
2514
+ this._armed = true;
2515
+ }
2516
+ /** Read-only flag — true while a commit / reset is unprocessed. */
2517
+ get armed() {
2518
+ return this._armed;
2519
+ }
2520
+ /** Returns true when `stream` is currently within a backoff window. */
2521
+ isDeferred = (stream) => {
2522
+ const next = this._backoff.get(stream);
2523
+ return next !== void 0 && next > Date.now();
2524
+ };
2525
+ /**
2526
+ * Schedule the next drain re-arm at the earliest pending backoff
2527
+ * expiry. Called only when the backoff map is non-empty (caller guard).
2528
+ * Idempotent — collapses many simultaneously deferred streams into a
2529
+ * single timer.
2530
+ */
2531
+ scheduleBackoffWake() {
2532
+ if (this._backoffTimer) clearTimeout(this._backoffTimer);
2533
+ let earliest = Number.POSITIVE_INFINITY;
2534
+ for (const t of this._backoff.values()) if (t < earliest) earliest = t;
2535
+ const delay = Math.max(0, earliest - Date.now());
2536
+ this._backoffTimer = setTimeout(() => {
2537
+ this._backoffTimer = void 0;
2538
+ const now = Date.now();
2539
+ for (const [stream, at] of this._backoff) {
2540
+ if (at <= now) this._backoff.delete(stream);
2541
+ }
2542
+ this._armed = true;
2543
+ }, delay);
2544
+ this._backoffTimer.unref();
2545
+ }
2546
+ /** Lane this controller drains (undefined = legacy single-lane span). */
2547
+ get lane() {
2548
+ return this.deps.lane;
2549
+ }
2010
2550
  /**
2011
- * Schedule a settle pass. Multiple calls inside the debounce window
2012
- * coalesce into one cycle. The cycle runs correlate→drain in a loop
2013
- * until no progress is made (no new subscriptions, no acks, no blocks)
2014
- * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
2015
- * via {@link SettleDeps.onSettled}.
2551
+ * Start a per-lane worker that drains at the lane's `cycleMs`
2552
+ * cadence (ACT-1103). When armed, the worker calls `drain()` on every
2553
+ * tick and re-schedules; when not armed, it still re-schedules at
2554
+ * `cycleMs` so a future `arm()` is picked up on the next tick.
2555
+ *
2556
+ * The setTimeout chain uses `unref()` so it doesn't keep the process
2557
+ * alive on its own.
2016
2558
  */
2017
- schedule(options = {}) {
2018
- const {
2019
- debounceMs = this.defaultDebounceMs,
2020
- correlate: correlateQuery = { after: -1, limit: 100 },
2021
- maxPasses = Infinity,
2022
- ...drainOptions
2023
- } = options;
2024
- if (this._timer) clearTimeout(this._timer);
2025
- this._timer = setTimeout(() => {
2026
- this._timer = void 0;
2027
- if (this._running) return;
2028
- this._running = true;
2029
- (async () => {
2030
- await this.deps.init();
2031
- let lastDrain;
2032
- for (let i = 0; i < maxPasses; i++) {
2033
- const { subscribed } = await this.deps.correlate({
2034
- ...correlateQuery,
2035
- after: this.deps.checkpoint()
2036
- });
2037
- lastDrain = await this.deps.drain(drainOptions);
2038
- const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
2039
- if (!made_progress) break;
2040
- }
2041
- if (lastDrain) this.deps.onSettled(lastDrain);
2042
- })().catch((err) => this.deps.logger.error(err)).finally(() => {
2043
- this._running = false;
2044
- });
2045
- }, debounceMs);
2559
+ start(cycleMs) {
2560
+ if (this._worker || this._stopped) return;
2561
+ const tick = async () => {
2562
+ if (this._armed) await this.drain();
2563
+ if (this._stopped) return;
2564
+ this._worker = setTimeout(tick, cycleMs);
2565
+ this._worker.unref();
2566
+ };
2567
+ this._worker = setTimeout(tick, cycleMs);
2568
+ this._worker.unref();
2046
2569
  }
2047
- /** Cancel any pending or active settle cycle. Idempotent. */
2570
+ /** Stop the per-lane worker. Idempotent. */
2048
2571
  stop() {
2049
- if (this._timer) {
2050
- clearTimeout(this._timer);
2051
- this._timer = void 0;
2572
+ this._stopped = true;
2573
+ if (this._worker) {
2574
+ clearTimeout(this._worker);
2575
+ this._worker = void 0;
2052
2576
  }
2053
2577
  }
2054
- };
2055
-
2056
- // src/internal/drain.ts
2057
- var claim = (lagging, leading, by, millis) => store2().claim(lagging, leading, by, millis);
2058
- async function fetch(leased, eventLimit) {
2059
- return Promise.all(
2060
- leased.map(async ({ stream, source, at, lagging }) => {
2061
- const events = [];
2062
- await store2().query((e) => events.push(e), {
2063
- stream: source,
2064
- after: at,
2065
- limit: eventLimit
2066
- });
2067
- return { stream, source, at, lagging, events };
2068
- })
2069
- );
2070
- }
2071
- var ack = (leases) => store2().ack(leases);
2072
- var block = (leases) => store2().block(leases);
2073
- var subscribe = (streams) => store2().subscribe(streams);
2074
-
2075
- // src/internal/event-sourcing.ts
2076
- var import_act_patch = require("@rotorsoft/act-patch");
2077
- async function snap(snapshot) {
2078
- try {
2079
- const { id, stream, name, meta, version } = snapshot.event;
2080
- await store2().commit(
2081
- stream,
2082
- [{ name: SNAP_EVENT, data: snapshot.state }],
2083
- {
2084
- correlation: meta.correlation,
2085
- causation: { event: { id, name, stream } }
2086
- },
2087
- version
2088
- // IMPORTANT! - state events are committed right after the snapshot event
2089
- );
2090
- } catch (error) {
2091
- log().error(error);
2092
- }
2093
- }
2094
- async function tombstone(stream, expectedVersion, correlation) {
2095
- try {
2096
- const [committed] = await store2().commit(
2097
- stream,
2098
- [{ name: TOMBSTONE_EVENT, data: {} }],
2099
- { correlation, causation: {} },
2100
- expectedVersion
2101
- );
2102
- return committed;
2103
- } catch (error) {
2104
- if (error instanceof ConcurrencyError) return void 0;
2105
- throw error;
2106
- }
2107
- }
2108
- async function load(me, stream, callback, asOf) {
2109
- const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
2110
- const cached = timeTravel ? void 0 : await cache2().get(stream);
2111
- const cache_hit = !!cached;
2112
- let state2 = cached?.state ?? (me.init ? me.init() : {});
2113
- let patches = cached?.patches ?? 0;
2114
- let snaps = cached?.snaps ?? 0;
2115
- let version = cached?.version ?? -1;
2116
- let replayed = 0;
2117
- let event;
2118
- await store2().query(
2119
- (e) => {
2120
- event = e;
2121
- version = e.version;
2122
- if (e.name === SNAP_EVENT) {
2123
- state2 = e.data;
2124
- snaps++;
2125
- patches = 0;
2126
- replayed++;
2127
- } else if (me.patch[e.name]) {
2128
- state2 = (0, import_act_patch.patch)(state2, me.patch[e.name](event, state2));
2129
- patches++;
2130
- replayed++;
2131
- } else if (e.name !== TOMBSTONE_EVENT) {
2132
- log().warn(
2133
- `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
2134
- );
2578
+ /** Run one drain pass. Short-circuits when not armed or already running. */
2579
+ async drain(options = {}) {
2580
+ if (!this._armed) return EMPTY_DRAIN;
2581
+ if (this._locked) return EMPTY_DRAIN;
2582
+ const d = this.deps.defaults ?? {};
2583
+ const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
2584
+ const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
2585
+ const leaseMillis = d.leaseMillis ?? options.leaseMillis ?? 1e4;
2586
+ try {
2587
+ this._locked = true;
2588
+ const lagging = Math.ceil(streamLimit * this._ratio);
2589
+ const leading = streamLimit - lagging;
2590
+ const cycle = await runDrainCycle(
2591
+ this.deps.ops,
2592
+ this.deps.registry,
2593
+ this.deps.batchHandlers,
2594
+ this.deps.handle,
2595
+ this.deps.handleBatch,
2596
+ lagging,
2597
+ leading,
2598
+ eventLimit,
2599
+ leaseMillis,
2600
+ this._backoff.size > 0 ? this.isDeferred : void 0,
2601
+ this.deps.lane
2602
+ );
2603
+ if (!cycle) {
2604
+ this._armed = false;
2605
+ return EMPTY_DRAIN;
2135
2606
  }
2136
- callback?.({
2137
- event,
2138
- state: state2,
2139
- version,
2140
- patches,
2141
- snaps,
2142
- cache_hit,
2143
- replayed
2144
- });
2145
- },
2146
- {
2147
- stream,
2148
- stream_exact: true,
2149
- ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
2607
+ const { leased, fetched, handled, acked, blocked } = cycle;
2608
+ traceCycle(this.deps.logger, leased, fetched, handled, acked, blocked);
2609
+ this._ratio = computeLagLeadRatio(handled, lagging, leading);
2610
+ for (const lease of acked) this._backoff.delete(lease.stream);
2611
+ for (const lease of blocked) this._backoff.delete(lease.stream);
2612
+ for (const h of handled) {
2613
+ if (h.nextAttemptAt !== void 0 && !h.block) {
2614
+ this._backoff.set(h.lease.stream, h.nextAttemptAt);
2615
+ }
2616
+ }
2617
+ if (this._backoff.size > 0) this.scheduleBackoffWake();
2618
+ if (acked.length) this.deps.onAcked(acked);
2619
+ if (blocked.length) this.deps.onBlocked(blocked);
2620
+ const hasErrors = handled.some(({ error }) => error);
2621
+ if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
2622
+ return { fetched, leased, acked, blocked };
2623
+ } catch (error) {
2624
+ this.deps.logger.error(error);
2625
+ return EMPTY_DRAIN;
2626
+ } finally {
2627
+ this._locked = false;
2150
2628
  }
2151
- );
2152
- if (replayed > 0 && !timeTravel && event) {
2153
- await cache2().set(stream, {
2154
- state: state2,
2155
- version,
2156
- event_id: event.id,
2157
- patches,
2158
- snaps
2159
- });
2160
- }
2161
- return { event, state: state2, version, patches, snaps, cache_hit, replayed };
2162
- }
2163
- async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
2164
- const { stream, expectedVersion, actor } = target;
2165
- if (!stream) throw new Error("Missing target stream");
2166
- const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
2167
- const snapshot = await load(me, stream);
2168
- if (snapshot.event?.name === TOMBSTONE_EVENT)
2169
- throw new StreamClosedError(stream);
2170
- const expected = expectedVersion ?? snapshot.event?.version;
2171
- if (me.given) {
2172
- const invariants = me.given[action2] || [];
2173
- invariants.forEach(({ valid, description }) => {
2174
- if (!valid(snapshot.state, actor))
2175
- throw new InvariantError(
2176
- action2,
2177
- validated,
2178
- target,
2179
- snapshot,
2180
- description
2181
- );
2182
- });
2183
2629
  }
2184
- const result = me.on[action2](validated, snapshot, target);
2185
- if (!result) return [snapshot];
2186
- if (Array.isArray(result) && result.length === 0) {
2187
- return [snapshot];
2630
+ };
2631
+
2632
+ // src/internal/merge.ts
2633
+ var import_zod4 = require("zod");
2634
+ function baseTypeName(zodType) {
2635
+ let t = zodType;
2636
+ while (typeof t.unwrap === "function") {
2637
+ t = t.unwrap();
2188
2638
  }
2189
- const tuples = Array.isArray(result[0]) ? result : [result];
2190
- const deprecated = me._deprecated;
2191
- if (deprecated && deprecated.size > 0) {
2192
- const me_ = me;
2193
- const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
2194
- for (const [name] of tuples) {
2195
- const evt = name;
2196
- if (deprecated.has(evt) && !warned.has(evt)) {
2197
- warned.add(evt);
2198
- log().warn(
2199
- `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)`
2200
- );
2639
+ return t.constructor.name;
2640
+ }
2641
+ function mergeSchemas(existing, incoming, stateName) {
2642
+ if (existing instanceof import_zod4.ZodObject && incoming instanceof import_zod4.ZodObject) {
2643
+ const existingShape = existing.shape;
2644
+ const incomingShape = incoming.shape;
2645
+ for (const key of Object.keys(incomingShape)) {
2646
+ if (key in existingShape) {
2647
+ const existingBase = baseTypeName(existingShape[key]);
2648
+ const incomingBase = baseTypeName(incomingShape[key]);
2649
+ if (existingBase !== incomingBase) {
2650
+ throw new Error(
2651
+ `Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
2652
+ );
2653
+ }
2201
2654
  }
2202
2655
  }
2656
+ return existing.extend(incomingShape);
2203
2657
  }
2204
- const emitted = tuples.map(([name, data]) => ({
2205
- name,
2206
- data: skipValidation ? data : validate(name, data, me.events[name])
2207
- }));
2208
- const meta = {
2209
- correlation: reactingTo?.meta.correlation || correlator({
2210
- action: action2,
2211
- state: me.name,
2212
- stream,
2213
- actor: target.actor
2214
- }),
2215
- causation: {
2216
- action: {
2217
- name: action2,
2218
- ...target
2219
- // payload intentionally omitted: it can be large or contain PII,
2220
- // and callers correlate via the correlation id when they need it.
2221
- },
2222
- event: reactingTo ? {
2223
- id: reactingTo.id,
2224
- name: reactingTo.name,
2225
- stream: reactingTo.stream
2226
- } : void 0
2227
- }
2228
- };
2229
- let committed;
2230
- try {
2231
- committed = await store2().commit(
2232
- stream,
2233
- emitted,
2234
- meta,
2235
- // Reactions skip optimistic concurrency: they always append against the
2236
- // current head. Stream leasing already serializes concurrent reactions,
2237
- // and forcing version checks here would turn ordinary catch-up into
2238
- // spurious retries.
2239
- reactingTo ? void 0 : expected
2240
- );
2241
- } catch (error) {
2242
- if (error instanceof ConcurrencyError) {
2243
- await cache2().invalidate(stream);
2244
- }
2245
- throw error;
2658
+ return existing;
2659
+ }
2660
+ function mergeInits(existing, incoming) {
2661
+ return () => ({ ...existing(), ...incoming() });
2662
+ }
2663
+ function registerState(state2, states, actions, events) {
2664
+ const existing = states.get(state2.name);
2665
+ if (existing) {
2666
+ mergeIntoExisting(state2, existing, states, actions, events);
2667
+ } else {
2668
+ registerNewState(state2, states, actions, events);
2246
2669
  }
2247
- let { state: state2, patches } = snapshot;
2248
- const snapshots = committed.map((event) => {
2249
- const p = me.patch[event.name](event, state2);
2250
- state2 = (0, import_act_patch.patch)(state2, p);
2251
- patches++;
2252
- return {
2253
- event,
2254
- state: state2,
2255
- version: event.version,
2256
- patches,
2257
- snaps: snapshot.snaps,
2258
- patch: p,
2259
- cache_hit: snapshot.cache_hit,
2260
- replayed: snapshot.replayed
2261
- };
2262
- });
2263
- const last = snapshots.at(-1);
2264
- const snapped = me.snap?.(last);
2265
- cache2().set(stream, {
2266
- state: last.state,
2267
- version: last.event.version,
2268
- event_id: last.event.id,
2269
- patches: snapped ? 0 : last.patches,
2270
- snaps: snapped ? last.snaps + 1 : last.snaps
2271
- }).catch((err) => log().error(err));
2272
- if (snapped) void snap(last);
2273
- return snapshots;
2274
2670
  }
2275
-
2276
- // src/internal/tracing.ts
2277
- var PRETTY = config().env !== "production";
2278
- var C_BLUE = "\x1B[38;5;39m";
2279
- var C_ORANGE = "\x1B[38;5;208m";
2280
- var C_GREEN = "\x1B[38;5;42m";
2281
- var C_MAGENTA = "\x1B[38;5;165m";
2282
- var C_DRAIN = "\x1B[38;5;244m";
2283
- var C_HIT = "\x1B[38;5;82m";
2284
- var C_MISS = "\x1B[38;5;220m";
2285
- var C_RESET = "\x1B[0m";
2286
- var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
2287
- var drain_caption = (caption) => {
2288
- const tag = `>> ${caption}`;
2289
- return PRETTY ? `${C_DRAIN}${tag}${C_RESET}` : tag;
2290
- };
2291
- var cache_marker = (hit) => {
2292
- const word = hit ? "hit" : "miss";
2293
- if (!PRETTY) return word;
2294
- return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
2295
- };
2296
- var stats_marker = (version, replayed, snaps, patches) => {
2297
- const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
2298
- if (!PRETTY) return text;
2299
- return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
2300
- };
2301
- var as_of_marker = (asOf) => {
2302
- if (!asOf) return "";
2303
- const parts = [];
2304
- if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
2305
- if (asOf.created_before !== void 0)
2306
- parts.push(`created_before=${asOf.created_before.toISOString()}`);
2307
- if (asOf.created_after !== void 0)
2308
- parts.push(`created_after=${asOf.created_after.toISOString()}`);
2309
- if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
2310
- return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
2311
- };
2312
- var traced = (inner, exit, entry) => (async (...args) => {
2313
- entry?.(...args);
2314
- const result = await inner(...args);
2315
- exit?.(result, ...args);
2316
- return result;
2317
- });
2318
- function buildEs(logger, correlator = defaultCorrelator) {
2319
- const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
2320
- me,
2321
- actionName,
2322
- target,
2323
- payload,
2324
- reactingTo,
2325
- skipValidation,
2326
- correlator
2327
- );
2328
- if (logger.level !== "trace") {
2329
- return {
2330
- snap,
2331
- load,
2332
- action: boundAction,
2333
- tombstone
2334
- };
2671
+ function registerNewState(state2, states, actions, events) {
2672
+ states.set(state2.name, state2);
2673
+ for (const name of Object.keys(state2.actions)) {
2674
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
2675
+ actions[name] = state2;
2335
2676
  }
2336
- return {
2337
- snap: traced(snap, void 0, (snapshot) => {
2338
- logger.trace(
2339
- es_caption(
2340
- "snap",
2341
- C_MAGENTA,
2342
- `${snapshot.event.stream}@${snapshot.event.version}`
2343
- )
2677
+ for (const name of Object.keys(state2.events)) {
2678
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
2679
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
2680
+ }
2681
+ }
2682
+ function mergeIntoExisting(state2, existing, states, actions, events) {
2683
+ for (const name of Object.keys(state2.actions)) {
2684
+ if (existing.actions[name] === state2.actions[name]) continue;
2685
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
2686
+ }
2687
+ for (const name of Object.keys(state2.events)) {
2688
+ if (existing.events[name] === state2.events[name]) continue;
2689
+ if (existing.events[name]) {
2690
+ throw new Error(
2691
+ `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.`
2344
2692
  );
2345
- }),
2346
- load: traced(load, (result, _me, stream, _cb, asOf) => {
2347
- const stats = stats_marker(
2348
- result.version,
2349
- result.replayed,
2350
- result.snaps,
2351
- result.patches
2693
+ }
2694
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
2695
+ }
2696
+ const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
2697
+ const merged = {
2698
+ ...existing,
2699
+ state: mergeSchemas(existing.state, state2.state, state2.name),
2700
+ init: mergeInits(existing.init, state2.init),
2701
+ events: { ...existing.events, ...state2.events },
2702
+ actions: { ...existing.actions, ...state2.actions },
2703
+ patch: mergedPatch,
2704
+ on: { ...existing.on, ...state2.on },
2705
+ given: { ...existing.given, ...state2.given },
2706
+ snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
2707
+ throw new Error(
2708
+ `Duplicate snap strategy for state "${state2.name}"`
2352
2709
  );
2353
- logger.trace(
2354
- es_caption(
2355
- "load",
2356
- C_GREEN,
2357
- `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
2358
- )
2710
+ })() : state2.snap || existing.snap
2711
+ };
2712
+ states.set(state2.name, merged);
2713
+ for (const name of Object.keys(merged.actions)) {
2714
+ actions[name] = merged;
2715
+ }
2716
+ for (const name of Object.keys(state2.events)) {
2717
+ if (events[name]) continue;
2718
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
2719
+ }
2720
+ }
2721
+ function mergePatches(existing, incoming, stateName) {
2722
+ const merged = { ...existing };
2723
+ for (const name of Object.keys(incoming)) {
2724
+ const existingP = existing[name];
2725
+ const incomingP = incoming[name];
2726
+ if (!existingP) {
2727
+ merged[name] = incomingP;
2728
+ continue;
2729
+ }
2730
+ const existingIsDefault = existingP._passthrough;
2731
+ const incomingIsDefault = incomingP._passthrough;
2732
+ if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
2733
+ throw new Error(
2734
+ `Duplicate custom patch for event "${name}" in state "${stateName}"`
2359
2735
  );
2360
- }),
2361
- action: traced(
2362
- boundAction,
2363
- (snapshots, _me, _action, target) => {
2364
- const committed = snapshots.filter((s) => s.event);
2365
- if (committed.length) {
2366
- logger.trace(
2367
- committed.map((s) => s.event.data),
2368
- es_caption(
2369
- "committed",
2370
- C_ORANGE,
2371
- `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
2372
- )
2373
- );
2374
- }
2375
- },
2376
- (_me, action2, target, payload) => {
2377
- logger.trace(
2378
- payload,
2379
- es_caption("action", C_BLUE, `${target.stream}.${action2}`)
2380
- );
2736
+ }
2737
+ if (existingIsDefault && !incomingIsDefault) {
2738
+ merged[name] = incomingP;
2739
+ }
2740
+ }
2741
+ return merged;
2742
+ }
2743
+ function mergeEventRegister(target, source) {
2744
+ for (const [eventName, sourceReg] of Object.entries(source)) {
2745
+ const targetReg = target[eventName];
2746
+ if (!targetReg) continue;
2747
+ for (const [name, reaction] of sourceReg.reactions) {
2748
+ targetReg.reactions.set(name, reaction);
2749
+ }
2750
+ }
2751
+ }
2752
+ function mergeProjection(proj, events) {
2753
+ for (const eventName of Object.keys(proj.events)) {
2754
+ const projRegister = proj.events[eventName];
2755
+ const existing = events[eventName];
2756
+ if (!existing) {
2757
+ events[eventName] = {
2758
+ schema: projRegister.schema,
2759
+ reactions: new Map(projRegister.reactions)
2760
+ };
2761
+ } else {
2762
+ for (const [name, reaction] of projRegister.reactions) {
2763
+ let key = name;
2764
+ while (existing.reactions.has(key)) key = `${key}_p`;
2765
+ existing.reactions.set(key, reaction);
2381
2766
  }
2382
- ),
2383
- tombstone: traced(tombstone, (committed, stream) => {
2384
- if (committed)
2385
- logger.trace(
2386
- es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
2387
- );
2388
- })
2389
- };
2767
+ }
2768
+ }
2390
2769
  }
2391
- function buildDrain(logger) {
2392
- if (logger.level !== "trace") {
2393
- return {
2394
- claim,
2395
- fetch,
2396
- ack,
2397
- block,
2398
- subscribe
2399
- };
2770
+ var _this_ = ({ stream }) => ({
2771
+ source: stream,
2772
+ target: stream
2773
+ });
2774
+
2775
+ // src/internal/backoff.ts
2776
+ function computeBackoffDelay(retry, opts) {
2777
+ if (!opts || opts.baseMs <= 0) return 0;
2778
+ const r = Math.max(0, retry);
2779
+ let delay;
2780
+ switch (opts.strategy) {
2781
+ case "fixed":
2782
+ delay = opts.baseMs;
2783
+ break;
2784
+ case "linear":
2785
+ delay = opts.baseMs * (r + 1);
2786
+ break;
2787
+ case "exponential":
2788
+ delay = opts.baseMs * 2 ** r;
2789
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
2790
+ break;
2400
2791
  }
2792
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
2793
+ return Math.max(0, Math.floor(delay));
2794
+ }
2795
+
2796
+ // src/internal/reactions.ts
2797
+ function finalize(lease, handled, at, error, options, logger, failed_at) {
2798
+ if (!error) return { lease, handled, acked_at: at };
2799
+ logger.error(error);
2800
+ const nonRetryable = error instanceof NonRetryableError;
2801
+ const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
2802
+ if (block2)
2803
+ logger.error(
2804
+ nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
2805
+ );
2806
+ const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
2401
2807
  return {
2402
- claim: traced(claim, (leased) => {
2403
- if (leased.length) {
2404
- const data = Object.fromEntries(
2405
- leased.map(({ stream, at, retry }) => [stream, { at, retry }])
2406
- );
2407
- logger.trace(data, drain_caption("claimed"));
2408
- }
2409
- }),
2410
- fetch: traced(fetch, (fetched) => {
2411
- const data = Object.fromEntries(
2412
- fetched.map(({ stream, source, events }) => {
2413
- const key = source ? `${stream}<-${source}` : stream;
2414
- const value = Object.fromEntries(
2415
- events.map(({ id, stream: stream2, name }) => [id, { [stream2]: name }])
2416
- );
2417
- return [key, value];
2418
- })
2808
+ lease,
2809
+ handled,
2810
+ acked_at: at,
2811
+ error: error.message,
2812
+ block: block2,
2813
+ nextAttemptAt,
2814
+ failed_at
2815
+ };
2816
+ }
2817
+ function buildHandle(deps) {
2818
+ const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
2819
+ return async (lease, payloads) => {
2820
+ if (payloads.length === 0) return { lease, handled: 0, acked_at: lease.at };
2821
+ const stream = lease.stream;
2822
+ let at = payloads.at(0).event.id;
2823
+ let handled = 0;
2824
+ if (lease.retry > 0)
2825
+ logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
2826
+ const scopedApp = {
2827
+ do: boundDo,
2828
+ load: boundLoad,
2829
+ query: boundQuery,
2830
+ query_array: boundQueryArray
2831
+ };
2832
+ for (const payload of payloads) {
2833
+ const { event, handler } = payload;
2834
+ scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
2835
+ action2,
2836
+ target,
2837
+ actionPayload,
2838
+ reactingTo ?? event,
2839
+ skipValidation
2419
2840
  );
2420
- logger.trace(data, drain_caption("fetched"));
2421
- }),
2422
- ack: traced(ack, (acked) => {
2423
- if (acked.length) {
2424
- const data = Object.fromEntries(
2425
- acked.map(({ stream, at, retry }) => [stream, { at, retry }])
2426
- );
2427
- logger.trace(data, drain_caption("acked"));
2428
- }
2429
- }),
2430
- block: traced(block, (blocked) => {
2431
- if (blocked.length) {
2432
- const data = Object.fromEntries(
2433
- blocked.map(({ stream, at, retry, error }) => [
2434
- stream,
2435
- { at, retry, error }
2436
- ])
2841
+ try {
2842
+ await handler(event, stream, scopedApp);
2843
+ at = event.id;
2844
+ handled++;
2845
+ } catch (error) {
2846
+ return finalize(
2847
+ lease,
2848
+ handled,
2849
+ at,
2850
+ error,
2851
+ payload.options,
2852
+ logger,
2853
+ event.id
2437
2854
  );
2438
- logger.trace(data, drain_caption("blocked"));
2439
- }
2440
- }),
2441
- subscribe: traced(subscribe, (result, streams) => {
2442
- if (result.subscribed) {
2443
- const data = streams.map(({ stream }) => stream).join(" ");
2444
- logger.trace(`${drain_caption("correlated")} ${data}`);
2445
2855
  }
2446
- })
2856
+ }
2857
+ return finalize(lease, handled, at, void 0, payloads[0].options, logger);
2858
+ };
2859
+ }
2860
+ function buildHandleBatch(logger) {
2861
+ return async (lease, payloads, batchHandler) => {
2862
+ const stream = lease.stream;
2863
+ const events = payloads.map((p) => p.event);
2864
+ const options = payloads[0].options;
2865
+ if (lease.retry > 0)
2866
+ logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
2867
+ try {
2868
+ await batchHandler(events, stream);
2869
+ return finalize(
2870
+ lease,
2871
+ events.length,
2872
+ events.at(-1).id,
2873
+ void 0,
2874
+ options,
2875
+ logger
2876
+ );
2877
+ } catch (error) {
2878
+ return finalize(lease, 0, lease.at, error, options, logger);
2879
+ }
2447
2880
  };
2448
2881
  }
2449
2882
 
2883
+ // src/internal/settle.ts
2884
+ var SettleLoop = class {
2885
+ constructor(deps, defaultDebounceMs) {
2886
+ this.deps = deps;
2887
+ this.defaultDebounceMs = defaultDebounceMs;
2888
+ }
2889
+ _timer = void 0;
2890
+ _running = false;
2891
+ /**
2892
+ * Schedule a settle pass. Multiple calls inside the debounce window
2893
+ * coalesce into one cycle. The cycle runs correlate→drain in a loop
2894
+ * until no progress is made (no new subscriptions, no acks, no blocks)
2895
+ * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
2896
+ * via {@link SettleDeps.onSettled}.
2897
+ */
2898
+ schedule(options = {}) {
2899
+ const {
2900
+ debounceMs = this.defaultDebounceMs,
2901
+ correlate: correlateQuery = { after: -1, limit: 100 },
2902
+ maxPasses = Infinity,
2903
+ ...drainOptions
2904
+ } = options;
2905
+ if (this._timer) clearTimeout(this._timer);
2906
+ this._timer = setTimeout(() => {
2907
+ this._timer = void 0;
2908
+ if (this._running) return;
2909
+ this._running = true;
2910
+ (async () => {
2911
+ await this.deps.init();
2912
+ let lastDrain;
2913
+ for (let i = 0; i < maxPasses; i++) {
2914
+ const { subscribed } = await this.deps.correlate({
2915
+ ...correlateQuery,
2916
+ after: this.deps.checkpoint()
2917
+ });
2918
+ lastDrain = await this.deps.drain(drainOptions);
2919
+ const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
2920
+ if (!made_progress) break;
2921
+ }
2922
+ if (lastDrain) this.deps.onSettled(lastDrain);
2923
+ })().catch((err) => this.deps.logger.error(err)).finally(() => {
2924
+ this._running = false;
2925
+ });
2926
+ }, debounceMs);
2927
+ }
2928
+ /** Cancel any pending or active settle cycle. Idempotent. */
2929
+ stop() {
2930
+ if (this._timer) {
2931
+ clearTimeout(this._timer);
2932
+ this._timer = void 0;
2933
+ }
2934
+ }
2935
+ };
2936
+
2450
2937
  // src/act.ts
2451
2938
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2452
2939
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
@@ -2461,11 +2948,26 @@ var Act = class {
2461
2948
  * @param _states Merged map of state name → state definition
2462
2949
  * @param batchHandlers Static-target projection batch handlers (target → handler)
2463
2950
  * @param options Tuning knobs — see {@link ActOptions}
2951
+ * @param lanes Declared drain lanes (ACT-1103). The builder collects
2952
+ * these from `.withLane(...)` calls. Slice 1 records them on the
2953
+ * instance; later slices fan out one `DrainController` per lane.
2464
2954
  */
2465
- constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
2955
+ constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}, lanes = []) {
2466
2956
  this.registry = registry;
2467
2957
  this._states = _states;
2468
2958
  this._batch_handlers = batchHandlers;
2959
+ this._lanes = lanes;
2960
+ if (options.onlyLanes && options.onlyLanes.length > 0) {
2961
+ const declared = /* @__PURE__ */ new Set([
2962
+ "default",
2963
+ ...lanes.map((l) => l.name)
2964
+ ]);
2965
+ const unknown = options.onlyLanes.filter((l) => !declared.has(l));
2966
+ if (unknown.length > 0)
2967
+ throw new Error(
2968
+ `ActOptions.onlyLanes references undeclared lane(s): ${unknown.map((l) => `"${l}"`).join(", ")}`
2969
+ );
2970
+ }
2469
2971
  this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
2470
2972
  this._correlator = options.correlator ?? defaultCorrelator;
2471
2973
  this._es = buildEs(this._logger, this._correlator);
@@ -2478,19 +2980,53 @@ var Act = class {
2478
2980
  boundQueryArray: this._bound_query_array
2479
2981
  });
2480
2982
  this._handle_batch = buildHandleBatch(this._logger);
2481
- const { staticTargets, hasDynamicResolvers, reactiveEvents, eventToState } = classifyRegistry(this.registry, this._states);
2983
+ const {
2984
+ staticTargets,
2985
+ hasDynamicResolvers,
2986
+ reactiveEvents,
2987
+ eventToState,
2988
+ eventToLanes
2989
+ } = classifyRegistry(this.registry, this._states);
2482
2990
  this._reactive_events = reactiveEvents;
2483
2991
  this._event_to_state = eventToState;
2484
- this._drain = new DrainController({
2992
+ this._event_to_lanes = eventToLanes;
2993
+ const allLanes = ["default", ...lanes.map((l) => l.name)];
2994
+ const onlySet = options.onlyLanes && options.onlyLanes.length > 0 ? new Set(options.onlyLanes) : void 0;
2995
+ const activeLanes = onlySet ? allLanes.filter((n) => onlySet.has(n)) : allLanes;
2996
+ const singleDefaultLane = activeLanes.length === 1 && activeLanes[0] === "default";
2997
+ this._drain_controllers = /* @__PURE__ */ new Map();
2998
+ for (const name of activeLanes) {
2999
+ const cfg = lanes.find((l) => l.name === name);
3000
+ const controller = new DrainController({
3001
+ logger: this._logger,
3002
+ ops: this._cd,
3003
+ registry: this.registry,
3004
+ batchHandlers: this._batch_handlers,
3005
+ handle: this._handle,
3006
+ handleBatch: this._handle_batch,
3007
+ onAcked: (acked) => this.emit("acked", acked),
3008
+ onBlocked: (blocked) => this.emit("blocked", blocked),
3009
+ // Pass lane only when a true per-lane controller is active.
3010
+ // The all-lanes (single default) case keeps lane=undefined so
3011
+ // adapter SQL collapses to the pre-1103 shape.
3012
+ lane: singleDefaultLane ? void 0 : name,
3013
+ defaults: cfg && {
3014
+ streamLimit: cfg.streamLimit,
3015
+ leaseMillis: cfg.leaseMillis
3016
+ }
3017
+ });
3018
+ if (cfg?.cycleMs !== void 0) controller.start(cfg.cycleMs);
3019
+ this._drain_controllers.set(name, controller);
3020
+ }
3021
+ this._audit_deps = {
3022
+ store: store2,
2485
3023
  logger: this._logger,
2486
- ops: this._cd,
2487
- registry: this.registry,
2488
- batchHandlers: this._batch_handlers,
2489
- handle: this._handle,
2490
- handleBatch: this._handle_batch,
2491
- onAcked: (acked) => this.emit("acked", acked),
2492
- onBlocked: (blocked) => this.emit("blocked", blocked)
2493
- });
3024
+ event_to_state: eventToState,
3025
+ states: this._states,
3026
+ known_events: new Set(eventToState.keys()),
3027
+ declared_lanes: new Set(this._drain_controllers.keys()),
3028
+ routed_events: new Set(eventToLanes.keys())
3029
+ };
2494
3030
  this._correlate = new CorrelateCycle(
2495
3031
  this.registry,
2496
3032
  staticTargets,
@@ -2499,7 +3035,7 @@ var Act = class {
2499
3035
  options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
2500
3036
  // Cold start: assume drain is needed (historical events may need processing)
2501
3037
  () => {
2502
- if (this._reactive_events.size > 0) this._drain.arm();
3038
+ if (this._reactive_events.size > 0) this._armAll();
2503
3039
  }
2504
3040
  );
2505
3041
  this._settle = new SettleLoop(
@@ -2519,8 +3055,8 @@ var Act = class {
2519
3055
  _emitter = new import_node_events.default();
2520
3056
  /** Event names with at least one registered reaction (computed at build time) */
2521
3057
  _reactive_events;
2522
- /** Drain pipeline driver: armed flag, concurrency lock, adaptive ratio. */
2523
- _drain;
3058
+ /** One DrainController per active lane, keyed by lane name. */
3059
+ _drain_controllers;
2524
3060
  /** Correlation state machine: lazy init, dynamic-resolver scan, periodic worker. */
2525
3061
  _correlate;
2526
3062
  /** Debounced correlate→drain catch-up loop. */
@@ -2574,6 +3110,22 @@ var Act = class {
2574
3110
  * set when seeding a `restart` snapshot in multi-state apps.
2575
3111
  */
2576
3112
  _event_to_state;
3113
+ /**
3114
+ * Event-name → lane fan-in for selective arming (ACT-1103). Built by
3115
+ * `classifyRegistry` once per build. `"all"` means at least one of
3116
+ * the event's reactions is a dynamic resolver (lane opaque until
3117
+ * runtime); a `Set<string>` lists the static lanes only that event's
3118
+ * reactions target.
3119
+ */
3120
+ _event_to_lanes;
3121
+ /**
3122
+ * Audit dependency bag (#723). Built once at construction; held as
3123
+ * an immutable snapshot of the registry state the audit module
3124
+ * needs. Lives in `internal/audit.ts` — this orchestrator never
3125
+ * carries audit logic, only the deps + a one-liner that hands them
3126
+ * over.
3127
+ */
3128
+ _audit_deps;
2577
3129
  /** Logger resolved at construction time (after user port configuration) */
2578
3130
  _logger = log();
2579
3131
  /** Wraps a public-method body so internal `store()`/`cache()` resolve to the
@@ -2597,6 +3149,12 @@ var Act = class {
2597
3149
  /** Reaction dispatchers built once and handed to runDrainCycle each cycle. */
2598
3150
  _handle;
2599
3151
  _handle_batch;
3152
+ /** Declared drain lanes (ACT-1103). */
3153
+ _lanes;
3154
+ /** Drain lanes declared via `.withLane(...)`. Implicit default not included. */
3155
+ get lanes() {
3156
+ return this._lanes;
3157
+ }
2600
3158
  /** True after the first `shutdown()` call. Guards idempotency. */
2601
3159
  _shutdown_promise;
2602
3160
  /**
@@ -2615,6 +3173,7 @@ var Act = class {
2615
3173
  this._emitter.removeAllListeners();
2616
3174
  this.stop_correlations();
2617
3175
  this.stop_settling();
3176
+ for (const c of this._drain_controllers.values()) c.stop();
2618
3177
  const disposer = await this._notify_disposer;
2619
3178
  if (disposer) await disposer();
2620
3179
  })();
@@ -2634,13 +3193,10 @@ var Act = class {
2634
3193
  return await s.notify((notification) => {
2635
3194
  try {
2636
3195
  this.emit("notified", notification);
2637
- const hasReactive = notification.events.some(
2638
- (e) => this._reactive_events.has(e.name)
3196
+ const armed = this._armForEventNames(
3197
+ notification.events.map((e) => e.name)
2639
3198
  );
2640
- if (hasReactive) {
2641
- this._drain.arm();
2642
- this._settle.schedule({ debounceMs: 0 });
2643
- }
3199
+ if (armed) this._settle.schedule({ debounceMs: 0 });
2644
3200
  } catch (err) {
2645
3201
  this._logger.error(err, "notified handler threw");
2646
3202
  }
@@ -2741,14 +3297,10 @@ var Act = class {
2741
3297
  reactingTo,
2742
3298
  skipValidation
2743
3299
  );
2744
- if (this._reactive_events.size > 0) {
2745
- for (const snap2 of snapshots) {
2746
- if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
2747
- this._drain.arm();
2748
- break;
2749
- }
2750
- }
2751
- }
3300
+ if (this._reactive_events.size > 0)
3301
+ this._armForEventNames(
3302
+ snapshots.map((s) => s.event.name)
3303
+ );
2752
3304
  this.emit("committed", snapshots);
2753
3305
  return snapshots;
2754
3306
  });
@@ -2895,7 +3447,59 @@ var Act = class {
2895
3447
  * @see {@link start_correlations} for automatic correlation
2896
3448
  */
2897
3449
  async drain(options = {}) {
2898
- return this._scoped(() => this._drain.drain(options));
3450
+ return this._scoped(() => this._drainAll(options));
3451
+ }
3452
+ /** Arm every active lane controller (ACT-1103). */
3453
+ _armAll() {
3454
+ for (const c of this._drain_controllers.values()) c.arm();
3455
+ }
3456
+ /**
3457
+ * Arm only the lane controllers whose reactions match the supplied
3458
+ * event names (ACT-1103 selective arming). Events with any dynamic
3459
+ * resolver fall back to `_armAll()` via the `"all"` sentinel — the
3460
+ * resolver's lane isn't known until correlate runs the function.
3461
+ * Events with no reactions are skipped; `_event_to_lanes` doesn't
3462
+ * carry them. Returns true when any controller was armed (used by
3463
+ * the notify handler to decide whether to schedule a settle).
3464
+ */
3465
+ _armForEventNames(names) {
3466
+ const to_arm = /* @__PURE__ */ new Set();
3467
+ for (const name of names) {
3468
+ const set = this._event_to_lanes.get(name);
3469
+ if (set === void 0) continue;
3470
+ if (set === ALL_LANES) {
3471
+ this._armAll();
3472
+ return true;
3473
+ }
3474
+ for (const lane of set) to_arm.add(lane);
3475
+ }
3476
+ if (to_arm.size === 0) return false;
3477
+ for (const lane of to_arm) this._drain_controllers.get(lane)?.arm();
3478
+ return true;
3479
+ }
3480
+ /** Drain every active lane controller in parallel and aggregate.
3481
+ *
3482
+ * Parallel — not sequential — so a slow lane's in-flight handler does
3483
+ * not block a fast lane's claim/dispatch/ack cycle. Each controller's
3484
+ * `claim()` is independent (filtered by lane); the store's
3485
+ * `SKIP LOCKED` keeps cross-controller races safe. Lifecycle events
3486
+ * (`acked`, `blocked`) may interleave by lane — listeners filter via
3487
+ * `lease.lane`. */
3488
+ async _drainAll(options) {
3489
+ const results = await Promise.all(
3490
+ [...this._drain_controllers.values()].map((c) => c.drain(options))
3491
+ );
3492
+ const fetched = [];
3493
+ const leased = [];
3494
+ const acked = [];
3495
+ const blocked = [];
3496
+ for (const r of results) {
3497
+ fetched.push(...r.fetched);
3498
+ leased.push(...r.leased);
3499
+ acked.push(...r.acked);
3500
+ blocked.push(...r.blocked);
3501
+ }
3502
+ return { fetched, leased, acked, blocked };
2899
3503
  }
2900
3504
  /**
2901
3505
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -3066,7 +3670,7 @@ var Act = class {
3066
3670
  async reset(input) {
3067
3671
  return this._scoped(async () => {
3068
3672
  const count = await store2().reset(input);
3069
- if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
3673
+ if (count > 0 && this._reactive_events.size > 0) this._armAll();
3070
3674
  return count;
3071
3675
  });
3072
3676
  }
@@ -3100,7 +3704,7 @@ var Act = class {
3100
3704
  async unblock(input) {
3101
3705
  return this._scoped(async () => {
3102
3706
  const count = await store2().unblock(input);
3103
- if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
3707
+ if (count > 0 && this._reactive_events.size > 0) this._armAll();
3104
3708
  return count;
3105
3709
  });
3106
3710
  }
@@ -3138,6 +3742,50 @@ var Act = class {
3138
3742
  return positions;
3139
3743
  });
3140
3744
  }
3745
+ /**
3746
+ * Operator-driven store audit (#723).
3747
+ *
3748
+ * Walks the connected store and yields per-category findings —
3749
+ * each tagged with the remediation it suggests. Same operator-
3750
+ * driven category as `app.close()` / `app.reset()` /
3751
+ * `app.unblock()` / `app.blocked_streams()`: never auto-invoked by
3752
+ * the framework; the operator decides when to run it (CI gate,
3753
+ * scheduled job, ad-hoc forensics) and what to do with the
3754
+ * findings.
3755
+ *
3756
+ * Categories are independent — pass a subset to scope the work,
3757
+ * or omit to run everything:
3758
+ *
3759
+ * ```typescript
3760
+ * // Targeted: schema drift + deprecated-event load only
3761
+ * for await (const f of app.audit(["schema", "deprecated-load"], {
3762
+ * query: { created_after: lastScan },
3763
+ * thresholds: { deprecatedLoadShareMin: 0.10 },
3764
+ * })) {
3765
+ * await escalate(f);
3766
+ * }
3767
+ *
3768
+ * // Full audit, default thresholds
3769
+ * for await (const f of app.audit()) console.log(f);
3770
+ * ```
3771
+ *
3772
+ * Returns an `AsyncIterable` so callers can `break` early — the
3773
+ * underlying store paginations respect the iterator protocol and
3774
+ * stop cleanly. Each finding is emitted independently, so
3775
+ * pipelining into Slack / persistence / further analysis works
3776
+ * without buffering the full report in memory.
3777
+ *
3778
+ * Findings shape — see {@link AuditFinding}. The discriminated
3779
+ * union carries enough context for the operator to act on each
3780
+ * finding directly: stream id, event id, recommendation hints.
3781
+ *
3782
+ * @param categories - Subset of categories to run (default: all).
3783
+ * @param options - Query window + per-category thresholds.
3784
+ * @returns Async iterable of {@link AuditFinding}.
3785
+ */
3786
+ audit(categories, options) {
3787
+ return audit(this._audit_deps, categories, options);
3788
+ }
3141
3789
  /**
3142
3790
  * Bulk-update scheduling priority for streams matching `filter`.
3143
3791
  *
@@ -3273,6 +3921,22 @@ function registerBatchHandler(proj, batchHandlers) {
3273
3921
  }
3274
3922
  batchHandlers.set(proj.target, proj.batchHandler);
3275
3923
  }
3924
+ function validateLaneReferences(registry, lanes) {
3925
+ const declared = /* @__PURE__ */ new Set([DEFAULT_LANE, ...lanes.map((l) => l.name)]);
3926
+ for (const [eventName, def] of Object.entries(registry.events)) {
3927
+ const entry = def;
3928
+ for (const [handlerName, reaction] of entry.reactions) {
3929
+ const resolver = reaction.resolver;
3930
+ if (typeof resolver === "function") continue;
3931
+ const lane = resolver.lane;
3932
+ if (lane && !declared.has(lane)) {
3933
+ throw new Error(
3934
+ `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.`
3935
+ );
3936
+ }
3937
+ }
3938
+ }
3939
+ }
3276
3940
  function act() {
3277
3941
  const states = /* @__PURE__ */ new Map();
3278
3942
  const registry = {
@@ -3281,6 +3945,7 @@ function act() {
3281
3945
  };
3282
3946
  const pendingProjections = [];
3283
3947
  const batchHandlers = /* @__PURE__ */ new Map();
3948
+ const lanes = [];
3284
3949
  let _built = false;
3285
3950
  const finalizeDeprecations = () => {
3286
3951
  const deprecationSummary = [];
@@ -3327,6 +3992,18 @@ function act() {
3327
3992
  }
3328
3993
  mergeEventRegister(registry.events, input.events);
3329
3994
  pendingProjections.push(...input.projections);
3995
+ for (const sliceLane of input.lanes) {
3996
+ const existing = lanes.find((l) => l.name === sliceLane.name);
3997
+ if (!existing) {
3998
+ lanes.push(sliceLane);
3999
+ continue;
4000
+ }
4001
+ if (existing.leaseMillis !== sliceLane.leaseMillis || existing.streamLimit !== sliceLane.streamLimit || existing.cycleMs !== sliceLane.cycleMs) {
4002
+ throw new Error(
4003
+ `Lane "${sliceLane.name}" was already declared with a different config`
4004
+ );
4005
+ }
4006
+ }
3330
4007
  return builder;
3331
4008
  },
3332
4009
  withProjection: (proj) => {
@@ -3335,6 +4012,14 @@ function act() {
3335
4012
  return builder;
3336
4013
  },
3337
4014
  withActor: () => builder,
4015
+ withLane: (config2) => {
4016
+ if (config2.name === DEFAULT_LANE)
4017
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
4018
+ if (lanes.some((l) => l.name === config2.name))
4019
+ throw new Error(`Lane "${config2.name}" was already declared`);
4020
+ lanes.push(config2);
4021
+ return builder;
4022
+ },
3338
4023
  on: (event) => ({
3339
4024
  do: (handler, options) => {
3340
4025
  const reaction = {
@@ -3366,13 +4051,15 @@ function act() {
3366
4051
  registerBatchHandler(proj, batchHandlers);
3367
4052
  }
3368
4053
  finalizeDeprecations();
4054
+ validateLaneReferences(registry, lanes);
3369
4055
  _built = true;
3370
4056
  }
3371
4057
  return new Act(
3372
4058
  registry,
3373
4059
  states,
3374
4060
  batchHandlers,
3375
- options
4061
+ options,
4062
+ lanes
3376
4063
  );
3377
4064
  },
3378
4065
  events: registry.events
@@ -3453,6 +4140,7 @@ function slice() {
3453
4140
  const actions = {};
3454
4141
  const events = {};
3455
4142
  const projections = [];
4143
+ const lanes = [];
3456
4144
  const builder = {
3457
4145
  withState: (state2) => {
3458
4146
  registerState(state2, states, actions, events);
@@ -3462,6 +4150,14 @@ function slice() {
3462
4150
  projections.push(proj);
3463
4151
  return builder;
3464
4152
  },
4153
+ withLane: (config2) => {
4154
+ if (config2.name === DEFAULT_LANE)
4155
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
4156
+ if (lanes.some((l) => l.name === config2.name))
4157
+ throw new Error(`Lane "${config2.name}" was already declared`);
4158
+ lanes.push(config2);
4159
+ return builder;
4160
+ },
3465
4161
  on: (event) => ({
3466
4162
  do: (handler, options) => {
3467
4163
  const reaction = {
@@ -3490,7 +4186,8 @@ function slice() {
3490
4186
  _tag: "Slice",
3491
4187
  states,
3492
4188
  events,
3493
- projections
4189
+ projections,
4190
+ lanes
3494
4191
  }),
3495
4192
  events
3496
4193
  };
@@ -3584,6 +4281,7 @@ function action_builder(state2) {
3584
4281
  CommittedMetaSchema,
3585
4282
  ConcurrencyError,
3586
4283
  ConsoleLogger,
4284
+ DEFAULT_LANE,
3587
4285
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
3588
4286
  DEFAULT_SETTLE_DEBOUNCE_MS,
3589
4287
  Environments,