@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.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,48 @@ 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
+ * @returns The polled streams.
326
371
  */
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 };
372
+ async poll(limit) {
373
+ await sleep();
374
+ 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 }));
339
375
  }
340
376
  /**
341
377
  * Lease streams for processing (e.g., for distributed consumers).
342
- * @param leases - Lease requests.
378
+ * @param leases - Lease requests for streams, including end-of-lease watermark, lease holder, and source stream.
379
+ * @param leaseMilis - Lease duration in milliseconds.
343
380
  * @returns Granted leases.
344
381
  */
345
- async lease(leases) {
382
+ async lease(leases, millis) {
346
383
  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);
384
+ return leases.map(({ stream, at, by, source }) => {
385
+ const found = this._streams.get(stream) || // store new correlations
386
+ this._streams.set(stream, new InMemoryStream(stream, source)).get(stream);
387
+ return found.lease(at, by, millis);
351
388
  }).filter((l) => !!l);
352
389
  }
353
390
  /**
354
391
  * Acknowledge completion of processing for leased streams.
355
- * @param leases - Leases to acknowledge.
392
+ * @param leases - Leases to acknowledge, including last processed watermark and lease holder.
356
393
  */
357
394
  async ack(leases) {
358
395
  await sleep();
359
- leases.forEach((lease) => this._streams.get(lease.stream)?.ack(lease));
396
+ return leases.filter(
397
+ (lease) => this._streams.get(lease.stream)?.ack(lease.at, lease.by)
398
+ );
399
+ }
400
+ /**
401
+ * Block a stream for processing after failing to process and reaching max retries with blocking enabled.
402
+ * @param leases - Leases to block, including lease holder and last error message.
403
+ * @returns Blocked leases.
404
+ */
405
+ async block(leases) {
406
+ await sleep();
407
+ return leases.filter(
408
+ (lease) => this._streams.get(lease.stream)?.block(lease.by, lease.error)
409
+ );
360
410
  }
361
411
  };
362
412
 
@@ -466,21 +516,21 @@ async function load(me, stream, callback) {
466
516
  }
467
517
  callback && callback({ event, state: state2, patches, snaps });
468
518
  },
469
- { stream },
470
- true
519
+ { stream, with_snaps: true }
471
520
  );
472
- logger.trace({ stream, patches, snaps, state: state2 }, "\u{1F7E2} load");
521
+ logger.trace(state2, `\u{1F7E2} load ${stream}`);
473
522
  return { event, state: state2, patches, snaps };
474
523
  }
475
524
  async function action(me, action2, target, payload, reactingTo, skipValidation = false) {
476
525
  const { stream, expectedVersion, actor } = target;
477
526
  if (!stream) throw new Error("Missing target stream");
478
527
  payload = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
528
+ const snapshot = await load(me, stream);
529
+ const expected = expectedVersion || snapshot.event?.version;
479
530
  logger.trace(
480
531
  payload,
481
- `\u{1F535} ${action2} "${stream}${expectedVersion ? `@${expectedVersion}` : ""}"`
532
+ `\u{1F535} ${stream}.${action2}${typeof expected === "number" ? `.${expected}` : ""}`
482
533
  );
483
- let snapshot = await load(me, stream);
484
534
  if (me.given) {
485
535
  const invariants = me.given[action2] || [];
486
536
  invariants.forEach(({ valid, description }) => {
@@ -489,15 +539,15 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
489
539
  action2,
490
540
  payload,
491
541
  target,
542
+ snapshot,
492
543
  description
493
544
  );
494
545
  });
495
546
  }
496
- let { state: state2, patches } = snapshot;
497
- const result = me.on[action2](payload, state2, target);
498
- if (!result) return snapshot;
547
+ const result = me.on[action2](payload, snapshot, target);
548
+ if (!result) return [snapshot];
499
549
  if (Array.isArray(result) && result.length === 0) {
500
- return snapshot;
550
+ return [snapshot];
501
551
  }
502
552
  const tuples = Array.isArray(result[0]) ? result : [result];
503
553
  const emitted = tuples.map(([name, data]) => ({
@@ -520,36 +570,80 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
520
570
  } : void 0
521
571
  }
522
572
  };
573
+ logger.trace(
574
+ emitted.map((e) => e.data),
575
+ `\u{1F534} commit ${stream}.${emitted.map((e) => e.name).join(", ")}`
576
+ );
523
577
  const committed = await store().commit(
524
578
  stream,
525
579
  emitted,
526
580
  meta,
527
581
  // TODO: review reactions not enforcing expected version
528
- reactingTo ? void 0 : expectedVersion || snapshot.event?.version
582
+ reactingTo ? void 0 : expected
529
583
  );
530
- snapshot = committed.map((event) => {
584
+ let { state: state2, patches } = snapshot;
585
+ const snapshots = committed.map((event) => {
531
586
  state2 = patch(state2, me.patch[event.name](event, state2));
532
587
  patches++;
533
- logger.trace({ event, state: state2 }, "\u{1F534} commit");
534
588
  return { event, state: state2, patches, snaps: snapshot.snaps };
535
- }).at(-1);
536
- me.snap && me.snap(snapshot) && void snap(snapshot);
537
- return snapshot;
589
+ });
590
+ const last = snapshots.at(-1);
591
+ me.snap && me.snap(last) && void snap(last);
592
+ return snapshots;
538
593
  }
539
594
 
540
595
  // src/act.ts
596
+ function traceFetch(fetch) {
597
+ const data = Object.fromEntries(
598
+ fetch.map(({ stream, source, events }) => {
599
+ const key = source ? `${stream}<-${source}` : stream;
600
+ const value = Object.fromEntries(
601
+ events.map(({ id, stream: stream2, name }) => [id, { [stream2]: name }])
602
+ );
603
+ return [key, value];
604
+ })
605
+ );
606
+ logger.trace(data, "\u26A1\uFE0F fetch");
607
+ }
608
+ function traceCorrelated(leases) {
609
+ const data = leases.map(({ stream }) => stream).join(" ");
610
+ logger.trace(`\u26A1\uFE0F correlate ${data}`);
611
+ }
612
+ function traceLeased(leases) {
613
+ const data = Object.fromEntries(
614
+ leases.map(({ stream, at, retry }) => [stream, { at, retry }])
615
+ );
616
+ logger.trace(data, "\u26A1\uFE0F lease");
617
+ }
618
+ function traceAcked(leases) {
619
+ const data = Object.fromEntries(
620
+ leases.map(({ stream, at, retry }) => [stream, { at, retry }])
621
+ );
622
+ logger.trace(data, "\u26A1\uFE0F ack");
623
+ }
624
+ function traceBlocked(leases) {
625
+ const data = Object.fromEntries(
626
+ leases.map(({ stream, at, retry, error }) => [stream, { at, retry, error }])
627
+ );
628
+ logger.trace(data, "\u26A1\uFE0F block");
629
+ }
541
630
  var Act = class {
542
631
  /**
543
632
  * Create a new Act orchestrator.
544
633
  *
545
634
  * @param registry The registry of state, event, and action schemas
546
- * @param drainLimit The maximum number of events to drain per cycle
547
635
  */
548
- constructor(registry, drainLimit) {
636
+ constructor(registry) {
549
637
  this.registry = registry;
550
- this.drainLimit = drainLimit;
638
+ dispose(() => {
639
+ this._emitter.removeAllListeners();
640
+ this.stop_correlations();
641
+ return Promise.resolve();
642
+ });
551
643
  }
552
644
  _emitter = new EventEmitter();
645
+ _drain_locked = false;
646
+ _correlation_interval = void 0;
553
647
  emit(event, args) {
554
648
  return this._emitter.emit(event, args);
555
649
  }
@@ -576,16 +670,17 @@ var Act = class {
576
670
  * await app.do("increment", { stream: "counter1", actor }, { by: 1 });
577
671
  */
578
672
  async do(action2, target, payload, reactingTo, skipValidation = false) {
579
- const snapshot = await action(
673
+ const snapshots = await action(
580
674
  this.registry.actions[action2],
581
675
  action2,
582
676
  target,
583
677
  payload,
678
+ // @ts-expect-error type lost
584
679
  reactingTo,
585
680
  skipValidation
586
681
  );
587
- this.emit("committed", snapshot);
588
- return snapshot;
682
+ this.emit("committed", snapshots);
683
+ return snapshots;
589
684
  }
590
685
  /**
591
686
  * Loads the current state snapshot for a given state machine and stream.
@@ -623,38 +718,76 @@ var Act = class {
623
718
  }, query);
624
719
  return { first, last, count };
625
720
  }
721
+ /**
722
+ * Query the event store for events matching a filter.
723
+ * Use this version with caution, as it return events in memory.
724
+ *
725
+ * @param query The query filter (e.g., by stream, event name, or time range)
726
+ * @returns The matching events
727
+ *
728
+ * @example
729
+ * const { count } = await app.query({ stream: "counter1" }, (event) => console.log(event));
730
+ */
731
+ async query_array(query) {
732
+ const events = [];
733
+ await store().query((e) => events.push(e), query);
734
+ return events;
735
+ }
626
736
  /**
627
737
  * Handles leased reactions.
628
738
  *
739
+ * This is called by the main `drain` loop after fetching new events.
740
+ * It handles reactions, supporting retries, blocking, and error handling.
741
+ *
629
742
  * @internal
630
743
  * @param lease The lease to handle
631
- * @param reactions The reactions to handle
632
- * @returns The lease
744
+ * @param payloads The reactions to handle
745
+ * @returns The lease with results
633
746
  */
634
- async handle(lease, reactions) {
747
+ async handle(lease, payloads) {
748
+ if (payloads.length === 0) return { lease, at: lease.at };
635
749
  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;
750
+ let at = payloads.at(0).event.id, handled = 0;
751
+ lease.retry > 0 && logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
752
+ for (const payload of payloads) {
753
+ const { event, handler, options } = payload;
639
754
  try {
640
755
  await handler(event, stream);
641
- lease.at = event.id;
756
+ at = event.id;
757
+ handled++;
642
758
  } 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;
759
+ logger.error(error);
760
+ const block = lease.retry >= options.maxRetries && options.blockOnError;
761
+ block && logger.error(`Blocking ${stream} after ${lease.retry} retries.`);
762
+ return {
763
+ lease,
764
+ at,
765
+ // only report error when nothing was handled
766
+ error: handled === 0 ? error.message : void 0,
767
+ block
768
+ };
653
769
  }
654
770
  }
655
- return lease;
771
+ return { lease, at };
772
+ }
773
+ /**
774
+ * Fetches new events from store according to the fetch options.
775
+ * @param options - Fetch options.
776
+ * @returns Fetched streams with next events to process.
777
+ */
778
+ async fetch({ streamLimit = 10, eventLimit = 10 }) {
779
+ const polled = await store().poll(streamLimit);
780
+ return Promise.all(
781
+ polled.map(async ({ stream, source, at }) => {
782
+ const events = await this.query_array({
783
+ stream: source,
784
+ after: at,
785
+ limit: eventLimit
786
+ });
787
+ return { stream, source, events };
788
+ })
789
+ );
656
790
  }
657
- drainLocked = false;
658
791
  /**
659
792
  * Drains and processes events from the store, triggering reactions and updating state.
660
793
  *
@@ -665,91 +798,162 @@ var Act = class {
665
798
  * @example
666
799
  * await app.drain();
667
800
  */
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 });
801
+ async drain({
802
+ streamLimit = 10,
803
+ eventLimit = 10,
804
+ leaseMillis = 1e4
805
+ } = {}) {
806
+ if (!this._drain_locked) {
807
+ try {
808
+ this._drain_locked = true;
809
+ const fetch = await this.fetch({ streamLimit, eventLimit });
810
+ fetch.length && traceFetch(fetch);
811
+ const [last_at, count] = fetch.reduce(
812
+ ([last_at2, count2], { events }) => [
813
+ Math.max(last_at2, events.at(-1)?.id || 0),
814
+ count2 + events.length
815
+ ],
816
+ [0, 0]
817
+ );
818
+ if (count > 0) {
819
+ const leases = /* @__PURE__ */ new Map();
820
+ fetch.forEach(({ stream, events }) => {
821
+ const payloads = events.flatMap((event) => {
822
+ const register = this.registry.events[event.name];
823
+ if (!register) return [];
824
+ return [...register.reactions.values()].filter((reaction) => {
825
+ const resolved = typeof reaction.resolver === "function" ? (
826
+ // @ts-expect-error index by key
827
+ reaction.resolver(event)
828
+ ) : reaction.resolver;
829
+ return resolved && resolved.target === stream;
830
+ }).map((reaction) => ({ ...reaction, event }));
831
+ });
832
+ leases.set(stream, {
833
+ lease: {
834
+ stream,
835
+ by: randomUUID2(),
836
+ at: events.at(-1)?.id || last_at,
837
+ // move the lease watermark forward when no events found in window
838
+ retry: 0
839
+ },
840
+ // @ts-expect-error indexed by key
841
+ payloads
842
+ });
843
+ });
844
+ if (leases.size) {
845
+ const leased = await store().lease(
846
+ [...leases.values()].map((l) => l.lease),
847
+ leaseMillis
848
+ );
849
+ if (leased.length) {
850
+ traceLeased(leased);
851
+ const handled = await Promise.all(
852
+ leased.map(
853
+ (lease) => this.handle(lease, leases.get(lease.stream).payloads)
854
+ )
855
+ );
856
+ const acked = await store().ack(
857
+ handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
858
+ );
859
+ if (acked.length) {
860
+ traceAcked(acked);
861
+ this.emit("acked", acked);
862
+ }
863
+ const blocked = await store().block(
864
+ handled.filter(({ block }) => block).map(({ lease, error }) => ({ ...lease, error }))
865
+ );
866
+ if (blocked.length) {
867
+ traceBlocked(blocked);
868
+ this.emit("blocked", blocked);
869
+ }
870
+ return { leased, acked, blocked };
871
+ }
691
872
  }
692
873
  }
874
+ } catch (error) {
875
+ logger.error(error);
876
+ } finally {
877
+ this._drain_locked = false;
693
878
  }
694
- const last = events.at(-1).id;
695
- const leases = [...resolved.values()].map((stream) => ({
696
- by: randomUUID2(),
879
+ }
880
+ return { leased: [], acked: [], blocked: [] };
881
+ }
882
+ /**
883
+ * Correlates streams using reaction resolvers.
884
+ * @param query - The query filter (e.g., by stream, event name, or starting point).
885
+ * @returns The leases of newly correlated streams, and the last seen event ID.
886
+ */
887
+ async correlate(query = { after: -1, limit: 10 }) {
888
+ const correlated = /* @__PURE__ */ new Map();
889
+ let last_id = query.after || -1;
890
+ await store().query((event) => {
891
+ last_id = event.id;
892
+ const register = this.registry.events[event.name];
893
+ if (register) {
894
+ for (const reaction of register.reactions.values()) {
895
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
896
+ resolved && (correlated.get(resolved.target) || correlated.set(resolved.target, []).get(resolved.target)).push({ ...reaction, source: resolved.source, event });
897
+ }
898
+ }
899
+ }, query);
900
+ if (correlated.size) {
901
+ const leases = [...correlated.entries()].map(([stream, payloads]) => ({
697
902
  stream,
698
- at: last,
903
+ // TODO: by convention, the first defined source wins (this can be tricky)
904
+ source: payloads.find((p) => p.source)?.source || void 0,
905
+ by: randomUUID2(),
906
+ at: 0,
699
907
  retry: 0,
700
- block: false
908
+ payloads
701
909
  }));
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
- );
910
+ const leased = await store().lease(leases, 0);
911
+ leased.length && traceCorrelated(leased);
912
+ return { leased, last_id };
913
+ }
914
+ return { leased: [], last_id };
915
+ }
916
+ /**
917
+ * Starts correlation worker that identifies and registers new streams using reaction resolvers.
918
+ *
919
+ * Enables "dynamic reactions", allowing streams to be auto-discovered based on event content.
920
+ * - Uses a correlation sliding window over the event stream to identify new streams.
921
+ * - Once registered, these streams are picked up by the main `drain` loop.
922
+ * - Users should have full control over their correlation strategy.
923
+ * - The starting point keeps increasing with each new batch of events.
924
+ * - Users are responsible for storing the last seen event ID.
925
+ *
926
+ * @param query - The query filter (e.g., by stream, event name, or starting point).
927
+ * @param frequency - The frequency of correlation checks (in milliseconds).
928
+ * @param callback - Callback to report stats (new strems, last seen event ID, etc.).
929
+ * @returns true if the correlation worker started, false otherwise (already started).
930
+ */
931
+ start_correlations(query = {}, frequency = 1e4, callback) {
932
+ if (this._correlation_interval) return false;
933
+ const limit = query.limit || 100;
934
+ let after = query.after || -1;
935
+ this._correlation_interval = setInterval(
936
+ () => this.correlate({ ...query, after, limit }).then((result) => {
937
+ after = result.last_id;
938
+ if (callback && result.leased.length) callback(result.leased);
939
+ }).catch(console.error),
940
+ frequency
941
+ );
942
+ return true;
943
+ }
944
+ stop_correlations() {
945
+ if (this._correlation_interval) {
946
+ clearInterval(this._correlation_interval);
947
+ this._correlation_interval = void 0;
745
948
  }
746
- this.drainLocked = false;
747
- return drained.length;
748
949
  }
749
950
  };
750
951
 
751
952
  // src/act-builder.ts
752
- var _this_ = ({ stream }) => stream;
953
+ var _this_ = ({ stream }) => ({
954
+ source: stream,
955
+ target: stream
956
+ });
753
957
  var _void_ = () => void 0;
754
958
  function act(states = /* @__PURE__ */ new Set(), registry = {
755
959
  actions: {},
@@ -801,8 +1005,7 @@ function act(states = /* @__PURE__ */ new Set(), registry = {
801
1005
  resolver: _this_,
802
1006
  options: {
803
1007
  blockOnError: options?.blockOnError ?? true,
804
- maxRetries: options?.maxRetries ?? 3,
805
- retryDelayMs: options?.retryDelayMs ?? 1e3
1008
+ maxRetries: options?.maxRetries ?? 3
806
1009
  }
807
1010
  };
808
1011
  registry.events[event].reactions.set(handler.name, reaction);
@@ -811,7 +1014,7 @@ function act(states = /* @__PURE__ */ new Set(), registry = {
811
1014
  to(resolver) {
812
1015
  registry.events[event].reactions.set(handler.name, {
813
1016
  ...reaction,
814
- resolver
1017
+ resolver: typeof resolver === "string" ? { target: resolver } : resolver
815
1018
  });
816
1019
  return builder;
817
1020
  },
@@ -825,7 +1028,7 @@ function act(states = /* @__PURE__ */ new Set(), registry = {
825
1028
  };
826
1029
  }
827
1030
  }),
828
- build: (drainLimit = 10) => new Act(registry, drainLimit),
1031
+ build: () => new Act(registry),
829
1032
  events: registry.events
830
1033
  };
831
1034
  return builder;