@rotorsoft/act 0.41.0 → 0.43.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 (41) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +75 -3
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/adapters/in-memory-store.d.ts +26 -6
  5. package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
  6. package/dist/@types/internal/close-cycle.d.ts +7 -0
  7. package/dist/@types/internal/close-cycle.d.ts.map +1 -1
  8. package/dist/@types/internal/correlator.d.ts +44 -0
  9. package/dist/@types/internal/correlator.d.ts.map +1 -0
  10. package/dist/@types/internal/event-sourcing.d.ts +10 -3
  11. package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
  12. package/dist/@types/internal/index.d.ts +2 -1
  13. package/dist/@types/internal/index.d.ts.map +1 -1
  14. package/dist/@types/internal/reactions.d.ts +1 -1
  15. package/dist/@types/internal/reactions.d.ts.map +1 -1
  16. package/dist/@types/internal/tracing.d.ts +2 -2
  17. package/dist/@types/internal/tracing.d.ts.map +1 -1
  18. package/dist/@types/types/action.d.ts +41 -0
  19. package/dist/@types/types/action.d.ts.map +1 -1
  20. package/dist/@types/types/errors.d.ts +56 -0
  21. package/dist/@types/types/errors.d.ts.map +1 -1
  22. package/dist/@types/types/ports.d.ts +80 -8
  23. package/dist/@types/types/ports.d.ts.map +1 -1
  24. package/dist/{chunk-M5YFKVRV.js → chunk-QAB4SDOS.js} +89 -24
  25. package/dist/chunk-QAB4SDOS.js.map +1 -0
  26. package/dist/{chunk-AGWZY6YT.js → chunk-VMX7RPTC.js} +13 -2
  27. package/dist/chunk-VMX7RPTC.js.map +1 -0
  28. package/dist/index.cjs +232 -39
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.js +138 -20
  31. package/dist/index.js.map +1 -1
  32. package/dist/test/index.cjs +91 -25
  33. package/dist/test/index.cjs.map +1 -1
  34. package/dist/test/index.js +4 -4
  35. package/dist/test/index.js.map +1 -1
  36. package/dist/types/index.cjs +13 -1
  37. package/dist/types/index.cjs.map +1 -1
  38. package/dist/types/index.js +3 -1
  39. package/package.json +1 -1
  40. package/dist/chunk-AGWZY6YT.js.map +0 -1
  41. package/dist/chunk-M5YFKVRV.js.map +0 -1
package/dist/index.cjs CHANGED
@@ -46,6 +46,7 @@ __export(index_exports, {
46
46
  InMemoryStore: () => InMemoryStore,
47
47
  InvariantError: () => InvariantError,
48
48
  LogLevels: () => LogLevels,
49
+ NonRetryableError: () => NonRetryableError,
49
50
  PackageSchema: () => PackageSchema,
50
51
  QuerySchema: () => QuerySchema,
51
52
  SNAP_EVENT: () => SNAP_EVENT,
@@ -286,7 +287,8 @@ var Errors = {
286
287
  ValidationError: "ERR_VALIDATION",
287
288
  InvariantError: "ERR_INVARIANT",
288
289
  ConcurrencyError: "ERR_CONCURRENCY",
289
- StreamClosedError: "ERR_STREAM_CLOSED"
290
+ StreamClosedError: "ERR_STREAM_CLOSED",
291
+ NonRetryableError: "ERR_NON_RETRYABLE"
290
292
  };
291
293
  var ValidationError = class extends Error {
292
294
  constructor(target, payload, details) {
@@ -329,6 +331,15 @@ var StreamClosedError = class extends Error {
329
331
  this.name = Errors.StreamClosedError;
330
332
  }
331
333
  };
334
+ var NonRetryableError = class extends Error {
335
+ /** The original failure, if any. Mirrors the standard `Error.cause` shape. */
336
+ cause;
337
+ constructor(message, options) {
338
+ super(message);
339
+ this.name = Errors.NonRetryableError;
340
+ this.cause = options?.cause;
341
+ }
342
+ };
332
343
 
333
344
  // src/utils.ts
334
345
  var import_zod3 = require("zod");
@@ -597,6 +608,20 @@ var InMemoryStream = class {
597
608
  this._leased_by = void 0;
598
609
  this._leased_until = void 0;
599
610
  }
611
+ /**
612
+ * Clear the blocked flag and lease bookkeeping without touching the
613
+ * watermark. Returns true if the stream was actually blocked (and is
614
+ * now flipped); false otherwise.
615
+ */
616
+ unblock() {
617
+ if (!this._blocked) return false;
618
+ this._blocked = false;
619
+ this._retry = -1;
620
+ this._error = "";
621
+ this._leased_by = void 0;
622
+ this._leased_until = void 0;
623
+ return true;
624
+ }
600
625
  };
601
626
  var InMemoryStore = class {
602
627
  // stored events
@@ -832,19 +857,81 @@ var InMemoryStore = class {
832
857
  return leases.map((l) => this._streams.get(l.stream)?.block(l, l.error)).filter((l) => !!l);
833
858
  }
834
859
  /**
835
- * Reset watermarks for the given streams to -1, clearing retry, blocked,
836
- * error, and lease state so they can be replayed from the beginning.
837
- * @param streams - Stream names to reset.
860
+ * Build a predicate from a {@link StreamFilter}. Compiled regexes are
861
+ * cached in the closure so callers can apply it across the streams
862
+ * map without re-compiling per iteration.
863
+ */
864
+ _filterPredicate(filter) {
865
+ const streamRe = filter.stream && !filter.stream_exact ? new RegExp(filter.stream) : void 0;
866
+ const sourceRe = filter.source && !filter.source_exact ? new RegExp(filter.source) : void 0;
867
+ return (s) => {
868
+ if (filter.stream !== void 0) {
869
+ if (filter.stream_exact ? s.stream !== filter.stream : !streamRe.test(s.stream))
870
+ return false;
871
+ }
872
+ if (filter.source !== void 0) {
873
+ if (s.source === void 0) return false;
874
+ if (filter.source_exact ? s.source !== filter.source : !sourceRe.test(s.source))
875
+ return false;
876
+ }
877
+ if (filter.blocked !== void 0 && s.blocked !== filter.blocked)
878
+ return false;
879
+ return true;
880
+ };
881
+ }
882
+ /**
883
+ * Reset watermarks to -1, clearing retry, blocked, error, and lease
884
+ * state so the matched streams can be replayed from the beginning.
885
+ * Accepts either an explicit list of names or a {@link StreamFilter}.
886
+ *
887
+ * @param input - Stream names or a filter selecting the streams to reset.
838
888
  * @returns Count of streams that were actually reset.
839
889
  */
840
- async reset(streams) {
890
+ async reset(input) {
841
891
  await sleep();
842
892
  let count = 0;
843
- for (const name of streams) {
844
- const s = this._streams.get(name);
845
- if (s) {
846
- s.reset();
847
- count++;
893
+ if (Array.isArray(input)) {
894
+ for (const name of input) {
895
+ const s = this._streams.get(name);
896
+ if (s) {
897
+ s.reset();
898
+ count++;
899
+ }
900
+ }
901
+ } else {
902
+ const matches = this._filterPredicate(input);
903
+ for (const s of this._streams.values()) {
904
+ if (matches(s)) {
905
+ s.reset();
906
+ count++;
907
+ }
908
+ }
909
+ }
910
+ return count;
911
+ }
912
+ /**
913
+ * Clear the blocked flag (and retry / error / lease) on the matched
914
+ * streams without touching the watermark. Streams that aren't blocked
915
+ * at call time are silently skipped. Accepts either an explicit list
916
+ * of names or a {@link StreamFilter}. The filter form always restricts
917
+ * to blocked streams — passing `blocked: false` matches nothing.
918
+ * See {@link Store.unblock}.
919
+ *
920
+ * @param input - Stream names or a filter selecting the streams to unblock.
921
+ * @returns Count of streams that were actually flipped (were blocked).
922
+ */
923
+ async unblock(input) {
924
+ await sleep();
925
+ let count = 0;
926
+ if (Array.isArray(input)) {
927
+ for (const name of input) {
928
+ const s = this._streams.get(name);
929
+ if (s?.unblock()) count++;
930
+ }
931
+ } else {
932
+ const matches = this._filterPredicate({ ...input, blocked: true });
933
+ for (const s of this._streams.values()) {
934
+ if (matches(s) && s.unblock()) count++;
848
935
  }
849
936
  }
850
937
  return count;
@@ -860,21 +947,10 @@ var InMemoryStore = class {
860
947
  */
861
948
  async prioritize(filter, priority) {
862
949
  await sleep();
863
- const streamRe = filter.stream && !filter.stream_exact ? new RegExp(filter.stream) : void 0;
864
- const sourceRe = filter.source && !filter.source_exact ? new RegExp(filter.source) : void 0;
950
+ const matches = this._filterPredicate(filter);
865
951
  let count = 0;
866
952
  for (const s of this._streams.values()) {
867
- if (filter.stream !== void 0) {
868
- if (filter.stream_exact ? s.stream !== filter.stream : !streamRe.test(s.stream))
869
- continue;
870
- }
871
- if (filter.source !== void 0) {
872
- if (s.source === void 0) continue;
873
- if (filter.source_exact ? s.source !== filter.source : !sourceRe.test(s.source))
874
- continue;
875
- }
876
- if (filter.blocked !== void 0 && s.blocked !== filter.blocked)
877
- continue;
953
+ if (!matches(s)) continue;
878
954
  if (s.priority !== priority) {
879
955
  s.setPriority(priority);
880
956
  count++;
@@ -1090,7 +1166,6 @@ function classifyRegistry(registry, states) {
1090
1166
  }
1091
1167
 
1092
1168
  // src/internal/close-cycle.ts
1093
- var import_node_crypto = require("crypto");
1094
1169
  async function runCloseCycle(targets, deps) {
1095
1170
  const targetMap = new Map(targets.map((t) => [t.stream, t]));
1096
1171
  const streams = [...targetMap.keys()];
@@ -1102,11 +1177,10 @@ async function runCloseCycle(targets, deps) {
1102
1177
  skipped
1103
1178
  );
1104
1179
  if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
1105
- const correlation = (0, import_node_crypto.randomUUID)();
1106
1180
  const { guarded, guardEvents } = await guardWithTombstones(
1107
1181
  safe,
1108
1182
  streamInfo,
1109
- correlation,
1183
+ deps.correlation,
1110
1184
  deps.tombstone,
1111
1185
  skipped
1112
1186
  );
@@ -1124,7 +1198,7 @@ async function runCloseCycle(targets, deps) {
1124
1198
  guarded,
1125
1199
  seedStates,
1126
1200
  guardEvents,
1127
- correlation
1201
+ deps.correlation
1128
1202
  );
1129
1203
  return { truncated, skipped };
1130
1204
  }
@@ -1365,6 +1439,30 @@ var CorrelateCycle = class {
1365
1439
  }
1366
1440
  };
1367
1441
 
1442
+ // src/internal/correlator.ts
1443
+ var import_node_crypto = require("crypto");
1444
+ var BASE = 36;
1445
+ var SEG_WIDTH = 4;
1446
+ var SEG_SPACE = BASE ** SEG_WIDTH;
1447
+ function seg(n) {
1448
+ return n.toString(BASE).padStart(SEG_WIDTH, "0");
1449
+ }
1450
+ var defaultCorrelator = ({ state: state2, action: action2 }) => {
1451
+ const s = state2.slice(0, SEG_WIDTH).toLowerCase();
1452
+ const a = action2.slice(0, SEG_WIDTH).toLowerCase();
1453
+ const ts = seg(Date.now() % SEG_SPACE);
1454
+ const rnd = seg((0, import_node_crypto.randomInt)(SEG_SPACE));
1455
+ return `${s}-${a}-${ts}${rnd}`;
1456
+ };
1457
+ function closeCorrelation(correlator, actor) {
1458
+ return correlator({
1459
+ state: "$close",
1460
+ action: "close",
1461
+ stream: "$close",
1462
+ actor
1463
+ });
1464
+ }
1465
+
1368
1466
  // src/internal/drain-cycle.ts
1369
1467
  var import_node_crypto2 = require("crypto");
1370
1468
 
@@ -1755,9 +1853,12 @@ function computeBackoffDelay(retry, opts) {
1755
1853
  function finalize(lease, handled, at, error, options, logger) {
1756
1854
  if (!error) return { lease, handled, at };
1757
1855
  logger.error(error);
1758
- const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1856
+ const nonRetryable = error instanceof NonRetryableError;
1857
+ const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
1759
1858
  if (block2)
1760
- logger.error(`Blocking ${lease.stream} after ${lease.retry} retries.`);
1859
+ logger.error(
1860
+ nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
1861
+ );
1761
1862
  const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
1762
1863
  return {
1763
1864
  lease,
@@ -1907,7 +2008,6 @@ var block = (leases) => store2().block(leases);
1907
2008
  var subscribe = (streams) => store2().subscribe(streams);
1908
2009
 
1909
2010
  // src/internal/event-sourcing.ts
1910
- var import_node_crypto3 = require("crypto");
1911
2011
  var import_act_patch = require("@rotorsoft/act-patch");
1912
2012
  async function snap(snapshot) {
1913
2013
  try {
@@ -1995,7 +2095,7 @@ async function load(me, stream, callback, asOf) {
1995
2095
  }
1996
2096
  return { event, state: state2, version, patches, snaps, cache_hit, replayed };
1997
2097
  }
1998
- async function action(me, action2, target, payload, reactingTo, skipValidation = false) {
2098
+ async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
1999
2099
  const { stream, expectedVersion, actor } = target;
2000
2100
  if (!stream) throw new Error("Missing target stream");
2001
2101
  const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
@@ -2041,7 +2141,12 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
2041
2141
  data: skipValidation ? data : validate(name, data, me.events[name])
2042
2142
  }));
2043
2143
  const meta = {
2044
- correlation: reactingTo?.meta.correlation || (0, import_node_crypto3.randomUUID)(),
2144
+ correlation: reactingTo?.meta.correlation || correlator({
2145
+ action: action2,
2146
+ state: me.name,
2147
+ stream,
2148
+ actor: target.actor
2149
+ }),
2045
2150
  causation: {
2046
2151
  action: {
2047
2152
  name: action2,
@@ -2145,12 +2250,21 @@ var traced = (inner, exit, entry) => (async (...args) => {
2145
2250
  exit?.(result, ...args);
2146
2251
  return result;
2147
2252
  });
2148
- function buildEs(logger) {
2253
+ function buildEs(logger, correlator = defaultCorrelator) {
2254
+ const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
2255
+ me,
2256
+ actionName,
2257
+ target,
2258
+ payload,
2259
+ reactingTo,
2260
+ skipValidation,
2261
+ correlator
2262
+ );
2149
2263
  if (logger.level !== "trace") {
2150
2264
  return {
2151
2265
  snap,
2152
2266
  load,
2153
- action,
2267
+ action: boundAction,
2154
2268
  tombstone
2155
2269
  };
2156
2270
  }
@@ -2180,7 +2294,7 @@ function buildEs(logger) {
2180
2294
  );
2181
2295
  }),
2182
2296
  action: traced(
2183
- action,
2297
+ boundAction,
2184
2298
  (snapshots, _me, _action, target) => {
2185
2299
  const committed = snapshots.filter((s) => s.event);
2186
2300
  if (committed.length) {
@@ -2288,7 +2402,8 @@ var Act = class {
2288
2402
  this._states = _states;
2289
2403
  this._batch_handlers = batchHandlers;
2290
2404
  this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
2291
- this._es = buildEs(this._logger);
2405
+ this._correlator = options.correlator ?? defaultCorrelator;
2406
+ this._es = buildEs(this._logger, this._correlator);
2292
2407
  this._cd = buildDrain(this._logger);
2293
2408
  this._handle = buildHandle({
2294
2409
  logger: this._logger,
@@ -2401,6 +2516,13 @@ var Act = class {
2401
2516
  * path keeps reading fresh `store()`/`cache()` per call, which matters for
2402
2517
  * tests that dispose and re-seed mid-suite. */
2403
2518
  _scoped;
2519
+ /**
2520
+ * Correlation-id generator for originating actions. Bound at
2521
+ * construction from `options.correlator ?? defaultCorrelator`. The
2522
+ * `do()` path passes this into the `_es.action` closure; close-cycle
2523
+ * uses it via {@link closeCorrelation}.
2524
+ */
2525
+ _correlator;
2404
2526
  /** Pre-bound IAct methods reused across drain cycles. Only `do` varies per
2405
2527
  * payload (it captures the triggering event for reactingTo auto-inject). */
2406
2528
  _bound_do = this.do.bind(this);
@@ -2876,13 +2998,81 @@ var Act = class {
2876
2998
  * @see {@link Store.reset} for the underlying store primitive
2877
2999
  * @see {@link settle} for the debounced full-catch-up loop
2878
3000
  */
2879
- async reset(streams) {
3001
+ async reset(input) {
2880
3002
  return this._scoped(async () => {
2881
- const count = await store2().reset(streams);
3003
+ const count = await store2().reset(input);
2882
3004
  if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
2883
3005
  return count;
2884
3006
  });
2885
3007
  }
3008
+ /**
3009
+ * Clear the blocked flag on streams without replaying their history.
3010
+ *
3011
+ * Use this to recover from a poison message after fixing the
3012
+ * underlying issue — the stream resumes from the next event after the
3013
+ * last successful ack, not from the beginning. Compare with
3014
+ * {@link reset}, which rebuilds from event 0 (suitable for projection
3015
+ * rebuilds, wrong for "I fixed the bug, please retry").
3016
+ *
3017
+ * Wraps `store().unblock(streams)` and raises the orchestrator's
3018
+ * internal "needs drain" flag so a settled app picks up the now-free
3019
+ * streams on the next cycle. Equivalent to calling `store().unblock(...)`
3020
+ * directly, but `store().unblock(...)` alone leaves the flag
3021
+ * untouched.
3022
+ *
3023
+ * @param streams - Stream names to unblock
3024
+ * @returns Count of streams that were actually flipped (were blocked)
3025
+ *
3026
+ * @example Recover from a 4xx webhook after fixing the bug
3027
+ * ```typescript
3028
+ * await app.unblock(["webhooks-out-customer-42"]);
3029
+ * // The stream resumes from the next event, not from zero.
3030
+ * ```
3031
+ *
3032
+ * @see {@link Store.unblock} for the underlying store primitive
3033
+ * @see {@link reset} for the rebuild-from-zero alternative
3034
+ */
3035
+ async unblock(input) {
3036
+ return this._scoped(async () => {
3037
+ const count = await store2().unblock(input);
3038
+ if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
3039
+ return count;
3040
+ });
3041
+ }
3042
+ /**
3043
+ * Return every currently-blocked stream position. Convenience wrapper
3044
+ * around `store().query_streams(cb, { blocked: true })` for the common
3045
+ * "show me what's broken" operational query.
3046
+ *
3047
+ * Results are ordered by stream name, paginated by `limit` (default
3048
+ * 100). Pass `after` to fetch the next page (keyset cursor on the
3049
+ * stream name). For richer queries — including blocked + source
3050
+ * filters, or full unblocked introspection — drop to
3051
+ * `store().query_streams(...)` directly.
3052
+ *
3053
+ * @returns Array of {@link StreamPosition} for currently-blocked streams.
3054
+ *
3055
+ * @example Discover and recover
3056
+ * ```typescript
3057
+ * const blocked = await app.blocked_streams();
3058
+ * console.table(blocked.map(({ stream, retry, error }) => ({ stream, retry, error })));
3059
+ *
3060
+ * // Operator investigates, then bulk-unblocks the family:
3061
+ * await app.unblock({ stream: "^webhooks-out-" });
3062
+ * ```
3063
+ */
3064
+ async blocked_streams(options) {
3065
+ return this._scoped(async () => {
3066
+ const positions = [];
3067
+ await store2().query_streams(
3068
+ (p) => {
3069
+ positions.push(p);
3070
+ },
3071
+ { blocked: true, after: options?.after, limit: options?.limit }
3072
+ );
3073
+ return positions;
3074
+ });
3075
+ }
2886
3076
  /**
2887
3077
  * Bulk-update scheduling priority for streams matching `filter`.
2888
3078
  *
@@ -2961,12 +3151,14 @@ var Act = class {
2961
3151
  if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
2962
3152
  return this._scoped(async () => {
2963
3153
  await this.correlate({ limit: 1e3 });
3154
+ const closeActor = { id: "$close", name: "close" };
2964
3155
  const result = await runCloseCycle(targets, {
2965
3156
  reactiveEventsSize: this._reactive_events.size,
2966
3157
  eventToState: this._event_to_state,
2967
3158
  load: this._es.load,
2968
3159
  tombstone: this._es.tombstone,
2969
- logger: this._logger
3160
+ logger: this._logger,
3161
+ correlation: closeCorrelation(this._correlator, closeActor)
2970
3162
  });
2971
3163
  this.emit("closed", result);
2972
3164
  return result;
@@ -3337,6 +3529,7 @@ function action_builder(state2) {
3337
3529
  InMemoryStore,
3338
3530
  InvariantError,
3339
3531
  LogLevels,
3532
+ NonRetryableError,
3340
3533
  PackageSchema,
3341
3534
  QuerySchema,
3342
3535
  SNAP_EVENT,