@nwire/forge 0.8.0 → 0.9.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 (63) hide show
  1. package/README.md +132 -71
  2. package/dist/__tests__/actor-workflow-hooks.test.js +3 -1
  3. package/dist/__tests__/actor-workflow-hooks.test.js.map +1 -1
  4. package/dist/__tests__/lifecycle-logging.test.js.map +1 -1
  5. package/dist/__tests__/plugin-app-narrow.test.d.ts +12 -0
  6. package/dist/__tests__/plugin-app-narrow.test.d.ts.map +1 -0
  7. package/dist/__tests__/plugin-app-narrow.test.js +77 -0
  8. package/dist/__tests__/plugin-app-narrow.test.js.map +1 -0
  9. package/dist/__tests__/plugin-stress.test.js +9 -9
  10. package/dist/__tests__/plugin-stress.test.js.map +1 -1
  11. package/dist/__tests__/public-marker.test.js +7 -3
  12. package/dist/__tests__/public-marker.test.js.map +1 -1
  13. package/dist/__tests__/workflow-saga.test.js +26 -0
  14. package/dist/__tests__/workflow-saga.test.js.map +1 -1
  15. package/dist/actor-store.d.ts +20 -2
  16. package/dist/actor-store.d.ts.map +1 -1
  17. package/dist/actor-store.js +30 -2
  18. package/dist/actor-store.js.map +1 -1
  19. package/dist/create-app.d.ts +10 -2
  20. package/dist/create-app.d.ts.map +1 -1
  21. package/dist/create-app.js +36 -17
  22. package/dist/create-app.js.map +1 -1
  23. package/dist/define-action.d.ts +85 -9
  24. package/dist/define-action.d.ts.map +1 -1
  25. package/dist/define-action.js +101 -27
  26. package/dist/define-action.js.map +1 -1
  27. package/dist/define-actor.js.map +1 -1
  28. package/dist/define-handler.d.ts +21 -4
  29. package/dist/define-handler.d.ts.map +1 -1
  30. package/dist/define-handler.js +7 -0
  31. package/dist/define-handler.js.map +1 -1
  32. package/dist/define-plugin.d.ts +21 -0
  33. package/dist/define-plugin.d.ts.map +1 -1
  34. package/dist/define-plugin.js +66 -0
  35. package/dist/define-plugin.js.map +1 -1
  36. package/dist/define-query.d.ts +70 -12
  37. package/dist/define-query.d.ts.map +1 -1
  38. package/dist/define-query.js +49 -18
  39. package/dist/define-query.js.map +1 -1
  40. package/dist/define-upcaster.d.ts +25 -0
  41. package/dist/define-upcaster.d.ts.map +1 -0
  42. package/dist/define-upcaster.js +31 -0
  43. package/dist/define-upcaster.js.map +1 -0
  44. package/dist/define-workflow.d.ts +4 -4
  45. package/dist/define-workflow.d.ts.map +1 -1
  46. package/dist/define-workflow.js +40 -10
  47. package/dist/define-workflow.js.map +1 -1
  48. package/dist/dev-logger.d.ts.map +1 -1
  49. package/dist/dev-logger.js +5 -2
  50. package/dist/dev-logger.js.map +1 -1
  51. package/dist/event-message.d.ts +9 -2
  52. package/dist/event-message.d.ts.map +1 -1
  53. package/dist/event-message.js +10 -3
  54. package/dist/event-message.js.map +1 -1
  55. package/dist/index.d.ts +5 -5
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +4 -4
  58. package/dist/index.js.map +1 -1
  59. package/dist/runtime.d.ts +53 -123
  60. package/dist/runtime.d.ts.map +1 -1
  61. package/dist/runtime.js +166 -289
  62. package/dist/runtime.js.map +1 -1
  63. package/package.json +11 -11
package/dist/runtime.js CHANGED
@@ -19,38 +19,20 @@
19
19
  * - Events fan out to actors first, then workflow reactions, then
20
20
  * projections.
21
21
  */
22
- import { rootContainer } from "@nwire/container";
23
22
  import { hook } from "@nwire/hooks";
23
+ import { Runtime as RuntimeBase, serializeError, } from "@nwire/app";
24
24
  import { isValidated, markValidated } from "@nwire/messages";
25
25
  import { seedEnvelope, deriveEnvelope } from "@nwire/envelope";
26
26
  import { InMemoryWorkflowTimerStore, timerEventName, } from "./workflow-timer-store.js";
27
27
  import { randomUUID } from "node:crypto";
28
- import { InMemoryActorStore, createInitialInstance, } from "./actor-store.js";
28
+ import { InMemoryActorStore, createInitialInstance, ActorVersionConflictError, } from "./actor-store.js";
29
29
  import { normalizeEventReturn } from "./event-message.js";
30
30
  import { InMemoryProjectionStore } from "./projection-store.js";
31
31
  import { InMemoryIdempotencyStore } from "./idempotency-store.js";
32
- import { NoopLogger, loggerForEnvelope } from "@nwire/logger";
32
+ import { loggerForEnvelope } from "@nwire/logger";
33
33
  import { InMemoryDeadLetterSink, buildDeadLetterEntry, } from "@nwire/dead-letter";
34
- import { FrameworkEventBus } from "./framework-event-bus.js";
35
34
  import { ActionDispatching, ActionCompleted, ActionFailed, builtInFrameworkEvents, } from "./framework-events.js";
36
- /** Serialize an unknown thrown value into the canonical SerializedError shape. */
37
- function serializeError(err) {
38
- if (err instanceof Error) {
39
- const out = {
40
- name: err.name,
41
- message: err.message,
42
- stack: err.stack,
43
- };
44
- for (const k of Object.keys(err)) {
45
- if (k === "name" || k === "message" || k === "stack")
46
- continue;
47
- out[k] = err[k];
48
- }
49
- return out;
50
- }
51
- return { name: "NonError", message: String(err) };
52
- }
53
- export class Runtime {
35
+ export class Runtime extends RuntimeBase {
54
36
  handlers = new Map();
55
37
  actors = new Map();
56
38
  /**
@@ -83,19 +65,9 @@ export class Runtime {
83
65
  externalCalls = new Map();
84
66
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
67
  externalCallExecutors = new Map();
86
- container;
87
68
  actorStore;
88
69
  projectionStore;
89
- logger;
90
70
  deadLetterSink;
91
- /**
92
- * Dispatch is a `@nwire/hooks` chain — user middlewares plus a permanent
93
- * "handler" step pinned to the inner edge. `runtime.use(mw)` adapts the
94
- * legacy `DispatchMiddleware` shape into a `ChainFn` and registers it.
95
- * Taps fire into `runtime.onTelemetry` under `kind: "hook.step"`, which is
96
- * what Studio + CLI + OTel consume.
97
- */
98
- dispatchHook;
99
71
  /**
100
72
  * Per-action `action.before:<name>` hooks. Pre-created at
101
73
  * `registerHandler()` so they show up in `listHooks()` + scan + Studio
@@ -118,8 +90,6 @@ export class Runtime {
118
90
  actorTransitionHooks = [];
119
91
  bus;
120
92
  publishToBus;
121
- appName;
122
- telemetryListeners = [];
123
93
  /** Known external events — set by createApp from modules' needs.externalEvents. */
124
94
  externalEventNames = new Set();
125
95
  /** Public-event names (visibility: 'public') — set by createApp from modules' events. */
@@ -128,82 +98,53 @@ export class Runtime {
128
98
  workflowTimerStore;
129
99
  /** Envelope-level dedup table — see RuntimeOptions.idempotencyStore. */
130
100
  idempotencyStore;
131
- /**
132
- * Framework event bus — lifecycle + dispatch hooks. Plugins and apps
133
- * subscribe with `runtime.onFramework(Event, handler)`; the runtime
134
- * fires events at the appropriate sites in `dispatch`, `start`, `stop`.
135
- */
136
- frameworkEvents;
137
101
  constructor(options = {}) {
138
- this.container = options.container ?? rootContainer;
102
+ // Container / logger / appName / frameworkEvents / dispatchHook / use /
103
+ // adoptHook / onTelemetry / offTelemetry / emit / getContainer are all
104
+ // owned by the base. Forge layers the CQRS engine on top.
105
+ super({
106
+ container: options.container,
107
+ logger: options.logger,
108
+ appName: options.appName,
109
+ events: builtInFrameworkEvents,
110
+ });
139
111
  this.actorStore = options.actorStore ?? new InMemoryActorStore();
140
112
  this.projectionStore = options.projectionStore ?? new InMemoryProjectionStore();
141
- this.logger = options.logger ?? new NoopLogger();
142
113
  this.deadLetterSink = options.deadLetterSink ?? new InMemoryDeadLetterSink();
143
114
  this.bus = options.bus;
144
115
  this.publishToBus = options.publishToBus ?? false;
145
- this.appName = options.appName ?? "app";
146
116
  this.workflowTimerStore = options.workflowTimerStore ?? new InMemoryWorkflowTimerStore();
147
117
  this.idempotencyStore = options.idempotencyStore ?? new InMemoryIdempotencyStore();
148
- // Forward bus's per-event hook adoptions through runtime.adoptHook so
149
- // every framework event becomes a registered hook (visible in
150
- // listHooks() / scan / Studio).
151
- this.frameworkEvents = new FrameworkEventBus(this.logger, {
152
- adoptHook: (h) => this.adoptHook(h),
153
- events: builtInFrameworkEvents,
154
- });
155
- // Bridge framework-event firings into the telemetry stream so every
156
- // existing telemetry consumer (dev logger, Studio Live SSE, OTel
157
- // exporter) sees lifecycle activity through the same channel as
158
- // domain events. `namespace` = the second dotted segment so dev
159
- // logger / Studio can group by phase (app / plugin / wire / …).
160
- this.frameworkEvents.onFire((rec) => {
161
- const parts = rec.eventName.split(".");
162
- const namespace = parts.length >= 2 ? parts[1] : "framework";
163
- this.emit({
164
- kind: "lifecycle",
165
- event: rec.eventName,
166
- namespace,
167
- payload: rec.payload,
168
- phase: rec.phase,
169
- appName: this.appName,
170
- ts: rec.ts,
171
- });
172
- });
173
- // Set up the dispatch hook + the innermost "handler" step (pinned at
174
- // -Infinity so user middleware always wraps it). Every step emits a
175
- // start/end/error observation through the tap into the canonical
176
- // telemetry stream — that's what powers Studio Live + CLI hook trace.
177
- this.dispatchHook = hook("forge.action.dispatch");
118
+ // Pin the innermost dispatch step that calls forge's per-action retry +
119
+ // handler-invocation + event-publishing closure. priority `-Infinity`
120
+ // keeps user `runtime.use()` middleware strictly outside it.
178
121
  this.dispatchHook.use(async (hctx, next) => {
179
122
  hctx.result = await hctx.coreFn();
180
123
  await next();
181
124
  }, { name: "handler", priority: -Infinity });
182
- this.dispatchHook.tap((obs) => {
183
- this.emit({
184
- kind: "hook.step",
185
- hookName: obs.hookName,
186
- hookId: obs.hookId,
187
- runId: obs.runId,
188
- parentRunId: obs.parentRunId,
189
- stepId: obs.stepId,
190
- stepKind: obs.stepKind,
191
- stepName: obs.stepName,
192
- phase: obs.phase,
193
- durationMs: obs.durationMs,
194
- error: obs.error ? serializeError(obs.error) : undefined,
195
- appName: this.appName,
196
- ts: new Date().toISOString(),
197
- });
198
- });
199
125
  }
200
126
  /**
201
- * Subscribe to a framework event. Sugar over `runtime.frameworkEvents.on`
202
- * so plugins can write `runtime.onFramework(ActionDispatching, ...)`.
203
- * Returns an unsubscribe function.
127
+ * Tap into the canonical telemetry stream narrowed to forge's widened
128
+ * `Telemetry` union. Delegates to the base `Runtime.onTelemetry` but
129
+ * locks the listener's record type to forge's CQRS-aware shape so
130
+ * `rec.kind === "event.published"` narrowing works for consumers.
204
131
  */
205
- onFramework(event, handler, priority = 0) {
206
- return this.frameworkEvents.on(event, handler, priority);
132
+ onTelemetry(listener) {
133
+ return super.onTelemetry(listener);
134
+ }
135
+ offTelemetry(listener) {
136
+ super.offTelemetry(listener);
137
+ }
138
+ /**
139
+ * Register a dispatch middleware. Narrows the base `use(middleware)` to
140
+ * forge's tightened `DispatchMiddleware` shape so middleware authors
141
+ * see typed `action: ActionDefinition` / `ctx: HandlerContext`. The base
142
+ * runtime sees its `unknown`-typed shape; the cast is safe — every call
143
+ * site we control hands the same `(action, input, ctx)` triple forge's
144
+ * dispatcher pushes through `dispatchHook.run(...)`.
145
+ */
146
+ use(middleware) {
147
+ super.use(middleware);
207
148
  }
208
149
  /** Internal — createApp registers known external event names. */
209
150
  registerExternalEvent(eventName) {
@@ -213,67 +154,10 @@ export class Runtime {
213
154
  registerPublicEvent(eventName) {
214
155
  this.publicEventNames.add(eventName);
215
156
  }
216
- /**
217
- * Wire a hook's per-step tap into the canonical telemetry stream. After
218
- * calling this, every `.use()` / `.on()` step on the hook emits a
219
- * `kind: "hook.step"` record through `runtime.onTelemetry`, the same way
220
- * the built-in `forge.action.dispatch` hook does.
221
- *
222
- * createApp uses this for every per-plugin and per-module lifecycle hook;
223
- * plugin authors can call it on their own hooks if they want their
224
- * extension points surfaced in Studio + dev-logger + OTel for free.
225
- *
226
- * Idempotent at the source — calling twice on the same hook attaches two
227
- * tap subscribers; the framework only calls this once per hook it owns.
228
- */
229
- adoptHook(hook) {
230
- hook.tap((obs) => {
231
- this.emit({
232
- kind: "hook.step",
233
- hookName: obs.hookName,
234
- hookId: obs.hookId,
235
- runId: obs.runId,
236
- parentRunId: obs.parentRunId,
237
- stepId: obs.stepId,
238
- stepKind: obs.stepKind,
239
- stepName: obs.stepName,
240
- phase: obs.phase,
241
- durationMs: obs.durationMs,
242
- error: obs.error ? serializeError(obs.error) : undefined,
243
- appName: this.appName,
244
- ts: new Date().toISOString(),
245
- });
246
- });
247
- }
248
157
  /** Internal — createApp registers actor-transition hooks from plugins. */
249
158
  registerActorTransitionHook(hook) {
250
159
  this.actorTransitionHooks.push(hook);
251
160
  }
252
- /**
253
- * Register a dispatch middleware. Outermost first — the order you call
254
- * `use()` is the order layers wrap (first `use` is the outermost layer).
255
- * Middlewares run once per dispatch, outside the retry loop.
256
- *
257
- * Internally this attaches to the `forge.action.dispatch` hook as a
258
- * chain step. The legacy `(next, action, input, ctx)` shape is adapted
259
- * to a `ChainFn<DispatchHookCtx>` without surface change for callers.
260
- */
261
- use(middleware) {
262
- // Each user middleware gets a distinct priority so the chain order
263
- // matches registration order. We start from 0 and DECREMENT — the
264
- // first registered middleware ends up with the highest priority
265
- // (= outermost), and the pinned "handler" step at -Infinity stays
266
- // strictly innermost.
267
- const priority = -this.userMiddlewareCount;
268
- this.userMiddlewareCount += 1;
269
- this.dispatchHook.use(async (hctx, next) => {
270
- hctx.result = await middleware(async () => {
271
- await next();
272
- return hctx.result;
273
- }, hctx.action, hctx.input, hctx.ctx);
274
- }, { name: middleware.name || `middleware#${this.userMiddlewareCount}`, priority });
275
- }
276
- userMiddlewareCount = 0;
277
161
  // ─── Registration ────────────────────────────────────────────────
278
162
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
279
163
  registerHandler(handler) {
@@ -485,12 +369,27 @@ export class Runtime {
485
369
  throw new Error(`Runtime: no query registered with name "${queryName}".`);
486
370
  }
487
371
  const t0 = performance.now();
488
- const validated = isValidated(input)
489
- ? input
490
- : markValidated(query.schema.parse(input));
491
- const state = (await this.projectionStore.load(query.projection.name, tenant)) ??
492
- query.projection.initial();
493
- const result = (await query.execute(state, validated));
372
+ const validated = isValidated(input) ? input : markValidated(query.schema.parse(input));
373
+ let result;
374
+ if (query.projection && query.execute) {
375
+ // Projection form load state, hand it to `execute`.
376
+ const state = (await this.projectionStore.load(query.projection.name, tenant)) ??
377
+ query.projection.initial();
378
+ result = (await query.execute(state, validated));
379
+ }
380
+ else if (query.handler) {
381
+ // Handler form — no projection, hand a QueryContext (DI + tenant +
382
+ // signal) to the user's reader. Lets queries read from any source
383
+ // (Postgres, Redis, search) without a projection layer.
384
+ result = (await query.handler(validated, {
385
+ resolve: (name) => this.container.resolve(name),
386
+ tenant,
387
+ }));
388
+ }
389
+ else {
390
+ throw new Error(`Runtime: query "${queryName}" has neither projection+execute nor a handler — ` +
391
+ `the runtime cannot route the call. defineQuery must be given one or the other.`);
392
+ }
494
393
  this.emit({
495
394
  kind: "query.executed",
496
395
  query: queryName,
@@ -509,9 +408,6 @@ export class Runtime {
509
408
  getProjectionStore() {
510
409
  return this.projectionStore;
511
410
  }
512
- getContainer() {
513
- return this.container;
514
- }
515
411
  listHandlers() {
516
412
  return [...this.handlers.keys()];
517
413
  }
@@ -599,6 +495,12 @@ export class Runtime {
599
495
  }
600
496
  return fired;
601
497
  }
498
+ /**
499
+ * Resolve an action definition by its routing name. Used by execute/send
500
+ * to support the callable form `execute(myAction(input))` — the
501
+ * `CommandMessage` carries only the name; the runtime looks up the actual
502
+ * action from the registered handler map.
503
+ */
602
504
  findActionByName(name) {
603
505
  const handler = this.handlers.get(name);
604
506
  return handler?.action;
@@ -653,9 +555,7 @@ export class Runtime {
653
555
  // parse path because the brand is missing. The brand is dropped by
654
556
  // serialization, structural copy, and primitive coercion — so it
655
557
  // never produces a false "trust me."
656
- const validated = isValidated(input)
657
- ? input
658
- : markValidated(action.schema.parse(input));
558
+ const validated = isValidated(input) ? input : markValidated(action.schema.parse(input));
659
559
  const log = loggerForEnvelope(this.logger, envelope);
660
560
  // Caller-side cancellation: every dispatch carries an AbortSignal on
661
561
  // ctx. When the caller doesn't supply one, we mint a never-aborted
@@ -698,7 +598,9 @@ export class Runtime {
698
598
  }
699
599
  }
700
600
  // Core: retry loop + handler invocation + event publishing.
701
- // Returns the raw handler return for the dispatcher to type-cast.
601
+ // Returns the raw handler return for the dispatcher to type-cast. The
602
+ // return union mirrors `HandlerReturn` — plain records (e.g. `{ userId }`)
603
+ // surface here untouched so the dispatcher can hand them back to callers.
702
604
  const core = async () => {
703
605
  const retry = action.retry;
704
606
  const maxAttempts = 1 + (retry?.max ?? 0);
@@ -854,9 +756,7 @@ export class Runtime {
854
756
  // one event ships under the same parent envelope we tag the
855
757
  // dedup key with the event index so each one applies on the
856
758
  // first delivery and dedups on the second.
857
- const dedupKey = events.length === 1
858
- ? parentEnvelope.messageId
859
- : `${parentEnvelope.messageId}#${i}`;
759
+ const dedupKey = events.length === 1 ? parentEnvelope.messageId : `${parentEnvelope.messageId}#${i}`;
860
760
  const childEnvelope = deriveEnvelope(parentEnvelope);
861
761
  if (await this.idempotencyStore.seen(dedupKey)) {
862
762
  this.emit({
@@ -906,41 +806,6 @@ export class Runtime {
906
806
  }
907
807
  }
908
808
  }
909
- /**
910
- * Tap into the canonical telemetry stream. One subscriber sees every kind:
911
- * `action.dispatched` / `.completed` / `.failed`, `event.published`,
912
- * `actor.transitioned`, `projection.folded`, `reaction.fired` / `.failed`,
913
- * `query.executed`, `timer.scheduled` / `.fired`, `dlq.recorded`.
914
- *
915
- * Listeners run AFTER the corresponding lifecycle action commits — they
916
- * observe what actually happened, not in-flight intent. Throwing in a
917
- * listener is caught and logged; it never breaks domain dispatch.
918
- *
919
- * Returns an unsubscribe function.
920
- */
921
- onTelemetry(listener) {
922
- this.telemetryListeners.push(listener);
923
- return () => this.offTelemetry(listener);
924
- }
925
- offTelemetry(listener) {
926
- const i = this.telemetryListeners.indexOf(listener);
927
- if (i >= 0)
928
- this.telemetryListeners.splice(i, 1);
929
- }
930
- emit(record) {
931
- if (this.telemetryListeners.length === 0)
932
- return;
933
- for (const listener of this.telemetryListeners) {
934
- try {
935
- listener(record);
936
- }
937
- catch (err) {
938
- // Best-effort — don't let a bad listener break dispatch.
939
- // eslint-disable-next-line no-console
940
- console.error("Runtime.onTelemetry listener threw:", err);
941
- }
942
- }
943
- }
944
809
  /**
945
810
  * Apply an event that arrived from the cross-service bus. Same pipeline
946
811
  * as `publish` (actors → projections → workflows) but does NOT re-publish
@@ -1065,96 +930,109 @@ export class Runtime {
1065
930
  }
1066
931
  }
1067
932
  async applyEventToActorLocked(actor, key, tenant, event, candidateReactions, envelope) {
1068
- const existing = await this.actorStore.load(actor.name, key, tenant);
1069
- const instance = existing ?? createInitialInstance(actor, key, tenant);
1070
- const matching = candidateReactions.find((c) => c.state === instance.state);
1071
- if (!matching) {
1072
- // Actor is in a state that doesn't react to this event — silently
1073
- // skip. (Future: dead-letter / log; surfacing here is too noisy for
1074
- // events that fan out to many actors, only some of which match.)
1075
- return;
1076
- }
1077
- const stateConfig = actor.states[instance.state];
1078
- if (stateConfig?.final) {
1079
- // Defensive: a final state shouldn't have entries in eventIndex,
1080
- // but guard anyway.
1081
- return;
1082
- }
1083
- const partial = matching.reaction.assign
1084
- ? matching.reaction.assign(instance.data, event.payload)
1085
- : {};
1086
- const nextData = { ...instance.data, ...partial };
1087
- const nextStateName = matching.reaction.target ?? instance.state;
1088
- const nextStateConfig = actor.states[nextStateName];
1089
- if (!nextStateConfig) {
1090
- throw new Error(`Runtime: actor "${actor.name}" reaction targets undeclared state "${nextStateName}" from "${instance.state}".`);
1091
- }
1092
- // Schema validation on save invalid partial throw, atomically
1093
- // skip persistence, re-raise to caller.
1094
- const validated = actor.schema.parse(nextData);
1095
- const stateChanged = nextStateName !== instance.state;
1096
- const isNewActor = !existing;
1097
- // Timers are owned by a state, not the actor. Compute them on:
1098
- // - state change (cancel old, schedule new), or
1099
- // - actor creation (born into a state schedule its timers).
1100
- const nextTimers = stateChanged || isNewActor
1101
- ? this.computeTimersForState(actor, nextStateName, key)
1102
- : instance.activeTimers;
1103
- const nextInstance = {
1104
- actorName: actor.name,
1105
- key,
1106
- tenant,
1107
- state: nextStateName,
1108
- data: validated,
1109
- activeTimers: nextTimers,
1110
- };
1111
- await this.actorStore.save(nextInstance);
1112
- if (stateChanged) {
1113
- this.emit({
1114
- kind: "actor.transitioned",
1115
- actor: actor.name,
933
+ const maxOccRetries = 3;
934
+ for (let occAttempt = 0; occAttempt < maxOccRetries; occAttempt++) {
935
+ const existing = await this.actorStore.load(actor.name, key, tenant);
936
+ const instance = existing ?? createInitialInstance(actor, key, tenant);
937
+ const matching = candidateReactions.find((c) => c.state === instance.state);
938
+ if (!matching) {
939
+ // Actor is in a state that doesn't react to this event — silently
940
+ // skip. (Future: dead-letter / log; surfacing here is too noisy for
941
+ // events that fan out to many actors, only some of which match.)
942
+ return;
943
+ }
944
+ const stateConfig = actor.states[instance.state];
945
+ if (stateConfig?.final) {
946
+ // Defensive: a final state shouldn't have entries in eventIndex,
947
+ // but guard anyway.
948
+ return;
949
+ }
950
+ const partial = matching.reaction.assign
951
+ ? matching.reaction.assign(instance.data, event.payload)
952
+ : {};
953
+ const nextData = { ...instance.data, ...partial };
954
+ const nextStateName = matching.reaction.target ?? instance.state;
955
+ const nextStateConfig = actor.states[nextStateName];
956
+ if (!nextStateConfig) {
957
+ throw new Error(`Runtime: actor "${actor.name}" reaction targets undeclared state "${nextStateName}" from "${instance.state}".`);
958
+ }
959
+ // Schema validation on save — invalid partial → throw, atomically
960
+ // skip persistence, re-raise to caller.
961
+ const validated = actor.schema.parse(nextData);
962
+ const stateChanged = nextStateName !== instance.state;
963
+ const isNewActor = !existing;
964
+ // Timers are owned by a state, not the actor. Compute them on:
965
+ // - state change (cancel old, schedule new), or
966
+ // - actor creation (born into a state — schedule its timers).
967
+ const nextTimers = stateChanged || isNewActor
968
+ ? this.computeTimersForState(actor, nextStateName, key)
969
+ : instance.activeTimers;
970
+ const nextInstance = {
971
+ actorName: actor.name,
1116
972
  key,
1117
973
  tenant,
1118
- from: instance.state,
1119
- to: nextStateName,
1120
- triggeringEvent: event.eventName,
1121
- envelope,
1122
- appName: this.appName,
1123
- ts: new Date().toISOString(),
1124
- });
1125
- }
1126
- // Fire actor-transition hooks (registered by plugins). Hooks run AFTER
1127
- // the save so they observe committed state. Errors propagate — plugins
1128
- // are infrastructure; we want loud failures, not silent skips.
1129
- if (this.actorTransitionHooks.length > 0 && stateChanged) {
1130
- for (const hook of this.actorTransitionHooks) {
1131
- await hook(actor, key, instance.state, nextStateName, event, envelope);
974
+ state: nextStateName,
975
+ data: validated,
976
+ activeTimers: nextTimers,
977
+ version: instance.version,
978
+ };
979
+ try {
980
+ await this.actorStore.save(nextInstance, { expectedVersion: instance.version });
1132
981
  }
1133
- }
1134
- // Per-actor `actor.transition:<name>` hook named, observable in
1135
- // listHooks(), tap-able by plugins via runtime.ensureActorTransitionHook.
1136
- // Runs only on actual transitions (state changed) so observers don't
1137
- // see no-op events.
1138
- if (stateChanged) {
1139
- const perActorHook = this.perActorHooks.get(actor.name);
1140
- if (perActorHook) {
1141
- try {
1142
- await perActorHook.run({
1143
- actor,
1144
- key,
1145
- fromState: instance.state,
1146
- toState: nextStateName,
1147
- triggeringEvent: event,
1148
- envelope,
1149
- });
982
+ catch (err) {
983
+ if (err instanceof ActorVersionConflictError && occAttempt < maxOccRetries - 1) {
984
+ continue;
1150
985
  }
1151
- catch (err) {
1152
- loggerForEnvelope(this.logger, envelope).error(`actor.transition hook threw`, {
1153
- actor: actor.name,
1154
- error: err?.message,
1155
- });
986
+ throw err;
987
+ }
988
+ if (stateChanged) {
989
+ this.emit({
990
+ kind: "actor.transitioned",
991
+ actor: actor.name,
992
+ key,
993
+ tenant,
994
+ from: instance.state,
995
+ to: nextStateName,
996
+ triggeringEvent: event.eventName,
997
+ envelope,
998
+ appName: this.appName,
999
+ ts: new Date().toISOString(),
1000
+ });
1001
+ }
1002
+ // Fire actor-transition hooks (registered by plugins). Hooks run AFTER
1003
+ // the save so they observe committed state. Errors propagate — plugins
1004
+ // are infrastructure; we want loud failures, not silent skips.
1005
+ if (this.actorTransitionHooks.length > 0 && stateChanged) {
1006
+ for (const hook of this.actorTransitionHooks) {
1007
+ await hook(actor, key, instance.state, nextStateName, event, envelope);
1008
+ }
1009
+ }
1010
+ // Per-actor `actor.transition:<name>` hook — named, observable in
1011
+ // listHooks(), tap-able by plugins via runtime.ensureActorTransitionHook.
1012
+ // Runs only on actual transitions (state changed) so observers don't
1013
+ // see no-op events.
1014
+ if (stateChanged) {
1015
+ const perActorHook = this.perActorHooks.get(actor.name);
1016
+ if (perActorHook) {
1017
+ try {
1018
+ await perActorHook.run({
1019
+ actor,
1020
+ key,
1021
+ fromState: instance.state,
1022
+ toState: nextStateName,
1023
+ triggeringEvent: event,
1024
+ envelope,
1025
+ });
1026
+ }
1027
+ catch (err) {
1028
+ loggerForEnvelope(this.logger, envelope).error(`actor.transition hook threw`, {
1029
+ actor: actor.name,
1030
+ error: err?.message,
1031
+ });
1032
+ }
1156
1033
  }
1157
1034
  }
1035
+ return;
1158
1036
  }
1159
1037
  }
1160
1038
  computeTimersForState(actor, stateName, actorKey) {
@@ -1402,8 +1280,7 @@ export class Runtime {
1402
1280
  return envelope.messageId;
1403
1281
  },
1404
1282
  async request(action, input) {
1405
- const result = await self.dispatch(action, input, envelope, { signal: ctxSignal });
1406
- return result;
1283
+ return self.dispatch(action, input, envelope, { signal: ctxSignal });
1407
1284
  },
1408
1285
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1409
1286
  async query(