@rotorsoft/act 0.5.7 → 0.6.1

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.
package/dist/index.cjs CHANGED
@@ -48,6 +48,7 @@ __export(index_exports, {
48
48
  ValidationError: () => ValidationError,
49
49
  ZodEmpty: () => ZodEmpty,
50
50
  act: () => act,
51
+ build_tracer: () => build_tracer,
51
52
  config: () => config,
52
53
  dispose: () => dispose,
53
54
  disposeAndExit: () => disposeAndExit,
@@ -81,18 +82,24 @@ var ValidationError = class extends Error {
81
82
  }
82
83
  };
83
84
  var InvariantError = class extends Error {
84
- details;
85
- constructor(name, payload, target, description) {
86
- super(`${name} failed invariant: ${description}`);
85
+ constructor(action2, payload, target, snapshot, description) {
86
+ super(`${action2} failed invariant: ${description}`);
87
+ this.action = action2;
88
+ this.payload = payload;
89
+ this.target = target;
90
+ this.snapshot = snapshot;
91
+ this.description = description;
87
92
  this.name = Errors.InvariantError;
88
- this.details = { name, payload, target, description };
89
93
  }
90
94
  };
91
95
  var ConcurrencyError = class extends Error {
92
- constructor(lastVersion, events, expectedVersion) {
96
+ constructor(stream, lastVersion, events, expectedVersion) {
93
97
  super(
94
- `Concurrency error committing event "${events.at(0)?.name}". Expected version ${expectedVersion} but found version ${lastVersion}.`
98
+ `Concurrency error committing "${events.map((e) => `${stream}.${e.name}.${JSON.stringify(e.data)}`).join(
99
+ ", "
100
+ )}". Expected version ${expectedVersion} but found version ${lastVersion}.`
95
101
  );
102
+ this.stream = stream;
96
103
  this.lastVersion = lastVersion;
97
104
  this.events = events;
98
105
  this.expectedVersion = expectedVersion;
@@ -147,8 +154,9 @@ var QuerySchema = import_zod.z.object({
147
154
  created_before: import_zod.z.date().optional(),
148
155
  created_after: import_zod.z.date().optional(),
149
156
  backward: import_zod.z.boolean().optional(),
150
- correlation: import_zod.z.string().optional()
151
- });
157
+ correlation: import_zod.z.string().optional(),
158
+ with_snaps: import_zod.z.boolean().optional()
159
+ }).readonly();
152
160
 
153
161
  // src/types/index.ts
154
162
  var Environments = [
@@ -254,38 +262,71 @@ async function sleep(ms) {
254
262
 
255
263
  // src/adapters/InMemoryStore.ts
256
264
  var InMemoryStream = class {
257
- constructor(stream) {
265
+ stream;
266
+ source;
267
+ at = -1;
268
+ retry = -1;
269
+ blocked = false;
270
+ error = "";
271
+ leased_at = void 0;
272
+ leased_by = void 0;
273
+ leased_until = void 0;
274
+ constructor(stream, source) {
258
275
  this.stream = stream;
276
+ this.source = source;
277
+ }
278
+ get is_avaliable() {
279
+ return !this.blocked && (!this.leased_until || this.leased_until <= /* @__PURE__ */ new Date());
259
280
  }
260
- _at = -1;
261
- _retry = -1;
262
- _lease;
263
- _blocked = false;
264
281
  /**
265
282
  * Attempt to lease this stream for processing.
266
- * @param lease - Lease request.
283
+ * @param at - The end-of-lease watermark.
284
+ * @param by - The lease holder.
285
+ * @param millis - Lease duration in milliseconds.
267
286
  * @returns The granted lease or undefined if blocked.
268
287
  */
269
- lease(lease) {
270
- if (!this._blocked && lease.at > this._at) {
271
- this._lease = { ...lease, retry: this._retry + 1 };
272
- return this._lease;
288
+ lease(at, by, millis) {
289
+ if (this.is_avaliable && at > this.at) {
290
+ this.leased_at = at;
291
+ this.leased_by = by;
292
+ this.leased_until = new Date(Date.now() + millis);
293
+ millis > 0 && (this.retry = this.retry + 1);
294
+ return {
295
+ stream: this.stream,
296
+ source: this.source,
297
+ at,
298
+ by,
299
+ retry: this.retry
300
+ };
273
301
  }
274
302
  }
275
303
  /**
276
304
  * Acknowledge completion of processing for this stream.
277
- * @param lease - Lease to acknowledge.
305
+ * @param at - Last processed watermark.
306
+ * @param by - Lease holder that processed the watermark.
278
307
  */
279
- ack(lease) {
280
- if (this._lease && lease.at >= this._at) {
281
- this._retry = lease.retry;
282
- this._blocked = lease.block;
283
- if (!this._blocked && !lease.error) {
284
- this._at = lease.at;
285
- this._retry = 0;
286
- }
287
- this._lease = void 0;
308
+ ack(at, by) {
309
+ if (this.leased_by === by && at >= this.at) {
310
+ this.leased_at = void 0;
311
+ this.leased_by = void 0;
312
+ this.leased_until = void 0;
313
+ this.at = at;
314
+ this.retry = -1;
315
+ return true;
316
+ }
317
+ return false;
318
+ }
319
+ /**
320
+ * Block a stream for processing after failing to process and reaching max retries with blocking enabled.
321
+ * @param error Blocked error message.
322
+ */
323
+ block(by, error) {
324
+ if (this.leased_by === by) {
325
+ this.blocked = true;
326
+ this.error = error;
327
+ return true;
288
328
  }
329
+ return false;
289
330
  }
290
331
  };
291
332
  var InMemoryStore = class {
@@ -315,6 +356,7 @@ var InMemoryStore = class {
315
356
  async drop() {
316
357
  await sleep();
317
358
  this._events.length = 0;
359
+ this._streams = /* @__PURE__ */ new Map();
318
360
  }
319
361
  /**
320
362
  * Query events in the store, optionally filtered by query options.
@@ -332,15 +374,17 @@ var InMemoryStore = class {
332
374
  limit,
333
375
  created_before,
334
376
  created_after,
335
- correlation
377
+ correlation,
378
+ with_snaps = false
336
379
  } = query || {};
337
380
  let i = after + 1, count = 0;
338
381
  while (i < this._events.length) {
339
382
  const e = this._events[i++];
340
- if (stream && e.stream !== stream) continue;
383
+ if (stream && !RegExp(`^${stream}$`).test(e.stream)) continue;
341
384
  if (names && !names.includes(e.name)) continue;
342
385
  if (correlation && e.meta?.correlation !== correlation) continue;
343
386
  if (created_after && e.created <= created_after) continue;
387
+ if (e.name === SNAP_EVENT && !with_snaps) continue;
344
388
  if (before && e.id >= before) break;
345
389
  if (created_before && e.created >= created_before) break;
346
390
  callback(e);
@@ -361,12 +405,14 @@ var InMemoryStore = class {
361
405
  async commit(stream, msgs, meta, expectedVersion) {
362
406
  await sleep();
363
407
  const instance = this._events.filter((e) => e.stream === stream);
364
- if (typeof expectedVersion === "number" && instance.length - 1 !== expectedVersion)
408
+ if (typeof expectedVersion === "number" && instance.length - 1 !== expectedVersion) {
365
409
  throw new ConcurrencyError(
410
+ stream,
366
411
  instance.length - 1,
367
412
  msgs,
368
413
  expectedVersion
369
414
  );
415
+ }
370
416
  let version = instance.length;
371
417
  return msgs.map(({ name, data }) => {
372
418
  const committed = {
@@ -384,43 +430,49 @@ var InMemoryStore = class {
384
430
  });
385
431
  }
386
432
  /**
387
- * Fetches new events from stream watermarks for processing.
388
- * @param limit - Maximum number of streams to fetch.
389
- * @returns Fetched streams and events.
433
+ * Polls the store for unblocked streams needing processing, ordered by lease watermark ascending.
434
+ * @param limit - Maximum number of streams to poll.
435
+ * @param descending - Whether to poll streams in descending order (aka poll the most advanced first).
436
+ * @returns The polled streams.
390
437
  */
391
- async fetch(limit) {
392
- const streams = [...this._streams.values()].filter((s) => !s._blocked).sort((a, b) => a._at - b._at).slice(0, limit);
393
- const after = streams.length ? streams.reduce(
394
- (min, s) => Math.min(min, s._at),
395
- Number.MAX_SAFE_INTEGER
396
- ) : -1;
397
- const events = [];
398
- await this.query((e) => e.name !== SNAP_EVENT && events.push(e), {
399
- after,
400
- limit
401
- });
402
- return { streams: streams.map(({ stream }) => stream), events };
438
+ async poll(limit, descending = false) {
439
+ await sleep();
440
+ return [...this._streams.values()].filter((s) => s.is_avaliable).sort((a, b) => descending ? b.at - a.at : a.at - b.at).slice(0, limit).map(({ stream, source, at }) => ({ stream, source, at }));
403
441
  }
404
442
  /**
405
443
  * Lease streams for processing (e.g., for distributed consumers).
406
- * @param leases - Lease requests.
444
+ * @param leases - Lease requests for streams, including end-of-lease watermark, lease holder, and source stream.
445
+ * @param leaseMilis - Lease duration in milliseconds.
407
446
  * @returns Granted leases.
408
447
  */
409
- async lease(leases) {
448
+ async lease(leases, millis) {
410
449
  await sleep();
411
- return leases.map((lease) => {
412
- const stream = this._streams.get(lease.stream) || // store new correlations
413
- this._streams.set(lease.stream, new InMemoryStream(lease.stream)).get(lease.stream);
414
- return stream.lease(lease);
450
+ return leases.map(({ stream, at, by, source }) => {
451
+ const found = this._streams.get(stream) || // store new correlations
452
+ this._streams.set(stream, new InMemoryStream(stream, source)).get(stream);
453
+ return found.lease(at, by, millis);
415
454
  }).filter((l) => !!l);
416
455
  }
417
456
  /**
418
457
  * Acknowledge completion of processing for leased streams.
419
- * @param leases - Leases to acknowledge.
458
+ * @param leases - Leases to acknowledge, including last processed watermark and lease holder.
420
459
  */
421
460
  async ack(leases) {
422
461
  await sleep();
423
- leases.forEach((lease) => this._streams.get(lease.stream)?.ack(lease));
462
+ return leases.filter(
463
+ (lease) => this._streams.get(lease.stream)?.ack(lease.at, lease.by)
464
+ );
465
+ }
466
+ /**
467
+ * Block a stream for processing after failing to process and reaching max retries with blocking enabled.
468
+ * @param leases - Leases to block, including lease holder and last error message.
469
+ * @returns Blocked leases.
470
+ */
471
+ async block(leases) {
472
+ await sleep();
473
+ return leases.filter(
474
+ (lease) => this._streams.get(lease.stream)?.block(lease.by, lease.error)
475
+ );
424
476
  }
425
477
  };
426
478
 
@@ -469,6 +521,62 @@ var SNAP_EVENT = "__snapshot__";
469
521
  var store = port(function store2(adapter) {
470
522
  return adapter || new InMemoryStore();
471
523
  });
524
+ function build_tracer(logLevel2) {
525
+ if (logLevel2 === "trace") {
526
+ return {
527
+ fetched: (fetched) => {
528
+ const data = Object.fromEntries(
529
+ fetched.map(({ stream, source, events }) => {
530
+ const key = source ? `${stream}<-${source}` : stream;
531
+ const value = Object.fromEntries(
532
+ events.map(({ id, stream: stream2, name }) => [id, { [stream2]: name }])
533
+ );
534
+ return [key, value];
535
+ })
536
+ );
537
+ logger.trace(data, "\u26A1\uFE0F fetch");
538
+ },
539
+ correlated: (leases) => {
540
+ const data = leases.map(({ stream }) => stream).join(" ");
541
+ logger.trace(`\u26A1\uFE0F correlate ${data}`);
542
+ },
543
+ leased: (leases) => {
544
+ const data = Object.fromEntries(
545
+ leases.map(({ stream, at, retry }) => [stream, { at, retry }])
546
+ );
547
+ logger.trace(data, "\u26A1\uFE0F lease");
548
+ },
549
+ acked: (leases) => {
550
+ const data = Object.fromEntries(
551
+ leases.map(({ stream, at, retry }) => [stream, { at, retry }])
552
+ );
553
+ logger.trace(data, "\u26A1\uFE0F ack");
554
+ },
555
+ blocked: (leases) => {
556
+ const data = Object.fromEntries(
557
+ leases.map(({ stream, at, retry, error }) => [
558
+ stream,
559
+ { at, retry, error }
560
+ ])
561
+ );
562
+ logger.trace(data, "\u26A1\uFE0F block");
563
+ }
564
+ };
565
+ } else {
566
+ return {
567
+ fetched: () => {
568
+ },
569
+ correlated: () => {
570
+ },
571
+ leased: () => {
572
+ },
573
+ acked: () => {
574
+ },
575
+ blocked: () => {
576
+ }
577
+ };
578
+ }
579
+ }
472
580
 
473
581
  // src/signals.ts
474
582
  process.once("SIGINT", async (arg) => {
@@ -530,21 +638,21 @@ async function load(me, stream, callback) {
530
638
  }
531
639
  callback && callback({ event, state: state2, patches, snaps });
532
640
  },
533
- { stream },
534
- true
641
+ { stream, with_snaps: true }
535
642
  );
536
- logger.trace({ stream, patches, snaps, state: state2 }, "\u{1F7E2} load");
643
+ logger.trace(state2, `\u{1F7E2} load ${stream}`);
537
644
  return { event, state: state2, patches, snaps };
538
645
  }
539
646
  async function action(me, action2, target, payload, reactingTo, skipValidation = false) {
540
647
  const { stream, expectedVersion, actor } = target;
541
648
  if (!stream) throw new Error("Missing target stream");
542
649
  payload = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
650
+ const snapshot = await load(me, stream);
651
+ const expected = expectedVersion || snapshot.event?.version;
543
652
  logger.trace(
544
653
  payload,
545
- `\u{1F535} ${action2} "${stream}${expectedVersion ? `@${expectedVersion}` : ""}"`
654
+ `\u{1F535} ${stream}.${action2}${typeof expected === "number" ? `.${expected}` : ""}`
546
655
  );
547
- let snapshot = await load(me, stream);
548
656
  if (me.given) {
549
657
  const invariants = me.given[action2] || [];
550
658
  invariants.forEach(({ valid, description }) => {
@@ -553,15 +661,15 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
553
661
  action2,
554
662
  payload,
555
663
  target,
664
+ snapshot,
556
665
  description
557
666
  );
558
667
  });
559
668
  }
560
- let { state: state2, patches } = snapshot;
561
- const result = me.on[action2](payload, state2, target);
562
- if (!result) return snapshot;
669
+ const result = me.on[action2](payload, snapshot, target);
670
+ if (!result) return [snapshot];
563
671
  if (Array.isArray(result) && result.length === 0) {
564
- return snapshot;
672
+ return [snapshot];
565
673
  }
566
674
  const tuples = Array.isArray(result[0]) ? result : [result];
567
675
  const emitted = tuples.map(([name, data]) => ({
@@ -584,36 +692,47 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
584
692
  } : void 0
585
693
  }
586
694
  };
695
+ logger.trace(
696
+ emitted.map((e) => e.data),
697
+ `\u{1F534} commit ${stream}.${emitted.map((e) => e.name).join(", ")}`
698
+ );
587
699
  const committed = await store().commit(
588
700
  stream,
589
701
  emitted,
590
702
  meta,
591
703
  // TODO: review reactions not enforcing expected version
592
- reactingTo ? void 0 : expectedVersion || snapshot.event?.version
704
+ reactingTo ? void 0 : expected
593
705
  );
594
- snapshot = committed.map((event) => {
706
+ let { state: state2, patches } = snapshot;
707
+ const snapshots = committed.map((event) => {
595
708
  state2 = patch(state2, me.patch[event.name](event, state2));
596
709
  patches++;
597
- logger.trace({ event, state: state2 }, "\u{1F534} commit");
598
710
  return { event, state: state2, patches, snaps: snapshot.snaps };
599
- }).at(-1);
600
- me.snap && me.snap(snapshot) && void snap(snapshot);
601
- return snapshot;
711
+ });
712
+ const last = snapshots.at(-1);
713
+ me.snap && me.snap(last) && void snap(last);
714
+ return snapshots;
602
715
  }
603
716
 
604
717
  // src/act.ts
718
+ var tracer = build_tracer(config().logLevel);
605
719
  var Act = class {
606
720
  /**
607
721
  * Create a new Act orchestrator.
608
722
  *
609
723
  * @param registry The registry of state, event, and action schemas
610
- * @param drainLimit The maximum number of events to drain per cycle
611
724
  */
612
- constructor(registry, drainLimit) {
725
+ constructor(registry) {
613
726
  this.registry = registry;
614
- this.drainLimit = drainLimit;
727
+ dispose(() => {
728
+ this._emitter.removeAllListeners();
729
+ this.stop_correlations();
730
+ return Promise.resolve();
731
+ });
615
732
  }
616
733
  _emitter = new import_events.default();
734
+ _drain_locked = false;
735
+ _correlation_interval = void 0;
617
736
  emit(event, args) {
618
737
  return this._emitter.emit(event, args);
619
738
  }
@@ -640,16 +759,17 @@ var Act = class {
640
759
  * await app.do("increment", { stream: "counter1", actor }, { by: 1 });
641
760
  */
642
761
  async do(action2, target, payload, reactingTo, skipValidation = false) {
643
- const snapshot = await action(
762
+ const snapshots = await action(
644
763
  this.registry.actions[action2],
645
764
  action2,
646
765
  target,
647
766
  payload,
767
+ // @ts-expect-error type lost
648
768
  reactingTo,
649
769
  skipValidation
650
770
  );
651
- this.emit("committed", snapshot);
652
- return snapshot;
771
+ this.emit("committed", snapshots);
772
+ return snapshots;
653
773
  }
654
774
  /**
655
775
  * Loads the current state snapshot for a given state machine and stream.
@@ -687,38 +807,58 @@ var Act = class {
687
807
  }, query);
688
808
  return { first, last, count };
689
809
  }
810
+ /**
811
+ * Query the event store for events matching a filter.
812
+ * Use this version with caution, as it return events in memory.
813
+ *
814
+ * @param query The query filter (e.g., by stream, event name, or time range)
815
+ * @returns The matching events
816
+ *
817
+ * @example
818
+ * const { count } = await app.query({ stream: "counter1" }, (event) => console.log(event));
819
+ */
820
+ async query_array(query) {
821
+ const events = [];
822
+ await store().query((e) => events.push(e), query);
823
+ return events;
824
+ }
690
825
  /**
691
826
  * Handles leased reactions.
692
827
  *
828
+ * This is called by the main `drain` loop after fetching new events.
829
+ * It handles reactions, supporting retries, blocking, and error handling.
830
+ *
693
831
  * @internal
694
832
  * @param lease The lease to handle
695
- * @param reactions The reactions to handle
696
- * @returns The lease
833
+ * @param payloads The reactions to handle
834
+ * @returns The lease with results
697
835
  */
698
- async handle(lease, reactions) {
836
+ async handle(lease, payloads) {
837
+ if (payloads.length === 0) return { lease, at: lease.at };
699
838
  const stream = lease.stream;
700
- lease.retry > 0 && logger.warn(`Retrying ${stream}@${lease.at} (${lease.retry}).`);
701
- for (const reaction of reactions) {
702
- const { event, handler, options } = reaction;
839
+ let at = payloads.at(0).event.id, handled = 0;
840
+ lease.retry > 0 && logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
841
+ for (const payload of payloads) {
842
+ const { event, handler, options } = payload;
703
843
  try {
704
844
  await handler(event, stream);
705
- lease.at = event.id;
845
+ at = event.id;
846
+ handled++;
706
847
  } catch (error) {
707
- lease.error = error;
708
- if (error instanceof ValidationError)
709
- logger.error({ stream, error }, error.message);
710
- else logger.error(error);
711
- if (lease.retry < options.maxRetries) lease.retry++;
712
- else if (options.blockOnError) {
713
- lease.block = true;
714
- logger.error(`Blocked ${stream} after ${lease.retry} retries.`);
715
- }
716
- break;
848
+ logger.error(error);
849
+ const block = lease.retry >= options.maxRetries && options.blockOnError;
850
+ block && logger.error(`Blocking ${stream} after ${lease.retry} retries.`);
851
+ return {
852
+ lease,
853
+ at,
854
+ // only report error when nothing was handled
855
+ error: handled === 0 ? error.message : void 0,
856
+ block
857
+ };
717
858
  }
718
859
  }
719
- return lease;
860
+ return { lease, at };
720
861
  }
721
- drainLocked = false;
722
862
  /**
723
863
  * Drains and processes events from the store, triggering reactions and updating state.
724
864
  *
@@ -729,91 +869,173 @@ var Act = class {
729
869
  * @example
730
870
  * await app.drain();
731
871
  */
732
- async drain() {
733
- if (this.drainLocked) return 0;
734
- this.drainLocked = true;
735
- const drained = [];
736
- const { streams, events } = await store().fetch(this.drainLimit);
737
- if (events.length) {
738
- logger.trace(
739
- events.map(({ id, stream, name }) => ({ id, stream, name })).reduce(
740
- (a, { id, stream, name }) => ({ ...a, [id]: { [stream]: name } }),
741
- {}
742
- ),
743
- "\u26A1\uFE0F fetch"
744
- );
745
- const resolved = new Set(streams);
746
- const correlated = /* @__PURE__ */ new Map();
747
- for (const event of events) {
748
- const register = this.registry.events[event.name];
749
- if (!register) continue;
750
- for (const reaction of register.reactions.values()) {
751
- const stream = typeof reaction.resolver === "string" ? reaction.resolver : reaction.resolver(event);
752
- if (stream) {
753
- resolved.add(stream);
754
- (correlated.get(stream) || correlated.set(stream, []).get(stream)).push({ ...reaction, event });
872
+ async drain({
873
+ streamLimit = 10,
874
+ eventLimit = 10,
875
+ leaseMillis = 1e4,
876
+ descending = false
877
+ } = {}) {
878
+ if (!this._drain_locked) {
879
+ try {
880
+ this._drain_locked = true;
881
+ const polled = await store().poll(streamLimit, descending);
882
+ const fetched = await Promise.all(
883
+ polled.map(async ({ stream, source, at }) => {
884
+ const events = await this.query_array({
885
+ stream: source,
886
+ after: at,
887
+ limit: eventLimit
888
+ });
889
+ return { stream, source, events };
890
+ })
891
+ );
892
+ fetched.length && tracer.fetched(fetched);
893
+ const [last_at, count] = fetched.reduce(
894
+ ([last_at2, count2], { events }) => [
895
+ Math.max(last_at2, events.at(-1)?.id || 0),
896
+ count2 + events.length
897
+ ],
898
+ [0, 0]
899
+ );
900
+ if (count > 0) {
901
+ const leases = /* @__PURE__ */ new Map();
902
+ fetched.forEach(({ stream, events }) => {
903
+ const payloads = events.flatMap((event) => {
904
+ const register = this.registry.events[event.name];
905
+ if (!register) return [];
906
+ return [...register.reactions.values()].filter((reaction) => {
907
+ const resolved = typeof reaction.resolver === "function" ? (
908
+ // @ts-expect-error index by key
909
+ reaction.resolver(event)
910
+ ) : reaction.resolver;
911
+ return resolved && resolved.target === stream;
912
+ }).map((reaction) => ({ ...reaction, event }));
913
+ });
914
+ leases.set(stream, {
915
+ lease: {
916
+ stream,
917
+ by: (0, import_crypto2.randomUUID)(),
918
+ at: events.at(-1)?.id || last_at,
919
+ // move the lease watermark forward when no events found in window
920
+ retry: 0
921
+ },
922
+ // @ts-expect-error indexed by key
923
+ payloads
924
+ });
925
+ });
926
+ if (leases.size) {
927
+ const leased = await store().lease(
928
+ [...leases.values()].map((l) => l.lease),
929
+ leaseMillis
930
+ );
931
+ if (leased.length) {
932
+ tracer.leased(leased);
933
+ const handled = await Promise.all(
934
+ leased.map(
935
+ (lease) => this.handle(lease, leases.get(lease.stream).payloads)
936
+ )
937
+ );
938
+ const acked = await store().ack(
939
+ handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
940
+ );
941
+ if (acked.length) {
942
+ tracer.acked(acked);
943
+ this.emit("acked", acked);
944
+ }
945
+ const blocked = await store().block(
946
+ handled.filter(({ block }) => block).map(({ lease, error }) => ({ ...lease, error }))
947
+ );
948
+ if (blocked.length) {
949
+ tracer.blocked(blocked);
950
+ this.emit("blocked", blocked);
951
+ }
952
+ return { leased, acked, blocked };
953
+ }
755
954
  }
756
955
  }
956
+ } catch (error) {
957
+ logger.error(error);
958
+ } finally {
959
+ this._drain_locked = false;
757
960
  }
758
- const last = events.at(-1).id;
759
- const leases = [...resolved.values()].map((stream) => ({
760
- by: (0, import_crypto2.randomUUID)(),
961
+ }
962
+ return { leased: [], acked: [], blocked: [] };
963
+ }
964
+ /**
965
+ * Correlates streams using reaction resolvers.
966
+ * @param query - The query filter (e.g., by stream, event name, or starting point).
967
+ * @returns The leases of newly correlated streams, and the last seen event ID.
968
+ */
969
+ async correlate(query = { after: -1, limit: 10 }) {
970
+ const correlated = /* @__PURE__ */ new Map();
971
+ let last_id = query.after || -1;
972
+ await store().query((event) => {
973
+ last_id = event.id;
974
+ const register = this.registry.events[event.name];
975
+ if (register) {
976
+ for (const reaction of register.reactions.values()) {
977
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
978
+ resolved && (correlated.get(resolved.target) || correlated.set(resolved.target, []).get(resolved.target)).push({ ...reaction, source: resolved.source, event });
979
+ }
980
+ }
981
+ }, query);
982
+ if (correlated.size) {
983
+ const leases = [...correlated.entries()].map(([stream, payloads]) => ({
761
984
  stream,
762
- at: last,
985
+ // TODO: by convention, the first defined source wins (this can be tricky)
986
+ source: payloads.find((p) => p.source)?.source || void 0,
987
+ by: (0, import_crypto2.randomUUID)(),
988
+ at: 0,
763
989
  retry: 0,
764
- block: false
990
+ payloads
765
991
  }));
766
- const leased = await store().lease(leases);
767
- logger.trace(
768
- leased.map(({ stream, at, retry }) => ({ stream, at, retry })).reduce(
769
- (a, { stream, at, retry }) => ({ ...a, [stream]: { at, retry } }),
770
- {}
771
- ),
772
- "\u26A1\uFE0F lease"
773
- );
774
- const handling = leased.map((lease) => ({
775
- lease,
776
- reactions: correlated.get(lease.stream) || []
777
- })).filter(({ reactions }) => reactions.length);
778
- if (handling.length) {
779
- await Promise.allSettled(
780
- handling.map(({ lease, reactions }) => this.handle(lease, reactions))
781
- ).then(
782
- (promise) => {
783
- promise.forEach((result) => {
784
- if (result.status === "rejected") logger.error(result.reason);
785
- else if (!result.value.error) drained.push(result.value);
786
- });
787
- },
788
- (error) => logger.error(error)
789
- );
790
- drained.length && this.emit("drained", drained);
791
- }
792
- await store().ack(leased);
793
- logger.trace(
794
- leased.map(({ stream, at, retry, block, error }) => ({
795
- stream,
796
- at,
797
- retry,
798
- block,
799
- error
800
- })).reduce(
801
- (a, { stream, at, retry, block, error }) => ({
802
- ...a,
803
- [stream]: { at, retry, block, error }
804
- }),
805
- {}
806
- ),
807
- "\u26A1\uFE0F ack"
808
- );
992
+ const leased = await store().lease(leases, 0);
993
+ leased.length && tracer.correlated(leased);
994
+ return { leased, last_id };
995
+ }
996
+ return { leased: [], last_id };
997
+ }
998
+ /**
999
+ * Starts correlation worker that identifies and registers new streams using reaction resolvers.
1000
+ *
1001
+ * Enables "dynamic reactions", allowing streams to be auto-discovered based on event content.
1002
+ * - Uses a correlation sliding window over the event stream to identify new streams.
1003
+ * - Once registered, these streams are picked up by the main `drain` loop.
1004
+ * - Users should have full control over their correlation strategy.
1005
+ * - The starting point keeps increasing with each new batch of events.
1006
+ * - Users are responsible for storing the last seen event ID.
1007
+ *
1008
+ * @param query - The query filter (e.g., by stream, event name, or starting point).
1009
+ * @param frequency - The frequency of correlation checks (in milliseconds).
1010
+ * @param callback - Callback to report stats (new strems, last seen event ID, etc.).
1011
+ * @returns true if the correlation worker started, false otherwise (already started).
1012
+ */
1013
+ start_correlations(query = {}, frequency = 1e4, callback) {
1014
+ if (this._correlation_interval) return false;
1015
+ const limit = query.limit || 100;
1016
+ let after = query.after || -1;
1017
+ this._correlation_interval = setInterval(
1018
+ () => this.correlate({ ...query, after, limit }).then((result) => {
1019
+ after = result.last_id;
1020
+ if (callback && result.leased.length) callback(result.leased);
1021
+ }).catch(console.error),
1022
+ frequency
1023
+ );
1024
+ return true;
1025
+ }
1026
+ stop_correlations() {
1027
+ if (this._correlation_interval) {
1028
+ clearInterval(this._correlation_interval);
1029
+ this._correlation_interval = void 0;
809
1030
  }
810
- this.drainLocked = false;
811
- return drained.length;
812
1031
  }
813
1032
  };
814
1033
 
815
1034
  // src/act-builder.ts
816
- var _this_ = ({ stream }) => stream;
1035
+ var _this_ = ({ stream }) => ({
1036
+ source: stream,
1037
+ target: stream
1038
+ });
817
1039
  var _void_ = () => void 0;
818
1040
  function act(states = /* @__PURE__ */ new Set(), registry = {
819
1041
  actions: {},
@@ -865,8 +1087,7 @@ function act(states = /* @__PURE__ */ new Set(), registry = {
865
1087
  resolver: _this_,
866
1088
  options: {
867
1089
  blockOnError: options?.blockOnError ?? true,
868
- maxRetries: options?.maxRetries ?? 3,
869
- retryDelayMs: options?.retryDelayMs ?? 1e3
1090
+ maxRetries: options?.maxRetries ?? 3
870
1091
  }
871
1092
  };
872
1093
  registry.events[event].reactions.set(handler.name, reaction);
@@ -875,7 +1096,7 @@ function act(states = /* @__PURE__ */ new Set(), registry = {
875
1096
  to(resolver) {
876
1097
  registry.events[event].reactions.set(handler.name, {
877
1098
  ...reaction,
878
- resolver
1099
+ resolver: typeof resolver === "string" ? { target: resolver } : resolver
879
1100
  });
880
1101
  return builder;
881
1102
  },
@@ -889,7 +1110,7 @@ function act(states = /* @__PURE__ */ new Set(), registry = {
889
1110
  };
890
1111
  }
891
1112
  }),
892
- build: (drainLimit = 10) => new Act(registry, drainLimit),
1113
+ build: () => new Act(registry),
893
1114
  events: registry.events
894
1115
  };
895
1116
  return builder;
@@ -970,6 +1191,7 @@ function action_builder(state2) {
970
1191
  ValidationError,
971
1192
  ZodEmpty,
972
1193
  act,
1194
+ build_tracer,
973
1195
  config,
974
1196
  dispose,
975
1197
  disposeAndExit,