@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.cjs CHANGED
@@ -2123,6 +2123,29 @@ var subscribe = (streams) => store2().subscribe(streams);
2123
2123
 
2124
2124
  // src/internal/event-sourcing.ts
2125
2125
  var import_act_patch = require("@rotorsoft/act-patch");
2126
+
2127
+ // src/internal/backoff.ts
2128
+ function computeBackoffDelay(retry, opts) {
2129
+ if (!opts || opts.baseMs <= 0) return 0;
2130
+ const r = Math.max(0, retry);
2131
+ let delay;
2132
+ switch (opts.strategy) {
2133
+ case "fixed":
2134
+ delay = opts.baseMs;
2135
+ break;
2136
+ case "linear":
2137
+ delay = opts.baseMs * (r + 1);
2138
+ break;
2139
+ case "exponential":
2140
+ delay = opts.baseMs * 2 ** r;
2141
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
2142
+ break;
2143
+ }
2144
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
2145
+ return Math.max(0, Math.floor(delay));
2146
+ }
2147
+
2148
+ // src/internal/event-sourcing.ts
2126
2149
  var DEFAULT_BATCH = 500;
2127
2150
  async function snap(snapshot) {
2128
2151
  try {
@@ -2162,13 +2185,30 @@ function is_valid(event) {
2162
2185
  return true;
2163
2186
  }
2164
2187
  async function scan(source, opts = {}, callback) {
2165
- const { drop_snapshots = false, on_progress } = opts;
2188
+ const {
2189
+ drop_snapshots = false,
2190
+ drop_closed_streams = false,
2191
+ on_progress,
2192
+ event_migrations,
2193
+ stream_rename
2194
+ } = opts;
2166
2195
  const limit = opts.batch_size ?? DEFAULT_BATCH;
2167
2196
  const id_map = /* @__PURE__ */ new Map();
2168
2197
  let kept = 0;
2169
2198
  let dropped_snaps = 0;
2199
+ let dropped_closed = 0;
2200
+ let migrated_count = 0;
2170
2201
  let processed = 0;
2171
2202
  let at;
2203
+ const closed_streams = /* @__PURE__ */ new Set();
2204
+ if (drop_closed_streams) {
2205
+ await source.query(
2206
+ (e) => {
2207
+ if (e.name === TOMBSTONE_EVENT) closed_streams.add(e.stream);
2208
+ },
2209
+ { names: [TOMBSTONE_EVENT] }
2210
+ );
2211
+ }
2172
2212
  let max_id;
2173
2213
  const probed = await source.query(
2174
2214
  (e) => {
@@ -2192,12 +2232,35 @@ async function scan(source, opts = {}, callback) {
2192
2232
  dropped_snaps++;
2193
2233
  return;
2194
2234
  }
2235
+ if (closed_streams.has(event.stream) && event.name !== TOMBSTONE_EVENT) {
2236
+ dropped_closed++;
2237
+ return;
2238
+ }
2239
+ let migrated = event;
2240
+ const migration = event_migrations?.[event.name];
2241
+ if (migration) {
2242
+ const old_data = migration.from_schema.parse(event.data);
2243
+ const new_data = migration.migrate(old_data);
2244
+ migration.to_schema.parse(new_data);
2245
+ migrated = {
2246
+ ...event,
2247
+ name: migration.to,
2248
+ // biome-ignore lint/suspicious/noExplicitAny: migration target shape is caller-defined
2249
+ data: new_data
2250
+ };
2251
+ migrated_count++;
2252
+ }
2253
+ if (stream_rename) {
2254
+ const renamed = stream_rename(migrated.stream);
2255
+ if (renamed !== migrated.stream)
2256
+ migrated = { ...migrated, stream: renamed };
2257
+ }
2195
2258
  if (!callback) {
2196
2259
  kept++;
2197
2260
  return;
2198
2261
  }
2199
- let remapped = event;
2200
- const caused_by = event.meta.causation.event?.id;
2262
+ let remapped = migrated;
2263
+ const caused_by = migrated.meta.causation.event?.id;
2201
2264
  if (caused_by !== void 0) {
2202
2265
  const new_caused_by = id_map.get(caused_by);
2203
2266
  if (new_caused_by !== void 0 && new_caused_by !== caused_by) {
@@ -2224,10 +2287,10 @@ async function scan(source, opts = {}, callback) {
2224
2287
  }
2225
2288
  return {
2226
2289
  kept,
2290
+ migrated: migrated_count,
2227
2291
  dropped: {
2228
- closed_streams: 0,
2229
- snapshots: dropped_snaps,
2230
- empty_streams: 0
2292
+ closed_streams: dropped_closed,
2293
+ snapshots: dropped_snaps
2231
2294
  }
2232
2295
  };
2233
2296
  }
@@ -2290,113 +2353,126 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
2290
2353
  const { stream, expectedVersion, actor } = target;
2291
2354
  if (!stream) throw new Error("Missing target stream");
2292
2355
  const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
2293
- const snapshot = await load(me, stream);
2294
- if (snapshot.event?.name === TOMBSTONE_EVENT)
2295
- throw new StreamClosedError(stream);
2296
- const expected = expectedVersion ?? snapshot.event?.version;
2297
- if (me.given) {
2298
- const invariants = me.given[action2] || [];
2299
- invariants.forEach(({ valid, description }) => {
2300
- if (!valid(snapshot.state, actor))
2301
- throw new InvariantError(
2302
- action2,
2303
- validated,
2304
- target,
2305
- snapshot,
2306
- description
2307
- );
2308
- });
2309
- }
2310
- const result = me.on[action2](validated, snapshot, target);
2311
- if (!result) return [snapshot];
2312
- if (Array.isArray(result) && result.length === 0) {
2313
- return [snapshot];
2314
- }
2315
- const tuples = Array.isArray(result[0]) ? result : [result];
2316
- const deprecated = me._deprecated;
2317
- if (deprecated && deprecated.size > 0) {
2318
- const me_ = me;
2319
- const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
2320
- for (const [name] of tuples) {
2321
- const evt = name;
2322
- if (deprecated.has(evt) && !warned.has(evt)) {
2323
- warned.add(evt);
2324
- log().warn(
2325
- `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)`
2356
+ const opts = me.options?.[action2];
2357
+ const maxRetries = opts?.maxRetries ?? 0;
2358
+ for (let attempt = 0; ; attempt++) {
2359
+ try {
2360
+ const snapshot = await load(me, stream);
2361
+ if (snapshot.event?.name === TOMBSTONE_EVENT)
2362
+ throw new StreamClosedError(stream);
2363
+ const expected = expectedVersion ?? snapshot.event?.version;
2364
+ if (me.given) {
2365
+ const invariants = me.given[action2] || [];
2366
+ invariants.forEach(({ valid, description }) => {
2367
+ if (!valid(snapshot.state, actor))
2368
+ throw new InvariantError(
2369
+ action2,
2370
+ validated,
2371
+ target,
2372
+ snapshot,
2373
+ description
2374
+ );
2375
+ });
2376
+ }
2377
+ const result = me.on[action2](validated, snapshot, target);
2378
+ if (!result) return [snapshot];
2379
+ if (Array.isArray(result) && result.length === 0) {
2380
+ return [snapshot];
2381
+ }
2382
+ const tuples = Array.isArray(result[0]) ? result : [result];
2383
+ const deprecated = me._deprecated;
2384
+ if (deprecated && deprecated.size > 0) {
2385
+ const me_ = me;
2386
+ const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
2387
+ for (const [name] of tuples) {
2388
+ const evt = name;
2389
+ if (deprecated.has(evt) && !warned.has(evt)) {
2390
+ warned.add(evt);
2391
+ log().warn(
2392
+ `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)`
2393
+ );
2394
+ }
2395
+ }
2396
+ }
2397
+ const emitted = tuples.map(([name, data]) => ({
2398
+ name,
2399
+ data: skipValidation ? data : validate(name, data, me.events[name])
2400
+ }));
2401
+ const meta = {
2402
+ correlation: reactingTo?.meta.correlation || correlator({
2403
+ action: action2,
2404
+ state: me.name,
2405
+ stream,
2406
+ actor: target.actor
2407
+ }),
2408
+ causation: {
2409
+ action: {
2410
+ name: action2,
2411
+ ...target
2412
+ // payload intentionally omitted: it can be large or contain PII,
2413
+ // and callers correlate via the correlation id when they need it.
2414
+ },
2415
+ event: reactingTo ? {
2416
+ id: reactingTo.id,
2417
+ name: reactingTo.name,
2418
+ stream: reactingTo.stream
2419
+ } : void 0
2420
+ }
2421
+ };
2422
+ let committed;
2423
+ try {
2424
+ committed = await store2().commit(
2425
+ stream,
2426
+ emitted,
2427
+ meta,
2428
+ // Reactions skip optimistic concurrency: they always append against the
2429
+ // current head. Stream leasing already serializes concurrent reactions,
2430
+ // and forcing version checks here would turn ordinary catch-up into
2431
+ // spurious retries.
2432
+ reactingTo ? void 0 : expected
2326
2433
  );
2434
+ } catch (error) {
2435
+ if (error instanceof ConcurrencyError) {
2436
+ await cache2().invalidate(stream);
2437
+ }
2438
+ throw error;
2439
+ }
2440
+ let { state: state2, patches } = snapshot;
2441
+ const snapshots = committed.map((event) => {
2442
+ const p = me.patch[event.name](event, state2);
2443
+ state2 = (0, import_act_patch.patch)(state2, p);
2444
+ patches++;
2445
+ return {
2446
+ event,
2447
+ state: state2,
2448
+ version: event.version,
2449
+ patches,
2450
+ snaps: snapshot.snaps,
2451
+ patch: p,
2452
+ cache_hit: snapshot.cache_hit,
2453
+ replayed: snapshot.replayed
2454
+ };
2455
+ });
2456
+ const last = snapshots.at(-1);
2457
+ const snapped = me.snap?.(last);
2458
+ cache2().set(stream, {
2459
+ state: last.state,
2460
+ version: last.event.version,
2461
+ event_id: last.event.id,
2462
+ patches: snapped ? 0 : last.patches,
2463
+ snaps: snapped ? last.snaps + 1 : last.snaps
2464
+ }).catch((err) => log().error(err));
2465
+ if (snapped) void snap(last);
2466
+ return snapshots;
2467
+ } catch (error) {
2468
+ if (!(error instanceof ConcurrencyError)) throw error;
2469
+ if (attempt >= maxRetries) throw error;
2470
+ if (opts?.backoff) {
2471
+ const delayMs = computeBackoffDelay(attempt, opts.backoff);
2472
+ if (delayMs > 0) await sleep(delayMs);
2327
2473
  }
2328
2474
  }
2329
2475
  }
2330
- const emitted = tuples.map(([name, data]) => ({
2331
- name,
2332
- data: skipValidation ? data : validate(name, data, me.events[name])
2333
- }));
2334
- const meta = {
2335
- correlation: reactingTo?.meta.correlation || correlator({
2336
- action: action2,
2337
- state: me.name,
2338
- stream,
2339
- actor: target.actor
2340
- }),
2341
- causation: {
2342
- action: {
2343
- name: action2,
2344
- ...target
2345
- // payload intentionally omitted: it can be large or contain PII,
2346
- // and callers correlate via the correlation id when they need it.
2347
- },
2348
- event: reactingTo ? {
2349
- id: reactingTo.id,
2350
- name: reactingTo.name,
2351
- stream: reactingTo.stream
2352
- } : void 0
2353
- }
2354
- };
2355
- let committed;
2356
- try {
2357
- committed = await store2().commit(
2358
- stream,
2359
- emitted,
2360
- meta,
2361
- // Reactions skip optimistic concurrency: they always append against the
2362
- // current head. Stream leasing already serializes concurrent reactions,
2363
- // and forcing version checks here would turn ordinary catch-up into
2364
- // spurious retries.
2365
- reactingTo ? void 0 : expected
2366
- );
2367
- } catch (error) {
2368
- if (error instanceof ConcurrencyError) {
2369
- await cache2().invalidate(stream);
2370
- }
2371
- throw error;
2372
- }
2373
- let { state: state2, patches } = snapshot;
2374
- const snapshots = committed.map((event) => {
2375
- const p = me.patch[event.name](event, state2);
2376
- state2 = (0, import_act_patch.patch)(state2, p);
2377
- patches++;
2378
- return {
2379
- event,
2380
- state: state2,
2381
- version: event.version,
2382
- patches,
2383
- snaps: snapshot.snaps,
2384
- patch: p,
2385
- cache_hit: snapshot.cache_hit,
2386
- replayed: snapshot.replayed
2387
- };
2388
- });
2389
- const last = snapshots.at(-1);
2390
- const snapped = me.snap?.(last);
2391
- cache2().set(stream, {
2392
- state: last.state,
2393
- version: last.event.version,
2394
- event_id: last.event.id,
2395
- patches: snapped ? 0 : last.patches,
2396
- snaps: snapped ? last.snaps + 1 : last.snaps
2397
- }).catch((err) => log().error(err));
2398
- if (snapped) void snap(last);
2399
- return snapshots;
2400
2476
  }
2401
2477
 
2402
2478
  // src/internal/tracing.ts
@@ -2930,27 +3006,6 @@ var _this_ = ({ stream }) => ({
2930
3006
  target: stream
2931
3007
  });
2932
3008
 
2933
- // src/internal/backoff.ts
2934
- function computeBackoffDelay(retry, opts) {
2935
- if (!opts || opts.baseMs <= 0) return 0;
2936
- const r = Math.max(0, retry);
2937
- let delay;
2938
- switch (opts.strategy) {
2939
- case "fixed":
2940
- delay = opts.baseMs;
2941
- break;
2942
- case "linear":
2943
- delay = opts.baseMs * (r + 1);
2944
- break;
2945
- case "exponential":
2946
- delay = opts.baseMs * 2 ** r;
2947
- if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
2948
- break;
2949
- }
2950
- if (opts.jitter) delay = delay * (0.5 + Math.random());
2951
- return Math.max(0, Math.floor(delay));
2952
- }
2953
-
2954
3009
  // src/internal/reactions.ts
2955
3010
  function finalize(lease, handled, at, error, options, logger, failed_at) {
2956
3011
  if (!error) return { lease, handled, acked_at: at };
@@ -3942,13 +3997,15 @@ var Act = class {
3942
3997
  return s;
3943
3998
  })();
3944
3999
  let kept = 0;
3945
- let dropped = { closed_streams: 0, snapshots: 0, empty_streams: 0 };
4000
+ let migrated = 0;
4001
+ let dropped = { closed_streams: 0, snapshots: 0 };
3946
4002
  await target.restore(async (callback) => {
3947
4003
  const partial = await scan(source, opts, callback);
3948
4004
  kept = partial.kept;
4005
+ migrated = partial.migrated;
3949
4006
  dropped = partial.dropped;
3950
4007
  });
3951
- return { kept, dropped, duration_ms: Date.now() - started };
4008
+ return { kept, migrated, dropped, duration_ms: Date.now() - started };
3952
4009
  });
3953
4010
  }
3954
4011
  /**
@@ -4480,7 +4537,7 @@ function state(entry) {
4480
4537
  function action_builder(state2) {
4481
4538
  const internal = state2;
4482
4539
  const builder = {
4483
- on(entry) {
4540
+ on(entry, options) {
4484
4541
  const keys = Object.keys(entry);
4485
4542
  if (keys.length !== 1) throw new Error(".on() requires exactly one key");
4486
4543
  const action2 = keys[0];
@@ -4488,6 +4545,10 @@ function action_builder(state2) {
4488
4545
  if (action2 in internal.actions)
4489
4546
  throw new Error(`Duplicate action "${action2}"`);
4490
4547
  internal.actions[action2] = schema;
4548
+ if (options) {
4549
+ internal.options ??= {};
4550
+ internal.options[action2] = options;
4551
+ }
4491
4552
  function given(rules) {
4492
4553
  internal.given ??= {};
4493
4554
  internal.given[action2] = rules;