@rotorsoft/act 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +87 -379
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/act.d.ts +43 -5
  4. package/dist/@types/act.d.ts.map +1 -1
  5. package/dist/@types/adapters/console-logger.d.ts.map +1 -1
  6. package/dist/@types/adapters/in-memory-store.d.ts +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/build-classify.d.ts +20 -0
  13. package/dist/@types/internal/build-classify.d.ts.map +1 -1
  14. package/dist/@types/internal/correlate-cycle.d.ts +1 -0
  15. package/dist/@types/internal/correlate-cycle.d.ts.map +1 -1
  16. package/dist/@types/internal/drain-cycle.d.ts +43 -3
  17. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  18. package/dist/@types/internal/drain.d.ts +3 -1
  19. package/dist/@types/internal/drain.d.ts.map +1 -1
  20. package/dist/@types/internal/index.d.ts +3 -2
  21. package/dist/@types/internal/index.d.ts.map +1 -1
  22. package/dist/@types/internal/reactions.d.ts.map +1 -1
  23. package/dist/@types/internal/tracing.d.ts +51 -0
  24. package/dist/@types/internal/tracing.d.ts.map +1 -1
  25. package/dist/@types/ports.d.ts +10 -0
  26. package/dist/@types/ports.d.ts.map +1 -1
  27. package/dist/@types/test/sandbox.d.ts +1 -1
  28. package/dist/@types/test/sandbox.d.ts.map +1 -1
  29. package/dist/@types/types/ports.d.ts +9 -2
  30. package/dist/@types/types/ports.d.ts.map +1 -1
  31. package/dist/@types/types/reaction.d.ts +20 -2
  32. package/dist/@types/types/reaction.d.ts.map +1 -1
  33. package/dist/{chunk-LKRNWD7C.js → chunk-PGTC7VOC.js} +46 -11
  34. package/dist/chunk-PGTC7VOC.js.map +1 -0
  35. package/dist/index.cjs +1139 -884
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.js +1097 -876
  38. package/dist/index.js.map +1 -1
  39. package/dist/test/index.cjs +45 -11
  40. package/dist/test/index.cjs.map +1 -1
  41. package/dist/test/index.js +3 -3
  42. package/dist/test/index.js.map +1 -1
  43. package/package.json +2 -2
  44. package/dist/chunk-LKRNWD7C.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  ConsoleLogger,
3
+ DEFAULT_LANE,
3
4
  ExitCodes,
4
5
  InMemoryCache,
5
6
  InMemoryStore,
@@ -18,7 +19,7 @@ import {
18
19
  sleep,
19
20
  store,
20
21
  validate
21
- } from "./chunk-LKRNWD7C.js";
22
+ } from "./chunk-PGTC7VOC.js";
22
23
  import {
23
24
  ActorSchema,
24
25
  CausationEventSchema,
@@ -60,23 +61,39 @@ process.once("unhandledRejection", async (arg) => {
60
61
  import EventEmitter from "events";
61
62
 
62
63
  // src/internal/build-classify.ts
64
+ var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
63
65
  function classifyRegistry(registry, states) {
64
66
  const statics = /* @__PURE__ */ new Map();
65
67
  const reactiveEvents = /* @__PURE__ */ new Set();
68
+ const eventToLanes = /* @__PURE__ */ new Map();
66
69
  let hasDynamicResolvers = false;
67
70
  for (const [name, register] of Object.entries(registry.events)) {
68
71
  if (register.reactions.size > 0) reactiveEvents.add(name);
69
72
  for (const reaction of register.reactions.values()) {
70
73
  if (typeof reaction.resolver === "function") {
71
74
  hasDynamicResolvers = true;
75
+ eventToLanes.set(name, ALL_LANES);
72
76
  } else {
73
- const { target, source, priority = 0 } = reaction.resolver;
77
+ const { target, source, priority = 0, lane } = reaction.resolver;
78
+ const lane_name = lane ?? "default";
79
+ const existing_lanes = eventToLanes.get(name);
80
+ if (existing_lanes !== ALL_LANES) {
81
+ const set = existing_lanes ?? /* @__PURE__ */ new Set();
82
+ set.add(lane_name);
83
+ eventToLanes.set(name, set);
84
+ }
74
85
  const key = `${target}|${source ?? ""}`;
75
86
  const existing = statics.get(key);
76
87
  if (!existing) {
77
- statics.set(key, { stream: target, source, priority });
78
- } else if (priority > existing.priority) {
79
- statics.set(key, { ...existing, priority });
88
+ statics.set(key, { stream: target, source, priority, lane });
89
+ } else {
90
+ if ((existing.lane ?? void 0) !== (lane ?? void 0))
91
+ throw new Error(
92
+ `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
93
+ );
94
+ if (priority > existing.priority) {
95
+ statics.set(key, { ...existing, priority });
96
+ }
80
97
  }
81
98
  }
82
99
  }
@@ -91,7 +108,8 @@ function classifyRegistry(registry, states) {
91
108
  staticTargets: [...statics.values()],
92
109
  hasDynamicResolvers,
93
110
  reactiveEvents,
94
- eventToState
111
+ eventToState,
112
+ eventToLanes
95
113
  };
96
114
  }
97
115
 
@@ -302,6 +320,7 @@ var CorrelateCycle = class {
302
320
  const entry = correlated.get(resolved.target) || {
303
321
  source: resolved.source,
304
322
  priority: incomingPriority,
323
+ lane: resolved.lane,
305
324
  payloads: []
306
325
  };
307
326
  if (incomingPriority > entry.priority)
@@ -320,10 +339,11 @@ var CorrelateCycle = class {
320
339
  );
321
340
  if (correlated.size) {
322
341
  const streams = [...correlated.entries()].map(
323
- ([stream, { source, priority }]) => ({
342
+ ([stream, { source, priority, lane }]) => ({
324
343
  stream,
325
344
  source,
326
- priority
345
+ priority,
346
+ lane
327
347
  })
328
348
  );
329
349
  const { subscribed } = await this.cd.subscribe(streams);
@@ -408,904 +428,955 @@ function computeLagLeadRatio(handled, lagging, leading) {
408
428
  return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
409
429
  }
410
430
 
411
- // src/internal/drain-cycle.ts
412
- async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred) {
413
- const leased = await ops.claim(lagging, leading, randomUUID(), leaseMillis);
414
- if (!leased.length) return void 0;
415
- const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
416
- if (!active.length) {
417
- return {
418
- leased,
419
- fetched: [],
420
- handled: [],
421
- acked: [],
422
- blocked: []
423
- };
424
- }
425
- const fetched = await ops.fetch(active, eventLimit);
426
- const fetchMap = /* @__PURE__ */ new Map();
427
- const fetch_window_at = fetched.reduce(
428
- (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
429
- 0
431
+ // src/internal/drain.ts
432
+ var claim = (lagging, leading, by, millis, lane) => store().claim(lagging, leading, by, millis, lane);
433
+ async function fetch(leased, eventLimit) {
434
+ return Promise.all(
435
+ leased.map(async ({ stream, source, at, lagging }) => {
436
+ const events = [];
437
+ await store().query((e) => events.push(e), {
438
+ stream: source,
439
+ after: at,
440
+ limit: eventLimit
441
+ });
442
+ return { stream, source, at, lagging, events };
443
+ })
430
444
  );
431
- for (const f of fetched) {
432
- const { stream, events } = f;
433
- const payloads = events.flatMap((event) => {
434
- const register = registry.events[event.name];
435
- if (!register) return [];
436
- return [...register.reactions.values()].filter((reaction) => {
437
- const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
438
- return resolved && resolved.target === stream;
439
- }).map((reaction) => ({ ...reaction, event }));
440
- });
441
- fetchMap.set(stream, { fetch: f, payloads });
445
+ }
446
+ var ack = (leases) => store().ack(leases);
447
+ var block = (leases) => store().block(leases);
448
+ var subscribe = (streams) => store().subscribe(streams);
449
+
450
+ // src/internal/event-sourcing.ts
451
+ import { patch } from "@rotorsoft/act-patch";
452
+ async function snap(snapshot) {
453
+ try {
454
+ const { id, stream, name, meta, version } = snapshot.event;
455
+ await store().commit(
456
+ stream,
457
+ [{ name: SNAP_EVENT, data: snapshot.state }],
458
+ {
459
+ correlation: meta.correlation,
460
+ causation: { event: { id, name, stream } }
461
+ },
462
+ version
463
+ // IMPORTANT! - state events are committed right after the snapshot event
464
+ );
465
+ } catch (error) {
466
+ log().error(error);
442
467
  }
443
- const handled = await Promise.all(
444
- active.map((lease) => {
445
- const entry = fetchMap.get(lease.stream);
446
- const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
447
- const { payloads } = entry;
448
- const batchHandler = batchHandlers.get(lease.stream);
449
- if (batchHandler && payloads.length > 0) {
450
- return handleBatch({ ...lease, at }, payloads, batchHandler);
468
+ }
469
+ async function tombstone(stream, expectedVersion, correlation) {
470
+ try {
471
+ const [committed] = await store().commit(
472
+ stream,
473
+ [{ name: TOMBSTONE_EVENT, data: {} }],
474
+ { correlation, causation: {} },
475
+ expectedVersion
476
+ );
477
+ return committed;
478
+ } catch (error) {
479
+ if (error instanceof ConcurrencyError) return void 0;
480
+ throw error;
481
+ }
482
+ }
483
+ async function load(me, stream, callback, asOf) {
484
+ const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
485
+ const cached = timeTravel ? void 0 : await cache().get(stream);
486
+ const cache_hit = !!cached;
487
+ let state2 = cached?.state ?? (me.init ? me.init() : {});
488
+ let patches = cached?.patches ?? 0;
489
+ let snaps = cached?.snaps ?? 0;
490
+ let version = cached?.version ?? -1;
491
+ let replayed = 0;
492
+ let event;
493
+ await store().query(
494
+ (e) => {
495
+ event = e;
496
+ version = e.version;
497
+ if (e.name === SNAP_EVENT) {
498
+ state2 = e.data;
499
+ snaps++;
500
+ patches = 0;
501
+ replayed++;
502
+ } else if (me.patch[e.name]) {
503
+ state2 = patch(state2, me.patch[e.name](event, state2));
504
+ patches++;
505
+ replayed++;
506
+ } else if (e.name !== TOMBSTONE_EVENT) {
507
+ log().warn(
508
+ `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
509
+ );
451
510
  }
452
- return handle({ ...lease, at }, payloads);
453
- })
454
- );
455
- const acked = await ops.ack(
456
- handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
457
- );
458
- const blocked = await ops.block(
459
- handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
511
+ callback?.({
512
+ event,
513
+ state: state2,
514
+ version,
515
+ patches,
516
+ snaps,
517
+ cache_hit,
518
+ replayed
519
+ });
520
+ },
521
+ {
522
+ stream,
523
+ stream_exact: true,
524
+ ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
525
+ }
460
526
  );
461
- return { leased, fetched, handled, acked, blocked };
462
- }
463
- var EMPTY_DRAIN = {
464
- fetched: [],
465
- leased: [],
466
- acked: [],
467
- blocked: []
468
- };
469
- var DrainController = class {
470
- constructor(deps) {
471
- this.deps = deps;
527
+ if (replayed > 0 && !timeTravel && event) {
528
+ await cache().set(stream, {
529
+ state: state2,
530
+ version,
531
+ event_id: event.id,
532
+ patches,
533
+ snaps
534
+ });
472
535
  }
473
- _armed = false;
474
- _locked = false;
475
- _ratio = 0.5;
476
- /**
477
- * Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
478
- * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
479
- * ack or terminal block. Lives in process memory — per-worker pacing
480
- * by design (see {@link BackoffOptions} for the multi-worker trade-off).
481
- */
482
- _backoff = /* @__PURE__ */ new Map();
483
- /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
484
- _backoffTimer;
485
- /**
486
- * Signal that a commit (or reset / cold-start) may have produced work.
487
- * Subsequent `drain()` calls will run the pipeline; once the pipeline
488
- * settles to no-progress, the controller disarms itself.
489
- */
490
- arm() {
491
- this._armed = true;
536
+ return { event, state: state2, version, patches, snaps, cache_hit, replayed };
537
+ }
538
+ async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
539
+ const { stream, expectedVersion, actor } = target;
540
+ if (!stream) throw new Error("Missing target stream");
541
+ const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
542
+ const snapshot = await load(me, stream);
543
+ if (snapshot.event?.name === TOMBSTONE_EVENT)
544
+ throw new StreamClosedError(stream);
545
+ const expected = expectedVersion ?? snapshot.event?.version;
546
+ if (me.given) {
547
+ const invariants = me.given[action2] || [];
548
+ invariants.forEach(({ valid, description }) => {
549
+ if (!valid(snapshot.state, actor))
550
+ throw new InvariantError(
551
+ action2,
552
+ validated,
553
+ target,
554
+ snapshot,
555
+ description
556
+ );
557
+ });
492
558
  }
493
- /** Read-only flag true while a commit / reset is unprocessed. */
494
- get armed() {
495
- return this._armed;
559
+ const result = me.on[action2](validated, snapshot, target);
560
+ if (!result) return [snapshot];
561
+ if (Array.isArray(result) && result.length === 0) {
562
+ return [snapshot];
496
563
  }
497
- /** Returns true when `stream` is currently within a backoff window. */
498
- isDeferred = (stream) => {
499
- const next = this._backoff.get(stream);
500
- return next !== void 0 && next > Date.now();
501
- };
502
- /**
503
- * Schedule the next drain re-arm at the earliest pending backoff
504
- * expiry. Called only when the backoff map is non-empty (caller guard).
505
- * Idempotent — collapses many simultaneously deferred streams into a
506
- * single timer.
507
- */
508
- scheduleBackoffWake() {
509
- if (this._backoffTimer) clearTimeout(this._backoffTimer);
510
- let earliest = Number.POSITIVE_INFINITY;
511
- for (const t of this._backoff.values()) if (t < earliest) earliest = t;
512
- const delay = Math.max(0, earliest - Date.now());
513
- this._backoffTimer = setTimeout(() => {
514
- this._backoffTimer = void 0;
515
- const now = Date.now();
516
- for (const [stream, at] of this._backoff) {
517
- if (at <= now) this._backoff.delete(stream);
564
+ const tuples = Array.isArray(result[0]) ? result : [result];
565
+ const deprecated = me._deprecated;
566
+ if (deprecated && deprecated.size > 0) {
567
+ const me_ = me;
568
+ const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
569
+ for (const [name] of tuples) {
570
+ const evt = name;
571
+ if (deprecated.has(evt) && !warned.has(evt)) {
572
+ warned.add(evt);
573
+ log().warn(
574
+ `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)`
575
+ );
518
576
  }
519
- this._armed = true;
520
- }, delay);
521
- this._backoffTimer.unref();
577
+ }
522
578
  }
523
- /** Run one drain pass. Short-circuits when not armed or already running. */
524
- async drain({
525
- streamLimit = 10,
526
- eventLimit = 10,
527
- leaseMillis = 1e4
528
- } = {}) {
529
- if (!this._armed) return EMPTY_DRAIN;
530
- if (this._locked) return EMPTY_DRAIN;
531
- try {
532
- this._locked = true;
533
- const lagging = Math.ceil(streamLimit * this._ratio);
534
- const leading = streamLimit - lagging;
535
- const cycle = await runDrainCycle(
536
- this.deps.ops,
537
- this.deps.registry,
538
- this.deps.batchHandlers,
539
- this.deps.handle,
540
- this.deps.handleBatch,
541
- lagging,
542
- leading,
543
- eventLimit,
544
- leaseMillis,
545
- this._backoff.size > 0 ? this.isDeferred : void 0
546
- );
547
- if (!cycle) {
548
- this._armed = false;
549
- return EMPTY_DRAIN;
550
- }
551
- const { leased, fetched, handled, acked, blocked } = cycle;
552
- this._ratio = computeLagLeadRatio(handled, lagging, leading);
553
- for (const lease of acked) this._backoff.delete(lease.stream);
554
- for (const lease of blocked) this._backoff.delete(lease.stream);
555
- for (const h of handled) {
556
- if (h.nextAttemptAt !== void 0 && !h.block) {
557
- this._backoff.set(h.lease.stream, h.nextAttemptAt);
558
- }
559
- }
560
- if (this._backoff.size > 0) this.scheduleBackoffWake();
561
- if (acked.length) this.deps.onAcked(acked);
562
- if (blocked.length) this.deps.onBlocked(blocked);
563
- const hasErrors = handled.some(({ error }) => error);
564
- if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
565
- return { fetched, leased, acked, blocked };
566
- } catch (error) {
567
- this.deps.logger.error(error);
568
- return EMPTY_DRAIN;
569
- } finally {
570
- this._locked = false;
571
- }
572
- }
573
- };
574
-
575
- // src/internal/event-versions.ts
576
- var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
577
- function parse(name) {
578
- const m = name.match(VERSION_SUFFIX);
579
- if (m) {
580
- const v = Number.parseInt(m[2], 10);
581
- if (v >= 2) return { base: m[1], version: v };
582
- }
583
- return { base: name, version: 1 };
584
- }
585
- function deprecatedEventNames(names) {
586
- const groups = /* @__PURE__ */ new Map();
587
- for (const name of names) {
588
- const { base, version } = parse(name);
589
- const list = groups.get(base);
590
- if (list) list.push({ version, name });
591
- else groups.set(base, [{ version, name }]);
592
- }
593
- const deprecated = /* @__PURE__ */ new Set();
594
- for (const list of groups.values()) {
595
- if (list.length < 2) continue;
596
- list.sort((a, b) => b.version - a.version);
597
- for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
598
- }
599
- return deprecated;
600
- }
601
- function currentVersionOf(deprecatedName, allNames) {
602
- const target = parse(deprecatedName);
603
- let highest;
604
- for (const name of allNames) {
605
- const { base, version } = parse(name);
606
- if (base !== target.base) continue;
607
- if (!highest || version > highest.version) highest = { version, name };
608
- }
609
- return highest && highest.version > target.version ? highest.name : void 0;
610
- }
611
-
612
- // src/internal/merge.ts
613
- import { ZodObject } from "zod";
614
- function baseTypeName(zodType) {
615
- let t = zodType;
616
- while (typeof t.unwrap === "function") {
617
- t = t.unwrap();
618
- }
619
- return t.constructor.name;
620
- }
621
- function mergeSchemas(existing, incoming, stateName) {
622
- if (existing instanceof ZodObject && incoming instanceof ZodObject) {
623
- const existingShape = existing.shape;
624
- const incomingShape = incoming.shape;
625
- for (const key of Object.keys(incomingShape)) {
626
- if (key in existingShape) {
627
- const existingBase = baseTypeName(existingShape[key]);
628
- const incomingBase = baseTypeName(incomingShape[key]);
629
- if (existingBase !== incomingBase) {
630
- throw new Error(
631
- `Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
632
- );
633
- }
634
- }
635
- }
636
- return existing.extend(incomingShape);
637
- }
638
- return existing;
639
- }
640
- function mergeInits(existing, incoming) {
641
- return () => ({ ...existing(), ...incoming() });
642
- }
643
- function registerState(state2, states, actions, events) {
644
- const existing = states.get(state2.name);
645
- if (existing) {
646
- mergeIntoExisting(state2, existing, states, actions, events);
647
- } else {
648
- registerNewState(state2, states, actions, events);
649
- }
650
- }
651
- function registerNewState(state2, states, actions, events) {
652
- states.set(state2.name, state2);
653
- for (const name of Object.keys(state2.actions)) {
654
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
655
- actions[name] = state2;
656
- }
657
- for (const name of Object.keys(state2.events)) {
658
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
659
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
660
- }
661
- }
662
- function mergeIntoExisting(state2, existing, states, actions, events) {
663
- for (const name of Object.keys(state2.actions)) {
664
- if (existing.actions[name] === state2.actions[name]) continue;
665
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
666
- }
667
- for (const name of Object.keys(state2.events)) {
668
- if (existing.events[name] === state2.events[name]) continue;
669
- if (existing.events[name]) {
670
- throw new Error(
671
- `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.`
672
- );
579
+ const emitted = tuples.map(([name, data]) => ({
580
+ name,
581
+ data: skipValidation ? data : validate(name, data, me.events[name])
582
+ }));
583
+ const meta = {
584
+ correlation: reactingTo?.meta.correlation || correlator({
585
+ action: action2,
586
+ state: me.name,
587
+ stream,
588
+ actor: target.actor
589
+ }),
590
+ causation: {
591
+ action: {
592
+ name: action2,
593
+ ...target
594
+ // payload intentionally omitted: it can be large or contain PII,
595
+ // and callers correlate via the correlation id when they need it.
596
+ },
597
+ event: reactingTo ? {
598
+ id: reactingTo.id,
599
+ name: reactingTo.name,
600
+ stream: reactingTo.stream
601
+ } : void 0
673
602
  }
674
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
675
- }
676
- const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
677
- const merged = {
678
- ...existing,
679
- state: mergeSchemas(existing.state, state2.state, state2.name),
680
- init: mergeInits(existing.init, state2.init),
681
- events: { ...existing.events, ...state2.events },
682
- actions: { ...existing.actions, ...state2.actions },
683
- patch: mergedPatch,
684
- on: { ...existing.on, ...state2.on },
685
- given: { ...existing.given, ...state2.given },
686
- snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
687
- throw new Error(
688
- `Duplicate snap strategy for state "${state2.name}"`
689
- );
690
- })() : state2.snap || existing.snap
691
603
  };
692
- states.set(state2.name, merged);
693
- for (const name of Object.keys(merged.actions)) {
694
- actions[name] = merged;
695
- }
696
- for (const name of Object.keys(state2.events)) {
697
- if (events[name]) continue;
698
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
699
- }
700
- }
701
- function mergePatches(existing, incoming, stateName) {
702
- const merged = { ...existing };
703
- for (const name of Object.keys(incoming)) {
704
- const existingP = existing[name];
705
- const incomingP = incoming[name];
706
- if (!existingP) {
707
- merged[name] = incomingP;
708
- continue;
709
- }
710
- const existingIsDefault = existingP._passthrough;
711
- const incomingIsDefault = incomingP._passthrough;
712
- if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
713
- throw new Error(
714
- `Duplicate custom patch for event "${name}" in state "${stateName}"`
715
- );
716
- }
717
- if (existingIsDefault && !incomingIsDefault) {
718
- merged[name] = incomingP;
719
- }
720
- }
721
- return merged;
722
- }
723
- function mergeEventRegister(target, source) {
724
- for (const [eventName, sourceReg] of Object.entries(source)) {
725
- const targetReg = target[eventName];
726
- if (!targetReg) continue;
727
- for (const [name, reaction] of sourceReg.reactions) {
728
- targetReg.reactions.set(name, reaction);
729
- }
730
- }
731
- }
732
- function mergeProjection(proj, events) {
733
- for (const eventName of Object.keys(proj.events)) {
734
- const projRegister = proj.events[eventName];
735
- const existing = events[eventName];
736
- if (!existing) {
737
- events[eventName] = {
738
- schema: projRegister.schema,
739
- reactions: new Map(projRegister.reactions)
740
- };
741
- } else {
742
- for (const [name, reaction] of projRegister.reactions) {
743
- let key = name;
744
- while (existing.reactions.has(key)) key = `${key}_p`;
745
- existing.reactions.set(key, reaction);
746
- }
604
+ let committed;
605
+ try {
606
+ committed = await store().commit(
607
+ stream,
608
+ emitted,
609
+ meta,
610
+ // Reactions skip optimistic concurrency: they always append against the
611
+ // current head. Stream leasing already serializes concurrent reactions,
612
+ // and forcing version checks here would turn ordinary catch-up into
613
+ // spurious retries.
614
+ reactingTo ? void 0 : expected
615
+ );
616
+ } catch (error) {
617
+ if (error instanceof ConcurrencyError) {
618
+ await cache().invalidate(stream);
747
619
  }
620
+ throw error;
748
621
  }
622
+ let { state: state2, patches } = snapshot;
623
+ const snapshots = committed.map((event) => {
624
+ const p = me.patch[event.name](event, state2);
625
+ state2 = patch(state2, p);
626
+ patches++;
627
+ return {
628
+ event,
629
+ state: state2,
630
+ version: event.version,
631
+ patches,
632
+ snaps: snapshot.snaps,
633
+ patch: p,
634
+ cache_hit: snapshot.cache_hit,
635
+ replayed: snapshot.replayed
636
+ };
637
+ });
638
+ const last = snapshots.at(-1);
639
+ const snapped = me.snap?.(last);
640
+ cache().set(stream, {
641
+ state: last.state,
642
+ version: last.event.version,
643
+ event_id: last.event.id,
644
+ patches: snapped ? 0 : last.patches,
645
+ snaps: snapped ? last.snaps + 1 : last.snaps
646
+ }).catch((err) => log().error(err));
647
+ if (snapped) void snap(last);
648
+ return snapshots;
749
649
  }
750
- var _this_ = ({ stream }) => ({
751
- source: stream,
752
- target: stream
753
- });
754
650
 
755
- // src/internal/backoff.ts
756
- function computeBackoffDelay(retry, opts) {
757
- if (!opts || opts.baseMs <= 0) return 0;
758
- const r = Math.max(0, retry);
759
- let delay;
760
- switch (opts.strategy) {
761
- case "fixed":
762
- delay = opts.baseMs;
763
- break;
764
- case "linear":
765
- delay = opts.baseMs * (r + 1);
766
- break;
767
- case "exponential":
768
- delay = opts.baseMs * 2 ** r;
769
- if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
770
- break;
651
+ // src/internal/tracing.ts
652
+ var PRETTY = config().env !== "production";
653
+ var C_BLUE = "\x1B[38;5;39m";
654
+ var C_ORANGE = "\x1B[38;5;208m";
655
+ var C_GREEN = "\x1B[38;5;42m";
656
+ var C_MAGENTA = "\x1B[38;5;165m";
657
+ var C_DRAIN = "\x1B[38;5;244m";
658
+ var C_HIT = "\x1B[38;5;82m";
659
+ var C_MISS = "\x1B[38;5;220m";
660
+ var C_RESET = "\x1B[0m";
661
+ var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
662
+ var C_LANE = "\x1B[38;5;183m";
663
+ var C_DIM = "\x1B[38;5;240m";
664
+ var C_ERR = "\x1B[38;5;196m";
665
+ var C_STREAM = "\x1B[38;5;226m";
666
+ var dim = (text) => PRETTY ? `${C_DIM}${text}${C_RESET}` : text;
667
+ var hue = (color, text) => PRETTY ? `${color}${text}${C_RESET}` : text;
668
+ var drain_caption = (caption, lane) => {
669
+ const showLane = lane && lane !== "default";
670
+ if (PRETTY) {
671
+ const tag = `${C_DRAIN}>> ${caption}${C_RESET}`;
672
+ return showLane ? `${tag} ${C_LANE}${lane}${C_RESET}` : tag;
673
+ }
674
+ return showLane ? `>> ${caption} ${lane}` : `>> ${caption}`;
675
+ };
676
+ var cache_marker = (hit) => {
677
+ const word = hit ? "hit" : "miss";
678
+ if (!PRETTY) return word;
679
+ return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
680
+ };
681
+ var stats_marker = (version, replayed, snaps, patches) => {
682
+ const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
683
+ if (!PRETTY) return text;
684
+ return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
685
+ };
686
+ var as_of_marker = (asOf) => {
687
+ if (!asOf) return "";
688
+ const parts = [];
689
+ if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
690
+ if (asOf.created_before !== void 0)
691
+ parts.push(`created_before=${asOf.created_before.toISOString()}`);
692
+ if (asOf.created_after !== void 0)
693
+ parts.push(`created_after=${asOf.created_after.toISOString()}`);
694
+ if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
695
+ return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
696
+ };
697
+ var traced = (inner, exit, entry) => (async (...args) => {
698
+ entry?.(...args);
699
+ const result = await inner(...args);
700
+ exit?.(result, ...args);
701
+ return result;
702
+ });
703
+ function buildEs(logger, correlator = defaultCorrelator) {
704
+ const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
705
+ me,
706
+ actionName,
707
+ target,
708
+ payload,
709
+ reactingTo,
710
+ skipValidation,
711
+ correlator
712
+ );
713
+ if (logger.level !== "trace") {
714
+ return {
715
+ snap,
716
+ load,
717
+ action: boundAction,
718
+ tombstone
719
+ };
771
720
  }
772
- if (opts.jitter) delay = delay * (0.5 + Math.random());
773
- return Math.max(0, Math.floor(delay));
774
- }
775
-
776
- // src/internal/reactions.ts
777
- function finalize(lease, handled, at, error, options, logger) {
778
- if (!error) return { lease, handled, at };
779
- logger.error(error);
780
- const nonRetryable = error instanceof NonRetryableError;
781
- const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
782
- if (block2)
783
- logger.error(
784
- nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
785
- );
786
- const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
787
721
  return {
788
- lease,
789
- handled,
790
- at,
791
- error: handled === 0 ? error.message : void 0,
792
- block: block2,
793
- nextAttemptAt
794
- };
795
- }
796
- function buildHandle(deps) {
797
- const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
798
- return async (lease, payloads) => {
799
- if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
800
- const stream = lease.stream;
801
- let at = payloads.at(0).event.id;
802
- let handled = 0;
803
- if (lease.retry > 0)
804
- logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
805
- const scopedApp = {
806
- do: boundDo,
807
- load: boundLoad,
808
- query: boundQuery,
809
- query_array: boundQueryArray
810
- };
811
- for (const payload of payloads) {
812
- const { event, handler } = payload;
813
- scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
814
- action2,
815
- target,
816
- actionPayload,
817
- reactingTo ?? event,
818
- skipValidation
722
+ snap: traced(snap, void 0, (snapshot) => {
723
+ logger.trace(
724
+ es_caption(
725
+ "snap",
726
+ C_MAGENTA,
727
+ `${snapshot.event.stream}@${snapshot.event.version}`
728
+ )
819
729
  );
820
- try {
821
- await handler(event, stream, scopedApp);
822
- at = event.id;
823
- handled++;
824
- } catch (error) {
825
- return finalize(
826
- lease,
827
- handled,
828
- at,
829
- error,
830
- payload.options,
831
- logger
730
+ }),
731
+ load: traced(load, (result, _me, stream, _cb, asOf) => {
732
+ const stats = stats_marker(
733
+ result.version,
734
+ result.replayed,
735
+ result.snaps,
736
+ result.patches
737
+ );
738
+ logger.trace(
739
+ es_caption(
740
+ "load",
741
+ C_GREEN,
742
+ `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
743
+ )
744
+ );
745
+ }),
746
+ action: traced(
747
+ boundAction,
748
+ (snapshots, _me, _action, target) => {
749
+ const committed = snapshots.filter((s) => s.event);
750
+ if (committed.length) {
751
+ logger.trace(
752
+ committed.map((s) => s.event.data),
753
+ es_caption(
754
+ "committed",
755
+ C_ORANGE,
756
+ `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
757
+ )
758
+ );
759
+ }
760
+ },
761
+ (_me, action2, target, payload) => {
762
+ logger.trace(
763
+ payload,
764
+ es_caption("action", C_BLUE, `${target.stream}.${action2}`)
832
765
  );
833
766
  }
834
- }
835
- return finalize(lease, handled, at, void 0, payloads[0].options, logger);
767
+ ),
768
+ tombstone: traced(tombstone, (committed, stream) => {
769
+ if (committed)
770
+ logger.trace(
771
+ es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
772
+ );
773
+ })
836
774
  };
837
775
  }
838
- function buildHandleBatch(logger) {
839
- return async (lease, payloads, batchHandler) => {
840
- const stream = lease.stream;
841
- const events = payloads.map((p) => p.event);
842
- const options = payloads[0].options;
843
- if (lease.retry > 0)
844
- logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
845
- try {
846
- await batchHandler(events, stream);
847
- return finalize(
848
- lease,
849
- events.length,
850
- events.at(-1).id,
851
- void 0,
852
- options,
853
- logger
854
- );
855
- } catch (error) {
856
- return finalize(lease, 0, lease.at, error, options, logger);
857
- }
776
+ function buildDrain(logger) {
777
+ return {
778
+ claim,
779
+ fetch,
780
+ ack,
781
+ block,
782
+ subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
783
+ if (!result.subscribed) return;
784
+ const lanes = new Set(streams.map((s) => s.lane ?? "default"));
785
+ const uniformLane = lanes.size === 1 ? streams[0]?.lane : void 0;
786
+ const data = streams.map(
787
+ ({ stream, lane }) => uniformLane || !lane || lane === "default" ? hue(C_STREAM, stream) : `${hue(C_STREAM, stream)}${dim(`[${lane}]`)}`
788
+ ).join(" ");
789
+ logger.trace(`${drain_caption("correlated", uniformLane)} ${data}`);
790
+ })
858
791
  };
859
792
  }
793
+ function traceCycle(logger, leased, fetched, handled, acked, blocked) {
794
+ if (logger.level !== "trace" || !leased.length) return;
795
+ const lane = leased[0]?.lane;
796
+ const fetchByStream = new Map(fetched.map((f) => [f.stream, f]));
797
+ const ackedByStream = new Map(acked.map((a) => [a.stream, a.at]));
798
+ const blockedByStream = new Map(blocked.map((b) => [b.stream, b.error]));
799
+ const failedByStream = new Map(
800
+ handled.filter((h) => h.error).map((h) => [h.lease.stream, h])
801
+ );
802
+ const detail = leased.map(({ stream, at, retry }) => {
803
+ const f = fetchByStream.get(stream);
804
+ const key = f?.source ? `${hue(C_STREAM, stream)}${dim(`<-${f.source}`)}` : hue(C_STREAM, stream);
805
+ const events = f && f.events.length ? ` ${dim(
806
+ `[${f.events.map(({ id, name }) => `#${id} ${String(name)}`).join(", ")}]`
807
+ )}` : "";
808
+ const ackedAt = ackedByStream.get(stream);
809
+ const ackPart = ackedAt !== void 0 ? hue(C_HIT, `\u2713 @${ackedAt}`) : "";
810
+ const failure = failedByStream.get(stream);
811
+ let failPart = "";
812
+ if (failure) {
813
+ const failedAt = failure.failed_at ?? at;
814
+ const blockedError = blockedByStream.get(stream);
815
+ if (blockedError !== void 0) {
816
+ failPart = `${hue(C_ERR, `\u2717 @${failedAt}/${retry}`)} ${dim(`(${blockedError})`)}`;
817
+ } else {
818
+ failPart = `${hue(C_MISS, `\u26A0 @${failedAt}/${retry}`)} ${dim(`(${failure.error})`)}`;
819
+ }
820
+ }
821
+ let tail;
822
+ if (ackPart && failPart) tail = ` ${ackPart} ${failPart}`;
823
+ else if (ackPart) tail = ` ${ackPart}`;
824
+ else if (failPart) tail = ` ${failPart}`;
825
+ else tail = ` ${dim(`\u2298 @${at}/${retry}`)}`;
826
+ return `${key}${events}${tail}`;
827
+ }).join(", ");
828
+ logger.trace(`${drain_caption("drained", lane)} ${detail}`);
829
+ }
860
830
 
861
- // src/internal/settle.ts
862
- var SettleLoop = class {
863
- constructor(deps, defaultDebounceMs) {
831
+ // src/internal/drain-cycle.ts
832
+ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred, lane) {
833
+ const leased = await ops.claim(
834
+ lagging,
835
+ leading,
836
+ randomUUID(),
837
+ leaseMillis,
838
+ lane
839
+ );
840
+ if (!leased.length) return void 0;
841
+ const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
842
+ if (!active.length) {
843
+ return {
844
+ leased,
845
+ fetched: [],
846
+ handled: [],
847
+ acked: [],
848
+ blocked: []
849
+ };
850
+ }
851
+ const fetched = await ops.fetch(active, eventLimit);
852
+ const fetchMap = /* @__PURE__ */ new Map();
853
+ const fetch_window_at = fetched.reduce(
854
+ (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
855
+ 0
856
+ );
857
+ for (const f of fetched) {
858
+ const { stream, events } = f;
859
+ const payloads = events.flatMap((event) => {
860
+ const register = registry.events[event.name];
861
+ if (!register) return [];
862
+ return [...register.reactions.values()].filter((reaction) => {
863
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
864
+ return resolved && resolved.target === stream;
865
+ }).map((reaction) => ({ ...reaction, event }));
866
+ });
867
+ fetchMap.set(stream, { fetch: f, payloads });
868
+ }
869
+ const handled = await Promise.all(
870
+ active.map((lease) => {
871
+ const entry = fetchMap.get(lease.stream);
872
+ const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
873
+ const { payloads } = entry;
874
+ const batchHandler = batchHandlers.get(lease.stream);
875
+ if (batchHandler && payloads.length > 0) {
876
+ return handleBatch({ ...lease, at }, payloads, batchHandler);
877
+ }
878
+ return handle({ ...lease, at }, payloads);
879
+ })
880
+ );
881
+ const acked = await ops.ack(
882
+ handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
883
+ );
884
+ const blocked = await ops.block(
885
+ handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
886
+ );
887
+ return { leased, fetched, handled, acked, blocked };
888
+ }
889
+ var EMPTY_DRAIN = {
890
+ fetched: [],
891
+ leased: [],
892
+ acked: [],
893
+ blocked: []
894
+ };
895
+ var DrainController = class {
896
+ constructor(deps) {
864
897
  this.deps = deps;
865
- this.defaultDebounceMs = defaultDebounceMs;
866
898
  }
867
- _timer = void 0;
868
- _running = false;
899
+ _armed = false;
900
+ _locked = false;
901
+ _ratio = 0.5;
902
+ /**
903
+ * Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
904
+ * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
905
+ * ack or terminal block. Lives in process memory — per-worker pacing
906
+ * by design (see {@link BackoffOptions} for the multi-worker trade-off).
907
+ */
908
+ _backoff = /* @__PURE__ */ new Map();
909
+ /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
910
+ _backoffTimer;
911
+ /** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
912
+ _worker;
913
+ _stopped = false;
914
+ /**
915
+ * Signal that a commit (or reset / cold-start) may have produced work.
916
+ * Subsequent `drain()` calls will run the pipeline; once the pipeline
917
+ * settles to no-progress, the controller disarms itself.
918
+ */
919
+ arm() {
920
+ this._armed = true;
921
+ }
922
+ /** Read-only flag — true while a commit / reset is unprocessed. */
923
+ get armed() {
924
+ return this._armed;
925
+ }
926
+ /** Returns true when `stream` is currently within a backoff window. */
927
+ isDeferred = (stream) => {
928
+ const next = this._backoff.get(stream);
929
+ return next !== void 0 && next > Date.now();
930
+ };
931
+ /**
932
+ * Schedule the next drain re-arm at the earliest pending backoff
933
+ * expiry. Called only when the backoff map is non-empty (caller guard).
934
+ * Idempotent — collapses many simultaneously deferred streams into a
935
+ * single timer.
936
+ */
937
+ scheduleBackoffWake() {
938
+ if (this._backoffTimer) clearTimeout(this._backoffTimer);
939
+ let earliest = Number.POSITIVE_INFINITY;
940
+ for (const t of this._backoff.values()) if (t < earliest) earliest = t;
941
+ const delay = Math.max(0, earliest - Date.now());
942
+ this._backoffTimer = setTimeout(() => {
943
+ this._backoffTimer = void 0;
944
+ const now = Date.now();
945
+ for (const [stream, at] of this._backoff) {
946
+ if (at <= now) this._backoff.delete(stream);
947
+ }
948
+ this._armed = true;
949
+ }, delay);
950
+ this._backoffTimer.unref();
951
+ }
952
+ /** Lane this controller drains (undefined = legacy single-lane span). */
953
+ get lane() {
954
+ return this.deps.lane;
955
+ }
869
956
  /**
870
- * Schedule a settle pass. Multiple calls inside the debounce window
871
- * coalesce into one cycle. The cycle runs correlate→drain in a loop
872
- * until no progress is made (no new subscriptions, no acks, no blocks)
873
- * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
874
- * via {@link SettleDeps.onSettled}.
957
+ * Start a per-lane worker that drains at the lane's `cycleMs`
958
+ * cadence (ACT-1103). When armed, the worker calls `drain()` on every
959
+ * tick and re-schedules; when not armed, it still re-schedules at
960
+ * `cycleMs` so a future `arm()` is picked up on the next tick.
961
+ *
962
+ * The setTimeout chain uses `unref()` so it doesn't keep the process
963
+ * alive on its own.
875
964
  */
876
- schedule(options = {}) {
877
- const {
878
- debounceMs = this.defaultDebounceMs,
879
- correlate: correlateQuery = { after: -1, limit: 100 },
880
- maxPasses = Infinity,
881
- ...drainOptions
882
- } = options;
883
- if (this._timer) clearTimeout(this._timer);
884
- this._timer = setTimeout(() => {
885
- this._timer = void 0;
886
- if (this._running) return;
887
- this._running = true;
888
- (async () => {
889
- await this.deps.init();
890
- let lastDrain;
891
- for (let i = 0; i < maxPasses; i++) {
892
- const { subscribed } = await this.deps.correlate({
893
- ...correlateQuery,
894
- after: this.deps.checkpoint()
895
- });
896
- lastDrain = await this.deps.drain(drainOptions);
897
- const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
898
- if (!made_progress) break;
899
- }
900
- if (lastDrain) this.deps.onSettled(lastDrain);
901
- })().catch((err) => this.deps.logger.error(err)).finally(() => {
902
- this._running = false;
903
- });
904
- }, debounceMs);
965
+ start(cycleMs) {
966
+ if (this._worker || this._stopped) return;
967
+ const tick = async () => {
968
+ if (this._armed) await this.drain();
969
+ if (this._stopped) return;
970
+ this._worker = setTimeout(tick, cycleMs);
971
+ this._worker.unref();
972
+ };
973
+ this._worker = setTimeout(tick, cycleMs);
974
+ this._worker.unref();
905
975
  }
906
- /** Cancel any pending or active settle cycle. Idempotent. */
976
+ /** Stop the per-lane worker. Idempotent. */
907
977
  stop() {
908
- if (this._timer) {
909
- clearTimeout(this._timer);
910
- this._timer = void 0;
978
+ this._stopped = true;
979
+ if (this._worker) {
980
+ clearTimeout(this._worker);
981
+ this._worker = void 0;
982
+ }
983
+ }
984
+ /** Run one drain pass. Short-circuits when not armed or already running. */
985
+ async drain(options = {}) {
986
+ if (!this._armed) return EMPTY_DRAIN;
987
+ if (this._locked) return EMPTY_DRAIN;
988
+ const d = this.deps.defaults ?? {};
989
+ const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
990
+ const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
991
+ const leaseMillis = d.leaseMillis ?? options.leaseMillis ?? 1e4;
992
+ try {
993
+ this._locked = true;
994
+ const lagging = Math.ceil(streamLimit * this._ratio);
995
+ const leading = streamLimit - lagging;
996
+ const cycle = await runDrainCycle(
997
+ this.deps.ops,
998
+ this.deps.registry,
999
+ this.deps.batchHandlers,
1000
+ this.deps.handle,
1001
+ this.deps.handleBatch,
1002
+ lagging,
1003
+ leading,
1004
+ eventLimit,
1005
+ leaseMillis,
1006
+ this._backoff.size > 0 ? this.isDeferred : void 0,
1007
+ this.deps.lane
1008
+ );
1009
+ if (!cycle) {
1010
+ this._armed = false;
1011
+ return EMPTY_DRAIN;
1012
+ }
1013
+ const { leased, fetched, handled, acked, blocked } = cycle;
1014
+ traceCycle(this.deps.logger, leased, fetched, handled, acked, blocked);
1015
+ this._ratio = computeLagLeadRatio(handled, lagging, leading);
1016
+ for (const lease of acked) this._backoff.delete(lease.stream);
1017
+ for (const lease of blocked) this._backoff.delete(lease.stream);
1018
+ for (const h of handled) {
1019
+ if (h.nextAttemptAt !== void 0 && !h.block) {
1020
+ this._backoff.set(h.lease.stream, h.nextAttemptAt);
1021
+ }
1022
+ }
1023
+ if (this._backoff.size > 0) this.scheduleBackoffWake();
1024
+ if (acked.length) this.deps.onAcked(acked);
1025
+ if (blocked.length) this.deps.onBlocked(blocked);
1026
+ const hasErrors = handled.some(({ error }) => error);
1027
+ if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
1028
+ return { fetched, leased, acked, blocked };
1029
+ } catch (error) {
1030
+ this.deps.logger.error(error);
1031
+ return EMPTY_DRAIN;
1032
+ } finally {
1033
+ this._locked = false;
911
1034
  }
912
1035
  }
913
1036
  };
914
1037
 
915
- // src/internal/drain.ts
916
- var claim = (lagging, leading, by, millis) => store().claim(lagging, leading, by, millis);
917
- async function fetch(leased, eventLimit) {
918
- return Promise.all(
919
- leased.map(async ({ stream, source, at, lagging }) => {
920
- const events = [];
921
- await store().query((e) => events.push(e), {
922
- stream: source,
923
- after: at,
924
- limit: eventLimit
925
- });
926
- return { stream, source, at, lagging, events };
927
- })
928
- );
1038
+ // src/internal/event-versions.ts
1039
+ var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
1040
+ function parse(name) {
1041
+ const m = name.match(VERSION_SUFFIX);
1042
+ if (m) {
1043
+ const v = Number.parseInt(m[2], 10);
1044
+ if (v >= 2) return { base: m[1], version: v };
1045
+ }
1046
+ return { base: name, version: 1 };
929
1047
  }
930
- var ack = (leases) => store().ack(leases);
931
- var block = (leases) => store().block(leases);
932
- var subscribe = (streams) => store().subscribe(streams);
933
-
934
- // src/internal/event-sourcing.ts
935
- import { patch } from "@rotorsoft/act-patch";
936
- async function snap(snapshot) {
937
- try {
938
- const { id, stream, name, meta, version } = snapshot.event;
939
- await store().commit(
940
- stream,
941
- [{ name: SNAP_EVENT, data: snapshot.state }],
942
- {
943
- correlation: meta.correlation,
944
- causation: { event: { id, name, stream } }
945
- },
946
- version
947
- // IMPORTANT! - state events are committed right after the snapshot event
948
- );
949
- } catch (error) {
950
- log().error(error);
1048
+ function deprecatedEventNames(names) {
1049
+ const groups = /* @__PURE__ */ new Map();
1050
+ for (const name of names) {
1051
+ const { base, version } = parse(name);
1052
+ const list = groups.get(base);
1053
+ if (list) list.push({ version, name });
1054
+ else groups.set(base, [{ version, name }]);
1055
+ }
1056
+ const deprecated = /* @__PURE__ */ new Set();
1057
+ for (const list of groups.values()) {
1058
+ if (list.length < 2) continue;
1059
+ list.sort((a, b) => b.version - a.version);
1060
+ for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
951
1061
  }
1062
+ return deprecated;
952
1063
  }
953
- async function tombstone(stream, expectedVersion, correlation) {
954
- try {
955
- const [committed] = await store().commit(
956
- stream,
957
- [{ name: TOMBSTONE_EVENT, data: {} }],
958
- { correlation, causation: {} },
959
- expectedVersion
960
- );
961
- return committed;
962
- } catch (error) {
963
- if (error instanceof ConcurrencyError) return void 0;
964
- throw error;
1064
+ function currentVersionOf(deprecatedName, allNames) {
1065
+ const target = parse(deprecatedName);
1066
+ let highest;
1067
+ for (const name of allNames) {
1068
+ const { base, version } = parse(name);
1069
+ if (base !== target.base) continue;
1070
+ if (!highest || version > highest.version) highest = { version, name };
965
1071
  }
1072
+ return highest && highest.version > target.version ? highest.name : void 0;
966
1073
  }
967
- async function load(me, stream, callback, asOf) {
968
- const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
969
- const cached = timeTravel ? void 0 : await cache().get(stream);
970
- const cache_hit = !!cached;
971
- let state2 = cached?.state ?? (me.init ? me.init() : {});
972
- let patches = cached?.patches ?? 0;
973
- let snaps = cached?.snaps ?? 0;
974
- let version = cached?.version ?? -1;
975
- let replayed = 0;
976
- let event;
977
- await store().query(
978
- (e) => {
979
- event = e;
980
- version = e.version;
981
- if (e.name === SNAP_EVENT) {
982
- state2 = e.data;
983
- snaps++;
984
- patches = 0;
985
- replayed++;
986
- } else if (me.patch[e.name]) {
987
- state2 = patch(state2, me.patch[e.name](event, state2));
988
- patches++;
989
- replayed++;
990
- } else if (e.name !== TOMBSTONE_EVENT) {
991
- log().warn(
992
- `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
993
- );
1074
+
1075
+ // src/internal/merge.ts
1076
+ import { ZodObject } from "zod";
1077
+ function baseTypeName(zodType) {
1078
+ let t = zodType;
1079
+ while (typeof t.unwrap === "function") {
1080
+ t = t.unwrap();
1081
+ }
1082
+ return t.constructor.name;
1083
+ }
1084
+ function mergeSchemas(existing, incoming, stateName) {
1085
+ if (existing instanceof ZodObject && incoming instanceof ZodObject) {
1086
+ const existingShape = existing.shape;
1087
+ const incomingShape = incoming.shape;
1088
+ for (const key of Object.keys(incomingShape)) {
1089
+ if (key in existingShape) {
1090
+ const existingBase = baseTypeName(existingShape[key]);
1091
+ const incomingBase = baseTypeName(incomingShape[key]);
1092
+ if (existingBase !== incomingBase) {
1093
+ throw new Error(
1094
+ `Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
1095
+ );
1096
+ }
994
1097
  }
995
- callback?.({
996
- event,
997
- state: state2,
998
- version,
999
- patches,
1000
- snaps,
1001
- cache_hit,
1002
- replayed
1003
- });
1004
- },
1005
- {
1006
- stream,
1007
- stream_exact: true,
1008
- ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
1009
1098
  }
1010
- );
1011
- if (replayed > 0 && !timeTravel && event) {
1012
- await cache().set(stream, {
1013
- state: state2,
1014
- version,
1015
- event_id: event.id,
1016
- patches,
1017
- snaps
1018
- });
1099
+ return existing.extend(incomingShape);
1100
+ }
1101
+ return existing;
1102
+ }
1103
+ function mergeInits(existing, incoming) {
1104
+ return () => ({ ...existing(), ...incoming() });
1105
+ }
1106
+ function registerState(state2, states, actions, events) {
1107
+ const existing = states.get(state2.name);
1108
+ if (existing) {
1109
+ mergeIntoExisting(state2, existing, states, actions, events);
1110
+ } else {
1111
+ registerNewState(state2, states, actions, events);
1019
1112
  }
1020
- return { event, state: state2, version, patches, snaps, cache_hit, replayed };
1021
1113
  }
1022
- async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
1023
- const { stream, expectedVersion, actor } = target;
1024
- if (!stream) throw new Error("Missing target stream");
1025
- const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
1026
- const snapshot = await load(me, stream);
1027
- if (snapshot.event?.name === TOMBSTONE_EVENT)
1028
- throw new StreamClosedError(stream);
1029
- const expected = expectedVersion ?? snapshot.event?.version;
1030
- if (me.given) {
1031
- const invariants = me.given[action2] || [];
1032
- invariants.forEach(({ valid, description }) => {
1033
- if (!valid(snapshot.state, actor))
1034
- throw new InvariantError(
1035
- action2,
1036
- validated,
1037
- target,
1038
- snapshot,
1039
- description
1040
- );
1041
- });
1114
+ function registerNewState(state2, states, actions, events) {
1115
+ states.set(state2.name, state2);
1116
+ for (const name of Object.keys(state2.actions)) {
1117
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
1118
+ actions[name] = state2;
1042
1119
  }
1043
- const result = me.on[action2](validated, snapshot, target);
1044
- if (!result) return [snapshot];
1045
- if (Array.isArray(result) && result.length === 0) {
1046
- return [snapshot];
1120
+ for (const name of Object.keys(state2.events)) {
1121
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
1122
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
1047
1123
  }
1048
- const tuples = Array.isArray(result[0]) ? result : [result];
1049
- const deprecated = me._deprecated;
1050
- if (deprecated && deprecated.size > 0) {
1051
- const me_ = me;
1052
- const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
1053
- for (const [name] of tuples) {
1054
- const evt = name;
1055
- if (deprecated.has(evt) && !warned.has(evt)) {
1056
- warned.add(evt);
1057
- log().warn(
1058
- `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)`
1059
- );
1060
- }
1061
- }
1124
+ }
1125
+ function mergeIntoExisting(state2, existing, states, actions, events) {
1126
+ for (const name of Object.keys(state2.actions)) {
1127
+ if (existing.actions[name] === state2.actions[name]) continue;
1128
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
1062
1129
  }
1063
- const emitted = tuples.map(([name, data]) => ({
1064
- name,
1065
- data: skipValidation ? data : validate(name, data, me.events[name])
1066
- }));
1067
- const meta = {
1068
- correlation: reactingTo?.meta.correlation || correlator({
1069
- action: action2,
1070
- state: me.name,
1071
- stream,
1072
- actor: target.actor
1073
- }),
1074
- causation: {
1075
- action: {
1076
- name: action2,
1077
- ...target
1078
- // payload intentionally omitted: it can be large or contain PII,
1079
- // and callers correlate via the correlation id when they need it.
1080
- },
1081
- event: reactingTo ? {
1082
- id: reactingTo.id,
1083
- name: reactingTo.name,
1084
- stream: reactingTo.stream
1085
- } : void 0
1130
+ for (const name of Object.keys(state2.events)) {
1131
+ if (existing.events[name] === state2.events[name]) continue;
1132
+ if (existing.events[name]) {
1133
+ throw new Error(
1134
+ `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.`
1135
+ );
1086
1136
  }
1137
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
1138
+ }
1139
+ const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
1140
+ const merged = {
1141
+ ...existing,
1142
+ state: mergeSchemas(existing.state, state2.state, state2.name),
1143
+ init: mergeInits(existing.init, state2.init),
1144
+ events: { ...existing.events, ...state2.events },
1145
+ actions: { ...existing.actions, ...state2.actions },
1146
+ patch: mergedPatch,
1147
+ on: { ...existing.on, ...state2.on },
1148
+ given: { ...existing.given, ...state2.given },
1149
+ snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
1150
+ throw new Error(
1151
+ `Duplicate snap strategy for state "${state2.name}"`
1152
+ );
1153
+ })() : state2.snap || existing.snap
1087
1154
  };
1088
- let committed;
1089
- try {
1090
- committed = await store().commit(
1091
- stream,
1092
- emitted,
1093
- meta,
1094
- // Reactions skip optimistic concurrency: they always append against the
1095
- // current head. Stream leasing already serializes concurrent reactions,
1096
- // and forcing version checks here would turn ordinary catch-up into
1097
- // spurious retries.
1098
- reactingTo ? void 0 : expected
1099
- );
1100
- } catch (error) {
1101
- if (error instanceof ConcurrencyError) {
1102
- await cache().invalidate(stream);
1155
+ states.set(state2.name, merged);
1156
+ for (const name of Object.keys(merged.actions)) {
1157
+ actions[name] = merged;
1158
+ }
1159
+ for (const name of Object.keys(state2.events)) {
1160
+ if (events[name]) continue;
1161
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
1162
+ }
1163
+ }
1164
+ function mergePatches(existing, incoming, stateName) {
1165
+ const merged = { ...existing };
1166
+ for (const name of Object.keys(incoming)) {
1167
+ const existingP = existing[name];
1168
+ const incomingP = incoming[name];
1169
+ if (!existingP) {
1170
+ merged[name] = incomingP;
1171
+ continue;
1172
+ }
1173
+ const existingIsDefault = existingP._passthrough;
1174
+ const incomingIsDefault = incomingP._passthrough;
1175
+ if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
1176
+ throw new Error(
1177
+ `Duplicate custom patch for event "${name}" in state "${stateName}"`
1178
+ );
1179
+ }
1180
+ if (existingIsDefault && !incomingIsDefault) {
1181
+ merged[name] = incomingP;
1103
1182
  }
1104
- throw error;
1105
1183
  }
1106
- let { state: state2, patches } = snapshot;
1107
- const snapshots = committed.map((event) => {
1108
- const p = me.patch[event.name](event, state2);
1109
- state2 = patch(state2, p);
1110
- patches++;
1111
- return {
1112
- event,
1113
- state: state2,
1114
- version: event.version,
1115
- patches,
1116
- snaps: snapshot.snaps,
1117
- patch: p,
1118
- cache_hit: snapshot.cache_hit,
1119
- replayed: snapshot.replayed
1120
- };
1121
- });
1122
- const last = snapshots.at(-1);
1123
- const snapped = me.snap?.(last);
1124
- cache().set(stream, {
1125
- state: last.state,
1126
- version: last.event.version,
1127
- event_id: last.event.id,
1128
- patches: snapped ? 0 : last.patches,
1129
- snaps: snapped ? last.snaps + 1 : last.snaps
1130
- }).catch((err) => log().error(err));
1131
- if (snapped) void snap(last);
1132
- return snapshots;
1184
+ return merged;
1133
1185
  }
1134
-
1135
- // src/internal/tracing.ts
1136
- var PRETTY = config().env !== "production";
1137
- var C_BLUE = "\x1B[38;5;39m";
1138
- var C_ORANGE = "\x1B[38;5;208m";
1139
- var C_GREEN = "\x1B[38;5;42m";
1140
- var C_MAGENTA = "\x1B[38;5;165m";
1141
- var C_DRAIN = "\x1B[38;5;244m";
1142
- var C_HIT = "\x1B[38;5;82m";
1143
- var C_MISS = "\x1B[38;5;220m";
1144
- var C_RESET = "\x1B[0m";
1145
- var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
1146
- var drain_caption = (caption) => {
1147
- const tag = `>> ${caption}`;
1148
- return PRETTY ? `${C_DRAIN}${tag}${C_RESET}` : tag;
1149
- };
1150
- var cache_marker = (hit) => {
1151
- const word = hit ? "hit" : "miss";
1152
- if (!PRETTY) return word;
1153
- return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
1154
- };
1155
- var stats_marker = (version, replayed, snaps, patches) => {
1156
- const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
1157
- if (!PRETTY) return text;
1158
- return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
1159
- };
1160
- var as_of_marker = (asOf) => {
1161
- if (!asOf) return "";
1162
- const parts = [];
1163
- if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
1164
- if (asOf.created_before !== void 0)
1165
- parts.push(`created_before=${asOf.created_before.toISOString()}`);
1166
- if (asOf.created_after !== void 0)
1167
- parts.push(`created_after=${asOf.created_after.toISOString()}`);
1168
- if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
1169
- return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
1170
- };
1171
- var traced = (inner, exit, entry) => (async (...args) => {
1172
- entry?.(...args);
1173
- const result = await inner(...args);
1174
- exit?.(result, ...args);
1175
- return result;
1186
+ function mergeEventRegister(target, source) {
1187
+ for (const [eventName, sourceReg] of Object.entries(source)) {
1188
+ const targetReg = target[eventName];
1189
+ if (!targetReg) continue;
1190
+ for (const [name, reaction] of sourceReg.reactions) {
1191
+ targetReg.reactions.set(name, reaction);
1192
+ }
1193
+ }
1194
+ }
1195
+ function mergeProjection(proj, events) {
1196
+ for (const eventName of Object.keys(proj.events)) {
1197
+ const projRegister = proj.events[eventName];
1198
+ const existing = events[eventName];
1199
+ if (!existing) {
1200
+ events[eventName] = {
1201
+ schema: projRegister.schema,
1202
+ reactions: new Map(projRegister.reactions)
1203
+ };
1204
+ } else {
1205
+ for (const [name, reaction] of projRegister.reactions) {
1206
+ let key = name;
1207
+ while (existing.reactions.has(key)) key = `${key}_p`;
1208
+ existing.reactions.set(key, reaction);
1209
+ }
1210
+ }
1211
+ }
1212
+ }
1213
+ var _this_ = ({ stream }) => ({
1214
+ source: stream,
1215
+ target: stream
1176
1216
  });
1177
- function buildEs(logger, correlator = defaultCorrelator) {
1178
- const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
1179
- me,
1180
- actionName,
1181
- target,
1182
- payload,
1183
- reactingTo,
1184
- skipValidation,
1185
- correlator
1186
- );
1187
- if (logger.level !== "trace") {
1188
- return {
1189
- snap,
1190
- load,
1191
- action: boundAction,
1192
- tombstone
1193
- };
1217
+
1218
+ // src/internal/backoff.ts
1219
+ function computeBackoffDelay(retry, opts) {
1220
+ if (!opts || opts.baseMs <= 0) return 0;
1221
+ const r = Math.max(0, retry);
1222
+ let delay;
1223
+ switch (opts.strategy) {
1224
+ case "fixed":
1225
+ delay = opts.baseMs;
1226
+ break;
1227
+ case "linear":
1228
+ delay = opts.baseMs * (r + 1);
1229
+ break;
1230
+ case "exponential":
1231
+ delay = opts.baseMs * 2 ** r;
1232
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
1233
+ break;
1194
1234
  }
1235
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
1236
+ return Math.max(0, Math.floor(delay));
1237
+ }
1238
+
1239
+ // src/internal/reactions.ts
1240
+ function finalize(lease, handled, at, error, options, logger, failed_at) {
1241
+ if (!error) return { lease, handled, acked_at: at };
1242
+ logger.error(error);
1243
+ const nonRetryable = error instanceof NonRetryableError;
1244
+ const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
1245
+ if (block2)
1246
+ logger.error(
1247
+ nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
1248
+ );
1249
+ const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
1195
1250
  return {
1196
- snap: traced(snap, void 0, (snapshot) => {
1197
- logger.trace(
1198
- es_caption(
1199
- "snap",
1200
- C_MAGENTA,
1201
- `${snapshot.event.stream}@${snapshot.event.version}`
1202
- )
1203
- );
1204
- }),
1205
- load: traced(load, (result, _me, stream, _cb, asOf) => {
1206
- const stats = stats_marker(
1207
- result.version,
1208
- result.replayed,
1209
- result.snaps,
1210
- result.patches
1211
- );
1212
- logger.trace(
1213
- es_caption(
1214
- "load",
1215
- C_GREEN,
1216
- `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
1217
- )
1218
- );
1219
- }),
1220
- action: traced(
1221
- boundAction,
1222
- (snapshots, _me, _action, target) => {
1223
- const committed = snapshots.filter((s) => s.event);
1224
- if (committed.length) {
1225
- logger.trace(
1226
- committed.map((s) => s.event.data),
1227
- es_caption(
1228
- "committed",
1229
- C_ORANGE,
1230
- `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
1231
- )
1232
- );
1233
- }
1234
- },
1235
- (_me, action2, target, payload) => {
1236
- logger.trace(
1237
- payload,
1238
- es_caption("action", C_BLUE, `${target.stream}.${action2}`)
1239
- );
1240
- }
1241
- ),
1242
- tombstone: traced(tombstone, (committed, stream) => {
1243
- if (committed)
1244
- logger.trace(
1245
- es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
1246
- );
1247
- })
1251
+ lease,
1252
+ handled,
1253
+ acked_at: at,
1254
+ error: error.message,
1255
+ block: block2,
1256
+ nextAttemptAt,
1257
+ failed_at
1248
1258
  };
1249
1259
  }
1250
- function buildDrain(logger) {
1251
- if (logger.level !== "trace") {
1252
- return {
1253
- claim,
1254
- fetch,
1255
- ack,
1256
- block,
1257
- subscribe
1260
+ function buildHandle(deps) {
1261
+ const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
1262
+ return async (lease, payloads) => {
1263
+ if (payloads.length === 0) return { lease, handled: 0, acked_at: lease.at };
1264
+ const stream = lease.stream;
1265
+ let at = payloads.at(0).event.id;
1266
+ let handled = 0;
1267
+ if (lease.retry > 0)
1268
+ logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1269
+ const scopedApp = {
1270
+ do: boundDo,
1271
+ load: boundLoad,
1272
+ query: boundQuery,
1273
+ query_array: boundQueryArray
1258
1274
  };
1259
- }
1260
- return {
1261
- claim: traced(claim, (leased) => {
1262
- if (leased.length) {
1263
- const data = Object.fromEntries(
1264
- leased.map(({ stream, at, retry }) => [stream, { at, retry }])
1265
- );
1266
- logger.trace(data, drain_caption("claimed"));
1267
- }
1268
- }),
1269
- fetch: traced(fetch, (fetched) => {
1270
- const data = Object.fromEntries(
1271
- fetched.map(({ stream, source, events }) => {
1272
- const key = source ? `${stream}<-${source}` : stream;
1273
- const value = Object.fromEntries(
1274
- events.map(({ id, stream: stream2, name }) => [id, { [stream2]: name }])
1275
- );
1276
- return [key, value];
1277
- })
1275
+ for (const payload of payloads) {
1276
+ const { event, handler } = payload;
1277
+ scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
1278
+ action2,
1279
+ target,
1280
+ actionPayload,
1281
+ reactingTo ?? event,
1282
+ skipValidation
1278
1283
  );
1279
- logger.trace(data, drain_caption("fetched"));
1280
- }),
1281
- ack: traced(ack, (acked) => {
1282
- if (acked.length) {
1283
- const data = Object.fromEntries(
1284
- acked.map(({ stream, at, retry }) => [stream, { at, retry }])
1285
- );
1286
- logger.trace(data, drain_caption("acked"));
1287
- }
1288
- }),
1289
- block: traced(block, (blocked) => {
1290
- if (blocked.length) {
1291
- const data = Object.fromEntries(
1292
- blocked.map(({ stream, at, retry, error }) => [
1293
- stream,
1294
- { at, retry, error }
1295
- ])
1284
+ try {
1285
+ await handler(event, stream, scopedApp);
1286
+ at = event.id;
1287
+ handled++;
1288
+ } catch (error) {
1289
+ return finalize(
1290
+ lease,
1291
+ handled,
1292
+ at,
1293
+ error,
1294
+ payload.options,
1295
+ logger,
1296
+ event.id
1296
1297
  );
1297
- logger.trace(data, drain_caption("blocked"));
1298
- }
1299
- }),
1300
- subscribe: traced(subscribe, (result, streams) => {
1301
- if (result.subscribed) {
1302
- const data = streams.map(({ stream }) => stream).join(" ");
1303
- logger.trace(`${drain_caption("correlated")} ${data}`);
1304
1298
  }
1305
- })
1299
+ }
1300
+ return finalize(lease, handled, at, void 0, payloads[0].options, logger);
1301
+ };
1302
+ }
1303
+ function buildHandleBatch(logger) {
1304
+ return async (lease, payloads, batchHandler) => {
1305
+ const stream = lease.stream;
1306
+ const events = payloads.map((p) => p.event);
1307
+ const options = payloads[0].options;
1308
+ if (lease.retry > 0)
1309
+ logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
1310
+ try {
1311
+ await batchHandler(events, stream);
1312
+ return finalize(
1313
+ lease,
1314
+ events.length,
1315
+ events.at(-1).id,
1316
+ void 0,
1317
+ options,
1318
+ logger
1319
+ );
1320
+ } catch (error) {
1321
+ return finalize(lease, 0, lease.at, error, options, logger);
1322
+ }
1306
1323
  };
1307
1324
  }
1308
1325
 
1326
+ // src/internal/settle.ts
1327
+ var SettleLoop = class {
1328
+ constructor(deps, defaultDebounceMs) {
1329
+ this.deps = deps;
1330
+ this.defaultDebounceMs = defaultDebounceMs;
1331
+ }
1332
+ _timer = void 0;
1333
+ _running = false;
1334
+ /**
1335
+ * Schedule a settle pass. Multiple calls inside the debounce window
1336
+ * coalesce into one cycle. The cycle runs correlate→drain in a loop
1337
+ * until no progress is made (no new subscriptions, no acks, no blocks)
1338
+ * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
1339
+ * via {@link SettleDeps.onSettled}.
1340
+ */
1341
+ schedule(options = {}) {
1342
+ const {
1343
+ debounceMs = this.defaultDebounceMs,
1344
+ correlate: correlateQuery = { after: -1, limit: 100 },
1345
+ maxPasses = Infinity,
1346
+ ...drainOptions
1347
+ } = options;
1348
+ if (this._timer) clearTimeout(this._timer);
1349
+ this._timer = setTimeout(() => {
1350
+ this._timer = void 0;
1351
+ if (this._running) return;
1352
+ this._running = true;
1353
+ (async () => {
1354
+ await this.deps.init();
1355
+ let lastDrain;
1356
+ for (let i = 0; i < maxPasses; i++) {
1357
+ const { subscribed } = await this.deps.correlate({
1358
+ ...correlateQuery,
1359
+ after: this.deps.checkpoint()
1360
+ });
1361
+ lastDrain = await this.deps.drain(drainOptions);
1362
+ const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
1363
+ if (!made_progress) break;
1364
+ }
1365
+ if (lastDrain) this.deps.onSettled(lastDrain);
1366
+ })().catch((err) => this.deps.logger.error(err)).finally(() => {
1367
+ this._running = false;
1368
+ });
1369
+ }, debounceMs);
1370
+ }
1371
+ /** Cancel any pending or active settle cycle. Idempotent. */
1372
+ stop() {
1373
+ if (this._timer) {
1374
+ clearTimeout(this._timer);
1375
+ this._timer = void 0;
1376
+ }
1377
+ }
1378
+ };
1379
+
1309
1380
  // src/act.ts
1310
1381
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
1311
1382
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
@@ -1320,11 +1391,26 @@ var Act = class {
1320
1391
  * @param _states Merged map of state name → state definition
1321
1392
  * @param batchHandlers Static-target projection batch handlers (target → handler)
1322
1393
  * @param options Tuning knobs — see {@link ActOptions}
1394
+ * @param lanes Declared drain lanes (ACT-1103). The builder collects
1395
+ * these from `.withLane(...)` calls. Slice 1 records them on the
1396
+ * instance; later slices fan out one `DrainController` per lane.
1323
1397
  */
1324
- constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
1398
+ constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}, lanes = []) {
1325
1399
  this.registry = registry;
1326
1400
  this._states = _states;
1327
1401
  this._batch_handlers = batchHandlers;
1402
+ this._lanes = lanes;
1403
+ if (options.onlyLanes && options.onlyLanes.length > 0) {
1404
+ const declared = /* @__PURE__ */ new Set([
1405
+ "default",
1406
+ ...lanes.map((l) => l.name)
1407
+ ]);
1408
+ const unknown = options.onlyLanes.filter((l) => !declared.has(l));
1409
+ if (unknown.length > 0)
1410
+ throw new Error(
1411
+ `ActOptions.onlyLanes references undeclared lane(s): ${unknown.map((l) => `"${l}"`).join(", ")}`
1412
+ );
1413
+ }
1328
1414
  this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
1329
1415
  this._correlator = options.correlator ?? defaultCorrelator;
1330
1416
  this._es = buildEs(this._logger, this._correlator);
@@ -1337,19 +1423,44 @@ var Act = class {
1337
1423
  boundQueryArray: this._bound_query_array
1338
1424
  });
1339
1425
  this._handle_batch = buildHandleBatch(this._logger);
1340
- const { staticTargets, hasDynamicResolvers, reactiveEvents, eventToState } = classifyRegistry(this.registry, this._states);
1426
+ const {
1427
+ staticTargets,
1428
+ hasDynamicResolvers,
1429
+ reactiveEvents,
1430
+ eventToState,
1431
+ eventToLanes
1432
+ } = classifyRegistry(this.registry, this._states);
1341
1433
  this._reactive_events = reactiveEvents;
1342
1434
  this._event_to_state = eventToState;
1343
- this._drain = new DrainController({
1344
- logger: this._logger,
1345
- ops: this._cd,
1346
- registry: this.registry,
1347
- batchHandlers: this._batch_handlers,
1348
- handle: this._handle,
1349
- handleBatch: this._handle_batch,
1350
- onAcked: (acked) => this.emit("acked", acked),
1351
- onBlocked: (blocked) => this.emit("blocked", blocked)
1352
- });
1435
+ this._event_to_lanes = eventToLanes;
1436
+ const allLanes = ["default", ...lanes.map((l) => l.name)];
1437
+ const onlySet = options.onlyLanes && options.onlyLanes.length > 0 ? new Set(options.onlyLanes) : void 0;
1438
+ const activeLanes = onlySet ? allLanes.filter((n) => onlySet.has(n)) : allLanes;
1439
+ const singleDefaultLane = activeLanes.length === 1 && activeLanes[0] === "default";
1440
+ this._drain_controllers = /* @__PURE__ */ new Map();
1441
+ for (const name of activeLanes) {
1442
+ const cfg = lanes.find((l) => l.name === name);
1443
+ const controller = new DrainController({
1444
+ logger: this._logger,
1445
+ ops: this._cd,
1446
+ registry: this.registry,
1447
+ batchHandlers: this._batch_handlers,
1448
+ handle: this._handle,
1449
+ handleBatch: this._handle_batch,
1450
+ onAcked: (acked) => this.emit("acked", acked),
1451
+ onBlocked: (blocked) => this.emit("blocked", blocked),
1452
+ // Pass lane only when a true per-lane controller is active.
1453
+ // The all-lanes (single default) case keeps lane=undefined so
1454
+ // adapter SQL collapses to the pre-1103 shape.
1455
+ lane: singleDefaultLane ? void 0 : name,
1456
+ defaults: cfg && {
1457
+ streamLimit: cfg.streamLimit,
1458
+ leaseMillis: cfg.leaseMillis
1459
+ }
1460
+ });
1461
+ if (cfg?.cycleMs !== void 0) controller.start(cfg.cycleMs);
1462
+ this._drain_controllers.set(name, controller);
1463
+ }
1353
1464
  this._correlate = new CorrelateCycle(
1354
1465
  this.registry,
1355
1466
  staticTargets,
@@ -1358,7 +1469,7 @@ var Act = class {
1358
1469
  options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
1359
1470
  // Cold start: assume drain is needed (historical events may need processing)
1360
1471
  () => {
1361
- if (this._reactive_events.size > 0) this._drain.arm();
1472
+ if (this._reactive_events.size > 0) this._armAll();
1362
1473
  }
1363
1474
  );
1364
1475
  this._settle = new SettleLoop(
@@ -1378,8 +1489,8 @@ var Act = class {
1378
1489
  _emitter = new EventEmitter();
1379
1490
  /** Event names with at least one registered reaction (computed at build time) */
1380
1491
  _reactive_events;
1381
- /** Drain pipeline driver: armed flag, concurrency lock, adaptive ratio. */
1382
- _drain;
1492
+ /** One DrainController per active lane, keyed by lane name. */
1493
+ _drain_controllers;
1383
1494
  /** Correlation state machine: lazy init, dynamic-resolver scan, periodic worker. */
1384
1495
  _correlate;
1385
1496
  /** Debounced correlate→drain catch-up loop. */
@@ -1433,6 +1544,14 @@ var Act = class {
1433
1544
  * set when seeding a `restart` snapshot in multi-state apps.
1434
1545
  */
1435
1546
  _event_to_state;
1547
+ /**
1548
+ * Event-name → lane fan-in for selective arming (ACT-1103). Built by
1549
+ * `classifyRegistry` once per build. `"all"` means at least one of
1550
+ * the event's reactions is a dynamic resolver (lane opaque until
1551
+ * runtime); a `Set<string>` lists the static lanes only that event's
1552
+ * reactions target.
1553
+ */
1554
+ _event_to_lanes;
1436
1555
  /** Logger resolved at construction time (after user port configuration) */
1437
1556
  _logger = log();
1438
1557
  /** Wraps a public-method body so internal `store()`/`cache()` resolve to the
@@ -1456,6 +1575,12 @@ var Act = class {
1456
1575
  /** Reaction dispatchers built once and handed to runDrainCycle each cycle. */
1457
1576
  _handle;
1458
1577
  _handle_batch;
1578
+ /** Declared drain lanes (ACT-1103). */
1579
+ _lanes;
1580
+ /** Drain lanes declared via `.withLane(...)`. Implicit default not included. */
1581
+ get lanes() {
1582
+ return this._lanes;
1583
+ }
1459
1584
  /** True after the first `shutdown()` call. Guards idempotency. */
1460
1585
  _shutdown_promise;
1461
1586
  /**
@@ -1474,6 +1599,7 @@ var Act = class {
1474
1599
  this._emitter.removeAllListeners();
1475
1600
  this.stop_correlations();
1476
1601
  this.stop_settling();
1602
+ for (const c of this._drain_controllers.values()) c.stop();
1477
1603
  const disposer = await this._notify_disposer;
1478
1604
  if (disposer) await disposer();
1479
1605
  })();
@@ -1493,13 +1619,10 @@ var Act = class {
1493
1619
  return await s.notify((notification) => {
1494
1620
  try {
1495
1621
  this.emit("notified", notification);
1496
- const hasReactive = notification.events.some(
1497
- (e) => this._reactive_events.has(e.name)
1622
+ const armed = this._armForEventNames(
1623
+ notification.events.map((e) => e.name)
1498
1624
  );
1499
- if (hasReactive) {
1500
- this._drain.arm();
1501
- this._settle.schedule({ debounceMs: 0 });
1502
- }
1625
+ if (armed) this._settle.schedule({ debounceMs: 0 });
1503
1626
  } catch (err) {
1504
1627
  this._logger.error(err, "notified handler threw");
1505
1628
  }
@@ -1600,14 +1723,10 @@ var Act = class {
1600
1723
  reactingTo,
1601
1724
  skipValidation
1602
1725
  );
1603
- if (this._reactive_events.size > 0) {
1604
- for (const snap2 of snapshots) {
1605
- if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
1606
- this._drain.arm();
1607
- break;
1608
- }
1609
- }
1610
- }
1726
+ if (this._reactive_events.size > 0)
1727
+ this._armForEventNames(
1728
+ snapshots.map((s) => s.event.name)
1729
+ );
1611
1730
  this.emit("committed", snapshots);
1612
1731
  return snapshots;
1613
1732
  });
@@ -1754,7 +1873,59 @@ var Act = class {
1754
1873
  * @see {@link start_correlations} for automatic correlation
1755
1874
  */
1756
1875
  async drain(options = {}) {
1757
- return this._scoped(() => this._drain.drain(options));
1876
+ return this._scoped(() => this._drainAll(options));
1877
+ }
1878
+ /** Arm every active lane controller (ACT-1103). */
1879
+ _armAll() {
1880
+ for (const c of this._drain_controllers.values()) c.arm();
1881
+ }
1882
+ /**
1883
+ * Arm only the lane controllers whose reactions match the supplied
1884
+ * event names (ACT-1103 selective arming). Events with any dynamic
1885
+ * resolver fall back to `_armAll()` via the `"all"` sentinel — the
1886
+ * resolver's lane isn't known until correlate runs the function.
1887
+ * Events with no reactions are skipped; `_event_to_lanes` doesn't
1888
+ * carry them. Returns true when any controller was armed (used by
1889
+ * the notify handler to decide whether to schedule a settle).
1890
+ */
1891
+ _armForEventNames(names) {
1892
+ const to_arm = /* @__PURE__ */ new Set();
1893
+ for (const name of names) {
1894
+ const set = this._event_to_lanes.get(name);
1895
+ if (set === void 0) continue;
1896
+ if (set === ALL_LANES) {
1897
+ this._armAll();
1898
+ return true;
1899
+ }
1900
+ for (const lane of set) to_arm.add(lane);
1901
+ }
1902
+ if (to_arm.size === 0) return false;
1903
+ for (const lane of to_arm) this._drain_controllers.get(lane)?.arm();
1904
+ return true;
1905
+ }
1906
+ /** Drain every active lane controller in parallel and aggregate.
1907
+ *
1908
+ * Parallel — not sequential — so a slow lane's in-flight handler does
1909
+ * not block a fast lane's claim/dispatch/ack cycle. Each controller's
1910
+ * `claim()` is independent (filtered by lane); the store's
1911
+ * `SKIP LOCKED` keeps cross-controller races safe. Lifecycle events
1912
+ * (`acked`, `blocked`) may interleave by lane — listeners filter via
1913
+ * `lease.lane`. */
1914
+ async _drainAll(options) {
1915
+ const results = await Promise.all(
1916
+ [...this._drain_controllers.values()].map((c) => c.drain(options))
1917
+ );
1918
+ const fetched = [];
1919
+ const leased = [];
1920
+ const acked = [];
1921
+ const blocked = [];
1922
+ for (const r of results) {
1923
+ fetched.push(...r.fetched);
1924
+ leased.push(...r.leased);
1925
+ acked.push(...r.acked);
1926
+ blocked.push(...r.blocked);
1927
+ }
1928
+ return { fetched, leased, acked, blocked };
1758
1929
  }
1759
1930
  /**
1760
1931
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -1925,7 +2096,7 @@ var Act = class {
1925
2096
  async reset(input) {
1926
2097
  return this._scoped(async () => {
1927
2098
  const count = await store().reset(input);
1928
- if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
2099
+ if (count > 0 && this._reactive_events.size > 0) this._armAll();
1929
2100
  return count;
1930
2101
  });
1931
2102
  }
@@ -1959,7 +2130,7 @@ var Act = class {
1959
2130
  async unblock(input) {
1960
2131
  return this._scoped(async () => {
1961
2132
  const count = await store().unblock(input);
1962
- if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
2133
+ if (count > 0 && this._reactive_events.size > 0) this._armAll();
1963
2134
  return count;
1964
2135
  });
1965
2136
  }
@@ -2132,6 +2303,22 @@ function registerBatchHandler(proj, batchHandlers) {
2132
2303
  }
2133
2304
  batchHandlers.set(proj.target, proj.batchHandler);
2134
2305
  }
2306
+ function validateLaneReferences(registry, lanes) {
2307
+ const declared = /* @__PURE__ */ new Set([DEFAULT_LANE, ...lanes.map((l) => l.name)]);
2308
+ for (const [eventName, def] of Object.entries(registry.events)) {
2309
+ const entry = def;
2310
+ for (const [handlerName, reaction] of entry.reactions) {
2311
+ const resolver = reaction.resolver;
2312
+ if (typeof resolver === "function") continue;
2313
+ const lane = resolver.lane;
2314
+ if (lane && !declared.has(lane)) {
2315
+ throw new Error(
2316
+ `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.`
2317
+ );
2318
+ }
2319
+ }
2320
+ }
2321
+ }
2135
2322
  function act() {
2136
2323
  const states = /* @__PURE__ */ new Map();
2137
2324
  const registry = {
@@ -2140,6 +2327,7 @@ function act() {
2140
2327
  };
2141
2328
  const pendingProjections = [];
2142
2329
  const batchHandlers = /* @__PURE__ */ new Map();
2330
+ const lanes = [];
2143
2331
  let _built = false;
2144
2332
  const finalizeDeprecations = () => {
2145
2333
  const deprecationSummary = [];
@@ -2186,6 +2374,18 @@ function act() {
2186
2374
  }
2187
2375
  mergeEventRegister(registry.events, input.events);
2188
2376
  pendingProjections.push(...input.projections);
2377
+ for (const sliceLane of input.lanes) {
2378
+ const existing = lanes.find((l) => l.name === sliceLane.name);
2379
+ if (!existing) {
2380
+ lanes.push(sliceLane);
2381
+ continue;
2382
+ }
2383
+ if (existing.leaseMillis !== sliceLane.leaseMillis || existing.streamLimit !== sliceLane.streamLimit || existing.cycleMs !== sliceLane.cycleMs) {
2384
+ throw new Error(
2385
+ `Lane "${sliceLane.name}" was already declared with a different config`
2386
+ );
2387
+ }
2388
+ }
2189
2389
  return builder;
2190
2390
  },
2191
2391
  withProjection: (proj) => {
@@ -2194,6 +2394,14 @@ function act() {
2194
2394
  return builder;
2195
2395
  },
2196
2396
  withActor: () => builder,
2397
+ withLane: (config2) => {
2398
+ if (config2.name === DEFAULT_LANE)
2399
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
2400
+ if (lanes.some((l) => l.name === config2.name))
2401
+ throw new Error(`Lane "${config2.name}" was already declared`);
2402
+ lanes.push(config2);
2403
+ return builder;
2404
+ },
2197
2405
  on: (event) => ({
2198
2406
  do: (handler, options) => {
2199
2407
  const reaction = {
@@ -2225,13 +2433,15 @@ function act() {
2225
2433
  registerBatchHandler(proj, batchHandlers);
2226
2434
  }
2227
2435
  finalizeDeprecations();
2436
+ validateLaneReferences(registry, lanes);
2228
2437
  _built = true;
2229
2438
  }
2230
2439
  return new Act(
2231
2440
  registry,
2232
2441
  states,
2233
2442
  batchHandlers,
2234
- options
2443
+ options,
2444
+ lanes
2235
2445
  );
2236
2446
  },
2237
2447
  events: registry.events
@@ -2312,6 +2522,7 @@ function slice() {
2312
2522
  const actions = {};
2313
2523
  const events = {};
2314
2524
  const projections = [];
2525
+ const lanes = [];
2315
2526
  const builder = {
2316
2527
  withState: (state2) => {
2317
2528
  registerState(state2, states, actions, events);
@@ -2321,6 +2532,14 @@ function slice() {
2321
2532
  projections.push(proj);
2322
2533
  return builder;
2323
2534
  },
2535
+ withLane: (config2) => {
2536
+ if (config2.name === DEFAULT_LANE)
2537
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
2538
+ if (lanes.some((l) => l.name === config2.name))
2539
+ throw new Error(`Lane "${config2.name}" was already declared`);
2540
+ lanes.push(config2);
2541
+ return builder;
2542
+ },
2324
2543
  on: (event) => ({
2325
2544
  do: (handler, options) => {
2326
2545
  const reaction = {
@@ -2349,7 +2568,8 @@ function slice() {
2349
2568
  _tag: "Slice",
2350
2569
  states,
2351
2570
  events,
2352
- projections
2571
+ projections,
2572
+ lanes
2353
2573
  }),
2354
2574
  events
2355
2575
  };
@@ -2442,6 +2662,7 @@ export {
2442
2662
  CommittedMetaSchema,
2443
2663
  ConcurrencyError,
2444
2664
  ConsoleLogger,
2665
+ DEFAULT_LANE,
2445
2666
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
2446
2667
  DEFAULT_SETTLE_DEBOUNCE_MS,
2447
2668
  Environments,