@rotorsoft/act 0.32.2 → 0.32.4

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
@@ -154,7 +154,7 @@ var InMemoryCache = class {
154
154
  };
155
155
 
156
156
  // src/utils.ts
157
- import { prettifyError } from "zod";
157
+ import { ZodError, prettifyError } from "zod";
158
158
 
159
159
  // src/config.ts
160
160
  import * as fs from "fs";
@@ -179,12 +179,19 @@ var BaseSchema = PackageSchema.extend({
179
179
  });
180
180
  var { NODE_ENV, LOG_LEVEL, LOG_SINGLE_LINE, SLEEP_MS } = process.env;
181
181
  var env = NODE_ENV || "development";
182
- var logLevel = LOG_LEVEL || (NODE_ENV === "test" ? "error" : NODE_ENV === "production" ? "info" : "trace");
182
+ var logLevel = LOG_LEVEL || (NODE_ENV === "test" ? "fatal" : NODE_ENV === "production" ? "info" : "trace");
183
183
  var logSingleLine = (LOG_SINGLE_LINE || "true") === "true";
184
- var sleepMs = parseInt(NODE_ENV === "test" ? "0" : SLEEP_MS ?? "100");
184
+ var sleepMs = parseInt(NODE_ENV === "test" ? "0" : SLEEP_MS ?? "100", 10);
185
185
  var pkg = getPackage();
186
+ var _validated;
186
187
  var config = () => {
187
- return extend({ ...pkg, env, logLevel, logSingleLine, sleepMs }, BaseSchema);
188
+ if (!_validated) {
189
+ _validated = extend(
190
+ { ...pkg, env, logLevel, logSingleLine, sleepMs },
191
+ BaseSchema
192
+ );
193
+ }
194
+ return _validated;
188
195
  };
189
196
 
190
197
  // src/utils.ts
@@ -192,19 +199,15 @@ var validate = (target, payload, schema) => {
192
199
  try {
193
200
  return schema ? schema.parse(payload) : payload;
194
201
  } catch (error) {
195
- if (error instanceof Error && error.name === "ZodError") {
196
- throw new ValidationError(
197
- target,
198
- payload,
199
- prettifyError(error)
200
- );
202
+ if (error instanceof ZodError) {
203
+ throw new ValidationError(target, payload, prettifyError(error));
201
204
  }
202
205
  throw new ValidationError(target, payload, error);
203
206
  }
204
207
  };
205
208
  var extend = (source, schema, target) => {
206
209
  const value = validate("config", source, schema);
207
- return Object.assign(target || {}, value);
210
+ return { ...target, ...value };
208
211
  };
209
212
  async function sleep(ms) {
210
213
  return new Promise((resolve) => setTimeout(resolve, ms ?? config().sleepMs));
@@ -611,7 +614,7 @@ function port(injector) {
611
614
  if (!adapters.has(injector.name)) {
612
615
  const injected = injector(adapter);
613
616
  adapters.set(injector.name, injected);
614
- console.log(`[act] + ${injector.name}:${injected.constructor.name}`);
617
+ log().info(`[act] + ${injector.name}:${injected.constructor.name}`);
615
618
  }
616
619
  return adapters.get(injector.name);
617
620
  };
@@ -632,13 +635,13 @@ var cache = port(function cache2(adapter) {
632
635
  var disposers = [];
633
636
  async function disposeAndExit(code = "EXIT") {
634
637
  if (code === "ERROR" && config().env === "production") return;
635
- await Promise.all(disposers.map((disposer) => disposer()));
636
- await Promise.all(
637
- [...adapters.values()].reverse().map(async (adapter) => {
638
- await adapter.dispose();
639
- console.log(`[act] - ${adapter.constructor.name}`);
640
- })
641
- );
638
+ for (const disposer of [...disposers].reverse()) {
639
+ await disposer();
640
+ }
641
+ for (const adapter of [...adapters.values()].reverse()) {
642
+ await adapter.dispose();
643
+ log().info(`[act] - ${adapter.constructor.name}`);
644
+ }
642
645
  adapters.clear();
643
646
  config().env !== "test" && process.exit(code === "ERROR" ? 1 : 0);
644
647
  }
@@ -650,21 +653,20 @@ var SNAP_EVENT = "__snapshot__";
650
653
  var TOMBSTONE_EVENT = "__tombstone__";
651
654
 
652
655
  // src/signals.ts
653
- var logger = log();
654
656
  process.once("SIGINT", async (arg) => {
655
- logger.info(arg, "SIGINT");
657
+ log().info(arg, "SIGINT");
656
658
  await disposeAndExit("EXIT");
657
659
  });
658
660
  process.once("SIGTERM", async (arg) => {
659
- logger.info(arg, "SIGTERM");
661
+ log().info(arg, "SIGTERM");
660
662
  await disposeAndExit("EXIT");
661
663
  });
662
664
  process.once("uncaughtException", async (arg) => {
663
- logger.error(arg, "Uncaught Exception");
665
+ log().error(arg, "Uncaught Exception");
664
666
  await disposeAndExit("ERROR");
665
667
  });
666
668
  process.once("unhandledRejection", async (arg) => {
667
- logger.error(arg, "Unhandled Rejection");
669
+ log().error(arg, "Unhandled Rejection");
668
670
  await disposeAndExit("ERROR");
669
671
  });
670
672
 
@@ -858,6 +860,10 @@ async function load(me, stream, callback, asOf) {
858
860
  } else if (me.patch[e.name]) {
859
861
  state2 = patch(state2, me.patch[e.name](event, state2));
860
862
  patches++;
863
+ } else if (e.name !== TOMBSTONE_EVENT) {
864
+ log().warn(
865
+ `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
866
+ );
861
867
  }
862
868
  callback && callback({ event, state: state2, patches, snaps });
863
869
  },
@@ -952,44 +958,69 @@ async function action(me, action2, target, payload, reactingTo, skipValidation =
952
958
  }
953
959
 
954
960
  // src/internal/tracing.ts
961
+ var PRETTY = config().env !== "production";
962
+ var C_BLUE = "\x1B[38;5;39m";
963
+ var C_ORANGE = "\x1B[38;5;208m";
964
+ var C_GREEN = "\x1B[38;5;42m";
965
+ var C_MAGENTA = "\x1B[38;5;165m";
966
+ var C_DRAIN = "\x1B[38;5;244m";
967
+ var C_RESET = "\x1B[0m";
968
+ var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
969
+ var drain_caption = (caption) => {
970
+ const tag = `>> ${caption}`;
971
+ return PRETTY ? `${C_DRAIN}${tag}${C_RESET}` : tag;
972
+ };
955
973
  var traced = (inner, exit, entry) => (async (...args) => {
956
974
  entry?.(...args);
957
975
  const result = await inner(...args);
958
976
  exit?.(result, ...args);
959
977
  return result;
960
978
  });
961
- function buildEs(logger2) {
962
- if (logger2.level !== "trace") {
979
+ function buildEs(logger) {
980
+ if (logger.level !== "trace") {
963
981
  return { snap, load, action };
964
982
  }
965
983
  return {
966
984
  snap: traced(snap, void 0, (snapshot) => {
967
- logger2.trace(
968
- `\u{1F7E0} snap ${snapshot.event.stream}@${snapshot.event.version}`
985
+ logger.trace(
986
+ es_caption(
987
+ "snap",
988
+ C_MAGENTA,
989
+ `${snapshot.event.stream}@${snapshot.event.version}`
990
+ )
969
991
  );
970
992
  }),
971
993
  load: traced(load, void 0, (_me, stream, _cb, asOf) => {
972
- logger2.trace(`\u{1F7E2} load ${stream}${asOf ? " (as-of)" : ""}`);
994
+ logger.trace(
995
+ es_caption("load", C_GREEN, `${stream}${asOf ? " (as-of)" : ""}`)
996
+ );
973
997
  }),
974
998
  action: traced(
975
999
  action,
976
1000
  (snapshots, _me, _action, target) => {
977
1001
  const committed = snapshots.filter((s) => s.event);
978
1002
  if (committed.length) {
979
- logger2.trace(
1003
+ logger.trace(
980
1004
  committed.map((s) => s.event.data),
981
- `\u{1F534} commit ${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
1005
+ es_caption(
1006
+ "committed",
1007
+ C_ORANGE,
1008
+ `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
1009
+ )
982
1010
  );
983
1011
  }
984
1012
  },
985
1013
  (_me, action2, target, payload) => {
986
- logger2.trace(payload, `\u{1F535} ${target.stream}.${action2}`);
1014
+ logger.trace(
1015
+ payload,
1016
+ es_caption("action", C_BLUE, `${target.stream}.${action2}`)
1017
+ );
987
1018
  }
988
1019
  )
989
1020
  };
990
1021
  }
991
- function buildDrain(logger2) {
992
- if (logger2.level !== "trace") {
1022
+ function buildDrain(logger) {
1023
+ if (logger.level !== "trace") {
993
1024
  return {
994
1025
  claim,
995
1026
  fetch,
@@ -1004,7 +1035,7 @@ function buildDrain(logger2) {
1004
1035
  const data = Object.fromEntries(
1005
1036
  leased.map(({ stream, at, retry }) => [stream, { at, retry }])
1006
1037
  );
1007
- logger2.trace(data, ">> lease");
1038
+ logger.trace(data, drain_caption("claimed"));
1008
1039
  }
1009
1040
  }),
1010
1041
  fetch: traced(fetch, (fetched) => {
@@ -1017,14 +1048,14 @@ function buildDrain(logger2) {
1017
1048
  return [key, value];
1018
1049
  })
1019
1050
  );
1020
- logger2.trace(data, ">> fetch");
1051
+ logger.trace(data, drain_caption("fetched"));
1021
1052
  }),
1022
1053
  ack: traced(ack, (acked) => {
1023
1054
  if (acked.length) {
1024
1055
  const data = Object.fromEntries(
1025
1056
  acked.map(({ stream, at, retry }) => [stream, { at, retry }])
1026
1057
  );
1027
- logger2.trace(data, ">> ack");
1058
+ logger.trace(data, drain_caption("acked"));
1028
1059
  }
1029
1060
  }),
1030
1061
  block: traced(block, (blocked) => {
@@ -1035,13 +1066,13 @@ function buildDrain(logger2) {
1035
1066
  { at, retry, error }
1036
1067
  ])
1037
1068
  );
1038
- logger2.trace(data, ">> block");
1069
+ logger.trace(data, drain_caption("blocked"));
1039
1070
  }
1040
1071
  }),
1041
1072
  subscribe: traced(subscribe, (result, streams) => {
1042
1073
  if (result.subscribed) {
1043
1074
  const data = streams.map(({ stream }) => stream).join(" ");
1044
- logger2.trace(`>> correlate ${data}`);
1075
+ logger.trace(`${drain_caption("correlated")} ${data}`);
1045
1076
  }
1046
1077
  })
1047
1078
  };
@@ -1055,7 +1086,7 @@ var Act = class {
1055
1086
  this._batch_handlers = batchHandlers;
1056
1087
  this._es = buildEs(this._logger);
1057
1088
  this._cd = buildDrain(this._logger);
1058
- const statics = [];
1089
+ const statics = /* @__PURE__ */ new Map();
1059
1090
  for (const [name, register] of Object.entries(this.registry.events)) {
1060
1091
  if (register.reactions.size > 0) {
1061
1092
  this._reactive_events.add(name);
@@ -1064,14 +1095,18 @@ var Act = class {
1064
1095
  if (typeof reaction.resolver === "function") {
1065
1096
  this._has_dynamic_resolvers = true;
1066
1097
  } else {
1067
- statics.push({
1068
- stream: reaction.resolver.target,
1069
- source: reaction.resolver.source
1070
- });
1098
+ const { target, source } = reaction.resolver;
1099
+ const key = `${target}|${source ?? ""}`;
1100
+ if (!statics.has(key)) statics.set(key, { stream: target, source });
1071
1101
  }
1072
1102
  }
1073
1103
  }
1074
- this._static_targets = statics;
1104
+ this._static_targets = [...statics.values()];
1105
+ for (const merged of this._states.values()) {
1106
+ for (const eventName of Object.keys(merged.events)) {
1107
+ this._event_to_state.set(eventName, merged);
1108
+ }
1109
+ }
1075
1110
  dispose(() => {
1076
1111
  this._emitter.removeAllListeners();
1077
1112
  this.stop_correlations();
@@ -1118,8 +1153,21 @@ var Act = class {
1118
1153
  _es;
1119
1154
  /** Correlate/drain pipeline ops, optionally wrapped with trace decorators */
1120
1155
  _cd;
1156
+ /**
1157
+ * Event-name → owning state, computed at build time. The duplicate-event
1158
+ * guard in merge.ts ensures one event name maps to at most one state, so
1159
+ * this lookup is unambiguous. Used by `close()` to pick the right reducer
1160
+ * set when seeding a `restart` snapshot in multi-state apps.
1161
+ */
1162
+ _event_to_state = /* @__PURE__ */ new Map();
1121
1163
  /** Logger resolved at construction time (after user port configuration) */
1122
1164
  _logger = log();
1165
+ /** Pre-bound IAct methods reused across drain cycles. Only `do` varies per
1166
+ * payload (it captures the triggering event for reactingTo auto-inject). */
1167
+ _bound_do = this.do.bind(this);
1168
+ _bound_load = this.load.bind(this);
1169
+ _bound_query = this.query.bind(this);
1170
+ _bound_query_array = this.query_array.bind(this);
1123
1171
  /**
1124
1172
  * Executes an action on a state instance, committing resulting events.
1125
1173
  *
@@ -1342,12 +1390,12 @@ var Act = class {
1342
1390
  const stream = lease.stream;
1343
1391
  let at = payloads.at(0).event.id, handled = 0;
1344
1392
  lease.retry > 0 && this._logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1345
- const doAction = this.do.bind(this);
1393
+ const doAction = this._bound_do;
1346
1394
  const scopedApp = {
1347
1395
  do: doAction,
1348
- load: this.load.bind(this),
1349
- query: this.query.bind(this),
1350
- query_array: this.query_array.bind(this)
1396
+ load: this._bound_load,
1397
+ query: this._bound_query,
1398
+ query_array: this._bound_query_array
1351
1399
  };
1352
1400
  for (const payload of payloads) {
1353
1401
  const { event, handler, options } = payload;
@@ -1480,12 +1528,13 @@ var Act = class {
1480
1528
  return { fetched: [], leased: [], acked: [], blocked: [] };
1481
1529
  }
1482
1530
  const fetched = await this._cd.fetch(leased, eventLimit);
1483
- const payloadsMap = /* @__PURE__ */ new Map();
1531
+ const fetchMap = /* @__PURE__ */ new Map();
1484
1532
  const fetch_window_at = fetched.reduce(
1485
1533
  (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
1486
1534
  0
1487
1535
  );
1488
- fetched.forEach(({ stream, events }) => {
1536
+ for (const f of fetched) {
1537
+ const { stream, events } = f;
1489
1538
  const payloads = events.flatMap((event) => {
1490
1539
  const register = this.registry.events[event.name];
1491
1540
  if (!register) return [];
@@ -1494,13 +1543,13 @@ var Act = class {
1494
1543
  return resolved && resolved.target === stream;
1495
1544
  }).map((reaction) => ({ ...reaction, event }));
1496
1545
  });
1497
- payloadsMap.set(stream, payloads);
1498
- });
1546
+ fetchMap.set(stream, { fetch: f, payloads });
1547
+ }
1499
1548
  const handled = await Promise.all(
1500
1549
  leased.map((lease) => {
1501
- const streamFetch = fetched.find((f) => f.stream === lease.stream);
1502
- const at = streamFetch?.events.at(-1)?.id || fetch_window_at;
1503
- const payloads = payloadsMap.get(lease.stream);
1550
+ const entry = fetchMap.get(lease.stream);
1551
+ const at = entry?.fetch.events.at(-1)?.id || fetch_window_at;
1552
+ const payloads = entry?.payloads ?? [];
1504
1553
  const batchHandler = this._batch_handlers.get(lease.stream);
1505
1554
  if (batchHandler && payloads.length > 0) {
1506
1555
  return this.handleBatch({ ...lease, at }, payloads, batchHandler);
@@ -1834,16 +1883,24 @@ var Act = class {
1834
1883
  streams.map(async (s) => {
1835
1884
  let maxId = -1;
1836
1885
  let version = -1;
1886
+ let lastEventName;
1837
1887
  await store().query(
1838
1888
  (e) => {
1839
- if (e.name !== TOMBSTONE_EVENT) {
1889
+ if (e.name === TOMBSTONE_EVENT) return;
1890
+ if (maxId === -1) {
1840
1891
  maxId = e.id;
1841
1892
  version = e.version;
1842
1893
  }
1894
+ if (e.name !== SNAP_EVENT && lastEventName === void 0) {
1895
+ lastEventName = e.name;
1896
+ }
1843
1897
  },
1844
- { stream: s, stream_exact: true, backward: true, limit: 1 }
1898
+ // limit: 2 covers the typical snapshot-at-head case (snapshot is
1899
+ // always preceded by the domain event it captured). Streams with
1900
+ // unusual layouts fall back to no-seed via the lookup miss path.
1901
+ { stream: s, stream_exact: true, backward: true, limit: 2 }
1845
1902
  );
1846
- if (maxId >= 0) streamInfo.set(s, { maxId, version });
1903
+ if (maxId >= 0) streamInfo.set(s, { maxId, version, lastEventName });
1847
1904
  })
1848
1905
  );
1849
1906
  const skipped = [];
@@ -1852,16 +1909,14 @@ var Act = class {
1852
1909
  safe = [...streamInfo.keys()];
1853
1910
  } else {
1854
1911
  const pendingSet = /* @__PURE__ */ new Set();
1855
- const leases = await store().claim(1e3, 1e3, randomUUID2(), 1);
1856
- if (leases.length) await store().ack(leases);
1857
- for (const lease of leases) {
1858
- const sourceRe = lease.source ? RegExp(lease.source) : void 0;
1912
+ await store().query_streams((position) => {
1913
+ const sourceRe = position.source ? RegExp(position.source) : void 0;
1859
1914
  for (const [stream, info] of streamInfo) {
1860
- if ((!sourceRe || sourceRe.test(stream)) && lease.at < info.maxId) {
1915
+ if ((!sourceRe || sourceRe.test(stream)) && position.at < info.maxId) {
1861
1916
  pendingSet.add(stream);
1862
1917
  }
1863
1918
  }
1864
- }
1919
+ });
1865
1920
  safe = [];
1866
1921
  for (const [stream] of streamInfo) {
1867
1922
  if (pendingSet.has(stream)) {
@@ -1901,16 +1956,21 @@ var Act = class {
1901
1956
  this.emit("closed", result2);
1902
1957
  return result2;
1903
1958
  }
1904
- const mergedState = [...this._states.values()][0];
1905
1959
  const seedStates = /* @__PURE__ */ new Map();
1906
- if (mergedState) {
1907
- await Promise.all(
1908
- guarded.filter((s) => targetMap.get(s)?.restart).map(async (stream) => {
1909
- const snap2 = await this._es.load(mergedState, stream);
1910
- seedStates.set(stream, snap2.state);
1911
- })
1912
- );
1913
- }
1960
+ await Promise.all(
1961
+ guarded.filter((s) => targetMap.get(s)?.restart).map(async (stream) => {
1962
+ const lastEventName = streamInfo.get(stream)?.lastEventName;
1963
+ const ownerState = lastEventName ? this._event_to_state.get(lastEventName) : void 0;
1964
+ if (!ownerState) {
1965
+ this._logger.error(
1966
+ `Cannot seed restart for "${stream}": no registered state owns event "${lastEventName ?? "<none>"}". Stream will be tombstoned instead.`
1967
+ );
1968
+ return;
1969
+ }
1970
+ const snap2 = await this._es.load(ownerState, stream);
1971
+ seedStates.set(stream, snap2.state);
1972
+ })
1973
+ );
1914
1974
  for (const stream of guarded) {
1915
1975
  const archiveFn = targetMap.get(stream)?.archive;
1916
1976
  if (archiveFn) await archiveFn();
@@ -2021,6 +2081,14 @@ var Act = class {
2021
2081
  };
2022
2082
 
2023
2083
  // src/act-builder.ts
2084
+ function registerBatchHandler(proj, batchHandlers) {
2085
+ if (!proj.batchHandler || !proj.target) return;
2086
+ const existing = batchHandlers.get(proj.target);
2087
+ if (existing && existing !== proj.batchHandler) {
2088
+ throw new Error(`Duplicate batch handler for target "${proj.target}"`);
2089
+ }
2090
+ batchHandlers.set(proj.target, proj.batchHandler);
2091
+ }
2024
2092
  function act(states = /* @__PURE__ */ new Map(), registry = {
2025
2093
  actions: {},
2026
2094
  events: {}
@@ -2055,9 +2123,7 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2055
2123
  },
2056
2124
  withProjection: (proj) => {
2057
2125
  mergeProjection(proj, registry.events);
2058
- if (proj.batchHandler && proj.target) {
2059
- batchHandlers.set(proj.target, proj.batchHandler);
2060
- }
2126
+ registerBatchHandler(proj, batchHandlers);
2061
2127
  return act(
2062
2128
  states,
2063
2129
  registry,
@@ -2103,9 +2169,7 @@ function act(states = /* @__PURE__ */ new Map(), registry = {
2103
2169
  build: () => {
2104
2170
  for (const proj of pendingProjections) {
2105
2171
  mergeProjection(proj, registry.events);
2106
- if (proj.batchHandler && proj.target) {
2107
- batchHandlers.set(proj.target, proj.batchHandler);
2108
- }
2172
+ registerBatchHandler(proj, batchHandlers);
2109
2173
  }
2110
2174
  return new Act(
2111
2175
  registry,