@rotorsoft/act 1.22.8 → 1.23.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
@@ -1396,6 +1396,7 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
1396
1396
  }
1397
1397
 
1398
1398
  // src/internal/correlate-cycle.ts
1399
+ var DEFAULT_COLD_START_BACK_SCAN = 1e4;
1399
1400
  var CorrelateCycle = class {
1400
1401
  _checkpoint = -1;
1401
1402
  _initialized = false;
@@ -1424,8 +1425,24 @@ var CorrelateCycle = class {
1424
1425
  * `_scoped` (identity for a non-scoped Act), so it's required.
1425
1426
  */
1426
1427
  _run_scoped;
1427
- constructor(registry, static_targets, has_dynamic_resolvers, cd, maxSubscribedStreams, on_init, run_scoped, on_init_async) {
1428
- this._subscribed = new LruSet(maxSubscribedStreams);
1428
+ /**
1429
+ * Tail re-scan window applied to the cold-start checkpoint (ACT-1207).
1430
+ * See {@link DEFAULT_COLD_START_BACK_SCAN}. Constructor arg (not a
1431
+ * public option) so tests can shrink it; defaults otherwise.
1432
+ */
1433
+ _cold_start_back_scan;
1434
+ constructor({
1435
+ registry,
1436
+ static_targets,
1437
+ has_dynamic_resolvers,
1438
+ cd,
1439
+ max_subscribed_streams,
1440
+ run_scoped,
1441
+ on_init,
1442
+ on_init_async,
1443
+ cold_start_back_scan = DEFAULT_COLD_START_BACK_SCAN
1444
+ }) {
1445
+ this._subscribed = new LruSet(max_subscribed_streams);
1429
1446
  this._registry = registry;
1430
1447
  this._static_targets = static_targets;
1431
1448
  this._has_dynamic_resolvers = has_dynamic_resolvers;
@@ -1433,6 +1450,7 @@ var CorrelateCycle = class {
1433
1450
  this._on_init = on_init;
1434
1451
  this._run_scoped = run_scoped;
1435
1452
  this._on_init_async = on_init_async;
1453
+ this._cold_start_back_scan = cold_start_back_scan;
1436
1454
  }
1437
1455
  /** Last correlated event id. */
1438
1456
  get checkpoint() {
@@ -1440,7 +1458,10 @@ var CorrelateCycle = class {
1440
1458
  }
1441
1459
  /**
1442
1460
  * Initialize correlation state on first call.
1443
- * - Reads max(at) from store as cold-start checkpoint
1461
+ * - Reads max(at) from store, then floors the cold-start checkpoint at
1462
+ * `watermark - back_scan` when dynamic resolvers exist, so an event
1463
+ * committed-but-not-correlated before a crash is re-scanned on
1464
+ * restart instead of skipped (ACT-1207)
1444
1465
  * - Subscribes static resolver targets (idempotent upsert)
1445
1466
  * - Populates the subscribed-streams LRU
1446
1467
  * - Fires `on_init` once (Act uses this to flag a cold-start drain)
@@ -1449,7 +1470,7 @@ var CorrelateCycle = class {
1449
1470
  if (this._initialized) return;
1450
1471
  this._initialized = true;
1451
1472
  const { watermark } = await store().subscribe([...this._static_targets]);
1452
- this._checkpoint = watermark;
1473
+ this._checkpoint = this._has_dynamic_resolvers ? Math.max(-1, watermark - this._cold_start_back_scan) : watermark;
1453
1474
  this._on_init?.();
1454
1475
  for (const { stream } of this._static_targets) {
1455
1476
  this._subscribed.add(stream);
@@ -1715,6 +1736,15 @@ function compute_backoff_delay(retry, opts) {
1715
1736
  }
1716
1737
 
1717
1738
  // src/internal/event-sourcing.ts
1739
+ var bare_patch = (_me, state2, partial) => patch(state2, partial);
1740
+ var validating_patch = (me, state2, partial, event) => {
1741
+ const next = patch(state2, partial);
1742
+ return validate(
1743
+ `${me.name}.${String(event.name)}#${event.id}`,
1744
+ next,
1745
+ me.state
1746
+ );
1747
+ };
1718
1748
  var DEFAULT_BATCH = 500;
1719
1749
  async function snap(snapshot) {
1720
1750
  const { id, stream, name, meta, version } = snapshot.event;
@@ -1869,7 +1899,7 @@ async function scan(source, opts = {}, callback) {
1869
1899
  }
1870
1900
  };
1871
1901
  }
1872
- async function load(me, target, callback) {
1902
+ async function load(me, target, callback, patch_fn = bare_patch) {
1873
1903
  const { stream, actor, asOf } = target;
1874
1904
  const time_travel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
1875
1905
  const cached = time_travel ? void 0 : await cache().get(stream);
@@ -1888,7 +1918,7 @@ async function load(me, target, callback) {
1888
1918
  patches = 0;
1889
1919
  replayed++;
1890
1920
  } else if (me.patch[event.name]) {
1891
- state2 = patch(state2, me.patch[event.name](event, state2));
1921
+ state2 = patch_fn(me, state2, me.patch[event.name](event, state2), event);
1892
1922
  patches++;
1893
1923
  replayed++;
1894
1924
  } else if (event.name !== TOMBSTONE_EVENT) {
@@ -1900,6 +1930,7 @@ async function load(me, target, callback) {
1900
1930
  event,
1901
1931
  state: state2,
1902
1932
  version: event.version,
1933
+ id: event.id,
1903
1934
  patches,
1904
1935
  snaps,
1905
1936
  cache_hit,
@@ -1921,19 +1952,24 @@ async function load(me, target, callback) {
1921
1952
  event_id: event.id,
1922
1953
  patches,
1923
1954
  snaps
1924
- });
1955
+ }).catch((err) => log().error(err));
1925
1956
  }
1926
1957
  return {
1927
1958
  event,
1928
1959
  state: state2,
1929
1960
  version: event?.version ?? cached?.version ?? -1,
1961
+ // Head event id, captured atomically with `state`: the last replayed
1962
+ // event's id on a cache miss, or the cached checkpoint's event_id on a
1963
+ // warm hit with no new events. Consumers read this instead of a
1964
+ // separate cache lookup that could race a concurrent commit (ACT-1204).
1965
+ id: event?.id ?? cached?.event_id ?? -1,
1930
1966
  patches,
1931
1967
  snaps,
1932
1968
  cache_hit,
1933
1969
  replayed
1934
1970
  };
1935
1971
  }
1936
- async function action(me, action2, target, payload, options) {
1972
+ async function action(me, action2, target, payload, options, patch_fn = bare_patch) {
1937
1973
  const { stream, expectedVersion, actor } = target;
1938
1974
  if (!stream) throw new Error("Missing target stream");
1939
1975
  const reactingTo = options?.reactingTo;
@@ -1943,7 +1979,12 @@ async function action(me, action2, target, payload, options) {
1943
1979
  const max_retries = opts?.maxRetries ?? 0;
1944
1980
  for (let attempt = 0; ; attempt++) {
1945
1981
  try {
1946
- const snapshot = await load(me, { stream, actor: target.actor });
1982
+ const snapshot = await load(
1983
+ me,
1984
+ { stream, actor: target.actor },
1985
+ void 0,
1986
+ patch_fn
1987
+ );
1947
1988
  if (snapshot.event?.name === TOMBSTONE_EVENT)
1948
1989
  throw new StreamClosedError(stream);
1949
1990
  const expected = expectedVersion ?? (snapshot.version >= 0 ? snapshot.version : void 0);
@@ -2014,12 +2055,13 @@ async function action(me, action2, target, payload, options) {
2014
2055
  const snapshots = committed.map((row, i) => {
2015
2056
  const event = { ...row, data: valid[i].data };
2016
2057
  const p = me.patch[event.name](event, state2);
2017
- state2 = patch(state2, p);
2058
+ state2 = patch_fn(me, state2, p, event);
2018
2059
  patches++;
2019
2060
  return {
2020
2061
  event,
2021
2062
  state: state2,
2022
2063
  version: event.version,
2064
+ id: event.id,
2023
2065
  patches,
2024
2066
  snaps: snapshot.snaps,
2025
2067
  patch: p,
@@ -2047,6 +2089,7 @@ async function action(me, action2, target, payload, options) {
2047
2089
  return snapshots;
2048
2090
  } catch (error) {
2049
2091
  if (!(error instanceof ConcurrencyError)) throw error;
2092
+ if (expectedVersion !== void 0) throw error;
2050
2093
  if (attempt >= max_retries) throw error;
2051
2094
  if (opts?.backoff) {
2052
2095
  const delay_ms = compute_backoff_delay(attempt, opts.backoff);
@@ -2108,12 +2151,20 @@ var traced = (inner, exit, entry) => (async (...args) => {
2108
2151
  exit?.(result, ...args);
2109
2152
  return result;
2110
2153
  });
2111
- function build_es(logger, correlator = default_correlator) {
2112
- const bound_action = (me, action_name, target, payload, options) => action(me, action_name, target, payload, { correlator, ...options });
2154
+ function build_es(logger, correlator = default_correlator, patch_fn = bare_patch) {
2155
+ const bound_action = (me, action_name, target, payload, options) => action(
2156
+ me,
2157
+ action_name,
2158
+ target,
2159
+ payload,
2160
+ { correlator, ...options },
2161
+ patch_fn
2162
+ );
2163
+ const bound_load = (me, target, callback) => load(me, target, callback, patch_fn);
2113
2164
  if (logger.level !== "trace") {
2114
2165
  return {
2115
2166
  snap,
2116
- load,
2167
+ load: bound_load,
2117
2168
  action: bound_action,
2118
2169
  tombstone
2119
2170
  };
@@ -2128,7 +2179,7 @@ function build_es(logger, correlator = default_correlator) {
2128
2179
  )
2129
2180
  );
2130
2181
  }),
2131
- load: traced(load, (result, _me, target) => {
2182
+ load: traced(bound_load, (result, _me, target) => {
2132
2183
  const stats = stats_marker(
2133
2184
  result.version,
2134
2185
  result.replayed,
@@ -2439,6 +2490,83 @@ var DrainController = class {
2439
2490
  }
2440
2491
  };
2441
2492
 
2493
+ // src/internal/projection-fold.ts
2494
+ import { z as z5 } from "zod";
2495
+ var DEFAULT_FOLD_FLUSH_EVERY = 1e3;
2496
+ var DEFAULT_MAX_CACHED_STATES = 1e4;
2497
+ var FoldOptionsSchema = z5.object({
2498
+ flushEvery: z5.number().int().min(1).default(DEFAULT_FOLD_FLUSH_EVERY),
2499
+ maxCachedStates: z5.number().int().min(1).default(DEFAULT_MAX_CACHED_STATES)
2500
+ });
2501
+ function resolveFoldConfig(options) {
2502
+ return FoldOptionsSchema.parse(options);
2503
+ }
2504
+ function make_fold_handler(me, flush, config2, patch_fn = bare_patch) {
2505
+ const cache2 = /* @__PURE__ */ new Map();
2506
+ const row = ({ dirty: _, ...row2 }) => row2;
2507
+ const flush_dirty = async () => {
2508
+ const rows = [];
2509
+ const flushed = [];
2510
+ for (const f of cache2.values())
2511
+ if (f.dirty) {
2512
+ rows.push(row(f));
2513
+ flushed.push(f);
2514
+ }
2515
+ if (rows.length === 0) return;
2516
+ await flush(rows);
2517
+ for (const f of flushed) f.dirty = false;
2518
+ };
2519
+ return async (events) => {
2520
+ let folded_since_flush = 0;
2521
+ for (const event of events) {
2522
+ const stream = event.stream;
2523
+ let fold = cache2.get(stream);
2524
+ if (fold) {
2525
+ cache2.delete(stream);
2526
+ cache2.set(stream, fold);
2527
+ } else {
2528
+ if (cache2.size >= config2.maxCachedStates) {
2529
+ const oldest = cache2.keys().next().value;
2530
+ const evictee = cache2.get(oldest);
2531
+ if (evictee.dirty) await flush([row(evictee)]);
2532
+ cache2.delete(oldest);
2533
+ }
2534
+ const snapshot = await load(me, { stream }, void 0, patch_fn);
2535
+ fold = {
2536
+ stream,
2537
+ state: snapshot.state,
2538
+ version: snapshot.version,
2539
+ event_id: snapshot.id,
2540
+ patches: snapshot.patches,
2541
+ snaps: snapshot.snaps,
2542
+ dirty: true
2543
+ };
2544
+ cache2.set(stream, fold);
2545
+ }
2546
+ if (event.id > fold.event_id) {
2547
+ const reducer = me.patch[event.name];
2548
+ fold.state = patch_fn(
2549
+ me,
2550
+ fold.state,
2551
+ reducer(event, fold.state),
2552
+ event
2553
+ );
2554
+ fold.version = event.version;
2555
+ fold.event_id = event.id;
2556
+ fold.patches++;
2557
+ fold.dirty = true;
2558
+ } else {
2559
+ fold.dirty = true;
2560
+ }
2561
+ if (++folded_since_flush >= config2.flushEvery) {
2562
+ await flush_dirty();
2563
+ folded_since_flush = 0;
2564
+ }
2565
+ }
2566
+ await flush_dirty();
2567
+ };
2568
+ }
2569
+
2442
2570
  // src/internal/reactions.ts
2443
2571
  function finalize(lease, handled, at, error, options, logger, failed_at) {
2444
2572
  if (!error) return { lease, handled, acked_at: at };
@@ -2569,6 +2697,16 @@ function build_handle_batch(logger) {
2569
2697
  var SettleLoop = class {
2570
2698
  _timer = void 0;
2571
2699
  _running = false;
2700
+ /**
2701
+ * Set when a `schedule()` timer fires while a cycle is still running
2702
+ * (ACT-1205). The in-flight cycle's `finally` re-arms one more pass so
2703
+ * the wake-up isn't dropped — a commit landing during the final
2704
+ * no-progress drain pass would otherwise leave armed controllers with
2705
+ * nothing to re-drain on an instance with no lane `cycleMs` and no
2706
+ * polling. Carries the options of the dropped call so the re-armed
2707
+ * pass honors its `debounceMs`/`maxPasses`/drain overrides.
2708
+ */
2709
+ _pending = void 0;
2572
2710
  _deps;
2573
2711
  /** Debounce window applied when the caller doesn't override via `SettleOptions.debounceMs`. */
2574
2712
  _default_debounce_ms;
@@ -2593,7 +2731,10 @@ var SettleLoop = class {
2593
2731
  if (this._timer) clearTimeout(this._timer);
2594
2732
  this._timer = setTimeout(() => {
2595
2733
  this._timer = void 0;
2596
- if (this._running) return;
2734
+ if (this._running) {
2735
+ this._pending = options;
2736
+ return;
2737
+ }
2597
2738
  this._running = true;
2598
2739
  (async () => {
2599
2740
  await this._deps.init();
@@ -2613,11 +2754,17 @@ var SettleLoop = class {
2613
2754
  this._deps.breaker.failed(Date.now(), err);
2614
2755
  }).finally(() => {
2615
2756
  this._running = false;
2757
+ const pending = this._pending;
2758
+ if (pending !== void 0) {
2759
+ this._pending = void 0;
2760
+ this.schedule(pending);
2761
+ }
2616
2762
  });
2617
2763
  }, debounceMs);
2618
2764
  }
2619
2765
  /** Cancel any pending or active settle cycle. Idempotent. */
2620
2766
  stop() {
2767
+ this._pending = void 0;
2621
2768
  if (this._timer) {
2622
2769
  clearTimeout(this._timer);
2623
2770
  this._timer = void 0;
@@ -2625,89 +2772,6 @@ var SettleLoop = class {
2625
2772
  }
2626
2773
  };
2627
2774
 
2628
- // src/internal/state-fold.ts
2629
- import { patch as patch2 } from "@rotorsoft/act-patch";
2630
- import { z as z5 } from "zod";
2631
- var DEFAULT_FOLD_FLUSH_EVERY = 1e3;
2632
- var DEFAULT_MAX_CACHED_STATES = 1e4;
2633
- var FoldOptionsSchema = z5.object({
2634
- flushEvery: z5.number().int().min(1).default(DEFAULT_FOLD_FLUSH_EVERY),
2635
- maxCachedStates: z5.number().int().min(1).default(DEFAULT_MAX_CACHED_STATES)
2636
- });
2637
- function resolveFoldConfig(options) {
2638
- return FoldOptionsSchema.parse(options);
2639
- }
2640
- function make_fold_handler(me, flush, config2) {
2641
- const cache2 = /* @__PURE__ */ new Map();
2642
- const row = ({ dirty: _, ...row2 }) => row2;
2643
- const flush_dirty = async () => {
2644
- const rows = [];
2645
- const flushed = [];
2646
- for (const f of cache2.values())
2647
- if (f.dirty) {
2648
- rows.push(row(f));
2649
- flushed.push(f);
2650
- }
2651
- if (rows.length === 0) return;
2652
- await flush(rows);
2653
- for (const f of flushed) f.dirty = false;
2654
- };
2655
- return async (events) => {
2656
- let folded_since_flush = 0;
2657
- for (const event of events) {
2658
- const stream = event.stream;
2659
- let fold = cache2.get(stream);
2660
- if (fold) {
2661
- cache2.delete(stream);
2662
- cache2.set(stream, fold);
2663
- } else {
2664
- if (cache2.size >= config2.maxCachedStates) {
2665
- const oldest = cache2.keys().next().value;
2666
- const evictee = cache2.get(oldest);
2667
- if (evictee.dirty) await flush([row(evictee)]);
2668
- cache2.delete(oldest);
2669
- }
2670
- const snapshot = await load(me, { stream });
2671
- const entry = await cache().get(stream);
2672
- const frontier = entry ?? {
2673
- version: snapshot.event.version,
2674
- event_id: snapshot.event.id,
2675
- patches: snapshot.patches,
2676
- snaps: snapshot.snaps
2677
- };
2678
- fold = {
2679
- stream,
2680
- state: snapshot.state,
2681
- version: frontier.version,
2682
- event_id: frontier.event_id,
2683
- patches: frontier.patches,
2684
- snaps: frontier.snaps,
2685
- dirty: true
2686
- };
2687
- cache2.set(stream, fold);
2688
- }
2689
- if (event.id > fold.event_id) {
2690
- const reducer = me.patch[event.name];
2691
- fold.state = patch2(
2692
- fold.state,
2693
- reducer(event, fold.state)
2694
- );
2695
- fold.version = event.version;
2696
- fold.event_id = event.id;
2697
- fold.patches++;
2698
- fold.dirty = true;
2699
- } else {
2700
- fold.dirty = true;
2701
- }
2702
- if (++folded_since_flush >= config2.flushEvery) {
2703
- await flush_dirty();
2704
- folded_since_flush = 0;
2705
- }
2706
- }
2707
- await flush_dirty();
2708
- };
2709
- }
2710
-
2711
2775
  // src/act.ts
2712
2776
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
2713
2777
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
@@ -2864,8 +2928,14 @@ var Act = class {
2864
2928
  * @param lanes Declared drain lanes (ACT-1103). The builder collects
2865
2929
  * these from `.withLane(...)` calls. Slice 1 records them on the
2866
2930
  * instance; later slices fan out one `DrainController` per lane.
2931
+ * @param patch_fn The per-event patch step selected once by the builder
2932
+ * from `ActOptions.validateFoldedState` (ACT-1238) — `bare_patch` by
2933
+ * default, `validating_patch` when the flag is on. The builder uses
2934
+ * the same value for its projection-fold handlers, so there is a
2935
+ * single selection site. Defaults to `bare_patch` for direct
2936
+ * construction.
2867
2937
  */
2868
- constructor(registry, states = /* @__PURE__ */ new Map(), batch_handlers = /* @__PURE__ */ new Map(), options = {}, lanes = []) {
2938
+ constructor(registry, states = /* @__PURE__ */ new Map(), batch_handlers = /* @__PURE__ */ new Map(), options = {}, lanes = [], patch_fn = bare_patch) {
2869
2939
  this.registry = registry;
2870
2940
  this._states = states;
2871
2941
  this._batch_handlers = batch_handlers;
@@ -2873,7 +2943,7 @@ var Act = class {
2873
2943
  validate_only_lanes(options, lanes);
2874
2944
  this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
2875
2945
  this._correlator = options.correlator ?? default_correlator;
2876
- this._es = build_es(this._logger, this._correlator);
2946
+ this._es = build_es(this._logger, this._correlator, patch_fn);
2877
2947
  this._cd = build_drain(this._logger);
2878
2948
  this._handle = build_handle({
2879
2949
  logger: this._logger,
@@ -3040,22 +3110,22 @@ var Act = class {
3040
3110
  * except on writer-only instances (`drain: false`).
3041
3111
  */
3042
3112
  _build_correlate(options, classification) {
3043
- return new CorrelateCycle(
3044
- this.registry,
3045
- classification.static_targets,
3046
- classification.has_dynamic_resolvers,
3047
- this._cd,
3048
- options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
3049
- () => {
3113
+ return new CorrelateCycle({
3114
+ registry: this.registry,
3115
+ static_targets: classification.static_targets,
3116
+ has_dynamic_resolvers: classification.has_dynamic_resolvers,
3117
+ cd: this._cd,
3118
+ max_subscribed_streams: options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
3119
+ on_init: () => {
3050
3120
  if (this._drain && this._reactive_events.size > 0) this._arm_all();
3051
3121
  },
3052
3122
  // Re-scope the background `start_correlations` timer so its
3053
3123
  // correlate resolves the scoped ports, not the singleton (#1191).
3054
- this._scoped,
3124
+ run_scoped: this._scoped,
3055
3125
  // Cold-start defer re-seed (#1221). Skipped on writer-only instances
3056
3126
  // (`drain: false`) — they run no local controllers to re-arm.
3057
- this._drain ? () => this._seed_persisted_defers() : void 0
3058
- );
3127
+ on_init_async: this._drain ? () => this._seed_persisted_defers() : void 0
3128
+ });
3059
3129
  }
3060
3130
  /**
3061
3131
  * Re-seed every active lane controller's process-local defer timer from
@@ -4114,6 +4184,7 @@ function act() {
4114
4184
  },
4115
4185
  on: (event) => reaction_on(event, registry.events, builder),
4116
4186
  build: (options) => {
4187
+ const patch_fn = options?.validateFoldedState === true ? validating_patch : bare_patch;
4117
4188
  if (!_built) {
4118
4189
  for (const proj of pending_projections) {
4119
4190
  merge_projection(proj, registry.events);
@@ -4136,7 +4207,7 @@ function act() {
4136
4207
  );
4137
4208
  batch_handlers.set(
4138
4209
  proj.target,
4139
- make_fold_handler(merged, fold.flush, fold.config)
4210
+ make_fold_handler(merged, fold.flush, fold.config, patch_fn)
4140
4211
  );
4141
4212
  }
4142
4213
  finalize_deprecations();
@@ -4209,7 +4280,8 @@ function act() {
4209
4280
  states,
4210
4281
  batch_handlers,
4211
4282
  options,
4212
- lanes
4283
+ lanes,
4284
+ patch_fn
4213
4285
  );
4214
4286
  },
4215
4287
  events: registry.events