@rotorsoft/act 1.5.2 → 1.7.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
@@ -873,6 +873,29 @@ var subscribe = (streams) => store().subscribe(streams);
873
873
 
874
874
  // src/internal/event-sourcing.ts
875
875
  import { patch } from "@rotorsoft/act-patch";
876
+
877
+ // src/internal/backoff.ts
878
+ function computeBackoffDelay(retry, opts) {
879
+ if (!opts || opts.baseMs <= 0) return 0;
880
+ const r = Math.max(0, retry);
881
+ let delay;
882
+ switch (opts.strategy) {
883
+ case "fixed":
884
+ delay = opts.baseMs;
885
+ break;
886
+ case "linear":
887
+ delay = opts.baseMs * (r + 1);
888
+ break;
889
+ case "exponential":
890
+ delay = opts.baseMs * 2 ** r;
891
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
892
+ break;
893
+ }
894
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
895
+ return Math.max(0, Math.floor(delay));
896
+ }
897
+
898
+ // src/internal/event-sourcing.ts
876
899
  var DEFAULT_BATCH = 500;
877
900
  async function snap(snapshot) {
878
901
  try {
@@ -912,13 +935,30 @@ function is_valid(event) {
912
935
  return true;
913
936
  }
914
937
  async function scan(source, opts = {}, callback) {
915
- const { drop_snapshots = false, on_progress } = opts;
938
+ const {
939
+ drop_snapshots = false,
940
+ drop_closed_streams = false,
941
+ on_progress,
942
+ event_migrations,
943
+ stream_rename
944
+ } = opts;
916
945
  const limit = opts.batch_size ?? DEFAULT_BATCH;
917
946
  const id_map = /* @__PURE__ */ new Map();
918
947
  let kept = 0;
919
948
  let dropped_snaps = 0;
949
+ let dropped_closed = 0;
950
+ let migrated_count = 0;
920
951
  let processed = 0;
921
952
  let at;
953
+ const closed_streams = /* @__PURE__ */ new Set();
954
+ if (drop_closed_streams) {
955
+ await source.query(
956
+ (e) => {
957
+ if (e.name === TOMBSTONE_EVENT) closed_streams.add(e.stream);
958
+ },
959
+ { names: [TOMBSTONE_EVENT] }
960
+ );
961
+ }
922
962
  let max_id;
923
963
  const probed = await source.query(
924
964
  (e) => {
@@ -942,12 +982,35 @@ async function scan(source, opts = {}, callback) {
942
982
  dropped_snaps++;
943
983
  return;
944
984
  }
985
+ if (closed_streams.has(event.stream) && event.name !== TOMBSTONE_EVENT) {
986
+ dropped_closed++;
987
+ return;
988
+ }
989
+ let migrated = event;
990
+ const migration = event_migrations?.[event.name];
991
+ if (migration) {
992
+ const old_data = migration.from_schema.parse(event.data);
993
+ const new_data = migration.migrate(old_data);
994
+ migration.to_schema.parse(new_data);
995
+ migrated = {
996
+ ...event,
997
+ name: migration.to,
998
+ // biome-ignore lint/suspicious/noExplicitAny: migration target shape is caller-defined
999
+ data: new_data
1000
+ };
1001
+ migrated_count++;
1002
+ }
1003
+ if (stream_rename) {
1004
+ const renamed = stream_rename(migrated.stream);
1005
+ if (renamed !== migrated.stream)
1006
+ migrated = { ...migrated, stream: renamed };
1007
+ }
945
1008
  if (!callback) {
946
1009
  kept++;
947
1010
  return;
948
1011
  }
949
- let remapped = event;
950
- const caused_by = event.meta.causation.event?.id;
1012
+ let remapped = migrated;
1013
+ const caused_by = migrated.meta.causation.event?.id;
951
1014
  if (caused_by !== void 0) {
952
1015
  const new_caused_by = id_map.get(caused_by);
953
1016
  if (new_caused_by !== void 0 && new_caused_by !== caused_by) {
@@ -974,10 +1037,10 @@ async function scan(source, opts = {}, callback) {
974
1037
  }
975
1038
  return {
976
1039
  kept,
1040
+ migrated: migrated_count,
977
1041
  dropped: {
978
- closed_streams: 0,
979
- snapshots: dropped_snaps,
980
- empty_streams: 0
1042
+ closed_streams: dropped_closed,
1043
+ snapshots: dropped_snaps
981
1044
  }
982
1045
  };
983
1046
  }
@@ -1040,113 +1103,126 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
1040
1103
  const { stream, expectedVersion, actor } = target;
1041
1104
  if (!stream) throw new Error("Missing target stream");
1042
1105
  const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
1043
- const snapshot = await load(me, stream);
1044
- if (snapshot.event?.name === TOMBSTONE_EVENT)
1045
- throw new StreamClosedError(stream);
1046
- const expected = expectedVersion ?? snapshot.event?.version;
1047
- if (me.given) {
1048
- const invariants = me.given[action2] || [];
1049
- invariants.forEach(({ valid, description }) => {
1050
- if (!valid(snapshot.state, actor))
1051
- throw new InvariantError(
1052
- action2,
1053
- validated,
1054
- target,
1055
- snapshot,
1056
- description
1057
- );
1058
- });
1059
- }
1060
- const result = me.on[action2](validated, snapshot, target);
1061
- if (!result) return [snapshot];
1062
- if (Array.isArray(result) && result.length === 0) {
1063
- return [snapshot];
1064
- }
1065
- const tuples = Array.isArray(result[0]) ? result : [result];
1066
- const deprecated = me._deprecated;
1067
- if (deprecated && deprecated.size > 0) {
1068
- const me_ = me;
1069
- const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
1070
- for (const [name] of tuples) {
1071
- const evt = name;
1072
- if (deprecated.has(evt) && !warned.has(evt)) {
1073
- warned.add(evt);
1074
- log().warn(
1075
- `Action "${String(action2)}" emitted deprecated event "${evt}". A newer version exists in the registry \u2014 update the action's .emit() to target the current version. (warned once per process)`
1106
+ const opts = me.options?.[action2];
1107
+ const maxRetries = opts?.maxRetries ?? 0;
1108
+ for (let attempt = 0; ; attempt++) {
1109
+ try {
1110
+ const snapshot = await load(me, stream);
1111
+ if (snapshot.event?.name === TOMBSTONE_EVENT)
1112
+ throw new StreamClosedError(stream);
1113
+ const expected = expectedVersion ?? snapshot.event?.version;
1114
+ if (me.given) {
1115
+ const invariants = me.given[action2] || [];
1116
+ invariants.forEach(({ valid, description }) => {
1117
+ if (!valid(snapshot.state, actor))
1118
+ throw new InvariantError(
1119
+ action2,
1120
+ validated,
1121
+ target,
1122
+ snapshot,
1123
+ description
1124
+ );
1125
+ });
1126
+ }
1127
+ const result = me.on[action2](validated, snapshot, target);
1128
+ if (!result) return [snapshot];
1129
+ if (Array.isArray(result) && result.length === 0) {
1130
+ return [snapshot];
1131
+ }
1132
+ const tuples = Array.isArray(result[0]) ? result : [result];
1133
+ const deprecated = me._deprecated;
1134
+ if (deprecated && deprecated.size > 0) {
1135
+ const me_ = me;
1136
+ const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
1137
+ for (const [name] of tuples) {
1138
+ const evt = name;
1139
+ if (deprecated.has(evt) && !warned.has(evt)) {
1140
+ warned.add(evt);
1141
+ log().warn(
1142
+ `Action "${String(action2)}" emitted deprecated event "${evt}". A newer version exists in the registry \u2014 update the action's .emit() to target the current version. (warned once per process)`
1143
+ );
1144
+ }
1145
+ }
1146
+ }
1147
+ const emitted = tuples.map(([name, data]) => ({
1148
+ name,
1149
+ data: skipValidation ? data : validate(name, data, me.events[name])
1150
+ }));
1151
+ const meta = {
1152
+ correlation: reactingTo?.meta.correlation || correlator({
1153
+ action: action2,
1154
+ state: me.name,
1155
+ stream,
1156
+ actor: target.actor
1157
+ }),
1158
+ causation: {
1159
+ action: {
1160
+ name: action2,
1161
+ ...target
1162
+ // payload intentionally omitted: it can be large or contain PII,
1163
+ // and callers correlate via the correlation id when they need it.
1164
+ },
1165
+ event: reactingTo ? {
1166
+ id: reactingTo.id,
1167
+ name: reactingTo.name,
1168
+ stream: reactingTo.stream
1169
+ } : void 0
1170
+ }
1171
+ };
1172
+ let committed;
1173
+ try {
1174
+ committed = await store().commit(
1175
+ stream,
1176
+ emitted,
1177
+ meta,
1178
+ // Reactions skip optimistic concurrency: they always append against the
1179
+ // current head. Stream leasing already serializes concurrent reactions,
1180
+ // and forcing version checks here would turn ordinary catch-up into
1181
+ // spurious retries.
1182
+ reactingTo ? void 0 : expected
1076
1183
  );
1184
+ } catch (error) {
1185
+ if (error instanceof ConcurrencyError) {
1186
+ await cache().invalidate(stream);
1187
+ }
1188
+ throw error;
1189
+ }
1190
+ let { state: state2, patches } = snapshot;
1191
+ const snapshots = committed.map((event) => {
1192
+ const p = me.patch[event.name](event, state2);
1193
+ state2 = patch(state2, p);
1194
+ patches++;
1195
+ return {
1196
+ event,
1197
+ state: state2,
1198
+ version: event.version,
1199
+ patches,
1200
+ snaps: snapshot.snaps,
1201
+ patch: p,
1202
+ cache_hit: snapshot.cache_hit,
1203
+ replayed: snapshot.replayed
1204
+ };
1205
+ });
1206
+ const last = snapshots.at(-1);
1207
+ const snapped = me.snap?.(last);
1208
+ cache().set(stream, {
1209
+ state: last.state,
1210
+ version: last.event.version,
1211
+ event_id: last.event.id,
1212
+ patches: snapped ? 0 : last.patches,
1213
+ snaps: snapped ? last.snaps + 1 : last.snaps
1214
+ }).catch((err) => log().error(err));
1215
+ if (snapped) void snap(last);
1216
+ return snapshots;
1217
+ } catch (error) {
1218
+ if (!(error instanceof ConcurrencyError)) throw error;
1219
+ if (attempt >= maxRetries) throw error;
1220
+ if (opts?.backoff) {
1221
+ const delayMs = computeBackoffDelay(attempt, opts.backoff);
1222
+ if (delayMs > 0) await sleep(delayMs);
1077
1223
  }
1078
1224
  }
1079
1225
  }
1080
- const emitted = tuples.map(([name, data]) => ({
1081
- name,
1082
- data: skipValidation ? data : validate(name, data, me.events[name])
1083
- }));
1084
- const meta = {
1085
- correlation: reactingTo?.meta.correlation || correlator({
1086
- action: action2,
1087
- state: me.name,
1088
- stream,
1089
- actor: target.actor
1090
- }),
1091
- causation: {
1092
- action: {
1093
- name: action2,
1094
- ...target
1095
- // payload intentionally omitted: it can be large or contain PII,
1096
- // and callers correlate via the correlation id when they need it.
1097
- },
1098
- event: reactingTo ? {
1099
- id: reactingTo.id,
1100
- name: reactingTo.name,
1101
- stream: reactingTo.stream
1102
- } : void 0
1103
- }
1104
- };
1105
- let committed;
1106
- try {
1107
- committed = await store().commit(
1108
- stream,
1109
- emitted,
1110
- meta,
1111
- // Reactions skip optimistic concurrency: they always append against the
1112
- // current head. Stream leasing already serializes concurrent reactions,
1113
- // and forcing version checks here would turn ordinary catch-up into
1114
- // spurious retries.
1115
- reactingTo ? void 0 : expected
1116
- );
1117
- } catch (error) {
1118
- if (error instanceof ConcurrencyError) {
1119
- await cache().invalidate(stream);
1120
- }
1121
- throw error;
1122
- }
1123
- let { state: state2, patches } = snapshot;
1124
- const snapshots = committed.map((event) => {
1125
- const p = me.patch[event.name](event, state2);
1126
- state2 = patch(state2, p);
1127
- patches++;
1128
- return {
1129
- event,
1130
- state: state2,
1131
- version: event.version,
1132
- patches,
1133
- snaps: snapshot.snaps,
1134
- patch: p,
1135
- cache_hit: snapshot.cache_hit,
1136
- replayed: snapshot.replayed
1137
- };
1138
- });
1139
- const last = snapshots.at(-1);
1140
- const snapped = me.snap?.(last);
1141
- cache().set(stream, {
1142
- state: last.state,
1143
- version: last.event.version,
1144
- event_id: last.event.id,
1145
- patches: snapped ? 0 : last.patches,
1146
- snaps: snapped ? last.snaps + 1 : last.snaps
1147
- }).catch((err) => log().error(err));
1148
- if (snapped) void snap(last);
1149
- return snapshots;
1150
1226
  }
1151
1227
 
1152
1228
  // src/internal/tracing.ts
@@ -1680,27 +1756,6 @@ var _this_ = ({ stream }) => ({
1680
1756
  target: stream
1681
1757
  });
1682
1758
 
1683
- // src/internal/backoff.ts
1684
- function computeBackoffDelay(retry, opts) {
1685
- if (!opts || opts.baseMs <= 0) return 0;
1686
- const r = Math.max(0, retry);
1687
- let delay;
1688
- switch (opts.strategy) {
1689
- case "fixed":
1690
- delay = opts.baseMs;
1691
- break;
1692
- case "linear":
1693
- delay = opts.baseMs * (r + 1);
1694
- break;
1695
- case "exponential":
1696
- delay = opts.baseMs * 2 ** r;
1697
- if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
1698
- break;
1699
- }
1700
- if (opts.jitter) delay = delay * (0.5 + Math.random());
1701
- return Math.max(0, Math.floor(delay));
1702
- }
1703
-
1704
1759
  // src/internal/reactions.ts
1705
1760
  function finalize(lease, handled, at, error, options, logger, failed_at) {
1706
1761
  if (!error) return { lease, handled, acked_at: at };
@@ -2692,13 +2747,15 @@ var Act = class {
2692
2747
  return s;
2693
2748
  })();
2694
2749
  let kept = 0;
2695
- let dropped = { closed_streams: 0, snapshots: 0, empty_streams: 0 };
2750
+ let migrated = 0;
2751
+ let dropped = { closed_streams: 0, snapshots: 0 };
2696
2752
  await target.restore(async (callback) => {
2697
2753
  const partial = await scan(source, opts, callback);
2698
2754
  kept = partial.kept;
2755
+ migrated = partial.migrated;
2699
2756
  dropped = partial.dropped;
2700
2757
  });
2701
- return { kept, dropped, duration_ms: Date.now() - started };
2758
+ return { kept, migrated, dropped, duration_ms: Date.now() - started };
2702
2759
  });
2703
2760
  }
2704
2761
  /**
@@ -3230,7 +3287,7 @@ function state(entry) {
3230
3287
  function action_builder(state2) {
3231
3288
  const internal = state2;
3232
3289
  const builder = {
3233
- on(entry) {
3290
+ on(entry, options) {
3234
3291
  const keys = Object.keys(entry);
3235
3292
  if (keys.length !== 1) throw new Error(".on() requires exactly one key");
3236
3293
  const action2 = keys[0];
@@ -3238,6 +3295,10 @@ function action_builder(state2) {
3238
3295
  if (action2 in internal.actions)
3239
3296
  throw new Error(`Duplicate action "${action2}"`);
3240
3297
  internal.actions[action2] = schema;
3298
+ if (options) {
3299
+ internal.options ??= {};
3300
+ internal.options[action2] = options;
3301
+ }
3241
3302
  function given(rules) {
3242
3303
  internal.given ??= {};
3243
3304
  internal.given[action2] = rules;