@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.js CHANGED
@@ -17,18 +17,24 @@ var ValidationError = class extends Error {
17
17
  }
18
18
  };
19
19
  var InvariantError = class extends Error {
20
- details;
21
- constructor(name, payload, target, description) {
22
- super(`${name} failed invariant: ${description}`);
20
+ constructor(action2, payload, target, snapshot, description) {
21
+ super(`${action2} failed invariant: ${description}`);
22
+ this.action = action2;
23
+ this.payload = payload;
24
+ this.target = target;
25
+ this.snapshot = snapshot;
26
+ this.description = description;
23
27
  this.name = Errors.InvariantError;
24
- this.details = { name, payload, target, description };
25
28
  }
26
29
  };
27
30
  var ConcurrencyError = class extends Error {
28
- constructor(lastVersion, events, expectedVersion) {
31
+ constructor(stream, lastVersion, events, expectedVersion) {
29
32
  super(
30
- `Concurrency error committing event "${events.at(0)?.name}". Expected version ${expectedVersion} but found version ${lastVersion}.`
33
+ `Concurrency error committing "${events.map((e) => `${stream}.${e.name}.${JSON.stringify(e.data)}`).join(
34
+ ", "
35
+ )}". Expected version ${expectedVersion} but found version ${lastVersion}.`
31
36
  );
37
+ this.stream = stream;
32
38
  this.lastVersion = lastVersion;
33
39
  this.events = events;
34
40
  this.expectedVersion = expectedVersion;
@@ -83,8 +89,9 @@ var QuerySchema = z.object({
83
89
  created_before: z.date().optional(),
84
90
  created_after: z.date().optional(),
85
91
  backward: z.boolean().optional(),
86
- correlation: z.string().optional()
87
- });
92
+ correlation: z.string().optional(),
93
+ with_snaps: z.boolean().optional()
94
+ }).readonly();
88
95
 
89
96
  // src/types/index.ts
90
97
  var Environments = [
@@ -190,38 +197,71 @@ async function sleep(ms) {
190
197
 
191
198
  // src/adapters/InMemoryStore.ts
192
199
  var InMemoryStream = class {
193
- constructor(stream) {
200
+ stream;
201
+ source;
202
+ at = -1;
203
+ retry = -1;
204
+ blocked = false;
205
+ error = "";
206
+ leased_at = void 0;
207
+ leased_by = void 0;
208
+ leased_until = void 0;
209
+ constructor(stream, source) {
194
210
  this.stream = stream;
211
+ this.source = source;
212
+ }
213
+ get is_avaliable() {
214
+ return !this.blocked && (!this.leased_until || this.leased_until <= /* @__PURE__ */ new Date());
195
215
  }
196
- _at = -1;
197
- _retry = -1;
198
- _lease;
199
- _blocked = false;
200
216
  /**
201
217
  * Attempt to lease this stream for processing.
202
- * @param lease - Lease request.
218
+ * @param at - The end-of-lease watermark.
219
+ * @param by - The lease holder.
220
+ * @param millis - Lease duration in milliseconds.
203
221
  * @returns The granted lease or undefined if blocked.
204
222
  */
205
- lease(lease) {
206
- if (!this._blocked && lease.at > this._at) {
207
- this._lease = { ...lease, retry: this._retry + 1 };
208
- return this._lease;
223
+ lease(at, by, millis) {
224
+ if (this.is_avaliable && at > this.at) {
225
+ this.leased_at = at;
226
+ this.leased_by = by;
227
+ this.leased_until = new Date(Date.now() + millis);
228
+ millis > 0 && (this.retry = this.retry + 1);
229
+ return {
230
+ stream: this.stream,
231
+ source: this.source,
232
+ at,
233
+ by,
234
+ retry: this.retry
235
+ };
209
236
  }
210
237
  }
211
238
  /**
212
239
  * Acknowledge completion of processing for this stream.
213
- * @param lease - Lease to acknowledge.
240
+ * @param at - Last processed watermark.
241
+ * @param by - Lease holder that processed the watermark.
214
242
  */
215
- ack(lease) {
216
- if (this._lease && lease.at >= this._at) {
217
- this._retry = lease.retry;
218
- this._blocked = lease.block;
219
- if (!this._blocked && !lease.error) {
220
- this._at = lease.at;
221
- this._retry = 0;
222
- }
223
- this._lease = void 0;
243
+ ack(at, by) {
244
+ if (this.leased_by === by && at >= this.at) {
245
+ this.leased_at = void 0;
246
+ this.leased_by = void 0;
247
+ this.leased_until = void 0;
248
+ this.at = at;
249
+ this.retry = -1;
250
+ return true;
251
+ }
252
+ return false;
253
+ }
254
+ /**
255
+ * Block a stream for processing after failing to process and reaching max retries with blocking enabled.
256
+ * @param error Blocked error message.
257
+ */
258
+ block(by, error) {
259
+ if (this.leased_by === by) {
260
+ this.blocked = true;
261
+ this.error = error;
262
+ return true;
224
263
  }
264
+ return false;
225
265
  }
226
266
  };
227
267
  var InMemoryStore = class {
@@ -251,6 +291,7 @@ var InMemoryStore = class {
251
291
  async drop() {
252
292
  await sleep();
253
293
  this._events.length = 0;
294
+ this._streams = /* @__PURE__ */ new Map();
254
295
  }
255
296
  /**
256
297
  * Query events in the store, optionally filtered by query options.
@@ -268,15 +309,17 @@ var InMemoryStore = class {
268
309
  limit,
269
310
  created_before,
270
311
  created_after,
271
- correlation
312
+ correlation,
313
+ with_snaps = false
272
314
  } = query || {};
273
315
  let i = after + 1, count = 0;
274
316
  while (i < this._events.length) {
275
317
  const e = this._events[i++];
276
- if (stream && e.stream !== stream) continue;
318
+ if (stream && !RegExp(`^${stream}$`).test(e.stream)) continue;
277
319
  if (names && !names.includes(e.name)) continue;
278
320
  if (correlation && e.meta?.correlation !== correlation) continue;
279
321
  if (created_after && e.created <= created_after) continue;
322
+ if (e.name === SNAP_EVENT && !with_snaps) continue;
280
323
  if (before && e.id >= before) break;
281
324
  if (created_before && e.created >= created_before) break;
282
325
  callback(e);
@@ -297,12 +340,14 @@ var InMemoryStore = class {
297
340
  async commit(stream, msgs, meta, expectedVersion) {
298
341
  await sleep();
299
342
  const instance = this._events.filter((e) => e.stream === stream);
300
- if (typeof expectedVersion === "number" && instance.length - 1 !== expectedVersion)
343
+ if (typeof expectedVersion === "number" && instance.length - 1 !== expectedVersion) {
301
344
  throw new ConcurrencyError(
345
+ stream,
302
346
  instance.length - 1,
303
347
  msgs,
304
348
  expectedVersion
305
349
  );
350
+ }
306
351
  let version = instance.length;
307
352
  return msgs.map(({ name, data }) => {
308
353
  const committed = {
@@ -320,43 +365,49 @@ var InMemoryStore = class {
320
365
  });
321
366
  }
322
367
  /**
323
- * Fetches new events from stream watermarks for processing.
324
- * @param limit - Maximum number of streams to fetch.
325
- * @returns Fetched streams and events.
368
+ * Polls the store for unblocked streams needing processing, ordered by lease watermark ascending.
369
+ * @param limit - Maximum number of streams to poll.
370
+ * @param descending - Whether to poll streams in descending order (aka poll the most advanced first).
371
+ * @returns The polled streams.
326
372
  */
327
- async fetch(limit) {
328
- const streams = [...this._streams.values()].filter((s) => !s._blocked).sort((a, b) => a._at - b._at).slice(0, limit);
329
- const after = streams.length ? streams.reduce(
330
- (min, s) => Math.min(min, s._at),
331
- Number.MAX_SAFE_INTEGER
332
- ) : -1;
333
- const events = [];
334
- await this.query((e) => e.name !== SNAP_EVENT && events.push(e), {
335
- after,
336
- limit
337
- });
338
- return { streams: streams.map(({ stream }) => stream), events };
373
+ async poll(limit, descending = false) {
374
+ await sleep();
375
+ 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 }));
339
376
  }
340
377
  /**
341
378
  * Lease streams for processing (e.g., for distributed consumers).
342
- * @param leases - Lease requests.
379
+ * @param leases - Lease requests for streams, including end-of-lease watermark, lease holder, and source stream.
380
+ * @param leaseMilis - Lease duration in milliseconds.
343
381
  * @returns Granted leases.
344
382
  */
345
- async lease(leases) {
383
+ async lease(leases, millis) {
346
384
  await sleep();
347
- return leases.map((lease) => {
348
- const stream = this._streams.get(lease.stream) || // store new correlations
349
- this._streams.set(lease.stream, new InMemoryStream(lease.stream)).get(lease.stream);
350
- return stream.lease(lease);
385
+ return leases.map(({ stream, at, by, source }) => {
386
+ const found = this._streams.get(stream) || // store new correlations
387
+ this._streams.set(stream, new InMemoryStream(stream, source)).get(stream);
388
+ return found.lease(at, by, millis);
351
389
  }).filter((l) => !!l);
352
390
  }
353
391
  /**
354
392
  * Acknowledge completion of processing for leased streams.
355
- * @param leases - Leases to acknowledge.
393
+ * @param leases - Leases to acknowledge, including last processed watermark and lease holder.
356
394
  */
357
395
  async ack(leases) {
358
396
  await sleep();
359
- leases.forEach((lease) => this._streams.get(lease.stream)?.ack(lease));
397
+ return leases.filter(
398
+ (lease) => this._streams.get(lease.stream)?.ack(lease.at, lease.by)
399
+ );
400
+ }
401
+ /**
402
+ * Block a stream for processing after failing to process and reaching max retries with blocking enabled.
403
+ * @param leases - Leases to block, including lease holder and last error message.
404
+ * @returns Blocked leases.
405
+ */
406
+ async block(leases) {
407
+ await sleep();
408
+ return leases.filter(
409
+ (lease) => this._streams.get(lease.stream)?.block(lease.by, lease.error)
410
+ );
360
411
  }
361
412
  };
362
413
 
@@ -405,6 +456,62 @@ var SNAP_EVENT = "__snapshot__";
405
456
  var store = port(function store2(adapter) {
406
457
  return adapter || new InMemoryStore();
407
458
  });
459
+ function build_tracer(logLevel2) {
460
+ if (logLevel2 === "trace") {
461
+ return {
462
+ fetched: (fetched) => {
463
+ const data = Object.fromEntries(
464
+ fetched.map(({ stream, source, events }) => {
465
+ const key = source ? `${stream}<-${source}` : stream;
466
+ const value = Object.fromEntries(
467
+ events.map(({ id, stream: stream2, name }) => [id, { [stream2]: name }])
468
+ );
469
+ return [key, value];
470
+ })
471
+ );
472
+ logger.trace(data, "\u26A1\uFE0F fetch");
473
+ },
474
+ correlated: (leases) => {
475
+ const data = leases.map(({ stream }) => stream).join(" ");
476
+ logger.trace(`\u26A1\uFE0F correlate ${data}`);
477
+ },
478
+ leased: (leases) => {
479
+ const data = Object.fromEntries(
480
+ leases.map(({ stream, at, retry }) => [stream, { at, retry }])
481
+ );
482
+ logger.trace(data, "\u26A1\uFE0F lease");
483
+ },
484
+ acked: (leases) => {
485
+ const data = Object.fromEntries(
486
+ leases.map(({ stream, at, retry }) => [stream, { at, retry }])
487
+ );
488
+ logger.trace(data, "\u26A1\uFE0F ack");
489
+ },
490
+ blocked: (leases) => {
491
+ const data = Object.fromEntries(
492
+ leases.map(({ stream, at, retry, error }) => [
493
+ stream,
494
+ { at, retry, error }
495
+ ])
496
+ );
497
+ logger.trace(data, "\u26A1\uFE0F block");
498
+ }
499
+ };
500
+ } else {
501
+ return {
502
+ fetched: () => {
503
+ },
504
+ correlated: () => {
505
+ },
506
+ leased: () => {
507
+ },
508
+ acked: () => {
509
+ },
510
+ blocked: () => {
511
+ }
512
+ };
513
+ }
514
+ }
408
515
 
409
516
  // src/signals.ts
410
517
  process.once("SIGINT", async (arg) => {
@@ -466,21 +573,21 @@ async function load(me, stream, callback) {
466
573
  }
467
574
  callback && callback({ event, state: state2, patches, snaps });
468
575
  },
469
- { stream },
470
- true
576
+ { stream, with_snaps: true }
471
577
  );
472
- logger.trace({ stream, patches, snaps, state: state2 }, "\u{1F7E2} load");
578
+ logger.trace(state2, `\u{1F7E2} load ${stream}`);
473
579
  return { event, state: state2, patches, snaps };
474
580
  }
475
581
  async function action(me, action2, target, payload, reactingTo, skipValidation = false) {
476
582
  const { stream, expectedVersion, actor } = target;
477
583
  if (!stream) throw new Error("Missing target stream");
478
584
  payload = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
585
+ const snapshot = await load(me, stream);
586
+ const expected = expectedVersion || snapshot.event?.version;
479
587
  logger.trace(
480
588
  payload,
481
- `\u{1F535} ${action2} "${stream}${expectedVersion ? `@${expectedVersion}` : ""}"`
589
+ `\u{1F535} ${stream}.${action2}${typeof expected === "number" ? `.${expected}` : ""}`
482
590
  );
483
- let snapshot = await load(me, stream);
484
591
  if (me.given) {
485
592
  const invariants = me.given[action2] || [];
486
593
  invariants.forEach(({ valid, description }) => {
@@ -489,15 +596,15 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
489
596
  action2,
490
597
  payload,
491
598
  target,
599
+ snapshot,
492
600
  description
493
601
  );
494
602
  });
495
603
  }
496
- let { state: state2, patches } = snapshot;
497
- const result = me.on[action2](payload, state2, target);
498
- if (!result) return snapshot;
604
+ const result = me.on[action2](payload, snapshot, target);
605
+ if (!result) return [snapshot];
499
606
  if (Array.isArray(result) && result.length === 0) {
500
- return snapshot;
607
+ return [snapshot];
501
608
  }
502
609
  const tuples = Array.isArray(result[0]) ? result : [result];
503
610
  const emitted = tuples.map(([name, data]) => ({
@@ -520,36 +627,47 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
520
627
  } : void 0
521
628
  }
522
629
  };
630
+ logger.trace(
631
+ emitted.map((e) => e.data),
632
+ `\u{1F534} commit ${stream}.${emitted.map((e) => e.name).join(", ")}`
633
+ );
523
634
  const committed = await store().commit(
524
635
  stream,
525
636
  emitted,
526
637
  meta,
527
638
  // TODO: review reactions not enforcing expected version
528
- reactingTo ? void 0 : expectedVersion || snapshot.event?.version
639
+ reactingTo ? void 0 : expected
529
640
  );
530
- snapshot = committed.map((event) => {
641
+ let { state: state2, patches } = snapshot;
642
+ const snapshots = committed.map((event) => {
531
643
  state2 = patch(state2, me.patch[event.name](event, state2));
532
644
  patches++;
533
- logger.trace({ event, state: state2 }, "\u{1F534} commit");
534
645
  return { event, state: state2, patches, snaps: snapshot.snaps };
535
- }).at(-1);
536
- me.snap && me.snap(snapshot) && void snap(snapshot);
537
- return snapshot;
646
+ });
647
+ const last = snapshots.at(-1);
648
+ me.snap && me.snap(last) && void snap(last);
649
+ return snapshots;
538
650
  }
539
651
 
540
652
  // src/act.ts
653
+ var tracer = build_tracer(config().logLevel);
541
654
  var Act = class {
542
655
  /**
543
656
  * Create a new Act orchestrator.
544
657
  *
545
658
  * @param registry The registry of state, event, and action schemas
546
- * @param drainLimit The maximum number of events to drain per cycle
547
659
  */
548
- constructor(registry, drainLimit) {
660
+ constructor(registry) {
549
661
  this.registry = registry;
550
- this.drainLimit = drainLimit;
662
+ dispose(() => {
663
+ this._emitter.removeAllListeners();
664
+ this.stop_correlations();
665
+ return Promise.resolve();
666
+ });
551
667
  }
552
668
  _emitter = new EventEmitter();
669
+ _drain_locked = false;
670
+ _correlation_interval = void 0;
553
671
  emit(event, args) {
554
672
  return this._emitter.emit(event, args);
555
673
  }
@@ -576,16 +694,17 @@ var Act = class {
576
694
  * await app.do("increment", { stream: "counter1", actor }, { by: 1 });
577
695
  */
578
696
  async do(action2, target, payload, reactingTo, skipValidation = false) {
579
- const snapshot = await action(
697
+ const snapshots = await action(
580
698
  this.registry.actions[action2],
581
699
  action2,
582
700
  target,
583
701
  payload,
702
+ // @ts-expect-error type lost
584
703
  reactingTo,
585
704
  skipValidation
586
705
  );
587
- this.emit("committed", snapshot);
588
- return snapshot;
706
+ this.emit("committed", snapshots);
707
+ return snapshots;
589
708
  }
590
709
  /**
591
710
  * Loads the current state snapshot for a given state machine and stream.
@@ -623,38 +742,58 @@ var Act = class {
623
742
  }, query);
624
743
  return { first, last, count };
625
744
  }
745
+ /**
746
+ * Query the event store for events matching a filter.
747
+ * Use this version with caution, as it return events in memory.
748
+ *
749
+ * @param query The query filter (e.g., by stream, event name, or time range)
750
+ * @returns The matching events
751
+ *
752
+ * @example
753
+ * const { count } = await app.query({ stream: "counter1" }, (event) => console.log(event));
754
+ */
755
+ async query_array(query) {
756
+ const events = [];
757
+ await store().query((e) => events.push(e), query);
758
+ return events;
759
+ }
626
760
  /**
627
761
  * Handles leased reactions.
628
762
  *
763
+ * This is called by the main `drain` loop after fetching new events.
764
+ * It handles reactions, supporting retries, blocking, and error handling.
765
+ *
629
766
  * @internal
630
767
  * @param lease The lease to handle
631
- * @param reactions The reactions to handle
632
- * @returns The lease
768
+ * @param payloads The reactions to handle
769
+ * @returns The lease with results
633
770
  */
634
- async handle(lease, reactions) {
771
+ async handle(lease, payloads) {
772
+ if (payloads.length === 0) return { lease, at: lease.at };
635
773
  const stream = lease.stream;
636
- lease.retry > 0 && logger.warn(`Retrying ${stream}@${lease.at} (${lease.retry}).`);
637
- for (const reaction of reactions) {
638
- const { event, handler, options } = reaction;
774
+ let at = payloads.at(0).event.id, handled = 0;
775
+ lease.retry > 0 && logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
776
+ for (const payload of payloads) {
777
+ const { event, handler, options } = payload;
639
778
  try {
640
779
  await handler(event, stream);
641
- lease.at = event.id;
780
+ at = event.id;
781
+ handled++;
642
782
  } catch (error) {
643
- lease.error = error;
644
- if (error instanceof ValidationError)
645
- logger.error({ stream, error }, error.message);
646
- else logger.error(error);
647
- if (lease.retry < options.maxRetries) lease.retry++;
648
- else if (options.blockOnError) {
649
- lease.block = true;
650
- logger.error(`Blocked ${stream} after ${lease.retry} retries.`);
651
- }
652
- break;
783
+ logger.error(error);
784
+ const block = lease.retry >= options.maxRetries && options.blockOnError;
785
+ block && logger.error(`Blocking ${stream} after ${lease.retry} retries.`);
786
+ return {
787
+ lease,
788
+ at,
789
+ // only report error when nothing was handled
790
+ error: handled === 0 ? error.message : void 0,
791
+ block
792
+ };
653
793
  }
654
794
  }
655
- return lease;
795
+ return { lease, at };
656
796
  }
657
- drainLocked = false;
658
797
  /**
659
798
  * Drains and processes events from the store, triggering reactions and updating state.
660
799
  *
@@ -665,91 +804,173 @@ var Act = class {
665
804
  * @example
666
805
  * await app.drain();
667
806
  */
668
- async drain() {
669
- if (this.drainLocked) return 0;
670
- this.drainLocked = true;
671
- const drained = [];
672
- const { streams, events } = await store().fetch(this.drainLimit);
673
- if (events.length) {
674
- logger.trace(
675
- events.map(({ id, stream, name }) => ({ id, stream, name })).reduce(
676
- (a, { id, stream, name }) => ({ ...a, [id]: { [stream]: name } }),
677
- {}
678
- ),
679
- "\u26A1\uFE0F fetch"
680
- );
681
- const resolved = new Set(streams);
682
- const correlated = /* @__PURE__ */ new Map();
683
- for (const event of events) {
684
- const register = this.registry.events[event.name];
685
- if (!register) continue;
686
- for (const reaction of register.reactions.values()) {
687
- const stream = typeof reaction.resolver === "string" ? reaction.resolver : reaction.resolver(event);
688
- if (stream) {
689
- resolved.add(stream);
690
- (correlated.get(stream) || correlated.set(stream, []).get(stream)).push({ ...reaction, event });
807
+ async drain({
808
+ streamLimit = 10,
809
+ eventLimit = 10,
810
+ leaseMillis = 1e4,
811
+ descending = false
812
+ } = {}) {
813
+ if (!this._drain_locked) {
814
+ try {
815
+ this._drain_locked = true;
816
+ const polled = await store().poll(streamLimit, descending);
817
+ const fetched = await Promise.all(
818
+ polled.map(async ({ stream, source, at }) => {
819
+ const events = await this.query_array({
820
+ stream: source,
821
+ after: at,
822
+ limit: eventLimit
823
+ });
824
+ return { stream, source, events };
825
+ })
826
+ );
827
+ fetched.length && tracer.fetched(fetched);
828
+ const [last_at, count] = fetched.reduce(
829
+ ([last_at2, count2], { events }) => [
830
+ Math.max(last_at2, events.at(-1)?.id || 0),
831
+ count2 + events.length
832
+ ],
833
+ [0, 0]
834
+ );
835
+ if (count > 0) {
836
+ const leases = /* @__PURE__ */ new Map();
837
+ fetched.forEach(({ stream, events }) => {
838
+ const payloads = events.flatMap((event) => {
839
+ const register = this.registry.events[event.name];
840
+ if (!register) return [];
841
+ return [...register.reactions.values()].filter((reaction) => {
842
+ const resolved = typeof reaction.resolver === "function" ? (
843
+ // @ts-expect-error index by key
844
+ reaction.resolver(event)
845
+ ) : reaction.resolver;
846
+ return resolved && resolved.target === stream;
847
+ }).map((reaction) => ({ ...reaction, event }));
848
+ });
849
+ leases.set(stream, {
850
+ lease: {
851
+ stream,
852
+ by: randomUUID2(),
853
+ at: events.at(-1)?.id || last_at,
854
+ // move the lease watermark forward when no events found in window
855
+ retry: 0
856
+ },
857
+ // @ts-expect-error indexed by key
858
+ payloads
859
+ });
860
+ });
861
+ if (leases.size) {
862
+ const leased = await store().lease(
863
+ [...leases.values()].map((l) => l.lease),
864
+ leaseMillis
865
+ );
866
+ if (leased.length) {
867
+ tracer.leased(leased);
868
+ const handled = await Promise.all(
869
+ leased.map(
870
+ (lease) => this.handle(lease, leases.get(lease.stream).payloads)
871
+ )
872
+ );
873
+ const acked = await store().ack(
874
+ handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
875
+ );
876
+ if (acked.length) {
877
+ tracer.acked(acked);
878
+ this.emit("acked", acked);
879
+ }
880
+ const blocked = await store().block(
881
+ handled.filter(({ block }) => block).map(({ lease, error }) => ({ ...lease, error }))
882
+ );
883
+ if (blocked.length) {
884
+ tracer.blocked(blocked);
885
+ this.emit("blocked", blocked);
886
+ }
887
+ return { leased, acked, blocked };
888
+ }
691
889
  }
692
890
  }
891
+ } catch (error) {
892
+ logger.error(error);
893
+ } finally {
894
+ this._drain_locked = false;
693
895
  }
694
- const last = events.at(-1).id;
695
- const leases = [...resolved.values()].map((stream) => ({
696
- by: randomUUID2(),
896
+ }
897
+ return { leased: [], acked: [], blocked: [] };
898
+ }
899
+ /**
900
+ * Correlates streams using reaction resolvers.
901
+ * @param query - The query filter (e.g., by stream, event name, or starting point).
902
+ * @returns The leases of newly correlated streams, and the last seen event ID.
903
+ */
904
+ async correlate(query = { after: -1, limit: 10 }) {
905
+ const correlated = /* @__PURE__ */ new Map();
906
+ let last_id = query.after || -1;
907
+ await store().query((event) => {
908
+ last_id = event.id;
909
+ const register = this.registry.events[event.name];
910
+ if (register) {
911
+ for (const reaction of register.reactions.values()) {
912
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
913
+ resolved && (correlated.get(resolved.target) || correlated.set(resolved.target, []).get(resolved.target)).push({ ...reaction, source: resolved.source, event });
914
+ }
915
+ }
916
+ }, query);
917
+ if (correlated.size) {
918
+ const leases = [...correlated.entries()].map(([stream, payloads]) => ({
697
919
  stream,
698
- at: last,
920
+ // TODO: by convention, the first defined source wins (this can be tricky)
921
+ source: payloads.find((p) => p.source)?.source || void 0,
922
+ by: randomUUID2(),
923
+ at: 0,
699
924
  retry: 0,
700
- block: false
925
+ payloads
701
926
  }));
702
- const leased = await store().lease(leases);
703
- logger.trace(
704
- leased.map(({ stream, at, retry }) => ({ stream, at, retry })).reduce(
705
- (a, { stream, at, retry }) => ({ ...a, [stream]: { at, retry } }),
706
- {}
707
- ),
708
- "\u26A1\uFE0F lease"
709
- );
710
- const handling = leased.map((lease) => ({
711
- lease,
712
- reactions: correlated.get(lease.stream) || []
713
- })).filter(({ reactions }) => reactions.length);
714
- if (handling.length) {
715
- await Promise.allSettled(
716
- handling.map(({ lease, reactions }) => this.handle(lease, reactions))
717
- ).then(
718
- (promise) => {
719
- promise.forEach((result) => {
720
- if (result.status === "rejected") logger.error(result.reason);
721
- else if (!result.value.error) drained.push(result.value);
722
- });
723
- },
724
- (error) => logger.error(error)
725
- );
726
- drained.length && this.emit("drained", drained);
727
- }
728
- await store().ack(leased);
729
- logger.trace(
730
- leased.map(({ stream, at, retry, block, error }) => ({
731
- stream,
732
- at,
733
- retry,
734
- block,
735
- error
736
- })).reduce(
737
- (a, { stream, at, retry, block, error }) => ({
738
- ...a,
739
- [stream]: { at, retry, block, error }
740
- }),
741
- {}
742
- ),
743
- "\u26A1\uFE0F ack"
744
- );
927
+ const leased = await store().lease(leases, 0);
928
+ leased.length && tracer.correlated(leased);
929
+ return { leased, last_id };
930
+ }
931
+ return { leased: [], last_id };
932
+ }
933
+ /**
934
+ * Starts correlation worker that identifies and registers new streams using reaction resolvers.
935
+ *
936
+ * Enables "dynamic reactions", allowing streams to be auto-discovered based on event content.
937
+ * - Uses a correlation sliding window over the event stream to identify new streams.
938
+ * - Once registered, these streams are picked up by the main `drain` loop.
939
+ * - Users should have full control over their correlation strategy.
940
+ * - The starting point keeps increasing with each new batch of events.
941
+ * - Users are responsible for storing the last seen event ID.
942
+ *
943
+ * @param query - The query filter (e.g., by stream, event name, or starting point).
944
+ * @param frequency - The frequency of correlation checks (in milliseconds).
945
+ * @param callback - Callback to report stats (new strems, last seen event ID, etc.).
946
+ * @returns true if the correlation worker started, false otherwise (already started).
947
+ */
948
+ start_correlations(query = {}, frequency = 1e4, callback) {
949
+ if (this._correlation_interval) return false;
950
+ const limit = query.limit || 100;
951
+ let after = query.after || -1;
952
+ this._correlation_interval = setInterval(
953
+ () => this.correlate({ ...query, after, limit }).then((result) => {
954
+ after = result.last_id;
955
+ if (callback && result.leased.length) callback(result.leased);
956
+ }).catch(console.error),
957
+ frequency
958
+ );
959
+ return true;
960
+ }
961
+ stop_correlations() {
962
+ if (this._correlation_interval) {
963
+ clearInterval(this._correlation_interval);
964
+ this._correlation_interval = void 0;
745
965
  }
746
- this.drainLocked = false;
747
- return drained.length;
748
966
  }
749
967
  };
750
968
 
751
969
  // src/act-builder.ts
752
- var _this_ = ({ stream }) => stream;
970
+ var _this_ = ({ stream }) => ({
971
+ source: stream,
972
+ target: stream
973
+ });
753
974
  var _void_ = () => void 0;
754
975
  function act(states = /* @__PURE__ */ new Set(), registry = {
755
976
  actions: {},
@@ -801,8 +1022,7 @@ function act(states = /* @__PURE__ */ new Set(), registry = {
801
1022
  resolver: _this_,
802
1023
  options: {
803
1024
  blockOnError: options?.blockOnError ?? true,
804
- maxRetries: options?.maxRetries ?? 3,
805
- retryDelayMs: options?.retryDelayMs ?? 1e3
1025
+ maxRetries: options?.maxRetries ?? 3
806
1026
  }
807
1027
  };
808
1028
  registry.events[event].reactions.set(handler.name, reaction);
@@ -811,7 +1031,7 @@ function act(states = /* @__PURE__ */ new Set(), registry = {
811
1031
  to(resolver) {
812
1032
  registry.events[event].reactions.set(handler.name, {
813
1033
  ...reaction,
814
- resolver
1034
+ resolver: typeof resolver === "string" ? { target: resolver } : resolver
815
1035
  });
816
1036
  return builder;
817
1037
  },
@@ -825,7 +1045,7 @@ function act(states = /* @__PURE__ */ new Set(), registry = {
825
1045
  };
826
1046
  }
827
1047
  }),
828
- build: (drainLimit = 10) => new Act(registry, drainLimit),
1048
+ build: () => new Act(registry),
829
1049
  events: registry.events
830
1050
  };
831
1051
  return builder;
@@ -905,6 +1125,7 @@ export {
905
1125
  ValidationError,
906
1126
  ZodEmpty,
907
1127
  act,
1128
+ build_tracer,
908
1129
  config,
909
1130
  dispose,
910
1131
  disposeAndExit,