@rotorsoft/act 1.32.30 → 1.33.1

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 (123) hide show
  1. package/README.md +0 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/act.d.ts +96 -385
  4. package/dist/@types/act.d.ts.map +1 -1
  5. package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
  6. package/dist/@types/builders/act-builder.d.ts +2 -115
  7. package/dist/@types/builders/act-builder.d.ts.map +1 -1
  8. package/dist/@types/builders/builder-utils.d.ts +1 -1
  9. package/dist/@types/builders/builder-utils.d.ts.map +1 -1
  10. package/dist/@types/builders/event-builder.d.ts +5 -5
  11. package/dist/@types/builders/event-builder.d.ts.map +1 -1
  12. package/dist/@types/builders/merge.d.ts +1 -1
  13. package/dist/@types/builders/merge.d.ts.map +1 -1
  14. package/dist/@types/builders/projection-builder.d.ts +2 -144
  15. package/dist/@types/builders/projection-builder.d.ts.map +1 -1
  16. package/dist/@types/builders/slice-builder.d.ts +2 -71
  17. package/dist/@types/builders/slice-builder.d.ts.map +1 -1
  18. package/dist/@types/builders/state-builder.d.ts +2 -376
  19. package/dist/@types/builders/state-builder.d.ts.map +1 -1
  20. package/dist/@types/config.d.ts +6 -43
  21. package/dist/@types/config.d.ts.map +1 -1
  22. package/dist/@types/csv.d.ts +1 -15
  23. package/dist/@types/csv.d.ts.map +1 -1
  24. package/dist/@types/index.d.ts +0 -1
  25. package/dist/@types/index.d.ts.map +1 -1
  26. package/dist/@types/internal/autoclose-policy.d.ts +1 -59
  27. package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
  28. package/dist/@types/internal/autoclose-reaction.d.ts +1 -2
  29. package/dist/@types/internal/autoclose-reaction.d.ts.map +1 -1
  30. package/dist/@types/internal/autoclose-window.d.ts +1 -1
  31. package/dist/@types/internal/autoclose-window.d.ts.map +1 -1
  32. package/dist/@types/internal/circuit-breaker.d.ts +1 -2
  33. package/dist/@types/internal/circuit-breaker.d.ts.map +1 -1
  34. package/dist/@types/{builders/build-classify.d.ts → internal/classify.d.ts} +2 -15
  35. package/dist/@types/internal/classify.d.ts.map +1 -0
  36. package/dist/@types/internal/close-cycle.d.ts.map +1 -1
  37. package/dist/@types/internal/config.d.ts +2 -42
  38. package/dist/@types/internal/config.d.ts.map +1 -1
  39. package/dist/@types/internal/correlate-cycle.d.ts +1 -1
  40. package/dist/@types/internal/correlate-cycle.d.ts.map +1 -1
  41. package/dist/@types/internal/{correlator.d.ts → correlation-id.d.ts} +1 -1
  42. package/dist/@types/internal/correlation-id.d.ts.map +1 -0
  43. package/dist/@types/internal/defer-config.d.ts.map +1 -1
  44. package/dist/@types/internal/drain-cycle.d.ts +1 -1
  45. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  46. package/dist/@types/internal/{drain.d.ts → drain-ops.d.ts} +1 -1
  47. package/dist/@types/internal/drain-ops.d.ts.map +1 -0
  48. package/dist/@types/internal/event-sourcing.d.ts +1 -8
  49. package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
  50. package/dist/@types/internal/{sensitive.d.ts → pii.d.ts} +4 -71
  51. package/dist/@types/internal/pii.d.ts.map +1 -0
  52. package/dist/@types/internal/projection-fold.d.ts +1 -2
  53. package/dist/@types/internal/projection-fold.d.ts.map +1 -1
  54. package/dist/@types/internal/{close-signal.d.ts → reaction-dispatch.d.ts} +47 -2
  55. package/dist/@types/internal/reaction-dispatch.d.ts.map +1 -0
  56. package/dist/@types/internal/report-once.d.ts +1 -1
  57. package/dist/@types/internal/settle.d.ts +21 -12
  58. package/dist/@types/internal/settle.d.ts.map +1 -1
  59. package/dist/@types/internal/tracing.d.ts +3 -3
  60. package/dist/@types/internal/tracing.d.ts.map +1 -1
  61. package/dist/@types/ports.d.ts +3 -43
  62. package/dist/@types/ports.d.ts.map +1 -1
  63. package/dist/@types/scoped.d.ts +1 -6
  64. package/dist/@types/scoped.d.ts.map +1 -1
  65. package/dist/@types/sensitive.d.ts +56 -0
  66. package/dist/@types/sensitive.d.ts.map +1 -0
  67. package/dist/@types/test/index.d.ts +1 -0
  68. package/dist/@types/test/index.d.ts.map +1 -1
  69. package/dist/@types/test/sandbox.d.ts +2 -26
  70. package/dist/@types/test/sandbox.d.ts.map +1 -1
  71. package/dist/@types/types/act.d.ts +214 -0
  72. package/dist/@types/types/act.d.ts.map +1 -0
  73. package/dist/@types/types/action.d.ts +69 -2
  74. package/dist/@types/types/action.d.ts.map +1 -1
  75. package/dist/@types/types/builders.d.ts +721 -0
  76. package/dist/@types/types/builders.d.ts.map +1 -0
  77. package/dist/@types/types/errors.d.ts +31 -1
  78. package/dist/@types/types/errors.d.ts.map +1 -1
  79. package/dist/@types/types/index.d.ts +38 -0
  80. package/dist/@types/types/index.d.ts.map +1 -1
  81. package/dist/@types/types/ports.d.ts +20 -18
  82. package/dist/@types/types/ports.d.ts.map +1 -1
  83. package/dist/@types/types/reaction.d.ts +11 -1
  84. package/dist/@types/types/reaction.d.ts.map +1 -1
  85. package/dist/@types/types/sandbox.d.ts +31 -0
  86. package/dist/@types/types/sandbox.d.ts.map +1 -0
  87. package/dist/@types/types/schemas.d.ts +12 -17
  88. package/dist/@types/types/schemas.d.ts.map +1 -1
  89. package/dist/chunk-NNWP65YM.cjs +264 -0
  90. package/dist/chunk-NNWP65YM.cjs.map +1 -0
  91. package/dist/{chunk-X3MMQIIP.js → chunk-NYH6FNTM.js} +174 -22
  92. package/dist/chunk-NYH6FNTM.js.map +1 -0
  93. package/dist/{chunk-HHKLVRR4.js → chunk-TJ4JRRUR.js} +38 -57
  94. package/dist/chunk-TJ4JRRUR.js.map +1 -0
  95. package/dist/chunk-YKL44PYX.cjs +1233 -0
  96. package/dist/chunk-YKL44PYX.cjs.map +1 -0
  97. package/dist/index.cjs +1954 -3440
  98. package/dist/index.cjs.map +1 -1
  99. package/dist/index.js +911 -1063
  100. package/dist/index.js.map +1 -1
  101. package/dist/test/index.cjs +16 -1113
  102. package/dist/test/index.cjs.map +1 -1
  103. package/dist/test/index.js +2 -2
  104. package/dist/test/index.js.map +1 -1
  105. package/dist/types/index.cjs +58 -276
  106. package/dist/types/index.cjs.map +1 -1
  107. package/dist/types/index.js +15 -1
  108. package/package.json +2 -2
  109. package/dist/@types/builders/build-classify.d.ts.map +0 -1
  110. package/dist/@types/builders/builder-base.d.ts +0 -70
  111. package/dist/@types/builders/builder-base.d.ts.map +0 -1
  112. package/dist/@types/builders/reaction-builder.d.ts +0 -55
  113. package/dist/@types/builders/reaction-builder.d.ts.map +0 -1
  114. package/dist/@types/internal/close-signal.d.ts.map +0 -1
  115. package/dist/@types/internal/correlator.d.ts.map +0 -1
  116. package/dist/@types/internal/defer-signal.d.ts +0 -32
  117. package/dist/@types/internal/defer-signal.d.ts.map +0 -1
  118. package/dist/@types/internal/drain.d.ts.map +0 -1
  119. package/dist/@types/internal/index.d.ts +0 -38
  120. package/dist/@types/internal/index.d.ts.map +0 -1
  121. package/dist/@types/internal/sensitive.d.ts.map +0 -1
  122. package/dist/chunk-HHKLVRR4.js.map +0 -1
  123. package/dist/chunk-X3MMQIIP.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import {
2
2
  ConsoleLogger,
3
- DEFAULT_LANE,
4
- ExitCodes,
3
+ DEFAULT_BACKOFF,
4
+ DEFAULT_BLOCK_ON_ERROR,
5
+ DEFAULT_EVENT_LIMIT,
6
+ DEFAULT_LEASE_MILLIS,
7
+ DEFAULT_MAX_RETRIES,
8
+ DEFAULT_STREAM_LIMIT,
5
9
  InMemoryCache,
6
10
  InMemoryStore,
7
11
  LruMap,
12
+ MAX_SHUTDOWN_GRACE_MS,
8
13
  PackageSchema,
9
- SNAP_EVENT,
10
- TOMBSTONE_EVENT,
11
14
  cache,
12
15
  config,
13
16
  current_autoclose_window,
@@ -23,37 +26,51 @@ import {
23
26
  make_run_scoped,
24
27
  port,
25
28
  register_weak_disposer,
29
+ resolveActConfig,
30
+ resolveActionConfig,
31
+ resolveAutocloseConfig,
32
+ resolveAutocloseConfig2,
33
+ resolveCircuitBreakerConfig,
34
+ resolveDrainConfig,
35
+ resolveFoldConfig,
36
+ resolveLaneConfig,
37
+ resolveReactionConfig,
38
+ resolveSettleConfig,
39
+ resolveShutdownConfig,
26
40
  sleep,
27
41
  store,
28
42
  validate
29
- } from "./chunk-X3MMQIIP.js";
43
+ } from "./chunk-NYH6FNTM.js";
30
44
  import {
31
45
  ActorSchema,
32
46
  CausationEventSchema,
33
47
  CommittedMetaSchema,
34
48
  ConcurrencyError,
49
+ DEFAULT_LANE,
50
+ DEFAULT_MAX_SUBSCRIBED_STREAMS,
51
+ DEFAULT_SETTLE_DEBOUNCE_MS,
52
+ DeferSignal,
35
53
  Environments,
36
54
  Errors,
37
55
  EventMetaSchema,
38
- IDENTITY_GATE,
56
+ ExitCodes,
39
57
  InvariantError,
40
58
  LogLevels,
41
59
  NonRetryableError,
42
60
  QuerySchema,
43
61
  REDACTED,
44
62
  SHREDDED,
63
+ SNAP_EVENT,
45
64
  StoreError,
46
65
  StreamClosedError,
66
+ TOMBSTONE_EVENT,
47
67
  TargetSchema,
48
68
  ValidationError,
49
69
  ZodEmpty,
50
- make_gate,
51
70
  pii_fields,
52
71
  pii_schemas,
53
- pii_split,
54
- pii_strip,
55
72
  sensitive
56
- } from "./chunk-HHKLVRR4.js";
73
+ } from "./chunk-TJ4JRRUR.js";
57
74
 
58
75
  // src/signals.ts
59
76
  process.once("SIGINT", async (arg) => {
@@ -76,55 +93,6 @@ process.once("unhandledRejection", async (arg) => {
76
93
  // src/act.ts
77
94
  import EventEmitter from "events";
78
95
 
79
- // src/builders/build-classify.ts
80
- var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
81
- function classify_registry(registry, states) {
82
- const statics = /* @__PURE__ */ new Map();
83
- const target_lanes = /* @__PURE__ */ new Map();
84
- const event_to_lanes = /* @__PURE__ */ new Map();
85
- for (const [name, register] of Object.entries(registry.events)) {
86
- for (const reaction of register.reactions.values()) {
87
- if (typeof reaction.resolver === "function") {
88
- event_to_lanes.set(name, ALL_LANES);
89
- } else {
90
- const { target, source, priority = 0, lane } = reaction.resolver;
91
- const lane_name = lane ?? "default";
92
- const existing_lanes = event_to_lanes.get(name);
93
- if (existing_lanes !== ALL_LANES) {
94
- const set = existing_lanes ?? /* @__PURE__ */ new Set();
95
- set.add(lane_name);
96
- event_to_lanes.set(name, set);
97
- }
98
- const recorded_lane = target_lanes.get(target);
99
- if (recorded_lane === void 0) {
100
- target_lanes.set(target, lane_name);
101
- } else if (recorded_lane !== lane_name) {
102
- throw new Error(
103
- `Stream "${target}" has conflicting lane assignments ("${recorded_lane}" vs "${lane_name}")`
104
- );
105
- }
106
- const existing = statics.get(target);
107
- if (!existing) {
108
- statics.set(target, { stream: target, source, priority, lane });
109
- } else if (priority > existing.priority) {
110
- statics.set(target, { ...existing, priority });
111
- }
112
- }
113
- }
114
- }
115
- const event_to_state = /* @__PURE__ */ new Map();
116
- for (const merged of states.values()) {
117
- for (const event_name of Object.keys(merged.events)) {
118
- event_to_state.set(event_name, merged);
119
- }
120
- }
121
- return {
122
- static_targets: [...statics.values()],
123
- event_to_state,
124
- event_to_lanes
125
- };
126
- }
127
-
128
96
  // src/internal/event-versions.ts
129
97
  var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
130
98
  function parse(name) {
@@ -683,495 +651,68 @@ var PASS_FACTORIES = {
683
651
  "unreadable-events": make_unreadable_events_pass
684
652
  };
685
653
 
686
- // src/internal/autoclose-policy.ts
687
- import { z } from "zod";
688
- var DAY_MS = 864e5;
689
- var MIN_AFTER_DAYS = 1 / 1440;
690
- var MIN_KEEP_DAYS = 1;
691
- function days_after(date, days) {
692
- return new Date(date.getTime() + days * DAY_MS);
693
- }
694
- function days_before_now(days) {
695
- return new Date(Date.now() - days * DAY_MS);
696
- }
697
- var window_schema = (field, min_days, floor) => z.object({
698
- days: z.number({ message: `autocloses: ${field}.days must be a number` }).positive(`autocloses: ${field}.days must be > 0`)
699
- }).refine((o) => o.days >= min_days, {
700
- message: `autocloses: ${field} resolves to < ${floor}, which is too short to be a meaningful retention window`
701
- });
702
- var AfterSchema = window_schema("after", MIN_AFTER_DAYS, "one minute");
703
- var KeepSchema = window_schema("keep", MIN_KEEP_DAYS, "one day");
704
- var IsSchema = z.union([
705
- z.string().min(1, "autocloses: is must be a non-empty event name"),
706
- z.array(
707
- z.string().min(1, "autocloses: is entries must be non-empty event names")
708
- ).min(1, "autocloses: is must include at least one event name").readonly()
709
- ]);
710
- 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");
711
- var OrBlockSchema = z.object({
712
- after: AfterSchema.optional(),
713
- is: IsSchema.optional(),
714
- reaches: ReachesSchema.optional()
715
- }).strict().refine(
716
- (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0,
717
- {
718
- message: "autocloses: `or` block must include at least one of after / is / reaches"
654
+ // src/internal/circuit-breaker.ts
655
+ var CircuitBreaker = class {
656
+ _failures = 0;
657
+ _opened_at;
658
+ _wake;
659
+ _threshold;
660
+ _cooldown_ms;
661
+ _hooks;
662
+ constructor(config2, hooks = {}) {
663
+ this._threshold = config2.failureThreshold;
664
+ this._cooldown_ms = config2.cooldownMs;
665
+ this._hooks = hooks;
719
666
  }
720
- );
721
- var AutoclosePolicySchema = z.object({
722
- after: AfterSchema.optional(),
723
- is: IsSchema.optional(),
724
- reaches: ReachesSchema.optional(),
725
- or: OrBlockSchema.optional(),
726
- keep: KeepSchema.optional()
727
- }).strict().refine(
728
- (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0 || o.keep !== void 0,
729
- {
730
- message: "autocloses: at least one of after / is / reaches / or / keep must be specified \u2014 empty `{}` is a misconfiguration"
667
+ /**
668
+ * True when the store has failed at least once since the last pass that
669
+ * went all the way through. The breaker can still read "closed" here: it
670
+ * only opens after several failures in a row, so this catches the first
671
+ * one, which `state()` cannot.
672
+ */
673
+ get failing() {
674
+ return this._failures > 0;
731
675
  }
732
- );
733
- function compile_after(after) {
734
- return (_stream, head) => days_after(head.created, after.days) <= /* @__PURE__ */ new Date();
735
- }
736
- function compile_is(is) {
737
- const set = new Set(typeof is === "string" ? [is] : is);
738
- return (_stream, head) => set.has(head.name);
739
- }
740
- function compile_reaches(reaches) {
741
- return (_stream, _head, count) => count >= reaches;
742
- }
743
- function policy_min_after_days(options) {
744
- const parsed = AutoclosePolicySchema.parse(options);
745
- const windows = [];
746
- if (parsed.after) windows.push(parsed.after.days);
747
- if (parsed.or?.after) windows.push(parsed.or.after.days);
748
- return windows.length ? Math.min(...windows) : void 0;
749
- }
750
- function policy_keep_days(options) {
751
- const parsed = AutoclosePolicySchema.parse(options);
752
- return parsed.keep?.days;
753
- }
754
- function compile_autoclose_policy(options) {
755
- const parsed = AutoclosePolicySchema.parse(options);
756
- const and_preds = [];
757
- if (parsed.after) and_preds.push(compile_after(parsed.after));
758
- if (parsed.is) and_preds.push(compile_is(parsed.is));
759
- if (parsed.reaches) and_preds.push(compile_reaches(parsed.reaches));
760
- const or_preds = [];
761
- if (parsed.or) {
762
- if (parsed.or.after) or_preds.push(compile_after(parsed.or.after));
763
- if (parsed.or.is) or_preds.push(compile_is(parsed.or.is));
764
- if (parsed.or.reaches) or_preds.push(compile_reaches(parsed.or.reaches));
676
+ /** How long the store is left alone after a failure before a retry. */
677
+ get cooldown_ms() {
678
+ return this._cooldown_ms;
765
679
  }
766
- return (stream, head, count) => {
767
- if (and_preds.length > 0 && and_preds.every((p) => p(stream, head, count))) {
768
- return true;
769
- }
770
- if (or_preds.some((p) => p(stream, head, count))) {
771
- return true;
772
- }
773
- return false;
774
- };
775
- }
776
-
777
- // src/internal/autoclose-window.ts
778
- function hour_in_zone(now, timeZone) {
779
- const parts = new Intl.DateTimeFormat("en-US", {
780
- timeZone,
781
- hour: "2-digit",
782
- hourCycle: "h23"
783
- }).formatToParts(now);
784
- return Number(parts.find((p) => p.type === "hour")?.value);
785
- }
786
- function hour_in_range(hour, start, end) {
787
- return start < end ? hour >= start && hour < end : hour >= start || hour < end;
788
- }
789
- function is_dst_gap_open(now, timeZone, start) {
790
- const hour = hour_in_zone(now, timeZone);
791
- if (hour <= start) return false;
792
- const prev = hour_in_zone(new Date(now.getTime() - 36e5), timeZone);
793
- return prev === start - 1 && hour === start + 1;
794
- }
795
- function in_autoclose_window(window, now) {
796
- if (!window) return true;
797
- const hour = hour_in_zone(now, window.timeZone);
798
- return hour_in_range(hour, window.start, window.end) || is_dst_gap_open(now, window.timeZone, window.start);
799
- }
800
- function next_window_open(window, now) {
801
- const start_ms = now.getTime();
802
- for (let h = 0; h <= 48; h++) {
803
- const candidate = new Date(start_ms + h * 36e5);
804
- if (hour_in_zone(candidate, window.timeZone) === window.start)
805
- return candidate;
806
- const boundary = new Date(
807
- candidate.getTime() - candidate.getTime() % 36e5
808
- );
809
- if (boundary.getTime() >= start_ms && is_dst_gap_open(boundary, window.timeZone, window.start))
810
- return boundary;
680
+ /** Current state given the wall-clock `now`. */
681
+ state(now) {
682
+ if (this._opened_at === void 0) return "closed";
683
+ return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
684
+ }
685
+ /** A store op passed — reset to closed and cancel any pending retry. */
686
+ passed() {
687
+ this._failures = 0;
688
+ this._opened_at = void 0;
689
+ this._clear_wake();
811
690
  }
812
- return new Date(start_ms + 864e5);
813
- }
814
-
815
- // src/internal/close-signal.ts
816
- var CloseSignal = class extends Error {
817
- /**
818
- * Stream to close. Omitted → the reaction's own lease stream (a self-close).
819
- * The synthesized autoclose reaction sets it to the aggregate stream, since
820
- * its lease runs on a synthetic `__autoclose__` target.
821
- */
822
- stream;
823
- /** Optional archive callback to run while the stream is guarded. */
824
- archive;
825
691
  /**
826
- * Watermark to ack the requesting reaction to before the close runs. The
827
- * close-cycle safety guard skips a stream whose subscriptions (matched by
828
- * source) lag the head; the autoclose reaction's `source` is the aggregate,
829
- * so it must advance its own watermark to the live head id it evaluated
830
- * against — otherwise it blocks its own close. Defaults to the triggering
831
- * event id when omitted.
692
+ * A store op failed. Opens the breaker when the consecutive-failure
693
+ * threshold is reached, or immediately re-opens (restarting the cooldown)
694
+ * if a half-open trial just failed. Returns the resulting state.
695
+ *
696
+ * Callers gate on the state directly — `state(now) === "open"` means skip.
697
+ * Every failure (re)schedules the `on_retry` wake, so one failed pass on
698
+ * an idle app retries without waiting for the breaker to open; always
699
+ * surfaces the failure via `on_error`.
832
700
  */
833
- at;
701
+ failed(now, error) {
702
+ const circuit = this._advance(now);
703
+ this._schedule_wake();
704
+ this._hooks.on_error?.(error, circuit);
705
+ return circuit;
706
+ }
707
+ /** Cancel the pending retry timer. Idempotent; call on shutdown. */
708
+ stop() {
709
+ this._clear_wake();
710
+ }
834
711
  /**
835
- * Windowed close: prune events older than this cutoff behind
836
- * the closest safe snapshot instead of retiring the stream. Thrown by
837
- * the autoclose reaction of a `.autocloses({ keep })` state; flows
838
- * into {@link CloseTarget.before}. Omitted → a full close.
839
- */
840
- before;
841
- constructor(opts) {
842
- super("reaction requested close");
843
- this.name = "CloseSignal";
844
- this.stream = opts?.stream;
845
- this.archive = opts?.archive;
846
- this.at = opts?.at;
847
- this.before = opts?.before;
848
- }
849
- };
850
-
851
- // src/internal/config.ts
852
- import { z as z2 } from "zod";
853
- var BackoffOptionsSchema = z2.object({
854
- strategy: z2.enum(["fixed", "linear", "exponential"]),
855
- baseMs: z2.number().min(0),
856
- maxMs: z2.number().gt(0).optional(),
857
- jitter: z2.boolean().optional()
858
- });
859
- var DEFAULT_BACKOFF = {
860
- strategy: "fixed",
861
- baseMs: 1e4
862
- };
863
- var DEFAULT_MAX_RETRIES = 3;
864
- var DEFAULT_BLOCK_ON_ERROR = true;
865
- var ReactionOptionsSchema = z2.object({
866
- blockOnError: z2.boolean(),
867
- maxRetries: z2.number().int().min(0),
868
- backoff: BackoffOptionsSchema.optional()
869
- });
870
- function resolveReactionConfig(options) {
871
- return ReactionOptionsSchema.parse(options);
872
- }
873
- var ActionOptionsSchema = z2.object({
874
- maxRetries: z2.number().int().min(0).optional(),
875
- backoff: BackoffOptionsSchema.optional()
876
- });
877
- function resolveActionConfig(options) {
878
- return ActionOptionsSchema.parse(options);
879
- }
880
- var LaneConfigSchema = z2.object({
881
- name: z2.string().min(1),
882
- leaseMillis: z2.number().min(0).optional(),
883
- streamLimit: z2.number().int().min(0).optional(),
884
- cycleMs: z2.number().min(0).optional()
885
- });
886
- function resolveLaneConfig(options) {
887
- LaneConfigSchema.parse(options);
888
- return options;
889
- }
890
- var DEFAULT_STREAM_LIMIT = 10;
891
- var DEFAULT_EVENT_LIMIT = 10;
892
- var DEFAULT_LEASE_MILLIS = 1e4;
893
- var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
894
- var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
895
- var DrainOptionsSchema = z2.object({
896
- streamLimit: z2.number().int().min(0).optional(),
897
- eventLimit: z2.number().int().min(0).optional(),
898
- leaseMillis: z2.number().min(0).optional()
899
- });
900
- function resolveDrainConfig(options) {
901
- if (options === void 0) return void 0;
902
- DrainOptionsSchema.parse(options);
903
- return options;
904
- }
905
- var SettleOptionsSchema = DrainOptionsSchema.extend({
906
- debounceMs: z2.number().min(0).optional(),
907
- // `maxPasses` defaults to Infinity (no cap) when omitted; a present value is
908
- // a non-negative integer — `0` is legal and means "run no passes".
909
- //
910
- // `Infinity` is accepted explicitly. It is the documented default
911
- // (`Act.settle`'s doc-comment and `SettleOptions.maxPasses` both name it),
912
- // and `settle.ts` destructures `maxPasses = Infinity`, so the one value the
913
- // docs advertise was the one value `z.number().int()` refused — Zod 4
914
- // rejects `±Infinity`, and an integer check cannot pass it either. Wrapper
915
- // code normalizing an optional cap (`maxPasses: opts.maxPasses ?? Infinity`)
916
- // is the natural way to hit it. Accepting it also honors this module's own
917
- // rule that a value which works today is never newly rejected.
918
- maxPasses: z2.union([z2.number().int().min(0), z2.literal(Number.POSITIVE_INFINITY)]).optional()
919
- }).loose();
920
- function resolveSettleConfig(options) {
921
- if (options === void 0) return void 0;
922
- SettleOptionsSchema.parse(options);
923
- return options;
924
- }
925
- var MAX_SHUTDOWN_GRACE_MS = 3e4;
926
- var ShutdownOptionsSchema = z2.object({
927
- // `0` is legal and is exactly today's behavior: stop scheduling and
928
- // return without waiting.
929
- graceMs: z2.number().min(0).optional()
930
- }).loose();
931
- function resolveShutdownConfig(options) {
932
- if (options === void 0) return void 0;
933
- ShutdownOptionsSchema.parse(options);
934
- return options;
935
- }
936
- var ActOptionsSchema = z2.object({
937
- maxSubscribedStreams: z2.number().int().min(1).optional(),
938
- settleDebounceMs: z2.number().int().min(0).optional()
939
- }).loose();
940
- function resolveActConfig(options) {
941
- if (options === void 0) return void 0;
942
- ActOptionsSchema.parse(options);
943
- return options;
944
- }
945
- var DEFAULT_AUTOCLOSE_CYCLE_MINUTES = 720;
946
- var DEFAULT_CLOSE_BATCH_SIZE = 64;
947
- var DEFAULT_CLOSE_YIELD_MS = 0;
948
- var DEFAULT_AUTOCLOSE_WINDOW_TZ = "UTC";
949
- function is_valid_time_zone(tz) {
950
- try {
951
- new Intl.DateTimeFormat("en-US", { timeZone: tz });
952
- return true;
953
- } catch {
954
- return false;
955
- }
956
- }
957
- var AutocloseWindowSchema = z2.object({
958
- start: z2.number().int().min(0).max(23, { message: "autocloseWindow.start must be an hour in [0, 23]" }),
959
- end: z2.number().int().min(0).max(23, { message: "autocloseWindow.end must be an hour in [0, 23]" }),
960
- timeZone: z2.string().refine(is_valid_time_zone, {
961
- message: "autocloseWindow.timeZone must be a valid IANA time zone"
962
- }).default(DEFAULT_AUTOCLOSE_WINDOW_TZ)
963
- }).refine((w) => w.start !== w.end, {
964
- message: "autocloseWindow.start and end must differ \u2014 an empty window disables autoclose"
965
- });
966
- var AutocloseOptionsSchema = z2.object({
967
- autocloseCycleMinutes: z2.number().int().min(1).max(1440).default(DEFAULT_AUTOCLOSE_CYCLE_MINUTES),
968
- closeBatchSize: z2.number().int().min(1).max(1024).default(DEFAULT_CLOSE_BATCH_SIZE),
969
- closeYieldMs: z2.number().min(0).max(1e3).default(DEFAULT_CLOSE_YIELD_MS),
970
- closeOnError: z2.boolean().default(false),
971
- autocloseWindow: AutocloseWindowSchema.optional()
972
- });
973
- function resolveAutocloseConfig(options) {
974
- return AutocloseOptionsSchema.parse({
975
- autocloseCycleMinutes: options?.autocloseCycleMinutes,
976
- closeBatchSize: options?.closeBatchSize,
977
- closeYieldMs: options?.closeYieldMs,
978
- closeOnError: options?.closeOnError,
979
- autocloseWindow: options?.autocloseWindow
980
- });
981
- }
982
- var DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 5;
983
- var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
984
- var CircuitBreakerOptionsSchema = z2.object({
985
- failureThreshold: z2.number().int().min(1).default(DEFAULT_CIRCUIT_FAILURE_THRESHOLD),
986
- cooldownMs: z2.number().int().min(100).max(36e5).default(DEFAULT_CIRCUIT_COOLDOWN_MS)
987
- });
988
- var resolveCircuitBreakerConfig = (options) => CircuitBreakerOptionsSchema.parse(options ?? {});
989
- var DEFAULT_FOLD_FLUSH_EVERY = 1e3;
990
- var DEFAULT_MAX_CACHED_STATES = 1e4;
991
- var FoldOptionsSchema = z2.object({
992
- flushEvery: z2.number().int().min(1).default(DEFAULT_FOLD_FLUSH_EVERY),
993
- maxCachedStates: z2.number().int().min(1).default(DEFAULT_MAX_CACHED_STATES)
994
- });
995
- function resolveFoldConfig(options) {
996
- return FoldOptionsSchema.parse(options);
997
- }
998
-
999
- // src/internal/defer-signal.ts
1000
- var DeferSignal = class extends Error {
1001
- /**
1002
- * The unresolved schedule. The dispatcher turns this into an absolute
1003
- * due-time by resolving it against the triggering event.
1004
- */
1005
- when;
1006
- constructor(when) {
1007
- super("reaction deferred");
1008
- this.name = "DeferSignal";
1009
- this.when = when;
1010
- }
1011
- };
1012
-
1013
- // src/internal/autoclose-reaction.ts
1014
- var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
1015
- function synthesize_autoclose_reactions(registry, states, read_window) {
1016
- for (const st of states.values()) {
1017
- const predicate = st.autoclose;
1018
- if (!predicate) continue;
1019
- const after_days = st.autoclose_after_days;
1020
- const keep_days = st.autoclose_keep_days;
1021
- const archiver = st.archive;
1022
- const reaction = {
1023
- // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
1024
- // per-aggregate `__autoclose__` key — so the autoclose reaction never
1025
- // shares a watermark with the aggregate's own reactions. A shared
1026
- // watermark would let autoclose's defer short-circuit the aggregate's
1027
- // other reactions (the "a defer affects all reactions on a stream"
1028
- // hazard). The close still targets the aggregate (`source`).
1029
- resolver: (e) => ({
1030
- target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
1031
- source: e.stream
1032
- }),
1033
- // Never block on autoclose: a transient query/store error should retry,
1034
- // not quarantine the synthetic stream.
1035
- options: { blockOnError: false, maxRetries: DEFAULT_MAX_RETRIES },
1036
- handler: async (event) => {
1037
- const aggregate = event.stream;
1038
- const window = read_window();
1039
- if (!in_autoclose_window(window, /* @__PURE__ */ new Date()))
1040
- throw new DeferSignal({ at: next_window_open(window, /* @__PURE__ */ new Date()) });
1041
- const stats = await store().query_stats([aggregate], {
1042
- count: true,
1043
- tail: keep_days !== void 0 ? true : void 0,
1044
- exclude: [TOMBSTONE_EVENT, SNAP_EVENT]
1045
- });
1046
- const entry = stats.get(aggregate);
1047
- if (!entry) return;
1048
- const head = entry.head;
1049
- if (predicate(aggregate, head, entry.count))
1050
- throw new CloseSignal({
1051
- stream: aggregate,
1052
- archive: archiver ? () => archiver(aggregate, head) : void 0,
1053
- // Ack this reaction's own watermark to the live head so the
1054
- // close-cycle guard (which matches subscriptions by source =
1055
- // aggregate) sees it caught up instead of blocking its own close.
1056
- at: head.id
1057
- });
1058
- if (keep_days !== void 0) {
1059
- const cutoff = days_before_now(keep_days);
1060
- if (entry.tail.created < cutoff)
1061
- throw new CloseSignal({
1062
- stream: aggregate,
1063
- before: cutoff,
1064
- archive: archiver ? () => archiver(aggregate, head, cutoff) : void 0,
1065
- at: head.id
1066
- });
1067
- }
1068
- const dues = [];
1069
- if (after_days !== void 0)
1070
- dues.push(days_after(head.created, after_days));
1071
- if (keep_days !== void 0)
1072
- dues.push(days_after(entry.tail.created, keep_days));
1073
- const future = dues.map((d) => d.getTime()).filter((t) => t > Date.now());
1074
- if (future.length)
1075
- throw new DeferSignal({ at: new Date(Math.min(...future)) });
1076
- }
1077
- };
1078
- const key = `__autoclose_${st.name}`;
1079
- for (const event_name of Object.keys(st.events)) {
1080
- registry.events[event_name]?.reactions.set(
1081
- key,
1082
- reaction
1083
- );
1084
- }
1085
- }
1086
- }
1087
-
1088
- // src/internal/backoff.ts
1089
- function compute_backoff_delay(retry, opts) {
1090
- if (!opts || opts.baseMs <= 0) return 0;
1091
- const r = Math.max(0, retry);
1092
- let delay;
1093
- switch (opts.strategy) {
1094
- case "fixed":
1095
- delay = opts.baseMs;
1096
- break;
1097
- case "linear":
1098
- delay = opts.baseMs * (r + 1);
1099
- break;
1100
- case "exponential":
1101
- delay = opts.baseMs * 2 ** r;
1102
- if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
1103
- break;
1104
- default: {
1105
- const _never = opts.strategy;
1106
- throw new Error(`unknown backoff strategy: ${String(_never)}`);
1107
- }
1108
- }
1109
- if (opts.jitter) delay = delay * (0.5 + Math.random());
1110
- return Math.max(0, Math.floor(delay));
1111
- }
1112
-
1113
- // src/internal/circuit-breaker.ts
1114
- var CircuitBreaker = class {
1115
- _failures = 0;
1116
- _opened_at;
1117
- _wake;
1118
- _threshold;
1119
- _cooldown_ms;
1120
- _hooks;
1121
- constructor(config2, hooks = {}) {
1122
- this._threshold = config2.failureThreshold;
1123
- this._cooldown_ms = config2.cooldownMs;
1124
- this._hooks = hooks;
1125
- }
1126
- /**
1127
- * True when the store has failed at least once since the last pass that
1128
- * went all the way through. The breaker can still read "closed" here: it
1129
- * only opens after several failures in a row, so this catches the first
1130
- * one, which `state()` cannot.
1131
- */
1132
- get failing() {
1133
- return this._failures > 0;
1134
- }
1135
- /** How long the store is left alone after a failure before a retry. */
1136
- get cooldown_ms() {
1137
- return this._cooldown_ms;
1138
- }
1139
- /** Current state given the wall-clock `now`. */
1140
- state(now) {
1141
- if (this._opened_at === void 0) return "closed";
1142
- return now - this._opened_at >= this._cooldown_ms ? "half-open" : "open";
1143
- }
1144
- /** A store op passed — reset to closed and cancel any pending retry. */
1145
- passed() {
1146
- this._failures = 0;
1147
- this._opened_at = void 0;
1148
- this._clear_wake();
1149
- }
1150
- /**
1151
- * A store op failed. Opens the breaker when the consecutive-failure
1152
- * threshold is reached, or immediately re-opens (restarting the cooldown)
1153
- * if a half-open trial just failed. Returns the resulting state.
1154
- *
1155
- * Callers gate on the state directly — `state(now) === "open"` means skip.
1156
- * Every failure (re)schedules the `on_retry` wake, so one failed pass on
1157
- * an idle app retries without waiting for the breaker to open; always
1158
- * surfaces the failure via `on_error`.
1159
- */
1160
- failed(now, error) {
1161
- const circuit = this._advance(now);
1162
- this._schedule_wake();
1163
- this._hooks.on_error?.(error, circuit);
1164
- return circuit;
1165
- }
1166
- /** Cancel the pending retry timer. Idempotent; call on shutdown. */
1167
- stop() {
1168
- this._clear_wake();
1169
- }
1170
- /**
1171
- * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
1172
- * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
1173
- * tests of the pure state machine). The timer is `unref`'d so it never
1174
- * keeps the process alive.
712
+ * Schedule the `on_retry` wake `cooldownMs` out so the breaker re-trials
713
+ * the store on its own. No-op when no `on_retry` hook is wired (e.g. unit
714
+ * tests of the pure state machine). The timer is `unref`'d so it never
715
+ * keeps the process alive.
1175
716
  */
1176
717
  _schedule_wake() {
1177
718
  if (!this._hooks.on_retry) return;
@@ -1203,6 +744,55 @@ var CircuitBreaker = class {
1203
744
  }
1204
745
  };
1205
746
 
747
+ // src/internal/classify.ts
748
+ var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
749
+ function classify_registry(registry, states) {
750
+ const statics = /* @__PURE__ */ new Map();
751
+ const target_lanes = /* @__PURE__ */ new Map();
752
+ const event_to_lanes = /* @__PURE__ */ new Map();
753
+ for (const [name, register] of Object.entries(registry.events)) {
754
+ for (const reaction of register.reactions.values()) {
755
+ if (typeof reaction.resolver === "function") {
756
+ event_to_lanes.set(name, ALL_LANES);
757
+ } else {
758
+ const { target, source, priority = 0, lane } = reaction.resolver;
759
+ const lane_name = lane ?? "default";
760
+ const existing_lanes = event_to_lanes.get(name);
761
+ if (existing_lanes !== ALL_LANES) {
762
+ const set = existing_lanes ?? /* @__PURE__ */ new Set();
763
+ set.add(lane_name);
764
+ event_to_lanes.set(name, set);
765
+ }
766
+ const recorded_lane = target_lanes.get(target);
767
+ if (recorded_lane === void 0) {
768
+ target_lanes.set(target, lane_name);
769
+ } else if (recorded_lane !== lane_name) {
770
+ throw new Error(
771
+ `Stream "${target}" has conflicting lane assignments ("${recorded_lane}" vs "${lane_name}")`
772
+ );
773
+ }
774
+ const existing = statics.get(target);
775
+ if (!existing) {
776
+ statics.set(target, { stream: target, source, priority, lane });
777
+ } else if (priority > existing.priority) {
778
+ statics.set(target, { ...existing, priority });
779
+ }
780
+ }
781
+ }
782
+ }
783
+ const event_to_state = /* @__PURE__ */ new Map();
784
+ for (const merged of states.values()) {
785
+ for (const event_name of Object.keys(merged.events)) {
786
+ event_to_state.set(event_name, merged);
787
+ }
788
+ }
789
+ return {
790
+ static_targets: [...statics.values()],
791
+ event_to_state,
792
+ event_to_lanes
793
+ };
794
+ }
795
+
1206
796
  // src/internal/close-cycle.ts
1207
797
  var CLOSE_CATCH_UP_LIMIT = 1e3;
1208
798
  var CLOSE_CATCH_UP_PASSES = 20;
@@ -1862,7 +1452,7 @@ var CorrelateCycle = class {
1862
1452
  }
1863
1453
  };
1864
1454
 
1865
- // src/internal/correlator.ts
1455
+ // src/internal/correlation-id.ts
1866
1456
  import { randomInt } from "crypto";
1867
1457
  var BASE = 36;
1868
1458
  var SEG_WIDTH = 4;
@@ -1886,111 +1476,34 @@ function close_correlation(correlator, actor) {
1886
1476
  });
1887
1477
  }
1888
1478
 
1889
- // src/internal/date-reviver.ts
1890
- import { z as z3 } from "zod";
1891
- var def_of = (schema) => schema._zod?.def ?? schema.def;
1892
- function variant_schema(schema) {
1893
- const shape = def_of(schema)?.shape;
1894
- if (!shape) {
1895
- const dates = date_reviver_schema(schema);
1896
- return { schema: dates ?? schema, dated: !!dates };
1897
- }
1898
- const next = {};
1899
- let dated = false;
1900
- for (const [key, field] of Object.entries(shape)) {
1901
- const dates = date_reviver_schema(field);
1902
- if (dates) dated = true;
1903
- next[key] = (dates ?? field).optional();
1904
- }
1905
- return { schema: z3.looseObject(next), dated };
1906
- }
1907
- function date_reviver_schema(schema) {
1908
- const def = def_of(schema);
1909
- if (!def) return void 0;
1910
- const inner = () => date_reviver_schema(def.innerType);
1911
- switch (def.type) {
1912
- case "date":
1913
- return z3.coerce.date();
1914
- case "object": {
1915
- const shape = def.shape;
1916
- if (!shape) return void 0;
1917
- const dated = {};
1918
- for (const [key, field] of Object.entries(shape)) {
1919
- const dates = date_reviver_schema(field);
1920
- if (dates) dated[key] = dates.optional();
1921
- }
1922
- return Object.keys(dated).length ? z3.looseObject(dated) : void 0;
1923
- }
1924
- case "array": {
1925
- const element = date_reviver_schema(def.element);
1926
- return element && z3.array(element);
1927
- }
1928
- case "tuple": {
1929
- const items = def.items.map((i) => date_reviver_schema(i));
1930
- return items.some(Boolean) ? z3.tuple(items.map((i) => i ?? z3.unknown())) : void 0;
1931
- }
1932
- case "record": {
1933
- const value = date_reviver_schema(def.valueType);
1934
- return value && z3.record(z3.string(), value);
1935
- }
1936
- case "union": {
1937
- const variants = def.options.map(variant_schema);
1938
- return variants.some((v) => v.dated) ? z3.union(variants.map((v) => v.schema)) : void 0;
1479
+ // src/internal/drain-cycle.ts
1480
+ import { randomUUID as randomUUID2 } from "crypto";
1481
+
1482
+ // src/internal/backoff.ts
1483
+ function compute_backoff_delay(retry, opts) {
1484
+ if (!opts || opts.baseMs <= 0) return 0;
1485
+ const r = Math.max(0, retry);
1486
+ let delay;
1487
+ switch (opts.strategy) {
1488
+ case "fixed":
1489
+ delay = opts.baseMs;
1490
+ break;
1491
+ case "linear":
1492
+ delay = opts.baseMs * (r + 1);
1493
+ break;
1494
+ case "exponential":
1495
+ delay = opts.baseMs * 2 ** r;
1496
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
1497
+ break;
1498
+ default: {
1499
+ const _never = opts.strategy;
1500
+ throw new Error(`unknown backoff strategy: ${String(_never)}`);
1939
1501
  }
1940
- case "nullable":
1941
- return inner()?.nullable();
1942
- case "optional":
1943
- case "nonoptional":
1944
- case "readonly":
1945
- case "default":
1946
- case "prefault":
1947
- case "catch":
1948
- return inner();
1949
- default:
1950
- return void 0;
1951
1502
  }
1503
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
1504
+ return Math.max(0, Math.floor(delay));
1952
1505
  }
1953
1506
 
1954
- // src/internal/defer-config.ts
1955
- import { z as z4 } from "zod";
1956
- var DeferDurationSchema = z4.object({
1957
- days: z4.number().positive().optional(),
1958
- hours: z4.number().positive().optional(),
1959
- minutes: z4.number().positive().optional()
1960
- }).strict().refine((d) => d.days != null || d.hours != null || d.minutes != null, {
1961
- message: "defer: a duration needs at least one of days, hours, or minutes"
1962
- });
1963
- var DeferWhenSchema = z4.object({
1964
- after: DeferDurationSchema.optional(),
1965
- at: z4.date().optional()
1966
- }).strict().refine((w) => w.after === void 0 !== (w.at === void 0), {
1967
- message: "defer: specify exactly one of `after` or `at`"
1968
- });
1969
- function duration_ms(d) {
1970
- return (d.days ?? 0) * 864e5 + (d.hours ?? 0) * 36e5 + (d.minutes ?? 0) * 6e4;
1971
- }
1972
- function resolve_defer_at(when, event) {
1973
- const parsed = DeferWhenSchema.parse(when);
1974
- if (parsed.after) return event.created.getTime() + duration_ms(parsed.after);
1975
- return parsed.at.getTime();
1976
- }
1977
- function assert_defer_when(when) {
1978
- DeferWhenSchema.parse(when);
1979
- }
1980
- function make_deferred(handler, schedule) {
1981
- const call = handler;
1982
- const deferred = async (event, stream, app) => {
1983
- const when = typeof schedule === "function" ? schedule(event) : schedule;
1984
- if (Date.now() < resolve_defer_at(when, event)) throw new DeferSignal(when);
1985
- return call(event, stream, app);
1986
- };
1987
- Object.defineProperty(deferred, "name", { value: handler.name });
1988
- return deferred;
1989
- }
1990
-
1991
- // src/internal/drain-cycle.ts
1992
- import { randomUUID as randomUUID2 } from "crypto";
1993
-
1994
1507
  // src/internal/defer-timer.ts
1995
1508
  var MAX_TIMER_DELAY_MS = 2147483647;
1996
1509
  var DeferTimer = class {
@@ -2084,7 +1597,7 @@ function compute_lag_lead_ratio(handled, lagging, leading) {
2084
1597
  return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
2085
1598
  }
2086
1599
 
2087
- // src/internal/drain.ts
1600
+ // src/internal/drain-ops.ts
2088
1601
  var claim = (lagging, leading, by, millis, lane) => store().claim(lagging, leading, by, millis, lane);
2089
1602
  async function fetch(leased, eventLimit) {
2090
1603
  return Promise.all(
@@ -2968,6 +2481,54 @@ var DrainController = class {
2968
2481
  }
2969
2482
  };
2970
2483
 
2484
+ // src/internal/pii.ts
2485
+ function pii_split(emitted, fields) {
2486
+ const data = { ...emitted.data };
2487
+ const pii = {};
2488
+ for (const f of fields) {
2489
+ if (f in data) {
2490
+ pii[f] = data[f];
2491
+ delete data[f];
2492
+ }
2493
+ }
2494
+ return { name: emitted.name, data, pii };
2495
+ }
2496
+ function pii_gate(event, fields, predicate, actor) {
2497
+ const data = event.data;
2498
+ const { pii, ...rest } = event;
2499
+ if (pii == null) {
2500
+ const shredded = { ...data };
2501
+ for (const f of fields) shredded[f] = SHREDDED;
2502
+ return { ...rest, data: shredded };
2503
+ }
2504
+ const allowed = !!actor && !!predicate && predicate(event, actor);
2505
+ if (allowed) {
2506
+ return {
2507
+ ...rest,
2508
+ data: { ...data, ...pii }
2509
+ };
2510
+ }
2511
+ const redacted = { ...data };
2512
+ for (const f of fields) redacted[f] = REDACTED;
2513
+ return { ...rest, data: redacted };
2514
+ }
2515
+ var IDENTITY_GATE = (event) => event;
2516
+ function make_gate(fields, predicate) {
2517
+ return (event, actor) => pii_gate(event, fields, predicate, actor);
2518
+ }
2519
+ function pii_strip(event, fields) {
2520
+ const data = event.data;
2521
+ const stripped = {};
2522
+ for (const k of Object.keys(data)) {
2523
+ if (!fields.includes(k)) stripped[k] = data[k];
2524
+ }
2525
+ const { pii: _drop_pii, ...rest } = event;
2526
+ return {
2527
+ ...rest,
2528
+ data: stripped
2529
+ };
2530
+ }
2531
+
2971
2532
  // src/internal/projection-fold.ts
2972
2533
  var FOLD_RESET = /* @__PURE__ */ Symbol("act.fold.reset");
2973
2534
  function make_fold_handler(me, flush, config2, patch_fn = bare_patch, sensitive_fields) {
@@ -3055,108 +2616,78 @@ function make_fold_handler(me, flush, config2, patch_fn = bare_patch, sensitive_
3055
2616
  return handler;
3056
2617
  }
3057
2618
 
3058
- // src/internal/settle.ts
3059
- var SettleLoop = class {
3060
- _timer = void 0;
3061
- _running = false;
3062
- /**
3063
- * Resolves when the cycle in flight finishes, so shutdown can wait for
3064
- * it (`stop()` cancels scheduling only). Never rejects.
3065
- */
3066
- _inflight;
2619
+ // src/internal/defer-config.ts
2620
+ import { z } from "zod";
2621
+ var DeferDurationSchema = z.object({
2622
+ days: z.number().positive().optional(),
2623
+ hours: z.number().positive().optional(),
2624
+ minutes: z.number().positive().optional()
2625
+ }).strict().refine((d) => d.days != null || d.hours != null || d.minutes != null, {
2626
+ message: "defer: a duration needs at least one of days, hours, or minutes"
2627
+ });
2628
+ var DeferWhenSchema = z.object({
2629
+ after: DeferDurationSchema.optional(),
2630
+ at: z.date().optional()
2631
+ }).strict().refine((w) => w.after === void 0 !== (w.at === void 0), {
2632
+ message: "defer: specify exactly one of `after` or `at`"
2633
+ });
2634
+ function duration_ms(d) {
2635
+ return (d.days ?? 0) * 864e5 + (d.hours ?? 0) * 36e5 + (d.minutes ?? 0) * 6e4;
2636
+ }
2637
+ function resolve_defer_at(when, event) {
2638
+ const parsed = DeferWhenSchema.parse(when);
2639
+ if (parsed.after) return event.created.getTime() + duration_ms(parsed.after);
2640
+ return parsed.at.getTime();
2641
+ }
2642
+ function assert_defer_when(when) {
2643
+ DeferWhenSchema.parse(when);
2644
+ }
2645
+ function make_deferred(handler, schedule) {
2646
+ const call = handler;
2647
+ const deferred = async (event, stream, app) => {
2648
+ const when = typeof schedule === "function" ? schedule(event) : schedule;
2649
+ if (Date.now() < resolve_defer_at(when, event)) throw new DeferSignal(when);
2650
+ return call(event, stream, app);
2651
+ };
2652
+ Object.defineProperty(deferred, "name", { value: handler.name });
2653
+ return deferred;
2654
+ }
2655
+
2656
+ // src/internal/reaction-dispatch.ts
2657
+ var CloseSignal = class extends Error {
3067
2658
  /**
3068
- * A wake-up that fired while a cycle was running. The running cycle's
3069
- * `finally` schedules one more pass with these options, so it isn't lost.
2659
+ * Stream to close. Omitted → the reaction's own lease stream (a self-close).
2660
+ * The synthesized autoclose reaction sets it to the aggregate stream, since
2661
+ * its lease runs on a synthetic `__autoclose__` target.
3070
2662
  */
3071
- _pending = void 0;
3072
- _deps;
3073
- /** Debounce window applied when the caller doesn't override via `SettleOptions.debounceMs`. */
3074
- _default_debounce_ms;
3075
- constructor(deps, default_debounce_ms) {
3076
- this._deps = deps;
3077
- this._default_debounce_ms = default_debounce_ms;
3078
- }
2663
+ stream;
2664
+ /** Optional archive callback to run while the stream is guarded. */
2665
+ archive;
3079
2666
  /**
3080
- * Schedule a settle pass. Multiple calls inside the debounce window
3081
- * coalesce into one cycle. The cycle runs correlate→drain in a loop
3082
- * until no progress is made (no new subscriptions, no events read, no
3083
- * streams leased)
3084
- * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
3085
- * via {@link SettleDeps.on_settled}.
2667
+ * Watermark to ack the requesting reaction to before the close runs. The
2668
+ * close-cycle safety guard skips a stream whose subscriptions (matched by
2669
+ * source) lag the head; the autoclose reaction's `source` is the aggregate,
2670
+ * so it must advance its own watermark to the live head id it evaluated
2671
+ * against — otherwise it blocks its own close. Defaults to the triggering
2672
+ * event id when omitted.
3086
2673
  */
3087
- schedule(options = {}) {
3088
- const {
3089
- debounceMs = this._default_debounce_ms,
3090
- correlate: correlate_query = { after: -1, limit: 100 },
3091
- maxPasses = Infinity,
3092
- ...drain_options
3093
- } = options;
3094
- if (this._timer) clearTimeout(this._timer);
3095
- this._timer = setTimeout(() => {
3096
- this._timer = void 0;
3097
- if (this._running) {
3098
- this._pending = options;
3099
- return;
3100
- }
3101
- this._running = true;
3102
- let settle_done;
3103
- this._inflight = new Promise((done) => {
3104
- settle_done = done;
3105
- });
3106
- (async () => {
3107
- await this._deps.init();
3108
- let settled_drain;
3109
- for (let i = 0; i < maxPasses; i++) {
3110
- const after_before = this._deps.checkpoint();
3111
- const { subscribed, last_id, scanned } = await this._deps.correlate({
3112
- ...correlate_query,
3113
- after: after_before
3114
- });
3115
- if (scanned) this._deps.breaker.passed();
3116
- const drain = await this._deps.drain(drain_options);
3117
- settled_drain = settled_drain ? {
3118
- fetched: [...settled_drain.fetched, ...drain.fetched],
3119
- leased: [...settled_drain.leased, ...drain.leased],
3120
- acked: [...settled_drain.acked, ...drain.acked],
3121
- blocked: [...settled_drain.blocked, ...drain.blocked]
3122
- } : drain;
3123
- const made_progress = subscribed > 0 || drain.leased.length > 0 || last_id > after_before;
3124
- if (!made_progress) break;
3125
- }
3126
- if (settled_drain) this._deps.on_settled(settled_drain);
3127
- })().catch((err) => {
3128
- this._deps.breaker.failed(Date.now(), err);
3129
- }).finally(() => {
3130
- this._running = false;
3131
- this._inflight = void 0;
3132
- settle_done();
3133
- const pending = this._pending;
3134
- if (pending !== void 0) {
3135
- this._pending = void 0;
3136
- this.schedule(pending);
3137
- }
3138
- });
3139
- }, debounceMs);
3140
- }
2674
+ at;
3141
2675
  /**
3142
- * The cycle currently in flight, or `undefined` when idle. A
3143
- * graceful shutdown awaits this alongside the drain controllers so a
3144
- * settle parked in `correlate` does not resume after teardown.
2676
+ * Windowed close: prune events older than this cutoff behind
2677
+ * the closest safe snapshot instead of retiring the stream. Thrown by
2678
+ * the autoclose reaction of a `.autocloses({ keep })` state; flows
2679
+ * into {@link CloseTarget.before}. Omitted → a full close.
3145
2680
  */
3146
- get inflight() {
3147
- return this._inflight;
3148
- }
3149
- /** Cancel any pending or active settle cycle. Idempotent. */
3150
- stop() {
3151
- this._pending = void 0;
3152
- if (this._timer) {
3153
- clearTimeout(this._timer);
3154
- this._timer = void 0;
3155
- }
2681
+ before;
2682
+ constructor(opts) {
2683
+ super("reaction requested close");
2684
+ this.name = "CloseSignal";
2685
+ this.stream = opts?.stream;
2686
+ this.archive = opts?.archive;
2687
+ this.at = opts?.at;
2688
+ this.before = opts?.before;
3156
2689
  }
3157
2690
  };
3158
-
3159
- // src/builders/reaction-builder.ts
3160
2691
  function build_handle(deps) {
3161
2692
  const { logger, reaction_scope } = deps;
3162
2693
  return async (lease, payloads) => {
@@ -3251,12 +2782,132 @@ function build_handle_batch(logger) {
3251
2782
  };
3252
2783
  }
3253
2784
 
3254
- // src/act.ts
3255
- function validate_only_lanes(options, lanes) {
3256
- if (!options.onlyLanes || options.onlyLanes.length === 0) return;
3257
- const declared = /* @__PURE__ */ new Set(["default", ...lanes.map((l) => l.name)]);
3258
- const unknown = options.onlyLanes.filter((l) => !declared.has(l));
3259
- if (unknown.length > 0)
2785
+ // src/internal/settle.ts
2786
+ var SettleLoop = class {
2787
+ _timer = void 0;
2788
+ _running = false;
2789
+ /**
2790
+ * Resolves when the cycle in flight finishes, so shutdown can wait for
2791
+ * it (`stop()` cancels scheduling only). Never rejects.
2792
+ */
2793
+ _inflight;
2794
+ /** A wake-up arrived while a cycle ran: run one more pass after it. */
2795
+ _pending = false;
2796
+ /** The last caller's options, used by the next pass. */
2797
+ _next = void 0;
2798
+ /** When the scheduled pass fires (epoch ms). */
2799
+ _due = 0;
2800
+ /** Set at shutdown: nothing schedules again. */
2801
+ _stopped = false;
2802
+ _deps;
2803
+ /** Debounce window applied when the caller doesn't override via `SettleOptions.debounceMs`. */
2804
+ _default_debounce_ms;
2805
+ constructor(deps, default_debounce_ms) {
2806
+ this._deps = deps;
2807
+ this._default_debounce_ms = default_debounce_ms;
2808
+ }
2809
+ /**
2810
+ * Schedule a settle pass. A pass already scheduled is never put off or
2811
+ * replaced: calls that arrive before it runs join it (a shorter
2812
+ * `debounceMs` brings it forward), and a call during a running cycle
2813
+ * queues one more pass after it. Callers pass options, which the next
2814
+ * pass uses; internal wake-ups pass none, so they never discard a
2815
+ * caller's. The cycle runs correlate→drain in a
2816
+ * loop until no progress is made (no new subscriptions, no events read,
2817
+ * no streams leased) or `maxPasses` is reached, then emits the
2818
+ * `"settled"` lifecycle event via {@link SettleDeps.on_settled}.
2819
+ */
2820
+ schedule(options) {
2821
+ if (this._stopped) return;
2822
+ if (options) this._next = options;
2823
+ if (this._running) {
2824
+ this._pending = true;
2825
+ return;
2826
+ }
2827
+ const delay = options?.debounceMs ?? this._default_debounce_ms;
2828
+ const due = Date.now() + delay;
2829
+ if (this._timer) {
2830
+ if (due >= this._due) return;
2831
+ clearTimeout(this._timer);
2832
+ }
2833
+ this._due = due;
2834
+ this._timer = setTimeout(() => {
2835
+ this._timer = void 0;
2836
+ const {
2837
+ debounceMs: _,
2838
+ correlate: correlate_query = {},
2839
+ maxPasses = Infinity,
2840
+ ...drain_options
2841
+ } = this._next ?? {};
2842
+ this._next = void 0;
2843
+ this._running = true;
2844
+ let settle_done;
2845
+ this._inflight = new Promise((done) => {
2846
+ settle_done = done;
2847
+ });
2848
+ (async () => {
2849
+ await this._deps.init();
2850
+ let settled_drain;
2851
+ for (let i = 0; i < maxPasses; i++) {
2852
+ const after_before = this._deps.checkpoint();
2853
+ const { subscribed, last_id, scanned } = await this._deps.correlate({
2854
+ ...correlate_query,
2855
+ after: after_before
2856
+ });
2857
+ if (scanned) this._deps.breaker.passed();
2858
+ const drain = await this._deps.drain(drain_options);
2859
+ settled_drain = settled_drain ? {
2860
+ fetched: [...settled_drain.fetched, ...drain.fetched],
2861
+ leased: [...settled_drain.leased, ...drain.leased],
2862
+ acked: [...settled_drain.acked, ...drain.acked],
2863
+ blocked: [...settled_drain.blocked, ...drain.blocked]
2864
+ } : drain;
2865
+ const made_progress = subscribed > 0 || drain.leased.length > 0 || last_id > after_before;
2866
+ if (!made_progress) break;
2867
+ }
2868
+ if (settled_drain) this._deps.on_settled(settled_drain);
2869
+ })().catch((err) => {
2870
+ this._deps.breaker.failed(Date.now(), err);
2871
+ }).finally(() => {
2872
+ this._running = false;
2873
+ this._inflight = void 0;
2874
+ settle_done();
2875
+ if (this._pending) {
2876
+ this._pending = false;
2877
+ this.schedule(this._next);
2878
+ }
2879
+ });
2880
+ }, delay);
2881
+ }
2882
+ /**
2883
+ * The cycle currently in flight, or `undefined` when idle. A
2884
+ * graceful shutdown awaits this alongside the drain controllers so a
2885
+ * settle parked in `correlate` does not resume after teardown.
2886
+ */
2887
+ get inflight() {
2888
+ return this._inflight;
2889
+ }
2890
+ /**
2891
+ * Cancel any pending settle pass. Idempotent. `final` (shutdown) also
2892
+ * stops all later scheduling.
2893
+ */
2894
+ stop(final = false) {
2895
+ if (final) this._stopped = true;
2896
+ this._pending = false;
2897
+ this._next = void 0;
2898
+ if (this._timer) {
2899
+ clearTimeout(this._timer);
2900
+ this._timer = void 0;
2901
+ }
2902
+ }
2903
+ };
2904
+
2905
+ // src/act.ts
2906
+ function validate_only_lanes(options, lanes) {
2907
+ if (!options.onlyLanes || options.onlyLanes.length === 0) return;
2908
+ const declared = /* @__PURE__ */ new Set(["default", ...lanes.map((l) => l.name)]);
2909
+ const unknown = options.onlyLanes.filter((l) => !declared.has(l));
2910
+ if (unknown.length > 0)
3260
2911
  throw new Error(
3261
2912
  `ActOptions.onlyLanes references undeclared lane(s): ${unknown.map((l) => `"${l}"`).join(", ")}`
3262
2913
  );
@@ -3266,14 +2917,15 @@ var Act = class {
3266
2917
  _breaker;
3267
2918
  _listen;
3268
2919
  _drain;
2920
+ _auto_settle;
3269
2921
  /** Whether any event has a reaction; nothing arms without one. */
3270
2922
  _reactive;
3271
2923
  _drain_controllers;
3272
2924
  _correlate;
3273
2925
  _settle;
3274
2926
  _poll;
3275
- /** The deprecated `start_correlations` callback, while the poll runs. */
3276
- _on_discovered;
2927
+ /** The poll's correlate `limit`, used by every settle pass while it runs. */
2928
+ _poll_limit;
3277
2929
  /**
3278
2930
  * Wired at construction against the store current then, so a store
3279
2931
  * injected after `build()` is never notified.
@@ -3402,6 +3054,7 @@ var Act = class {
3402
3054
  this._event_to_lanes = classification.event_to_lanes;
3403
3055
  this._listen = options.listen !== false;
3404
3056
  this._drain = options.drain !== false;
3057
+ this._auto_settle = options.autoSettle !== false;
3405
3058
  this._breaker = this._build_breaker(options);
3406
3059
  this._drain_controllers = this._build_drain_controllers(options, lanes);
3407
3060
  this._advise_orphaned_lanes(options);
@@ -3419,9 +3072,7 @@ var Act = class {
3419
3072
  on_error: (error, circuit) => this._emit_error(error, circuit),
3420
3073
  // When the cooldown elapses, settle once: its scan or claim is the
3421
3074
  // half-open probe that closes or re-opens the breaker.
3422
- on_retry: () => {
3423
- this.settle({ debounceMs: 0 });
3424
- }
3075
+ on_retry: () => this._schedule()
3425
3076
  }
3426
3077
  );
3427
3078
  }
@@ -3523,7 +3174,10 @@ var Act = class {
3523
3174
  {
3524
3175
  init: () => this._scoped(() => this._correlate.init()),
3525
3176
  checkpoint: () => this._correlate.checkpoint,
3526
- correlate: (q) => this._correlate_scanned(q, true),
3177
+ correlate: (q) => this._correlate_scanned(
3178
+ { ...q, limit: q.limit ?? this._poll_limit ?? 100 },
3179
+ true
3180
+ ),
3527
3181
  drain: (o) => this.drain(o),
3528
3182
  on_settled: (drain) => this.emit("settled", drain),
3529
3183
  breaker: this._breaker
@@ -3533,21 +3187,14 @@ var Act = class {
3533
3187
  }
3534
3188
  _shutdown_promise;
3535
3189
  /**
3536
- * Per-instance teardown: stop scheduling new work, give drain cycles
3537
- * already in flight a bounded chance to finish, then remove lifecycle
3538
- * listeners and tear down the cross-process notify subscription.
3539
- *
3540
- * Scheduling stops first, then in-flight cycles get up to `graceMs` to
3541
- * reach their `ack` (an abandoned handler holds its lease until it
3542
- * expires). Listeners come off after the wait, so they still see work that
3543
- * finished during it. The budget is a ceiling, not a delay; when it runs
3544
- * out, teardown proceeds anyway.
3190
+ * Per-instance teardown: stops scheduling, gives the cycles in flight up
3191
+ * to `graceMs` to reach their `ack` (an abandoned handler holds its lease
3192
+ * until it expires), then removes listeners and the notify subscription.
3193
+ * The budget is a ceiling, not a delay.
3545
3194
  *
3546
- * Idempotent — repeated calls return the same promise, and the first
3547
- * call's `graceMs` is the one that applies. Registered automatically with
3548
- * the global `dispose()` registry at construction, so process-wide
3549
- * `dispose()()` covers it; test helpers (or operators that mint
3550
- * short-lived Acts) call it explicitly for prompt cleanup.
3195
+ * Idempotent: repeated calls return the same promise, with the first
3196
+ * call's `graceMs`. Registered with the global `dispose()` registry, so
3197
+ * `dispose()()` covers it; call it directly for prompt cleanup.
3551
3198
  *
3552
3199
  * @param options - See {@link ShutdownOptions}. Defaults the grace budget
3553
3200
  * to the largest lane `leaseMillis` (capped at 30s).
@@ -3559,7 +3206,7 @@ var Act = class {
3559
3206
  this.stop_correlations();
3560
3207
  const disposer = await this._notify_disposer;
3561
3208
  if (disposer) await disposer();
3562
- this._settle.stop();
3209
+ this._settle.stop(true);
3563
3210
  this._breaker.stop();
3564
3211
  for (const c of this._drain_controllers.values()) c.stop();
3565
3212
  await this._await_inflight(options?.graceMs);
@@ -3613,7 +3260,7 @@ var Act = class {
3613
3260
  const armed = this._arm_for_event_names(
3614
3261
  notification.events.map((e) => e.name)
3615
3262
  );
3616
- if (armed) this._settle.schedule({ debounceMs: 0 });
3263
+ this._settle_armed(armed);
3617
3264
  }
3618
3265
  } catch (err) {
3619
3266
  this._logger.error(err, "notified handler threw");
@@ -3625,15 +3272,10 @@ var Act = class {
3625
3272
  }
3626
3273
  }
3627
3274
  /**
3628
- * Executes an action on a state instance, committing resulting events.
3629
- *
3630
- * This is the primary method for modifying state. It:
3631
- * 1. Validates the action payload against the schema
3632
- * 2. Loads the current state snapshot
3633
- * 3. Checks invariants (business rules)
3634
- * 4. Executes the action handler to generate events
3635
- * 5. Applies events to create new state
3636
- * 6. Commits events to the store with optimistic concurrency control
3275
+ * Executes an action: validates the payload, loads the state, checks its
3276
+ * invariants, and commits the events its handler emits, with optimistic
3277
+ * concurrency on the stream. The reactions to those events settle on
3278
+ * their own (unless `autoSettle: false`).
3637
3279
  *
3638
3280
  * @template TKey - Action name from registered actions
3639
3281
  * @param action - The name of the action to execute
@@ -3677,9 +3319,10 @@ var Act = class {
3677
3319
  payload,
3678
3320
  do_options
3679
3321
  );
3680
- this._arm_for_event_names(
3322
+ const armed = this._arm_for_event_names(
3681
3323
  snapshots.map((s) => s.event.name)
3682
3324
  );
3325
+ this._settle_armed(armed);
3683
3326
  this.emit("committed", snapshots);
3684
3327
  return snapshots;
3685
3328
  });
@@ -3711,9 +3354,7 @@ var Act = class {
3711
3354
  *
3712
3355
  * For small result sets, consider using {@link query_array} instead.
3713
3356
  *
3714
- * @param query - Filter criteria — see {@link Query} for available fields
3715
- * (`stream`, `name`, `after`, `before`, `created_after`, `created_before`,
3716
- * `limit`, `with_snaps`, `stream_exact`)
3357
+ * @param query - Filter criteria; see {@link Query} for the fields
3717
3358
  * @param callback - Optional callback invoked for each matching event
3718
3359
  * @returns Object with first event, last event, and total count
3719
3360
  *
@@ -3797,31 +3438,23 @@ var Act = class {
3797
3438
  });
3798
3439
  }
3799
3440
  /**
3800
- * Processes pending reactions by draining uncommitted events from the event store.
3441
+ * Runs one drain cycle: claims streams with marked work (leasing them),
3442
+ * fetches their events, runs the matching reactions, and acks or blocks
3443
+ * each stream.
3801
3444
  *
3802
- * Runs a single drain cycle:
3803
- * 1. Polls the store for streams with uncommitted events
3804
- * 2. Leases streams to prevent concurrent processing
3805
- * 3. Fetches events for each leased stream
3806
- * 4. Executes matching reaction handlers
3807
- * 5. Acknowledges successful reactions or blocks failing ones
3808
- *
3809
- * Drain uses a dual-frontier strategy to balance processing of new streams (lagging)
3810
- * vs active streams (leading). The ratio adapts based on event pressure.
3811
- *
3812
- * Call `correlate()` before `drain()`. It is not only how dynamic targets
3813
- * are discovered: a stream is claimable while `at < correlated_at`, and
3814
- * `correlate` is the only component that raises that mark, so a
3815
- * commit no correlate has seen is not drainable, static targets included. For a
3816
- * higher-level API that handles debouncing, correlation, and signaling
3817
- * automatically, use {@link settle}.
3445
+ * A stream is claimable only once `correlate()` has marked it, static
3446
+ * targets included, so a commit no correlate has seen is not drainable.
3447
+ * Apps rarely call this: each commit settles its reactions on its own.
3448
+ * With `autoSettle: false`, tests walk the pipeline with `correlate()`
3449
+ * then `drain()` (see the testing guide).
3818
3450
  *
3819
3451
  * @param options - Drain configuration — see {@link DrainOptions} for fields
3820
3452
  * (`streamLimit`, `eventLimit`, `leaseMillis`).
3821
3453
  * @returns Drain statistics with fetched, leased, acked, and blocked counts
3822
3454
  *
3823
- * @example In tests and scripts
3455
+ * @example Walking the pipeline in a test
3824
3456
  * ```typescript
3457
+ * const app = act().withState(User).withProjection(Users).build({ autoSettle: false });
3825
3458
  * await app.do("createUser", target, payload);
3826
3459
  * await app.correlate();
3827
3460
  * await app.drain();
@@ -3829,7 +3462,6 @@ var Act = class {
3829
3462
  *
3830
3463
  * @see {@link settle} for debounced correlate→drain with lifecycle events
3831
3464
  * @see {@link correlate} for dynamic stream discovery
3832
- * @see {@link start_correlations} for automatic correlation
3833
3465
  */
3834
3466
  async drain(options = {}) {
3835
3467
  resolveDrainConfig(options);
@@ -3842,6 +3474,18 @@ var Act = class {
3842
3474
  this._correlate.arm();
3843
3475
  for (const c of this._drain_controllers.values()) c.arm();
3844
3476
  }
3477
+ /**
3478
+ * Settles what a change armed (a commit, a reset, an unblock, a restore,
3479
+ * a notification from another process), unless the app walks the
3480
+ * pipeline itself (`autoSettle: false`).
3481
+ */
3482
+ _settle_armed(armed) {
3483
+ if (armed && this._auto_settle) this._schedule();
3484
+ }
3485
+ /** Schedules a settle pass; writer-only instances (`drain: false`) skip it. */
3486
+ _schedule(options) {
3487
+ if (this._drain) this._settle.schedule(options);
3488
+ }
3845
3489
  /** Arms only the lanes these events react on; true when any was armed. */
3846
3490
  _arm_for_event_names(names) {
3847
3491
  const to_arm = /* @__PURE__ */ new Set();
@@ -3884,32 +3528,20 @@ var Act = class {
3884
3528
  return { fetched, leased, acked, blocked };
3885
3529
  }
3886
3530
  /**
3887
- * Discovers and registers new streams dynamically based on reaction resolvers.
3888
- *
3889
- * Correlation enables "dynamic reactions" where target streams are determined at runtime
3890
- * based on event content. For example, you might create a stats stream for each user
3891
- * when they perform certain actions.
3892
- *
3893
- * This method scans events matching the query and identifies new target streams based
3894
- * on reaction resolvers. It then registers these streams so they'll be picked up by
3895
- * the next drain cycle.
3531
+ * Scans committed events from the correlation checkpoint, subscribes the
3532
+ * target streams their reactions resolve to (static or computed from
3533
+ * the event), and marks their work so the next drain can claim it.
3896
3534
  *
3897
3535
  * @param query - Scan filter — see {@link Query} for fields (typically
3898
- * `{ after: <event-id>, limit: <count> }`)
3536
+ * `{ limit: <count> }`; the scan starts at the checkpoint)
3899
3537
  * @returns The number of newly subscribed streams and the last scanned event id
3900
3538
  *
3901
3539
  * @example Manual correlation
3902
3540
  * ```typescript
3903
- * // Scan for new streams
3904
- * const { subscribed, last_id } = await app.correlate({ after: 0, limit: 100 });
3905
- * console.log(`Subscribed ${subscribed} new streams`);
3906
- *
3907
- * // Save last_id for next scan
3908
- * await saveCheckpoint(last_id);
3541
+ * const { subscribed } = await app.correlate({ limit: 100 });
3909
3542
  * ```
3910
3543
  *
3911
3544
  * @see {@link start_correlations} for automatic periodic correlation
3912
- * @see {@link stop_correlations} to stop automatic correlation
3913
3545
  */
3914
3546
  async correlate(query = { after: -1, limit: 10 }) {
3915
3547
  const { subscribed, last_id } = await this._correlate_scanned(query);
@@ -3921,7 +3553,6 @@ var Act = class {
3921
3553
  return this._scoped(async () => {
3922
3554
  const { subscribed, last_id, marked, scanned } = await this._correlate.correlate(query, lease);
3923
3555
  if (subscribed > 0 || marked > 0) this._arm_all();
3924
- if (subscribed > 0) this._on_discovered?.(subscribed);
3925
3556
  return { subscribed, last_id, scanned };
3926
3557
  });
3927
3558
  }
@@ -3941,9 +3572,6 @@ var Act = class {
3941
3572
  * reads (default 100). The scan always starts at the correlation
3942
3573
  * checkpoint, so `after` is ignored.
3943
3574
  * @param frequency - Milliseconds between ticks (default: 10000)
3944
- * @param callback - Called with the number of newly subscribed streams
3945
- * after each correlate pass that found some.
3946
- * @deprecated Listen to the `"settled"` lifecycle event instead.
3947
3575
  * @returns `true` if the poll started, `false` if one is already running
3948
3576
  *
3949
3577
  * @example
@@ -3956,13 +3584,12 @@ var Act = class {
3956
3584
  * @see {@link settle} for what each tick runs
3957
3585
  * @see {@link stop_correlations} to stop the poll
3958
3586
  */
3959
- start_correlations(query = {}, frequency = 1e4, callback) {
3587
+ start_correlations(query = {}, frequency = 1e4) {
3960
3588
  if (this._poll) return false;
3961
- this._on_discovered = callback;
3962
- const correlate = { limit: query.limit || 100 };
3589
+ this._poll_limit = query.limit || void 0;
3963
3590
  this._poll = setInterval(() => {
3964
3591
  this._correlate.arm();
3965
- this.settle({ debounceMs: 0, correlate });
3592
+ this._schedule();
3966
3593
  }, frequency);
3967
3594
  return true;
3968
3595
  }
@@ -3976,12 +3603,13 @@ var Act = class {
3976
3603
  if (this._poll) {
3977
3604
  clearInterval(this._poll);
3978
3605
  this._poll = void 0;
3606
+ this._poll_limit = void 0;
3979
3607
  }
3980
- this._on_discovered = void 0;
3981
3608
  void this._scoped(() => this._correlate.release_correlation());
3982
3609
  }
3983
3610
  /**
3984
- * Cancels any pending or active settle cycle.
3611
+ * Drops the settle pass scheduled but not yet started. A cycle already
3612
+ * running finishes; later changes schedule new passes.
3985
3613
  *
3986
3614
  * @see {@link settle}
3987
3615
  */
@@ -3989,19 +3617,17 @@ var Act = class {
3989
3617
  this._settle.stop();
3990
3618
  }
3991
3619
  /**
3992
- * Reset reaction stream watermarks and request a drain on the next
3993
- * `drain()` / `settle()` cycle.
3620
+ * Reset reaction stream watermarks and settle them, replaying their
3621
+ * events through their reactions.
3994
3622
  *
3995
3623
  * Use this to replay events through projections (or other reaction targets)
3996
- * after changing handler logic. Equivalent to calling `store().reset(streams)`
3997
- * directly, but also raises the orchestrator's internal "needs drain" flag —
3998
- * `store().reset(...)` alone leaves the flag untouched, so a settled app
3999
- * would short-circuit and skip the replay.
4000
- *
4001
- * Pair with `app.settle()` (or a single `app.drain()` for small streams).
4002
- * `settle()` loops correlate→drain until no progress is made, so one call
4003
- * fully catches up paginated streams without forcing callers to roll
4004
- * their own loop.
3624
+ * after changing handler logic. It rewinds the same watermarks as
3625
+ * `store().reset(streams)`, and also wakes the drain and schedules a
3626
+ * settle, which loops correlate→drain until the replay catches up;
3627
+ * `store().reset(...)` alone leaves a settled app idle. Call
3628
+ * `app.settle(options)` right after to give that pass batch options, or,
3629
+ * with `autoSettle: false`, `correlate()` / `drain()` to drive the replay
3630
+ * by hand.
4005
3631
  *
4006
3632
  * @param input - Reaction target streams (e.g., projection names) to reset, or a {@link StreamFilter} for bulk operations
4007
3633
  * @returns Count of streams that were actually reset
@@ -4009,7 +3635,7 @@ var Act = class {
4009
3635
  * @example Rebuild a projection (production)
4010
3636
  * ```typescript
4011
3637
  * await app.reset(["my-projection"]);
4012
- * app.settle({ eventLimit: 1000 }); // emits "settled" when fully replayed
3638
+ * app.settle({ eventLimit: 1000 }); // optional: bigger batches; emits "settled"
4013
3639
  * ```
4014
3640
  *
4015
3641
  * @see {@link Store.reset} for the underlying store primitive
@@ -4020,7 +3646,10 @@ var Act = class {
4020
3646
  const count = await store().reset(input);
4021
3647
  for (const handler of this._batch_handlers.values())
4022
3648
  handler[FOLD_RESET]?.();
4023
- if (count > 0) this._arm_all();
3649
+ if (count > 0) {
3650
+ this._arm_all();
3651
+ this._settle_armed(this._reactive);
3652
+ }
4024
3653
  return count;
4025
3654
  });
4026
3655
  }
@@ -4033,11 +3662,9 @@ var Act = class {
4033
3662
  * {@link reset}, which rebuilds from event 0 (suitable for projection
4034
3663
  * rebuilds, wrong for "I fixed the bug, please retry").
4035
3664
  *
4036
- * Wraps `store().unblock(streams)` and raises the orchestrator's
4037
- * internal "needs drain" flag so a settled app picks up the now-free
4038
- * streams on the next cycle. Equivalent to calling `store().unblock(...)`
4039
- * directly, but `store().unblock(...)` alone leaves the flag
4040
- * untouched.
3665
+ * Wraps `store().unblock(streams)` and settles the now-free streams on
3666
+ * its own. `store().unblock(...)` alone leaves them waiting for the next
3667
+ * commit.
4041
3668
  *
4042
3669
  * @param input - Stream names to unblock, or a {@link StreamFilter} for bulk recovery
4043
3670
  * @returns Count of streams that were actually flipped (were blocked)
@@ -4054,7 +3681,10 @@ var Act = class {
4054
3681
  async unblock(input) {
4055
3682
  return this._scoped(async () => {
4056
3683
  const count = await store().unblock(input);
4057
- if (count > 0) this._arm_all();
3684
+ if (count > 0) {
3685
+ this._arm_all();
3686
+ this._settle_armed(this._reactive);
3687
+ }
4058
3688
  return count;
4059
3689
  });
4060
3690
  }
@@ -4116,223 +3746,377 @@ var Act = class {
4116
3746
  if (!sink) {
4117
3747
  this._correlate.restart();
4118
3748
  this._arm_all();
3749
+ this._settle_armed(this._reactive);
3750
+ }
3751
+ return { kept, migrated, dropped, duration_ms: Date.now() - started };
3752
+ });
3753
+ }
3754
+ /**
3755
+ * Return every currently-blocked stream position. Convenience wrapper
3756
+ * around `store().query_streams(cb, { blocked: true })` for the common
3757
+ * "show me what's broken" operational query.
3758
+ *
3759
+ * Results are ordered by stream name, paginated by `limit` (default
3760
+ * 100). Pass `after` to fetch the next page (keyset cursor on the
3761
+ * stream name). For richer queries — including blocked + source
3762
+ * filters, or full unblocked introspection — drop to
3763
+ * `store().query_streams(...)` directly.
3764
+ *
3765
+ * @returns Array of {@link StreamPosition} for currently-blocked streams.
3766
+ *
3767
+ * @example Discover and recover
3768
+ * ```typescript
3769
+ * const blocked = await app.blocked_streams();
3770
+ * console.table(blocked.map(({ stream, retry, error }) => ({ stream, retry, error })));
3771
+ *
3772
+ * // Operator investigates, then bulk-unblocks the family:
3773
+ * await app.unblock({ stream: "^webhooks-out-" });
3774
+ * ```
3775
+ */
3776
+ async blocked_streams(options) {
3777
+ return this._scoped(async () => {
3778
+ const positions = [];
3779
+ await store().query_streams(
3780
+ (p) => {
3781
+ positions.push(p);
3782
+ },
3783
+ { blocked: true, after: options?.after, limit: options?.limit }
3784
+ );
3785
+ return positions;
3786
+ });
3787
+ }
3788
+ /**
3789
+ * Operator-driven store audit: walks the store and yields findings per
3790
+ * category, each with the remediation it suggests. Never run by the
3791
+ * framework. Pass a subset of categories to scope the work; the iterable
3792
+ * can be broken early, and findings stream without buffering the report.
3793
+ *
3794
+ * @param categories - Subset of categories to run (default: all).
3795
+ * @param options - Query window + per-category thresholds.
3796
+ * @returns Async iterable of {@link AuditFinding}.
3797
+ *
3798
+ * @example Schema drift since the last scan
3799
+ * ```typescript
3800
+ * for await (const f of app.audit(["schema"], { query: { created_after: lastScan } }))
3801
+ * await escalate(f);
3802
+ * ```
3803
+ */
3804
+ async *audit(categories, options) {
3805
+ const it = audit(this._audit_deps, categories, options)[Symbol.asyncIterator]();
3806
+ while (true) {
3807
+ const { value, done } = await this._scoped(() => it.next());
3808
+ if (done) break;
3809
+ yield value;
3810
+ }
3811
+ }
3812
+ /**
3813
+ * Sets the scheduling priority of every stream matching `filter`, as-is:
3814
+ * unlike build-time priorities (the highest across reactions wins), this
3815
+ * overrides them. Useful to let a long replay jump ahead of other lagging
3816
+ * streams, or to yield slots back. **An empty filter (`{}`) updates every
3817
+ * registered stream.**
3818
+ *
3819
+ * @param filter - Selection criteria (regex by default, like {@link query}).
3820
+ * @param priority - New priority value. Set as-is — no clamp.
3821
+ * @returns Count of streams whose priority changed.
3822
+ *
3823
+ * @example Boost a specific projection mid-replay
3824
+ * ```typescript
3825
+ * await app.prioritize({ stream: "^proj-orders$", stream_exact: false }, 10);
3826
+ * ```
3827
+ *
3828
+ * @see {@link Store.prioritize} for the underlying primitive
3829
+ */
3830
+ async prioritize(filter, priority) {
3831
+ return this._scoped(() => store().prioritize(filter, priority));
3832
+ }
3833
+ /** Shared by `close()` and reaction-requested closes. */
3834
+ async _run_close(targets) {
3835
+ const close_actor = { id: "$close", name: "close" };
3836
+ const result = await run_close_cycle(targets, {
3837
+ reactive: this._reactive,
3838
+ catch_up_correlation: (until) => catch_up_correlation(
3839
+ this._correlate,
3840
+ (limit) => this.correlate({ limit }),
3841
+ until
3842
+ ),
3843
+ event_to_state: this._event_to_state,
3844
+ load: this._es.load,
3845
+ tombstone: this._es.tombstone,
3846
+ logger: this._logger,
3847
+ correlation: close_correlation(this._correlator, close_actor),
3848
+ with_stream_lock: (stream, work) => this._close_lock.run(stream, work),
3849
+ forget_subscribed: (streams) => this._correlate.forget_subscribed(streams)
3850
+ });
3851
+ this.emit("closed", result);
3852
+ return result;
3853
+ }
3854
+ /**
3855
+ * Close the books: guard, archive, truncate, and optionally restart
3856
+ * streams. A stream with pending reactions is skipped; an archive
3857
+ * failure aborts the whole close while the streams are still guarded.
3858
+ * Targets carrying `before` prune a rolling window instead and leave the
3859
+ * stream live (needs `.snap(...)`). The phases and their failure
3860
+ * semantics are in the close-cycle architecture page.
3861
+ *
3862
+ * @param targets - Per-stream close options (stream, restart?, archive?, before?)
3863
+ * @returns `{ truncated: TruncateResult, skipped: string[] }`
3864
+ *
3865
+ * @example Archive and close
3866
+ * ```typescript
3867
+ * await app.close([
3868
+ * { stream: "order-123", archive: async () => { await archiveToS3("order-123"); } },
3869
+ * { stream: "order-456" },
3870
+ * ]);
3871
+ * ```
3872
+ */
3873
+ async close(targets) {
3874
+ if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
3875
+ return this._scoped(() => this._run_close(targets));
3876
+ }
3877
+ /**
3878
+ * Debounced, non-blocking correlate→drain cycle.
3879
+ *
3880
+ * A local change that frees reaction work (a commit, `reset()`,
3881
+ * `unblock()`, `restore()`) schedules this on its own unless the app
3882
+ * sets `autoSettle: false`. Call it at startup, to run what was pending
3883
+ * before a restart, or with options for the next pass. A pass already
3884
+ * scheduled is never put off: calls before it runs join it (a shorter
3885
+ * `debounceMs` brings it forward), and a call during a running cycle
3886
+ * queues one more pass after it. Runs correlate→drain in a loop until a pass makes no
3887
+ * progress — no new subscriptions, no events read, no streams leased —
3888
+ * then emits the `"settled"` lifecycle event, so one call fully catches
3889
+ * up paginated streams without forcing callers to loop.
3890
+ *
3891
+ * @param options - Settle configuration — see {@link SettleOptions} for fields:
3892
+ * `debounceMs` (default 10), `correlate.limit` (default 100, or the
3893
+ * poll's limit while `start_correlations` runs; `after` is always the
3894
+ * checkpoint),
3895
+ * `maxPasses` (default `Infinity` — kill-switch for runaway loops),
3896
+ * `streamLimit` (default 10), `eventLimit` (default 10),
3897
+ * `leaseMillis` (default 10000).
3898
+ *
3899
+ * @example Startup
3900
+ * ```typescript
3901
+ * app.settle(); // run what was pending before the restart
3902
+ *
3903
+ * app.on("settled", (drain) => {
3904
+ * // notify SSE clients, invalidate caches, etc.
3905
+ * });
3906
+ * ```
3907
+ *
3908
+ * @see {@link drain} for single synchronous drain cycles
3909
+ * @see {@link correlate} for manual correlation
3910
+ */
3911
+ settle(options) {
3912
+ resolveSettleConfig(options ?? {});
3913
+ this._schedule(options);
3914
+ }
3915
+ };
3916
+
3917
+ // src/internal/autoclose-policy.ts
3918
+ import { z as z2 } from "zod";
3919
+ var DAY_MS = 864e5;
3920
+ var MIN_AFTER_DAYS = 1 / 1440;
3921
+ var MIN_KEEP_DAYS = 1;
3922
+ function days_after(date, days) {
3923
+ return new Date(date.getTime() + days * DAY_MS);
3924
+ }
3925
+ function days_before_now(days) {
3926
+ return new Date(Date.now() - days * DAY_MS);
3927
+ }
3928
+ var window_schema = (field, min_days, floor) => z2.object({
3929
+ days: z2.number({ message: `autocloses: ${field}.days must be a number` }).positive(`autocloses: ${field}.days must be > 0`)
3930
+ }).refine((o) => o.days >= min_days, {
3931
+ message: `autocloses: ${field} resolves to < ${floor}, which is too short to be a meaningful retention window`
3932
+ });
3933
+ var AfterSchema = window_schema("after", MIN_AFTER_DAYS, "one minute");
3934
+ var KeepSchema = window_schema("keep", MIN_KEEP_DAYS, "one day");
3935
+ var IsSchema = z2.union([
3936
+ z2.string().min(1, "autocloses: is must be a non-empty event name"),
3937
+ z2.array(
3938
+ z2.string().min(1, "autocloses: is entries must be non-empty event names")
3939
+ ).min(1, "autocloses: is must include at least one event name").readonly()
3940
+ ]);
3941
+ 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");
3942
+ var OrBlockSchema = z2.object({
3943
+ after: AfterSchema.optional(),
3944
+ is: IsSchema.optional(),
3945
+ reaches: ReachesSchema.optional()
3946
+ }).strict().refine(
3947
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0,
3948
+ {
3949
+ message: "autocloses: `or` block must include at least one of after / is / reaches"
3950
+ }
3951
+ );
3952
+ var AutoclosePolicySchema = z2.object({
3953
+ after: AfterSchema.optional(),
3954
+ is: IsSchema.optional(),
3955
+ reaches: ReachesSchema.optional(),
3956
+ or: OrBlockSchema.optional(),
3957
+ keep: KeepSchema.optional()
3958
+ }).strict().refine(
3959
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0 || o.keep !== void 0,
3960
+ {
3961
+ message: "autocloses: at least one of after / is / reaches / or / keep must be specified \u2014 empty `{}` is a misconfiguration"
3962
+ }
3963
+ );
3964
+ function compile_after(after) {
3965
+ return (_stream, head) => days_after(head.created, after.days) <= /* @__PURE__ */ new Date();
3966
+ }
3967
+ function compile_is(is) {
3968
+ const set = new Set(typeof is === "string" ? [is] : is);
3969
+ return (_stream, head) => set.has(head.name);
3970
+ }
3971
+ function compile_reaches(reaches) {
3972
+ return (_stream, _head, count) => count >= reaches;
3973
+ }
3974
+ function policy_min_after_days(options) {
3975
+ const parsed = AutoclosePolicySchema.parse(options);
3976
+ const windows = [];
3977
+ if (parsed.after) windows.push(parsed.after.days);
3978
+ if (parsed.or?.after) windows.push(parsed.or.after.days);
3979
+ return windows.length ? Math.min(...windows) : void 0;
3980
+ }
3981
+ function policy_keep_days(options) {
3982
+ const parsed = AutoclosePolicySchema.parse(options);
3983
+ return parsed.keep?.days;
3984
+ }
3985
+ function compile_autoclose_policy(options) {
3986
+ const parsed = AutoclosePolicySchema.parse(options);
3987
+ const and_preds = [];
3988
+ if (parsed.after) and_preds.push(compile_after(parsed.after));
3989
+ if (parsed.is) and_preds.push(compile_is(parsed.is));
3990
+ if (parsed.reaches) and_preds.push(compile_reaches(parsed.reaches));
3991
+ const or_preds = [];
3992
+ if (parsed.or) {
3993
+ if (parsed.or.after) or_preds.push(compile_after(parsed.or.after));
3994
+ if (parsed.or.is) or_preds.push(compile_is(parsed.or.is));
3995
+ if (parsed.or.reaches) or_preds.push(compile_reaches(parsed.or.reaches));
3996
+ }
3997
+ return (stream, head, count) => {
3998
+ if (and_preds.length > 0 && and_preds.every((p) => p(stream, head, count))) {
3999
+ return true;
4000
+ }
4001
+ if (or_preds.some((p) => p(stream, head, count))) {
4002
+ return true;
4003
+ }
4004
+ return false;
4005
+ };
4006
+ }
4007
+
4008
+ // src/internal/autoclose-window.ts
4009
+ function hour_in_zone(now, timeZone) {
4010
+ const parts = new Intl.DateTimeFormat("en-US", {
4011
+ timeZone,
4012
+ hour: "2-digit",
4013
+ hourCycle: "h23"
4014
+ }).formatToParts(now);
4015
+ return Number(parts.find((p) => p.type === "hour")?.value);
4016
+ }
4017
+ function hour_in_range(hour, start, end) {
4018
+ return start < end ? hour >= start && hour < end : hour >= start || hour < end;
4019
+ }
4020
+ function is_dst_gap_open(now, timeZone, start) {
4021
+ const hour = hour_in_zone(now, timeZone);
4022
+ if (hour <= start) return false;
4023
+ const prev = hour_in_zone(new Date(now.getTime() - 36e5), timeZone);
4024
+ return prev === start - 1 && hour === start + 1;
4025
+ }
4026
+ function in_autoclose_window(window, now) {
4027
+ if (!window) return true;
4028
+ const hour = hour_in_zone(now, window.timeZone);
4029
+ return hour_in_range(hour, window.start, window.end) || is_dst_gap_open(now, window.timeZone, window.start);
4030
+ }
4031
+ function next_window_open(window, now) {
4032
+ const start_ms = now.getTime();
4033
+ for (let h = 0; h <= 48; h++) {
4034
+ const candidate = new Date(start_ms + h * 36e5);
4035
+ if (hour_in_zone(candidate, window.timeZone) === window.start)
4036
+ return candidate;
4037
+ const boundary = new Date(
4038
+ candidate.getTime() - candidate.getTime() % 36e5
4039
+ );
4040
+ if (boundary.getTime() >= start_ms && is_dst_gap_open(boundary, window.timeZone, window.start))
4041
+ return boundary;
4042
+ }
4043
+ return new Date(start_ms + 864e5);
4044
+ }
4045
+
4046
+ // src/internal/autoclose-reaction.ts
4047
+ var AUTOCLOSE_TARGET_PREFIX = "__autoclose__:";
4048
+ function synthesize_autoclose_reactions(registry, states, read_window) {
4049
+ for (const st of states.values()) {
4050
+ const predicate = st.autoclose;
4051
+ if (!predicate) continue;
4052
+ const after_days = st.autoclose_after_days;
4053
+ const keep_days = st.autoclose_keep_days;
4054
+ const archiver = st.archive;
4055
+ const reaction = {
4056
+ // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
4057
+ // per-aggregate `__autoclose__` key — so the autoclose reaction never
4058
+ // shares a watermark with the aggregate's own reactions. A shared
4059
+ // watermark would let autoclose's defer short-circuit the aggregate's
4060
+ // other reactions (the "a defer affects all reactions on a stream"
4061
+ // hazard). The close still targets the aggregate (`source`).
4062
+ resolver: (e) => ({
4063
+ target: `${AUTOCLOSE_TARGET_PREFIX}${e.stream}`,
4064
+ source: e.stream
4065
+ }),
4066
+ // Never block on autoclose: a transient query/store error should retry,
4067
+ // not quarantine the synthetic stream.
4068
+ options: { blockOnError: false, maxRetries: DEFAULT_MAX_RETRIES },
4069
+ handler: async (event) => {
4070
+ const aggregate = event.stream;
4071
+ const window = read_window();
4072
+ if (!in_autoclose_window(window, /* @__PURE__ */ new Date()))
4073
+ throw new DeferSignal({ at: next_window_open(window, /* @__PURE__ */ new Date()) });
4074
+ const stats = await store().query_stats([aggregate], {
4075
+ count: true,
4076
+ tail: keep_days !== void 0 ? true : void 0,
4077
+ exclude: [TOMBSTONE_EVENT, SNAP_EVENT]
4078
+ });
4079
+ const entry = stats.get(aggregate);
4080
+ if (!entry) return;
4081
+ const head = entry.head;
4082
+ if (predicate(aggregate, head, entry.count))
4083
+ throw new CloseSignal({
4084
+ stream: aggregate,
4085
+ archive: archiver ? () => archiver(aggregate, head) : void 0,
4086
+ // Ack this reaction's own watermark to the live head so the
4087
+ // close-cycle guard (which matches subscriptions by source =
4088
+ // aggregate) sees it caught up instead of blocking its own close.
4089
+ at: head.id
4090
+ });
4091
+ if (keep_days !== void 0) {
4092
+ const cutoff = days_before_now(keep_days);
4093
+ if (entry.tail.created < cutoff)
4094
+ throw new CloseSignal({
4095
+ stream: aggregate,
4096
+ before: cutoff,
4097
+ archive: archiver ? () => archiver(aggregate, head, cutoff) : void 0,
4098
+ at: head.id
4099
+ });
4100
+ }
4101
+ const dues = [];
4102
+ if (after_days !== void 0)
4103
+ dues.push(days_after(head.created, after_days));
4104
+ if (keep_days !== void 0)
4105
+ dues.push(days_after(entry.tail.created, keep_days));
4106
+ const future = dues.map((d) => d.getTime()).filter((t) => t > Date.now());
4107
+ if (future.length)
4108
+ throw new DeferSignal({ at: new Date(Math.min(...future)) });
4119
4109
  }
4120
- return { kept, migrated, dropped, duration_ms: Date.now() - started };
4121
- });
4122
- }
4123
- /**
4124
- * Return every currently-blocked stream position. Convenience wrapper
4125
- * around `store().query_streams(cb, { blocked: true })` for the common
4126
- * "show me what's broken" operational query.
4127
- *
4128
- * Results are ordered by stream name, paginated by `limit` (default
4129
- * 100). Pass `after` to fetch the next page (keyset cursor on the
4130
- * stream name). For richer queries — including blocked + source
4131
- * filters, or full unblocked introspection — drop to
4132
- * `store().query_streams(...)` directly.
4133
- *
4134
- * @returns Array of {@link StreamPosition} for currently-blocked streams.
4135
- *
4136
- * @example Discover and recover
4137
- * ```typescript
4138
- * const blocked = await app.blocked_streams();
4139
- * console.table(blocked.map(({ stream, retry, error }) => ({ stream, retry, error })));
4140
- *
4141
- * // Operator investigates, then bulk-unblocks the family:
4142
- * await app.unblock({ stream: "^webhooks-out-" });
4143
- * ```
4144
- */
4145
- async blocked_streams(options) {
4146
- return this._scoped(async () => {
4147
- const positions = [];
4148
- await store().query_streams(
4149
- (p) => {
4150
- positions.push(p);
4151
- },
4152
- { blocked: true, after: options?.after, limit: options?.limit }
4110
+ };
4111
+ const key = `__autoclose_${st.name}`;
4112
+ for (const event_name of Object.keys(st.events)) {
4113
+ registry.events[event_name]?.reactions.set(
4114
+ key,
4115
+ reaction
4153
4116
  );
4154
- return positions;
4155
- });
4156
- }
4157
- /**
4158
- * Operator-driven store audit.
4159
- *
4160
- * Walks the connected store and yields per-category findings —
4161
- * each tagged with the remediation it suggests. Same operator-
4162
- * driven category as `app.close()` / `app.reset()` /
4163
- * `app.unblock()` / `app.blocked_streams()`: never auto-invoked by
4164
- * the framework; the operator decides when to run it (CI gate,
4165
- * scheduled job, ad-hoc forensics) and what to do with the
4166
- * findings.
4167
- *
4168
- * Categories are independent — pass a subset to scope the work,
4169
- * or omit to run everything:
4170
- *
4171
- * ```typescript
4172
- * // Targeted: schema drift + deprecated-event load only
4173
- * for await (const f of app.audit(["schema", "deprecated-load"], {
4174
- * query: { created_after: lastScan },
4175
- * thresholds: { deprecatedLoadShareMin: 0.10 },
4176
- * })) {
4177
- * await escalate(f);
4178
- * }
4179
- *
4180
- * // Full audit, default thresholds
4181
- * for await (const f of app.audit()) console.log(f);
4182
- * ```
4183
- *
4184
- * Returns an `AsyncIterable` so callers can `break` early — the
4185
- * underlying store paginations respect the iterator protocol and
4186
- * stop cleanly. Each finding is emitted independently, so
4187
- * pipelining into Slack / persistence / further analysis works
4188
- * without buffering the full report in memory.
4189
- *
4190
- * Findings shape — see {@link AuditFinding}. The discriminated
4191
- * union carries enough context for the operator to act on each
4192
- * finding directly: stream id, event id, recommendation hints.
4193
- *
4194
- * @param categories - Subset of categories to run (default: all).
4195
- * @param options - Query window + per-category thresholds.
4196
- * @returns Async iterable of {@link AuditFinding}.
4197
- */
4198
- async *audit(categories, options) {
4199
- const it = audit(this._audit_deps, categories, options)[Symbol.asyncIterator]();
4200
- while (true) {
4201
- const { value, done } = await this._scoped(() => it.next());
4202
- if (done) break;
4203
- yield value;
4204
4117
  }
4205
4118
  }
4206
- /**
4207
- * Bulk-update scheduling priority for streams matching `filter`.
4208
- *
4209
- * Operator-grade override of the `claim()` lagging-frontier
4210
- * ordering. Useful when a long-running replay needs to
4211
- * jump ahead of other lagging streams, or when a no-longer-urgent
4212
- * job should yield slots back to the rest. Build-time priorities
4213
- * (set via the resolver's `priority` field) are subject to a
4214
- * `max()` invariant across reactions; this API ignores that and
4215
- * sets the priority outright on every matching row.
4216
- *
4217
- * Filter shape mirrors {@link query} / {@link Store.query_streams}:
4218
- * `stream` / `source` are regex by default, exact with the
4219
- * `*_exact` flags; `blocked` restricts to blocked or unblocked
4220
- * rows. **An empty filter (`{}`) updates every registered stream.**
4221
- *
4222
- * @param filter - Selection criteria (regex by default).
4223
- * @param priority - New priority value. Set as-is — no clamp.
4224
- * @returns Count of streams whose priority changed.
4225
- *
4226
- * @example Boost a specific projection mid-replay
4227
- * ```typescript
4228
- * await app.prioritize({ stream: "^proj-orders$", stream_exact: false }, 10);
4229
- * ```
4230
- *
4231
- * @see {@link Store.prioritize} for the underlying primitive
4232
- * @see {@link claim} for how priority biases scheduling
4233
- */
4234
- async prioritize(filter, priority) {
4235
- return this._scoped(() => store().prioritize(filter, priority));
4236
- }
4237
- /** Shared by `close()` and reaction-requested closes. */
4238
- async _run_close(targets) {
4239
- const close_actor = { id: "$close", name: "close" };
4240
- const result = await run_close_cycle(targets, {
4241
- reactive: this._reactive,
4242
- catch_up_correlation: (until) => catch_up_correlation(
4243
- this._correlate,
4244
- (limit) => this.correlate({ limit }),
4245
- until
4246
- ),
4247
- event_to_state: this._event_to_state,
4248
- load: this._es.load,
4249
- tombstone: this._es.tombstone,
4250
- logger: this._logger,
4251
- correlation: close_correlation(this._correlator, close_actor),
4252
- with_stream_lock: (stream, work) => this._close_lock.run(stream, work),
4253
- forget_subscribed: (streams) => this._correlate.forget_subscribed(streams)
4254
- });
4255
- this.emit("closed", result);
4256
- return result;
4257
- }
4258
- /**
4259
- * Close the books — guard, archive, truncate, and optionally restart streams.
4260
- *
4261
- * Safely removes historical events from the operational store:
4262
- *
4263
- * 1. **Correlate** — discover pending reaction targets
4264
- * 2. **Safety check** — skip streams with pending reactions (skipped when no reactive events)
4265
- * 3. **Guard** — commit `__tombstone__` with `expectedVersion` to block concurrent writes
4266
- * 4. **Load state** — for streams in `snapshots`, load final state while guarded (no races)
4267
- * 5. **Archive** — user callback per stream (abort-all on failure, streams are guarded)
4268
- * 6. **Truncate + seed** — atomic: delete all events, insert `__snapshot__` or `__tombstone__`
4269
- * 7. **Cache** — invalidate (tombstoned) or warm (restarted)
4270
- * 8. **Emit "closed"** — lifecycle event with results
4271
- *
4272
- * Targets carrying `before` take the **windowed** branch instead —
4273
- * close the books on a rolling window: probe the min consumer
4274
- * watermark (so the boundary never rises past a lagging reaction),
4275
- * run the archive callback against the cutoff, then prune the prefix
4276
- * below the closest safe `__snapshot__`. No tombstone guard (the
4277
- * pre-cutoff prefix is immutable), no seed, cache untouched — the
4278
- * stream stays live and keeps accepting actions. Requires the state
4279
- * to snapshot via `.snap(...)`; no qualifying snapshot ⇒ the stream
4280
- * lands in `skipped` (retry after the next snapshot). Windowed
4281
- * entries in the result echo `before` and carry the surviving
4282
- * boundary snapshot as `committed`.
4283
- *
4284
- * @param targets - Per-stream close options (stream, restart?, archive?, before?)
4285
- * @returns `{ truncated: TruncateResult, skipped: string[] }`
4286
- *
4287
- * @example Archive and close
4288
- * ```typescript
4289
- * await app.close([
4290
- * { stream: "order-123", archive: async () => { await archiveToS3("order-123"); } },
4291
- * { stream: "order-456" },
4292
- * ]);
4293
- * ```
4294
- */
4295
- async close(targets) {
4296
- if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
4297
- return this._scoped(() => this._run_close(targets));
4298
- }
4299
- /**
4300
- * Debounced, non-blocking correlate→drain cycle.
4301
- *
4302
- * Call this after `app.do()` (or `app.reset()`) to schedule a background
4303
- * drain. Multiple rapid calls within the debounce window are coalesced
4304
- * into a single cycle. Runs correlate→drain in a loop until a pass makes
4305
- * no progress — no new subscriptions, no events read, no streams leased —
4306
- * then emits
4307
- * the `"settled"` lifecycle event. This means a single `settle()` call
4308
- * fully catches up paginated streams (e.g. after `reset()` on a long
4309
- * projection) without forcing callers to loop.
4310
- *
4311
- * @param options - Settle configuration — see {@link SettleOptions} for fields:
4312
- * `debounceMs` (default 10), `correlate` (default `{ after: -1, limit: 100 }`),
4313
- * `maxPasses` (default `Infinity` — kill-switch for runaway loops),
4314
- * `streamLimit` (default 10), `eventLimit` (default 10),
4315
- * `leaseMillis` (default 10000).
4316
- *
4317
- * @example API mutations
4318
- * ```typescript
4319
- * await app.do("CreateItem", target, input);
4320
- * app.settle(); // non-blocking, returns immediately
4321
- *
4322
- * app.on("settled", (drain) => {
4323
- * // notify SSE clients, invalidate caches, etc.
4324
- * });
4325
- * ```
4326
- *
4327
- * @see {@link drain} for single synchronous drain cycles
4328
- * @see {@link correlate} for manual correlation
4329
- */
4330
- settle(options = {}) {
4331
- resolveSettleConfig(options);
4332
- if (!this._drain) return;
4333
- this._settle.schedule(options);
4334
- }
4335
- };
4119
+ }
4336
4120
 
4337
4121
  // src/builders/merge.ts
4338
4122
  import { ZodObject } from "zod";
@@ -4571,7 +4355,74 @@ function reaction_on(event, events, builder) {
4571
4355
  }
4572
4356
 
4573
4357
  // src/builders/event-builder.ts
4574
- import { z as z5 } from "zod";
4358
+ import { z as z4 } from "zod";
4359
+
4360
+ // src/internal/date-reviver.ts
4361
+ import { z as z3 } from "zod";
4362
+ var def_of = (schema) => schema._zod?.def ?? schema.def;
4363
+ function variant_schema(schema) {
4364
+ const shape = def_of(schema)?.shape;
4365
+ if (!shape) {
4366
+ const dates = date_reviver_schema(schema);
4367
+ return { schema: dates ?? schema, dated: !!dates };
4368
+ }
4369
+ const next = {};
4370
+ let dated = false;
4371
+ for (const [key, field] of Object.entries(shape)) {
4372
+ const dates = date_reviver_schema(field);
4373
+ if (dates) dated = true;
4374
+ next[key] = (dates ?? field).optional();
4375
+ }
4376
+ return { schema: z3.looseObject(next), dated };
4377
+ }
4378
+ function date_reviver_schema(schema) {
4379
+ const def = def_of(schema);
4380
+ if (!def) return void 0;
4381
+ const inner = () => date_reviver_schema(def.innerType);
4382
+ switch (def.type) {
4383
+ case "date":
4384
+ return z3.coerce.date();
4385
+ case "object": {
4386
+ const shape = def.shape;
4387
+ if (!shape) return void 0;
4388
+ const dated = {};
4389
+ for (const [key, field] of Object.entries(shape)) {
4390
+ const dates = date_reviver_schema(field);
4391
+ if (dates) dated[key] = dates.optional();
4392
+ }
4393
+ return Object.keys(dated).length ? z3.looseObject(dated) : void 0;
4394
+ }
4395
+ case "array": {
4396
+ const element = date_reviver_schema(def.element);
4397
+ return element && z3.array(element);
4398
+ }
4399
+ case "tuple": {
4400
+ const items = def.items.map((i) => date_reviver_schema(i));
4401
+ return items.some(Boolean) ? z3.tuple(items.map((i) => i ?? z3.unknown())) : void 0;
4402
+ }
4403
+ case "record": {
4404
+ const value = date_reviver_schema(def.valueType);
4405
+ return value && z3.record(z3.string(), value);
4406
+ }
4407
+ case "union": {
4408
+ const variants = def.options.map(variant_schema);
4409
+ return variants.some((v) => v.dated) ? z3.union(variants.map((v) => v.schema)) : void 0;
4410
+ }
4411
+ case "nullable":
4412
+ return inner()?.nullable();
4413
+ case "optional":
4414
+ case "nonoptional":
4415
+ case "readonly":
4416
+ case "default":
4417
+ case "prefault":
4418
+ case "catch":
4419
+ return inner();
4420
+ default:
4421
+ return void 0;
4422
+ }
4423
+ }
4424
+
4425
+ // src/builders/event-builder.ts
4575
4426
  function event_tags(schema) {
4576
4427
  const pii = pii_schemas(schema);
4577
4428
  const pii_dates = {};
@@ -4588,7 +4439,7 @@ function event_tags(schema) {
4588
4439
  return {
4589
4440
  sensitive: Object.keys(pii),
4590
4441
  date_reviver: data_reviver_schema && revive(data_reviver_schema),
4591
- pii_date_reviver: dated_pii ? revive(z5.looseObject(pii_dates)) : void 0
4442
+ pii_date_reviver: dated_pii ? revive(z4.looseObject(pii_dates)) : void 0
4592
4443
  };
4593
4444
  }
4594
4445
  function make_event_reader(tags, disclosure, predicate = null) {
@@ -5375,9 +5226,6 @@ export {
5375
5226
  ConcurrencyError,
5376
5227
  ConsoleLogger,
5377
5228
  CsvFile,
5378
- DEFAULT_AUTOCLOSE_CYCLE_MINUTES,
5379
- DEFAULT_CLOSE_BATCH_SIZE,
5380
- DEFAULT_CLOSE_YIELD_MS,
5381
5229
  DEFAULT_LANE,
5382
5230
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
5383
5231
  DEFAULT_SETTLE_DEBOUNCE_MS,
@@ -5415,7 +5263,7 @@ export {
5415
5263
  pii_fields,
5416
5264
  port,
5417
5265
  projection,
5418
- resolveAutocloseConfig,
5266
+ resolveAutocloseConfig2 as resolveAutocloseConfig,
5419
5267
  sensitive,
5420
5268
  sleep,
5421
5269
  slice,