@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.cjs CHANGED
@@ -803,6 +803,10 @@ var InMemoryStream = class {
803
803
  var InMemoryStore = class {
804
804
  // stored events
805
805
  _events = [];
806
+ // next event id — monotonic, never reused. Deletions (truncate, windowed
807
+ // prune) punch holes in the id sequence, so ids are NOT array indexes and
808
+ // NOT `_events.length`; they only stay sorted ascending in `_events`.
809
+ _next_id = 0;
806
810
  // stored stream positions and other metadata
807
811
  _streams = /* @__PURE__ */ new Map();
808
812
  // last committed version per stream — O(1) replacement for filter-on-commit
@@ -820,11 +824,26 @@ var InMemoryStore = class {
820
824
  _pii = /* @__PURE__ */ new Map();
821
825
  _reset_indexes() {
822
826
  this._events.length = 0;
827
+ this._next_id = 0;
823
828
  this._stream_versions.clear();
824
829
  this._max_event_id_by_stream.clear();
825
830
  this._max_non_snap_event_id = -1;
826
831
  this._pii.clear();
827
832
  }
833
+ // First index whose event id is greater than `after`. `_events` stays
834
+ // sorted ascending by id (append-only commits; deletions preserve
835
+ // order), so id-bounded scans binary-search their start instead of
836
+ // assuming id === index — an invariant that truncation breaks.
837
+ _first_index_after(after) {
838
+ let lo = 0;
839
+ let hi = this._events.length;
840
+ while (lo < hi) {
841
+ const mid = lo + hi >>> 1;
842
+ if (this._events[mid].id > after) hi = mid;
843
+ else lo = mid + 1;
844
+ }
845
+ return lo;
846
+ }
828
847
  // Attach the isolated PII payload (or null) to an event before handing it to
829
848
  // a caller. Allocation-free for events without PII — by far the common case.
830
849
  _with_pii(e) {
@@ -877,7 +896,7 @@ var InMemoryStore = class {
877
896
  await sleep();
878
897
  let count = 0;
879
898
  if (query?.backward) {
880
- let i = (query?.before || this._events.length) - 1;
899
+ let i = (query?.before !== void 0 ? this._first_index_after(query.before - 1) : this._events.length) - 1;
881
900
  while (i >= 0) {
882
901
  const e = this._events[i--];
883
902
  if (query && !this.in_query(query, e)) continue;
@@ -892,7 +911,7 @@ var InMemoryStore = class {
892
911
  if (query?.limit && count >= query.limit) break;
893
912
  }
894
913
  } else {
895
- let i = (query?.after ?? -1) + 1;
914
+ let i = this._first_index_after(query?.after ?? -1);
896
915
  if (query?.with_snaps && query.stream_exact && query.stream !== void 0 && query.after === void 0) {
897
916
  for (let j = this._events.length - 1; j >= 0; j--) {
898
917
  const e = this._events[j];
@@ -941,7 +960,7 @@ var InMemoryStore = class {
941
960
  let last_non_snap_id = -1;
942
961
  const committed = msgs.map(({ name, data, pii }) => {
943
962
  const c = {
944
- id: this._events.length,
963
+ id: this._next_id++,
945
964
  stream,
946
965
  version,
947
966
  created: /* @__PURE__ */ new Date(),
@@ -1282,7 +1301,7 @@ var InMemoryStore = class {
1282
1301
  count++;
1283
1302
  if (count >= limit) break;
1284
1303
  }
1285
- return { maxEventId: this._events.length - 1, count };
1304
+ return { maxEventId: this._events.at(-1)?.id ?? -1, count };
1286
1305
  }
1287
1306
  /**
1288
1307
  * Per-stream aggregated stats — see {@link Store.query_stats}.
@@ -1363,13 +1382,41 @@ var InMemoryStore = class {
1363
1382
  }
1364
1383
  /**
1365
1384
  * Atomically truncates streams and seeds each with a snapshot or tombstone.
1366
- * @param targets - Streams to truncate with optional snapshot state and meta.
1385
+ * Windowed targets (`before` set) prune the prefix below the closest safe
1386
+ * `__snapshot__` instead — no seed, subscriptions untouched, no-op when no
1387
+ * snapshot qualifies.
1388
+ * @param targets - Streams to truncate with optional snapshot state and meta,
1389
+ * or a `before`/`max_id` boundary for a windowed prefix delete.
1367
1390
  * @returns Map keyed by stream name, each entry with `deleted` count and `committed` event.
1368
1391
  */
1369
1392
  async truncate(targets) {
1370
1393
  await sleep();
1394
+ const result = /* @__PURE__ */ new Map();
1395
+ const windowed = targets.filter((t) => t.before !== void 0);
1396
+ if (windowed.length) {
1397
+ const drop = /* @__PURE__ */ new Set();
1398
+ for (const { stream, before, max_id } of windowed) {
1399
+ let boundary;
1400
+ for (const e of this._events) {
1401
+ if (e.stream === stream && e.name === SNAP_EVENT && e.created < before && (max_id === void 0 || e.id <= max_id) && (!boundary || e.id > boundary.id))
1402
+ boundary = e;
1403
+ }
1404
+ if (!boundary) continue;
1405
+ let deleted = 0;
1406
+ for (const e of this._events) {
1407
+ if (e.stream === stream && e.id < boundary.id) {
1408
+ drop.add(e.id);
1409
+ this._pii.get(stream)?.delete(e.id);
1410
+ deleted++;
1411
+ }
1412
+ }
1413
+ result.set(stream, { deleted, committed: boundary, before });
1414
+ }
1415
+ if (drop.size) this._events = this._events.filter((e) => !drop.has(e.id));
1416
+ }
1417
+ const full = targets.filter((t) => t.before === void 0);
1371
1418
  const deleted_counts = /* @__PURE__ */ new Map();
1372
- const stream_set = new Set(targets.map((t) => t.stream));
1419
+ const stream_set = new Set(full.map((t) => t.stream));
1373
1420
  for (const e of this._events) {
1374
1421
  if (stream_set.has(e.stream)) {
1375
1422
  deleted_counts.set(e.stream, (deleted_counts.get(e.stream) ?? 0) + 1);
@@ -1380,11 +1427,11 @@ var InMemoryStore = class {
1380
1427
  this._streams.delete(stream);
1381
1428
  this._stream_versions.delete(stream);
1382
1429
  this._max_event_id_by_stream.delete(stream);
1430
+ this._pii.delete(stream);
1383
1431
  }
1384
- const result = /* @__PURE__ */ new Map();
1385
- for (const { stream, snapshot, meta } of targets) {
1432
+ for (const { stream, snapshot, meta } of full) {
1386
1433
  const event = {
1387
- id: this._events.length,
1434
+ id: this._next_id++,
1388
1435
  stream,
1389
1436
  version: 0,
1390
1437
  created: /* @__PURE__ */ new Date(),
@@ -1416,25 +1463,27 @@ var InMemoryStore = class {
1416
1463
  * throw inside the driver restores the snapshot, leaving the store
1417
1464
  * byte-for-byte unchanged from the operator's perspective.
1418
1465
  *
1419
- * `id`s are reassigned `0..N-1` as events arrive (matching the
1420
- * adapter's commit-id convention — InMemory uses `_events.length`).
1421
- * `created` is preserved verbatim from the source.
1466
+ * `id`s are reassigned `0..N-1` as events arrive (dense — the
1467
+ * monotonic id counter restarts at 0 for the rebuild). `created` is
1468
+ * preserved verbatim from the source.
1422
1469
  */
1423
1470
  async restore(driver) {
1424
1471
  await sleep();
1425
1472
  const prev_events = this._events;
1473
+ const prev_next_id = this._next_id;
1426
1474
  const prev_streams = this._streams;
1427
1475
  const prev_stream_versions = this._stream_versions;
1428
1476
  const prev_max_event_id_by_stream = this._max_event_id_by_stream;
1429
1477
  const prev_max_non_snap_event_id = this._max_non_snap_event_id;
1430
1478
  this._events = [];
1479
+ this._next_id = 0;
1431
1480
  this._streams = /* @__PURE__ */ new Map();
1432
1481
  this._stream_versions = /* @__PURE__ */ new Map();
1433
1482
  this._max_event_id_by_stream = /* @__PURE__ */ new Map();
1434
1483
  this._max_non_snap_event_id = -1;
1435
1484
  try {
1436
1485
  await driver(async (event) => {
1437
- const id = this._events.length;
1486
+ const id = this._next_id++;
1438
1487
  const committed = { ...event, id };
1439
1488
  this._events.push(committed);
1440
1489
  this._stream_versions.set(event.stream, event.version);
@@ -1446,6 +1495,7 @@ var InMemoryStore = class {
1446
1495
  });
1447
1496
  } catch (err) {
1448
1497
  this._events = prev_events;
1498
+ this._next_id = prev_next_id;
1449
1499
  this._streams = prev_streams;
1450
1500
  this._stream_versions = prev_stream_versions;
1451
1501
  this._max_event_id_by_stream = prev_max_event_id_by_stream;
@@ -2009,12 +2059,22 @@ function in_autoclose_window(window, now) {
2009
2059
 
2010
2060
  // src/internal/autoclose-policy.ts
2011
2061
  var import_zod6 = require("zod");
2012
- var MIN_AFTER_MS = 6e4;
2013
- var AfterSchema = import_zod6.z.object({
2014
- days: import_zod6.z.number({ message: "autocloses: after.days must be a number" }).positive("autocloses: after.days must be > 0")
2015
- }).refine((o) => o.days * 864e5 >= MIN_AFTER_MS, {
2016
- message: `autocloses: after resolves to < ${MIN_AFTER_MS}ms (one minute), which is too short to be a meaningful retention window`
2062
+ var DAY_MS = 864e5;
2063
+ var MIN_AFTER_DAYS = 1 / 1440;
2064
+ var MIN_KEEP_DAYS = 1;
2065
+ function days_after(date, days) {
2066
+ return new Date(date.getTime() + days * DAY_MS);
2067
+ }
2068
+ function days_before_now(days) {
2069
+ return new Date(Date.now() - days * DAY_MS);
2070
+ }
2071
+ var window_schema = (field, min_days, floor) => import_zod6.z.object({
2072
+ days: import_zod6.z.number({ message: `autocloses: ${field}.days must be a number` }).positive(`autocloses: ${field}.days must be > 0`)
2073
+ }).refine((o) => o.days >= min_days, {
2074
+ message: `autocloses: ${field} resolves to < ${floor}, which is too short to be a meaningful retention window`
2017
2075
  });
2076
+ var AfterSchema = window_schema("after", MIN_AFTER_DAYS, "one minute");
2077
+ var KeepSchema = window_schema("keep", MIN_KEEP_DAYS, "one day");
2018
2078
  var IsSchema = import_zod6.z.union([
2019
2079
  import_zod6.z.string().min(1, "autocloses: is must be a non-empty event name"),
2020
2080
  import_zod6.z.array(
@@ -2036,16 +2096,16 @@ var AutoclosePolicySchema = import_zod6.z.object({
2036
2096
  after: AfterSchema.optional(),
2037
2097
  is: IsSchema.optional(),
2038
2098
  reaches: ReachesSchema.optional(),
2039
- or: OrBlockSchema.optional()
2099
+ or: OrBlockSchema.optional(),
2100
+ keep: KeepSchema.optional()
2040
2101
  }).strict().refine(
2041
- (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0,
2102
+ (o) => o.after !== void 0 || o.is !== void 0 || o.reaches !== void 0 || o.or !== void 0 || o.keep !== void 0,
2042
2103
  {
2043
- message: "autocloses: at least one of after / is / reaches / or must be specified \u2014 empty `{}` is a misconfiguration"
2104
+ message: "autocloses: at least one of after / is / reaches / or / keep must be specified \u2014 empty `{}` is a misconfiguration"
2044
2105
  }
2045
2106
  );
2046
2107
  function compile_after(after) {
2047
- const after_ms = after.days * 864e5;
2048
- return (_stream, head) => Date.now() - head.created.getTime() >= after_ms;
2108
+ return (_stream, head) => days_after(head.created, after.days) <= /* @__PURE__ */ new Date();
2049
2109
  }
2050
2110
  function compile_is(is) {
2051
2111
  const set = new Set(typeof is === "string" ? [is] : is);
@@ -2054,13 +2114,17 @@ function compile_is(is) {
2054
2114
  function compile_reaches(reaches) {
2055
2115
  return (_stream, _head, count) => count >= reaches;
2056
2116
  }
2057
- function policy_min_after_ms(options) {
2117
+ function policy_min_after_days(options) {
2058
2118
  const parsed = AutoclosePolicySchema.parse(options);
2059
2119
  const windows = [];
2060
- if (parsed.after) windows.push(parsed.after.days * 864e5);
2061
- if (parsed.or?.after) windows.push(parsed.or.after.days * 864e5);
2120
+ if (parsed.after) windows.push(parsed.after.days);
2121
+ if (parsed.or?.after) windows.push(parsed.or.after.days);
2062
2122
  return windows.length ? Math.min(...windows) : void 0;
2063
2123
  }
2124
+ function policy_keep_days(options) {
2125
+ const parsed = AutoclosePolicySchema.parse(options);
2126
+ return parsed.keep?.days;
2127
+ }
2064
2128
  function compile_autoclose_policy(options) {
2065
2129
  const parsed = AutoclosePolicySchema.parse(options);
2066
2130
  const and_preds = [];
@@ -2103,12 +2167,20 @@ var CloseSignal = class extends Error {
2103
2167
  * event id when omitted.
2104
2168
  */
2105
2169
  at;
2170
+ /**
2171
+ * Windowed close (#1011): prune events older than this cutoff behind
2172
+ * the closest safe snapshot instead of retiring the stream. Thrown by
2173
+ * the autoclose reaction of a `.autocloses({ keep })` state; flows
2174
+ * into {@link CloseTarget.before}. Omitted → a full close.
2175
+ */
2176
+ before;
2106
2177
  constructor(opts) {
2107
2178
  super("reaction requested close");
2108
2179
  this.name = "CloseSignal";
2109
2180
  this.stream = opts?.stream;
2110
2181
  this.archive = opts?.archive;
2111
2182
  this.at = opts?.at;
2183
+ this.before = opts?.before;
2112
2184
  }
2113
2185
  };
2114
2186
 
@@ -2132,7 +2204,8 @@ function synthesize_autoclose_reactions(registry, states, config2) {
2132
2204
  for (const st of states.values()) {
2133
2205
  const predicate = st.autoclose;
2134
2206
  if (!predicate) continue;
2135
- const after_ms = st.autoclose_after_ms;
2207
+ const after_days = st.autoclose_after_days;
2208
+ const keep_days = st.autoclose_keep_days;
2136
2209
  const archiver = st.archive;
2137
2210
  const reaction = {
2138
2211
  // Run on a SYNTHETIC stream — `source` is the aggregate, `target` is a
@@ -2156,7 +2229,8 @@ function synthesize_autoclose_reactions(registry, states, config2) {
2156
2229
  });
2157
2230
  const stats = await store2().query_stats([aggregate], {
2158
2231
  count: true,
2159
- exclude: [TOMBSTONE_EVENT]
2232
+ tail: keep_days !== void 0 ? true : void 0,
2233
+ exclude: keep_days !== void 0 ? [TOMBSTONE_EVENT, SNAP_EVENT] : [TOMBSTONE_EVENT]
2160
2234
  });
2161
2235
  const entry = stats.get(aggregate);
2162
2236
  if (!entry) return;
@@ -2170,9 +2244,24 @@ function synthesize_autoclose_reactions(registry, states, config2) {
2170
2244
  // aggregate) sees it caught up instead of blocking its own close.
2171
2245
  at: head.id
2172
2246
  });
2173
- if (after_ms !== void 0)
2247
+ if (keep_days !== void 0) {
2248
+ const cutoff = days_before_now(keep_days);
2249
+ if (entry.tail.created < cutoff)
2250
+ throw new CloseSignal({
2251
+ stream: aggregate,
2252
+ before: cutoff,
2253
+ archive: archiver ? () => archiver(aggregate, head, cutoff) : void 0,
2254
+ at: head.id
2255
+ });
2256
+ }
2257
+ const dues = [];
2258
+ if (after_days !== void 0)
2259
+ dues.push(days_after(head.created, after_days));
2260
+ if (keep_days !== void 0)
2261
+ dues.push(days_after(entry.tail.created, keep_days));
2262
+ if (dues.length)
2174
2263
  throw new DeferSignal({
2175
- at: new Date(head.created.getTime() + after_ms)
2264
+ at: new Date(Math.min(...dues.map((d) => d.getTime())))
2176
2265
  });
2177
2266
  }
2178
2267
  };
@@ -2559,8 +2648,20 @@ var CircuitBreaker = class {
2559
2648
  var SAFETY_PROBE_PAGE_SIZE = 1e3;
2560
2649
  async function run_close_cycle(targets, deps) {
2561
2650
  const target_map = new Map(targets.map((t) => [t.stream, t]));
2562
- const streams = [...target_map.keys()];
2651
+ for (const t of target_map.values()) {
2652
+ if (t.before !== void 0 && t.restart)
2653
+ throw new Error(
2654
+ `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`
2655
+ );
2656
+ }
2657
+ const windowed = [...target_map.values()].filter(
2658
+ (t) => t.before !== void 0
2659
+ );
2660
+ const full = [...target_map.values()].filter((t) => t.before === void 0);
2563
2661
  const skipped = [];
2662
+ const windowed_result = windowed.length ? await run_windowed_closes(windowed, deps, skipped) : /* @__PURE__ */ new Map();
2663
+ if (!full.length) return { truncated: windowed_result, skipped };
2664
+ const streams = full.map((t) => t.stream);
2564
2665
  const stream_info = await scan_stream_heads(streams);
2565
2666
  const safe = await partition_by_safety(
2566
2667
  stream_info,
@@ -2568,7 +2669,7 @@ async function run_close_cycle(targets, deps) {
2568
2669
  skipped,
2569
2670
  deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
2570
2671
  );
2571
- if (!safe.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
2672
+ if (!safe.length) return { truncated: windowed_result, skipped };
2572
2673
  const { guarded, guard_events } = await guard_with_tombstones(
2573
2674
  safe,
2574
2675
  stream_info,
@@ -2576,7 +2677,7 @@ async function run_close_cycle(targets, deps) {
2576
2677
  deps.tombstone,
2577
2678
  skipped
2578
2679
  );
2579
- if (!guarded.length) return { truncated: /* @__PURE__ */ new Map(), skipped };
2680
+ if (!guarded.length) return { truncated: windowed_result, skipped };
2580
2681
  const seed_states = await load_restart_seeds(
2581
2682
  guarded,
2582
2683
  target_map,
@@ -2592,8 +2693,62 @@ async function run_close_cycle(targets, deps) {
2592
2693
  guard_events,
2593
2694
  deps.correlation
2594
2695
  );
2696
+ for (const [stream, entry] of windowed_result) truncated.set(stream, entry);
2595
2697
  return { truncated, skipped };
2596
2698
  }
2699
+ async function run_windowed_closes(windowed, deps, skipped) {
2700
+ const min_at = deps.reactive_events_size > 0 ? await probe_min_watermarks(
2701
+ windowed.map((t) => t.stream),
2702
+ deps.probe_page_size ?? SAFETY_PROBE_PAGE_SIZE
2703
+ ) : /* @__PURE__ */ new Map();
2704
+ for (const t of windowed) {
2705
+ if (t.archive) await t.archive();
2706
+ }
2707
+ const truncated = await store2().truncate(
2708
+ windowed.map((t) => ({
2709
+ stream: t.stream,
2710
+ before: t.before,
2711
+ max_id: min_at.get(t.stream)
2712
+ }))
2713
+ );
2714
+ for (const t of windowed) {
2715
+ if (!truncated.has(t.stream)) skipped.push(t.stream);
2716
+ }
2717
+ return truncated;
2718
+ }
2719
+ async function probe_min_watermarks(streams, page_size) {
2720
+ const min_at = /* @__PURE__ */ new Map();
2721
+ const source_regex = /* @__PURE__ */ new Map();
2722
+ const get_regex = (source) => {
2723
+ let re = source_regex.get(source);
2724
+ if (!re) {
2725
+ re = new RegExp(source);
2726
+ source_regex.set(source, re);
2727
+ }
2728
+ return re;
2729
+ };
2730
+ let after;
2731
+ for (; ; ) {
2732
+ let last;
2733
+ const { count } = await store2().query_streams(
2734
+ (position) => {
2735
+ last = position.stream;
2736
+ const source_re = position.source ? get_regex(position.source) : void 0;
2737
+ for (const stream of streams) {
2738
+ if (!source_re || source_re.test(stream)) {
2739
+ const prev = min_at.get(stream);
2740
+ if (prev === void 0 || position.at < prev)
2741
+ min_at.set(stream, position.at);
2742
+ }
2743
+ }
2744
+ },
2745
+ { after, limit: page_size, source_matches: streams }
2746
+ );
2747
+ if (count < page_size) break;
2748
+ after = last;
2749
+ }
2750
+ return min_at;
2751
+ }
2597
2752
  async function scan_stream_heads(streams) {
2598
2753
  const stats = await store2().query_stats(streams, {
2599
2754
  exclude: [SNAP_EVENT]
@@ -3794,8 +3949,13 @@ function build_handle(deps) {
3794
3949
  acked_at: error.at ?? event.id,
3795
3950
  // Close the signalled stream (the autoclose reaction's aggregate,
3796
3951
  // which differs from its synthetic lease stream); a self-closing
3797
- // user reaction omits it and closes its own lease stream.
3798
- close: { stream: error.stream ?? stream, archive: error.archive }
3952
+ // user reaction omits it and closes its own lease stream. A
3953
+ // carried `before` makes it a windowed close (prune, not retire).
3954
+ close: {
3955
+ stream: error.stream ?? stream,
3956
+ archive: error.archive,
3957
+ before: error.before
3958
+ }
3799
3959
  };
3800
3960
  return finalize(
3801
3961
  lease,
@@ -5098,7 +5258,19 @@ var Act = class {
5098
5258
  * 7. **Cache** — invalidate (tombstoned) or warm (restarted)
5099
5259
  * 8. **Emit "closed"** — lifecycle event with results
5100
5260
  *
5101
- * @param targets - Per-stream close options (stream, restart?, archive?)
5261
+ * Targets carrying `before` take the **windowed** branch instead —
5262
+ * close the books on a rolling window: probe the min consumer
5263
+ * watermark (so the boundary never rises past a lagging reaction),
5264
+ * run the archive callback against the cutoff, then prune the prefix
5265
+ * below the closest safe `__snapshot__`. No tombstone guard (the
5266
+ * pre-cutoff prefix is immutable), no seed, cache untouched — the
5267
+ * stream stays live and keeps accepting actions. Requires the state
5268
+ * to snapshot via `.snap(...)`; no qualifying snapshot ⇒ the stream
5269
+ * lands in `skipped` (retry after the next snapshot). Windowed
5270
+ * entries in the result echo `before` and carry the surviving
5271
+ * boundary snapshot as `committed`.
5272
+ *
5273
+ * @param targets - Per-stream close options (stream, restart?, archive?, before?)
5102
5274
  * @returns `{ truncated: TruncateResult, skipped: string[] }`
5103
5275
  *
5104
5276
  * @example Archive and close
@@ -5116,6 +5288,19 @@ var Act = class {
5116
5288
  * { stream: "counter-2" }, // tombstoned
5117
5289
  * ]);
5118
5290
  * ```
5291
+ *
5292
+ * @example Windowed close — keep the last 180 days of real events
5293
+ * ```typescript
5294
+ * const cutoff = new Date();
5295
+ * cutoff.setDate(cutoff.getDate() - 180);
5296
+ * await app.close([
5297
+ * {
5298
+ * stream: "ledger-acme",
5299
+ * before: cutoff,
5300
+ * archive: async () => { await archiveToS3("ledger-acme", cutoff); },
5301
+ * },
5302
+ * ]);
5303
+ * ```
5119
5304
  */
5120
5305
  async close(targets) {
5121
5306
  if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
@@ -5648,10 +5833,16 @@ function action_builder(state2) {
5648
5833
  ".autocloses(...) requires a policy object; got " + typeof policy
5649
5834
  );
5650
5835
  }
5836
+ if (policy.keep && !internal.snap) {
5837
+ throw new Error(
5838
+ ".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."
5839
+ );
5840
+ }
5651
5841
  internal.autoclose = compile_autoclose_policy(
5652
5842
  policy
5653
5843
  );
5654
- internal.autoclose_after_ms = policy_min_after_ms(policy);
5844
+ internal.autoclose_after_days = policy_min_after_days(policy);
5845
+ internal.autoclose_keep_days = policy_keep_days(policy);
5655
5846
  return builder;
5656
5847
  },
5657
5848
  archives(archive) {