@rotorsoft/act 1.19.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 (37) 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/act-builder.d.ts.map +1 -1
  7. package/dist/@types/builders/projection-builder.d.ts +41 -12
  8. package/dist/@types/builders/projection-builder.d.ts.map +1 -1
  9. package/dist/@types/builders/state-builder.d.ts +35 -9
  10. package/dist/@types/builders/state-builder.d.ts.map +1 -1
  11. package/dist/@types/internal/autoclose-policy.d.ts +39 -13
  12. package/dist/@types/internal/autoclose-policy.d.ts.map +1 -1
  13. package/dist/@types/internal/autoclose-reaction.d.ts.map +1 -1
  14. package/dist/@types/internal/close-cycle.d.ts +5 -0
  15. package/dist/@types/internal/close-cycle.d.ts.map +1 -1
  16. package/dist/@types/internal/close-signal.d.ts +8 -0
  17. package/dist/@types/internal/close-signal.d.ts.map +1 -1
  18. package/dist/@types/internal/event-sourcing.d.ts +1 -1
  19. package/dist/@types/internal/event-sourcing.d.ts.map +1 -1
  20. package/dist/@types/internal/index.d.ts +1 -1
  21. package/dist/@types/internal/index.d.ts.map +1 -1
  22. package/dist/@types/internal/reactions.d.ts.map +1 -1
  23. package/dist/@types/types/action.d.ts +46 -13
  24. package/dist/@types/types/action.d.ts.map +1 -1
  25. package/dist/@types/types/ports.d.ts +29 -3
  26. package/dist/@types/types/ports.d.ts.map +1 -1
  27. package/dist/{chunk-TODGOWEZ.js → chunk-KBTC3NGQ.js} +64 -14
  28. package/dist/chunk-KBTC3NGQ.js.map +1 -0
  29. package/dist/index.cjs +302 -70
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.js +240 -58
  32. package/dist/index.js.map +1 -1
  33. package/dist/test/index.cjs +63 -13
  34. package/dist/test/index.cjs.map +1 -1
  35. package/dist/test/index.js +1 -1
  36. package/package.json +2 -2
  37. 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]
@@ -3024,7 +3179,7 @@ var DEFAULT_BATCH = 500;
3024
3179
  async function snap(snapshot) {
3025
3180
  const { id, stream, name, meta, version } = snapshot.event;
3026
3181
  try {
3027
- await store2().commit(
3182
+ const [committed] = await store2().commit(
3028
3183
  stream,
3029
3184
  [{ name: SNAP_EVENT, data: snapshot.state }],
3030
3185
  {
@@ -3034,6 +3189,7 @@ async function snap(snapshot) {
3034
3189
  version
3035
3190
  // IMPORTANT! - state events are committed right after the snapshot event
3036
3191
  );
3192
+ return committed;
3037
3193
  } catch (error) {
3038
3194
  const reason = error instanceof Error ? error.message : String(error);
3039
3195
  log().warn(
@@ -3246,7 +3402,7 @@ async function action(me, action2, target, payload, options) {
3246
3402
  const snapshot = await load(me, { stream, actor: target.actor });
3247
3403
  if (snapshot.event?.name === TOMBSTONE_EVENT)
3248
3404
  throw new StreamClosedError(stream);
3249
- const expected = expectedVersion ?? snapshot.event?.version;
3405
+ const expected = expectedVersion ?? (snapshot.version >= 0 ? snapshot.version : void 0);
3250
3406
  if (me.given) {
3251
3407
  const invariants = me.given[action2] || [];
3252
3408
  invariants.forEach(({ valid: valid2, description }) => {
@@ -3328,18 +3484,22 @@ async function action(me, action2, target, payload, options) {
3328
3484
  };
3329
3485
  });
3330
3486
  const last = snapshots.at(-1);
3331
- const snapped = me.snap?.(last);
3487
+ const contiguous = committed[0].version === snapshot.version + 1;
3488
+ const snapped = contiguous && me.snap?.(last);
3489
+ const snap_event = snapped ? await snap(last) : void 0;
3332
3490
  if (!me.pii_aware) {
3333
- cache2().set(stream, {
3334
- stream,
3335
- state: last.state,
3336
- version: last.event.version,
3337
- event_id: last.event.id,
3338
- patches: snapped ? 0 : last.patches,
3339
- snaps: snapped ? last.snaps + 1 : last.snaps
3340
- }).catch((err) => log().error(err));
3491
+ if (contiguous)
3492
+ cache2().set(stream, {
3493
+ stream,
3494
+ state: last.state,
3495
+ version: snap_event?.version ?? last.event.version,
3496
+ event_id: snap_event?.id ?? last.event.id,
3497
+ patches: snap_event ? 0 : last.patches,
3498
+ snaps: snap_event ? last.snaps + 1 : last.snaps
3499
+ }).catch((err) => log().error(err));
3500
+ else
3501
+ cache2().invalidate(stream).catch((err) => log().error(err));
3341
3502
  }
3342
- if (snapped) void snap(last);
3343
3503
  return snapshots;
3344
3504
  } catch (error) {
3345
3505
  if (!(error instanceof ConcurrencyError)) throw error;
@@ -3789,8 +3949,13 @@ function build_handle(deps) {
3789
3949
  acked_at: error.at ?? event.id,
3790
3950
  // Close the signalled stream (the autoclose reaction's aggregate,
3791
3951
  // which differs from its synthetic lease stream); a self-closing
3792
- // user reaction omits it and closes its own lease stream.
3793
- 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
+ }
3794
3959
  };
3795
3960
  return finalize(
3796
3961
  lease,
@@ -5093,7 +5258,19 @@ var Act = class {
5093
5258
  * 7. **Cache** — invalidate (tombstoned) or warm (restarted)
5094
5259
  * 8. **Emit "closed"** — lifecycle event with results
5095
5260
  *
5096
- * @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?)
5097
5274
  * @returns `{ truncated: TruncateResult, skipped: string[] }`
5098
5275
  *
5099
5276
  * @example Archive and close
@@ -5111,6 +5288,19 @@ var Act = class {
5111
5288
  * { stream: "counter-2" }, // tombstoned
5112
5289
  * ]);
5113
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
+ * ```
5114
5304
  */
5115
5305
  async close(targets) {
5116
5306
  if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
@@ -5208,6 +5398,7 @@ function act() {
5208
5398
  autoclose_archiver: (state_name) => _aa.get(state_name) ?? null
5209
5399
  };
5210
5400
  const pending_projections = [];
5401
+ const fold_projections = [];
5211
5402
  const batch_handlers = /* @__PURE__ */ new Map();
5212
5403
  const lanes = [];
5213
5404
  let _built = false;
@@ -5273,6 +5464,8 @@ function act() {
5273
5464
  withProjection: (proj) => {
5274
5465
  merge_projection(proj, registry.events);
5275
5466
  register_batch_handler(proj, batch_handlers);
5467
+ if (proj.fold)
5468
+ fold_projections.push(proj);
5276
5469
  return builder;
5277
5470
  },
5278
5471
  withActor: () => builder,
@@ -5286,6 +5479,26 @@ function act() {
5286
5479
  for (const proj of pending_projections) {
5287
5480
  merge_projection(proj, registry.events);
5288
5481
  register_batch_handler(proj, batch_handlers);
5482
+ if (proj.fold) fold_projections.push(proj);
5483
+ }
5484
+ for (const proj of fold_projections) {
5485
+ const fold = proj.fold;
5486
+ const merged = states.get(fold.name);
5487
+ if (!merged)
5488
+ throw new Error(
5489
+ `State projection "${proj.target}" folds "${fold.name}", which is not registered \u2014 add it via withState/withSlice before build()`
5490
+ );
5491
+ const missing = Object.keys(merged.events).filter(
5492
+ (event_name) => !(event_name in proj.events)
5493
+ );
5494
+ if (missing.length > 0)
5495
+ throw new Error(
5496
+ `State projection "${proj.target}" of "${fold.name}" is missing events ${missing.join(", ")} \u2014 pass every partial of the state to .of()`
5497
+ );
5498
+ batch_handlers.set(
5499
+ proj.target,
5500
+ make_fold_handler(merged, fold.flush, fold.config)
5501
+ );
5289
5502
  }
5290
5503
  finalize_deprecations();
5291
5504
  validate_lane_references(registry, lanes);
@@ -5428,39 +5641,52 @@ function _projection(target) {
5428
5641
  batchHandler: handler
5429
5642
  })
5430
5643
  }),
5431
- of: (state2, options = {}) => {
5644
+ of: (...args) => {
5432
5645
  if (Object.keys(events).length > 0)
5433
5646
  throw new Error(
5434
5647
  `Projection "${target}" mixes .of() with .on() handlers \u2014 a projection either folds a state or declares handlers, never both`
5435
5648
  );
5649
+ const is_state = (a) => !!a && typeof a.init === "function";
5650
+ const partials = args.filter(is_state);
5651
+ const options = args.find((a) => !is_state(a)) ?? {};
5652
+ if (partials.length === 0)
5653
+ throw new Error(`Projection "${target}" .of() requires a state`);
5654
+ const name = partials[0].name;
5655
+ for (const partial of partials)
5656
+ if (partial.name !== name)
5657
+ throw new Error(
5658
+ `Projection "${target}" .of() partials must share one state name \u2014 got "${name}" and "${partial.name}"`
5659
+ );
5436
5660
  const config2 = resolveFoldConfig(options);
5437
5661
  const fold_events = {};
5438
- for (const [name, schema] of Object.entries(state2.events)) {
5439
- const noop2 = {
5440
- [`${target}_fold`]: async () => {
5441
- }
5442
- }[`${target}_fold`];
5443
- fold_events[name] = {
5444
- schema,
5445
- reactions: /* @__PURE__ */ new Map([
5446
- [
5447
- `${target}_fold`,
5448
- {
5449
- handler: noop2,
5450
- resolver: { target },
5451
- options: { blockOnError: true, maxRetries: 3 }
5452
- }
5453
- ]
5454
- ])
5455
- };
5456
- }
5662
+ for (const partial of partials)
5663
+ for (const [event_name, schema] of Object.entries(partial.events)) {
5664
+ if (event_name in fold_events) continue;
5665
+ const noop2 = {
5666
+ [`${target}_fold`]: async () => {
5667
+ }
5668
+ }[`${target}_fold`];
5669
+ fold_events[event_name] = {
5670
+ schema,
5671
+ reactions: /* @__PURE__ */ new Map([
5672
+ [
5673
+ `${target}_fold`,
5674
+ {
5675
+ handler: noop2,
5676
+ resolver: { target },
5677
+ options: { blockOnError: true, maxRetries: 3 }
5678
+ }
5679
+ ]
5680
+ ])
5681
+ };
5682
+ }
5457
5683
  return {
5458
5684
  flush: (handler) => ({
5459
5685
  build: () => ({
5460
5686
  _tag: "Projection",
5461
5687
  events: fold_events,
5462
5688
  target,
5463
- batchHandler: make_fold_handler(state2, handler, config2)
5689
+ fold: { name, flush: handler, config: config2 }
5464
5690
  })
5465
5691
  })
5466
5692
  };
@@ -5607,10 +5833,16 @@ function action_builder(state2) {
5607
5833
  ".autocloses(...) requires a policy object; got " + typeof policy
5608
5834
  );
5609
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
+ }
5610
5841
  internal.autoclose = compile_autoclose_policy(
5611
5842
  policy
5612
5843
  );
5613
- 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);
5614
5846
  return builder;
5615
5847
  },
5616
5848
  archives(archive) {