@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.cjs CHANGED
@@ -2920,6 +2920,7 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
2920
2920
  }
2921
2921
 
2922
2922
  // src/internal/correlate-cycle.ts
2923
+ var DEFAULT_COLD_START_BACK_SCAN = 1e4;
2923
2924
  var CorrelateCycle = class {
2924
2925
  _checkpoint = -1;
2925
2926
  _initialized = false;
@@ -2948,8 +2949,24 @@ var CorrelateCycle = class {
2948
2949
  * `_scoped` (identity for a non-scoped Act), so it's required.
2949
2950
  */
2950
2951
  _run_scoped;
2951
- constructor(registry, static_targets, has_dynamic_resolvers, cd, maxSubscribedStreams, on_init, run_scoped, on_init_async) {
2952
- this._subscribed = new LruSet(maxSubscribedStreams);
2952
+ /**
2953
+ * Tail re-scan window applied to the cold-start checkpoint (ACT-1207).
2954
+ * See {@link DEFAULT_COLD_START_BACK_SCAN}. Constructor arg (not a
2955
+ * public option) so tests can shrink it; defaults otherwise.
2956
+ */
2957
+ _cold_start_back_scan;
2958
+ constructor({
2959
+ registry,
2960
+ static_targets,
2961
+ has_dynamic_resolvers,
2962
+ cd,
2963
+ max_subscribed_streams,
2964
+ run_scoped,
2965
+ on_init,
2966
+ on_init_async,
2967
+ cold_start_back_scan = DEFAULT_COLD_START_BACK_SCAN
2968
+ }) {
2969
+ this._subscribed = new LruSet(max_subscribed_streams);
2953
2970
  this._registry = registry;
2954
2971
  this._static_targets = static_targets;
2955
2972
  this._has_dynamic_resolvers = has_dynamic_resolvers;
@@ -2957,6 +2974,7 @@ var CorrelateCycle = class {
2957
2974
  this._on_init = on_init;
2958
2975
  this._run_scoped = run_scoped;
2959
2976
  this._on_init_async = on_init_async;
2977
+ this._cold_start_back_scan = cold_start_back_scan;
2960
2978
  }
2961
2979
  /** Last correlated event id. */
2962
2980
  get checkpoint() {
@@ -2964,7 +2982,10 @@ var CorrelateCycle = class {
2964
2982
  }
2965
2983
  /**
2966
2984
  * Initialize correlation state on first call.
2967
- * - Reads max(at) from store as cold-start checkpoint
2985
+ * - Reads max(at) from store, then floors the cold-start checkpoint at
2986
+ * `watermark - back_scan` when dynamic resolvers exist, so an event
2987
+ * committed-but-not-correlated before a crash is re-scanned on
2988
+ * restart instead of skipped (ACT-1207)
2968
2989
  * - Subscribes static resolver targets (idempotent upsert)
2969
2990
  * - Populates the subscribed-streams LRU
2970
2991
  * - Fires `on_init` once (Act uses this to flag a cold-start drain)
@@ -2973,7 +2994,7 @@ var CorrelateCycle = class {
2973
2994
  if (this._initialized) return;
2974
2995
  this._initialized = true;
2975
2996
  const { watermark } = await store2().subscribe([...this._static_targets]);
2976
- this._checkpoint = watermark;
2997
+ this._checkpoint = this._has_dynamic_resolvers ? Math.max(-1, watermark - this._cold_start_back_scan) : watermark;
2977
2998
  this._on_init?.();
2978
2999
  for (const { stream } of this._static_targets) {
2979
3000
  this._subscribed.add(stream);
@@ -3239,6 +3260,15 @@ function compute_backoff_delay(retry, opts) {
3239
3260
  }
3240
3261
 
3241
3262
  // src/internal/event-sourcing.ts
3263
+ var bare_patch = (_me, state2, partial) => (0, import_act_patch.patch)(state2, partial);
3264
+ var validating_patch = (me, state2, partial, event) => {
3265
+ const next = (0, import_act_patch.patch)(state2, partial);
3266
+ return validate(
3267
+ `${me.name}.${String(event.name)}#${event.id}`,
3268
+ next,
3269
+ me.state
3270
+ );
3271
+ };
3242
3272
  var DEFAULT_BATCH = 500;
3243
3273
  async function snap(snapshot) {
3244
3274
  const { id, stream, name, meta, version } = snapshot.event;
@@ -3393,7 +3423,7 @@ async function scan(source, opts = {}, callback) {
3393
3423
  }
3394
3424
  };
3395
3425
  }
3396
- async function load(me, target, callback) {
3426
+ async function load(me, target, callback, patch_fn = bare_patch) {
3397
3427
  const { stream, actor, asOf } = target;
3398
3428
  const time_travel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
3399
3429
  const cached = time_travel ? void 0 : await cache2().get(stream);
@@ -3412,7 +3442,7 @@ async function load(me, target, callback) {
3412
3442
  patches = 0;
3413
3443
  replayed++;
3414
3444
  } else if (me.patch[event.name]) {
3415
- state2 = (0, import_act_patch.patch)(state2, me.patch[event.name](event, state2));
3445
+ state2 = patch_fn(me, state2, me.patch[event.name](event, state2), event);
3416
3446
  patches++;
3417
3447
  replayed++;
3418
3448
  } else if (event.name !== TOMBSTONE_EVENT) {
@@ -3424,6 +3454,7 @@ async function load(me, target, callback) {
3424
3454
  event,
3425
3455
  state: state2,
3426
3456
  version: event.version,
3457
+ id: event.id,
3427
3458
  patches,
3428
3459
  snaps,
3429
3460
  cache_hit,
@@ -3445,19 +3476,24 @@ async function load(me, target, callback) {
3445
3476
  event_id: event.id,
3446
3477
  patches,
3447
3478
  snaps
3448
- });
3479
+ }).catch((err) => log().error(err));
3449
3480
  }
3450
3481
  return {
3451
3482
  event,
3452
3483
  state: state2,
3453
3484
  version: event?.version ?? cached?.version ?? -1,
3485
+ // Head event id, captured atomically with `state`: the last replayed
3486
+ // event's id on a cache miss, or the cached checkpoint's event_id on a
3487
+ // warm hit with no new events. Consumers read this instead of a
3488
+ // separate cache lookup that could race a concurrent commit (ACT-1204).
3489
+ id: event?.id ?? cached?.event_id ?? -1,
3454
3490
  patches,
3455
3491
  snaps,
3456
3492
  cache_hit,
3457
3493
  replayed
3458
3494
  };
3459
3495
  }
3460
- async function action(me, action2, target, payload, options) {
3496
+ async function action(me, action2, target, payload, options, patch_fn = bare_patch) {
3461
3497
  const { stream, expectedVersion, actor } = target;
3462
3498
  if (!stream) throw new Error("Missing target stream");
3463
3499
  const reactingTo = options?.reactingTo;
@@ -3467,7 +3503,12 @@ async function action(me, action2, target, payload, options) {
3467
3503
  const max_retries = opts?.maxRetries ?? 0;
3468
3504
  for (let attempt = 0; ; attempt++) {
3469
3505
  try {
3470
- const snapshot = await load(me, { stream, actor: target.actor });
3506
+ const snapshot = await load(
3507
+ me,
3508
+ { stream, actor: target.actor },
3509
+ void 0,
3510
+ patch_fn
3511
+ );
3471
3512
  if (snapshot.event?.name === TOMBSTONE_EVENT)
3472
3513
  throw new StreamClosedError(stream);
3473
3514
  const expected = expectedVersion ?? (snapshot.version >= 0 ? snapshot.version : void 0);
@@ -3538,12 +3579,13 @@ async function action(me, action2, target, payload, options) {
3538
3579
  const snapshots = committed.map((row, i) => {
3539
3580
  const event = { ...row, data: valid[i].data };
3540
3581
  const p = me.patch[event.name](event, state2);
3541
- state2 = (0, import_act_patch.patch)(state2, p);
3582
+ state2 = patch_fn(me, state2, p, event);
3542
3583
  patches++;
3543
3584
  return {
3544
3585
  event,
3545
3586
  state: state2,
3546
3587
  version: event.version,
3588
+ id: event.id,
3547
3589
  patches,
3548
3590
  snaps: snapshot.snaps,
3549
3591
  patch: p,
@@ -3571,6 +3613,7 @@ async function action(me, action2, target, payload, options) {
3571
3613
  return snapshots;
3572
3614
  } catch (error) {
3573
3615
  if (!(error instanceof ConcurrencyError)) throw error;
3616
+ if (expectedVersion !== void 0) throw error;
3574
3617
  if (attempt >= max_retries) throw error;
3575
3618
  if (opts?.backoff) {
3576
3619
  const delay_ms = compute_backoff_delay(attempt, opts.backoff);
@@ -3632,12 +3675,20 @@ var traced = (inner, exit, entry) => (async (...args) => {
3632
3675
  exit?.(result, ...args);
3633
3676
  return result;
3634
3677
  });
3635
- function build_es(logger, correlator = default_correlator) {
3636
- const bound_action = (me, action_name, target, payload, options) => action(me, action_name, target, payload, { correlator, ...options });
3678
+ function build_es(logger, correlator = default_correlator, patch_fn = bare_patch) {
3679
+ const bound_action = (me, action_name, target, payload, options) => action(
3680
+ me,
3681
+ action_name,
3682
+ target,
3683
+ payload,
3684
+ { correlator, ...options },
3685
+ patch_fn
3686
+ );
3687
+ const bound_load = (me, target, callback) => load(me, target, callback, patch_fn);
3637
3688
  if (logger.level !== "trace") {
3638
3689
  return {
3639
3690
  snap,
3640
- load,
3691
+ load: bound_load,
3641
3692
  action: bound_action,
3642
3693
  tombstone
3643
3694
  };
@@ -3652,7 +3703,7 @@ function build_es(logger, correlator = default_correlator) {
3652
3703
  )
3653
3704
  );
3654
3705
  }),
3655
- load: traced(load, (result, _me, target) => {
3706
+ load: traced(bound_load, (result, _me, target) => {
3656
3707
  const stats = stats_marker(
3657
3708
  result.version,
3658
3709
  result.replayed,
@@ -3963,6 +4014,83 @@ var DrainController = class {
3963
4014
  }
3964
4015
  };
3965
4016
 
4017
+ // src/internal/projection-fold.ts
4018
+ var import_zod10 = require("zod");
4019
+ var DEFAULT_FOLD_FLUSH_EVERY = 1e3;
4020
+ var DEFAULT_MAX_CACHED_STATES = 1e4;
4021
+ var FoldOptionsSchema = import_zod10.z.object({
4022
+ flushEvery: import_zod10.z.number().int().min(1).default(DEFAULT_FOLD_FLUSH_EVERY),
4023
+ maxCachedStates: import_zod10.z.number().int().min(1).default(DEFAULT_MAX_CACHED_STATES)
4024
+ });
4025
+ function resolveFoldConfig(options) {
4026
+ return FoldOptionsSchema.parse(options);
4027
+ }
4028
+ function make_fold_handler(me, flush, config2, patch_fn = bare_patch) {
4029
+ const cache3 = /* @__PURE__ */ new Map();
4030
+ const row = ({ dirty: _, ...row2 }) => row2;
4031
+ const flush_dirty = async () => {
4032
+ const rows = [];
4033
+ const flushed = [];
4034
+ for (const f of cache3.values())
4035
+ if (f.dirty) {
4036
+ rows.push(row(f));
4037
+ flushed.push(f);
4038
+ }
4039
+ if (rows.length === 0) return;
4040
+ await flush(rows);
4041
+ for (const f of flushed) f.dirty = false;
4042
+ };
4043
+ return async (events) => {
4044
+ let folded_since_flush = 0;
4045
+ for (const event of events) {
4046
+ const stream = event.stream;
4047
+ let fold = cache3.get(stream);
4048
+ if (fold) {
4049
+ cache3.delete(stream);
4050
+ cache3.set(stream, fold);
4051
+ } else {
4052
+ if (cache3.size >= config2.maxCachedStates) {
4053
+ const oldest = cache3.keys().next().value;
4054
+ const evictee = cache3.get(oldest);
4055
+ if (evictee.dirty) await flush([row(evictee)]);
4056
+ cache3.delete(oldest);
4057
+ }
4058
+ const snapshot = await load(me, { stream }, void 0, patch_fn);
4059
+ fold = {
4060
+ stream,
4061
+ state: snapshot.state,
4062
+ version: snapshot.version,
4063
+ event_id: snapshot.id,
4064
+ patches: snapshot.patches,
4065
+ snaps: snapshot.snaps,
4066
+ dirty: true
4067
+ };
4068
+ cache3.set(stream, fold);
4069
+ }
4070
+ if (event.id > fold.event_id) {
4071
+ const reducer = me.patch[event.name];
4072
+ fold.state = patch_fn(
4073
+ me,
4074
+ fold.state,
4075
+ reducer(event, fold.state),
4076
+ event
4077
+ );
4078
+ fold.version = event.version;
4079
+ fold.event_id = event.id;
4080
+ fold.patches++;
4081
+ fold.dirty = true;
4082
+ } else {
4083
+ fold.dirty = true;
4084
+ }
4085
+ if (++folded_since_flush >= config2.flushEvery) {
4086
+ await flush_dirty();
4087
+ folded_since_flush = 0;
4088
+ }
4089
+ }
4090
+ await flush_dirty();
4091
+ };
4092
+ }
4093
+
3966
4094
  // src/internal/reactions.ts
3967
4095
  function finalize(lease, handled, at, error, options, logger, failed_at) {
3968
4096
  if (!error) return { lease, handled, acked_at: at };
@@ -4093,6 +4221,16 @@ function build_handle_batch(logger) {
4093
4221
  var SettleLoop = class {
4094
4222
  _timer = void 0;
4095
4223
  _running = false;
4224
+ /**
4225
+ * Set when a `schedule()` timer fires while a cycle is still running
4226
+ * (ACT-1205). The in-flight cycle's `finally` re-arms one more pass so
4227
+ * the wake-up isn't dropped — a commit landing during the final
4228
+ * no-progress drain pass would otherwise leave armed controllers with
4229
+ * nothing to re-drain on an instance with no lane `cycleMs` and no
4230
+ * polling. Carries the options of the dropped call so the re-armed
4231
+ * pass honors its `debounceMs`/`maxPasses`/drain overrides.
4232
+ */
4233
+ _pending = void 0;
4096
4234
  _deps;
4097
4235
  /** Debounce window applied when the caller doesn't override via `SettleOptions.debounceMs`. */
4098
4236
  _default_debounce_ms;
@@ -4117,7 +4255,10 @@ var SettleLoop = class {
4117
4255
  if (this._timer) clearTimeout(this._timer);
4118
4256
  this._timer = setTimeout(() => {
4119
4257
  this._timer = void 0;
4120
- if (this._running) return;
4258
+ if (this._running) {
4259
+ this._pending = options;
4260
+ return;
4261
+ }
4121
4262
  this._running = true;
4122
4263
  (async () => {
4123
4264
  await this._deps.init();
@@ -4137,11 +4278,17 @@ var SettleLoop = class {
4137
4278
  this._deps.breaker.failed(Date.now(), err);
4138
4279
  }).finally(() => {
4139
4280
  this._running = false;
4281
+ const pending = this._pending;
4282
+ if (pending !== void 0) {
4283
+ this._pending = void 0;
4284
+ this.schedule(pending);
4285
+ }
4140
4286
  });
4141
4287
  }, debounceMs);
4142
4288
  }
4143
4289
  /** Cancel any pending or active settle cycle. Idempotent. */
4144
4290
  stop() {
4291
+ this._pending = void 0;
4145
4292
  if (this._timer) {
4146
4293
  clearTimeout(this._timer);
4147
4294
  this._timer = void 0;
@@ -4149,89 +4296,6 @@ var SettleLoop = class {
4149
4296
  }
4150
4297
  };
4151
4298
 
4152
- // src/internal/state-fold.ts
4153
- var import_act_patch2 = require("@rotorsoft/act-patch");
4154
- var import_zod10 = require("zod");
4155
- var DEFAULT_FOLD_FLUSH_EVERY = 1e3;
4156
- var DEFAULT_MAX_CACHED_STATES = 1e4;
4157
- var FoldOptionsSchema = import_zod10.z.object({
4158
- flushEvery: import_zod10.z.number().int().min(1).default(DEFAULT_FOLD_FLUSH_EVERY),
4159
- maxCachedStates: import_zod10.z.number().int().min(1).default(DEFAULT_MAX_CACHED_STATES)
4160
- });
4161
- function resolveFoldConfig(options) {
4162
- return FoldOptionsSchema.parse(options);
4163
- }
4164
- function make_fold_handler(me, flush, config2) {
4165
- const cache3 = /* @__PURE__ */ new Map();
4166
- const row = ({ dirty: _, ...row2 }) => row2;
4167
- const flush_dirty = async () => {
4168
- const rows = [];
4169
- const flushed = [];
4170
- for (const f of cache3.values())
4171
- if (f.dirty) {
4172
- rows.push(row(f));
4173
- flushed.push(f);
4174
- }
4175
- if (rows.length === 0) return;
4176
- await flush(rows);
4177
- for (const f of flushed) f.dirty = false;
4178
- };
4179
- return async (events) => {
4180
- let folded_since_flush = 0;
4181
- for (const event of events) {
4182
- const stream = event.stream;
4183
- let fold = cache3.get(stream);
4184
- if (fold) {
4185
- cache3.delete(stream);
4186
- cache3.set(stream, fold);
4187
- } else {
4188
- if (cache3.size >= config2.maxCachedStates) {
4189
- const oldest = cache3.keys().next().value;
4190
- const evictee = cache3.get(oldest);
4191
- if (evictee.dirty) await flush([row(evictee)]);
4192
- cache3.delete(oldest);
4193
- }
4194
- const snapshot = await load(me, { stream });
4195
- const entry = await cache2().get(stream);
4196
- const frontier = entry ?? {
4197
- version: snapshot.event.version,
4198
- event_id: snapshot.event.id,
4199
- patches: snapshot.patches,
4200
- snaps: snapshot.snaps
4201
- };
4202
- fold = {
4203
- stream,
4204
- state: snapshot.state,
4205
- version: frontier.version,
4206
- event_id: frontier.event_id,
4207
- patches: frontier.patches,
4208
- snaps: frontier.snaps,
4209
- dirty: true
4210
- };
4211
- cache3.set(stream, fold);
4212
- }
4213
- if (event.id > fold.event_id) {
4214
- const reducer = me.patch[event.name];
4215
- fold.state = (0, import_act_patch2.patch)(
4216
- fold.state,
4217
- reducer(event, fold.state)
4218
- );
4219
- fold.version = event.version;
4220
- fold.event_id = event.id;
4221
- fold.patches++;
4222
- fold.dirty = true;
4223
- } else {
4224
- fold.dirty = true;
4225
- }
4226
- if (++folded_since_flush >= config2.flushEvery) {
4227
- await flush_dirty();
4228
- folded_since_flush = 0;
4229
- }
4230
- }
4231
- await flush_dirty();
4232
- };
4233
- }
4234
-
4235
4299
  // src/act.ts
4236
4300
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
4237
4301
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
@@ -4388,8 +4452,14 @@ var Act = class {
4388
4452
  * @param lanes Declared drain lanes (ACT-1103). The builder collects
4389
4453
  * these from `.withLane(...)` calls. Slice 1 records them on the
4390
4454
  * instance; later slices fan out one `DrainController` per lane.
4455
+ * @param patch_fn The per-event patch step selected once by the builder
4456
+ * from `ActOptions.validateFoldedState` (ACT-1238) — `bare_patch` by
4457
+ * default, `validating_patch` when the flag is on. The builder uses
4458
+ * the same value for its projection-fold handlers, so there is a
4459
+ * single selection site. Defaults to `bare_patch` for direct
4460
+ * construction.
4391
4461
  */
4392
- constructor(registry, states = /* @__PURE__ */ new Map(), batch_handlers = /* @__PURE__ */ new Map(), options = {}, lanes = []) {
4462
+ constructor(registry, states = /* @__PURE__ */ new Map(), batch_handlers = /* @__PURE__ */ new Map(), options = {}, lanes = [], patch_fn = bare_patch) {
4393
4463
  this.registry = registry;
4394
4464
  this._states = states;
4395
4465
  this._batch_handlers = batch_handlers;
@@ -4397,7 +4467,7 @@ var Act = class {
4397
4467
  validate_only_lanes(options, lanes);
4398
4468
  this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
4399
4469
  this._correlator = options.correlator ?? default_correlator;
4400
- this._es = build_es(this._logger, this._correlator);
4470
+ this._es = build_es(this._logger, this._correlator, patch_fn);
4401
4471
  this._cd = build_drain(this._logger);
4402
4472
  this._handle = build_handle({
4403
4473
  logger: this._logger,
@@ -4564,22 +4634,22 @@ var Act = class {
4564
4634
  * except on writer-only instances (`drain: false`).
4565
4635
  */
4566
4636
  _build_correlate(options, classification) {
4567
- return new CorrelateCycle(
4568
- this.registry,
4569
- classification.static_targets,
4570
- classification.has_dynamic_resolvers,
4571
- this._cd,
4572
- options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
4573
- () => {
4637
+ return new CorrelateCycle({
4638
+ registry: this.registry,
4639
+ static_targets: classification.static_targets,
4640
+ has_dynamic_resolvers: classification.has_dynamic_resolvers,
4641
+ cd: this._cd,
4642
+ max_subscribed_streams: options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
4643
+ on_init: () => {
4574
4644
  if (this._drain && this._reactive_events.size > 0) this._arm_all();
4575
4645
  },
4576
4646
  // Re-scope the background `start_correlations` timer so its
4577
4647
  // correlate resolves the scoped ports, not the singleton (#1191).
4578
- this._scoped,
4648
+ run_scoped: this._scoped,
4579
4649
  // Cold-start defer re-seed (#1221). Skipped on writer-only instances
4580
4650
  // (`drain: false`) — they run no local controllers to re-arm.
4581
- this._drain ? () => this._seed_persisted_defers() : void 0
4582
- );
4651
+ on_init_async: this._drain ? () => this._seed_persisted_defers() : void 0
4652
+ });
4583
4653
  }
4584
4654
  /**
4585
4655
  * Re-seed every active lane controller's process-local defer timer from
@@ -5638,6 +5708,7 @@ function act() {
5638
5708
  },
5639
5709
  on: (event) => reaction_on(event, registry.events, builder),
5640
5710
  build: (options) => {
5711
+ const patch_fn = options?.validateFoldedState === true ? validating_patch : bare_patch;
5641
5712
  if (!_built) {
5642
5713
  for (const proj of pending_projections) {
5643
5714
  merge_projection(proj, registry.events);
@@ -5660,7 +5731,7 @@ function act() {
5660
5731
  );
5661
5732
  batch_handlers.set(
5662
5733
  proj.target,
5663
- make_fold_handler(merged, fold.flush, fold.config)
5734
+ make_fold_handler(merged, fold.flush, fold.config, patch_fn)
5664
5735
  );
5665
5736
  }
5666
5737
  finalize_deprecations();
@@ -5733,7 +5804,8 @@ function act() {
5733
5804
  states,
5734
5805
  batch_handlers,
5735
5806
  options,
5736
- lanes
5807
+ lanes,
5808
+ patch_fn
5737
5809
  );
5738
5810
  },
5739
5811
  events: registry.events