@rotorsoft/act 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +19 -1
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/builders/act-builder.d.ts.map +1 -1
  5. package/dist/@types/builders/slice-builder.d.ts.map +1 -1
  6. package/dist/@types/internal/backoff.d.ts +22 -0
  7. package/dist/@types/internal/backoff.d.ts.map +1 -0
  8. package/dist/@types/internal/close-cycle.d.ts +7 -0
  9. package/dist/@types/internal/close-cycle.d.ts.map +1 -1
  10. package/dist/@types/internal/correlate-cycle.d.ts.map +1 -1
  11. package/dist/@types/internal/correlator.d.ts +44 -0
  12. package/dist/@types/internal/correlator.d.ts.map +1 -0
  13. package/dist/@types/internal/drain-cycle.d.ts +34 -1
  14. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  15. package/dist/@types/internal/event-sourcing.d.ts +10 -3
  16. package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
  17. package/dist/@types/internal/index.d.ts +2 -1
  18. package/dist/@types/internal/index.d.ts.map +1 -1
  19. package/dist/@types/internal/lru-map.d.ts.map +1 -0
  20. package/dist/@types/internal/reactions.d.ts.map +1 -1
  21. package/dist/@types/internal/tracing.d.ts +2 -2
  22. package/dist/@types/internal/tracing.d.ts.map +1 -1
  23. package/dist/@types/types/action.d.ts +32 -0
  24. package/dist/@types/types/action.d.ts.map +1 -1
  25. package/dist/@types/types/reaction.d.ts +44 -0
  26. package/dist/@types/types/reaction.d.ts.map +1 -1
  27. package/dist/{chunk-TP2OZWHP.js → chunk-M5YFKVRV.js} +2 -2
  28. package/dist/chunk-M5YFKVRV.js.map +1 -0
  29. package/dist/index.cjs +144 -20
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +146 -22
  32. package/dist/index.js.map +1 -1
  33. package/dist/test/index.cjs +1 -1
  34. package/dist/test/index.cjs.map +1 -1
  35. package/dist/test/index.js +1 -1
  36. package/package.json +2 -2
  37. package/dist/@types/lru-map.d.ts.map +0 -1
  38. package/dist/chunk-TP2OZWHP.js.map +0 -1
  39. /package/dist/@types/{lru-map.d.ts → internal/lru-map.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  sleep,
19
19
  store,
20
20
  validate
21
- } from "./chunk-TP2OZWHP.js";
21
+ } from "./chunk-M5YFKVRV.js";
22
22
  import {
23
23
  ActorSchema,
24
24
  CausationEventSchema,
@@ -95,7 +95,6 @@ function classifyRegistry(registry, states) {
95
95
  }
96
96
 
97
97
  // src/internal/close-cycle.ts
98
- import { randomUUID } from "crypto";
99
98
  async function runCloseCycle(targets, deps) {
100
99
  const targetMap = new Map(targets.map((t) => [t.stream, t]));
101
100
  const streams = [...targetMap.keys()];
@@ -107,11 +106,10 @@ async function runCloseCycle(targets, deps) {
107
106
  skipped
108
107
  );
109
108
  if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
110
- const correlation = randomUUID();
111
109
  const { guarded, guardEvents } = await guardWithTombstones(
112
110
  safe,
113
111
  streamInfo,
114
- correlation,
112
+ deps.correlation,
115
113
  deps.tombstone,
116
114
  skipped
117
115
  );
@@ -129,7 +127,7 @@ async function runCloseCycle(targets, deps) {
129
127
  guarded,
130
128
  seedStates,
131
129
  guardEvents,
132
- correlation
130
+ deps.correlation
133
131
  );
134
132
  return { truncated, skipped };
135
133
  }
@@ -370,8 +368,32 @@ var CorrelateCycle = class {
370
368
  }
371
369
  };
372
370
 
371
+ // src/internal/correlator.ts
372
+ import { randomInt } from "crypto";
373
+ var BASE = 36;
374
+ var SEG_WIDTH = 4;
375
+ var SEG_SPACE = BASE ** SEG_WIDTH;
376
+ function seg(n) {
377
+ return n.toString(BASE).padStart(SEG_WIDTH, "0");
378
+ }
379
+ var defaultCorrelator = ({ state: state2, action: action2 }) => {
380
+ const s = state2.slice(0, SEG_WIDTH).toLowerCase();
381
+ const a = action2.slice(0, SEG_WIDTH).toLowerCase();
382
+ const ts = seg(Date.now() % SEG_SPACE);
383
+ const rnd = seg(randomInt(SEG_SPACE));
384
+ return `${s}-${a}-${ts}${rnd}`;
385
+ };
386
+ function closeCorrelation(correlator, actor) {
387
+ return correlator({
388
+ state: "$close",
389
+ action: "close",
390
+ stream: "$close",
391
+ actor
392
+ });
393
+ }
394
+
373
395
  // src/internal/drain-cycle.ts
374
- import { randomUUID as randomUUID2 } from "crypto";
396
+ import { randomUUID } from "crypto";
375
397
 
376
398
  // src/internal/drain-ratio.ts
377
399
  var RATIO_MIN = 0.2;
@@ -392,10 +414,20 @@ function computeLagLeadRatio(handled, lagging, leading) {
392
414
  }
393
415
 
394
416
  // src/internal/drain-cycle.ts
395
- async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis) {
396
- const leased = await ops.claim(lagging, leading, randomUUID2(), leaseMillis);
417
+ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred) {
418
+ const leased = await ops.claim(lagging, leading, randomUUID(), leaseMillis);
397
419
  if (!leased.length) return void 0;
398
- const fetched = await ops.fetch(leased, eventLimit);
420
+ const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
421
+ if (!active.length) {
422
+ return {
423
+ leased,
424
+ fetched: [],
425
+ handled: [],
426
+ acked: [],
427
+ blocked: []
428
+ };
429
+ }
430
+ const fetched = await ops.fetch(active, eventLimit);
399
431
  const fetchMap = /* @__PURE__ */ new Map();
400
432
  const fetch_window_at = fetched.reduce(
401
433
  (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
@@ -414,7 +446,7 @@ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch,
414
446
  fetchMap.set(stream, { fetch: f, payloads });
415
447
  }
416
448
  const handled = await Promise.all(
417
- leased.map((lease) => {
449
+ active.map((lease) => {
418
450
  const entry = fetchMap.get(lease.stream);
419
451
  const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
420
452
  const { payloads } = entry;
@@ -446,6 +478,15 @@ var DrainController = class {
446
478
  _armed = false;
447
479
  _locked = false;
448
480
  _ratio = 0.5;
481
+ /**
482
+ * Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
483
+ * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
484
+ * ack or terminal block. Lives in process memory — per-worker pacing
485
+ * by design (see {@link BackoffOptions} for the multi-worker trade-off).
486
+ */
487
+ _backoff = /* @__PURE__ */ new Map();
488
+ /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
489
+ _backoffTimer;
449
490
  /**
450
491
  * Signal that a commit (or reset / cold-start) may have produced work.
451
492
  * Subsequent `drain()` calls will run the pipeline; once the pipeline
@@ -458,6 +499,32 @@ var DrainController = class {
458
499
  get armed() {
459
500
  return this._armed;
460
501
  }
502
+ /** Returns true when `stream` is currently within a backoff window. */
503
+ isDeferred = (stream) => {
504
+ const next = this._backoff.get(stream);
505
+ return next !== void 0 && next > Date.now();
506
+ };
507
+ /**
508
+ * Schedule the next drain re-arm at the earliest pending backoff
509
+ * expiry. Called only when the backoff map is non-empty (caller guard).
510
+ * Idempotent — collapses many simultaneously deferred streams into a
511
+ * single timer.
512
+ */
513
+ scheduleBackoffWake() {
514
+ if (this._backoffTimer) clearTimeout(this._backoffTimer);
515
+ let earliest = Number.POSITIVE_INFINITY;
516
+ for (const t of this._backoff.values()) if (t < earliest) earliest = t;
517
+ const delay = Math.max(0, earliest - Date.now());
518
+ this._backoffTimer = setTimeout(() => {
519
+ this._backoffTimer = void 0;
520
+ const now = Date.now();
521
+ for (const [stream, at] of this._backoff) {
522
+ if (at <= now) this._backoff.delete(stream);
523
+ }
524
+ this._armed = true;
525
+ }, delay);
526
+ this._backoffTimer.unref();
527
+ }
461
528
  /** Run one drain pass. Short-circuits when not armed or already running. */
462
529
  async drain({
463
530
  streamLimit = 10,
@@ -479,7 +546,8 @@ var DrainController = class {
479
546
  lagging,
480
547
  leading,
481
548
  eventLimit,
482
- leaseMillis
549
+ leaseMillis,
550
+ this._backoff.size > 0 ? this.isDeferred : void 0
483
551
  );
484
552
  if (!cycle) {
485
553
  this._armed = false;
@@ -487,6 +555,14 @@ var DrainController = class {
487
555
  }
488
556
  const { leased, fetched, handled, acked, blocked } = cycle;
489
557
  this._ratio = computeLagLeadRatio(handled, lagging, leading);
558
+ for (const lease of acked) this._backoff.delete(lease.stream);
559
+ for (const lease of blocked) this._backoff.delete(lease.stream);
560
+ for (const h of handled) {
561
+ if (h.nextAttemptAt !== void 0 && !h.block) {
562
+ this._backoff.set(h.lease.stream, h.nextAttemptAt);
563
+ }
564
+ }
565
+ if (this._backoff.size > 0) this.scheduleBackoffWake();
490
566
  if (acked.length) this.deps.onAcked(acked);
491
567
  if (blocked.length) this.deps.onBlocked(blocked);
492
568
  const hasErrors = handled.some(({ error }) => error);
@@ -681,6 +757,27 @@ var _this_ = ({ stream }) => ({
681
757
  target: stream
682
758
  });
683
759
 
760
+ // src/internal/backoff.ts
761
+ function computeBackoffDelay(retry, opts) {
762
+ if (!opts || opts.baseMs <= 0) return 0;
763
+ const r = Math.max(0, retry);
764
+ let delay;
765
+ switch (opts.strategy) {
766
+ case "fixed":
767
+ delay = opts.baseMs;
768
+ break;
769
+ case "linear":
770
+ delay = opts.baseMs * (r + 1);
771
+ break;
772
+ case "exponential":
773
+ delay = opts.baseMs * 2 ** r;
774
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
775
+ break;
776
+ }
777
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
778
+ return Math.max(0, Math.floor(delay));
779
+ }
780
+
684
781
  // src/internal/reactions.ts
685
782
  function finalize(lease, handled, at, error, options, logger) {
686
783
  if (!error) return { lease, handled, at };
@@ -688,12 +785,14 @@ function finalize(lease, handled, at, error, options, logger) {
688
785
  const block2 = lease.retry >= options.maxRetries && options.blockOnError;
689
786
  if (block2)
690
787
  logger.error(`Blocking ${lease.stream} after ${lease.retry} retries.`);
788
+ const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
691
789
  return {
692
790
  lease,
693
791
  handled,
694
792
  at,
695
793
  error: handled === 0 ? error.message : void 0,
696
- block: block2
794
+ block: block2,
795
+ nextAttemptAt
697
796
  };
698
797
  }
699
798
  function buildHandle(deps) {
@@ -835,7 +934,6 @@ var block = (leases) => store().block(leases);
835
934
  var subscribe = (streams) => store().subscribe(streams);
836
935
 
837
936
  // src/internal/event-sourcing.ts
838
- import { randomUUID as randomUUID3 } from "crypto";
839
937
  import { patch } from "@rotorsoft/act-patch";
840
938
  async function snap(snapshot) {
841
939
  try {
@@ -923,7 +1021,7 @@ async function load(me, stream, callback, asOf) {
923
1021
  }
924
1022
  return { event, state: state2, version, patches, snaps, cache_hit, replayed };
925
1023
  }
926
- async function action(me, action2, target, payload, reactingTo, skipValidation = false) {
1024
+ async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
927
1025
  const { stream, expectedVersion, actor } = target;
928
1026
  if (!stream) throw new Error("Missing target stream");
929
1027
  const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
@@ -969,7 +1067,12 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
969
1067
  data: skipValidation ? data : validate(name, data, me.events[name])
970
1068
  }));
971
1069
  const meta = {
972
- correlation: reactingTo?.meta.correlation || randomUUID3(),
1070
+ correlation: reactingTo?.meta.correlation || correlator({
1071
+ action: action2,
1072
+ state: me.name,
1073
+ stream,
1074
+ actor: target.actor
1075
+ }),
973
1076
  causation: {
974
1077
  action: {
975
1078
  name: action2,
@@ -1073,12 +1176,21 @@ var traced = (inner, exit, entry) => (async (...args) => {
1073
1176
  exit?.(result, ...args);
1074
1177
  return result;
1075
1178
  });
1076
- function buildEs(logger) {
1179
+ function buildEs(logger, correlator = defaultCorrelator) {
1180
+ const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
1181
+ me,
1182
+ actionName,
1183
+ target,
1184
+ payload,
1185
+ reactingTo,
1186
+ skipValidation,
1187
+ correlator
1188
+ );
1077
1189
  if (logger.level !== "trace") {
1078
1190
  return {
1079
1191
  snap,
1080
1192
  load,
1081
- action,
1193
+ action: boundAction,
1082
1194
  tombstone
1083
1195
  };
1084
1196
  }
@@ -1108,7 +1220,7 @@ function buildEs(logger) {
1108
1220
  );
1109
1221
  }),
1110
1222
  action: traced(
1111
- action,
1223
+ boundAction,
1112
1224
  (snapshots, _me, _action, target) => {
1113
1225
  const committed = snapshots.filter((s) => s.event);
1114
1226
  if (committed.length) {
@@ -1216,7 +1328,8 @@ var Act = class {
1216
1328
  this._states = _states;
1217
1329
  this._batch_handlers = batchHandlers;
1218
1330
  this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
1219
- this._es = buildEs(this._logger);
1331
+ this._correlator = options.correlator ?? defaultCorrelator;
1332
+ this._es = buildEs(this._logger, this._correlator);
1220
1333
  this._cd = buildDrain(this._logger);
1221
1334
  this._handle = buildHandle({
1222
1335
  logger: this._logger,
@@ -1329,6 +1442,13 @@ var Act = class {
1329
1442
  * path keeps reading fresh `store()`/`cache()` per call, which matters for
1330
1443
  * tests that dispose and re-seed mid-suite. */
1331
1444
  _scoped;
1445
+ /**
1446
+ * Correlation-id generator for originating actions. Bound at
1447
+ * construction from `options.correlator ?? defaultCorrelator`. The
1448
+ * `do()` path passes this into the `_es.action` closure; close-cycle
1449
+ * uses it via {@link closeCorrelation}.
1450
+ */
1451
+ _correlator;
1332
1452
  /** Pre-bound IAct methods reused across drain cycles. Only `do` varies per
1333
1453
  * payload (it captures the triggering event for reactingTo auto-inject). */
1334
1454
  _bound_do = this.do.bind(this);
@@ -1889,12 +2009,14 @@ var Act = class {
1889
2009
  if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
1890
2010
  return this._scoped(async () => {
1891
2011
  await this.correlate({ limit: 1e3 });
2012
+ const closeActor = { id: "$close", name: "close" };
1892
2013
  const result = await runCloseCycle(targets, {
1893
2014
  reactiveEventsSize: this._reactive_events.size,
1894
2015
  eventToState: this._event_to_state,
1895
2016
  load: this._es.load,
1896
2017
  tombstone: this._es.tombstone,
1897
- logger: this._logger
2018
+ logger: this._logger,
2019
+ correlation: closeCorrelation(this._correlator, closeActor)
1898
2020
  });
1899
2021
  this.emit("closed", result);
1900
2022
  return result;
@@ -2013,7 +2135,8 @@ function act() {
2013
2135
  resolver: _this_,
2014
2136
  options: {
2015
2137
  blockOnError: options?.blockOnError ?? true,
2016
- maxRetries: options?.maxRetries ?? 3
2138
+ maxRetries: options?.maxRetries ?? 3,
2139
+ backoff: options?.backoff
2017
2140
  }
2018
2141
  };
2019
2142
  if (!handler.name)
@@ -2139,7 +2262,8 @@ function slice() {
2139
2262
  resolver: _this_,
2140
2263
  options: {
2141
2264
  blockOnError: options?.blockOnError ?? true,
2142
- maxRetries: options?.maxRetries ?? 3
2265
+ maxRetries: options?.maxRetries ?? 3,
2266
+ backoff: options?.backoff
2143
2267
  }
2144
2268
  };
2145
2269
  if (!handler.name)