@rotorsoft/act 0.42.0 → 0.44.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
@@ -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++;
@@ -926,6 +1002,77 @@ var InMemoryStore = class {
926
1002
  }
927
1003
  return { maxEventId: this._events.length - 1, count };
928
1004
  }
1005
+ /**
1006
+ * Per-stream aggregated stats — see {@link Store.query_stats}.
1007
+ *
1008
+ * Single forward scan over the in-memory event list, accumulating per
1009
+ * stream. The "cheap heads" cost tier from durable adapters doesn't
1010
+ * apply here (InMemory has no indexes); correctness is the goal, perf
1011
+ * is a non-issue.
1012
+ *
1013
+ * Scope rules:
1014
+ * - Array `input` — explicit stream names, regardless of subscription.
1015
+ * - Filter `input` — `stream`/`stream_exact` match against event-bearing
1016
+ * stream names; `source`/`source_exact`/`blocked` require a
1017
+ * corresponding subscription in `_streams` (those are subscription
1018
+ * concepts, not event concepts). Empty filter `{}` matches every
1019
+ * event-bearing stream.
1020
+ */
1021
+ async query_stats(input, options) {
1022
+ await sleep();
1023
+ const exclude = new Set(options?.exclude ?? []);
1024
+ const wantTail = options?.tail ?? false;
1025
+ const wantCount = options?.count ?? false;
1026
+ const wantNames = options?.names ?? false;
1027
+ const before = options?.before;
1028
+ const arrayTargets = Array.isArray(input) ? new Set(input) : null;
1029
+ const filter = Array.isArray(input) ? null : input;
1030
+ const streamRe = filter?.stream && !filter.stream_exact ? new RegExp(filter.stream) : void 0;
1031
+ const scopeCache = /* @__PURE__ */ new Map();
1032
+ const inScope = (stream) => {
1033
+ const cached = scopeCache.get(stream);
1034
+ if (cached !== void 0) return cached;
1035
+ let ok = true;
1036
+ if (arrayTargets) {
1037
+ ok = arrayTargets.has(stream);
1038
+ } else if (filter?.stream !== void 0) {
1039
+ ok = filter.stream_exact ? stream === filter.stream : (
1040
+ // biome-ignore lint/style/noNonNullAssertion: streamRe set when stream is regex
1041
+ streamRe.test(stream)
1042
+ );
1043
+ }
1044
+ scopeCache.set(stream, ok);
1045
+ return ok;
1046
+ };
1047
+ const acc = /* @__PURE__ */ new Map();
1048
+ for (const e of this._events) {
1049
+ if (before !== void 0 && e.id >= before) continue;
1050
+ if (!inScope(e.stream)) continue;
1051
+ if (exclude.has(e.name)) continue;
1052
+ let a = acc.get(e.stream);
1053
+ if (!a) {
1054
+ a = { head: e, count: 0 };
1055
+ if (wantTail) a.tail = e;
1056
+ if (wantNames) a.names = {};
1057
+ acc.set(e.stream, a);
1058
+ }
1059
+ a.head = e;
1060
+ a.count++;
1061
+ if (wantNames) {
1062
+ const n = String(e.name);
1063
+ a.names[n] = (a.names[n] ?? 0) + 1;
1064
+ }
1065
+ }
1066
+ const out = /* @__PURE__ */ new Map();
1067
+ for (const [stream, a] of acc) {
1068
+ const stats = { head: a.head };
1069
+ if (wantTail) stats.tail = a.tail;
1070
+ if (wantCount) stats.count = a.count;
1071
+ if (wantNames) stats.names = a.names;
1072
+ out.set(stream, stats);
1073
+ }
1074
+ return out;
1075
+ }
929
1076
  /**
930
1077
  * Atomically truncates streams and seeds each with a snapshot or tombstone.
931
1078
  * @param targets - Streams to truncate with optional snapshot state and meta.
@@ -1127,24 +1274,18 @@ async function runCloseCycle(targets, deps) {
1127
1274
  return { truncated, skipped };
1128
1275
  }
1129
1276
  async function scanStreamHeads(streams) {
1277
+ const stats = await store2().query_stats(streams, {
1278
+ exclude: [SNAP_EVENT]
1279
+ });
1130
1280
  const out = /* @__PURE__ */ new Map();
1131
- await Promise.all(
1132
- streams.map(async (s) => {
1133
- let maxId = -1;
1134
- let version = -1;
1135
- let lastEventName = "";
1136
- await store2().query(
1137
- (e) => {
1138
- if (e.name === TOMBSTONE_EVENT || maxId !== -1) return;
1139
- maxId = e.id;
1140
- version = e.version;
1141
- lastEventName = e.name;
1142
- },
1143
- { stream: s, stream_exact: true, backward: true, limit: 1 }
1144
- );
1145
- if (maxId >= 0) out.set(s, { maxId, version, lastEventName });
1146
- })
1147
- );
1281
+ for (const [stream, { head }] of stats) {
1282
+ if (head.name === TOMBSTONE_EVENT) continue;
1283
+ out.set(stream, {
1284
+ maxId: head.id,
1285
+ version: head.version,
1286
+ lastEventName: head.name
1287
+ });
1288
+ }
1148
1289
  return out;
1149
1290
  }
1150
1291
  async function partitionBySafety(streamInfo, reactiveEventsSize, skipped) {
@@ -1777,9 +1918,12 @@ function computeBackoffDelay(retry, opts) {
1777
1918
  function finalize(lease, handled, at, error, options, logger) {
1778
1919
  if (!error) return { lease, handled, at };
1779
1920
  logger.error(error);
1780
- const block2 = lease.retry >= options.maxRetries && options.blockOnError;
1921
+ const nonRetryable = error instanceof NonRetryableError;
1922
+ const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
1781
1923
  if (block2)
1782
- logger.error(`Blocking ${lease.stream} after ${lease.retry} retries.`);
1924
+ logger.error(
1925
+ nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
1926
+ );
1783
1927
  const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
1784
1928
  return {
1785
1929
  lease,
@@ -2919,13 +3063,81 @@ var Act = class {
2919
3063
  * @see {@link Store.reset} for the underlying store primitive
2920
3064
  * @see {@link settle} for the debounced full-catch-up loop
2921
3065
  */
2922
- async reset(streams) {
3066
+ async reset(input) {
3067
+ return this._scoped(async () => {
3068
+ const count = await store2().reset(input);
3069
+ if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
3070
+ return count;
3071
+ });
3072
+ }
3073
+ /**
3074
+ * Clear the blocked flag on streams without replaying their history.
3075
+ *
3076
+ * Use this to recover from a poison message after fixing the
3077
+ * underlying issue — the stream resumes from the next event after the
3078
+ * last successful ack, not from the beginning. Compare with
3079
+ * {@link reset}, which rebuilds from event 0 (suitable for projection
3080
+ * rebuilds, wrong for "I fixed the bug, please retry").
3081
+ *
3082
+ * Wraps `store().unblock(streams)` and raises the orchestrator's
3083
+ * internal "needs drain" flag so a settled app picks up the now-free
3084
+ * streams on the next cycle. Equivalent to calling `store().unblock(...)`
3085
+ * directly, but `store().unblock(...)` alone leaves the flag
3086
+ * untouched.
3087
+ *
3088
+ * @param streams - Stream names to unblock
3089
+ * @returns Count of streams that were actually flipped (were blocked)
3090
+ *
3091
+ * @example Recover from a 4xx webhook after fixing the bug
3092
+ * ```typescript
3093
+ * await app.unblock(["webhooks-out-customer-42"]);
3094
+ * // The stream resumes from the next event, not from zero.
3095
+ * ```
3096
+ *
3097
+ * @see {@link Store.unblock} for the underlying store primitive
3098
+ * @see {@link reset} for the rebuild-from-zero alternative
3099
+ */
3100
+ async unblock(input) {
2923
3101
  return this._scoped(async () => {
2924
- const count = await store2().reset(streams);
3102
+ const count = await store2().unblock(input);
2925
3103
  if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
2926
3104
  return count;
2927
3105
  });
2928
3106
  }
3107
+ /**
3108
+ * Return every currently-blocked stream position. Convenience wrapper
3109
+ * around `store().query_streams(cb, { blocked: true })` for the common
3110
+ * "show me what's broken" operational query.
3111
+ *
3112
+ * Results are ordered by stream name, paginated by `limit` (default
3113
+ * 100). Pass `after` to fetch the next page (keyset cursor on the
3114
+ * stream name). For richer queries — including blocked + source
3115
+ * filters, or full unblocked introspection — drop to
3116
+ * `store().query_streams(...)` directly.
3117
+ *
3118
+ * @returns Array of {@link StreamPosition} for currently-blocked streams.
3119
+ *
3120
+ * @example Discover and recover
3121
+ * ```typescript
3122
+ * const blocked = await app.blocked_streams();
3123
+ * console.table(blocked.map(({ stream, retry, error }) => ({ stream, retry, error })));
3124
+ *
3125
+ * // Operator investigates, then bulk-unblocks the family:
3126
+ * await app.unblock({ stream: "^webhooks-out-" });
3127
+ * ```
3128
+ */
3129
+ async blocked_streams(options) {
3130
+ return this._scoped(async () => {
3131
+ const positions = [];
3132
+ await store2().query_streams(
3133
+ (p) => {
3134
+ positions.push(p);
3135
+ },
3136
+ { blocked: true, after: options?.after, limit: options?.limit }
3137
+ );
3138
+ return positions;
3139
+ });
3140
+ }
2929
3141
  /**
2930
3142
  * Bulk-update scheduling priority for streams matching `filter`.
2931
3143
  *
@@ -3382,6 +3594,7 @@ function action_builder(state2) {
3382
3594
  InMemoryStore,
3383
3595
  InvariantError,
3384
3596
  LogLevels,
3597
+ NonRetryableError,
3385
3598
  PackageSchema,
3386
3599
  QuerySchema,
3387
3600
  SNAP_EVENT,