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