@rotorsoft/act 1.37.7 → 2.0.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.
Files changed (59) hide show
  1. package/README.md +16 -3
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/act.d.ts +5 -4
  4. package/dist/@types/act.d.ts.map +1 -1
  5. package/dist/@types/adapters/in-memory-store.d.ts +2 -2
  6. package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
  7. package/dist/@types/builders/act-builder.d.ts.map +1 -1
  8. package/dist/@types/builders/merge.d.ts.map +1 -1
  9. package/dist/@types/builders/state-builder.d.ts +1 -1
  10. package/dist/@types/internal/autoclose-reaction.d.ts +8 -0
  11. package/dist/@types/internal/autoclose-reaction.d.ts.map +1 -1
  12. package/dist/@types/internal/classify.d.ts.map +1 -1
  13. package/dist/@types/internal/close-cycle.d.ts.map +1 -1
  14. package/dist/@types/internal/config.d.ts +11 -0
  15. package/dist/@types/internal/config.d.ts.map +1 -1
  16. package/dist/@types/internal/date-reviver.d.ts +6 -3
  17. package/dist/@types/internal/date-reviver.d.ts.map +1 -1
  18. package/dist/@types/internal/drain-ops.d.ts.map +1 -1
  19. package/dist/@types/internal/event-sourcing.d.ts +6 -2
  20. package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
  21. package/dist/@types/internal/reaction-dispatch.d.ts +6 -0
  22. package/dist/@types/internal/reaction-dispatch.d.ts.map +1 -1
  23. package/dist/@types/internal/tracing.d.ts.map +1 -1
  24. package/dist/@types/ports.d.ts.map +1 -1
  25. package/dist/@types/types/act.d.ts +0 -9
  26. package/dist/@types/types/act.d.ts.map +1 -1
  27. package/dist/@types/types/action.d.ts +36 -32
  28. package/dist/@types/types/action.d.ts.map +1 -1
  29. package/dist/@types/types/builders.d.ts +126 -47
  30. package/dist/@types/types/builders.d.ts.map +1 -1
  31. package/dist/@types/types/errors.d.ts +11 -43
  32. package/dist/@types/types/errors.d.ts.map +1 -1
  33. package/dist/@types/types/ports.d.ts +51 -34
  34. package/dist/@types/types/ports.d.ts.map +1 -1
  35. package/dist/@types/types/reaction.d.ts +3 -3
  36. package/dist/@types/utils.d.ts +37 -12
  37. package/dist/@types/utils.d.ts.map +1 -1
  38. package/dist/{chunk-PRDX3HP6.cjs → chunk-LUHPSF3C.cjs} +73 -50
  39. package/dist/chunk-LUHPSF3C.cjs.map +1 -0
  40. package/dist/{chunk-HXW3TPP6.js → chunk-MWHTNPUY.js} +62 -39
  41. package/dist/chunk-MWHTNPUY.js.map +1 -0
  42. package/dist/{chunk-V5ZB54DC.js → chunk-SNPEQW6Z.js} +7 -21
  43. package/dist/chunk-SNPEQW6Z.js.map +1 -0
  44. package/dist/{chunk-H6KVPVP4.cjs → chunk-YFFWCVXW.cjs} +8 -22
  45. package/dist/chunk-YFFWCVXW.cjs.map +1 -0
  46. package/dist/index.cjs +1828 -1621
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.js +1511 -1304
  49. package/dist/index.js.map +1 -1
  50. package/dist/test/index.cjs +4 -4
  51. package/dist/test/index.js +2 -2
  52. package/dist/types/index.cjs +2 -4
  53. package/dist/types/index.cjs.map +1 -1
  54. package/dist/types/index.js +1 -3
  55. package/package.json +2 -2
  56. package/dist/chunk-H6KVPVP4.cjs.map +0 -1
  57. package/dist/chunk-HXW3TPP6.js.map +0 -1
  58. package/dist/chunk-PRDX3HP6.cjs.map +0 -1
  59. package/dist/chunk-V5ZB54DC.js.map +0 -1
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  DEFAULT_EVENT_LIMIT,
6
6
  DEFAULT_LEASE_MILLIS,
7
7
  DEFAULT_MAX_RETRIES,
8
+ DEFAULT_QUERY_STREAMS_LIMIT,
8
9
  DEFAULT_STREAM_LIMIT,
9
10
  InMemoryCache,
10
11
  InMemoryStore,
@@ -12,6 +13,8 @@ import {
12
13
  MAX_SHUTDOWN_GRACE_MS,
13
14
  PackageSchema,
14
15
  cache,
16
+ check_appends,
17
+ check_store,
15
18
  config,
16
19
  current_autoclose_window,
17
20
  current_reacting,
@@ -20,6 +23,7 @@ import {
20
23
  dispose,
21
24
  disposeAndExit,
22
25
  extend,
26
+ fair_slots,
23
27
  is_literal_source,
24
28
  log,
25
29
  make_reaction_scope,
@@ -40,7 +44,7 @@ import {
40
44
  sleep,
41
45
  store,
42
46
  validate
43
- } from "./chunk-HXW3TPP6.js";
47
+ } from "./chunk-MWHTNPUY.js";
44
48
  import {
45
49
  ActorSchema,
46
50
  CausationEventSchema,
@@ -54,7 +58,6 @@ import {
54
58
  Errors,
55
59
  EventMetaSchema,
56
60
  ExitCodes,
57
- InvariantError,
58
61
  LogLevels,
59
62
  NonRetryableError,
60
63
  PreconditionError,
@@ -74,7 +77,7 @@ import {
74
77
  pii_fields,
75
78
  pii_schemas,
76
79
  sensitive
77
- } from "./chunk-V5ZB54DC.js";
80
+ } from "./chunk-SNPEQW6Z.js";
78
81
 
79
82
  // src/signals.ts
80
83
  process.once("SIGINT", async (arg) => {
@@ -750,6 +753,7 @@ var CircuitBreaker = class {
750
753
 
751
754
  // src/internal/classify.ts
752
755
  var ALL_LANES = /* @__PURE__ */ Symbol("act/all-lanes");
756
+ var describe_source = (source) => source === void 0 ? "no source (every stream)" : `"${source}"`;
753
757
  function classify_registry(registry, states) {
754
758
  const statics = /* @__PURE__ */ new Map();
755
759
  const target_lanes = /* @__PURE__ */ new Map();
@@ -776,6 +780,10 @@ function classify_registry(registry, states) {
776
780
  );
777
781
  }
778
782
  const existing = statics.get(target);
783
+ if (existing && existing.source !== source)
784
+ throw new Error(
785
+ `Target "${target}" has reactions on two sources, ${describe_source(existing.source)} and ${describe_source(source)}. A target reads one source, so the second would miss events or read too many. Give it its own target.`
786
+ );
779
787
  if (!existing) {
780
788
  statics.set(target, { stream: target, source, priority, lane });
781
789
  } else if (priority > existing.priority) {
@@ -797,674 +805,303 @@ function classify_registry(registry, states) {
797
805
  };
798
806
  }
799
807
 
800
- // src/internal/close-cycle.ts
801
- var CLOSE_CATCH_UP_LIMIT = 1e3;
802
- var CLOSE_CATCH_UP_PASSES = 20;
803
- async function catch_up_correlation(cursor, correlate, until) {
804
- for (let pass = 0; pass < CLOSE_CATCH_UP_PASSES && cursor.checkpoint < until; pass++) {
805
- const before = cursor.checkpoint;
806
- cursor.arm();
807
- await correlate(CLOSE_CATCH_UP_LIMIT);
808
- if (cursor.checkpoint <= before) break;
809
- }
810
- return cursor.checkpoint;
808
+ // src/internal/autoclose-policy.ts
809
+ import { z } from "zod";
810
+ var DAY_MS = 864e5;
811
+ var MIN_AFTER_DAYS = 1 / 1440;
812
+ var MIN_KEEP_DAYS = 1;
813
+ function days_after(date, days) {
814
+ return new Date(date.getTime() + days * DAY_MS);
811
815
  }
812
- var CloseLock = class {
813
- _tails = /* @__PURE__ */ new Map();
814
- /** Run `work` after every earlier close of `stream` has settled. */
815
- run(stream, work) {
816
- const prev = this._tails.get(stream) ?? Promise.resolve();
817
- const next = prev.then(work, work);
818
- this._tails.set(stream, next);
819
- const cleanup = () => {
820
- if (this._tails.get(stream) === next) this._tails.delete(stream);
821
- };
822
- next.then(cleanup, cleanup);
823
- return next;
816
+ function days_before_now(days) {
817
+ return new Date(Date.now() - days * DAY_MS);
818
+ }
819
+ var window_schema = (field, min_days, floor) => z.object({
820
+ days: z.number({ message: `autocloses: ${field}.days must be a number` }).positive(`autocloses: ${field}.days must be > 0`)
821
+ }).refine((o) => o.days >= min_days, {
822
+ message: `autocloses: ${field} resolves to < ${floor}, which is too short to be a meaningful retention window`
823
+ });
824
+ var AfterSchema = window_schema("after", MIN_AFTER_DAYS, "one minute");
825
+ var KeepSchema = window_schema("keep", MIN_KEEP_DAYS, "one day");
826
+ var IsSchema = z.union([
827
+ z.string().min(1, "autocloses: is must be a non-empty event name"),
828
+ z.array(
829
+ z.string().min(1, "autocloses: is entries must be non-empty event names")
830
+ ).min(1, "autocloses: is must include at least one event name").readonly()
831
+ ]);
832
+ var ReachesSchema = z.number({ message: "autocloses: reaches must be a number" }).int("autocloses: reaches must be an integer").min(1, "autocloses: reaches must be >= 1");
833
+ var OrBlockSchema = z.object({
834
+ after: AfterSchema.optional(),
835
+ is: IsSchema.optional(),
836
+ reaches: ReachesSchema.optional()
837
+ }).strict().refine(
838
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0,
839
+ {
840
+ message: "autocloses: `or` block must include at least one of after / is / reaches"
824
841
  }
825
- };
826
- var SAFETY_PROBE_PAGE_SIZE = 1e3;
827
- async function run_close_cycle(targets, deps) {
828
- const target_map = new Map(targets.map((t) => [t.stream, t]));
829
- for (const t of target_map.values()) {
830
- if (t.before !== void 0 && t.restart)
831
- throw new Error(
832
- `close: \`before\` and \`restart\` are mutually exclusive (stream "${t.stream}") \u2014 a windowed close keeps the stream live behind a real snapshot; restart reseeds it`
833
- );
842
+ );
843
+ var AutoclosePolicySchema = z.object({
844
+ after: AfterSchema.optional(),
845
+ is: IsSchema.optional(),
846
+ reaches: ReachesSchema.optional(),
847
+ or: OrBlockSchema.optional(),
848
+ keep: KeepSchema.optional()
849
+ }).strict().refine(
850
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0 || o.keep !== void 0,
851
+ {
852
+ message: "autocloses: at least one of after / is / reaches / or / keep must be specified \u2014 empty `{}` is a misconfiguration"
834
853
  }
835
- const windowed = [...target_map.values()].filter(
836
- (t) => t.before !== void 0
837
- );
838
- const full = [...target_map.values()].filter((t) => t.before === void 0);
839
- const skipped = [];
840
- const windowed_result = windowed.length ? await run_windowed_closes(windowed, deps, skipped) : /* @__PURE__ */ new Map();
841
- if (!full.length) return { truncated: windowed_result, skipped };
842
- const truncated = await with_stream_locks(
843
- full.map((t) => t.stream),
844
- deps,
845
- () => run_full_closes(full, target_map, deps, skipped)
846
- );
847
- const retired = [...truncated.entries()].filter(([, r]) => r.committed.name === TOMBSTONE_EVENT).map(([stream]) => stream);
848
- if (retired.length) deps.forget_subscribed?.(retired);
849
- for (const [stream, entry] of windowed_result) truncated.set(stream, entry);
850
- return { truncated, skipped };
854
+ );
855
+ function compile_after(after) {
856
+ return (_stream, head) => days_after(head.created, after.days) <= /* @__PURE__ */ new Date();
851
857
  }
852
- function with_stream_locks(streams, deps, work) {
853
- const with_lock = deps.with_stream_lock ?? ((_stream, w) => w());
854
- return [...streams].sort().reduceRight(
855
- (inner, stream) => () => with_lock(stream, inner),
856
- work
857
- )();
858
+ function compile_is(is) {
859
+ const set = new Set(typeof is === "string" ? [is] : is);
860
+ return (_stream, head) => set.has(head.name);
858
861
  }
859
- async function run_full_closes(full, target_map, deps, skipped) {
860
- const none = /* @__PURE__ */ new Map();
861
- const streams = full.map((t) => t.stream);
862
- const stream_info = await scan_stream_heads(streams);
863
- for (const target of full) {
864
- if (!target.restart) continue;
865
- const info = stream_info.get(target.stream);
866
- if (!info) continue;
867
- const owner = deps.event_to_state.get(info.last_event_name);
868
- if (owner?.pii_aware) {
869
- deps.logger.error(
870
- `Refusing to close "${target.stream}" with restart: state "${owner.name}" carries sensitive fields, so a restart seed would persist redacted values while deleting the originals. Close it without restart to retire the stream, or leave it open.`
871
- );
872
- skipped.push(target.stream);
873
- stream_info.delete(target.stream);
874
- }
875
- }
876
- const safe = await partition_by_safety(
877
- stream_info,
878
- deps.reactive,
879
- skipped,
880
- deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE,
881
- deps.catch_up_correlation
882
- );
883
- if (!safe.length) return none;
884
- const { guarded, guard_events } = await guard_with_tombstones(
885
- safe,
886
- stream_info,
887
- deps.correlation,
888
- deps.tombstone,
889
- skipped
890
- );
891
- if (!guarded.length) return none;
892
- const seed_states = await load_restart_seeds(
893
- guarded,
894
- target_map,
895
- stream_info,
896
- deps.event_to_state,
897
- deps.load,
898
- deps.logger
899
- );
900
- await run_archive_callbacks(guarded, target_map);
901
- const still_guarded = await heads_still_guarded(
902
- guarded,
903
- guard_events,
904
- skipped
905
- );
906
- if (!still_guarded.length) return none;
907
- return truncate_and_warm_cache(
908
- still_guarded,
909
- seed_states,
910
- guard_events,
911
- deps.correlation
912
- );
862
+ function compile_reaches(reaches) {
863
+ return (_stream, _head, count) => count >= reaches;
913
864
  }
914
- async function run_windowed_closes(windowed, deps, skipped) {
915
- const checkpoint = deps.reactive ? await deps.catch_up_correlation(Number.MAX_SAFE_INTEGER) : -1;
916
- const min_at = deps.reactive ? await probe_min_watermarks(
917
- windowed.map((t) => t.stream),
918
- deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE,
919
- checkpoint
920
- ) : /* @__PURE__ */ new Map();
921
- const with_lock = deps.with_stream_lock ?? ((_stream, work) => work());
922
- const truncated = /* @__PURE__ */ new Map();
923
- for (const t of windowed) {
924
- const entry = await with_lock(t.stream, async () => {
925
- const max_id = min_at.get(t.stream);
926
- if (!await windowed_prune_pending(t.stream, t.before, max_id))
927
- return void 0;
928
- if (t.archive) await t.archive();
929
- const result = await store().truncate([
930
- { stream: t.stream, before: t.before, max_id }
931
- ]);
932
- return result.get(t.stream);
933
- });
934
- if (entry) truncated.set(t.stream, entry);
935
- else skipped.push(t.stream);
936
- }
937
- return truncated;
865
+ function policy_min_after_days(options) {
866
+ const parsed = AutoclosePolicySchema.parse(options);
867
+ const windows = [];
868
+ if (parsed.after) windows.push(parsed.after.days);
869
+ if (parsed.or?.after) windows.push(parsed.or.after.days);
870
+ return windows.length ? Math.min(...windows) : void 0;
938
871
  }
939
- async function windowed_prune_pending(stream, before, max_id) {
940
- let boundary_id;
941
- let min_id;
942
- await store().query(
943
- (event) => {
944
- if (min_id === void 0 || event.id < min_id) min_id = event.id;
945
- if (event.name === SNAP_EVENT && event.created < before && (max_id === void 0 || event.id <= max_id) && (boundary_id === void 0 || event.id > boundary_id))
946
- boundary_id = event.id;
947
- },
948
- { stream, stream_exact: true, with_snaps: true, after: -1 }
949
- );
950
- return boundary_id !== void 0 && min_id < boundary_id;
872
+ function policy_after_windows(options) {
873
+ const parsed = AutoclosePolicySchema.parse(options);
874
+ return [parsed.after, parsed.or?.after].flatMap((w) => w ? [w.days] : []);
951
875
  }
952
- async function probe_min_watermarks(streams, page_size, checkpoint) {
953
- const min_at = /* @__PURE__ */ new Map();
954
- const matches = source_matcher();
955
- await walk_streams(
956
- store(),
957
- (position) => {
958
- const pending = position.correlated_at === void 0 || position.at < position.correlated_at;
959
- const cap = pending ? position.at : Math.max(position.at, checkpoint);
960
- for (const stream of streams) {
961
- if (matches(position.source, stream)) {
962
- const prev = min_at.get(stream);
963
- if (prev === void 0 || cap < prev) min_at.set(stream, cap);
964
- }
965
- }
966
- },
967
- { limit: page_size, source_matches: streams }
968
- );
969
- return min_at;
876
+ function policy_keep_days(options) {
877
+ const parsed = AutoclosePolicySchema.parse(options);
878
+ return parsed.keep?.days;
970
879
  }
971
- function source_matcher() {
972
- const compiled = /* @__PURE__ */ new Map();
973
- return (source, stream) => {
974
- if (!source) return true;
975
- let re = compiled.get(source);
976
- if (!re) {
977
- re = new RegExp(source);
978
- compiled.set(source, re);
880
+ function compile_autoclose_policy(options) {
881
+ const parsed = AutoclosePolicySchema.parse(options);
882
+ const and_preds = [];
883
+ if (parsed.after) and_preds.push(compile_after(parsed.after));
884
+ if (parsed.is) and_preds.push(compile_is(parsed.is));
885
+ if (parsed.reaches) and_preds.push(compile_reaches(parsed.reaches));
886
+ const or_preds = [];
887
+ if (parsed.or) {
888
+ if (parsed.or.after) or_preds.push(compile_after(parsed.or.after));
889
+ if (parsed.or.is) or_preds.push(compile_is(parsed.or.is));
890
+ if (parsed.or.reaches) or_preds.push(compile_reaches(parsed.or.reaches));
891
+ }
892
+ return (stream, head, count) => {
893
+ if (and_preds.length > 0 && and_preds.every((p) => p(stream, head, count))) {
894
+ return true;
979
895
  }
980
- return re.test(stream);
896
+ if (or_preds.some((p) => p(stream, head, count))) {
897
+ return true;
898
+ }
899
+ return false;
981
900
  };
982
901
  }
983
- async function scan_stream_heads(streams) {
984
- const stats = await store().query_stats(streams, {
985
- exclude: [SNAP_EVENT]
986
- });
987
- const domain_heads = await store().query_stats(streams, {
988
- exclude: [SNAP_EVENT, TOMBSTONE_EVENT]
989
- });
990
- const true_heads = await store().query_stats(streams, {});
991
- const out = /* @__PURE__ */ new Map();
992
- for (const [stream, { head }] of domain_heads) {
993
- const marker_head = stats.get(stream)?.head;
994
- const interrupted = marker_head?.name === TOMBSTONE_EVENT;
995
- out.set(stream, {
996
- max_id: head.id,
997
- version: true_heads.get(stream).head.version,
998
- last_event_name: head.name,
999
- ...interrupted ? { resumed_guard: { id: marker_head.id } } : {}
1000
- });
1001
- }
1002
- return out;
902
+
903
+ // src/internal/autoclose-window.ts
904
+ function hour_in_zone(now, timeZone) {
905
+ const parts = new Intl.DateTimeFormat("en-US", {
906
+ timeZone,
907
+ hour: "2-digit",
908
+ hourCycle: "h23"
909
+ }).formatToParts(now);
910
+ return Number(parts.find((p) => p.type === "hour")?.value);
1003
911
  }
1004
- async function partition_by_safety(stream_info, reactive, skipped, page_size, catch_up_correlation2) {
1005
- if (!reactive) return [...stream_info.keys()];
1006
- let needed = -1;
1007
- for (const info of stream_info.values())
1008
- needed = Math.max(needed, info.max_id);
1009
- const checkpoint = await catch_up_correlation2(needed);
1010
- const uncorrelated = /* @__PURE__ */ new Set();
1011
- for (const [stream, info] of stream_info) {
1012
- if (checkpoint < info.max_id) uncorrelated.add(stream);
1013
- }
1014
- const pending_set = /* @__PURE__ */ new Set();
1015
- const matches = source_matcher();
1016
- const targets = [...stream_info.keys()];
1017
- await walk_streams(
1018
- store(),
1019
- (position) => {
1020
- const has_work = position.correlated_at !== void 0 && position.at < position.correlated_at;
1021
- if (!has_work) return;
1022
- for (const [stream, info] of stream_info) {
1023
- if (matches(position.source, stream) && position.at < info.max_id)
1024
- pending_set.add(stream);
1025
- }
1026
- },
1027
- { limit: page_size, source_matches: targets }
1028
- );
1029
- const safe = [];
1030
- for (const [stream] of stream_info) {
1031
- if (pending_set.has(stream) || uncorrelated.has(stream))
1032
- skipped.push(stream);
1033
- else safe.push(stream);
1034
- }
1035
- return safe;
912
+ function hour_in_range(hour, start, end) {
913
+ return start < end ? hour >= start && hour < end : hour >= start || hour < end;
1036
914
  }
1037
- async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
1038
- const guarded = [];
1039
- const guard_events = /* @__PURE__ */ new Map();
1040
- await Promise.all(
1041
- safe.map(async (stream) => {
1042
- const info = stream_info.get(stream);
1043
- if (info.resumed_guard) {
1044
- guarded.push(stream);
1045
- guard_events.set(stream, { id: info.resumed_guard.id, stream });
1046
- return;
1047
- }
1048
- const committed = await tombstone2(stream, info.version, correlation);
1049
- if (committed) {
1050
- guarded.push(stream);
1051
- guard_events.set(stream, { id: committed.id, stream });
1052
- } else {
1053
- skipped.push(stream);
1054
- }
1055
- })
1056
- );
1057
- return { guarded, guard_events };
915
+ function is_dst_gap_open(now, timeZone, start) {
916
+ const hour = hour_in_zone(now, timeZone);
917
+ if (hour <= start) return false;
918
+ const prev = hour_in_zone(new Date(now.getTime() - 36e5), timeZone);
919
+ return prev === start - 1 && hour === start + 1;
1058
920
  }
1059
- async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
1060
- const seed_states = /* @__PURE__ */ new Map();
1061
- await Promise.all(
1062
- guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
1063
- const last_event_name = stream_info.get(stream).last_event_name;
1064
- const owner_state = event_to_state.get(last_event_name);
1065
- if (!owner_state) {
1066
- logger.error(
1067
- `Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
1068
- );
1069
- return;
1070
- }
1071
- const snap2 = await load2(owner_state, { stream });
1072
- seed_states.set(stream, snap2.state);
1073
- })
1074
- );
1075
- return seed_states;
921
+ function in_autoclose_window(window, now) {
922
+ if (!window) return true;
923
+ const hour = hour_in_zone(now, window.timeZone);
924
+ return hour_in_range(hour, window.start, window.end) || is_dst_gap_open(now, window.timeZone, window.start);
1076
925
  }
1077
- async function run_archive_callbacks(guarded, target_map) {
1078
- for (const stream of guarded) {
1079
- const archive_fn = target_map.get(stream)?.archive;
1080
- if (archive_fn) await archive_fn();
926
+ function next_window_open(window, now) {
927
+ const start_ms = now.getTime();
928
+ for (let h = 0; h <= 48; h++) {
929
+ const candidate = new Date(start_ms + h * 36e5);
930
+ if (hour_in_zone(candidate, window.timeZone) === window.start)
931
+ return candidate;
932
+ const boundary = new Date(
933
+ candidate.getTime() - candidate.getTime() % 36e5
934
+ );
935
+ if (boundary.getTime() >= start_ms && is_dst_gap_open(boundary, window.timeZone, window.start))
936
+ return boundary;
1081
937
  }
938
+ return new Date(start_ms + 864e5);
1082
939
  }
1083
- async function heads_still_guarded(guarded, guard_events, skipped) {
1084
- const heads = await store().query_stats(guarded, {});
1085
- return guarded.filter((stream) => {
1086
- if (heads.get(stream)?.head.id === guard_events.get(stream).id)
1087
- return true;
1088
- skipped.push(stream);
1089
- return false;
1090
- });
940
+
941
+ // src/internal/defer-config.ts
942
+ import { z as z2 } from "zod";
943
+ var DeferDurationSchema = z2.object({
944
+ days: z2.number().positive().optional(),
945
+ hours: z2.number().positive().optional(),
946
+ minutes: z2.number().positive().optional()
947
+ }).strict().refine((d) => d.days != null || d.hours != null || d.minutes != null, {
948
+ message: "defer: a duration needs at least one of days, hours, or minutes"
949
+ });
950
+ var DeferWhenSchema = z2.object({
951
+ after: DeferDurationSchema.optional(),
952
+ at: z2.date().optional()
953
+ }).strict().refine((w) => w.after === void 0 !== (w.at === void 0), {
954
+ message: "defer: specify exactly one of `after` or `at`"
955
+ });
956
+ function duration_ms(d) {
957
+ return (d.days ?? 0) * 864e5 + (d.hours ?? 0) * 36e5 + (d.minutes ?? 0) * 6e4;
1091
958
  }
1092
- async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
1093
- const trunc_targets = guarded.map((stream) => {
1094
- const snapshot = seed_states.get(stream);
1095
- const guard = guard_events.get(stream);
1096
- return {
1097
- stream,
1098
- snapshot,
1099
- meta: {
1100
- correlation,
1101
- causation: {
1102
- event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
1103
- }
1104
- }
1105
- };
1106
- });
1107
- const truncated = await store().truncate(trunc_targets);
1108
- await Promise.all(
1109
- guarded.map(async (stream) => {
1110
- const entry = truncated.get(stream);
1111
- const state2 = seed_states.get(stream);
1112
- if (state2 && entry) {
1113
- await cache().set(stream, {
1114
- stream,
1115
- state: state2,
1116
- version: entry.committed.version,
1117
- event_id: entry.committed.id,
1118
- patches: 0,
1119
- snaps: 1
1120
- });
1121
- } else {
1122
- await cache().invalidate(stream);
1123
- }
1124
- })
1125
- );
1126
- return truncated;
959
+ function resolve_defer_at(when, event) {
960
+ const parsed = DeferWhenSchema.parse(when);
961
+ if (parsed.after) return event.created.getTime() + duration_ms(parsed.after);
962
+ return parsed.at.getTime();
963
+ }
964
+ function assert_defer_when(when) {
965
+ DeferWhenSchema.parse(when);
966
+ }
967
+ function make_deferred(handler, schedule) {
968
+ const call = handler;
969
+ const deferred = async (event, stream, app) => {
970
+ const when = typeof schedule === "function" ? schedule(event) : schedule;
971
+ if (Date.now() < resolve_defer_at(when, event)) throw new DeferSignal(when);
972
+ return call(event, stream, app);
973
+ };
974
+ Object.defineProperty(deferred, "name", { value: handler.name });
975
+ return deferred;
1127
976
  }
1128
977
 
1129
- // src/internal/correlate-cycle.ts
1130
- import { createHash, randomUUID } from "crypto";
978
+ // src/internal/drain-cycle.ts
979
+ import { randomUUID } from "crypto";
1131
980
 
1132
- // src/internal/report-once.ts
1133
- function report_once(seen, key, message) {
1134
- if (seen.has(key)) return;
1135
- seen.add(key);
1136
- log().error(new Error(message));
981
+ // src/internal/backoff.ts
982
+ function compute_backoff_delay(retry, opts) {
983
+ if (!opts || opts.baseMs <= 0) return 0;
984
+ const r = Math.max(0, retry);
985
+ let delay;
986
+ switch (opts.strategy) {
987
+ case "fixed":
988
+ delay = opts.baseMs;
989
+ break;
990
+ case "linear":
991
+ delay = opts.baseMs * (r + 1);
992
+ break;
993
+ case "exponential":
994
+ delay = opts.baseMs * 2 ** r;
995
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
996
+ break;
997
+ default: {
998
+ const _never = opts.strategy;
999
+ throw new Error(`unknown backoff strategy: ${String(_never)}`);
1000
+ }
1001
+ }
1002
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
1003
+ return Math.max(0, Math.floor(delay));
1137
1004
  }
1138
1005
 
1139
- // src/internal/correlate-cycle.ts
1140
- var COLD_START_BACK_SCAN = 1e4;
1141
- var CORRELATION_LEASE_MS = 5e3;
1142
- var registry_key = (events) => {
1143
- const shape = Object.keys(events).filter((name) => events[name].reactions.size > 0).sort().map((name) => [name, [...events[name].reactions.keys()].sort()]);
1144
- return createHash("sha256").update(JSON.stringify(shape)).digest("hex").slice(0, 32);
1145
- };
1146
- var PATTERN_CACHE_SIZE = 32;
1147
- function report_undeclared_lane(seen, handler, target, lane, declared) {
1148
- report_once(
1149
- seen,
1150
- `lane|${handler}|${lane}`,
1151
- `Reaction "${handler}" resolved onto undeclared lane "${lane}" \u2014 for example target "${target}". Declared lanes: ${[...declared].map((l) => `"${l}"`).join(", ")}. No controller claims it, so the stream would never drain \u2014 running it on "default" instead. The equivalent static \`.to({ lane })\` is rejected at build; a dynamic resolver's lane is only knowable here.`
1152
- );
1153
- }
1154
- function report_lane_conflict(seen, handler, target, kept, dropped) {
1155
- report_once(
1156
- seen,
1157
- `conflict|${handler}|${kept}|${dropped}`,
1158
- `Reaction "${handler}" resolved lane "${dropped}" for a stream already on "${kept}" \u2014 for example "${target}". These are conflicting lane assignments from two dynamic resolutions at equal priority. Keeping "${kept}", the lane it was first discovered on \u2014 re-laning a live stream would move it out from under a worker holding its lease. The reaction resolving "${dropped}" runs inside the "${kept}" lane's budget, and a process restricted to "${dropped}" via onlyLanes never runs it. The equivalent static declaration is rejected at build; align the resolvers, or split the target.`
1159
- );
1160
- }
1161
- var CorrelateCycle = class {
1162
- _checkpoint = -1;
1163
- // Lease holder id; per instance, so a restart never inherits a stale claim.
1164
- _by = randomUUID();
1165
- _key;
1166
- // Local expiry of the held lease (0 = none); stale only costs a duplicate scan.
1167
- _lease_until = 0;
1168
- // Armed while `_raised` (bumped by a commit or notification) is ahead of
1169
- // `_lowered` (the raise count the last short page saw). Starts armed.
1170
- _raised = 1;
1171
- _lowered = 0;
1172
- // Bumped by a release (shutdown), so a scan or lease reply that was out
1173
- // across one writes nothing.
1174
- _epoch = 0;
1175
- // Passes in flight: a restore waits for them, and refuses new ones.
1176
- _passes = /* @__PURE__ */ new Set();
1177
- _restoring = false;
1178
- _init_promise;
1179
- // Bounded by `maxSubscribedStreams`; static records live apart so eviction
1180
- // never lets a resolution re-lane them.
1181
- _dynamic_subscriptions;
1182
- _static_subscriptions = /* @__PURE__ */ new Map();
1183
- _patterns = new LruMap(PATTERN_CACHE_SIZE);
1184
- _registry;
1185
- _static_targets;
1186
- _cd;
1187
- _on_init;
1188
- _declared_lanes;
1189
- // Report-once keys; per instance because `internal/` holds no module state.
1190
- _reported = /* @__PURE__ */ new Set();
1191
- constructor({
1192
- registry,
1193
- static_targets,
1194
- cd,
1195
- max_subscribed_streams,
1196
- declared_lanes,
1197
- shard,
1198
- on_init
1199
- }) {
1200
- this._key = shard ? `${registry_key(registry.events)}:${[...shard].sort().join(",")}` : registry_key(registry.events);
1201
- this._dynamic_subscriptions = new LruMap(max_subscribed_streams);
1202
- this._registry = registry;
1203
- this._declared_lanes = declared_lanes;
1204
- this._static_targets = static_targets;
1205
- this._cd = cd;
1206
- this._on_init = on_init;
1006
+ // src/internal/defer-timer.ts
1007
+ var MAX_TIMER_DELAY_MS = 2147483647;
1008
+ var DeferTimer = class {
1009
+ _due = /* @__PURE__ */ new Map();
1010
+ _timer;
1011
+ _on_wake;
1012
+ /**
1013
+ * @param on_wake - invoked once each time the earliest due-time elapses.
1014
+ * The consumer re-schedules for whatever is still parked.
1015
+ */
1016
+ constructor(on_wake) {
1017
+ this._on_wake = on_wake;
1207
1018
  }
1208
- get checkpoint() {
1209
- return this._checkpoint;
1019
+ /** Number of currently parked streams. */
1020
+ get size() {
1021
+ return this._due.size;
1210
1022
  }
1211
- /** A commit (local or remote) may have produced uncorrelated events. */
1212
- arm() {
1213
- this._raised++;
1023
+ /**
1024
+ * Park `stream` for a re-visit at `at` (ms since epoch). A plain
1025
+ * overwrite: the caller computes the authoritative next-visit for the
1026
+ * stream (the drain's `handle` already reconciles a stream's reactions
1027
+ * into one result per cycle), so there is no stale value to merge against.
1028
+ */
1029
+ set(stream, at) {
1030
+ this._due.set(stream, at);
1214
1031
  }
1215
- async _init() {
1216
- this._init_promise ??= this._run_init().catch((error) => {
1217
- this._init_promise = void 0;
1218
- throw error;
1219
- });
1220
- await this._init_promise;
1032
+ /** Drop `stream` from the parked set (e.g. on a successful ack or block). */
1033
+ delete(stream) {
1034
+ this._due.delete(stream);
1221
1035
  }
1222
- async _run_init() {
1223
- const { watermark, correlated_at } = await this._cd.subscribe([
1224
- ...this._static_targets
1225
- ]);
1226
- this._checkpoint = correlated_at >= 0 ? correlated_at : Math.max(-1, watermark - COLD_START_BACK_SCAN);
1227
- for (const { stream, priority = 0, lane } of this._static_targets) {
1228
- this._static_subscriptions.set(stream, {
1229
- priority,
1230
- lane
1231
- });
1232
- }
1233
- await this._on_init?.();
1036
+ /**
1037
+ * Drop every entry due at or before `before`. The drain calls it when a
1038
+ * pass that started at `before` claimed nothing: whatever was due by then
1039
+ * was handled elsewhere (another worker) or has no work left, so waking
1040
+ * for it again would spin.
1041
+ */
1042
+ prune(before) {
1043
+ for (const [stream, at] of this._due)
1044
+ if (at <= before) this._due.delete(stream);
1234
1045
  }
1235
1046
  /**
1236
- * Replace the log with no pass in flight, then cold-start: a pass that
1237
- * read the old log writes before the restore, never into the new one.
1047
+ * (Re)schedule the wake timer at the earliest pending due-time. Idempotent
1048
+ * — collapses many parked streams into a single timer. A no-op clears any
1049
+ * pending timer when the map is empty.
1050
+ *
1051
+ * The timer is `unref()`-ed so pending re-visits never keep the process
1052
+ * alive on their own.
1238
1053
  */
1239
- async restart(restore) {
1240
- this._restoring = true;
1241
- try {
1242
- await Promise.allSettled(this._passes);
1243
- await restore();
1244
- } finally {
1245
- this._restoring = false;
1054
+ schedule() {
1055
+ if (this._timer) clearTimeout(this._timer);
1056
+ if (this._due.size === 0) {
1057
+ this._timer = void 0;
1058
+ return;
1246
1059
  }
1247
- this._init_promise = void 0;
1248
- this._checkpoint = -1;
1249
- this._dynamic_subscriptions.clear();
1250
- this._static_subscriptions.clear();
1251
- }
1252
- /** Forget dynamic targets whose rows a full close deleted, so they re-subscribe. */
1253
- forget_subscribed(streams) {
1254
- for (const stream of streams) this._dynamic_subscriptions.delete(stream);
1060
+ let earliest = Number.POSITIVE_INFINITY;
1061
+ for (const t of this._due.values()) if (t < earliest) earliest = t;
1062
+ const delay = Math.min(
1063
+ Math.max(0, earliest - Date.now()),
1064
+ MAX_TIMER_DELAY_MS
1065
+ );
1066
+ this._timer = setTimeout(() => {
1067
+ this._timer = void 0;
1068
+ this._on_wake();
1069
+ }, delay);
1070
+ this._timer.unref();
1255
1071
  }
1256
- // Would the target's own `fetch` (filtered by `source`) return this event?
1257
- _in_fetch_window(source, stream) {
1258
- if (source === void 0 || source === stream) return true;
1259
- if (is_literal_source(source)) return false;
1260
- let pattern = this._patterns.get(source);
1261
- if (!pattern) {
1262
- pattern = new RegExp(source);
1263
- this._patterns.set(source, pattern);
1072
+ /** Cancel any pending wake timer. Idempotent. Leaves the parked set intact. */
1073
+ stop() {
1074
+ if (this._timer) {
1075
+ clearTimeout(this._timer);
1076
+ this._timer = void 0;
1264
1077
  }
1265
- return pattern.test(stream);
1266
1078
  }
1267
- /** Scan past the checkpoint, subscribe new targets and raise their work marks. */
1268
- async correlate(limit, lease = false) {
1269
- if (this._restoring) return { kind: "idle", last_id: this._checkpoint };
1270
- const pass = this._scan(limit, lease);
1271
- this._passes.add(pass);
1272
- try {
1273
- return await pass;
1274
- } finally {
1275
- this._passes.delete(pass);
1276
- }
1079
+ };
1080
+
1081
+ // src/internal/drain-ratio.ts
1082
+ var RATIO_MIN = 0.2;
1083
+ var RATIO_MAX = 0.8;
1084
+ var RATIO_DEFAULT = 0.5;
1085
+ function compute_lag_lead_ratio(handled, lagging, leading) {
1086
+ let lagging_handled = 0;
1087
+ let leading_handled = 0;
1088
+ for (const { lease, handled: count } of handled) {
1089
+ if (lease.lagging) lagging_handled += count;
1090
+ else leading_handled += count;
1277
1091
  }
1278
- async _scan(limit, lease) {
1279
- await this._init();
1280
- const epoch = this._epoch;
1281
- const idle = () => ({
1282
- kind: "idle",
1283
- last_id: this._checkpoint
1284
- });
1285
- if (this._raised === this._lowered) return idle();
1286
- const raised = this._raised;
1287
- const now = Date.now();
1288
- if (lease && now >= this._lease_until - CORRELATION_LEASE_MS / 2) {
1289
- const { correlating } = await this._cd.subscribe([], void 0, {
1290
- key: this._key,
1291
- by: this._by,
1292
- millis: CORRELATION_LEASE_MS
1293
- });
1294
- if (this._epoch !== epoch) return idle();
1295
- this._lease_until = correlating ? now + CORRELATION_LEASE_MS : 0;
1296
- if (correlating === false) return idle();
1297
- }
1298
- const after = this._checkpoint;
1299
- const correlated = /* @__PURE__ */ new Map();
1300
- let last_id = after;
1301
- let stopped = false;
1302
- const found = await store().query(
1303
- (event) => {
1304
- if (stopped) return;
1305
- const register = this._registry.events[event.name];
1306
- if (register) {
1307
- for (const reaction of register.reactions.values()) {
1308
- let resolved;
1309
- try {
1310
- resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1311
- } catch (error) {
1312
- stopped = true;
1313
- report_once(
1314
- this._reported,
1315
- `resolver|${reaction.handler.name}|${event.id}`,
1316
- `Resolver of reaction "${reaction.handler.name}" threw on ${String(event.name)} #${event.id} (stream "${event.stream}"): ${String(error)}. Correlation stops before this event until the resolver stops throwing; work already found keeps draining.`
1317
- );
1318
- return;
1319
- }
1320
- if (!resolved) continue;
1321
- let lane = resolved.lane;
1322
- if (lane !== void 0 && !this._declared_lanes.has(lane)) {
1323
- report_undeclared_lane(
1324
- this._reported,
1325
- reaction.handler.name,
1326
- resolved.target,
1327
- lane,
1328
- this._declared_lanes
1329
- );
1330
- lane = void 0;
1331
- }
1332
- const fixed = this._static_subscriptions.get(resolved.target);
1333
- const recorded = fixed ?? this._dynamic_subscriptions.get(resolved.target);
1334
- const priority = resolved.priority ?? 0;
1335
- const upgraded = !recorded || !fixed && priority > recorded.priority;
1336
- const carried = upgraded ? { priority, lane } : { priority: recorded.priority, lane: recorded.lane };
1337
- const entry = correlated.get(resolved.target) || {
1338
- source: resolved.source,
1339
- priority: carried.priority,
1340
- lane: carried.lane,
1341
- upgraded,
1342
- missed: !recorded,
1343
- handler: reaction.handler.name,
1344
- correlated_at: void 0
1345
- };
1346
- const seen_in_scan = correlated.has(resolved.target);
1347
- const held_lane = (seen_in_scan ? entry.lane : recorded?.lane) ?? DEFAULT_LANE;
1348
- const held_priority = seen_in_scan ? entry.priority : recorded?.priority;
1349
- const resolved_lane = lane ?? DEFAULT_LANE;
1350
- if (held_priority === priority && held_lane !== resolved_lane)
1351
- report_lane_conflict(
1352
- this._reported,
1353
- reaction.handler.name,
1354
- resolved.target,
1355
- held_lane,
1356
- resolved_lane
1357
- );
1358
- if (carried.priority > entry.priority) {
1359
- entry.priority = carried.priority;
1360
- entry.lane = carried.lane;
1361
- entry.upgraded = upgraded;
1362
- entry.handler = reaction.handler.name;
1363
- }
1364
- if (this._in_fetch_window(resolved.source, event.stream))
1365
- entry.correlated_at = event.id;
1366
- correlated.set(resolved.target, entry);
1367
- }
1368
- }
1369
- last_id = event.id;
1370
- },
1371
- // Resolvers see `pii: null`: correlate runs actor-less, and one
1372
- // undecryptable row would otherwise stop every stream's reactions.
1373
- { after, limit, with_pii: false }
1374
- );
1375
- if (this._declared_lanes.size > 1) await this._recall(correlated);
1376
- const read = last_id > after;
1377
- if (this._epoch !== epoch) return idle();
1378
- const streams = [];
1379
- for (const [stream, entry] of correlated) {
1380
- if (entry.upgraded || entry.correlated_at !== void 0)
1381
- streams.push({
1382
- stream,
1383
- source: entry.source,
1384
- priority: entry.priority,
1385
- lane: entry.lane,
1386
- correlated_at: entry.correlated_at
1387
- });
1388
- }
1389
- if (streams.length) {
1390
- const renewed_at = Date.now();
1391
- const { subscribed, correlating } = await this._cd.subscribe(
1392
- streams,
1393
- last_id,
1394
- lease ? { key: this._key, by: this._by, millis: CORRELATION_LEASE_MS } : void 0
1395
- );
1396
- if (lease && correlating !== false)
1397
- this._lease_until = renewed_at + CORRELATION_LEASE_MS;
1398
- const marked = streams.filter(
1399
- (entry) => entry.correlated_at !== void 0
1400
- ).length;
1401
- this._checkpoint = last_id;
1402
- for (const { stream, priority, lane } of streams) {
1403
- if (correlated.get(stream)?.upgraded)
1404
- this._dynamic_subscriptions.set(stream, {
1405
- priority,
1406
- lane
1407
- });
1408
- }
1409
- return { kind: "scanned", last_id, subscribed, marked, read };
1410
- }
1411
- this._checkpoint = last_id;
1412
- if (!stopped && found !== limit) this._lowered = raised;
1413
- return { kind: "scanned", last_id, subscribed: 0, marked: 0, read };
1414
- }
1415
- // Read the row of each target this scan had no record of (new or evicted),
1416
- // so a resolution that doesn't beat its priority keeps the row's lane.
1417
- // Only called with more than one lane declared.
1418
- async _recall(correlated) {
1419
- const missed = [...correlated].filter(([, entry]) => entry.missed);
1420
- await Promise.all(
1421
- missed.map(async ([stream, entry]) => {
1422
- let row;
1423
- await store().query_streams(
1424
- (position) => {
1425
- row = position;
1426
- },
1427
- { stream, stream_exact: true, limit: 1 }
1428
- );
1429
- if (!row) return;
1430
- this._dynamic_subscriptions.set(stream, {
1431
- priority: row.priority,
1432
- lane: row.lane
1433
- });
1434
- if (entry.priority > row.priority) return;
1435
- const held_lane = row.lane ?? DEFAULT_LANE;
1436
- const resolved_lane = entry.lane ?? DEFAULT_LANE;
1437
- if (entry.priority === row.priority && held_lane !== resolved_lane)
1438
- report_lane_conflict(
1439
- this._reported,
1440
- entry.handler,
1441
- stream,
1442
- held_lane,
1443
- resolved_lane
1444
- );
1445
- entry.priority = row.priority;
1446
- entry.lane = row.lane;
1447
- entry.upgraded = false;
1448
- })
1449
- );
1450
- }
1451
- /** Release the lease early by re-acquiring it for zero ms. Best-effort. */
1452
- async release_correlation() {
1453
- try {
1454
- this._lease_until = 0;
1455
- this._epoch++;
1456
- await this._cd.subscribe([], void 0, {
1457
- key: this._key,
1458
- by: this._by,
1459
- millis: 0
1460
- });
1461
- } catch (error) {
1462
- log().warn(
1463
- `Could not hand back the correlation lease during shutdown; it expires on its own within ${CORRELATION_LEASE_MS}ms and correlation resumes normally after that. On a single-writer store this usually means another connection holds the database. Cause: ${String(error.cause ?? error)}`
1464
- );
1465
- }
1466
- }
1467
- };
1092
+ const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1093
+ const leading_avg = leading > 0 ? leading_handled / leading : 0;
1094
+ const total = lagging_avg + leading_avg;
1095
+ if (total === 0) return RATIO_DEFAULT;
1096
+ return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1097
+ }
1098
+
1099
+ // src/internal/report-once.ts
1100
+ function report_once(seen, key, message) {
1101
+ if (seen.has(key)) return;
1102
+ seen.add(key);
1103
+ log().error(new Error(message));
1104
+ }
1468
1105
 
1469
1106
  // src/internal/correlation-id.ts
1470
1107
  import { randomInt } from "crypto";
@@ -1490,134 +1127,13 @@ function close_correlation(correlator, actor) {
1490
1127
  });
1491
1128
  }
1492
1129
 
1493
- // src/internal/drain-cycle.ts
1494
- import { randomUUID as randomUUID2 } from "crypto";
1495
-
1496
- // src/internal/backoff.ts
1497
- function compute_backoff_delay(retry, opts) {
1498
- if (!opts || opts.baseMs <= 0) return 0;
1499
- const r = Math.max(0, retry);
1500
- let delay;
1501
- switch (opts.strategy) {
1502
- case "fixed":
1503
- delay = opts.baseMs;
1504
- break;
1505
- case "linear":
1506
- delay = opts.baseMs * (r + 1);
1507
- break;
1508
- case "exponential":
1509
- delay = opts.baseMs * 2 ** r;
1510
- if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
1511
- break;
1512
- default: {
1513
- const _never = opts.strategy;
1514
- throw new Error(`unknown backoff strategy: ${String(_never)}`);
1515
- }
1516
- }
1517
- if (opts.jitter) delay = delay * (0.5 + Math.random());
1518
- return Math.max(0, Math.floor(delay));
1519
- }
1520
-
1521
- // src/internal/defer-timer.ts
1522
- var MAX_TIMER_DELAY_MS = 2147483647;
1523
- var DeferTimer = class {
1524
- _due = /* @__PURE__ */ new Map();
1525
- _timer;
1526
- _on_wake;
1527
- /**
1528
- * @param on_wake - invoked once each time the earliest due-time elapses.
1529
- * The consumer re-schedules for whatever is still parked.
1530
- */
1531
- constructor(on_wake) {
1532
- this._on_wake = on_wake;
1533
- }
1534
- /** Number of currently parked streams. */
1535
- get size() {
1536
- return this._due.size;
1537
- }
1538
- /**
1539
- * Park `stream` for a re-visit at `at` (ms since epoch). A plain
1540
- * overwrite: the caller computes the authoritative next-visit for the
1541
- * stream (the drain's `handle` already reconciles a stream's reactions
1542
- * into one result per cycle), so there is no stale value to merge against.
1543
- */
1544
- set(stream, at) {
1545
- this._due.set(stream, at);
1546
- }
1547
- /** Drop `stream` from the parked set (e.g. on a successful ack or block). */
1548
- delete(stream) {
1549
- this._due.delete(stream);
1550
- }
1551
- /**
1552
- * Drop every entry due at or before `before`. The drain calls it when a
1553
- * pass that started at `before` claimed nothing: whatever was due by then
1554
- * was handled elsewhere (another worker) or has no work left, so waking
1555
- * for it again would spin.
1556
- */
1557
- prune(before) {
1558
- for (const [stream, at] of this._due)
1559
- if (at <= before) this._due.delete(stream);
1560
- }
1561
- /**
1562
- * (Re)schedule the wake timer at the earliest pending due-time. Idempotent
1563
- * — collapses many parked streams into a single timer. A no-op clears any
1564
- * pending timer when the map is empty.
1565
- *
1566
- * The timer is `unref()`-ed so pending re-visits never keep the process
1567
- * alive on their own.
1568
- */
1569
- schedule() {
1570
- if (this._timer) clearTimeout(this._timer);
1571
- if (this._due.size === 0) {
1572
- this._timer = void 0;
1573
- return;
1574
- }
1575
- let earliest = Number.POSITIVE_INFINITY;
1576
- for (const t of this._due.values()) if (t < earliest) earliest = t;
1577
- const delay = Math.min(
1578
- Math.max(0, earliest - Date.now()),
1579
- MAX_TIMER_DELAY_MS
1580
- );
1581
- this._timer = setTimeout(() => {
1582
- this._timer = void 0;
1583
- this._on_wake();
1584
- }, delay);
1585
- this._timer.unref();
1586
- }
1587
- /** Cancel any pending wake timer. Idempotent. Leaves the parked set intact. */
1588
- stop() {
1589
- if (this._timer) {
1590
- clearTimeout(this._timer);
1591
- this._timer = void 0;
1592
- }
1593
- }
1594
- };
1595
-
1596
- // src/internal/drain-ratio.ts
1597
- var RATIO_MIN = 0.2;
1598
- var RATIO_MAX = 0.8;
1599
- var RATIO_DEFAULT = 0.5;
1600
- function compute_lag_lead_ratio(handled, lagging, leading) {
1601
- let lagging_handled = 0;
1602
- let leading_handled = 0;
1603
- for (const { lease, handled: count } of handled) {
1604
- if (lease.lagging) lagging_handled += count;
1605
- else leading_handled += count;
1606
- }
1607
- const lagging_avg = lagging > 0 ? lagging_handled / lagging : 0;
1608
- const leading_avg = leading > 0 ? leading_handled / leading : 0;
1609
- const total = lagging_avg + leading_avg;
1610
- if (total === 0) return RATIO_DEFAULT;
1611
- return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
1612
- }
1613
-
1614
1130
  // src/internal/drain-ops.ts
1615
1131
  var claim = (lagging, leading, by, millis, lane) => store().claim(lagging, leading, by, millis, lane);
1616
1132
  async function fetch(leased, eventLimit) {
1617
1133
  return Promise.all(
1618
1134
  leased.map(async ({ stream, source, at, lagging }) => {
1619
1135
  const events = [];
1620
- const stream_exact = source !== void 0 && is_literal_source(source) ? true : void 0;
1136
+ const stream_exact = source !== void 0 && is_literal_source(source, stream) ? true : void 0;
1621
1137
  try {
1622
1138
  await store().query((e) => events.push(e), {
1623
1139
  stream: source,
@@ -1687,14 +1203,18 @@ async function snap(snapshot) {
1687
1203
  const { id, stream, name, meta, version } = snapshot.event;
1688
1204
  try {
1689
1205
  const [committed] = await store().commit(
1690
- stream,
1691
- [{ name: SNAP_EVENT, data: snapshot.state }],
1206
+ [
1207
+ {
1208
+ stream,
1209
+ msgs: [{ name: SNAP_EVENT, data: snapshot.state }],
1210
+ // IMPORTANT! - state events are committed right after the snapshot event
1211
+ expectedVersion: version
1212
+ }
1213
+ ],
1692
1214
  {
1693
1215
  correlation: meta.correlation,
1694
1216
  causation: { event: { id, name, stream } }
1695
- },
1696
- version
1697
- // IMPORTANT! - state events are committed right after the snapshot event
1217
+ }
1698
1218
  );
1699
1219
  return committed;
1700
1220
  } catch (error) {
@@ -1707,10 +1227,14 @@ async function snap(snapshot) {
1707
1227
  async function tombstone(stream, expectedVersion, correlation) {
1708
1228
  try {
1709
1229
  const [committed] = await store().commit(
1710
- stream,
1711
- [{ name: TOMBSTONE_EVENT, data: {} }],
1712
- { correlation, causation: {} },
1713
- expectedVersion
1230
+ [
1231
+ {
1232
+ stream,
1233
+ msgs: [{ name: TOMBSTONE_EVENT, data: {} }],
1234
+ expectedVersion
1235
+ }
1236
+ ],
1237
+ { correlation, causation: {} }
1714
1238
  );
1715
1239
  return committed;
1716
1240
  } catch (error) {
@@ -1912,6 +1436,38 @@ async function load(me, target, callback, patch_fn = bare_patch) {
1912
1436
  replayed
1913
1437
  };
1914
1438
  }
1439
+ async function load_others(loads, payload, own, actor, patch_fn, refuse) {
1440
+ const streams = loads.map((l) => l.target(payload, own.state));
1441
+ streams.forEach((stream, i) => {
1442
+ if (stream === void 0) return;
1443
+ if (typeof stream !== "string" || !stream)
1444
+ throw refuse(`loads "${loads[i].name}" from an empty stream name`);
1445
+ if (stream === own.stream) throw refuse(`loads its own stream "${stream}"`);
1446
+ if (streams.indexOf(stream) !== i)
1447
+ throw refuse(`loads stream "${stream}" twice`);
1448
+ });
1449
+ return Promise.all(
1450
+ loads.map(async (l, i) => {
1451
+ const stream = streams[i];
1452
+ if (stream === void 0) return void 0;
1453
+ const snapshot = await load(
1454
+ l.state,
1455
+ { stream, actor },
1456
+ void 0,
1457
+ patch_fn
1458
+ );
1459
+ if (snapshot.event?.name === TOMBSTONE_EVENT)
1460
+ throw new StreamClosedError(stream);
1461
+ return {
1462
+ state: l.state,
1463
+ stream,
1464
+ snapshot,
1465
+ expected: snapshot.version,
1466
+ events: []
1467
+ };
1468
+ })
1469
+ );
1470
+ }
1915
1471
  async function action(me, action2, target, payload, options, patch_fn = bare_patch) {
1916
1472
  const { stream, expectedVersion, actor } = target;
1917
1473
  if (!stream) throw new Error("Missing target stream");
@@ -1920,6 +1476,7 @@ async function action(me, action2, target, payload, options, patch_fn = bare_pat
1920
1476
  const validated = validate(action2, payload, me.actions[action2]);
1921
1477
  const opts = me.options?.[action2];
1922
1478
  const max_retries = opts?.maxRetries ?? 0;
1479
+ const loads = me.loads?.[action2] ?? [];
1923
1480
  for (let attempt = 0; ; attempt++) {
1924
1481
  try {
1925
1482
  const snapshot = await load(
@@ -1930,34 +1487,64 @@ async function action(me, action2, target, payload, options, patch_fn = bare_pat
1930
1487
  );
1931
1488
  if (snapshot.event?.name === TOMBSTONE_EVENT)
1932
1489
  throw new StreamClosedError(stream);
1933
- const expected = expectedVersion ?? snapshot.version;
1934
- for (const { valid, description } of me.given?.[action2] ?? [])
1935
- if (!valid(snapshot.state, actor))
1936
- throw new InvariantError(
1490
+ const own = {
1491
+ state: me,
1492
+ stream,
1493
+ snapshot,
1494
+ expected: expectedVersion ?? snapshot.version,
1495
+ events: []
1496
+ };
1497
+ const others = loads.length ? await load_others(
1498
+ loads,
1499
+ validated,
1500
+ { stream, state: snapshot.state },
1501
+ target.actor,
1502
+ patch_fn,
1503
+ (description) => new PreconditionError(
1504
+ action2,
1505
+ validated,
1506
+ target,
1507
+ snapshot,
1508
+ description
1509
+ )
1510
+ ) : [];
1511
+ const loaded = Object.fromEntries(
1512
+ others.map((p, i) => [loads[i].name, p?.snapshot.state])
1513
+ );
1514
+ for (const { holds, description } of me.requires?.[action2] ?? [])
1515
+ if (!holds(validated, snapshot.state, actor, loaded))
1516
+ throw new PreconditionError(
1937
1517
  action2,
1938
1518
  validated,
1939
1519
  target,
1940
1520
  snapshot,
1941
1521
  description
1942
1522
  );
1943
- for (const { holds, description } of me.requires?.[action2] ?? [])
1944
- if (!holds(validated, snapshot.state, actor))
1945
- throw new PreconditionError(
1946
- action2,
1947
- validated,
1948
- target,
1949
- snapshot,
1950
- description
1951
- );
1952
- const result = me.on[action2](validated, snapshot, target);
1523
+ const result = me.on[action2](validated, snapshot, target, loaded);
1953
1524
  if (!result || Array.isArray(result) && !result.length)
1954
1525
  return [snapshot];
1955
1526
  const tuples = Array.isArray(result[0]) ? result : [result];
1956
- const events = tuples.map(([name, data]) => ({
1957
- name,
1958
- data: validate(name, data, me.events[name])
1959
- }));
1960
- const emitted = events.map((e) => me.message(e));
1527
+ for (const [name, data] of tuples) {
1528
+ const event = name;
1529
+ const i = loads.findIndex((l) => l.events.includes(event));
1530
+ if (i >= 0 && !others[i]) continue;
1531
+ const to = i >= 0 ? others[i] : name in me.events ? own : void 0;
1532
+ if (!to)
1533
+ throw new Error(
1534
+ `Action "${action2}" emitted "${event}", which isn't an event of "${me.name}" and isn't listed on any of its loads`
1535
+ );
1536
+ to.events.push({
1537
+ name: event,
1538
+ data: validate(event, data, to.state.events[event])
1539
+ });
1540
+ }
1541
+ if (!own.events.length)
1542
+ throw new Error(
1543
+ `Action "${action2}" appends only to the streams it loads; it must append an event to its own stream too`
1544
+ );
1545
+ const appends = [own, ...others].filter(
1546
+ (p) => !!p?.events.length
1547
+ );
1961
1548
  const meta = {
1962
1549
  correlation: reactingTo?.meta.correlation || correlator({
1963
1550
  action: action2,
@@ -1978,61 +1565,86 @@ async function action(me, action2, target, payload, options, patch_fn = bare_pat
1978
1565
  } : void 0
1979
1566
  }
1980
1567
  };
1981
- let { state: state2, patches } = snapshot;
1982
1568
  const created = /* @__PURE__ */ new Date();
1983
- const folded = events.map((e, i) => {
1984
- const event = {
1985
- ...e,
1986
- id: -1,
1987
- stream,
1988
- version: snapshot.version + i + 1,
1989
- created,
1990
- meta
1991
- };
1992
- const patch2 = me.patch[event.name](event, state2);
1993
- state2 = patch_fn(me, state2, patch2, event);
1994
- patches++;
1995
- return { state: state2, patch: patch2, patches };
1569
+ const folds = appends.map((p) => {
1570
+ let { state: state2, patches } = p.snapshot;
1571
+ const folded = p.events.map((e, i) => {
1572
+ const event = {
1573
+ ...e,
1574
+ id: -1,
1575
+ stream: p.stream,
1576
+ version: p.snapshot.version + i + 1,
1577
+ created,
1578
+ meta
1579
+ };
1580
+ const patch2 = p.state.patch[event.name](event, state2);
1581
+ state2 = patch_fn(p.state, state2, patch2, event);
1582
+ patches++;
1583
+ return { state: state2, patch: patch2, patches };
1584
+ });
1585
+ check_invariants(
1586
+ `${p.state.name}.${action2}`,
1587
+ state2,
1588
+ p.state.state,
1589
+ p.state.pii_aware
1590
+ );
1591
+ return folded;
1996
1592
  });
1997
- check_invariants(
1998
- `${me.name}.${action2}`,
1999
- state2,
2000
- me.state,
2001
- me.pii_aware
2002
- );
2003
1593
  let committed;
2004
1594
  try {
2005
- committed = await store().commit(stream, emitted, meta, expected);
1595
+ committed = await store().commit(
1596
+ appends.map((p) => ({
1597
+ stream: p.stream,
1598
+ msgs: p.events.map((e) => p.state.message(e)),
1599
+ expectedVersion: p.expected
1600
+ })),
1601
+ meta
1602
+ );
2006
1603
  } catch (error) {
2007
- if (error instanceof ConcurrencyError) void cache_drop(stream);
1604
+ if (error instanceof ConcurrencyError) void cache_drop(error.stream);
2008
1605
  throw error;
2009
1606
  }
2010
- const snapshots = committed.map((row, i) => ({
2011
- event: { ...row, data: events[i].data },
2012
- state: folded[i].state,
2013
- version: row.version,
2014
- id: row.id,
2015
- patches: folded[i].patches,
2016
- snaps: snapshot.snaps,
2017
- patch: folded[i].patch,
2018
- cache_hit: snapshot.cache_hit,
2019
- replayed: snapshot.replayed
2020
- }));
2021
- const last = snapshots.at(-1);
2022
- const snap_event = me.snap?.(last) ? await snap(last) : void 0;
2023
- if (!me.pii_aware)
2024
- void cache_put(stream, {
2025
- stream,
2026
- state: last.state,
2027
- version: snap_event?.version ?? last.event.version,
2028
- event_id: snap_event?.id ?? last.event.id,
2029
- patches: snap_event ? 0 : last.patches,
2030
- snaps: snap_event ? last.snaps + 1 : last.snaps
1607
+ let row = 0;
1608
+ const snapshots = [];
1609
+ for (const [a, append] of appends.entries()) {
1610
+ const { snapshot: base, state: owner } = append;
1611
+ const appended = append.events.map((e, i) => {
1612
+ const stored = committed[row++];
1613
+ const fold = folds[a][i];
1614
+ return {
1615
+ event: { ...stored, data: e.data },
1616
+ state: fold.state,
1617
+ version: stored.version,
1618
+ id: stored.id,
1619
+ patches: fold.patches,
1620
+ snaps: base.snaps,
1621
+ patch: fold.patch,
1622
+ cache_hit: base.cache_hit,
1623
+ replayed: base.replayed
1624
+ };
2031
1625
  });
1626
+ const last = appended.at(-1);
1627
+ const snap_event = owner.snap?.(last) ? await snap(last) : void 0;
1628
+ if (!owner.pii_aware)
1629
+ void cache_put(append.stream, {
1630
+ stream: append.stream,
1631
+ state: last.state,
1632
+ version: snap_event?.version ?? last.event.version,
1633
+ event_id: snap_event?.id ?? last.event.id,
1634
+ patches: snap_event ? 0 : last.patches,
1635
+ snaps: snap_event ? last.snaps + 1 : last.snaps
1636
+ });
1637
+ if (snap_event)
1638
+ appended[appended.length - 1] = {
1639
+ ...last,
1640
+ version: snap_event.version
1641
+ };
1642
+ snapshots.push(...appended);
1643
+ }
2032
1644
  return snapshots;
2033
1645
  } catch (error) {
2034
1646
  if (!(error instanceof ConcurrencyError)) throw error;
2035
- if (expectedVersion !== void 0) throw error;
1647
+ if (expectedVersion !== void 0 && error.stream === stream) throw error;
2036
1648
  if (attempt >= max_retries) throw error;
2037
1649
  if (opts?.backoff) {
2038
1650
  const delay_ms = compute_backoff_delay(attempt, opts.backoff);
@@ -2139,14 +1751,14 @@ function build_es(logger, correlator = default_correlator, patch_fn = bare_patch
2139
1751
  }),
2140
1752
  action: traced(
2141
1753
  bound_action,
2142
- (snapshots, _me, _action, target) => {
1754
+ (snapshots) => {
2143
1755
  if (snapshots[0].patch)
2144
1756
  logger.trace(
2145
1757
  snapshots.map((s) => s.event.data),
2146
1758
  es_caption(
2147
1759
  "committed",
2148
1760
  C_ORANGE,
2149
- `${target.stream}.${snapshots.map((s) => s.event.name).join(", ")}`
1761
+ snapshots.map((s) => `${s.event.stream}.${s.event.name}`).join(", ")
2150
1762
  )
2151
1763
  );
2152
1764
  },
@@ -2280,7 +1892,7 @@ async function run_drain_cycle(deps, cycle) {
2280
1892
  const leased = await ops.claim(
2281
1893
  lagging,
2282
1894
  leading,
2283
- randomUUID2(),
1895
+ randomUUID(),
2284
1896
  leaseMillis,
2285
1897
  lane
2286
1898
  );
@@ -2467,68 +2079,958 @@ var DrainController = class {
2467
2079
  clearTimeout(this._worker);
2468
2080
  this._worker = void 0;
2469
2081
  }
2470
- this._defer.stop();
2082
+ this._defer.stop();
2083
+ }
2084
+ async drain(options = {}) {
2085
+ if (!this.armed || this._stopped || this._inflight)
2086
+ return EMPTY_DRAIN;
2087
+ const d = this._deps.defaults ?? {};
2088
+ const streamLimit = d.streamLimit ?? options.streamLimit ?? DEFAULT_STREAM_LIMIT;
2089
+ const eventLimit = options.eventLimit ?? DEFAULT_EVENT_LIMIT;
2090
+ const leaseMillis = d.leaseMillis ?? options.leaseMillis ?? DEFAULT_LEASE_MILLIS;
2091
+ if (this._deps.breaker.state(Date.now()) === "open")
2092
+ return EMPTY_DRAIN;
2093
+ const pass = this._pass(streamLimit, eventLimit, leaseMillis).finally(
2094
+ () => {
2095
+ this._inflight = void 0;
2096
+ }
2097
+ );
2098
+ this._inflight = pass;
2099
+ return pass;
2100
+ }
2101
+ // One claim → fetch → handle → ack pass. Never throws.
2102
+ async _pass(streamLimit, eventLimit, leaseMillis) {
2103
+ try {
2104
+ const lagging = Math.ceil(streamLimit * this._ratio);
2105
+ const leading = streamLimit - lagging;
2106
+ const started = Date.now();
2107
+ const raised = this._raised;
2108
+ const cycle = await run_drain_cycle(this._deps, {
2109
+ lagging,
2110
+ leading,
2111
+ eventLimit,
2112
+ leaseMillis,
2113
+ misrouted: this._misrouted,
2114
+ store_failing: this._deps.breaker.failing
2115
+ });
2116
+ if (!cycle) {
2117
+ this._defer.prune(started);
2118
+ this._deps.breaker.passed();
2119
+ this._lowered = raised;
2120
+ return EMPTY_DRAIN;
2121
+ }
2122
+ const { leased, fetched, handled, acked, blocked, closeable } = cycle;
2123
+ trace_cycle(this._deps.logger, leased, fetched, handled, acked, blocked);
2124
+ this._ratio = compute_lag_lead_ratio(handled, lagging, leading);
2125
+ for (const lease of acked) this._defer.delete(lease.stream);
2126
+ for (const lease of blocked) this._defer.delete(lease.stream);
2127
+ for (const h of handled) {
2128
+ const next = h.defer ?? h.next_attempt_at;
2129
+ if (next !== void 0) this._defer.set(h.lease.stream, next);
2130
+ }
2131
+ if (acked.length) this._deps.on_acked(acked);
2132
+ if (closeable.length) await this._deps.on_close(closeable);
2133
+ this._deps.breaker.passed();
2134
+ return { fetched, leased, acked, blocked };
2135
+ } catch (error) {
2136
+ this._defer.set(
2137
+ RETRY,
2138
+ Date.now() + Math.max(leaseMillis, this._deps.breaker.cooldown_ms)
2139
+ );
2140
+ this._deps.breaker.failed(Date.now(), error);
2141
+ return EMPTY_DRAIN;
2142
+ } finally {
2143
+ if (!this._stopped) this._defer.schedule();
2144
+ }
2145
+ }
2146
+ };
2147
+
2148
+ // src/internal/reaction-dispatch.ts
2149
+ var CloseSignal = class extends Error {
2150
+ /**
2151
+ * Stream to close. Omitted → the reaction's own lease stream (a self-close).
2152
+ * The synthesized autoclose reaction sets it to the aggregate stream, since
2153
+ * its lease runs on a synthetic `__autoclose__` target.
2154
+ */
2155
+ stream;
2156
+ /** Optional archive callback to run while the stream is guarded. */
2157
+ archive;
2158
+ /**
2159
+ * Watermark to ack the requesting reaction to before the close runs. The
2160
+ * close-cycle safety guard skips a stream whose subscriptions (matched by
2161
+ * source) lag the head; the autoclose reaction's `source` is the aggregate,
2162
+ * so it must advance its own watermark to the live head id it evaluated
2163
+ * against — otherwise it blocks its own close. Defaults to the triggering
2164
+ * event id when omitted.
2165
+ */
2166
+ at;
2167
+ /**
2168
+ * Windowed close: prune events older than this cutoff behind
2169
+ * the closest safe snapshot instead of retiring the stream. Thrown by
2170
+ * the autoclose reaction of a `.autocloses({ keep })` state; flows
2171
+ * into {@link CloseTarget.before}. Omitted → a full close.
2172
+ */
2173
+ before;
2174
+ /**
2175
+ * Revisit the requesting reaction at this time. Only useful with an `at`
2176
+ * below the head: a reaction runs only when an event is waiting for it.
2177
+ */
2178
+ defer;
2179
+ constructor(opts) {
2180
+ super("reaction requested close");
2181
+ this.name = "CloseSignal";
2182
+ this.stream = opts?.stream;
2183
+ this.archive = opts?.archive;
2184
+ this.at = opts?.at;
2185
+ this.before = opts?.before;
2186
+ this.defer = opts?.defer;
2187
+ }
2188
+ };
2189
+ function build_handle(deps) {
2190
+ const { logger, reaction_scope } = deps;
2191
+ return async (lease, payloads) => {
2192
+ if (payloads.length === 0) return { lease, handled: 0, acked_at: lease.at };
2193
+ const stream = lease.stream;
2194
+ const last_index_of = /* @__PURE__ */ new Map();
2195
+ for (let i = 0; i < payloads.length; i++)
2196
+ last_index_of.set(payloads[i].event.id, i);
2197
+ let at = lease.at;
2198
+ let handled = 0;
2199
+ if (lease.retry > 0)
2200
+ logger.warn(
2201
+ `Retrying ${stream}@${payloads.at(0).event.id} (${lease.retry}).`
2202
+ );
2203
+ for (let i = 0; i < payloads.length; i++) {
2204
+ const payload = payloads[i];
2205
+ const { event, handler } = payload;
2206
+ try {
2207
+ await reaction_scope.run(
2208
+ event,
2209
+ () => handler(event, stream, reaction_scope.app)
2210
+ );
2211
+ if (last_index_of.get(event.id) === i) {
2212
+ at = event.id;
2213
+ handled++;
2214
+ }
2215
+ } catch (error) {
2216
+ if (error instanceof DeferSignal) {
2217
+ let defer;
2218
+ try {
2219
+ defer = resolve_defer_at(error.when, event);
2220
+ } catch (invalid) {
2221
+ return finalize(
2222
+ lease,
2223
+ handled,
2224
+ at,
2225
+ invalid,
2226
+ payload.options,
2227
+ logger,
2228
+ event.id
2229
+ );
2230
+ }
2231
+ return { lease, handled, acked_at: at, defer };
2232
+ }
2233
+ if (error instanceof CloseSignal)
2234
+ return {
2235
+ lease,
2236
+ handled: handled + 1,
2237
+ // Advance to the live head the handler evaluated against (when
2238
+ // provided) so the close-cycle guard sees this reaction caught up.
2239
+ acked_at: error.at ?? event.id,
2240
+ defer: error.defer?.getTime(),
2241
+ // Close the signalled stream (the autoclose reaction's aggregate,
2242
+ // which differs from its synthetic lease stream); a self-closing
2243
+ // user reaction omits it and closes its own lease stream. A
2244
+ // carried `before` makes it a windowed close (prune, not retire).
2245
+ close: {
2246
+ stream: error.stream ?? stream,
2247
+ archive: error.archive,
2248
+ before: error.before
2249
+ }
2250
+ };
2251
+ return finalize(
2252
+ lease,
2253
+ handled,
2254
+ at,
2255
+ error,
2256
+ payload.options,
2257
+ logger,
2258
+ event.id
2259
+ );
2260
+ }
2261
+ }
2262
+ return finalize(lease, handled, at, void 0, payloads[0].options, logger);
2263
+ };
2264
+ }
2265
+ function build_handle_batch(logger) {
2266
+ return async (lease, payloads, batchHandler) => {
2267
+ const stream = lease.stream;
2268
+ const events = [];
2269
+ const seen = /* @__PURE__ */ new Set();
2270
+ for (const p of payloads) {
2271
+ if (seen.has(p.event.id)) continue;
2272
+ seen.add(p.event.id);
2273
+ events.push(p.event);
2274
+ }
2275
+ const options = payloads[0].options;
2276
+ if (lease.retry > 0)
2277
+ logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
2278
+ try {
2279
+ await batchHandler(events, stream);
2280
+ return finalize(
2281
+ lease,
2282
+ events.length,
2283
+ events.at(-1).id,
2284
+ void 0,
2285
+ options,
2286
+ logger
2287
+ );
2288
+ } catch (error) {
2289
+ return finalize(lease, 0, lease.at, error, options, logger);
2290
+ }
2291
+ };
2292
+ }
2293
+
2294
+ // src/internal/autoclose-reaction.ts
2295
+ var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
2296
+ function synthesize_autoclose_reactions(registry, states, read_window) {
2297
+ for (const st of states.values()) {
2298
+ const predicate = st.autoclose;
2299
+ if (!predicate) continue;
2300
+ const after_windows = st.autoclose_after_windows;
2301
+ const keep_days = st.autoclose_keep_days;
2302
+ const archiver = st.archive;
2303
+ const reaction = {
2304
+ // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
2305
+ // per-aggregate `__autoclose__` key — so the autoclose reaction never
2306
+ // shares a watermark with the aggregate's own reactions. A shared
2307
+ // watermark would let autoclose's defer short-circuit the aggregate's
2308
+ // other reactions (the "a defer affects all reactions on a stream"
2309
+ // hazard). The close still targets the aggregate (`source`).
2310
+ // An anchored stream name would read as a pattern, and a store may refuse
2311
+ // it at subscribe, stopping every reaction. Such a stream has no
2312
+ // autoclose: `app.close` still closes it.
2313
+ resolver: (e) => is_literal_source(e.stream) ? {
2314
+ target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
2315
+ source: e.stream
2316
+ } : void 0,
2317
+ // Never block on autoclose: a transient query/store error should retry,
2318
+ // not quarantine the synthetic stream.
2319
+ options: { blockOnError: false, maxRetries: DEFAULT_MAX_RETRIES },
2320
+ handler: async (event) => {
2321
+ const aggregate = event.stream;
2322
+ const window = read_window();
2323
+ if (!in_autoclose_window(window, /* @__PURE__ */ new Date()))
2324
+ throw new DeferSignal({ at: next_window_open(window, /* @__PURE__ */ new Date()) });
2325
+ const stats = await store().query_stats([aggregate], {
2326
+ count: true,
2327
+ tail: keep_days !== void 0 ? true : void 0,
2328
+ exclude: [TOMBSTONE_EVENT, SNAP_EVENT]
2329
+ });
2330
+ const entry = stats.get(aggregate);
2331
+ if (!entry) return;
2332
+ const head = entry.head;
2333
+ if (predicate(aggregate, head, entry.count))
2334
+ throw new CloseSignal({
2335
+ stream: aggregate,
2336
+ archive: archiver ? () => archiver(aggregate, head) : void 0,
2337
+ // Ack this reaction's own watermark to the live head so the
2338
+ // close-cycle guard (which matches subscriptions by source =
2339
+ // aggregate) sees it caught up instead of blocking its own close.
2340
+ at: head.id
2341
+ });
2342
+ if (keep_days !== void 0) {
2343
+ const cutoff = days_before_now(keep_days);
2344
+ if (entry.tail.created < cutoff) {
2345
+ const waiting = after_windows.some(
2346
+ (days) => days_after(head.created, days).getTime() > Date.now()
2347
+ );
2348
+ throw new CloseSignal({
2349
+ stream: aggregate,
2350
+ before: cutoff,
2351
+ archive: archiver ? () => archiver(aggregate, head, cutoff) : void 0,
2352
+ at: waiting ? head.id - 1 : head.id,
2353
+ defer: waiting ? days_after(/* @__PURE__ */ new Date(), 1) : void 0
2354
+ });
2355
+ }
2356
+ }
2357
+ const dues = [];
2358
+ for (const days of after_windows)
2359
+ dues.push(days_after(head.created, days));
2360
+ if (keep_days !== void 0)
2361
+ dues.push(days_after(entry.tail.created, keep_days));
2362
+ const future = dues.map((d) => d.getTime()).filter((t) => t > Date.now());
2363
+ if (future.length)
2364
+ throw new DeferSignal({ at: new Date(Math.min(...future)) });
2365
+ }
2366
+ };
2367
+ const key = `__autoclose_${st.name}`;
2368
+ for (const event_name of Object.keys(st.events)) {
2369
+ registry.events[event_name]?.reactions.set(
2370
+ key,
2371
+ reaction
2372
+ );
2373
+ }
2374
+ }
2375
+ }
2376
+
2377
+ // src/internal/close-cycle.ts
2378
+ var CLOSE_CATCH_UP_LIMIT = 1e3;
2379
+ var CLOSE_CATCH_UP_PASSES = 20;
2380
+ async function catch_up_correlation(cursor, correlate, until) {
2381
+ for (let pass = 0; pass < CLOSE_CATCH_UP_PASSES && cursor.checkpoint < until; pass++) {
2382
+ const before = cursor.checkpoint;
2383
+ cursor.arm();
2384
+ await correlate(CLOSE_CATCH_UP_LIMIT);
2385
+ if (cursor.checkpoint <= before) break;
2386
+ }
2387
+ return cursor.checkpoint;
2388
+ }
2389
+ var CloseLock = class {
2390
+ _tails = /* @__PURE__ */ new Map();
2391
+ /** Run `work` after every earlier close of `stream` has settled. */
2392
+ run(stream, work) {
2393
+ const prev = this._tails.get(stream) ?? Promise.resolve();
2394
+ const next = prev.then(work, work);
2395
+ this._tails.set(stream, next);
2396
+ const cleanup = () => {
2397
+ if (this._tails.get(stream) === next) this._tails.delete(stream);
2398
+ };
2399
+ next.then(cleanup, cleanup);
2400
+ return next;
2401
+ }
2402
+ };
2403
+ var SAFETY_PROBE_PAGE_SIZE = 1e3;
2404
+ async function run_close_cycle(targets, deps) {
2405
+ const target_map = new Map(targets.map((t) => [t.stream, t]));
2406
+ for (const t of target_map.values()) {
2407
+ if (t.before !== void 0 && t.restart)
2408
+ throw new Error(
2409
+ `close: \`before\` and \`restart\` are mutually exclusive (stream "${t.stream}") \u2014 a windowed close keeps the stream live behind a real snapshot; restart reseeds it`
2410
+ );
2411
+ }
2412
+ const windowed = [...target_map.values()].filter(
2413
+ (t) => t.before !== void 0
2414
+ );
2415
+ const full = [...target_map.values()].filter((t) => t.before === void 0);
2416
+ const skipped = [];
2417
+ const windowed_result = windowed.length ? await run_windowed_closes(windowed, deps, skipped) : /* @__PURE__ */ new Map();
2418
+ if (!full.length) return { truncated: windowed_result, skipped };
2419
+ const truncated = await with_stream_locks(
2420
+ full.map((t) => t.stream),
2421
+ deps,
2422
+ () => run_full_closes(full, target_map, deps, skipped)
2423
+ );
2424
+ const retired = [...truncated.entries()].filter(([, r]) => r.committed.name === TOMBSTONE_EVENT).map(([stream]) => stream);
2425
+ if (retired.length) deps.forget_subscribed?.(retired);
2426
+ for (const [stream, entry] of windowed_result) truncated.set(stream, entry);
2427
+ return { truncated, skipped };
2428
+ }
2429
+ function with_stream_locks(streams, deps, work) {
2430
+ const with_lock = deps.with_stream_lock ?? ((_stream, w) => w());
2431
+ return [...streams].sort().reduceRight(
2432
+ (inner, stream) => () => with_lock(stream, inner),
2433
+ work
2434
+ )();
2435
+ }
2436
+ async function run_full_closes(full, target_map, deps, skipped) {
2437
+ const none = /* @__PURE__ */ new Map();
2438
+ const streams = full.map((t) => t.stream);
2439
+ const stream_info = await scan_stream_heads(streams);
2440
+ for (const target of full) {
2441
+ if (!target.restart) continue;
2442
+ const info = stream_info.get(target.stream);
2443
+ if (!info) continue;
2444
+ const owner = deps.event_to_state.get(info.last_event_name);
2445
+ if (owner?.pii_aware) {
2446
+ deps.logger.error(
2447
+ `Refusing to close "${target.stream}" with restart: state "${owner.name}" carries sensitive fields, so a restart seed would persist redacted values while deleting the originals. Close it without restart to retire the stream, or leave it open.`
2448
+ );
2449
+ skipped.push(target.stream);
2450
+ stream_info.delete(target.stream);
2451
+ }
2452
+ }
2453
+ const safe = await partition_by_safety(
2454
+ stream_info,
2455
+ deps.reactive,
2456
+ skipped,
2457
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE,
2458
+ deps.catch_up_correlation
2459
+ );
2460
+ if (!safe.length) return none;
2461
+ const { guarded, guard_events } = await guard_with_tombstones(
2462
+ safe,
2463
+ stream_info,
2464
+ deps.correlation,
2465
+ deps.tombstone,
2466
+ skipped
2467
+ );
2468
+ if (!guarded.length) return none;
2469
+ const seed_states = await load_restart_seeds(
2470
+ guarded,
2471
+ target_map,
2472
+ stream_info,
2473
+ deps.event_to_state,
2474
+ deps.load,
2475
+ deps.logger
2476
+ );
2477
+ await run_archive_callbacks(guarded, target_map);
2478
+ const still_guarded = await heads_still_guarded(
2479
+ guarded,
2480
+ guard_events,
2481
+ skipped
2482
+ );
2483
+ if (!still_guarded.length) return none;
2484
+ return truncate_and_warm_cache(
2485
+ still_guarded,
2486
+ seed_states,
2487
+ guard_events,
2488
+ deps.correlation
2489
+ );
2490
+ }
2491
+ async function run_windowed_closes(windowed, deps, skipped) {
2492
+ const checkpoint = deps.reactive ? await deps.catch_up_correlation(Number.MAX_SAFE_INTEGER) : -1;
2493
+ const min_at = deps.reactive ? await probe_min_watermarks(
2494
+ windowed.map((t) => t.stream),
2495
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE,
2496
+ checkpoint
2497
+ ) : /* @__PURE__ */ new Map();
2498
+ const with_lock = deps.with_stream_lock ?? ((_stream, work) => work());
2499
+ const truncated = /* @__PURE__ */ new Map();
2500
+ for (const t of windowed) {
2501
+ const entry = await with_lock(t.stream, async () => {
2502
+ const max_id = min_at.get(t.stream);
2503
+ if (!await windowed_prune_pending(t.stream, t.before, max_id))
2504
+ return void 0;
2505
+ if (t.archive) await t.archive();
2506
+ const result = await store().truncate([
2507
+ { stream: t.stream, before: t.before, max_id }
2508
+ ]);
2509
+ return result.get(t.stream);
2510
+ });
2511
+ if (entry) truncated.set(t.stream, entry);
2512
+ else skipped.push(t.stream);
2513
+ }
2514
+ return truncated;
2515
+ }
2516
+ async function windowed_prune_pending(stream, before, max_id) {
2517
+ let boundary_id;
2518
+ let min_id;
2519
+ await store().query(
2520
+ (event) => {
2521
+ if (min_id === void 0 || event.id < min_id) min_id = event.id;
2522
+ if (event.name === SNAP_EVENT && event.created < before && (max_id === void 0 || event.id <= max_id) && (boundary_id === void 0 || event.id > boundary_id))
2523
+ boundary_id = event.id;
2524
+ },
2525
+ { stream, stream_exact: true, with_snaps: true, after: -1 }
2526
+ );
2527
+ return boundary_id !== void 0 && min_id < boundary_id;
2528
+ }
2529
+ async function probe_min_watermarks(streams, page_size, checkpoint) {
2530
+ const min_at = /* @__PURE__ */ new Map();
2531
+ const matches = source_matcher();
2532
+ await walk_streams(
2533
+ store(),
2534
+ (position) => {
2535
+ const pending = position.correlated_at === void 0 || position.at < position.correlated_at;
2536
+ const cap = pending ? position.at : Math.max(position.at, checkpoint);
2537
+ for (const stream of streams) {
2538
+ if (matches(position.source, position.stream, stream)) {
2539
+ const prev = min_at.get(stream);
2540
+ if (prev === void 0 || cap < prev) min_at.set(stream, cap);
2541
+ }
2542
+ }
2543
+ },
2544
+ { limit: page_size }
2545
+ );
2546
+ return min_at;
2547
+ }
2548
+ function source_matcher() {
2549
+ const compiled = /* @__PURE__ */ new Map();
2550
+ return (source, target, stream) => {
2551
+ if (!source) return true;
2552
+ if (is_literal_source(source, target)) return source === stream;
2553
+ let re = compiled.get(source);
2554
+ if (!re) {
2555
+ re = new RegExp(source);
2556
+ compiled.set(source, re);
2557
+ }
2558
+ return re.test(stream);
2559
+ };
2560
+ }
2561
+ async function scan_stream_heads(streams) {
2562
+ const stats = await store().query_stats(streams, {
2563
+ exclude: [SNAP_EVENT]
2564
+ });
2565
+ const domain_heads = await store().query_stats(streams, {
2566
+ exclude: [SNAP_EVENT, TOMBSTONE_EVENT]
2567
+ });
2568
+ const true_heads = await store().query_stats(streams, {});
2569
+ const out = /* @__PURE__ */ new Map();
2570
+ for (const [stream, { head }] of domain_heads) {
2571
+ const marker_head = stats.get(stream)?.head;
2572
+ const interrupted = marker_head?.name === TOMBSTONE_EVENT;
2573
+ out.set(stream, {
2574
+ max_id: head.id,
2575
+ version: true_heads.get(stream).head.version,
2576
+ last_event_name: head.name,
2577
+ ...interrupted ? { resumed_guard: { id: marker_head.id } } : {}
2578
+ });
2579
+ }
2580
+ return out;
2581
+ }
2582
+ async function partition_by_safety(stream_info, reactive, skipped, page_size, catch_up_correlation2) {
2583
+ if (!reactive) return [...stream_info.keys()];
2584
+ let needed = -1;
2585
+ for (const info of stream_info.values())
2586
+ needed = Math.max(needed, info.max_id);
2587
+ const checkpoint = await catch_up_correlation2(needed);
2588
+ const uncorrelated = /* @__PURE__ */ new Set();
2589
+ for (const [stream, info] of stream_info) {
2590
+ if (checkpoint < info.max_id) uncorrelated.add(stream);
2591
+ }
2592
+ const pending_set = /* @__PURE__ */ new Set();
2593
+ const matches = source_matcher();
2594
+ await walk_streams(
2595
+ store(),
2596
+ (position) => {
2597
+ const has_work = position.correlated_at !== void 0 && position.at < position.correlated_at;
2598
+ if (!has_work) return;
2599
+ if (position.stream.startsWith(AUTOCLOSE_TARGET_PREFIX)) return;
2600
+ for (const [stream, info] of stream_info) {
2601
+ if (matches(position.source, position.stream, stream) && position.at < info.max_id)
2602
+ pending_set.add(stream);
2603
+ }
2604
+ },
2605
+ { limit: page_size }
2606
+ );
2607
+ const safe = [];
2608
+ for (const [stream] of stream_info) {
2609
+ if (pending_set.has(stream) || uncorrelated.has(stream))
2610
+ skipped.push(stream);
2611
+ else safe.push(stream);
2612
+ }
2613
+ return safe;
2614
+ }
2615
+ async function guard_with_tombstones(safe, stream_info, correlation, tombstone2, skipped) {
2616
+ const guarded = [];
2617
+ const guard_events = /* @__PURE__ */ new Map();
2618
+ await Promise.all(
2619
+ safe.map(async (stream) => {
2620
+ const info = stream_info.get(stream);
2621
+ if (info.resumed_guard) {
2622
+ guarded.push(stream);
2623
+ guard_events.set(stream, { id: info.resumed_guard.id, stream });
2624
+ return;
2625
+ }
2626
+ const committed = await tombstone2(stream, info.version, correlation);
2627
+ if (committed) {
2628
+ guarded.push(stream);
2629
+ guard_events.set(stream, { id: committed.id, stream });
2630
+ } else {
2631
+ skipped.push(stream);
2632
+ }
2633
+ })
2634
+ );
2635
+ return { guarded, guard_events };
2636
+ }
2637
+ async function load_restart_seeds(guarded, target_map, stream_info, event_to_state, load2, logger) {
2638
+ const seed_states = /* @__PURE__ */ new Map();
2639
+ await Promise.all(
2640
+ guarded.filter((s) => target_map.get(s)?.restart).map(async (stream) => {
2641
+ const last_event_name = stream_info.get(stream).last_event_name;
2642
+ const owner_state = event_to_state.get(last_event_name);
2643
+ if (!owner_state) {
2644
+ logger.error(
2645
+ `Cannot seed restart for "${stream}": no registered state owns event "${last_event_name}". Stream will be tombstoned instead.`
2646
+ );
2647
+ return;
2648
+ }
2649
+ const snap2 = await load2(owner_state, { stream });
2650
+ seed_states.set(stream, snap2.state);
2651
+ })
2652
+ );
2653
+ return seed_states;
2654
+ }
2655
+ async function run_archive_callbacks(guarded, target_map) {
2656
+ for (const stream of guarded) {
2657
+ const archive_fn = target_map.get(stream)?.archive;
2658
+ if (archive_fn) await archive_fn();
2659
+ }
2660
+ }
2661
+ async function heads_still_guarded(guarded, guard_events, skipped) {
2662
+ const heads = await store().query_stats(guarded, {});
2663
+ return guarded.filter((stream) => {
2664
+ if (heads.get(stream)?.head.id === guard_events.get(stream).id)
2665
+ return true;
2666
+ skipped.push(stream);
2667
+ return false;
2668
+ });
2669
+ }
2670
+ async function truncate_and_warm_cache(guarded, seed_states, guard_events, correlation) {
2671
+ const trunc_targets = guarded.map((stream) => {
2672
+ const snapshot = seed_states.get(stream);
2673
+ const guard = guard_events.get(stream);
2674
+ return {
2675
+ stream,
2676
+ snapshot,
2677
+ meta: {
2678
+ correlation,
2679
+ causation: {
2680
+ event: { id: guard.id, name: TOMBSTONE_EVENT, stream: guard.stream }
2681
+ }
2682
+ }
2683
+ };
2684
+ });
2685
+ const truncated = await store().truncate(trunc_targets);
2686
+ await Promise.all(
2687
+ guarded.map(async (stream) => {
2688
+ const entry = truncated.get(stream);
2689
+ const state2 = seed_states.get(stream);
2690
+ if (state2 && entry) {
2691
+ await cache().set(stream, {
2692
+ stream,
2693
+ state: state2,
2694
+ version: entry.committed.version,
2695
+ event_id: entry.committed.id,
2696
+ patches: 0,
2697
+ snaps: 1
2698
+ });
2699
+ } else {
2700
+ await cache().invalidate(stream);
2701
+ }
2702
+ })
2703
+ );
2704
+ return truncated;
2705
+ }
2706
+
2707
+ // src/internal/correlate-cycle.ts
2708
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
2709
+ var COLD_START_BACK_SCAN = 1e4;
2710
+ var CORRELATION_LEASE_MS = 5e3;
2711
+ var registry_key = (events) => {
2712
+ const shape = Object.keys(events).filter((name) => events[name].reactions.size > 0).sort().map((name) => [name, [...events[name].reactions.keys()].sort()]);
2713
+ return createHash("sha256").update(JSON.stringify(shape)).digest("hex").slice(0, 32);
2714
+ };
2715
+ var PATTERN_CACHE_SIZE = 32;
2716
+ function report_undeclared_lane(seen, handler, target, lane, declared) {
2717
+ report_once(
2718
+ seen,
2719
+ `lane|${handler}|${lane}`,
2720
+ `Reaction "${handler}" resolved onto undeclared lane "${lane}" \u2014 for example target "${target}". Declared lanes: ${[...declared].map((l) => `"${l}"`).join(", ")}. No controller claims it, so the stream would never drain \u2014 running it on "default" instead. The equivalent static \`.to({ lane })\` is rejected at build; a dynamic resolver's lane is only knowable here.`
2721
+ );
2722
+ }
2723
+ function report_lane_conflict(seen, handler, target, kept, dropped) {
2724
+ report_once(
2725
+ seen,
2726
+ `conflict|${handler}|${kept}|${dropped}`,
2727
+ `Reaction "${handler}" resolved lane "${dropped}" for a stream already on "${kept}" \u2014 for example "${target}". These are conflicting lane assignments from two dynamic resolutions at equal priority. Keeping "${kept}", the lane it was first discovered on \u2014 re-laning a live stream would move it out from under a worker holding its lease. The reaction resolving "${dropped}" runs inside the "${kept}" lane's budget, and a process restricted to "${dropped}" via onlyLanes never runs it. The equivalent static declaration is rejected at build; align the resolvers, or split the target.`
2728
+ );
2729
+ }
2730
+ var CorrelateCycle = class {
2731
+ _checkpoint = -1;
2732
+ // Lease holder id; per instance, so a restart never inherits a stale claim.
2733
+ _by = randomUUID2();
2734
+ _key;
2735
+ // Local expiry of the held lease (0 = none); stale only costs a duplicate scan.
2736
+ _lease_until = 0;
2737
+ // Armed while `_raised` (bumped by a commit or notification) is ahead of
2738
+ // `_lowered` (the raise count the last short page saw). Starts armed.
2739
+ _raised = 1;
2740
+ _lowered = 0;
2741
+ // Bumped by a release (shutdown), so a scan or lease reply that was out
2742
+ // across one writes nothing.
2743
+ _epoch = 0;
2744
+ // Passes in flight: a restore waits for them, and refuses new ones.
2745
+ _passes = /* @__PURE__ */ new Set();
2746
+ _restoring = false;
2747
+ _init_promise;
2748
+ // Bounded by `maxSubscribedStreams`; static records live apart so eviction
2749
+ // never lets a resolution re-lane them.
2750
+ _dynamic_subscriptions;
2751
+ _static_subscriptions = /* @__PURE__ */ new Map();
2752
+ _patterns = new LruMap(PATTERN_CACHE_SIZE);
2753
+ _registry;
2754
+ _static_targets;
2755
+ _cd;
2756
+ _on_init;
2757
+ _declared_lanes;
2758
+ // Report-once keys; per instance because `internal/` holds no module state.
2759
+ _reported = /* @__PURE__ */ new Set();
2760
+ constructor({
2761
+ registry,
2762
+ static_targets,
2763
+ cd,
2764
+ max_subscribed_streams,
2765
+ declared_lanes,
2766
+ shard,
2767
+ on_init
2768
+ }) {
2769
+ this._key = shard ? `${registry_key(registry.events)}:${[...shard].sort().join(",")}` : registry_key(registry.events);
2770
+ this._dynamic_subscriptions = new LruMap(max_subscribed_streams);
2771
+ this._registry = registry;
2772
+ this._declared_lanes = declared_lanes;
2773
+ this._static_targets = static_targets;
2774
+ this._cd = cd;
2775
+ this._on_init = on_init;
2776
+ }
2777
+ get checkpoint() {
2778
+ return this._checkpoint;
2779
+ }
2780
+ /** A commit (local or remote) may have produced uncorrelated events. */
2781
+ arm() {
2782
+ this._raised++;
2783
+ }
2784
+ async _init() {
2785
+ this._init_promise ??= this._run_init().catch((error) => {
2786
+ this._init_promise = void 0;
2787
+ throw error;
2788
+ });
2789
+ await this._init_promise;
2790
+ }
2791
+ async _run_init() {
2792
+ const { watermark, correlated_at } = await this._cd.subscribe([
2793
+ ...this._static_targets
2794
+ ]);
2795
+ this._checkpoint = correlated_at >= 0 ? correlated_at : Math.max(-1, watermark - COLD_START_BACK_SCAN);
2796
+ for (const { stream, priority = 0, lane } of this._static_targets) {
2797
+ this._static_subscriptions.set(stream, {
2798
+ priority,
2799
+ lane
2800
+ });
2801
+ }
2802
+ await this._on_init?.();
2803
+ }
2804
+ /**
2805
+ * Replace the log with no pass in flight, then cold-start: a pass that
2806
+ * read the old log writes before the restore, never into the new one.
2807
+ */
2808
+ async restart(restore) {
2809
+ this._restoring = true;
2810
+ try {
2811
+ await Promise.allSettled(this._passes);
2812
+ await restore();
2813
+ } finally {
2814
+ this._restoring = false;
2815
+ }
2816
+ this._init_promise = void 0;
2817
+ this._checkpoint = -1;
2818
+ this._dynamic_subscriptions.clear();
2819
+ this._static_subscriptions.clear();
2820
+ }
2821
+ /** Forget dynamic targets whose rows a full close deleted, so they re-subscribe. */
2822
+ forget_subscribed(streams) {
2823
+ for (const stream of streams) this._dynamic_subscriptions.delete(stream);
2824
+ }
2825
+ // Would the target's own `fetch` (filtered by `source`) return this event?
2826
+ _in_fetch_window(source, stream) {
2827
+ if (source === void 0 || source === stream) return true;
2828
+ if (is_literal_source(source)) return false;
2829
+ let pattern = this._patterns.get(source);
2830
+ if (!pattern) {
2831
+ pattern = new RegExp(source);
2832
+ this._patterns.set(source, pattern);
2833
+ }
2834
+ return pattern.test(stream);
2835
+ }
2836
+ /** Scan past the checkpoint, subscribe new targets and raise their work marks. */
2837
+ async correlate(limit, lease = false) {
2838
+ if (this._restoring) return { kind: "idle", last_id: this._checkpoint };
2839
+ const pass = this._scan(limit, lease);
2840
+ this._passes.add(pass);
2841
+ try {
2842
+ return await pass;
2843
+ } finally {
2844
+ this._passes.delete(pass);
2845
+ }
2846
+ }
2847
+ async _scan(limit, lease) {
2848
+ await this._init();
2849
+ const epoch = this._epoch;
2850
+ const idle = () => ({
2851
+ kind: "idle",
2852
+ last_id: this._checkpoint
2853
+ });
2854
+ if (this._raised === this._lowered) return idle();
2855
+ const raised = this._raised;
2856
+ const now = Date.now();
2857
+ if (lease && now >= this._lease_until - CORRELATION_LEASE_MS / 2) {
2858
+ const { correlating } = await this._cd.subscribe([], void 0, {
2859
+ key: this._key,
2860
+ by: this._by,
2861
+ millis: CORRELATION_LEASE_MS
2862
+ });
2863
+ if (this._epoch !== epoch) return idle();
2864
+ this._lease_until = correlating ? now + CORRELATION_LEASE_MS : 0;
2865
+ if (correlating === false) return idle();
2866
+ }
2867
+ const after = this._checkpoint;
2868
+ const correlated = /* @__PURE__ */ new Map();
2869
+ let last_id = after;
2870
+ let stopped = false;
2871
+ const found = await store().query(
2872
+ (event) => {
2873
+ if (stopped) return;
2874
+ const register = this._registry.events[event.name];
2875
+ if (register) {
2876
+ for (const reaction of register.reactions.values()) {
2877
+ let resolved;
2878
+ try {
2879
+ resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
2880
+ } catch (error) {
2881
+ stopped = true;
2882
+ report_once(
2883
+ this._reported,
2884
+ `resolver|${reaction.handler.name}|${event.id}`,
2885
+ `Resolver of reaction "${reaction.handler.name}" threw on ${String(event.name)} #${event.id} (stream "${event.stream}"): ${String(error)}. Correlation stops before this event until the resolver stops throwing; work already found keeps draining.`
2886
+ );
2887
+ return;
2888
+ }
2889
+ if (!resolved) continue;
2890
+ let lane = resolved.lane;
2891
+ if (lane !== void 0 && !this._declared_lanes.has(lane)) {
2892
+ report_undeclared_lane(
2893
+ this._reported,
2894
+ reaction.handler.name,
2895
+ resolved.target,
2896
+ lane,
2897
+ this._declared_lanes
2898
+ );
2899
+ lane = void 0;
2900
+ }
2901
+ const fixed = this._static_subscriptions.get(resolved.target);
2902
+ const recorded = fixed ?? this._dynamic_subscriptions.get(resolved.target);
2903
+ const priority = resolved.priority ?? 0;
2904
+ const upgraded = !recorded || !fixed && priority > recorded.priority;
2905
+ const carried = upgraded ? { priority, lane } : { priority: recorded.priority, lane: recorded.lane };
2906
+ const entry = correlated.get(resolved.target) || {
2907
+ source: resolved.source,
2908
+ priority: carried.priority,
2909
+ lane: carried.lane,
2910
+ upgraded,
2911
+ missed: !recorded,
2912
+ handler: reaction.handler.name,
2913
+ correlated_at: void 0
2914
+ };
2915
+ const seen_in_scan = correlated.has(resolved.target);
2916
+ const held_lane = (seen_in_scan ? entry.lane : recorded?.lane) ?? DEFAULT_LANE;
2917
+ const held_priority = seen_in_scan ? entry.priority : recorded?.priority;
2918
+ const resolved_lane = lane ?? DEFAULT_LANE;
2919
+ if (held_priority === priority && held_lane !== resolved_lane)
2920
+ report_lane_conflict(
2921
+ this._reported,
2922
+ reaction.handler.name,
2923
+ resolved.target,
2924
+ held_lane,
2925
+ resolved_lane
2926
+ );
2927
+ if (carried.priority > entry.priority) {
2928
+ entry.priority = carried.priority;
2929
+ entry.lane = carried.lane;
2930
+ entry.upgraded = upgraded;
2931
+ entry.handler = reaction.handler.name;
2932
+ }
2933
+ if (this._in_fetch_window(resolved.source, event.stream))
2934
+ entry.correlated_at = event.id;
2935
+ correlated.set(resolved.target, entry);
2936
+ }
2937
+ }
2938
+ last_id = event.id;
2939
+ },
2940
+ // Resolvers see `pii: null`: correlate runs actor-less, and one
2941
+ // undecryptable row would otherwise stop every stream's reactions.
2942
+ { after, limit, with_pii: false }
2943
+ );
2944
+ if (this._declared_lanes.size > 1) await this._recall(correlated);
2945
+ const read = last_id > after;
2946
+ if (this._epoch !== epoch) return idle();
2947
+ const streams = [];
2948
+ for (const [stream, entry] of correlated) {
2949
+ if (entry.upgraded || entry.correlated_at !== void 0)
2950
+ streams.push({
2951
+ stream,
2952
+ source: entry.source,
2953
+ priority: entry.priority,
2954
+ lane: entry.lane,
2955
+ correlated_at: entry.correlated_at
2956
+ });
2957
+ }
2958
+ if (streams.length) {
2959
+ const renewed_at = Date.now();
2960
+ const { subscribed, correlating } = await this._cd.subscribe(
2961
+ streams,
2962
+ last_id,
2963
+ lease ? { key: this._key, by: this._by, millis: CORRELATION_LEASE_MS } : void 0
2964
+ );
2965
+ if (lease && correlating !== false)
2966
+ this._lease_until = renewed_at + CORRELATION_LEASE_MS;
2967
+ const marked = streams.filter(
2968
+ (entry) => entry.correlated_at !== void 0
2969
+ ).length;
2970
+ this._checkpoint = last_id;
2971
+ for (const { stream, priority, lane } of streams) {
2972
+ if (correlated.get(stream)?.upgraded)
2973
+ this._dynamic_subscriptions.set(stream, {
2974
+ priority,
2975
+ lane
2976
+ });
2977
+ }
2978
+ return { kind: "scanned", last_id, subscribed, marked, read };
2979
+ }
2980
+ this._checkpoint = last_id;
2981
+ if (!stopped && found !== limit) this._lowered = raised;
2982
+ return { kind: "scanned", last_id, subscribed: 0, marked: 0, read };
2471
2983
  }
2472
- async drain(options = {}) {
2473
- if (!this.armed || this._stopped || this._inflight)
2474
- return EMPTY_DRAIN;
2475
- const d = this._deps.defaults ?? {};
2476
- const streamLimit = d.streamLimit ?? options.streamLimit ?? DEFAULT_STREAM_LIMIT;
2477
- const eventLimit = options.eventLimit ?? DEFAULT_EVENT_LIMIT;
2478
- const leaseMillis = d.leaseMillis ?? options.leaseMillis ?? DEFAULT_LEASE_MILLIS;
2479
- if (this._deps.breaker.state(Date.now()) === "open")
2480
- return EMPTY_DRAIN;
2481
- const pass = this._pass(streamLimit, eventLimit, leaseMillis).finally(
2482
- () => {
2483
- this._inflight = void 0;
2484
- }
2984
+ // Read the row of each target this scan had no record of (new or evicted),
2985
+ // so a resolution that doesn't beat its priority keeps the row's lane.
2986
+ // Only called with more than one lane declared.
2987
+ async _recall(correlated) {
2988
+ const missed = [...correlated].filter(([, entry]) => entry.missed);
2989
+ await Promise.all(
2990
+ missed.map(async ([stream, entry]) => {
2991
+ let row;
2992
+ await store().query_streams(
2993
+ (position) => {
2994
+ row = position;
2995
+ },
2996
+ { stream, stream_exact: true, limit: 1 }
2997
+ );
2998
+ if (!row) return;
2999
+ this._dynamic_subscriptions.set(stream, {
3000
+ priority: row.priority,
3001
+ lane: row.lane
3002
+ });
3003
+ if (entry.priority > row.priority) return;
3004
+ const held_lane = row.lane ?? DEFAULT_LANE;
3005
+ const resolved_lane = entry.lane ?? DEFAULT_LANE;
3006
+ if (entry.priority === row.priority && held_lane !== resolved_lane)
3007
+ report_lane_conflict(
3008
+ this._reported,
3009
+ entry.handler,
3010
+ stream,
3011
+ held_lane,
3012
+ resolved_lane
3013
+ );
3014
+ entry.priority = row.priority;
3015
+ entry.lane = row.lane;
3016
+ entry.upgraded = false;
3017
+ })
2485
3018
  );
2486
- this._inflight = pass;
2487
- return pass;
2488
3019
  }
2489
- // One claim → fetch → handle → ack pass. Never throws.
2490
- async _pass(streamLimit, eventLimit, leaseMillis) {
3020
+ /** Release the lease early by re-acquiring it for zero ms. Best-effort. */
3021
+ async release_correlation() {
2491
3022
  try {
2492
- const lagging = Math.ceil(streamLimit * this._ratio);
2493
- const leading = streamLimit - lagging;
2494
- const started = Date.now();
2495
- const raised = this._raised;
2496
- const cycle = await run_drain_cycle(this._deps, {
2497
- lagging,
2498
- leading,
2499
- eventLimit,
2500
- leaseMillis,
2501
- misrouted: this._misrouted,
2502
- store_failing: this._deps.breaker.failing
3023
+ this._lease_until = 0;
3024
+ this._epoch++;
3025
+ await this._cd.subscribe([], void 0, {
3026
+ key: this._key,
3027
+ by: this._by,
3028
+ millis: 0
2503
3029
  });
2504
- if (!cycle) {
2505
- this._defer.prune(started);
2506
- this._deps.breaker.passed();
2507
- this._lowered = raised;
2508
- return EMPTY_DRAIN;
2509
- }
2510
- const { leased, fetched, handled, acked, blocked, closeable } = cycle;
2511
- trace_cycle(this._deps.logger, leased, fetched, handled, acked, blocked);
2512
- this._ratio = compute_lag_lead_ratio(handled, lagging, leading);
2513
- for (const lease of acked) this._defer.delete(lease.stream);
2514
- for (const lease of blocked) this._defer.delete(lease.stream);
2515
- for (const h of handled) {
2516
- const next = h.defer ?? h.next_attempt_at;
2517
- if (next !== void 0) this._defer.set(h.lease.stream, next);
2518
- }
2519
- if (acked.length) this._deps.on_acked(acked);
2520
- if (closeable.length) await this._deps.on_close(closeable);
2521
- this._deps.breaker.passed();
2522
- return { fetched, leased, acked, blocked };
2523
3030
  } catch (error) {
2524
- this._defer.set(
2525
- RETRY,
2526
- Date.now() + Math.max(leaseMillis, this._deps.breaker.cooldown_ms)
3031
+ log().warn(
3032
+ `Could not hand back the correlation lease during shutdown; it expires on its own within ${CORRELATION_LEASE_MS}ms and correlation resumes normally after that. On a single-writer store this usually means another connection holds the database. Cause: ${String(error.cause ?? error)}`
2527
3033
  );
2528
- this._deps.breaker.failed(Date.now(), error);
2529
- return EMPTY_DRAIN;
2530
- } finally {
2531
- if (!this._stopped) this._defer.schedule();
2532
3034
  }
2533
3035
  }
2534
3036
  };
@@ -2657,191 +3159,15 @@ function make_fold_handler(me, flush, config2, sensitive_fields) {
2657
3159
  } else {
2658
3160
  fold.dirty = true;
2659
3161
  }
2660
- if (++folded_since_flush >= config2.flushEvery) {
2661
- await flush_dirty();
2662
- folded_since_flush = 0;
2663
- }
2664
- }
2665
- await flush_dirty();
2666
- };
2667
- Object.defineProperty(handler, FOLD_RESET, { value: () => cache2.clear() });
2668
- return handler;
2669
- }
2670
-
2671
- // src/internal/defer-config.ts
2672
- import { z } from "zod";
2673
- var DeferDurationSchema = z.object({
2674
- days: z.number().positive().optional(),
2675
- hours: z.number().positive().optional(),
2676
- minutes: z.number().positive().optional()
2677
- }).strict().refine((d) => d.days != null || d.hours != null || d.minutes != null, {
2678
- message: "defer: a duration needs at least one of days, hours, or minutes"
2679
- });
2680
- var DeferWhenSchema = z.object({
2681
- after: DeferDurationSchema.optional(),
2682
- at: z.date().optional()
2683
- }).strict().refine((w) => w.after === void 0 !== (w.at === void 0), {
2684
- message: "defer: specify exactly one of `after` or `at`"
2685
- });
2686
- function duration_ms(d) {
2687
- return (d.days ?? 0) * 864e5 + (d.hours ?? 0) * 36e5 + (d.minutes ?? 0) * 6e4;
2688
- }
2689
- function resolve_defer_at(when, event) {
2690
- const parsed = DeferWhenSchema.parse(when);
2691
- if (parsed.after) return event.created.getTime() + duration_ms(parsed.after);
2692
- return parsed.at.getTime();
2693
- }
2694
- function assert_defer_when(when) {
2695
- DeferWhenSchema.parse(when);
2696
- }
2697
- function make_deferred(handler, schedule) {
2698
- const call = handler;
2699
- const deferred = async (event, stream, app) => {
2700
- const when = typeof schedule === "function" ? schedule(event) : schedule;
2701
- if (Date.now() < resolve_defer_at(when, event)) throw new DeferSignal(when);
2702
- return call(event, stream, app);
2703
- };
2704
- Object.defineProperty(deferred, "name", { value: handler.name });
2705
- return deferred;
2706
- }
2707
-
2708
- // src/internal/reaction-dispatch.ts
2709
- var CloseSignal = class extends Error {
2710
- /**
2711
- * Stream to close. Omitted → the reaction's own lease stream (a self-close).
2712
- * The synthesized autoclose reaction sets it to the aggregate stream, since
2713
- * its lease runs on a synthetic `__autoclose__` target.
2714
- */
2715
- stream;
2716
- /** Optional archive callback to run while the stream is guarded. */
2717
- archive;
2718
- /**
2719
- * Watermark to ack the requesting reaction to before the close runs. The
2720
- * close-cycle safety guard skips a stream whose subscriptions (matched by
2721
- * source) lag the head; the autoclose reaction's `source` is the aggregate,
2722
- * so it must advance its own watermark to the live head id it evaluated
2723
- * against — otherwise it blocks its own close. Defaults to the triggering
2724
- * event id when omitted.
2725
- */
2726
- at;
2727
- /**
2728
- * Windowed close: prune events older than this cutoff behind
2729
- * the closest safe snapshot instead of retiring the stream. Thrown by
2730
- * the autoclose reaction of a `.autocloses({ keep })` state; flows
2731
- * into {@link CloseTarget.before}. Omitted → a full close.
2732
- */
2733
- before;
2734
- constructor(opts) {
2735
- super("reaction requested close");
2736
- this.name = "CloseSignal";
2737
- this.stream = opts?.stream;
2738
- this.archive = opts?.archive;
2739
- this.at = opts?.at;
2740
- this.before = opts?.before;
2741
- }
2742
- };
2743
- function build_handle(deps) {
2744
- const { logger, reaction_scope } = deps;
2745
- return async (lease, payloads) => {
2746
- if (payloads.length === 0) return { lease, handled: 0, acked_at: lease.at };
2747
- const stream = lease.stream;
2748
- const last_index_of = /* @__PURE__ */ new Map();
2749
- for (let i = 0; i < payloads.length; i++)
2750
- last_index_of.set(payloads[i].event.id, i);
2751
- let at = lease.at;
2752
- let handled = 0;
2753
- if (lease.retry > 0)
2754
- logger.warn(
2755
- `Retrying ${stream}@${payloads.at(0).event.id} (${lease.retry}).`
2756
- );
2757
- for (let i = 0; i < payloads.length; i++) {
2758
- const payload = payloads[i];
2759
- const { event, handler } = payload;
2760
- try {
2761
- await reaction_scope.run(
2762
- event,
2763
- () => handler(event, stream, reaction_scope.app)
2764
- );
2765
- if (last_index_of.get(event.id) === i) {
2766
- at = event.id;
2767
- handled++;
2768
- }
2769
- } catch (error) {
2770
- if (error instanceof DeferSignal) {
2771
- let defer;
2772
- try {
2773
- defer = resolve_defer_at(error.when, event);
2774
- } catch (invalid) {
2775
- return finalize(
2776
- lease,
2777
- handled,
2778
- at,
2779
- invalid,
2780
- payload.options,
2781
- logger,
2782
- event.id
2783
- );
2784
- }
2785
- return { lease, handled, acked_at: at, defer };
2786
- }
2787
- if (error instanceof CloseSignal)
2788
- return {
2789
- lease,
2790
- handled: handled + 1,
2791
- // Advance to the live head the handler evaluated against (when
2792
- // provided) so the close-cycle guard sees this reaction caught up.
2793
- acked_at: error.at ?? event.id,
2794
- // Close the signalled stream (the autoclose reaction's aggregate,
2795
- // which differs from its synthetic lease stream); a self-closing
2796
- // user reaction omits it and closes its own lease stream. A
2797
- // carried `before` makes it a windowed close (prune, not retire).
2798
- close: {
2799
- stream: error.stream ?? stream,
2800
- archive: error.archive,
2801
- before: error.before
2802
- }
2803
- };
2804
- return finalize(
2805
- lease,
2806
- handled,
2807
- at,
2808
- error,
2809
- payload.options,
2810
- logger,
2811
- event.id
2812
- );
3162
+ if (++folded_since_flush >= config2.flushEvery) {
3163
+ await flush_dirty();
3164
+ folded_since_flush = 0;
2813
3165
  }
2814
3166
  }
2815
- return finalize(lease, handled, at, void 0, payloads[0].options, logger);
2816
- };
2817
- }
2818
- function build_handle_batch(logger) {
2819
- return async (lease, payloads, batchHandler) => {
2820
- const stream = lease.stream;
2821
- const events = [];
2822
- const seen = /* @__PURE__ */ new Set();
2823
- for (const p of payloads) {
2824
- if (seen.has(p.event.id)) continue;
2825
- seen.add(p.event.id);
2826
- events.push(p.event);
2827
- }
2828
- const options = payloads[0].options;
2829
- if (lease.retry > 0)
2830
- logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
2831
- try {
2832
- await batchHandler(events, stream);
2833
- return finalize(
2834
- lease,
2835
- events.length,
2836
- events.at(-1).id,
2837
- void 0,
2838
- options,
2839
- logger
2840
- );
2841
- } catch (error) {
2842
- return finalize(lease, 0, lease.at, error, options, logger);
2843
- }
3167
+ await flush_dirty();
2844
3168
  };
3169
+ Object.defineProperty(handler, FOLD_RESET, { value: () => cache2.clear() });
3170
+ return handler;
2845
3171
  }
2846
3172
 
2847
3173
  // src/internal/settle.ts
@@ -3061,6 +3387,7 @@ var Act = class {
3061
3387
  this._lanes = lanes;
3062
3388
  this._declared_lanes = ["default", ...lanes.map((l) => l.name)];
3063
3389
  validate_only_lanes(options.onlyLanes, this._declared_lanes);
3390
+ if (options.scoped) check_store(options.scoped.store);
3064
3391
  this._ports = options.scoped ?? default_scope();
3065
3392
  const ports = this._ports;
3066
3393
  this._scoped = make_run_scoped({
@@ -3316,7 +3643,7 @@ var Act = class {
3316
3643
  * Executes an action: validates the payload, loads the state, checks its
3317
3644
  * preconditions, runs the handler, folds the events it emits, checks the
3318
3645
  * state's invariants (its schema) on the result, and commits them with
3319
- * optimistic concurrency on the stream. The reactions to those events
3646
+ * optimistic concurrency on every stream it appends to. The reactions to those events
3320
3647
  * settle on their own (unless `autoSettle: false`).
3321
3648
  *
3322
3649
  * @template TKey - Action name from registered actions
@@ -3326,7 +3653,8 @@ var Act = class {
3326
3653
  * @param options - Per-call dispatch options ({@link DoOptions}) —
3327
3654
  * `reactingTo` to thread correlation, `correlator` to override the
3328
3655
  * framework or orchestrator-level correlator for this call only.
3329
- * @returns Array of snapshots for all affected states (usually one)
3656
+ * @returns The snapshots of the action's own stream, one per event appended
3657
+ * to it; the loaded snapshot alone when the action commits nothing
3330
3658
  *
3331
3659
  * @throws {ValidationError} If the payload doesn't match the action schema,
3332
3660
  * or the state the action leaves breaks the state schema
@@ -3363,13 +3691,22 @@ var Act = class {
3363
3691
  do_options
3364
3692
  );
3365
3693
  if (!snapshots[0].patch) return snapshots;
3694
+ const me = this.registry.actions[action2];
3366
3695
  this._wake(snapshots.map((s) => s.event.name));
3367
- this.emit("committed", snapshots);
3368
- this._notify(
3369
- `committed:${this.registry.actions[action2].name}`,
3370
- snapshots
3371
- );
3372
- return snapshots;
3696
+ const by_stream = /* @__PURE__ */ new Map();
3697
+ for (const s of snapshots) {
3698
+ const of_stream = by_stream.get(s.event.stream);
3699
+ if (of_stream) of_stream.push(s);
3700
+ else by_stream.set(s.event.stream, [s]);
3701
+ }
3702
+ for (const [stream, of_stream] of by_stream) {
3703
+ const owner = stream === target.stream ? me : me.loads[action2].find(
3704
+ (l) => l.events.includes(of_stream[0].event.name)
3705
+ ).state;
3706
+ this.emit("committed", of_stream);
3707
+ this._notify(`committed:${owner.name}`, of_stream);
3708
+ }
3709
+ return snapshots.filter((s) => s.event.stream === target.stream);
3373
3710
  });
3374
3711
  }
3375
3712
  async load(stateOrName, streamOrTarget, callback, asOf) {
@@ -3963,217 +4300,6 @@ var Act = class {
3963
4300
  }
3964
4301
  };
3965
4302
 
3966
- // src/internal/autoclose-policy.ts
3967
- import { z as z2 } from "zod";
3968
- var DAY_MS = 864e5;
3969
- var MIN_AFTER_DAYS = 1 / 1440;
3970
- var MIN_KEEP_DAYS = 1;
3971
- function days_after(date, days) {
3972
- return new Date(date.getTime() + days * DAY_MS);
3973
- }
3974
- function days_before_now(days) {
3975
- return new Date(Date.now() - days * DAY_MS);
3976
- }
3977
- var window_schema = (field, min_days, floor) => z2.object({
3978
- days: z2.number({ message: `autocloses: ${field}.days must be a number` }).positive(`autocloses: ${field}.days must be > 0`)
3979
- }).refine((o) => o.days >= min_days, {
3980
- message: `autocloses: ${field} resolves to < ${floor}, which is too short to be a meaningful retention window`
3981
- });
3982
- var AfterSchema = window_schema("after", MIN_AFTER_DAYS, "one minute");
3983
- var KeepSchema = window_schema("keep", MIN_KEEP_DAYS, "one day");
3984
- var IsSchema = z2.union([
3985
- z2.string().min(1, "autocloses: is must be a non-empty event name"),
3986
- z2.array(
3987
- z2.string().min(1, "autocloses: is entries must be non-empty event names")
3988
- ).min(1, "autocloses: is must include at least one event name").readonly()
3989
- ]);
3990
- var ReachesSchema = z2.number({ message: "autocloses: reaches must be a number" }).int("autocloses: reaches must be an integer").min(1, "autocloses: reaches must be >= 1");
3991
- var OrBlockSchema = z2.object({
3992
- after: AfterSchema.optional(),
3993
- is: IsSchema.optional(),
3994
- reaches: ReachesSchema.optional()
3995
- }).strict().refine(
3996
- (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0,
3997
- {
3998
- message: "autocloses: `or` block must include at least one of after / is / reaches"
3999
- }
4000
- );
4001
- var AutoclosePolicySchema = z2.object({
4002
- after: AfterSchema.optional(),
4003
- is: IsSchema.optional(),
4004
- reaches: ReachesSchema.optional(),
4005
- or: OrBlockSchema.optional(),
4006
- keep: KeepSchema.optional()
4007
- }).strict().refine(
4008
- (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0 || o.keep !== void 0,
4009
- {
4010
- message: "autocloses: at least one of after / is / reaches / or / keep must be specified \u2014 empty `{}` is a misconfiguration"
4011
- }
4012
- );
4013
- function compile_after(after) {
4014
- return (_stream, head) => days_after(head.created, after.days) <= /* @__PURE__ */ new Date();
4015
- }
4016
- function compile_is(is) {
4017
- const set = new Set(typeof is === "string" ? [is] : is);
4018
- return (_stream, head) => set.has(head.name);
4019
- }
4020
- function compile_reaches(reaches) {
4021
- return (_stream, _head, count) => count >= reaches;
4022
- }
4023
- function policy_min_after_days(options) {
4024
- const parsed = AutoclosePolicySchema.parse(options);
4025
- const windows = [];
4026
- if (parsed.after) windows.push(parsed.after.days);
4027
- if (parsed.or?.after) windows.push(parsed.or.after.days);
4028
- return windows.length ? Math.min(...windows) : void 0;
4029
- }
4030
- function policy_after_windows(options) {
4031
- const parsed = AutoclosePolicySchema.parse(options);
4032
- return [parsed.after, parsed.or?.after].flatMap((w) => w ? [w.days] : []);
4033
- }
4034
- function policy_keep_days(options) {
4035
- const parsed = AutoclosePolicySchema.parse(options);
4036
- return parsed.keep?.days;
4037
- }
4038
- function compile_autoclose_policy(options) {
4039
- const parsed = AutoclosePolicySchema.parse(options);
4040
- const and_preds = [];
4041
- if (parsed.after) and_preds.push(compile_after(parsed.after));
4042
- if (parsed.is) and_preds.push(compile_is(parsed.is));
4043
- if (parsed.reaches) and_preds.push(compile_reaches(parsed.reaches));
4044
- const or_preds = [];
4045
- if (parsed.or) {
4046
- if (parsed.or.after) or_preds.push(compile_after(parsed.or.after));
4047
- if (parsed.or.is) or_preds.push(compile_is(parsed.or.is));
4048
- if (parsed.or.reaches) or_preds.push(compile_reaches(parsed.or.reaches));
4049
- }
4050
- return (stream, head, count) => {
4051
- if (and_preds.length > 0 && and_preds.every((p) => p(stream, head, count))) {
4052
- return true;
4053
- }
4054
- if (or_preds.some((p) => p(stream, head, count))) {
4055
- return true;
4056
- }
4057
- return false;
4058
- };
4059
- }
4060
-
4061
- // src/internal/autoclose-window.ts
4062
- function hour_in_zone(now, timeZone) {
4063
- const parts = new Intl.DateTimeFormat("en-US", {
4064
- timeZone,
4065
- hour: "2-digit",
4066
- hourCycle: "h23"
4067
- }).formatToParts(now);
4068
- return Number(parts.find((p) => p.type === "hour")?.value);
4069
- }
4070
- function hour_in_range(hour, start, end) {
4071
- return start < end ? hour >= start && hour < end : hour >= start || hour < end;
4072
- }
4073
- function is_dst_gap_open(now, timeZone, start) {
4074
- const hour = hour_in_zone(now, timeZone);
4075
- if (hour <= start) return false;
4076
- const prev = hour_in_zone(new Date(now.getTime() - 36e5), timeZone);
4077
- return prev === start - 1 && hour === start + 1;
4078
- }
4079
- function in_autoclose_window(window, now) {
4080
- if (!window) return true;
4081
- const hour = hour_in_zone(now, window.timeZone);
4082
- return hour_in_range(hour, window.start, window.end) || is_dst_gap_open(now, window.timeZone, window.start);
4083
- }
4084
- function next_window_open(window, now) {
4085
- const start_ms = now.getTime();
4086
- for (let h = 0; h <= 48; h++) {
4087
- const candidate = new Date(start_ms + h * 36e5);
4088
- if (hour_in_zone(candidate, window.timeZone) === window.start)
4089
- return candidate;
4090
- const boundary = new Date(
4091
- candidate.getTime() - candidate.getTime() % 36e5
4092
- );
4093
- if (boundary.getTime() >= start_ms && is_dst_gap_open(boundary, window.timeZone, window.start))
4094
- return boundary;
4095
- }
4096
- return new Date(start_ms + 864e5);
4097
- }
4098
-
4099
- // src/internal/autoclose-reaction.ts
4100
- var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
4101
- function synthesize_autoclose_reactions(registry, states, read_window) {
4102
- for (const st of states.values()) {
4103
- const predicate = st.autoclose;
4104
- if (!predicate) continue;
4105
- const after_windows = st.autoclose_after_windows;
4106
- const keep_days = st.autoclose_keep_days;
4107
- const archiver = st.archive;
4108
- const reaction = {
4109
- // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
4110
- // per-aggregate `__autoclose__` key — so the autoclose reaction never
4111
- // shares a watermark with the aggregate's own reactions. A shared
4112
- // watermark would let autoclose's defer short-circuit the aggregate's
4113
- // other reactions (the "a defer affects all reactions on a stream"
4114
- // hazard). The close still targets the aggregate (`source`).
4115
- // A stream name with a regex metacharacter would read as a pattern, and a
4116
- // store may refuse it at subscribe, stopping every reaction. Such a stream
4117
- // has no autoclose: `app.close` still closes it.
4118
- resolver: (e) => is_literal_source(e.stream) ? {
4119
- target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
4120
- source: e.stream
4121
- } : void 0,
4122
- // Never block on autoclose: a transient query/store error should retry,
4123
- // not quarantine the synthetic stream.
4124
- options: { blockOnError: false, maxRetries: DEFAULT_MAX_RETRIES },
4125
- handler: async (event) => {
4126
- const aggregate = event.stream;
4127
- const window = read_window();
4128
- if (!in_autoclose_window(window, /* @__PURE__ */ new Date()))
4129
- throw new DeferSignal({ at: next_window_open(window, /* @__PURE__ */ new Date()) });
4130
- const stats = await store().query_stats([aggregate], {
4131
- count: true,
4132
- tail: keep_days !== void 0 ? true : void 0,
4133
- exclude: [TOMBSTONE_EVENT, SNAP_EVENT]
4134
- });
4135
- const entry = stats.get(aggregate);
4136
- if (!entry) return;
4137
- const head = entry.head;
4138
- if (predicate(aggregate, head, entry.count))
4139
- throw new CloseSignal({
4140
- stream: aggregate,
4141
- archive: archiver ? () => archiver(aggregate, head) : void 0,
4142
- // Ack this reaction's own watermark to the live head so the
4143
- // close-cycle guard (which matches subscriptions by source =
4144
- // aggregate) sees it caught up instead of blocking its own close.
4145
- at: head.id
4146
- });
4147
- if (keep_days !== void 0) {
4148
- const cutoff = days_before_now(keep_days);
4149
- if (entry.tail.created < cutoff)
4150
- throw new CloseSignal({
4151
- stream: aggregate,
4152
- before: cutoff,
4153
- archive: archiver ? () => archiver(aggregate, head, cutoff) : void 0,
4154
- at: head.id
4155
- });
4156
- }
4157
- const dues = [];
4158
- for (const days of after_windows)
4159
- dues.push(days_after(head.created, days));
4160
- if (keep_days !== void 0)
4161
- dues.push(days_after(entry.tail.created, keep_days));
4162
- const future = dues.map((d) => d.getTime()).filter((t) => t > Date.now());
4163
- if (future.length)
4164
- throw new DeferSignal({ at: new Date(Math.min(...future)) });
4165
- }
4166
- };
4167
- const key = `__autoclose_${st.name}`;
4168
- for (const event_name of Object.keys(st.events)) {
4169
- registry.events[event_name]?.reactions.set(
4170
- key,
4171
- reaction
4172
- );
4173
- }
4174
- }
4175
- }
4176
-
4177
4303
  // src/builders/merge.ts
4178
4304
  import { ZodObject } from "zod";
4179
4305
  function base_type_name(zodType) {
@@ -4273,8 +4399,8 @@ function merge_into_existing(state2, existing, states, actions, events) {
4273
4399
  actions: { ...existing.actions, ...state2.actions },
4274
4400
  patch: merged_patch,
4275
4401
  on: { ...existing.on, ...state2.on },
4276
- given: { ...existing.given, ...state2.given },
4277
4402
  requires: { ...existing.requires, ...state2.requires },
4403
+ loads: existing.loads || state2.loads ? { ...existing.loads, ...state2.loads } : void 0,
4278
4404
  snap: pick_declared(existing.snap, state2.snap, "snap strategy", state2.name),
4279
4405
  // Per-action retry policy is keyed by action name, so the two partials'
4280
4406
  // maps combine. Left `undefined` when neither declared one, so the
@@ -4433,25 +4559,47 @@ import { z as z4 } from "zod";
4433
4559
 
4434
4560
  // src/internal/date-reviver.ts
4435
4561
  import { z as z3 } from "zod";
4436
- function variant_schema(schema) {
4562
+ function variant_schema(schema, walk) {
4437
4563
  const shape = def_of(schema)?.shape;
4438
4564
  if (!shape) {
4439
- const dates = date_reviver_schema(schema);
4565
+ const dates = walk(schema);
4440
4566
  return { schema: dates ?? schema, dated: !!dates };
4441
4567
  }
4442
4568
  const next = {};
4443
4569
  let dated = false;
4444
4570
  for (const [key, field] of Object.entries(shape)) {
4445
- const dates = date_reviver_schema(field);
4571
+ const dates = walk(field);
4446
4572
  if (dates) dated = true;
4447
4573
  next[key] = (dates ?? field).optional();
4448
4574
  }
4449
4575
  return { schema: z3.looseObject(next), dated };
4450
4576
  }
4451
4577
  function date_reviver_schema(schema) {
4578
+ let recursive = false;
4579
+ const pass = (tie) => {
4580
+ const done = /* @__PURE__ */ new Map();
4581
+ const active = /* @__PURE__ */ new Set();
4582
+ const walk = (node) => {
4583
+ if (done.has(node)) return done.get(node);
4584
+ if (active.has(node)) {
4585
+ recursive = true;
4586
+ return tie ? z3.lazy(() => done.get(node)) : void 0;
4587
+ }
4588
+ active.add(node);
4589
+ const result = build(node, walk);
4590
+ active.delete(node);
4591
+ done.set(node, result);
4592
+ return result;
4593
+ };
4594
+ return walk(schema);
4595
+ };
4596
+ const first = pass(false);
4597
+ return first && recursive ? pass(true) : first;
4598
+ }
4599
+ function build(schema, walk) {
4452
4600
  const def = def_of(schema);
4453
4601
  if (!def) return void 0;
4454
- const inner = () => date_reviver_schema(def.innerType);
4602
+ const inner = () => walk(def.innerType);
4455
4603
  switch (def.type) {
4456
4604
  case "date":
4457
4605
  return z3.coerce.date();
@@ -4460,31 +4608,40 @@ function date_reviver_schema(schema) {
4460
4608
  if (!shape) return void 0;
4461
4609
  const dated = {};
4462
4610
  for (const [key, field] of Object.entries(shape)) {
4463
- const dates = date_reviver_schema(field);
4611
+ const dates = walk(field);
4464
4612
  if (dates) dated[key] = dates.optional();
4465
4613
  }
4466
4614
  return Object.keys(dated).length ? z3.looseObject(dated) : void 0;
4467
4615
  }
4468
4616
  case "array": {
4469
- const element = date_reviver_schema(def.element);
4617
+ const element = walk(def.element);
4470
4618
  return element && z3.array(element);
4471
4619
  }
4472
4620
  case "tuple": {
4473
- const items = def.items.map((i) => date_reviver_schema(i));
4621
+ const items = def.items.map((i) => walk(i));
4474
4622
  return items.some(Boolean) ? z3.tuple(items.map((i) => i ?? z3.unknown())) : void 0;
4475
4623
  }
4476
4624
  case "record": {
4477
- const value = date_reviver_schema(def.valueType);
4625
+ const value = walk(def.valueType);
4478
4626
  return value && z3.record(z3.string(), value);
4479
4627
  }
4480
4628
  case "union": {
4481
- const variants = def.options.map(variant_schema);
4629
+ const variants = def.options.map(
4630
+ (o) => variant_schema(o, walk)
4631
+ );
4482
4632
  return variants.some((v) => v.dated) ? z3.union(variants.map((v) => v.schema)) : void 0;
4483
4633
  }
4484
4634
  case "nullable":
4485
4635
  return inner()?.nullable();
4486
4636
  case "pipe":
4487
- return date_reviver_schema(def.out);
4637
+ return walk(def.out);
4638
+ case "lazy":
4639
+ return walk(def.getter());
4640
+ case "intersection": {
4641
+ const left = walk(def.left);
4642
+ const right = walk(def.right);
4643
+ return left && right ? left.pipe(right) : left ?? right;
4644
+ }
4488
4645
  default:
4489
4646
  return inner_of(def) ? inner() : void 0;
4490
4647
  }
@@ -4808,6 +4965,38 @@ function act() {
4808
4965
  for (const [name, gate] of built.handler_readers) _hr.set(name, gate);
4809
4966
  finalize_deprecations();
4810
4967
  if (built.advisory) log().warn(built.advisory);
4968
+ for (const [name, state2] of states) {
4969
+ if (!state2.loads) continue;
4970
+ const resolved = {};
4971
+ for (const [action2, loads] of Object.entries(state2.loads)) {
4972
+ resolved[action2] = loads.map((l) => {
4973
+ const registered = states.get(l.state.name);
4974
+ if (!registered)
4975
+ throw new Error(
4976
+ `Action "${action2}" loads "${l.state.name}", which is not registered \u2014 add it via withState/withSlice before build()`
4977
+ );
4978
+ const deprecated = l.events.find(
4979
+ (e) => _de.get(registered.name)?.has(e)
4980
+ );
4981
+ if (deprecated)
4982
+ throw new Error(
4983
+ `Action "${action2}" lists deprecated event "${deprecated}" on its load of "${registered.name}". A newer version exists: "${current_version_of(deprecated, Object.keys(registered.events))}". List the current version instead.`
4984
+ );
4985
+ return { ...l, state: registered };
4986
+ });
4987
+ }
4988
+ for (const [action2, loads] of Object.entries(resolved)) {
4989
+ const static_target = state2.on[action2]._static_emit;
4990
+ if (static_target && loads.some((l) => l.events.includes(static_target)))
4991
+ throw new Error(
4992
+ `Action "${action2}" statically emits "${static_target}", which its load lists, so nothing goes to its own stream; emit one of its own events too`
4993
+ );
4994
+ }
4995
+ const own = { ...state2, loads: resolved };
4996
+ states.set(name, own);
4997
+ for (const action2 of Object.keys(own.actions))
4998
+ registry.actions[action2] = own;
4999
+ }
4811
5000
  for (const state2 of states.values()) {
4812
5001
  if (state2.disclose) _dp.set(state2.name, state2.disclose);
4813
5002
  if (state2.autoclose) _ac.set(state2.name, state2.autoclose);
@@ -5048,9 +5237,25 @@ function action_builder(state2) {
5048
5237
  internal.options ??= {};
5049
5238
  internal.options[action2] = options;
5050
5239
  }
5051
- function given(rules) {
5052
- (internal.given ??= {})[action2] = rules;
5053
- return { requires, emit };
5240
+ const loads = [];
5241
+ function load2(state3, target, events = [], name = state3.name) {
5242
+ if (loads.some((l) => l.name === name))
5243
+ throw new Error(
5244
+ `Action "${action2}" loads "${name}" twice; name each load of the same state`
5245
+ );
5246
+ for (const event of events) {
5247
+ if (!(event in state3.events))
5248
+ throw new Error(
5249
+ `Action "${action2}" lists "${event}" on its load of "${state3.name}", which doesn't emit it`
5250
+ );
5251
+ if (loads.some((l) => l.events.includes(event)))
5252
+ throw new Error(
5253
+ `Action "${action2}" lists "${event}" on two loads; an event goes to one stream`
5254
+ );
5255
+ }
5256
+ loads.push({ state: state3, target, events, name });
5257
+ (internal.loads ??= {})[action2] = loads;
5258
+ return { loads: load2, requires, emit };
5054
5259
  }
5055
5260
  function requires(rules) {
5056
5261
  (internal.requires ??= {})[action2] = rules;
@@ -5071,7 +5276,7 @@ function action_builder(state2) {
5071
5276
  }
5072
5277
  return builder;
5073
5278
  }
5074
- return { given, requires, emit };
5279
+ return { loads: load2, requires, emit };
5075
5280
  },
5076
5281
  snap(snap2) {
5077
5282
  internal.snap = snap2;
@@ -5295,6 +5500,7 @@ export {
5295
5500
  CsvFile,
5296
5501
  DEFAULT_LANE,
5297
5502
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
5503
+ DEFAULT_QUERY_STREAMS_LIMIT,
5298
5504
  DEFAULT_SETTLE_DEBOUNCE_MS,
5299
5505
  DeferSignal,
5300
5506
  Environments,
@@ -5303,7 +5509,6 @@ export {
5303
5509
  ExitCodes,
5304
5510
  InMemoryCache,
5305
5511
  InMemoryStore,
5306
- InvariantError,
5307
5512
  LogLevels,
5308
5513
  NonRetryableError,
5309
5514
  PackageSchema,
@@ -5320,12 +5525,14 @@ export {
5320
5525
  ZodEmpty,
5321
5526
  act,
5322
5527
  cache,
5528
+ check_appends,
5323
5529
  config,
5324
5530
  dateReviver,
5325
5531
  default_scope,
5326
5532
  dispose,
5327
5533
  disposeAndExit,
5328
5534
  extend,
5535
+ fair_slots,
5329
5536
  is_literal_source,
5330
5537
  log,
5331
5538
  pii_fields,