@rotorsoft/act 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/@types/act.d.ts +26 -1
  3. package/dist/@types/act.d.ts.map +1 -1
  4. package/dist/@types/adapters/in-memory-store.d.ts +13 -4
  5. package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
  6. package/dist/@types/builders/state-builder.d.ts +35 -9
  7. package/dist/@types/builders/state-builder.d.ts.map +1 -1
  8. package/dist/@types/internal/autoclose-policy.d.ts +39 -13
  9. package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
  10. package/dist/@types/internal/autoclose-reaction.d.ts.map +1 -1
  11. package/dist/@types/internal/close-cycle.d.ts +5 -0
  12. package/dist/@types/internal/close-cycle.d.ts.map +1 -1
  13. package/dist/@types/internal/close-signal.d.ts +8 -0
  14. package/dist/@types/internal/close-signal.d.ts.map +1 -1
  15. package/dist/@types/internal/index.d.ts +1 -1
  16. package/dist/@types/internal/index.d.ts.map +1 -1
  17. package/dist/@types/internal/reactions.d.ts.map +1 -1
  18. package/dist/@types/types/action.d.ts +46 -13
  19. package/dist/@types/types/action.d.ts.map +1 -1
  20. package/dist/@types/types/ports.d.ts +29 -3
  21. package/dist/@types/types/ports.d.ts.map +1 -1
  22. package/dist/{chunk-TODGOWEZ.js → chunk-KBTC3NGQ.js} +64 -14
  23. package/dist/chunk-KBTC3NGQ.js.map +1 -0
  24. package/dist/index.cjs +228 -37
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.js +166 -25
  27. package/dist/index.js.map +1 -1
  28. package/dist/test/index.cjs +63 -13
  29. package/dist/test/index.cjs.map +1 -1
  30. package/dist/test/index.js +1 -1
  31. package/package.json +2 -2
  32. package/dist/chunk-TODGOWEZ.js.map +0 -1
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  sleep,
20
20
  store,
21
21
  validate
22
- } from "./chunk-TODGOWEZ.js";
22
+ } from "./chunk-KBTC3NGQ.js";
23
23
  import {
24
24
  ActorSchema,
25
25
  CausationEventSchema,
@@ -541,12 +541,22 @@ function in_autoclose_window(window, now) {
541
541
 
542
542
  // src/internal/autoclose-policy.ts
543
543
  import { z as z2 } from "zod";
544
- var MIN_AFTER_MS = 6e4;
545
- var AfterSchema = z2.object({
546
- days: z2.number({ message: "autocloses: after.days must be a number" }).positive("autocloses: after.days must be > 0")
547
- }).refine((o) => o.days * 864e5 >= MIN_AFTER_MS, {
548
- message: `autocloses: after resolves to < ${MIN_AFTER_MS}ms (one minute), which is too short to be a meaningful retention window`
544
+ var DAY_MS = 864e5;
545
+ var MIN_AFTER_DAYS = 1 / 1440;
546
+ var MIN_KEEP_DAYS = 1;
547
+ function days_after(date, days) {
548
+ return new Date(date.getTime() + days * DAY_MS);
549
+ }
550
+ function days_before_now(days) {
551
+ return new Date(Date.now() - days * DAY_MS);
552
+ }
553
+ var window_schema = (field, min_days, floor) => z2.object({
554
+ days: z2.number({ message: `autocloses: ${field}.days must be a number` }).positive(`autocloses: ${field}.days must be > 0`)
555
+ }).refine((o) => o.days >= min_days, {
556
+ message: `autocloses: ${field} resolves to < ${floor}, which is too short to be a meaningful retention window`
549
557
  });
558
+ var AfterSchema = window_schema("after", MIN_AFTER_DAYS, "one minute");
559
+ var KeepSchema = window_schema("keep", MIN_KEEP_DAYS, "one day");
550
560
  var IsSchema = z2.union([
551
561
  z2.string().min(1, "autocloses: is must be a non-empty event name"),
552
562
  z2.array(
@@ -568,16 +578,16 @@ var AutoclosePolicySchema = z2.object({
568
578
  after: AfterSchema.optional(),
569
579
  is: IsSchema.optional(),
570
580
  reaches: ReachesSchema.optional(),
571
- or: OrBlockSchema.optional()
581
+ or: OrBlockSchema.optional(),
582
+ keep: KeepSchema.optional()
572
583
  }).strict().refine(
573
- (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0,
584
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0 || o.keep !== void 0,
574
585
  {
575
- message: "autocloses: at least one of after / is / reaches / or must be specified \u2014 empty `{}` is a misconfiguration"
586
+ message: "autocloses: at least one of after / is / reaches / or / keep must be specified \u2014 empty `{}` is a misconfiguration"
576
587
  }
577
588
  );
578
589
  function compile_after(after) {
579
- const after_ms = after.days * 864e5;
580
- return (_stream, head) => Date.now() - head.created.getTime() >= after_ms;
590
+ return (_stream, head) => days_after(head.created, after.days) <= /* @__PURE__ */ new Date();
581
591
  }
582
592
  function compile_is(is) {
583
593
  const set = new Set(typeof is === "string" ? [is] : is);
@@ -586,13 +596,17 @@ function compile_is(is) {
586
596
  function compile_reaches(reaches) {
587
597
  return (_stream, _head, count) => count >= reaches;
588
598
  }
589
- function policy_min_after_ms(options) {
599
+ function policy_min_after_days(options) {
590
600
  const parsed = AutoclosePolicySchema.parse(options);
591
601
  const windows = [];
592
- if (parsed.after) windows.push(parsed.after.days * 864e5);
593
- if (parsed.or?.after) windows.push(parsed.or.after.days * 864e5);
602
+ if (parsed.after) windows.push(parsed.after.days);
603
+ if (parsed.or?.after) windows.push(parsed.or.after.days);
594
604
  return windows.length ? Math.min(...windows) : void 0;
595
605
  }
606
+ function policy_keep_days(options) {
607
+ const parsed = AutoclosePolicySchema.parse(options);
608
+ return parsed.keep?.days;
609
+ }
596
610
  function compile_autoclose_policy(options) {
597
611
  const parsed = AutoclosePolicySchema.parse(options);
598
612
  const and_preds = [];
@@ -635,12 +649,20 @@ var CloseSignal = class extends Error {
635
649
  * event id when omitted.
636
650
  */
637
651
  at;
652
+ /**
653
+ * Windowed close (#1011): prune events older than this cutoff behind
654
+ * the closest safe snapshot instead of retiring the stream. Thrown by
655
+ * the autoclose reaction of a `.autocloses({ keep })` state; flows
656
+ * into {@link CloseTarget.before}. Omitted → a full close.
657
+ */
658
+ before;
638
659
  constructor(opts) {
639
660
  super("reaction requested close");
640
661
  this.name = "CloseSignal";
641
662
  this.stream = opts?.stream;
642
663
  this.archive = opts?.archive;
643
664
  this.at = opts?.at;
665
+ this.before = opts?.before;
644
666
  }
645
667
  };
646
668
 
@@ -664,7 +686,8 @@ function synthesize_autoclose_reactions(registry, states, config2) {
664
686
  for (const st of states.values()) {
665
687
  const predicate = st.autoclose;
666
688
  if (!predicate) continue;
667
- const after_ms = st.autoclose_after_ms;
689
+ const after_days = st.autoclose_after_days;
690
+ const keep_days = st.autoclose_keep_days;
668
691
  const archiver = st.archive;
669
692
  const reaction = {
670
693
  // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
@@ -688,7 +711,8 @@ function synthesize_autoclose_reactions(registry, states, config2) {
688
711
  });
689
712
  const stats = await store().query_stats([aggregate], {
690
713
  count: true,
691
- exclude: [TOMBSTONE_EVENT]
714
+ tail: keep_days !== void 0 ? true : void 0,
715
+ exclude: keep_days !== void 0 ? [TOMBSTONE_EVENT, SNAP_EVENT] : [TOMBSTONE_EVENT]
692
716
  });
693
717
  const entry = stats.get(aggregate);
694
718
  if (!entry) return;
@@ -702,9 +726,24 @@ function synthesize_autoclose_reactions(registry, states, config2) {
702
726
  // aggregate) sees it caught up instead of blocking its own close.
703
727
  at: head.id
704
728
  });
705
- if (after_ms !== void 0)
729
+ if (keep_days !== void 0) {
730
+ const cutoff = days_before_now(keep_days);
731
+ if (entry.tail.created < cutoff)
732
+ throw new CloseSignal({
733
+ stream: aggregate,
734
+ before: cutoff,
735
+ archive: archiver ? () => archiver(aggregate, head, cutoff) : void 0,
736
+ at: head.id
737
+ });
738
+ }
739
+ const dues = [];
740
+ if (after_days !== void 0)
741
+ dues.push(days_after(head.created, after_days));
742
+ if (keep_days !== void 0)
743
+ dues.push(days_after(entry.tail.created, keep_days));
744
+ if (dues.length)
706
745
  throw new DeferSignal({
707
- at: new Date(head.created.getTime() + after_ms)
746
+ at: new Date(Math.min(...dues.map((d) => d.getTime())))
708
747
  });
709
748
  }
710
749
  };
@@ -1091,8 +1130,20 @@ var CircuitBreaker = class {
1091
1130
  var SAFETY_PROBE_PAGE_SIZE = 1e3;
1092
1131
  async function run_close_cycle(targets, deps) {
1093
1132
  const target_map = new Map(targets.map((t) => [t.stream, t]));
1094
- const streams = [...target_map.keys()];
1133
+ for (const t of target_map.values()) {
1134
+ if (t.before !== void 0 && t.restart)
1135
+ throw new Error(
1136
+ `close: \`before\` and \`restart\` are mutually exclusive (stream "${t.stream}") \u2014 a windowed close keeps the stream live behind a real snapshot; restart reseeds it`
1137
+ );
1138
+ }
1139
+ const windowed = [...target_map.values()].filter(
1140
+ (t) => t.before !== void 0
1141
+ );
1142
+ const full = [...target_map.values()].filter((t) => t.before === void 0);
1095
1143
  const skipped = [];
1144
+ const windowed_result = windowed.length ? await run_windowed_closes(windowed, deps, skipped) : /* @__PURE__ */ new Map();
1145
+ if (!full.length) return { truncated: windowed_result, skipped };
1146
+ const streams = full.map((t) => t.stream);
1096
1147
  const stream_info = await scan_stream_heads(streams);
1097
1148
  const safe = await partition_by_safety(
1098
1149
  stream_info,
@@ -1100,7 +1151,7 @@ async function run_close_cycle(targets, deps) {
1100
1151
  skipped,
1101
1152
  deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
1102
1153
  );
1103
- if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
1154
+ if (!safe.length) return { truncated: windowed_result, skipped };
1104
1155
  const { guarded, guard_events } = await guard_with_tombstones(
1105
1156
  safe,
1106
1157
  stream_info,
@@ -1108,7 +1159,7 @@ async function run_close_cycle(targets, deps) {
1108
1159
  deps.tombstone,
1109
1160
  skipped
1110
1161
  );
1111
- if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
1162
+ if (!guarded.length) return { truncated: windowed_result, skipped };
1112
1163
  const seed_states = await load_restart_seeds(
1113
1164
  guarded,
1114
1165
  target_map,
@@ -1124,8 +1175,62 @@ async function run_close_cycle(targets, deps) {
1124
1175
  guard_events,
1125
1176
  deps.correlation
1126
1177
  );
1178
+ for (const [stream, entry] of windowed_result) truncated.set(stream, entry);
1127
1179
  return { truncated, skipped };
1128
1180
  }
1181
+ async function run_windowed_closes(windowed, deps, skipped) {
1182
+ const min_at = deps.reactive_events_size > 0 ? await probe_min_watermarks(
1183
+ windowed.map((t) => t.stream),
1184
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
1185
+ ) : /* @__PURE__ */ new Map();
1186
+ for (const t of windowed) {
1187
+ if (t.archive) await t.archive();
1188
+ }
1189
+ const truncated = await store().truncate(
1190
+ windowed.map((t) => ({
1191
+ stream: t.stream,
1192
+ before: t.before,
1193
+ max_id: min_at.get(t.stream)
1194
+ }))
1195
+ );
1196
+ for (const t of windowed) {
1197
+ if (!truncated.has(t.stream)) skipped.push(t.stream);
1198
+ }
1199
+ return truncated;
1200
+ }
1201
+ async function probe_min_watermarks(streams, page_size) {
1202
+ const min_at = /* @__PURE__ */ new Map();
1203
+ const source_regex = /* @__PURE__ */ new Map();
1204
+ const get_regex = (source) => {
1205
+ let re = source_regex.get(source);
1206
+ if (!re) {
1207
+ re = new RegExp(source);
1208
+ source_regex.set(source, re);
1209
+ }
1210
+ return re;
1211
+ };
1212
+ let after;
1213
+ for (; ; ) {
1214
+ let last;
1215
+ const { count } = await store().query_streams(
1216
+ (position) => {
1217
+ last = position.stream;
1218
+ const source_re = position.source ? get_regex(position.source) : void 0;
1219
+ for (const stream of streams) {
1220
+ if (!source_re || source_re.test(stream)) {
1221
+ const prev = min_at.get(stream);
1222
+ if (prev === void 0 || position.at < prev)
1223
+ min_at.set(stream, position.at);
1224
+ }
1225
+ }
1226
+ },
1227
+ { after, limit: page_size, source_matches: streams }
1228
+ );
1229
+ if (count < page_size) break;
1230
+ after = last;
1231
+ }
1232
+ return min_at;
1233
+ }
1129
1234
  async function scan_stream_heads(streams) {
1130
1235
  const stats = await store().query_stats(streams, {
1131
1236
  exclude: [SNAP_EVENT]
@@ -2326,8 +2431,13 @@ function build_handle(deps) {
2326
2431
  acked_at: error.at ?? event.id,
2327
2432
  // Close the signalled stream (the autoclose reaction's aggregate,
2328
2433
  // which differs from its synthetic lease stream); a self-closing
2329
- // user reaction omits it and closes its own lease stream.
2330
- close: { stream: error.stream ?? stream, archive: error.archive }
2434
+ // user reaction omits it and closes its own lease stream. A
2435
+ // carried `before` makes it a windowed close (prune, not retire).
2436
+ close: {
2437
+ stream: error.stream ?? stream,
2438
+ archive: error.archive,
2439
+ before: error.before
2440
+ }
2331
2441
  };
2332
2442
  return finalize(
2333
2443
  lease,
@@ -3630,7 +3740,19 @@ var Act = class {
3630
3740
  * 7. **Cache** — invalidate (tombstoned) or warm (restarted)
3631
3741
  * 8. **Emit "closed"** — lifecycle event with results
3632
3742
  *
3633
- * @param targets - Per-stream close options (stream, restart?, archive?)
3743
+ * Targets carrying `before` take the **windowed** branch instead —
3744
+ * close the books on a rolling window: probe the min consumer
3745
+ * watermark (so the boundary never rises past a lagging reaction),
3746
+ * run the archive callback against the cutoff, then prune the prefix
3747
+ * below the closest safe `__snapshot__`. No tombstone guard (the
3748
+ * pre-cutoff prefix is immutable), no seed, cache untouched — the
3749
+ * stream stays live and keeps accepting actions. Requires the state
3750
+ * to snapshot via `.snap(...)`; no qualifying snapshot ⇒ the stream
3751
+ * lands in `skipped` (retry after the next snapshot). Windowed
3752
+ * entries in the result echo `before` and carry the surviving
3753
+ * boundary snapshot as `committed`.
3754
+ *
3755
+ * @param targets - Per-stream close options (stream, restart?, archive?, before?)
3634
3756
  * @returns `{ truncated: TruncateResult, skipped: string[] }`
3635
3757
  *
3636
3758
  * @example Archive and close
@@ -3648,6 +3770,19 @@ var Act = class {
3648
3770
  * { stream: "counter-2" }, // tombstoned
3649
3771
  * ]);
3650
3772
  * ```
3773
+ *
3774
+ * @example Windowed close — keep the last 180 days of real events
3775
+ * ```typescript
3776
+ * const cutoff = new Date();
3777
+ * cutoff.setDate(cutoff.getDate() - 180);
3778
+ * await app.close([
3779
+ * {
3780
+ * stream: "ledger-acme",
3781
+ * before: cutoff,
3782
+ * archive: async () => { await archiveToS3("ledger-acme", cutoff); },
3783
+ * },
3784
+ * ]);
3785
+ * ```
3651
3786
  */
3652
3787
  async close(targets) {
3653
3788
  if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
@@ -4180,10 +4315,16 @@ function action_builder(state2) {
4180
4315
  ".autocloses(...) requires a policy object; got " + typeof policy
4181
4316
  );
4182
4317
  }
4318
+ if (policy.keep && !internal.snap) {
4319
+ throw new Error(
4320
+ ".autocloses({ keep }) requires .snap(...) earlier in the chain \u2014 a rolling window prunes behind a real snapshot, so a state that never snapshots has nothing to prune behind."
4321
+ );
4322
+ }
4183
4323
  internal.autoclose = compile_autoclose_policy(
4184
4324
  policy
4185
4325
  );
4186
- internal.autoclose_after_ms = policy_min_after_ms(policy);
4326
+ internal.autoclose_after_days = policy_min_after_days(policy);
4327
+ internal.autoclose_keep_days = policy_keep_days(policy);
4187
4328
  return builder;
4188
4329
  },
4189
4330
  archives(archive) {