@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/.tsbuildinfo +1 -1
- package/dist/@types/act.d.ts.map +1 -1
- package/dist/@types/builders/state-builder.d.ts +23 -2
- package/dist/@types/builders/state-builder.d.ts.map +1 -1
- package/dist/@types/internal/backoff.d.ts +1 -1
- package/dist/@types/internal/backoff.d.ts.map +1 -1
- package/dist/@types/internal/event-sourcing.d.ts +11 -1
- package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
- package/dist/@types/types/action.d.ts +137 -10
- package/dist/@types/types/action.d.ts.map +1 -1
- package/dist/@types/types/reaction.d.ts +12 -39
- package/dist/@types/types/reaction.d.ts.map +1 -1
- package/dist/index.cjs +194 -133
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +194 -133
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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 {
|
|
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 =
|
|
950
|
-
const caused_by =
|
|
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:
|
|
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
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
warned.
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
|
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;
|