@rotorsoft/act 1.16.1 → 1.18.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/.tsbuildinfo +1 -1
- package/dist/@types/act.d.ts +0 -9
- package/dist/@types/act.d.ts.map +1 -1
- package/dist/@types/adapters/in-memory-store.d.ts +10 -0
- package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
- package/dist/@types/builders/act-builder.d.ts +3 -6
- package/dist/@types/builders/act-builder.d.ts.map +1 -1
- package/dist/@types/builders/slice-builder.d.ts +16 -8
- package/dist/@types/builders/slice-builder.d.ts.map +1 -1
- package/dist/@types/builders/state-builder.d.ts +19 -26
- package/dist/@types/builders/state-builder.d.ts.map +1 -1
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.d.ts.map +1 -1
- package/dist/@types/internal/autoclose-policy.d.ts +16 -0
- package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
- package/dist/@types/internal/builder-utils.d.ts +65 -0
- package/dist/@types/internal/builder-utils.d.ts.map +1 -0
- package/dist/@types/internal/close-signal.d.ts +51 -0
- package/dist/@types/internal/close-signal.d.ts.map +1 -0
- package/dist/@types/internal/defer-config.d.ts +64 -0
- package/dist/@types/internal/defer-config.d.ts.map +1 -0
- package/dist/@types/internal/defer-signal.d.ts +35 -0
- package/dist/@types/internal/defer-signal.d.ts.map +1 -0
- package/dist/@types/internal/defer-timer.d.ts +67 -0
- package/dist/@types/internal/defer-timer.d.ts.map +1 -0
- package/dist/@types/internal/drain-cycle.d.ts +39 -17
- package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
- package/dist/@types/internal/drain.d.ts +3 -1
- package/dist/@types/internal/drain.d.ts.map +1 -1
- package/dist/@types/internal/index.d.ts +7 -5
- package/dist/@types/internal/index.d.ts.map +1 -1
- package/dist/@types/internal/reactions.d.ts.map +1 -1
- package/dist/@types/internal/tracing.d.ts.map +1 -1
- package/dist/@types/types/action.d.ts +36 -0
- package/dist/@types/types/action.d.ts.map +1 -1
- package/dist/@types/types/ports.d.ts +31 -0
- package/dist/@types/types/ports.d.ts.map +1 -1
- package/dist/{chunk-46OCUAVF.js → chunk-7XME4ZLK.js} +52 -2
- package/dist/chunk-7XME4ZLK.js.map +1 -0
- package/dist/index.cjs +852 -754
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +728 -681
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +55 -5
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.js +5 -5
- package/dist/test/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/@types/internal/autoclose-cycle.d.ts +0 -185
- package/dist/@types/internal/autoclose-cycle.d.ts.map +0 -1
- package/dist/chunk-46OCUAVF.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
sleep,
|
|
20
20
|
store,
|
|
21
21
|
validate
|
|
22
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-7XME4ZLK.js";
|
|
23
23
|
import {
|
|
24
24
|
ActorSchema,
|
|
25
25
|
CausationEventSchema,
|
|
@@ -540,353 +540,6 @@ function in_autoclose_window(window, now) {
|
|
|
540
540
|
return window.start < window.end ? hour >= window.start && hour < window.end : hour >= window.start || hour < window.end;
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
-
// src/internal/close-cycle.ts
|
|
544
|
-
var SAFETY_PROBE_PAGE_SIZE = 1e3;
|
|
545
|
-
async function run_close_cycle(targets, deps) {
|
|
546
|
-
const target_map = new Map(targets.map((t) => [t.stream, t]));
|
|
547
|
-
const streams = [...target_map.keys()];
|
|
548
|
-
const skipped = [];
|
|
549
|
-
const stream_info = await scan_stream_heads(streams);
|
|
550
|
-
const safe = await partition_by_safety(
|
|
551
|
-
stream_info,
|
|
552
|
-
deps.reactive_events_size,
|
|
553
|
-
skipped,
|
|
554
|
-
deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
|
|
555
|
-
);
|
|
556
|
-
if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
|
|
557
|
-
const { guarded, guard_events } = await guard_with_tombstones(
|
|
558
|
-
safe,
|
|
559
|
-
stream_info,
|
|
560
|
-
deps.correlation,
|
|
561
|
-
deps.tombstone,
|
|
562
|
-
skipped
|
|
563
|
-
);
|
|
564
|
-
if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
|
|
565
|
-
const seed_states = await load_restart_seeds(
|
|
566
|
-
guarded,
|
|
567
|
-
target_map,
|
|
568
|
-
stream_info,
|
|
569
|
-
deps.event_to_state,
|
|
570
|
-
deps.load,
|
|
571
|
-
deps.logger
|
|
572
|
-
);
|
|
573
|
-
await run_archive_callbacks(guarded, target_map);
|
|
574
|
-
const truncated = await truncate_and_warm_cache(
|
|
575
|
-
guarded,
|
|
576
|
-
seed_states,
|
|
577
|
-
guard_events,
|
|
578
|
-
deps.correlation
|
|
579
|
-
);
|
|
580
|
-
return { truncated, skipped };
|
|
581
|
-
}
|
|
582
|
-
async function scan_stream_heads(streams) {
|
|
583
|
-
const stats = await store().query_stats(streams, {
|
|
584
|
-
exclude: [SNAP_EVENT]
|
|
585
|
-
});
|
|
586
|
-
const out = /* @__PURE__ */ new Map();
|
|
587
|
-
for (const [stream, { head }] of stats) {
|
|
588
|
-
if (head.name === TOMBSTONE_EVENT) continue;
|
|
589
|
-
out.set(stream, {
|
|
590
|
-
max_id: head.id,
|
|
591
|
-
version: head.version,
|
|
592
|
-
last_event_name: head.name
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
return out;
|
|
596
|
-
}
|
|
597
|
-
async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
|
|
598
|
-
if (reactive_events_size === 0) return [...stream_info.keys()];
|
|
599
|
-
const pending_set = /* @__PURE__ */ new Set();
|
|
600
|
-
const source_regex = /* @__PURE__ */ new Map();
|
|
601
|
-
const get_regex = (source) => {
|
|
602
|
-
let re = source_regex.get(source);
|
|
603
|
-
if (!re) {
|
|
604
|
-
re = new RegExp(source);
|
|
605
|
-
source_regex.set(source, re);
|
|
606
|
-
}
|
|
607
|
-
return re;
|
|
608
|
-
};
|
|
609
|
-
const targets = [...stream_info.keys()];
|
|
610
|
-
let after;
|
|
611
|
-
for (; ; ) {
|
|
612
|
-
let last;
|
|
613
|
-
const { count } = await store().query_streams(
|
|
614
|
-
(position) => {
|
|
615
|
-
last = position.stream;
|
|
616
|
-
const source_re = position.source ? get_regex(position.source) : void 0;
|
|
617
|
-
for (const [stream, info] of stream_info) {
|
|
618
|
-
if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
|
|
619
|
-
pending_set.add(stream);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
},
|
|
623
|
-
{ after, limit: page_size, source_matches: targets }
|
|
624
|
-
);
|
|
625
|
-
if (count < page_size) break;
|
|
626
|
-
after = last;
|
|
627
|
-
}
|
|
628
|
-
const safe = [];
|
|
629
|
-
for (const [stream] of stream_info) {
|
|
630
|
-
if (pending_set.has(stream)) skipped.push(stream);
|
|
631
|
-
else safe.push(stream);
|
|
632
|
-
}
|
|
633
|
-
return safe;
|
|
634
|
-
}
|
|
635
|
-
async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
|
|
636
|
-
const guarded = [];
|
|
637
|
-
const guard_events = /* @__PURE__ */ new Map();
|
|
638
|
-
await Promise.all(
|
|
639
|
-
safe.map(async (stream) => {
|
|
640
|
-
const info = stream_info.get(stream);
|
|
641
|
-
const committed = await tombstone2(stream, info.version, correlation);
|
|
642
|
-
if (committed) {
|
|
643
|
-
guarded.push(stream);
|
|
644
|
-
guard_events.set(stream, { id: committed.id, stream });
|
|
645
|
-
} else {
|
|
646
|
-
skipped.push(stream);
|
|
647
|
-
}
|
|
648
|
-
})
|
|
649
|
-
);
|
|
650
|
-
return { guarded, guard_events };
|
|
651
|
-
}
|
|
652
|
-
async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
|
|
653
|
-
const seed_states = /* @__PURE__ */ new Map();
|
|
654
|
-
await Promise.all(
|
|
655
|
-
guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
|
|
656
|
-
const last_event_name = stream_info.get(stream).last_event_name;
|
|
657
|
-
const owner_state = event_to_state.get(last_event_name);
|
|
658
|
-
if (!owner_state) {
|
|
659
|
-
logger.error(
|
|
660
|
-
`Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
|
|
661
|
-
);
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
const snap2 = await load2(owner_state, { stream });
|
|
665
|
-
seed_states.set(stream, snap2.state);
|
|
666
|
-
})
|
|
667
|
-
);
|
|
668
|
-
return seed_states;
|
|
669
|
-
}
|
|
670
|
-
async function run_archive_callbacks(guarded, target_map) {
|
|
671
|
-
for (const stream of guarded) {
|
|
672
|
-
const archive_fn = target_map.get(stream)?.archive;
|
|
673
|
-
if (archive_fn) await archive_fn();
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
|
|
677
|
-
const trunc_targets = guarded.map((stream) => {
|
|
678
|
-
const snapshot = seed_states.get(stream);
|
|
679
|
-
const guard = guard_events.get(stream);
|
|
680
|
-
return {
|
|
681
|
-
stream,
|
|
682
|
-
snapshot,
|
|
683
|
-
meta: {
|
|
684
|
-
correlation,
|
|
685
|
-
causation: {
|
|
686
|
-
event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
};
|
|
690
|
-
});
|
|
691
|
-
const truncated = await store().truncate(trunc_targets);
|
|
692
|
-
await Promise.all(
|
|
693
|
-
guarded.map(async (stream) => {
|
|
694
|
-
const entry = truncated.get(stream);
|
|
695
|
-
const state2 = seed_states.get(stream);
|
|
696
|
-
if (state2 && entry) {
|
|
697
|
-
await cache().set(stream, {
|
|
698
|
-
state: state2,
|
|
699
|
-
version: entry.committed.version,
|
|
700
|
-
event_id: entry.committed.id,
|
|
701
|
-
patches: 0,
|
|
702
|
-
snaps: 1
|
|
703
|
-
});
|
|
704
|
-
} else {
|
|
705
|
-
await cache().invalidate(stream);
|
|
706
|
-
}
|
|
707
|
-
})
|
|
708
|
-
);
|
|
709
|
-
return truncated;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// src/internal/correlator.ts
|
|
713
|
-
import { randomInt } from "crypto";
|
|
714
|
-
var BASE = 36;
|
|
715
|
-
var SEG_WIDTH = 4;
|
|
716
|
-
var SEG_SPACE = BASE ** SEG_WIDTH;
|
|
717
|
-
function seg(n) {
|
|
718
|
-
return n.toString(BASE).padStart(SEG_WIDTH, "0");
|
|
719
|
-
}
|
|
720
|
-
var default_correlator = ({ state: state2, action: action2 }) => {
|
|
721
|
-
const s = state2.slice(0, SEG_WIDTH).toLowerCase();
|
|
722
|
-
const a = action2.slice(0, SEG_WIDTH).toLowerCase();
|
|
723
|
-
const ts = seg(Date.now() % SEG_SPACE);
|
|
724
|
-
const rnd = seg(randomInt(SEG_SPACE));
|
|
725
|
-
return `${s}-${a}-${ts}${rnd}`;
|
|
726
|
-
};
|
|
727
|
-
function close_correlation(correlator, actor) {
|
|
728
|
-
return correlator({
|
|
729
|
-
state: "$close",
|
|
730
|
-
action: "close",
|
|
731
|
-
stream: "$close",
|
|
732
|
-
actor
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// src/internal/autoclose-cycle.ts
|
|
737
|
-
function yield_between_batches(ms) {
|
|
738
|
-
if (ms <= 0) return Promise.resolve();
|
|
739
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
740
|
-
}
|
|
741
|
-
async function run_autoclose_cycle(deps) {
|
|
742
|
-
const s = store();
|
|
743
|
-
let inspected = 0;
|
|
744
|
-
let evaluated = 0;
|
|
745
|
-
let predicate_errors = 0;
|
|
746
|
-
const truncated = /* @__PURE__ */ new Map();
|
|
747
|
-
const skipped = [];
|
|
748
|
-
const page = deps.config.closeBatchSize;
|
|
749
|
-
let after;
|
|
750
|
-
for (; ; ) {
|
|
751
|
-
const stats = await s.query_stats(
|
|
752
|
-
{},
|
|
753
|
-
{ count: true, exclude: [TOMBSTONE_EVENT], after, limit: page }
|
|
754
|
-
);
|
|
755
|
-
if (stats.size === 0) break;
|
|
756
|
-
inspected += stats.size;
|
|
757
|
-
const candidates = [];
|
|
758
|
-
for (const [stream, { head, count }] of stats) {
|
|
759
|
-
const owner = deps.event_to_state.get(head.name);
|
|
760
|
-
if (!owner) continue;
|
|
761
|
-
const predicate = deps.autoclose_policy(owner.name);
|
|
762
|
-
if (!predicate) continue;
|
|
763
|
-
let close = false;
|
|
764
|
-
try {
|
|
765
|
-
close = predicate(stream, head, count ?? 0);
|
|
766
|
-
evaluated += 1;
|
|
767
|
-
} catch (err) {
|
|
768
|
-
predicate_errors += 1;
|
|
769
|
-
deps.logger.error(
|
|
770
|
-
`Autoclose predicate for state "${owner.name}" threw on stream "${stream}": ${err instanceof Error ? err.message : String(err)}`
|
|
771
|
-
);
|
|
772
|
-
close = deps.config.closeOnError;
|
|
773
|
-
}
|
|
774
|
-
if (!close) continue;
|
|
775
|
-
const archiver = deps.autoclose_archiver(owner.name);
|
|
776
|
-
const archive = archiver ? () => archiver(stream, head) : void 0;
|
|
777
|
-
candidates.push({ stream, archive });
|
|
778
|
-
}
|
|
779
|
-
if (candidates.length > 0) {
|
|
780
|
-
const result = await run_close_cycle(candidates, {
|
|
781
|
-
reactive_events_size: deps.reactive_events_size,
|
|
782
|
-
event_to_state: deps.event_to_state,
|
|
783
|
-
load: deps.load,
|
|
784
|
-
tombstone: deps.tombstone,
|
|
785
|
-
logger: deps.logger,
|
|
786
|
-
correlation: deps.correlation
|
|
787
|
-
});
|
|
788
|
-
for (const [stream, entry] of result.truncated)
|
|
789
|
-
truncated.set(stream, entry);
|
|
790
|
-
for (const stream of result.skipped) skipped.push(stream);
|
|
791
|
-
await yield_between_batches(deps.config.closeYieldMs);
|
|
792
|
-
}
|
|
793
|
-
if (stats.size < page) break;
|
|
794
|
-
after = [...stats.keys()].at(-1);
|
|
795
|
-
}
|
|
796
|
-
return {
|
|
797
|
-
inspected,
|
|
798
|
-
evaluated,
|
|
799
|
-
predicate_errors,
|
|
800
|
-
close_result: { truncated, skipped }
|
|
801
|
-
};
|
|
802
|
-
}
|
|
803
|
-
var AutocloseController = class {
|
|
804
|
-
deps;
|
|
805
|
-
_timer;
|
|
806
|
-
/**
|
|
807
|
-
* Reentrancy guard. The interval fires the next tick on cadence
|
|
808
|
-
* regardless of how long the previous one took; this flag drops
|
|
809
|
-
* overlapping ticks so the cycle stays sequential per controller.
|
|
810
|
-
*/
|
|
811
|
-
_running = false;
|
|
812
|
-
constructor(deps) {
|
|
813
|
-
this.deps = deps;
|
|
814
|
-
}
|
|
815
|
-
/**
|
|
816
|
-
* Start the cycle ticker. Returns `false` if already running so
|
|
817
|
-
* accidental double-start doesn't stack timers (matches
|
|
818
|
-
* `_correlate.start_polling` semantics).
|
|
819
|
-
*/
|
|
820
|
-
start() {
|
|
821
|
-
if (this._timer) return false;
|
|
822
|
-
this._timer = setInterval(() => {
|
|
823
|
-
this.run_once().catch(() => {
|
|
824
|
-
});
|
|
825
|
-
}, this.deps.config.autocloseCycleMinutes * 6e4);
|
|
826
|
-
this._timer.unref();
|
|
827
|
-
return true;
|
|
828
|
-
}
|
|
829
|
-
/**
|
|
830
|
-
* Stop the cycle ticker. Idempotent.
|
|
831
|
-
*/
|
|
832
|
-
stop() {
|
|
833
|
-
if (this._timer) {
|
|
834
|
-
clearInterval(this._timer);
|
|
835
|
-
this._timer = void 0;
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
/**
|
|
839
|
-
* Whether the ticker is currently running.
|
|
840
|
-
*/
|
|
841
|
-
get is_running() {
|
|
842
|
-
return this._timer !== void 0;
|
|
843
|
-
}
|
|
844
|
-
/**
|
|
845
|
-
* Run one cycle synchronously. The controller's interval callback
|
|
846
|
-
* delegates here; tests can invoke it directly to deterministically
|
|
847
|
-
* exercise the cycle without spinning real timers.
|
|
848
|
-
*
|
|
849
|
-
* Overlapping invocations short-circuit: if a previous tick is
|
|
850
|
-
* still running, this one drops and returns `null`. The orchestrator
|
|
851
|
-
* never sees a queue of pending ticks regardless of how slow the
|
|
852
|
-
* predicate / archive / truncate are.
|
|
853
|
-
*/
|
|
854
|
-
async run_once() {
|
|
855
|
-
if (this._running) return null;
|
|
856
|
-
if (this.deps.breaker.state(Date.now()) === "open") return null;
|
|
857
|
-
if (!in_autoclose_window(this.deps.config.autocloseWindow, /* @__PURE__ */ new Date()))
|
|
858
|
-
return null;
|
|
859
|
-
this._running = true;
|
|
860
|
-
try {
|
|
861
|
-
const correlation = close_correlation(
|
|
862
|
-
this.deps.correlator,
|
|
863
|
-
this.deps.close_actor
|
|
864
|
-
);
|
|
865
|
-
const result = await run_autoclose_cycle({
|
|
866
|
-
autoclose_policy: this.deps.autoclose_policy,
|
|
867
|
-
autoclose_archiver: this.deps.autoclose_archiver,
|
|
868
|
-
event_to_state: this.deps.event_to_state,
|
|
869
|
-
reactive_events_size: this.deps.reactive_events_size,
|
|
870
|
-
load: this.deps.load,
|
|
871
|
-
tombstone: this.deps.tombstone,
|
|
872
|
-
logger: this.deps.logger,
|
|
873
|
-
config: this.deps.config,
|
|
874
|
-
correlation
|
|
875
|
-
});
|
|
876
|
-
this.deps.breaker.passed();
|
|
877
|
-
if (result.close_result.truncated.size > 0) {
|
|
878
|
-
this.deps.on_closed(result.close_result);
|
|
879
|
-
}
|
|
880
|
-
return result;
|
|
881
|
-
} catch (error) {
|
|
882
|
-
this.deps.breaker.failed(Date.now(), error);
|
|
883
|
-
throw error;
|
|
884
|
-
} finally {
|
|
885
|
-
this._running = false;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
};
|
|
889
|
-
|
|
890
543
|
// src/internal/autoclose-policy.ts
|
|
891
544
|
import { z as z2 } from "zod";
|
|
892
545
|
var MIN_AFTER_MS = 6e4;
|
|
@@ -934,6 +587,13 @@ function compile_is(is) {
|
|
|
934
587
|
function compile_reaches(reaches) {
|
|
935
588
|
return (_stream, _head, count) => count >= reaches;
|
|
936
589
|
}
|
|
590
|
+
function policy_min_after_ms(options) {
|
|
591
|
+
const parsed = AutoclosePolicySchema.parse(options);
|
|
592
|
+
const windows = [];
|
|
593
|
+
if (parsed.after) windows.push(parsed.after.days * 864e5);
|
|
594
|
+
if (parsed.or?.after) windows.push(parsed.or.after.days * 864e5);
|
|
595
|
+
return windows.length ? Math.min(...windows) : void 0;
|
|
596
|
+
}
|
|
937
597
|
function compile_autoclose_policy(options) {
|
|
938
598
|
const parsed = AutoclosePolicySchema.parse(options);
|
|
939
599
|
const and_preds = [];
|
|
@@ -995,28 +655,273 @@ function classify_registry(registry, states) {
|
|
|
995
655
|
}
|
|
996
656
|
}
|
|
997
657
|
}
|
|
998
|
-
const event_to_state = /* @__PURE__ */ new Map();
|
|
999
|
-
for (const merged of states.values()) {
|
|
1000
|
-
for (const event_name of Object.keys(merged.events)) {
|
|
1001
|
-
event_to_state.set(event_name, merged);
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
658
|
+
const event_to_state = /* @__PURE__ */ new Map();
|
|
659
|
+
for (const merged of states.values()) {
|
|
660
|
+
for (const event_name of Object.keys(merged.events)) {
|
|
661
|
+
event_to_state.set(event_name, merged);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
static_targets: [...statics.values()],
|
|
666
|
+
has_dynamic_resolvers,
|
|
667
|
+
reactive_events,
|
|
668
|
+
event_to_state,
|
|
669
|
+
event_to_lanes
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/internal/defer-config.ts
|
|
674
|
+
import { z as z3 } from "zod";
|
|
675
|
+
|
|
676
|
+
// src/internal/defer-signal.ts
|
|
677
|
+
var DeferSignal = class extends Error {
|
|
678
|
+
/**
|
|
679
|
+
* The unresolved schedule. The dispatcher turns this into an absolute
|
|
680
|
+
* due-time by resolving it against the triggering event.
|
|
681
|
+
*/
|
|
682
|
+
when;
|
|
683
|
+
constructor(when) {
|
|
684
|
+
super("reaction deferred");
|
|
685
|
+
this.name = "DeferSignal";
|
|
686
|
+
this.when = when;
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// src/internal/defer-config.ts
|
|
691
|
+
var DeferDurationSchema = z3.object({
|
|
692
|
+
days: z3.number().positive().optional(),
|
|
693
|
+
hours: z3.number().positive().optional(),
|
|
694
|
+
minutes: z3.number().positive().optional()
|
|
695
|
+
}).strict().refine((d) => d.days != null || d.hours != null || d.minutes != null, {
|
|
696
|
+
message: "defer: a duration needs at least one of days, hours, or minutes"
|
|
697
|
+
});
|
|
698
|
+
var DeferWhenSchema = z3.object({
|
|
699
|
+
after: DeferDurationSchema.optional(),
|
|
700
|
+
at: z3.date().optional()
|
|
701
|
+
}).strict().refine((w) => w.after === void 0 !== (w.at === void 0), {
|
|
702
|
+
message: "defer: specify exactly one of `after` or `at`"
|
|
703
|
+
});
|
|
704
|
+
function duration_ms(d) {
|
|
705
|
+
return (d.days ?? 0) * 864e5 + (d.hours ?? 0) * 36e5 + (d.minutes ?? 0) * 6e4;
|
|
706
|
+
}
|
|
707
|
+
function resolve_defer_at(when, event) {
|
|
708
|
+
const parsed = DeferWhenSchema.parse(when);
|
|
709
|
+
if (parsed.after) return event.created.getTime() + duration_ms(parsed.after);
|
|
710
|
+
return parsed.at.getTime();
|
|
711
|
+
}
|
|
712
|
+
function assert_defer_when(when) {
|
|
713
|
+
DeferWhenSchema.parse(when);
|
|
714
|
+
}
|
|
715
|
+
function make_deferred(handler, schedule) {
|
|
716
|
+
const call = handler;
|
|
717
|
+
const deferred = async (event, stream, app) => {
|
|
718
|
+
const when = typeof schedule === "function" ? schedule(event) : schedule;
|
|
719
|
+
if (Date.now() < resolve_defer_at(when, event)) throw new DeferSignal(when);
|
|
720
|
+
return call(event, stream, app);
|
|
721
|
+
};
|
|
722
|
+
Object.defineProperty(deferred, "name", { value: handler.name });
|
|
723
|
+
return deferred;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/internal/merge.ts
|
|
727
|
+
import { ZodObject } from "zod";
|
|
728
|
+
function base_type_name(zodType) {
|
|
729
|
+
let t = zodType;
|
|
730
|
+
while (typeof t.unwrap === "function") {
|
|
731
|
+
t = t.unwrap();
|
|
732
|
+
}
|
|
733
|
+
return t.constructor.name;
|
|
734
|
+
}
|
|
735
|
+
function merge_schemas(existing, incoming, state_name) {
|
|
736
|
+
if (existing instanceof ZodObject && incoming instanceof ZodObject) {
|
|
737
|
+
const existing_shape = existing.shape;
|
|
738
|
+
const incoming_shape = incoming.shape;
|
|
739
|
+
for (const key of Object.keys(incoming_shape)) {
|
|
740
|
+
if (key in existing_shape) {
|
|
741
|
+
const existing_base = base_type_name(existing_shape[key]);
|
|
742
|
+
const incoming_base = base_type_name(incoming_shape[key]);
|
|
743
|
+
if (existing_base !== incoming_base) {
|
|
744
|
+
throw new Error(
|
|
745
|
+
`Schema conflict in "${state_name}": key "${key}" has type "${existing_base}" but incoming partial declares "${incoming_base}"`
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return existing.extend(incoming_shape);
|
|
751
|
+
}
|
|
752
|
+
return existing;
|
|
753
|
+
}
|
|
754
|
+
function merge_inits(existing, incoming) {
|
|
755
|
+
return () => ({ ...existing(), ...incoming() });
|
|
756
|
+
}
|
|
757
|
+
function register_state(state2, states, actions, events) {
|
|
758
|
+
const existing = states.get(state2.name);
|
|
759
|
+
if (existing) {
|
|
760
|
+
merge_into_existing(state2, existing, states, actions, events);
|
|
761
|
+
} else {
|
|
762
|
+
register_new_state(state2, states, actions, events);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function register_new_state(state2, states, actions, events) {
|
|
766
|
+
states.set(state2.name, state2);
|
|
767
|
+
for (const name of Object.keys(state2.actions)) {
|
|
768
|
+
if (actions[name]) throw new Error(`Duplicate action "${name}"`);
|
|
769
|
+
actions[name] = state2;
|
|
770
|
+
}
|
|
771
|
+
for (const name of Object.keys(state2.events)) {
|
|
772
|
+
if (events[name]) throw new Error(`Duplicate event "${name}"`);
|
|
773
|
+
events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function merge_into_existing(state2, existing, states, actions, events) {
|
|
777
|
+
for (const name of Object.keys(state2.actions)) {
|
|
778
|
+
if (existing.actions[name] === state2.actions[name]) continue;
|
|
779
|
+
if (actions[name]) throw new Error(`Duplicate action "${name}"`);
|
|
780
|
+
}
|
|
781
|
+
for (const name of Object.keys(state2.events)) {
|
|
782
|
+
if (existing.events[name] === state2.events[name]) continue;
|
|
783
|
+
if (existing.events[name]) {
|
|
784
|
+
throw new Error(
|
|
785
|
+
`Event "${name}" in state "${state2.name}" is declared with different Zod schemas across slices. Cross-slice event schemas must reference the same instance \u2014 extract a shared schema (e.g. \`export const ${name} = z.object({ ... })\` in a shared module) and import it in every slice that declares it.`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
if (events[name]) throw new Error(`Duplicate event "${name}"`);
|
|
789
|
+
}
|
|
790
|
+
const merged_patch = merge_patches(existing.patch, state2.patch, state2.name);
|
|
791
|
+
const merged = {
|
|
792
|
+
...existing,
|
|
793
|
+
state: merge_schemas(existing.state, state2.state, state2.name),
|
|
794
|
+
init: merge_inits(existing.init, state2.init),
|
|
795
|
+
events: { ...existing.events, ...state2.events },
|
|
796
|
+
actions: { ...existing.actions, ...state2.actions },
|
|
797
|
+
patch: merged_patch,
|
|
798
|
+
on: { ...existing.on, ...state2.on },
|
|
799
|
+
given: { ...existing.given, ...state2.given },
|
|
800
|
+
snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
|
|
801
|
+
throw new Error(
|
|
802
|
+
`Duplicate snap strategy for state "${state2.name}"`
|
|
803
|
+
);
|
|
804
|
+
})() : state2.snap || existing.snap
|
|
805
|
+
};
|
|
806
|
+
states.set(state2.name, merged);
|
|
807
|
+
for (const name of Object.keys(merged.actions)) {
|
|
808
|
+
actions[name] = merged;
|
|
809
|
+
}
|
|
810
|
+
for (const name of Object.keys(state2.events)) {
|
|
811
|
+
if (events[name]) continue;
|
|
812
|
+
events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function merge_patches(existing, incoming, state_name) {
|
|
816
|
+
const merged = { ...existing };
|
|
817
|
+
for (const name of Object.keys(incoming)) {
|
|
818
|
+
const existing_p = existing[name];
|
|
819
|
+
const incoming_p = incoming[name];
|
|
820
|
+
if (!existing_p) {
|
|
821
|
+
merged[name] = incoming_p;
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
const existing_is_default = existing_p._passthrough;
|
|
825
|
+
const incoming_is_default = incoming_p._passthrough;
|
|
826
|
+
if (!existing_is_default && !incoming_is_default && existing_p !== incoming_p) {
|
|
827
|
+
throw new Error(
|
|
828
|
+
`Duplicate custom patch for event "${name}" in state "${state_name}"`
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
if (existing_is_default && !incoming_is_default) {
|
|
832
|
+
merged[name] = incoming_p;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return merged;
|
|
836
|
+
}
|
|
837
|
+
function merge_event_register(target, source) {
|
|
838
|
+
for (const [event_name, source_reg] of Object.entries(source)) {
|
|
839
|
+
const target_reg = target[event_name];
|
|
840
|
+
if (!target_reg) continue;
|
|
841
|
+
for (const [name, reaction] of source_reg.reactions) {
|
|
842
|
+
const existing = target_reg.reactions.get(name);
|
|
843
|
+
if (existing !== void 0 && existing !== reaction)
|
|
844
|
+
throw new Error(
|
|
845
|
+
`Duplicate reaction "${name}" for event "${event_name}". Reaction handlers are keyed by function name; rename one of them.`
|
|
846
|
+
);
|
|
847
|
+
target_reg.reactions.set(name, reaction);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
function merge_projection(proj, events) {
|
|
852
|
+
for (const event_name of Object.keys(proj.events)) {
|
|
853
|
+
const proj_register = proj.events[event_name];
|
|
854
|
+
const existing = events[event_name];
|
|
855
|
+
if (!existing) {
|
|
856
|
+
events[event_name] = {
|
|
857
|
+
schema: proj_register.schema,
|
|
858
|
+
reactions: new Map(proj_register.reactions)
|
|
859
|
+
};
|
|
860
|
+
} else {
|
|
861
|
+
for (const [name, reaction] of proj_register.reactions) {
|
|
862
|
+
let key = name;
|
|
863
|
+
while (existing.reactions.has(key)) key = `${key}_p`;
|
|
864
|
+
existing.reactions.set(key, reaction);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
var _this_ = ({ stream }) => ({
|
|
870
|
+
source: stream,
|
|
871
|
+
target: stream
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
// src/internal/builder-utils.ts
|
|
875
|
+
function register_lane(config2, lanes) {
|
|
876
|
+
if (config2.name === DEFAULT_LANE)
|
|
877
|
+
throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
|
|
878
|
+
if (lanes.some((l) => l.name === config2.name))
|
|
879
|
+
throw new Error(`Lane "${config2.name}" was already declared`);
|
|
880
|
+
lanes.push(config2);
|
|
881
|
+
}
|
|
882
|
+
function reaction_on(event, events, builder) {
|
|
883
|
+
const register = (handler, options, schedule) => {
|
|
884
|
+
if (!handler.name)
|
|
885
|
+
throw new Error(
|
|
886
|
+
`Reaction handler for "${String(event)}" must be a named function`
|
|
887
|
+
);
|
|
888
|
+
if (events[event].reactions.has(handler.name))
|
|
889
|
+
throw new Error(
|
|
890
|
+
`Duplicate reaction "${handler.name}" for event "${String(event)}". Reaction handlers are keyed by function name; rename one of them.`
|
|
891
|
+
);
|
|
892
|
+
if (schedule && typeof schedule !== "function") assert_defer_when(schedule);
|
|
893
|
+
const reaction = {
|
|
894
|
+
handler: schedule ? make_deferred(handler, schedule) : handler,
|
|
895
|
+
resolver: _this_,
|
|
896
|
+
options: {
|
|
897
|
+
blockOnError: options?.blockOnError ?? true,
|
|
898
|
+
maxRetries: options?.maxRetries ?? 3,
|
|
899
|
+
backoff: options?.backoff
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
events[event].reactions.set(handler.name, reaction);
|
|
903
|
+
return Object.assign(builder, {
|
|
904
|
+
to(resolver) {
|
|
905
|
+
reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
|
|
906
|
+
return builder;
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
};
|
|
1004
910
|
return {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
event_to_lanes
|
|
911
|
+
do: (handler, options) => register(handler, options),
|
|
912
|
+
defer: (schedule) => ({
|
|
913
|
+
do: (handler, options) => register(handler, options, schedule)
|
|
914
|
+
})
|
|
1010
915
|
};
|
|
1011
916
|
}
|
|
1012
917
|
|
|
1013
918
|
// src/internal/circuit-breaker.ts
|
|
1014
|
-
import { z as
|
|
919
|
+
import { z as z4 } from "zod";
|
|
1015
920
|
var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
|
|
1016
921
|
var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
|
|
1017
|
-
var CircuitBreakerOptionsSchema =
|
|
1018
|
-
failureThreshold:
|
|
1019
|
-
cooldownMs:
|
|
922
|
+
var CircuitBreakerOptionsSchema = z4.object({
|
|
923
|
+
failureThreshold: z4.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
|
|
924
|
+
cooldownMs: z4.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
|
|
1020
925
|
});
|
|
1021
926
|
var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
|
|
1022
927
|
var CircuitBreaker = class {
|
|
@@ -1097,6 +1002,203 @@ var CircuitBreaker = class {
|
|
|
1097
1002
|
}
|
|
1098
1003
|
};
|
|
1099
1004
|
|
|
1005
|
+
// src/internal/close-cycle.ts
|
|
1006
|
+
var SAFETY_PROBE_PAGE_SIZE = 1e3;
|
|
1007
|
+
async function run_close_cycle(targets, deps) {
|
|
1008
|
+
const target_map = new Map(targets.map((t) => [t.stream, t]));
|
|
1009
|
+
const streams = [...target_map.keys()];
|
|
1010
|
+
const skipped = [];
|
|
1011
|
+
const stream_info = await scan_stream_heads(streams);
|
|
1012
|
+
const safe = await partition_by_safety(
|
|
1013
|
+
stream_info,
|
|
1014
|
+
deps.reactive_events_size,
|
|
1015
|
+
skipped,
|
|
1016
|
+
deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
|
|
1017
|
+
);
|
|
1018
|
+
if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
|
|
1019
|
+
const { guarded, guard_events } = await guard_with_tombstones(
|
|
1020
|
+
safe,
|
|
1021
|
+
stream_info,
|
|
1022
|
+
deps.correlation,
|
|
1023
|
+
deps.tombstone,
|
|
1024
|
+
skipped
|
|
1025
|
+
);
|
|
1026
|
+
if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
|
|
1027
|
+
const seed_states = await load_restart_seeds(
|
|
1028
|
+
guarded,
|
|
1029
|
+
target_map,
|
|
1030
|
+
stream_info,
|
|
1031
|
+
deps.event_to_state,
|
|
1032
|
+
deps.load,
|
|
1033
|
+
deps.logger
|
|
1034
|
+
);
|
|
1035
|
+
await run_archive_callbacks(guarded, target_map);
|
|
1036
|
+
const truncated = await truncate_and_warm_cache(
|
|
1037
|
+
guarded,
|
|
1038
|
+
seed_states,
|
|
1039
|
+
guard_events,
|
|
1040
|
+
deps.correlation
|
|
1041
|
+
);
|
|
1042
|
+
return { truncated, skipped };
|
|
1043
|
+
}
|
|
1044
|
+
async function scan_stream_heads(streams) {
|
|
1045
|
+
const stats = await store().query_stats(streams, {
|
|
1046
|
+
exclude: [SNAP_EVENT]
|
|
1047
|
+
});
|
|
1048
|
+
const out = /* @__PURE__ */ new Map();
|
|
1049
|
+
for (const [stream, { head }] of stats) {
|
|
1050
|
+
if (head.name === TOMBSTONE_EVENT) continue;
|
|
1051
|
+
out.set(stream, {
|
|
1052
|
+
max_id: head.id,
|
|
1053
|
+
version: head.version,
|
|
1054
|
+
last_event_name: head.name
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
return out;
|
|
1058
|
+
}
|
|
1059
|
+
async function partition_by_safety(stream_info, reactive_events_size, skipped, page_size) {
|
|
1060
|
+
if (reactive_events_size === 0) return [...stream_info.keys()];
|
|
1061
|
+
const pending_set = /* @__PURE__ */ new Set();
|
|
1062
|
+
const source_regex = /* @__PURE__ */ new Map();
|
|
1063
|
+
const get_regex = (source) => {
|
|
1064
|
+
let re = source_regex.get(source);
|
|
1065
|
+
if (!re) {
|
|
1066
|
+
re = new RegExp(source);
|
|
1067
|
+
source_regex.set(source, re);
|
|
1068
|
+
}
|
|
1069
|
+
return re;
|
|
1070
|
+
};
|
|
1071
|
+
const targets = [...stream_info.keys()];
|
|
1072
|
+
let after;
|
|
1073
|
+
for (; ; ) {
|
|
1074
|
+
let last;
|
|
1075
|
+
const { count } = await store().query_streams(
|
|
1076
|
+
(position) => {
|
|
1077
|
+
last = position.stream;
|
|
1078
|
+
const source_re = position.source ? get_regex(position.source) : void 0;
|
|
1079
|
+
for (const [stream, info] of stream_info) {
|
|
1080
|
+
if ((!source_re || source_re.test(stream)) && position.at < info.max_id) {
|
|
1081
|
+
pending_set.add(stream);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
},
|
|
1085
|
+
{ after, limit: page_size, source_matches: targets }
|
|
1086
|
+
);
|
|
1087
|
+
if (count < page_size) break;
|
|
1088
|
+
after = last;
|
|
1089
|
+
}
|
|
1090
|
+
const safe = [];
|
|
1091
|
+
for (const [stream] of stream_info) {
|
|
1092
|
+
if (pending_set.has(stream)) skipped.push(stream);
|
|
1093
|
+
else safe.push(stream);
|
|
1094
|
+
}
|
|
1095
|
+
return safe;
|
|
1096
|
+
}
|
|
1097
|
+
async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
|
|
1098
|
+
const guarded = [];
|
|
1099
|
+
const guard_events = /* @__PURE__ */ new Map();
|
|
1100
|
+
await Promise.all(
|
|
1101
|
+
safe.map(async (stream) => {
|
|
1102
|
+
const info = stream_info.get(stream);
|
|
1103
|
+
const committed = await tombstone2(stream, info.version, correlation);
|
|
1104
|
+
if (committed) {
|
|
1105
|
+
guarded.push(stream);
|
|
1106
|
+
guard_events.set(stream, { id: committed.id, stream });
|
|
1107
|
+
} else {
|
|
1108
|
+
skipped.push(stream);
|
|
1109
|
+
}
|
|
1110
|
+
})
|
|
1111
|
+
);
|
|
1112
|
+
return { guarded, guard_events };
|
|
1113
|
+
}
|
|
1114
|
+
async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
|
|
1115
|
+
const seed_states = /* @__PURE__ */ new Map();
|
|
1116
|
+
await Promise.all(
|
|
1117
|
+
guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
|
|
1118
|
+
const last_event_name = stream_info.get(stream).last_event_name;
|
|
1119
|
+
const owner_state = event_to_state.get(last_event_name);
|
|
1120
|
+
if (!owner_state) {
|
|
1121
|
+
logger.error(
|
|
1122
|
+
`Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
|
|
1123
|
+
);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const snap2 = await load2(owner_state, { stream });
|
|
1127
|
+
seed_states.set(stream, snap2.state);
|
|
1128
|
+
})
|
|
1129
|
+
);
|
|
1130
|
+
return seed_states;
|
|
1131
|
+
}
|
|
1132
|
+
async function run_archive_callbacks(guarded, target_map) {
|
|
1133
|
+
for (const stream of guarded) {
|
|
1134
|
+
const archive_fn = target_map.get(stream)?.archive;
|
|
1135
|
+
if (archive_fn) await archive_fn();
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
|
|
1139
|
+
const trunc_targets = guarded.map((stream) => {
|
|
1140
|
+
const snapshot = seed_states.get(stream);
|
|
1141
|
+
const guard = guard_events.get(stream);
|
|
1142
|
+
return {
|
|
1143
|
+
stream,
|
|
1144
|
+
snapshot,
|
|
1145
|
+
meta: {
|
|
1146
|
+
correlation,
|
|
1147
|
+
causation: {
|
|
1148
|
+
event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
});
|
|
1153
|
+
const truncated = await store().truncate(trunc_targets);
|
|
1154
|
+
await Promise.all(
|
|
1155
|
+
guarded.map(async (stream) => {
|
|
1156
|
+
const entry = truncated.get(stream);
|
|
1157
|
+
const state2 = seed_states.get(stream);
|
|
1158
|
+
if (state2 && entry) {
|
|
1159
|
+
await cache().set(stream, {
|
|
1160
|
+
state: state2,
|
|
1161
|
+
version: entry.committed.version,
|
|
1162
|
+
event_id: entry.committed.id,
|
|
1163
|
+
patches: 0,
|
|
1164
|
+
snaps: 1
|
|
1165
|
+
});
|
|
1166
|
+
} else {
|
|
1167
|
+
await cache().invalidate(stream);
|
|
1168
|
+
}
|
|
1169
|
+
})
|
|
1170
|
+
);
|
|
1171
|
+
return truncated;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// src/internal/close-signal.ts
|
|
1175
|
+
var CloseSignal = class extends Error {
|
|
1176
|
+
/**
|
|
1177
|
+
* Stream to close. Omitted → the reaction's own lease stream (a self-close).
|
|
1178
|
+
* The synthesized autoclose reaction sets it to the aggregate stream, since
|
|
1179
|
+
* its lease runs on a synthetic `__autoclose__` target.
|
|
1180
|
+
*/
|
|
1181
|
+
stream;
|
|
1182
|
+
/** Optional archive callback to run while the stream is guarded. */
|
|
1183
|
+
archive;
|
|
1184
|
+
/**
|
|
1185
|
+
* Watermark to ack the requesting reaction to before the close runs. The
|
|
1186
|
+
* close-cycle safety guard skips a stream whose subscriptions (matched by
|
|
1187
|
+
* source) lag the head; the autoclose reaction's `source` is the aggregate,
|
|
1188
|
+
* so it must advance its own watermark to the live head id it evaluated
|
|
1189
|
+
* against — otherwise it blocks its own close. Defaults to the triggering
|
|
1190
|
+
* event id when omitted.
|
|
1191
|
+
*/
|
|
1192
|
+
at;
|
|
1193
|
+
constructor(opts) {
|
|
1194
|
+
super("reaction requested close");
|
|
1195
|
+
this.name = "CloseSignal";
|
|
1196
|
+
this.stream = opts?.stream;
|
|
1197
|
+
this.archive = opts?.archive;
|
|
1198
|
+
this.at = opts?.at;
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1100
1202
|
// src/internal/correlate-cycle.ts
|
|
1101
1203
|
var CorrelateCycle = class {
|
|
1102
1204
|
_checkpoint = -1;
|
|
@@ -1201,33 +1303,135 @@ var CorrelateCycle = class {
|
|
|
1201
1303
|
return { subscribed: 0, last_id };
|
|
1202
1304
|
}
|
|
1203
1305
|
/**
|
|
1204
|
-
* Start a periodic correlation worker. Returns false if one is already
|
|
1205
|
-
* running. Errors from `correlate()` are routed through `log()` so they
|
|
1206
|
-
* land in the configured logger (the timer keeps running on failure).
|
|
1306
|
+
* Start a periodic correlation worker. Returns false if one is already
|
|
1307
|
+
* running. Errors from `correlate()` are routed through `log()` so they
|
|
1308
|
+
* land in the configured logger (the timer keeps running on failure).
|
|
1309
|
+
*/
|
|
1310
|
+
start_polling(query = {}, frequency = 1e4, callback) {
|
|
1311
|
+
if (this._timer) return false;
|
|
1312
|
+
const limit = query.limit || 100;
|
|
1313
|
+
this._timer = setInterval(
|
|
1314
|
+
() => this.correlate({ ...query, after: this._checkpoint, limit }).then((result) => {
|
|
1315
|
+
if (callback && result.subscribed) callback(result.subscribed);
|
|
1316
|
+
}).catch((err) => log().error(err)),
|
|
1317
|
+
frequency
|
|
1318
|
+
);
|
|
1319
|
+
return true;
|
|
1320
|
+
}
|
|
1321
|
+
/** Stop the periodic correlation worker. Idempotent. */
|
|
1322
|
+
stop_polling() {
|
|
1323
|
+
if (this._timer) {
|
|
1324
|
+
clearInterval(this._timer);
|
|
1325
|
+
this._timer = void 0;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
// src/internal/correlator.ts
|
|
1331
|
+
import { randomInt } from "crypto";
|
|
1332
|
+
var BASE = 36;
|
|
1333
|
+
var SEG_WIDTH = 4;
|
|
1334
|
+
var SEG_SPACE = BASE ** SEG_WIDTH;
|
|
1335
|
+
function seg(n) {
|
|
1336
|
+
return n.toString(BASE).padStart(SEG_WIDTH, "0");
|
|
1337
|
+
}
|
|
1338
|
+
var default_correlator = ({ state: state2, action: action2 }) => {
|
|
1339
|
+
const s = state2.slice(0, SEG_WIDTH).toLowerCase();
|
|
1340
|
+
const a = action2.slice(0, SEG_WIDTH).toLowerCase();
|
|
1341
|
+
const ts = seg(Date.now() % SEG_SPACE);
|
|
1342
|
+
const rnd = seg(randomInt(SEG_SPACE));
|
|
1343
|
+
return `${s}-${a}-${ts}${rnd}`;
|
|
1344
|
+
};
|
|
1345
|
+
function close_correlation(correlator, actor) {
|
|
1346
|
+
return correlator({
|
|
1347
|
+
state: "$close",
|
|
1348
|
+
action: "close",
|
|
1349
|
+
stream: "$close",
|
|
1350
|
+
actor
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// src/internal/drain-cycle.ts
|
|
1355
|
+
import { randomUUID } from "crypto";
|
|
1356
|
+
|
|
1357
|
+
// src/internal/defer-timer.ts
|
|
1358
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
1359
|
+
var DeferTimer = class {
|
|
1360
|
+
_due = /* @__PURE__ */ new Map();
|
|
1361
|
+
_timer;
|
|
1362
|
+
_on_wake;
|
|
1363
|
+
/**
|
|
1364
|
+
* @param on_wake - invoked once each time the earliest due-time elapses,
|
|
1365
|
+
* after the come-due entries have been removed. Consumers use it to
|
|
1366
|
+
* re-arm their loop (the drain sets its `armed` flag; autoclose runs a
|
|
1367
|
+
* tick).
|
|
1368
|
+
*/
|
|
1369
|
+
constructor(on_wake) {
|
|
1370
|
+
this._on_wake = on_wake;
|
|
1371
|
+
}
|
|
1372
|
+
/** Number of currently parked streams. */
|
|
1373
|
+
get size() {
|
|
1374
|
+
return this._due.size;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* True while `stream` is parked with a due-time still in the future.
|
|
1378
|
+
* Consumers skip work for deferred streams until their window elapses.
|
|
1207
1379
|
*/
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1380
|
+
is_deferred = (stream) => {
|
|
1381
|
+
const next = this._due.get(stream);
|
|
1382
|
+
return next !== void 0 && next > Date.now();
|
|
1383
|
+
};
|
|
1384
|
+
/**
|
|
1385
|
+
* Park `stream` for a re-visit at `at` (ms since epoch). A plain
|
|
1386
|
+
* overwrite: the caller computes the authoritative next-visit for the
|
|
1387
|
+
* stream (the drain's `handle` already reconciles a stream's reactions
|
|
1388
|
+
* into one result per cycle), so there is no stale value to merge against.
|
|
1389
|
+
*/
|
|
1390
|
+
set(stream, at) {
|
|
1391
|
+
this._due.set(stream, at);
|
|
1392
|
+
}
|
|
1393
|
+
/** Drop `stream` from the parked set (e.g. on a successful ack or block). */
|
|
1394
|
+
delete(stream) {
|
|
1395
|
+
this._due.delete(stream);
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* (Re)schedule the wake timer at the earliest pending due-time. Idempotent
|
|
1399
|
+
* — collapses many parked streams into a single timer. A no-op clears any
|
|
1400
|
+
* pending timer when the map is empty.
|
|
1401
|
+
*
|
|
1402
|
+
* The timer is `unref()`-ed so pending re-visits never keep the process
|
|
1403
|
+
* alive on their own.
|
|
1404
|
+
*/
|
|
1405
|
+
schedule() {
|
|
1406
|
+
if (this._timer) clearTimeout(this._timer);
|
|
1407
|
+
if (this._due.size === 0) {
|
|
1408
|
+
this._timer = void 0;
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
1412
|
+
for (const t of this._due.values()) if (t < earliest) earliest = t;
|
|
1413
|
+
const delay = Math.min(
|
|
1414
|
+
Math.max(0, earliest - Date.now()),
|
|
1415
|
+
MAX_TIMER_DELAY_MS
|
|
1216
1416
|
);
|
|
1217
|
-
|
|
1417
|
+
this._timer = setTimeout(() => {
|
|
1418
|
+
this._timer = void 0;
|
|
1419
|
+
const now = Date.now();
|
|
1420
|
+
for (const [stream, at] of this._due)
|
|
1421
|
+
if (at <= now) this._due.delete(stream);
|
|
1422
|
+
this._on_wake();
|
|
1423
|
+
}, delay);
|
|
1424
|
+
this._timer.unref();
|
|
1218
1425
|
}
|
|
1219
|
-
/**
|
|
1220
|
-
|
|
1426
|
+
/** Cancel any pending wake timer. Idempotent. Leaves the parked set intact. */
|
|
1427
|
+
stop() {
|
|
1221
1428
|
if (this._timer) {
|
|
1222
|
-
|
|
1429
|
+
clearTimeout(this._timer);
|
|
1223
1430
|
this._timer = void 0;
|
|
1224
1431
|
}
|
|
1225
1432
|
}
|
|
1226
1433
|
};
|
|
1227
1434
|
|
|
1228
|
-
// src/internal/drain-cycle.ts
|
|
1229
|
-
import { randomUUID } from "crypto";
|
|
1230
|
-
|
|
1231
1435
|
// src/internal/drain-ratio.ts
|
|
1232
1436
|
var RATIO_MIN = 0.2;
|
|
1233
1437
|
var RATIO_MAX = 0.8;
|
|
@@ -1263,6 +1467,7 @@ async function fetch(leased, eventLimit) {
|
|
|
1263
1467
|
}
|
|
1264
1468
|
var ack = (leases) => store().ack(leases);
|
|
1265
1469
|
var block = (leases) => store().block(leases);
|
|
1470
|
+
var defer = (input, deferred_at) => store().defer(input, deferred_at);
|
|
1266
1471
|
var subscribe = (streams) => store().subscribe(streams);
|
|
1267
1472
|
|
|
1268
1473
|
// src/internal/event-sourcing.ts
|
|
@@ -1740,6 +1945,7 @@ function build_drain(logger) {
|
|
|
1740
1945
|
fetch,
|
|
1741
1946
|
ack,
|
|
1742
1947
|
block,
|
|
1948
|
+
defer,
|
|
1743
1949
|
subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
|
|
1744
1950
|
if (!result.subscribed) return;
|
|
1745
1951
|
const lanes = new Set(streams.map((s) => s.lane ?? "default"));
|
|
@@ -1808,7 +2014,8 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
|
|
|
1808
2014
|
fetched: [],
|
|
1809
2015
|
handled: [],
|
|
1810
2016
|
acked: [],
|
|
1811
|
-
blocked: []
|
|
2017
|
+
blocked: [],
|
|
2018
|
+
closeable: []
|
|
1812
2019
|
};
|
|
1813
2020
|
}
|
|
1814
2021
|
const fetched = await ops.fetch(active, eventLimit);
|
|
@@ -1842,12 +2049,23 @@ async function run_drain_cycle(ops, registry, batch_handlers, handle, handle_bat
|
|
|
1842
2049
|
})
|
|
1843
2050
|
);
|
|
1844
2051
|
const acked = await ops.ack(
|
|
1845
|
-
handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
|
|
2052
|
+
handled.filter((h) => h.defer === void 0 && (h.handled > 0 || !h.error)).map((h) => ({ ...h.lease, at: h.acked_at }))
|
|
1846
2053
|
);
|
|
1847
2054
|
const blocked = await ops.block(
|
|
1848
2055
|
handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
|
|
1849
2056
|
);
|
|
1850
|
-
|
|
2057
|
+
const deferrals = handled.filter((h) => h.defer !== void 0);
|
|
2058
|
+
if (deferrals.length) {
|
|
2059
|
+
const by_time = /* @__PURE__ */ new Map();
|
|
2060
|
+
for (const h of deferrals) {
|
|
2061
|
+
const list = by_time.get(h.defer);
|
|
2062
|
+
if (list) list.push(h.lease.stream);
|
|
2063
|
+
else by_time.set(h.defer, [h.lease.stream]);
|
|
2064
|
+
}
|
|
2065
|
+
for (const [at, streams] of by_time) await ops.defer(streams, at);
|
|
2066
|
+
}
|
|
2067
|
+
const closeable = handled.filter((h) => h.close !== void 0).map((h) => h.close);
|
|
2068
|
+
return { leased, fetched, handled, acked, blocked, closeable };
|
|
1851
2069
|
}
|
|
1852
2070
|
var EMPTY_DRAIN = {
|
|
1853
2071
|
fetched: [],
|
|
@@ -1860,14 +2078,16 @@ var DrainController = class {
|
|
|
1860
2078
|
_locked = false;
|
|
1861
2079
|
_ratio = 0.5;
|
|
1862
2080
|
/**
|
|
1863
|
-
* Per-stream
|
|
1864
|
-
*
|
|
1865
|
-
* ack or terminal block. Lives in
|
|
1866
|
-
* by design (see {@link BackoffOptions}
|
|
2081
|
+
* Per-stream re-visit schedule (#1090): `stream → next visit` (ms since
|
|
2082
|
+
* epoch). Holds both retry backoff (`HandleResult.next_attempt_at`) and the
|
|
2083
|
+
* `defer` outcome; cleared on successful ack or terminal block. Lives in
|
|
2084
|
+
* process memory — per-worker pacing by design (see {@link BackoffOptions}
|
|
2085
|
+
* for the multi-worker trade-off). Its wake re-arms drain at the earliest
|
|
2086
|
+
* pending visit.
|
|
1867
2087
|
*/
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
2088
|
+
_defer = new DeferTimer(() => {
|
|
2089
|
+
this._armed = true;
|
|
2090
|
+
});
|
|
1871
2091
|
/** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
|
|
1872
2092
|
_worker;
|
|
1873
2093
|
_stopped = false;
|
|
@@ -1887,32 +2107,6 @@ var DrainController = class {
|
|
|
1887
2107
|
get armed() {
|
|
1888
2108
|
return this._armed;
|
|
1889
2109
|
}
|
|
1890
|
-
/** Returns true when `stream` is currently within a backoff window. */
|
|
1891
|
-
is_deferred = (stream) => {
|
|
1892
|
-
const next = this._backoff.get(stream);
|
|
1893
|
-
return next !== void 0 && next > Date.now();
|
|
1894
|
-
};
|
|
1895
|
-
/**
|
|
1896
|
-
* Schedule the next drain re-arm at the earliest pending backoff
|
|
1897
|
-
* expiry. Called only when the backoff map is non-empty (caller guard).
|
|
1898
|
-
* Idempotent — collapses many simultaneously deferred streams into a
|
|
1899
|
-
* single timer.
|
|
1900
|
-
*/
|
|
1901
|
-
schedule_backoff_wake() {
|
|
1902
|
-
if (this._backoff_timer) clearTimeout(this._backoff_timer);
|
|
1903
|
-
let earliest = Number.POSITIVE_INFINITY;
|
|
1904
|
-
for (const t of this._backoff.values()) if (t < earliest) earliest = t;
|
|
1905
|
-
const delay = Math.max(0, earliest - Date.now());
|
|
1906
|
-
this._backoff_timer = setTimeout(() => {
|
|
1907
|
-
this._backoff_timer = void 0;
|
|
1908
|
-
const now = Date.now();
|
|
1909
|
-
for (const [stream, at] of this._backoff) {
|
|
1910
|
-
if (at <= now) this._backoff.delete(stream);
|
|
1911
|
-
}
|
|
1912
|
-
this._armed = true;
|
|
1913
|
-
}, delay);
|
|
1914
|
-
this._backoff_timer.unref();
|
|
1915
|
-
}
|
|
1916
2110
|
/** Lane this controller drains (undefined = legacy single-lane span). */
|
|
1917
2111
|
get lane() {
|
|
1918
2112
|
return this._deps.lane;
|
|
@@ -1944,6 +2138,7 @@ var DrainController = class {
|
|
|
1944
2138
|
clearTimeout(this._worker);
|
|
1945
2139
|
this._worker = void 0;
|
|
1946
2140
|
}
|
|
2141
|
+
this._defer.stop();
|
|
1947
2142
|
}
|
|
1948
2143
|
/** Run one drain pass. Short-circuits when not armed or already running. */
|
|
1949
2144
|
async drain(options = {}) {
|
|
@@ -1969,7 +2164,7 @@ var DrainController = class {
|
|
|
1969
2164
|
leading,
|
|
1970
2165
|
eventLimit,
|
|
1971
2166
|
leaseMillis,
|
|
1972
|
-
this.
|
|
2167
|
+
this._defer.size > 0 ? this._defer.is_deferred : void 0,
|
|
1973
2168
|
this._deps.lane
|
|
1974
2169
|
);
|
|
1975
2170
|
this._deps.breaker.passed();
|
|
@@ -1977,19 +2172,19 @@ var DrainController = class {
|
|
|
1977
2172
|
this._armed = false;
|
|
1978
2173
|
return EMPTY_DRAIN;
|
|
1979
2174
|
}
|
|
1980
|
-
const { leased, fetched, handled, acked, blocked } = cycle;
|
|
2175
|
+
const { leased, fetched, handled, acked, blocked, closeable } = cycle;
|
|
1981
2176
|
trace_cycle(this._deps.logger, leased, fetched, handled, acked, blocked);
|
|
1982
2177
|
this._ratio = compute_lag_lead_ratio(handled, lagging, leading);
|
|
1983
|
-
for (const lease of acked) this.
|
|
1984
|
-
for (const lease of blocked) this.
|
|
2178
|
+
for (const lease of acked) this._defer.delete(lease.stream);
|
|
2179
|
+
for (const lease of blocked) this._defer.delete(lease.stream);
|
|
1985
2180
|
for (const h of handled) {
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
}
|
|
2181
|
+
const next = h.defer ?? (h.block ? void 0 : h.next_attempt_at);
|
|
2182
|
+
if (next !== void 0) this._defer.set(h.lease.stream, next);
|
|
1989
2183
|
}
|
|
1990
|
-
if (this.
|
|
2184
|
+
if (this._defer.size > 0) this._defer.schedule();
|
|
1991
2185
|
if (acked.length) this._deps.on_acked(acked);
|
|
1992
2186
|
if (blocked.length) this._deps.on_blocked(blocked);
|
|
2187
|
+
if (closeable.length) await this._deps.on_close(closeable);
|
|
1993
2188
|
const has_errors = handled.some(({ error }) => error);
|
|
1994
2189
|
if (!acked.length && !blocked.length && !has_errors) this._armed = false;
|
|
1995
2190
|
return { fetched, leased, acked, blocked };
|
|
@@ -2002,154 +2197,6 @@ var DrainController = class {
|
|
|
2002
2197
|
}
|
|
2003
2198
|
};
|
|
2004
2199
|
|
|
2005
|
-
// src/internal/merge.ts
|
|
2006
|
-
import { ZodObject } from "zod";
|
|
2007
|
-
function base_type_name(zodType) {
|
|
2008
|
-
let t = zodType;
|
|
2009
|
-
while (typeof t.unwrap === "function") {
|
|
2010
|
-
t = t.unwrap();
|
|
2011
|
-
}
|
|
2012
|
-
return t.constructor.name;
|
|
2013
|
-
}
|
|
2014
|
-
function merge_schemas(existing, incoming, state_name) {
|
|
2015
|
-
if (existing instanceof ZodObject && incoming instanceof ZodObject) {
|
|
2016
|
-
const existing_shape = existing.shape;
|
|
2017
|
-
const incoming_shape = incoming.shape;
|
|
2018
|
-
for (const key of Object.keys(incoming_shape)) {
|
|
2019
|
-
if (key in existing_shape) {
|
|
2020
|
-
const existing_base = base_type_name(existing_shape[key]);
|
|
2021
|
-
const incoming_base = base_type_name(incoming_shape[key]);
|
|
2022
|
-
if (existing_base !== incoming_base) {
|
|
2023
|
-
throw new Error(
|
|
2024
|
-
`Schema conflict in "${state_name}": key "${key}" has type "${existing_base}" but incoming partial declares "${incoming_base}"`
|
|
2025
|
-
);
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
return existing.extend(incoming_shape);
|
|
2030
|
-
}
|
|
2031
|
-
return existing;
|
|
2032
|
-
}
|
|
2033
|
-
function merge_inits(existing, incoming) {
|
|
2034
|
-
return () => ({ ...existing(), ...incoming() });
|
|
2035
|
-
}
|
|
2036
|
-
function register_state(state2, states, actions, events) {
|
|
2037
|
-
const existing = states.get(state2.name);
|
|
2038
|
-
if (existing) {
|
|
2039
|
-
merge_into_existing(state2, existing, states, actions, events);
|
|
2040
|
-
} else {
|
|
2041
|
-
register_new_state(state2, states, actions, events);
|
|
2042
|
-
}
|
|
2043
|
-
}
|
|
2044
|
-
function register_new_state(state2, states, actions, events) {
|
|
2045
|
-
states.set(state2.name, state2);
|
|
2046
|
-
for (const name of Object.keys(state2.actions)) {
|
|
2047
|
-
if (actions[name]) throw new Error(`Duplicate action "${name}"`);
|
|
2048
|
-
actions[name] = state2;
|
|
2049
|
-
}
|
|
2050
|
-
for (const name of Object.keys(state2.events)) {
|
|
2051
|
-
if (events[name]) throw new Error(`Duplicate event "${name}"`);
|
|
2052
|
-
events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
|
|
2053
|
-
}
|
|
2054
|
-
}
|
|
2055
|
-
function merge_into_existing(state2, existing, states, actions, events) {
|
|
2056
|
-
for (const name of Object.keys(state2.actions)) {
|
|
2057
|
-
if (existing.actions[name] === state2.actions[name]) continue;
|
|
2058
|
-
if (actions[name]) throw new Error(`Duplicate action "${name}"`);
|
|
2059
|
-
}
|
|
2060
|
-
for (const name of Object.keys(state2.events)) {
|
|
2061
|
-
if (existing.events[name] === state2.events[name]) continue;
|
|
2062
|
-
if (existing.events[name]) {
|
|
2063
|
-
throw new Error(
|
|
2064
|
-
`Event "${name}" in state "${state2.name}" is declared with different Zod schemas across slices. Cross-slice event schemas must reference the same instance \u2014 extract a shared schema (e.g. \`export const ${name} = z.object({ ... })\` in a shared module) and import it in every slice that declares it.`
|
|
2065
|
-
);
|
|
2066
|
-
}
|
|
2067
|
-
if (events[name]) throw new Error(`Duplicate event "${name}"`);
|
|
2068
|
-
}
|
|
2069
|
-
const merged_patch = merge_patches(existing.patch, state2.patch, state2.name);
|
|
2070
|
-
const merged = {
|
|
2071
|
-
...existing,
|
|
2072
|
-
state: merge_schemas(existing.state, state2.state, state2.name),
|
|
2073
|
-
init: merge_inits(existing.init, state2.init),
|
|
2074
|
-
events: { ...existing.events, ...state2.events },
|
|
2075
|
-
actions: { ...existing.actions, ...state2.actions },
|
|
2076
|
-
patch: merged_patch,
|
|
2077
|
-
on: { ...existing.on, ...state2.on },
|
|
2078
|
-
given: { ...existing.given, ...state2.given },
|
|
2079
|
-
snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
|
|
2080
|
-
throw new Error(
|
|
2081
|
-
`Duplicate snap strategy for state "${state2.name}"`
|
|
2082
|
-
);
|
|
2083
|
-
})() : state2.snap || existing.snap
|
|
2084
|
-
};
|
|
2085
|
-
states.set(state2.name, merged);
|
|
2086
|
-
for (const name of Object.keys(merged.actions)) {
|
|
2087
|
-
actions[name] = merged;
|
|
2088
|
-
}
|
|
2089
|
-
for (const name of Object.keys(state2.events)) {
|
|
2090
|
-
if (events[name]) continue;
|
|
2091
|
-
events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
|
|
2092
|
-
}
|
|
2093
|
-
}
|
|
2094
|
-
function merge_patches(existing, incoming, state_name) {
|
|
2095
|
-
const merged = { ...existing };
|
|
2096
|
-
for (const name of Object.keys(incoming)) {
|
|
2097
|
-
const existing_p = existing[name];
|
|
2098
|
-
const incoming_p = incoming[name];
|
|
2099
|
-
if (!existing_p) {
|
|
2100
|
-
merged[name] = incoming_p;
|
|
2101
|
-
continue;
|
|
2102
|
-
}
|
|
2103
|
-
const existing_is_default = existing_p._passthrough;
|
|
2104
|
-
const incoming_is_default = incoming_p._passthrough;
|
|
2105
|
-
if (!existing_is_default && !incoming_is_default && existing_p !== incoming_p) {
|
|
2106
|
-
throw new Error(
|
|
2107
|
-
`Duplicate custom patch for event "${name}" in state "${state_name}"`
|
|
2108
|
-
);
|
|
2109
|
-
}
|
|
2110
|
-
if (existing_is_default && !incoming_is_default) {
|
|
2111
|
-
merged[name] = incoming_p;
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
return merged;
|
|
2115
|
-
}
|
|
2116
|
-
function merge_event_register(target, source) {
|
|
2117
|
-
for (const [event_name, source_reg] of Object.entries(source)) {
|
|
2118
|
-
const target_reg = target[event_name];
|
|
2119
|
-
if (!target_reg) continue;
|
|
2120
|
-
for (const [name, reaction] of source_reg.reactions) {
|
|
2121
|
-
const existing = target_reg.reactions.get(name);
|
|
2122
|
-
if (existing !== void 0 && existing !== reaction)
|
|
2123
|
-
throw new Error(
|
|
2124
|
-
`Duplicate reaction "${name}" for event "${event_name}". Reaction handlers are keyed by function name; rename one of them.`
|
|
2125
|
-
);
|
|
2126
|
-
target_reg.reactions.set(name, reaction);
|
|
2127
|
-
}
|
|
2128
|
-
}
|
|
2129
|
-
}
|
|
2130
|
-
function merge_projection(proj, events) {
|
|
2131
|
-
for (const event_name of Object.keys(proj.events)) {
|
|
2132
|
-
const proj_register = proj.events[event_name];
|
|
2133
|
-
const existing = events[event_name];
|
|
2134
|
-
if (!existing) {
|
|
2135
|
-
events[event_name] = {
|
|
2136
|
-
schema: proj_register.schema,
|
|
2137
|
-
reactions: new Map(proj_register.reactions)
|
|
2138
|
-
};
|
|
2139
|
-
} else {
|
|
2140
|
-
for (const [name, reaction] of proj_register.reactions) {
|
|
2141
|
-
let key = name;
|
|
2142
|
-
while (existing.reactions.has(key)) key = `${key}_p`;
|
|
2143
|
-
existing.reactions.set(key, reaction);
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
}
|
|
2148
|
-
var _this_ = ({ stream }) => ({
|
|
2149
|
-
source: stream,
|
|
2150
|
-
target: stream
|
|
2151
|
-
});
|
|
2152
|
-
|
|
2153
2200
|
// src/internal/reactions.ts
|
|
2154
2201
|
function finalize(lease, handled, at, error, options, logger, failed_at) {
|
|
2155
2202
|
if (!error) return { lease, handled, acked_at: at };
|
|
@@ -2205,6 +2252,25 @@ function build_handle(deps) {
|
|
|
2205
2252
|
at = event.id;
|
|
2206
2253
|
handled++;
|
|
2207
2254
|
} catch (error) {
|
|
2255
|
+
if (error instanceof DeferSignal)
|
|
2256
|
+
return {
|
|
2257
|
+
lease,
|
|
2258
|
+
handled,
|
|
2259
|
+
acked_at: at,
|
|
2260
|
+
defer: resolve_defer_at(error.when, event)
|
|
2261
|
+
};
|
|
2262
|
+
if (error instanceof CloseSignal)
|
|
2263
|
+
return {
|
|
2264
|
+
lease,
|
|
2265
|
+
handled: handled + 1,
|
|
2266
|
+
// Advance to the live head the handler evaluated against (when
|
|
2267
|
+
// provided) so the close-cycle guard sees this reaction caught up.
|
|
2268
|
+
acked_at: error.at ?? event.id,
|
|
2269
|
+
// Close the signalled stream (the autoclose reaction's aggregate,
|
|
2270
|
+
// which differs from its synthetic lease stream); a self-closing
|
|
2271
|
+
// user reaction omits it and closes its own lease stream.
|
|
2272
|
+
close: { stream: error.stream ?? stream, archive: error.archive }
|
|
2273
|
+
};
|
|
2208
2274
|
return finalize(
|
|
2209
2275
|
lease,
|
|
2210
2276
|
handled,
|
|
@@ -2307,6 +2373,7 @@ var SettleLoop = class {
|
|
|
2307
2373
|
// src/act.ts
|
|
2308
2374
|
var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
|
|
2309
2375
|
var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
|
|
2376
|
+
var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
|
|
2310
2377
|
var Act = class {
|
|
2311
2378
|
_emitter = new EventEmitter();
|
|
2312
2379
|
/** ACT-984: orchestrator-owned circuit breaker shared by all drain lanes. */
|
|
@@ -2348,15 +2415,6 @@ var Act = class {
|
|
|
2348
2415
|
* @internal
|
|
2349
2416
|
*/
|
|
2350
2417
|
_autoclose_config;
|
|
2351
|
-
/**
|
|
2352
|
-
* App-level autoclose controller. `undefined` when no state declares
|
|
2353
|
-
* `.autocloses(...)` — the controller is never constructed, so
|
|
2354
|
-
* `start_correlations()` / `stop_correlations()` skip the autoclose
|
|
2355
|
-
* lifecycle and the orchestrator pays zero per-tick cost.
|
|
2356
|
-
*
|
|
2357
|
-
* @internal
|
|
2358
|
-
*/
|
|
2359
|
-
_autoclose;
|
|
2360
2418
|
/**
|
|
2361
2419
|
* Emit a lifecycle event. The payload type is inferred from the event name
|
|
2362
2420
|
* via {@link ActLifecycleEvents}.
|
|
@@ -2497,6 +2555,62 @@ var Act = class {
|
|
|
2497
2555
|
bound_forget: this._bound_forget
|
|
2498
2556
|
});
|
|
2499
2557
|
this._handle_batch = build_handle_batch(this._logger);
|
|
2558
|
+
for (const st of this._states.values()) {
|
|
2559
|
+
const predicate = st.autoclose;
|
|
2560
|
+
if (!predicate) continue;
|
|
2561
|
+
const after_ms = st.autoclose_after_ms;
|
|
2562
|
+
const archiver = st.archive;
|
|
2563
|
+
const reaction = {
|
|
2564
|
+
// Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
|
|
2565
|
+
// per-aggregate `__autoclose__` key — so the autoclose reaction never
|
|
2566
|
+
// shares a watermark with the aggregate's own reactions. A shared
|
|
2567
|
+
// watermark would let autoclose's defer short-circuit the aggregate's
|
|
2568
|
+
// other reactions (the "a defer affects all reactions on a stream"
|
|
2569
|
+
// hazard). The close still targets the aggregate (`source`).
|
|
2570
|
+
resolver: (e) => ({
|
|
2571
|
+
target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
|
|
2572
|
+
source: e.stream
|
|
2573
|
+
}),
|
|
2574
|
+
// Never block on autoclose: a transient query/store error should retry,
|
|
2575
|
+
// not quarantine the synthetic stream.
|
|
2576
|
+
options: { blockOnError: false, maxRetries: 3 },
|
|
2577
|
+
handler: async (event) => {
|
|
2578
|
+
const aggregate = event.stream;
|
|
2579
|
+
const config2 = this._autoclose_config;
|
|
2580
|
+
if (!in_autoclose_window(config2.autocloseWindow, /* @__PURE__ */ new Date()))
|
|
2581
|
+
throw new DeferSignal({
|
|
2582
|
+
at: new Date(Date.now() + config2.autocloseCycleMinutes * 6e4)
|
|
2583
|
+
});
|
|
2584
|
+
const stats = await store().query_stats([aggregate], {
|
|
2585
|
+
count: true,
|
|
2586
|
+
exclude: [TOMBSTONE_EVENT]
|
|
2587
|
+
});
|
|
2588
|
+
const entry = stats.get(aggregate);
|
|
2589
|
+
if (!entry) return;
|
|
2590
|
+
const head = entry.head;
|
|
2591
|
+
if (predicate(aggregate, head, entry.count))
|
|
2592
|
+
throw new CloseSignal({
|
|
2593
|
+
stream: aggregate,
|
|
2594
|
+
archive: archiver ? () => archiver(aggregate, head) : void 0,
|
|
2595
|
+
// Ack this reaction's own watermark to the live head so the
|
|
2596
|
+
// close-cycle guard (which matches subscriptions by source =
|
|
2597
|
+
// aggregate) sees it caught up instead of blocking its own close.
|
|
2598
|
+
at: head.id
|
|
2599
|
+
});
|
|
2600
|
+
if (after_ms !== void 0)
|
|
2601
|
+
throw new DeferSignal({
|
|
2602
|
+
at: new Date(head.created.getTime() + after_ms)
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
};
|
|
2606
|
+
const key = `__autoclose_${st.name}`;
|
|
2607
|
+
for (const event_name of Object.keys(st.events)) {
|
|
2608
|
+
this.registry.events[event_name]?.reactions.set(
|
|
2609
|
+
key,
|
|
2610
|
+
reaction
|
|
2611
|
+
);
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2500
2614
|
const {
|
|
2501
2615
|
static_targets,
|
|
2502
2616
|
has_dynamic_resolvers,
|
|
@@ -2545,6 +2659,22 @@ var Act = class {
|
|
|
2545
2659
|
handle_batch: this._handle_batch,
|
|
2546
2660
|
on_acked: (acked) => this.emit("acked", acked),
|
|
2547
2661
|
on_blocked: (blocked) => this.emit("blocked", blocked),
|
|
2662
|
+
// Reaction-requested close (#1090). Runs the same close machinery as
|
|
2663
|
+
// `app.close` (tombstone guard + archive + atomic truncate) for the
|
|
2664
|
+
// targets a handler signalled via `CloseSignal`. No `correlate()` here
|
|
2665
|
+
// — the drain that produced these targets has already correlated.
|
|
2666
|
+
on_close: async (targets) => {
|
|
2667
|
+
const close_actor = { id: "$close", name: "close" };
|
|
2668
|
+
const result = await run_close_cycle(targets, {
|
|
2669
|
+
reactive_events_size: this._reactive_events.size,
|
|
2670
|
+
event_to_state: this._event_to_state,
|
|
2671
|
+
load: this._es.load,
|
|
2672
|
+
tombstone: this._es.tombstone,
|
|
2673
|
+
logger: this._logger,
|
|
2674
|
+
correlation: close_correlation(this._correlator, close_actor)
|
|
2675
|
+
});
|
|
2676
|
+
this.emit("closed", result);
|
|
2677
|
+
},
|
|
2548
2678
|
breaker: this._breaker,
|
|
2549
2679
|
// Pass lane only when a true per-lane controller is active.
|
|
2550
2680
|
// The all-lanes (single default) case keeps lane=undefined so
|
|
@@ -2591,27 +2721,6 @@ var Act = class {
|
|
|
2591
2721
|
},
|
|
2592
2722
|
options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
|
|
2593
2723
|
);
|
|
2594
|
-
const states_with_policy = [...this._states.values()].filter(
|
|
2595
|
-
(s) => s.autoclose
|
|
2596
|
-
);
|
|
2597
|
-
this._autoclose = states_with_policy.length ? new AutocloseController({
|
|
2598
|
-
// Internal cycle takes the loose `(string) => …` signature; the
|
|
2599
|
-
// public `registry.autoclose_policy` narrows to
|
|
2600
|
-
// `keyof TStateMap & string`. Thin lambdas bridge across the
|
|
2601
|
-
// narrowing — runtime semantics are unchanged.
|
|
2602
|
-
autoclose_policy: (name) => this.registry.autoclose_policy(name),
|
|
2603
|
-
autoclose_archiver: (name) => this.registry.autoclose_archiver(name),
|
|
2604
|
-
event_to_state: this._event_to_state,
|
|
2605
|
-
reactive_events_size: this._reactive_events.size,
|
|
2606
|
-
load: this._es.load,
|
|
2607
|
-
tombstone: this._es.tombstone,
|
|
2608
|
-
logger: this._logger,
|
|
2609
|
-
config: this._autoclose_config,
|
|
2610
|
-
correlator: this._correlator,
|
|
2611
|
-
close_actor: { id: "$autoclose", name: "autoclose" },
|
|
2612
|
-
on_closed: (result) => this.emit("closed", result),
|
|
2613
|
-
breaker: this._breaker
|
|
2614
|
-
}) : void 0;
|
|
2615
2724
|
this._notify_disposer = this._wire_notify(options.scoped?.store ?? store());
|
|
2616
2725
|
dispose(() => this.shutdown());
|
|
2617
2726
|
}
|
|
@@ -3105,7 +3214,6 @@ var Act = class {
|
|
|
3105
3214
|
*/
|
|
3106
3215
|
start_correlations(query = {}, frequency = 1e4, callback) {
|
|
3107
3216
|
const started = this._correlate.start_polling(query, frequency, callback);
|
|
3108
|
-
this._autoclose?.start();
|
|
3109
3217
|
return started;
|
|
3110
3218
|
}
|
|
3111
3219
|
/**
|
|
@@ -3127,7 +3235,6 @@ var Act = class {
|
|
|
3127
3235
|
*/
|
|
3128
3236
|
stop_correlations() {
|
|
3129
3237
|
this._correlate.stop_polling();
|
|
3130
|
-
this._autoclose?.stop();
|
|
3131
3238
|
}
|
|
3132
3239
|
/**
|
|
3133
3240
|
* Cancels any pending or active settle cycle.
|
|
@@ -3594,41 +3701,10 @@ function act() {
|
|
|
3594
3701
|
},
|
|
3595
3702
|
withActor: () => builder,
|
|
3596
3703
|
withLane: (config2) => {
|
|
3597
|
-
|
|
3598
|
-
throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
|
|
3599
|
-
if (lanes.some((l) => l.name === config2.name))
|
|
3600
|
-
throw new Error(`Lane "${config2.name}" was already declared`);
|
|
3601
|
-
lanes.push(config2);
|
|
3704
|
+
register_lane(config2, lanes);
|
|
3602
3705
|
return builder;
|
|
3603
3706
|
},
|
|
3604
|
-
on: (event) => (
|
|
3605
|
-
do: (handler, options) => {
|
|
3606
|
-
const reaction = {
|
|
3607
|
-
handler,
|
|
3608
|
-
resolver: _this_,
|
|
3609
|
-
options: {
|
|
3610
|
-
blockOnError: options?.blockOnError ?? true,
|
|
3611
|
-
maxRetries: options?.maxRetries ?? 3,
|
|
3612
|
-
backoff: options?.backoff
|
|
3613
|
-
}
|
|
3614
|
-
};
|
|
3615
|
-
if (!handler.name)
|
|
3616
|
-
throw new Error(
|
|
3617
|
-
`Reaction handler for "${String(event)}" must be a named function`
|
|
3618
|
-
);
|
|
3619
|
-
if (registry.events[event].reactions.has(handler.name))
|
|
3620
|
-
throw new Error(
|
|
3621
|
-
`Duplicate reaction "${handler.name}" for event "${String(event)}". Reaction handlers are keyed by function name; rename one of them.`
|
|
3622
|
-
);
|
|
3623
|
-
registry.events[event].reactions.set(handler.name, reaction);
|
|
3624
|
-
return Object.assign(builder, {
|
|
3625
|
-
to(resolver) {
|
|
3626
|
-
reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
|
|
3627
|
-
return builder;
|
|
3628
|
-
}
|
|
3629
|
-
});
|
|
3630
|
-
}
|
|
3631
|
-
}),
|
|
3707
|
+
on: (event) => reaction_on(event, registry.events, builder),
|
|
3632
3708
|
build: (options) => {
|
|
3633
3709
|
if (!_built) {
|
|
3634
3710
|
for (const proj of pending_projections) {
|
|
@@ -3793,41 +3869,10 @@ function slice() {
|
|
|
3793
3869
|
return builder;
|
|
3794
3870
|
},
|
|
3795
3871
|
withLane: (config2) => {
|
|
3796
|
-
|
|
3797
|
-
throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
|
|
3798
|
-
if (lanes.some((l) => l.name === config2.name))
|
|
3799
|
-
throw new Error(`Lane "${config2.name}" was already declared`);
|
|
3800
|
-
lanes.push(config2);
|
|
3872
|
+
register_lane(config2, lanes);
|
|
3801
3873
|
return builder;
|
|
3802
3874
|
},
|
|
3803
|
-
on: (event) => (
|
|
3804
|
-
do: (handler, options) => {
|
|
3805
|
-
const reaction = {
|
|
3806
|
-
handler,
|
|
3807
|
-
resolver: _this_,
|
|
3808
|
-
options: {
|
|
3809
|
-
blockOnError: options?.blockOnError ?? true,
|
|
3810
|
-
maxRetries: options?.maxRetries ?? 3,
|
|
3811
|
-
backoff: options?.backoff
|
|
3812
|
-
}
|
|
3813
|
-
};
|
|
3814
|
-
if (!handler.name)
|
|
3815
|
-
throw new Error(
|
|
3816
|
-
`Reaction handler for "${String(event)}" must be a named function`
|
|
3817
|
-
);
|
|
3818
|
-
if (events[event].reactions.has(handler.name))
|
|
3819
|
-
throw new Error(
|
|
3820
|
-
`Duplicate reaction "${handler.name}" for event "${String(event)}". Reaction handlers are keyed by function name; rename one of them.`
|
|
3821
|
-
);
|
|
3822
|
-
events[event].reactions.set(handler.name, reaction);
|
|
3823
|
-
return Object.assign(builder, {
|
|
3824
|
-
to(resolver) {
|
|
3825
|
-
reaction.resolver = typeof resolver === "string" ? { target: resolver } : resolver;
|
|
3826
|
-
return builder;
|
|
3827
|
-
}
|
|
3828
|
-
});
|
|
3829
|
-
}
|
|
3830
|
-
}),
|
|
3875
|
+
on: (event) => reaction_on(event, events, builder),
|
|
3831
3876
|
build: () => ({
|
|
3832
3877
|
_tag: "Slice",
|
|
3833
3878
|
states,
|
|
@@ -3930,20 +3975,21 @@ function action_builder(state2) {
|
|
|
3930
3975
|
internal.disclose = disclose;
|
|
3931
3976
|
return builder;
|
|
3932
3977
|
},
|
|
3933
|
-
autocloses(
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
} else if (predicate_or_policy !== null && typeof predicate_or_policy === "object") {
|
|
3938
|
-
predicate = compile_autoclose_policy(
|
|
3939
|
-
predicate_or_policy
|
|
3978
|
+
autocloses(policy) {
|
|
3979
|
+
if (typeof policy === "function") {
|
|
3980
|
+
throw new Error(
|
|
3981
|
+
".autocloses(fn) is no longer supported \u2014 pass a declarative policy ({ after, is, reaches, or }) or call app.close(...) from your own reaction for custom logic."
|
|
3940
3982
|
);
|
|
3941
|
-
}
|
|
3983
|
+
}
|
|
3984
|
+
if (policy === null || typeof policy !== "object") {
|
|
3942
3985
|
throw new Error(
|
|
3943
|
-
".autocloses(...) requires a
|
|
3986
|
+
".autocloses(...) requires a policy object; got " + typeof policy
|
|
3944
3987
|
);
|
|
3945
3988
|
}
|
|
3946
|
-
internal.autoclose =
|
|
3989
|
+
internal.autoclose = compile_autoclose_policy(
|
|
3990
|
+
policy
|
|
3991
|
+
);
|
|
3992
|
+
internal.autoclose_after_ms = policy_min_after_ms(policy);
|
|
3947
3993
|
return builder;
|
|
3948
3994
|
},
|
|
3949
3995
|
archives(archive) {
|
|
@@ -4136,6 +4182,7 @@ export {
|
|
|
4136
4182
|
DEFAULT_LANE,
|
|
4137
4183
|
DEFAULT_MAX_SUBSCRIBED_STREAMS,
|
|
4138
4184
|
DEFAULT_SETTLE_DEBOUNCE_MS,
|
|
4185
|
+
DeferSignal,
|
|
4139
4186
|
Environments,
|
|
4140
4187
|
Errors,
|
|
4141
4188
|
EventMetaSchema,
|