@rotorsoft/act 1.18.8 → 1.20.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
@@ -1242,6 +1242,7 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
1242
1242
  const state2 = seed_states.get(stream);
1243
1243
  if (state2 && entry) {
1244
1244
  await cache().set(stream, {
1245
+ stream,
1245
1246
  state: state2,
1246
1247
  version: entry.committed.version,
1247
1248
  event_id: entry.committed.id,
@@ -1555,7 +1556,7 @@ var DEFAULT_BATCH = 500;
1555
1556
  async function snap(snapshot) {
1556
1557
  const { id, stream, name, meta, version } = snapshot.event;
1557
1558
  try {
1558
- await store().commit(
1559
+ const [committed] = await store().commit(
1559
1560
  stream,
1560
1561
  [{ name: SNAP_EVENT, data: snapshot.state }],
1561
1562
  {
@@ -1565,6 +1566,7 @@ async function snap(snapshot) {
1565
1566
  version
1566
1567
  // IMPORTANT! - state events are committed right after the snapshot event
1567
1568
  );
1569
+ return committed;
1568
1570
  } catch (error) {
1569
1571
  const reason = error instanceof Error ? error.message : String(error);
1570
1572
  log().warn(
@@ -1746,6 +1748,7 @@ async function load(me, target, callback) {
1746
1748
  );
1747
1749
  if (replayed > 0 && !time_travel && event && !me.pii_aware) {
1748
1750
  await cache().set(stream, {
1751
+ stream,
1749
1752
  state: state2,
1750
1753
  version: event.version,
1751
1754
  event_id: event.id,
@@ -1776,7 +1779,7 @@ async function action(me, action2, target, payload, options) {
1776
1779
  const snapshot = await load(me, { stream, actor: target.actor });
1777
1780
  if (snapshot.event?.name === TOMBSTONE_EVENT)
1778
1781
  throw new StreamClosedError(stream);
1779
- const expected = expectedVersion ?? snapshot.event?.version;
1782
+ const expected = expectedVersion ?? (snapshot.version >= 0 ? snapshot.version : void 0);
1780
1783
  if (me.given) {
1781
1784
  const invariants = me.given[action2] || [];
1782
1785
  invariants.forEach(({ valid: valid2, description }) => {
@@ -1858,17 +1861,22 @@ async function action(me, action2, target, payload, options) {
1858
1861
  };
1859
1862
  });
1860
1863
  const last = snapshots.at(-1);
1861
- const snapped = me.snap?.(last);
1864
+ const contiguous = committed[0].version === snapshot.version + 1;
1865
+ const snapped = contiguous && me.snap?.(last);
1866
+ const snap_event = snapped ? await snap(last) : void 0;
1862
1867
  if (!me.pii_aware) {
1863
- cache().set(stream, {
1864
- state: last.state,
1865
- version: last.event.version,
1866
- event_id: last.event.id,
1867
- patches: snapped ? 0 : last.patches,
1868
- snaps: snapped ? last.snaps + 1 : last.snaps
1869
- }).catch((err) => log().error(err));
1868
+ if (contiguous)
1869
+ cache().set(stream, {
1870
+ stream,
1871
+ state: last.state,
1872
+ version: snap_event?.version ?? last.event.version,
1873
+ event_id: snap_event?.id ?? last.event.id,
1874
+ patches: snap_event ? 0 : last.patches,
1875
+ snaps: snap_event ? last.snaps + 1 : last.snaps
1876
+ }).catch((err) => log().error(err));
1877
+ else
1878
+ cache().invalidate(stream).catch((err) => log().error(err));
1870
1879
  }
1871
- if (snapped) void snap(last);
1872
1880
  return snapshots;
1873
1881
  } catch (error) {
1874
1882
  if (!(error instanceof ConcurrencyError)) throw error;
@@ -2420,6 +2428,89 @@ var SettleLoop = class {
2420
2428
  }
2421
2429
  };
2422
2430
 
2431
+ // src/internal/state-fold.ts
2432
+ import { patch as patch2 } from "@rotorsoft/act-patch";
2433
+ import { z as z5 } from "zod";
2434
+ var DEFAULT_FOLD_FLUSH_EVERY = 1e3;
2435
+ var DEFAULT_MAX_CACHED_STATES = 1e4;
2436
+ var FoldOptionsSchema = z5.object({
2437
+ flushEvery: z5.number().int().min(1).default(DEFAULT_FOLD_FLUSH_EVERY),
2438
+ maxCachedStates: z5.number().int().min(1).default(DEFAULT_MAX_CACHED_STATES)
2439
+ });
2440
+ function resolveFoldConfig(options) {
2441
+ return FoldOptionsSchema.parse(options);
2442
+ }
2443
+ function make_fold_handler(me, flush, config2) {
2444
+ const cache2 = /* @__PURE__ */ new Map();
2445
+ const row = ({ dirty: _, ...row2 }) => row2;
2446
+ const flush_dirty = async () => {
2447
+ const rows = [];
2448
+ const flushed = [];
2449
+ for (const f of cache2.values())
2450
+ if (f.dirty) {
2451
+ rows.push(row(f));
2452
+ flushed.push(f);
2453
+ }
2454
+ if (rows.length === 0) return;
2455
+ await flush(rows);
2456
+ for (const f of flushed) f.dirty = false;
2457
+ };
2458
+ return async (events) => {
2459
+ let folded_since_flush = 0;
2460
+ for (const event of events) {
2461
+ const stream = event.stream;
2462
+ let fold = cache2.get(stream);
2463
+ if (fold) {
2464
+ cache2.delete(stream);
2465
+ cache2.set(stream, fold);
2466
+ } else {
2467
+ if (cache2.size >= config2.maxCachedStates) {
2468
+ const oldest = cache2.keys().next().value;
2469
+ const evictee = cache2.get(oldest);
2470
+ if (evictee.dirty) await flush([row(evictee)]);
2471
+ cache2.delete(oldest);
2472
+ }
2473
+ const snapshot = await load(me, { stream });
2474
+ const entry = await cache().get(stream);
2475
+ const frontier = entry ?? {
2476
+ version: snapshot.event.version,
2477
+ event_id: snapshot.event.id,
2478
+ patches: snapshot.patches,
2479
+ snaps: snapshot.snaps
2480
+ };
2481
+ fold = {
2482
+ stream,
2483
+ state: snapshot.state,
2484
+ version: frontier.version,
2485
+ event_id: frontier.event_id,
2486
+ patches: frontier.patches,
2487
+ snaps: frontier.snaps,
2488
+ dirty: true
2489
+ };
2490
+ cache2.set(stream, fold);
2491
+ }
2492
+ if (event.id > fold.event_id) {
2493
+ const reducer = me.patch[event.name];
2494
+ fold.state = patch2(
2495
+ fold.state,
2496
+ reducer(event, fold.state)
2497
+ );
2498
+ fold.version = event.version;
2499
+ fold.event_id = event.id;
2500
+ fold.patches++;
2501
+ fold.dirty = true;
2502
+ } else {
2503
+ fold.dirty = true;
2504
+ }
2505
+ if (++folded_since_flush >= config2.flushEvery) {
2506
+ await flush_dirty();
2507
+ folded_since_flush = 0;
2508
+ }
2509
+ }
2510
+ await flush_dirty();
2511
+ };
2512
+ }
2513
+
2423
2514
  // src/act.ts
2424
2515
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2425
2516
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
@@ -3654,6 +3745,7 @@ function act() {
3654
3745
  autoclose_archiver: (state_name) => _aa.get(state_name) ?? null
3655
3746
  };
3656
3747
  const pending_projections = [];
3748
+ const fold_projections = [];
3657
3749
  const batch_handlers = /* @__PURE__ */ new Map();
3658
3750
  const lanes = [];
3659
3751
  let _built = false;
@@ -3719,6 +3811,8 @@ function act() {
3719
3811
  withProjection: (proj) => {
3720
3812
  merge_projection(proj, registry.events);
3721
3813
  register_batch_handler(proj, batch_handlers);
3814
+ if (proj.fold)
3815
+ fold_projections.push(proj);
3722
3816
  return builder;
3723
3817
  },
3724
3818
  withActor: () => builder,
@@ -3732,6 +3826,26 @@ function act() {
3732
3826
  for (const proj of pending_projections) {
3733
3827
  merge_projection(proj, registry.events);
3734
3828
  register_batch_handler(proj, batch_handlers);
3829
+ if (proj.fold) fold_projections.push(proj);
3830
+ }
3831
+ for (const proj of fold_projections) {
3832
+ const fold = proj.fold;
3833
+ const merged = states.get(fold.name);
3834
+ if (!merged)
3835
+ throw new Error(
3836
+ `State projection "${proj.target}" folds "${fold.name}", which is not registered \u2014 add it via withState/withSlice before build()`
3837
+ );
3838
+ const missing = Object.keys(merged.events).filter(
3839
+ (event_name) => !(event_name in proj.events)
3840
+ );
3841
+ if (missing.length > 0)
3842
+ throw new Error(
3843
+ `State projection "${proj.target}" of "${fold.name}" is missing events ${missing.join(", ")} \u2014 pass every partial of the state to .of()`
3844
+ );
3845
+ batch_handlers.set(
3846
+ proj.target,
3847
+ make_fold_handler(merged, fold.flush, fold.config)
3848
+ );
3735
3849
  }
3736
3850
  finalize_deprecations();
3737
3851
  validate_lane_references(registry, lanes);
@@ -3873,7 +3987,57 @@ function _projection(target) {
3873
3987
  target,
3874
3988
  batchHandler: handler
3875
3989
  })
3876
- })
3990
+ }),
3991
+ of: (...args) => {
3992
+ if (Object.keys(events).length > 0)
3993
+ throw new Error(
3994
+ `Projection "${target}" mixes .of() with .on() handlers \u2014 a projection either folds a state or declares handlers, never both`
3995
+ );
3996
+ const is_state = (a) => !!a && typeof a.init === "function";
3997
+ const partials = args.filter(is_state);
3998
+ const options = args.find((a) => !is_state(a)) ?? {};
3999
+ if (partials.length === 0)
4000
+ throw new Error(`Projection "${target}" .of() requires a state`);
4001
+ const name = partials[0].name;
4002
+ for (const partial of partials)
4003
+ if (partial.name !== name)
4004
+ throw new Error(
4005
+ `Projection "${target}" .of() partials must share one state name \u2014 got "${name}" and "${partial.name}"`
4006
+ );
4007
+ const config2 = resolveFoldConfig(options);
4008
+ const fold_events = {};
4009
+ for (const partial of partials)
4010
+ for (const [event_name, schema] of Object.entries(partial.events)) {
4011
+ if (event_name in fold_events) continue;
4012
+ const noop = {
4013
+ [`${target}_fold`]: async () => {
4014
+ }
4015
+ }[`${target}_fold`];
4016
+ fold_events[event_name] = {
4017
+ schema,
4018
+ reactions: /* @__PURE__ */ new Map([
4019
+ [
4020
+ `${target}_fold`,
4021
+ {
4022
+ handler: noop,
4023
+ resolver: { target },
4024
+ options: { blockOnError: true, maxRetries: 3 }
4025
+ }
4026
+ ]
4027
+ ])
4028
+ };
4029
+ }
4030
+ return {
4031
+ flush: (handler) => ({
4032
+ build: () => ({
4033
+ _tag: "Projection",
4034
+ events: fold_events,
4035
+ target,
4036
+ fold: { name, flush: handler, config: config2 }
4037
+ })
4038
+ })
4039
+ };
4040
+ }
3877
4041
  });
3878
4042
  }
3879
4043
  return base;