@rotorsoft/act 1.10.2 → 1.11.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
@@ -486,57 +486,24 @@ var PASS_FACTORIES = {
486
486
  "clock-anomalies": make_clock_anomalies_pass
487
487
  };
488
488
 
489
- // src/internal/build-classify.ts
490
- var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
491
- function classify_registry(registry, states) {
492
- const statics = /* @__PURE__ */ new Map();
493
- const reactive_events = /* @__PURE__ */ new Set();
494
- const event_to_lanes = /* @__PURE__ */ new Map();
495
- let has_dynamic_resolvers = false;
496
- for (const [name, register] of Object.entries(registry.events)) {
497
- if (register.reactions.size > 0) reactive_events.add(name);
498
- for (const reaction of register.reactions.values()) {
499
- if (typeof reaction.resolver === "function") {
500
- has_dynamic_resolvers = true;
501
- event_to_lanes.set(name, ALL_LANES);
502
- } else {
503
- const { target, source, priority = 0, lane } = reaction.resolver;
504
- const lane_name = lane ?? "default";
505
- const existing_lanes = event_to_lanes.get(name);
506
- if (existing_lanes !== ALL_LANES) {
507
- const set = existing_lanes ?? /* @__PURE__ */ new Set();
508
- set.add(lane_name);
509
- event_to_lanes.set(name, set);
510
- }
511
- const key = `${target}|${source ?? ""}`;
512
- const existing = statics.get(key);
513
- if (!existing) {
514
- statics.set(key, { stream: target, source, priority, lane });
515
- } else {
516
- if ((existing.lane ?? void 0) !== (lane ?? void 0))
517
- throw new Error(
518
- `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
519
- );
520
- if (priority > existing.priority) {
521
- statics.set(key, { ...existing, priority });
522
- }
523
- }
524
- }
525
- }
526
- }
527
- const event_to_state = /* @__PURE__ */ new Map();
528
- for (const merged of states.values()) {
529
- for (const event_name of Object.keys(merged.events)) {
530
- event_to_state.set(event_name, merged);
531
- }
532
- }
533
- return {
534
- static_targets: [...statics.values()],
535
- has_dynamic_resolvers,
536
- reactive_events,
537
- event_to_state,
538
- event_to_lanes
539
- };
489
+ // src/internal/autoclose-config.ts
490
+ import { z } from "zod";
491
+ var DEFAULT_AUTOCLOSE_CYCLE_MS = 6e4;
492
+ var DEFAULT_CLOSE_BATCH_SIZE = 64;
493
+ var DEFAULT_CLOSE_YIELD_MS = 0;
494
+ var AutocloseOptionsSchema = z.object({
495
+ autocloseCycleMs: z.number().min(1e4).max(36e5).default(DEFAULT_AUTOCLOSE_CYCLE_MS),
496
+ closeBatchSize: z.number().int().min(1).max(1024).default(DEFAULT_CLOSE_BATCH_SIZE),
497
+ closeYieldMs: z.number().min(0).max(1e3).default(DEFAULT_CLOSE_YIELD_MS),
498
+ closeOnError: z.boolean().default(false)
499
+ });
500
+ function resolveAutocloseConfig(options) {
501
+ return AutocloseOptionsSchema.parse({
502
+ autocloseCycleMs: options?.autocloseCycleMs,
503
+ closeBatchSize: options?.closeBatchSize,
504
+ closeYieldMs: options?.closeYieldMs,
505
+ closeOnError: options?.closeOnError
506
+ });
540
507
  }
541
508
 
542
509
  // src/internal/close-cycle.ts
@@ -686,6 +653,233 @@ async function truncate_and_warm_cache(guarded, seed_states, guard_events, corre
686
653
  return truncated;
687
654
  }
688
655
 
656
+ // src/internal/correlator.ts
657
+ import { randomInt } from "crypto";
658
+ var BASE = 36;
659
+ var SEG_WIDTH = 4;
660
+ var SEG_SPACE = BASE ** SEG_WIDTH;
661
+ function seg(n) {
662
+ return n.toString(BASE).padStart(SEG_WIDTH, "0");
663
+ }
664
+ var default_correlator = ({ state: state2, action: action2 }) => {
665
+ const s = state2.slice(0, SEG_WIDTH).toLowerCase();
666
+ const a = action2.slice(0, SEG_WIDTH).toLowerCase();
667
+ const ts = seg(Date.now() % SEG_SPACE);
668
+ const rnd = seg(randomInt(SEG_SPACE));
669
+ return `${s}-${a}-${ts}${rnd}`;
670
+ };
671
+ function close_correlation(correlator, actor) {
672
+ return correlator({
673
+ state: "$close",
674
+ action: "close",
675
+ stream: "$close",
676
+ actor
677
+ });
678
+ }
679
+
680
+ // src/internal/autoclose-cycle.ts
681
+ function yield_between_batches(ms) {
682
+ if (ms <= 0) return Promise.resolve();
683
+ return new Promise((resolve) => setTimeout(resolve, ms));
684
+ }
685
+ async function run_autoclose_cycle(deps) {
686
+ const s = store();
687
+ let inspected = 0;
688
+ let evaluated = 0;
689
+ let predicate_errors = 0;
690
+ const truncated = /* @__PURE__ */ new Map();
691
+ const skipped = [];
692
+ const stats = await s.query_stats(
693
+ {},
694
+ { count: true, exclude: [TOMBSTONE_EVENT] }
695
+ );
696
+ const flush_batch = async (candidates2) => {
697
+ if (candidates2.length === 0) return;
698
+ const result = await run_close_cycle(candidates2, {
699
+ reactive_events_size: deps.reactive_events_size,
700
+ event_to_state: deps.event_to_state,
701
+ load: deps.load,
702
+ tombstone: deps.tombstone,
703
+ logger: deps.logger,
704
+ correlation: deps.correlation
705
+ });
706
+ for (const [stream, entry] of result.truncated) {
707
+ truncated.set(stream, entry);
708
+ }
709
+ for (const stream of result.skipped) skipped.push(stream);
710
+ await yield_between_batches(deps.config.closeYieldMs);
711
+ };
712
+ let candidates = [];
713
+ for (const [stream, { head, count }] of stats) {
714
+ inspected += 1;
715
+ const owner = deps.event_to_state.get(head.name);
716
+ if (!owner) continue;
717
+ const predicate = deps.autoclose_policy(owner.name);
718
+ if (!predicate) continue;
719
+ let close = false;
720
+ try {
721
+ close = predicate(stream, head, count ?? 0);
722
+ evaluated += 1;
723
+ } catch (err) {
724
+ predicate_errors += 1;
725
+ deps.logger.error(
726
+ `Autoclose predicate for state "${owner.name}" threw on stream "${stream}": ${err instanceof Error ? err.message : String(err)}`
727
+ );
728
+ close = deps.config.closeOnError;
729
+ }
730
+ if (!close) continue;
731
+ const archiver = deps.autoclose_archiver(owner.name);
732
+ const archive = archiver ? () => archiver(stream, head) : void 0;
733
+ candidates.push({ stream, archive });
734
+ if (candidates.length >= deps.config.closeBatchSize) {
735
+ await flush_batch(candidates);
736
+ candidates = [];
737
+ }
738
+ }
739
+ await flush_batch(candidates);
740
+ return {
741
+ inspected,
742
+ evaluated,
743
+ predicate_errors,
744
+ close_result: { truncated, skipped }
745
+ };
746
+ }
747
+ var AutocloseController = class {
748
+ deps;
749
+ _timer;
750
+ /**
751
+ * Reentrancy guard. The interval fires the next tick on cadence
752
+ * regardless of how long the previous one took; this flag drops
753
+ * overlapping ticks so the cycle stays sequential per controller.
754
+ */
755
+ _running = false;
756
+ constructor(deps) {
757
+ this.deps = deps;
758
+ }
759
+ /**
760
+ * Start the cycle ticker. Returns `false` if already running so
761
+ * accidental double-start doesn't stack timers (matches
762
+ * `_correlate.start_polling` semantics).
763
+ */
764
+ start() {
765
+ if (this._timer) return false;
766
+ this._timer = setInterval(() => {
767
+ this.run_once().catch((err) => {
768
+ this.deps.logger.error(
769
+ `Autoclose cycle errored: ${err instanceof Error ? err.message : String(err)}`
770
+ );
771
+ });
772
+ }, this.deps.config.autocloseCycleMs);
773
+ this._timer.unref();
774
+ return true;
775
+ }
776
+ /**
777
+ * Stop the cycle ticker. Idempotent.
778
+ */
779
+ stop() {
780
+ if (this._timer) {
781
+ clearInterval(this._timer);
782
+ this._timer = void 0;
783
+ }
784
+ }
785
+ /**
786
+ * Whether the ticker is currently running.
787
+ */
788
+ get is_running() {
789
+ return this._timer !== void 0;
790
+ }
791
+ /**
792
+ * Run one cycle synchronously. The controller's interval callback
793
+ * delegates here; tests can invoke it directly to deterministically
794
+ * exercise the cycle without spinning real timers.
795
+ *
796
+ * Overlapping invocations short-circuit: if a previous tick is
797
+ * still running, this one drops and returns `null`. The orchestrator
798
+ * never sees a queue of pending ticks regardless of how slow the
799
+ * predicate / archive / truncate are.
800
+ */
801
+ async run_once() {
802
+ if (this._running) return null;
803
+ this._running = true;
804
+ try {
805
+ const correlation = close_correlation(
806
+ this.deps.correlator,
807
+ this.deps.close_actor
808
+ );
809
+ const result = await run_autoclose_cycle({
810
+ autoclose_policy: this.deps.autoclose_policy,
811
+ autoclose_archiver: this.deps.autoclose_archiver,
812
+ event_to_state: this.deps.event_to_state,
813
+ reactive_events_size: this.deps.reactive_events_size,
814
+ load: this.deps.load,
815
+ tombstone: this.deps.tombstone,
816
+ logger: this.deps.logger,
817
+ config: this.deps.config,
818
+ correlation
819
+ });
820
+ if (result.close_result.truncated.size > 0) {
821
+ this.deps.on_closed(result.close_result);
822
+ }
823
+ return result;
824
+ } finally {
825
+ this._running = false;
826
+ }
827
+ }
828
+ };
829
+
830
+ // src/internal/build-classify.ts
831
+ var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
832
+ function classify_registry(registry, states) {
833
+ const statics = /* @__PURE__ */ new Map();
834
+ const reactive_events = /* @__PURE__ */ new Set();
835
+ const event_to_lanes = /* @__PURE__ */ new Map();
836
+ let has_dynamic_resolvers = false;
837
+ for (const [name, register] of Object.entries(registry.events)) {
838
+ if (register.reactions.size > 0) reactive_events.add(name);
839
+ for (const reaction of register.reactions.values()) {
840
+ if (typeof reaction.resolver === "function") {
841
+ has_dynamic_resolvers = true;
842
+ event_to_lanes.set(name, ALL_LANES);
843
+ } else {
844
+ const { target, source, priority = 0, lane } = reaction.resolver;
845
+ const lane_name = lane ?? "default";
846
+ const existing_lanes = event_to_lanes.get(name);
847
+ if (existing_lanes !== ALL_LANES) {
848
+ const set = existing_lanes ?? /* @__PURE__ */ new Set();
849
+ set.add(lane_name);
850
+ event_to_lanes.set(name, set);
851
+ }
852
+ const key = `${target}|${source ?? ""}`;
853
+ const existing = statics.get(key);
854
+ if (!existing) {
855
+ statics.set(key, { stream: target, source, priority, lane });
856
+ } else {
857
+ if ((existing.lane ?? void 0) !== (lane ?? void 0))
858
+ throw new Error(
859
+ `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
860
+ );
861
+ if (priority > existing.priority) {
862
+ statics.set(key, { ...existing, priority });
863
+ }
864
+ }
865
+ }
866
+ }
867
+ }
868
+ const event_to_state = /* @__PURE__ */ new Map();
869
+ for (const merged of states.values()) {
870
+ for (const event_name of Object.keys(merged.events)) {
871
+ event_to_state.set(event_name, merged);
872
+ }
873
+ }
874
+ return {
875
+ static_targets: [...statics.values()],
876
+ has_dynamic_resolvers,
877
+ reactive_events,
878
+ event_to_state,
879
+ event_to_lanes
880
+ };
881
+ }
882
+
689
883
  // src/internal/correlate-cycle.ts
690
884
  var CorrelateCycle = class {
691
885
  _checkpoint = -1;
@@ -814,30 +1008,6 @@ var CorrelateCycle = class {
814
1008
  }
815
1009
  };
816
1010
 
817
- // src/internal/correlator.ts
818
- import { randomInt } from "crypto";
819
- var BASE = 36;
820
- var SEG_WIDTH = 4;
821
- var SEG_SPACE = BASE ** SEG_WIDTH;
822
- function seg(n) {
823
- return n.toString(BASE).padStart(SEG_WIDTH, "0");
824
- }
825
- var default_correlator = ({ state: state2, action: action2 }) => {
826
- const s = state2.slice(0, SEG_WIDTH).toLowerCase();
827
- const a = action2.slice(0, SEG_WIDTH).toLowerCase();
828
- const ts = seg(Date.now() % SEG_SPACE);
829
- const rnd = seg(randomInt(SEG_SPACE));
830
- return `${s}-${a}-${ts}${rnd}`;
831
- };
832
- function close_correlation(correlator, actor) {
833
- return correlator({
834
- state: "$close",
835
- action: "close",
836
- stream: "$close",
837
- actor
838
- });
839
- }
840
-
841
1011
  // src/internal/drain-cycle.ts
842
1012
  import { randomUUID } from "crypto";
843
1013
 
@@ -1941,6 +2111,22 @@ var Act = class {
1941
2111
  registry;
1942
2112
  /** Map of state name → state definition; populated by the builder. */
1943
2113
  _states;
2114
+ /**
2115
+ * Resolved autoclose configuration (#837 / epic #802). Frozen at
2116
+ * `act().build()` via {@link resolveAutocloseConfig}.
2117
+ *
2118
+ * @internal
2119
+ */
2120
+ _autoclose_config;
2121
+ /**
2122
+ * App-level autoclose controller. `undefined` when no state declares
2123
+ * `.autocloses(...)` — the controller is never constructed, so
2124
+ * `start_correlations()` / `stop_correlations()` skip the autoclose
2125
+ * lifecycle and the orchestrator pays zero per-tick cost.
2126
+ *
2127
+ * @internal
2128
+ */
2129
+ _autoclose;
1944
2130
  /**
1945
2131
  * Emit a lifecycle event. The payload type is inferred from the event name
1946
2132
  * via {@link ActLifecycleEvents}.
@@ -2075,6 +2261,7 @@ var Act = class {
2075
2261
  this._reactive_events = reactive_events;
2076
2262
  this._listen = options.listen !== false;
2077
2263
  this._drain = options.drain !== false;
2264
+ this._autoclose_config = resolveAutocloseConfig(options);
2078
2265
  this._event_to_state = event_to_state;
2079
2266
  this._event_to_lanes = event_to_lanes;
2080
2267
  const all_lanes = ["default", ...lanes.map((l) => l.name)];
@@ -2138,6 +2325,26 @@ var Act = class {
2138
2325
  },
2139
2326
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
2140
2327
  );
2328
+ const states_with_policy = [...this._states.values()].filter(
2329
+ (s) => s.autoclose
2330
+ );
2331
+ this._autoclose = states_with_policy.length ? new AutocloseController({
2332
+ // Internal cycle takes the loose `(string) => …` signature; the
2333
+ // public `registry.autoclose_policy` narrows to
2334
+ // `keyof TStateMap & string`. Thin lambdas bridge across the
2335
+ // narrowing — runtime semantics are unchanged.
2336
+ autoclose_policy: (name) => this.registry.autoclose_policy(name),
2337
+ autoclose_archiver: (name) => this.registry.autoclose_archiver(name),
2338
+ event_to_state: this._event_to_state,
2339
+ reactive_events_size: this._reactive_events.size,
2340
+ load: this._es.load,
2341
+ tombstone: this._es.tombstone,
2342
+ logger: this._logger,
2343
+ config: this._autoclose_config,
2344
+ correlator: this._correlator,
2345
+ close_actor: { id: "$autoclose", name: "autoclose" },
2346
+ on_closed: (result) => this.emit("closed", result)
2347
+ }) : void 0;
2141
2348
  this._notify_disposer = this._wire_notify(options.scoped?.store ?? store());
2142
2349
  dispose(() => this.shutdown());
2143
2350
  }
@@ -2629,7 +2836,9 @@ var Act = class {
2629
2836
  * @see {@link stop_correlations} to stop the worker
2630
2837
  */
2631
2838
  start_correlations(query = {}, frequency = 1e4, callback) {
2632
- return this._correlate.start_polling(query, frequency, callback);
2839
+ const started = this._correlate.start_polling(query, frequency, callback);
2840
+ this._autoclose?.start();
2841
+ return started;
2633
2842
  }
2634
2843
  /**
2635
2844
  * Stops the automatic correlation worker.
@@ -2650,6 +2859,7 @@ var Act = class {
2650
2859
  */
2651
2860
  stop_correlations() {
2652
2861
  this._correlate.stop_polling();
2862
+ this._autoclose?.stop();
2653
2863
  }
2654
2864
  /**
2655
2865
  * Cancels any pending or active settle cycle.
@@ -3034,13 +3244,17 @@ function act() {
3034
3244
  const _sf = /* @__PURE__ */ new Map();
3035
3245
  const _dp = /* @__PURE__ */ new Map();
3036
3246
  const _de = /* @__PURE__ */ new Map();
3247
+ const _ac = /* @__PURE__ */ new Map();
3248
+ const _aa = /* @__PURE__ */ new Map();
3037
3249
  const EMPTY_DEPRECATED = /* @__PURE__ */ new Set();
3038
3250
  const registry = {
3039
3251
  actions: {},
3040
3252
  events: {},
3041
3253
  sensitive_fields: (event_name) => _sf.get(event_name) ?? [],
3042
3254
  disclosure_predicate: (state_name) => _dp.get(state_name) ?? null,
3043
- deprecated_events: (state_name) => _de.get(state_name) ?? EMPTY_DEPRECATED
3255
+ deprecated_events: (state_name) => _de.get(state_name) ?? EMPTY_DEPRECATED,
3256
+ autoclose_policy: (state_name) => _ac.get(state_name) ?? null,
3257
+ autoclose_archiver: (state_name) => _aa.get(state_name) ?? null
3044
3258
  };
3045
3259
  const pending_projections = [];
3046
3260
  const batch_handlers = /* @__PURE__ */ new Map();
@@ -3167,6 +3381,8 @@ function act() {
3167
3381
  }
3168
3382
  for (const state2 of states.values()) {
3169
3383
  if (state2.disclose) _dp.set(state2.name, state2.disclose);
3384
+ if (state2.autoclose) _ac.set(state2.name, state2.autoclose);
3385
+ if (state2.archive) _aa.set(state2.name, state2.archive);
3170
3386
  const fields_by_event = /* @__PURE__ */ new Map();
3171
3387
  for (const event_name of Object.keys(state2.events)) {
3172
3388
  const fields = _sf.get(event_name);
@@ -3434,6 +3650,24 @@ function action_builder(state2) {
3434
3650
  internal.disclose = disclose;
3435
3651
  return builder;
3436
3652
  },
3653
+ autocloses(predicate) {
3654
+ if (typeof predicate !== "function") {
3655
+ throw new Error(
3656
+ ".autocloses(predicate) requires a function; got " + typeof predicate
3657
+ );
3658
+ }
3659
+ internal.autoclose = predicate;
3660
+ return builder;
3661
+ },
3662
+ archives(archive) {
3663
+ if (typeof archive !== "function") {
3664
+ throw new Error(
3665
+ ".archives(archive) requires a function; got " + typeof archive
3666
+ );
3667
+ }
3668
+ internal.archive = archive;
3669
+ return builder;
3670
+ },
3437
3671
  build() {
3438
3672
  return internal;
3439
3673
  }
@@ -3609,6 +3843,9 @@ export {
3609
3843
  ConcurrencyError,
3610
3844
  ConsoleLogger,
3611
3845
  CsvFile,
3846
+ DEFAULT_AUTOCLOSE_CYCLE_MS,
3847
+ DEFAULT_CLOSE_BATCH_SIZE,
3848
+ DEFAULT_CLOSE_YIELD_MS,
3612
3849
  DEFAULT_LANE,
3613
3850
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
3614
3851
  DEFAULT_SETTLE_DEBOUNCE_MS,
@@ -3640,6 +3877,7 @@ export {
3640
3877
  log,
3641
3878
  port,
3642
3879
  projection,
3880
+ resolveAutocloseConfig,
3643
3881
  scoped,
3644
3882
  sensitive,
3645
3883
  sleep,