@rotorsoft/act 0.27.0 → 0.29.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.
package/dist/index.js CHANGED
@@ -140,7 +140,8 @@ var InMemoryCache = class {
140
140
  var Errors = {
141
141
  ValidationError: "ERR_VALIDATION",
142
142
  InvariantError: "ERR_INVARIANT",
143
- ConcurrencyError: "ERR_CONCURRENCY"
143
+ ConcurrencyError: "ERR_CONCURRENCY",
144
+ StreamClosedError: "ERR_STREAM_CLOSED"
144
145
  };
145
146
  var ValidationError = class extends Error {
146
147
  constructor(target, payload, details) {
@@ -162,6 +163,13 @@ var InvariantError = class extends Error {
162
163
  this.name = Errors.InvariantError;
163
164
  }
164
165
  };
166
+ var StreamClosedError = class extends Error {
167
+ constructor(stream) {
168
+ super(`Stream "${stream}" is closed (tombstoned)`);
169
+ this.stream = stream;
170
+ this.name = Errors.StreamClosedError;
171
+ }
172
+ };
165
173
  var ConcurrencyError = class extends Error {
166
174
  constructor(stream, lastVersion, events, expectedVersion) {
167
175
  super(
@@ -595,6 +603,41 @@ var InMemoryStore = class {
595
603
  }
596
604
  return count;
597
605
  }
606
+ /**
607
+ * Atomically truncates streams and seeds each with a snapshot or tombstone.
608
+ * @param targets - Streams to truncate with optional snapshot state and meta.
609
+ * @returns Map keyed by stream name, each entry with `deleted` count and `committed` event.
610
+ */
611
+ async truncate(targets) {
612
+ await sleep();
613
+ const deletedCounts = /* @__PURE__ */ new Map();
614
+ const streamSet = new Set(targets.map((t) => t.stream));
615
+ for (const e of this._events) {
616
+ if (streamSet.has(e.stream)) {
617
+ deletedCounts.set(e.stream, (deletedCounts.get(e.stream) ?? 0) + 1);
618
+ }
619
+ }
620
+ this._events = this._events.filter((e) => !streamSet.has(e.stream));
621
+ const result = /* @__PURE__ */ new Map();
622
+ for (const { stream, snapshot, meta } of targets) {
623
+ this._streams.delete(stream);
624
+ const event = {
625
+ id: this._events.length,
626
+ stream,
627
+ version: 0,
628
+ created: /* @__PURE__ */ new Date(),
629
+ name: snapshot !== void 0 ? SNAP_EVENT : TOMBSTONE_EVENT,
630
+ data: snapshot ?? {},
631
+ meta: meta ?? { correlation: "", causation: {} }
632
+ };
633
+ this._events.push(event);
634
+ result.set(stream, {
635
+ deleted: deletedCounts.get(stream) ?? 0,
636
+ committed: event
637
+ });
638
+ }
639
+ return result;
640
+ }
598
641
  };
599
642
 
600
643
  // src/ports.ts
@@ -641,6 +684,7 @@ function dispose(disposer) {
641
684
  return disposeAndExit;
642
685
  }
643
686
  var SNAP_EVENT = "__snapshot__";
687
+ var TOMBSTONE_EVENT = "__tombstone__";
644
688
  function build_tracer(logLevel2) {
645
689
  if (logLevel2 === "trace") {
646
690
  const logger4 = log();
@@ -744,8 +788,9 @@ async function snap(snapshot) {
744
788
  logger2.error(error);
745
789
  }
746
790
  }
747
- async function load(me, stream, callback) {
748
- const cached = await cache().get(stream);
791
+ async function load(me, stream, callback, asOf) {
792
+ const timeTravel = asOf && (asOf.before !== void 0 || asOf.created_before !== void 0 || asOf.created_after !== void 0 || asOf.limit !== void 0);
793
+ const cached = timeTravel ? void 0 : await cache().get(stream);
749
794
  let state2 = cached?.state ?? (me.init ? me.init() : {});
750
795
  let patches = cached?.patches ?? 0;
751
796
  let snaps = cached?.snaps ?? 0;
@@ -763,11 +808,15 @@ async function load(me, stream, callback) {
763
808
  }
764
809
  callback && callback({ event, state: state2, patches, snaps });
765
810
  },
766
- { stream, with_snaps: !cached, after: cached?.event_id, stream_exact: true }
811
+ {
812
+ stream,
813
+ stream_exact: true,
814
+ ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
815
+ }
767
816
  );
768
817
  logger2.trace(
769
818
  state2,
770
- `\u{1F7E2} load ${stream}${cached && count === 0 ? " (cached)" : ""}`
819
+ `\u{1F7E2} load ${stream}${cached && count === 0 ? " (cached)" : ""}${timeTravel ? " (as-of)" : ""}`
771
820
  );
772
821
  return { event, state: state2, patches, snaps };
773
822
  }
@@ -776,6 +825,8 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
776
825
  if (!stream) throw new Error("Missing target stream");
777
826
  payload = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
778
827
  const snapshot = await load(me, stream);
828
+ if (snapshot.event?.name === TOMBSTONE_EVENT)
829
+ throw new StreamClosedError(stream);
779
830
  const expected = expectedVersion ?? snapshot.event?.version;
780
831
  logger2.trace(
781
832
  payload,
@@ -1027,7 +1078,7 @@ var Act = class {
1027
1078
  this.emit("committed", snapshots);
1028
1079
  return snapshots;
1029
1080
  }
1030
- async load(stateOrName, stream, callback) {
1081
+ async load(stateOrName, stream, callback, asOf) {
1031
1082
  let merged;
1032
1083
  if (typeof stateOrName === "string") {
1033
1084
  const found = this._states.get(stateOrName);
@@ -1036,7 +1087,7 @@ var Act = class {
1036
1087
  } else {
1037
1088
  merged = this._states.get(stateOrName.name) || stateOrName;
1038
1089
  }
1039
- return await load(merged, stream, callback);
1090
+ return await load(merged, stream, callback, asOf);
1040
1091
  }
1041
1092
  /**
1042
1093
  * Queries the event store for events matching a filter.
@@ -1554,6 +1605,170 @@ var Act = class {
1554
1605
  this._settle_timer = void 0;
1555
1606
  }
1556
1607
  }
1608
+ /**
1609
+ * Close the books — guard, archive, truncate, and optionally restart streams.
1610
+ *
1611
+ * Safely removes historical events from the operational store:
1612
+ *
1613
+ * 1. **Correlate** — discover pending reaction targets
1614
+ * 2. **Safety check** — skip streams with pending reactions (skipped when no reactive events)
1615
+ * 3. **Guard** — commit `__tombstone__` with `expectedVersion` to block concurrent writes
1616
+ * 4. **Load state** — for streams in `snapshots`, load final state while guarded (no races)
1617
+ * 5. **Archive** — user callback per stream (abort-all on failure, streams are guarded)
1618
+ * 6. **Truncate + seed** — atomic: delete all events, insert `__snapshot__` or `__tombstone__`
1619
+ * 7. **Cache** — invalidate (tombstoned) or warm (restarted)
1620
+ * 8. **Emit "closed"** — lifecycle event with results
1621
+ *
1622
+ * @param targets - Per-stream close options (stream, restart?, archive?)
1623
+ * @returns `{ truncated: TruncateResult, skipped: string[] }`
1624
+ *
1625
+ * @example Archive and close
1626
+ * ```typescript
1627
+ * await app.close([
1628
+ * { stream: "order-123", archive: async () => { await archiveToS3("order-123"); } },
1629
+ * { stream: "order-456" },
1630
+ * ]);
1631
+ * ```
1632
+ *
1633
+ * @example Close with restart (state loaded automatically after guard)
1634
+ * ```typescript
1635
+ * await app.close([
1636
+ * { stream: "counter-1", restart: true },
1637
+ * { stream: "counter-2" }, // tombstoned
1638
+ * ]);
1639
+ * ```
1640
+ */
1641
+ async close(targets) {
1642
+ if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
1643
+ const targetMap = new Map(targets.map((t) => [t.stream, t]));
1644
+ const streams = [...targetMap.keys()];
1645
+ await this.correlate({ limit: 1e3 });
1646
+ const streamInfo = /* @__PURE__ */ new Map();
1647
+ await Promise.all(
1648
+ streams.map(async (s) => {
1649
+ let maxId = -1;
1650
+ let version = -1;
1651
+ await store().query(
1652
+ (e) => {
1653
+ if (e.name !== TOMBSTONE_EVENT) {
1654
+ maxId = e.id;
1655
+ version = e.version;
1656
+ }
1657
+ },
1658
+ { stream: s, stream_exact: true, backward: true, limit: 1 }
1659
+ );
1660
+ if (maxId >= 0) streamInfo.set(s, { maxId, version });
1661
+ })
1662
+ );
1663
+ const skipped = [];
1664
+ let safe;
1665
+ if (this._reactive_events.size === 0) {
1666
+ safe = [...streamInfo.keys()];
1667
+ } else {
1668
+ const pendingSet = /* @__PURE__ */ new Set();
1669
+ const leases = await store().claim(1e3, 1e3, randomUUID2(), 1);
1670
+ if (leases.length) await store().ack(leases);
1671
+ for (const lease of leases) {
1672
+ const sourceRe = lease.source ? RegExp(lease.source) : void 0;
1673
+ for (const [stream, info] of streamInfo) {
1674
+ if ((!sourceRe || sourceRe.test(stream)) && lease.at < info.maxId) {
1675
+ pendingSet.add(stream);
1676
+ }
1677
+ }
1678
+ }
1679
+ safe = [];
1680
+ for (const [stream] of streamInfo) {
1681
+ if (pendingSet.has(stream)) {
1682
+ skipped.push(stream);
1683
+ } else {
1684
+ safe.push(stream);
1685
+ }
1686
+ }
1687
+ }
1688
+ if (!safe.length) {
1689
+ const result2 = { truncated: /* @__PURE__ */ new Map(), skipped };
1690
+ this.emit("closed", result2);
1691
+ return result2;
1692
+ }
1693
+ const correlation = randomUUID2();
1694
+ const guarded = [];
1695
+ const guardEvents = /* @__PURE__ */ new Map();
1696
+ await Promise.all(
1697
+ safe.map(async (stream) => {
1698
+ try {
1699
+ const info = streamInfo.get(stream);
1700
+ const [committed] = await store().commit(
1701
+ stream,
1702
+ [{ name: TOMBSTONE_EVENT, data: {} }],
1703
+ { correlation, causation: {} },
1704
+ info.version
1705
+ );
1706
+ guarded.push(stream);
1707
+ guardEvents.set(stream, { id: committed.id, stream });
1708
+ } catch {
1709
+ skipped.push(stream);
1710
+ }
1711
+ })
1712
+ );
1713
+ if (!guarded.length) {
1714
+ const result2 = { truncated: /* @__PURE__ */ new Map(), skipped };
1715
+ this.emit("closed", result2);
1716
+ return result2;
1717
+ }
1718
+ const mergedState = [...this._states.values()][0];
1719
+ const seedStates = /* @__PURE__ */ new Map();
1720
+ if (mergedState) {
1721
+ await Promise.all(
1722
+ guarded.filter((s) => targetMap.get(s)?.restart).map(async (stream) => {
1723
+ const snap2 = await load(mergedState, stream);
1724
+ seedStates.set(stream, snap2.state);
1725
+ })
1726
+ );
1727
+ }
1728
+ for (const stream of guarded) {
1729
+ const archiveFn = targetMap.get(stream)?.archive;
1730
+ if (archiveFn) await archiveFn();
1731
+ }
1732
+ const truncTargets = guarded.map((stream) => {
1733
+ const snapshot = seedStates.get(stream);
1734
+ const guard = guardEvents.get(stream);
1735
+ return {
1736
+ stream,
1737
+ snapshot,
1738
+ meta: {
1739
+ correlation,
1740
+ causation: {
1741
+ event: {
1742
+ id: guard.id,
1743
+ name: TOMBSTONE_EVENT,
1744
+ stream: guard.stream
1745
+ }
1746
+ }
1747
+ }
1748
+ };
1749
+ });
1750
+ const truncated = await store().truncate(truncTargets);
1751
+ await Promise.all(
1752
+ guarded.map(async (stream) => {
1753
+ const entry = truncated.get(stream);
1754
+ const state2 = seedStates.get(stream);
1755
+ if (state2 && entry) {
1756
+ await cache().set(stream, {
1757
+ state: state2,
1758
+ version: entry.committed.version,
1759
+ event_id: entry.committed.id,
1760
+ patches: 0,
1761
+ snaps: 1
1762
+ });
1763
+ } else {
1764
+ await cache().invalidate(stream);
1765
+ }
1766
+ })
1767
+ );
1768
+ const result = { truncated, skipped };
1769
+ this.emit("closed", result);
1770
+ return result;
1771
+ }
1557
1772
  /**
1558
1773
  * Debounced, non-blocking correlate→drain cycle.
1559
1774
  *
@@ -2099,6 +2314,8 @@ export {
2099
2314
  PackageSchema,
2100
2315
  QuerySchema,
2101
2316
  SNAP_EVENT,
2317
+ StreamClosedError,
2318
+ TOMBSTONE_EVENT,
2102
2319
  TargetSchema,
2103
2320
  ValidationError,
2104
2321
  ZodEmpty,