@nwire/forge 0.8.17 → 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.
- package/dist/__tests__/plugin-stress.test.js +7 -7
- package/dist/__tests__/plugin-stress.test.js.map +1 -1
- package/dist/__tests__/workflow-saga.test.js +26 -0
- package/dist/__tests__/workflow-saga.test.js.map +1 -1
- package/dist/actor-store.d.ts +20 -2
- package/dist/actor-store.d.ts.map +1 -1
- package/dist/actor-store.js +30 -2
- package/dist/actor-store.js.map +1 -1
- package/dist/create-app.d.ts +1 -1
- package/dist/create-app.d.ts.map +1 -1
- package/dist/create-app.js +7 -4
- package/dist/create-app.js.map +1 -1
- package/dist/define-action.d.ts +14 -2
- package/dist/define-action.d.ts.map +1 -1
- package/dist/define-action.js.map +1 -1
- package/dist/define-handler.d.ts +3 -3
- package/dist/define-handler.d.ts.map +1 -1
- package/dist/define-query.d.ts +4 -0
- package/dist/define-query.d.ts.map +1 -1
- package/dist/define-query.js +4 -2
- package/dist/define-query.js.map +1 -1
- package/dist/define-upcaster.d.ts +25 -0
- package/dist/define-upcaster.d.ts.map +1 -0
- package/dist/define-upcaster.js +31 -0
- package/dist/define-upcaster.js.map +1 -0
- package/dist/define-workflow.d.ts +4 -4
- package/dist/define-workflow.d.ts.map +1 -1
- package/dist/define-workflow.js +40 -10
- package/dist/define-workflow.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +45 -121
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +134 -276
- package/dist/runtime.js.map +1 -1
- package/package.json +7 -7
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
-
|
|
206
|
-
return
|
|
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) {
|
|
@@ -524,9 +408,6 @@ export class Runtime {
|
|
|
524
408
|
getProjectionStore() {
|
|
525
409
|
return this.projectionStore;
|
|
526
410
|
}
|
|
527
|
-
getContainer() {
|
|
528
|
-
return this.container;
|
|
529
|
-
}
|
|
530
411
|
listHandlers() {
|
|
531
412
|
return [...this.handlers.keys()];
|
|
532
413
|
}
|
|
@@ -925,41 +806,6 @@ export class Runtime {
|
|
|
925
806
|
}
|
|
926
807
|
}
|
|
927
808
|
}
|
|
928
|
-
/**
|
|
929
|
-
* Tap into the canonical telemetry stream. One subscriber sees every kind:
|
|
930
|
-
* `action.dispatched` / `.completed` / `.failed`, `event.published`,
|
|
931
|
-
* `actor.transitioned`, `projection.folded`, `reaction.fired` / `.failed`,
|
|
932
|
-
* `query.executed`, `timer.scheduled` / `.fired`, `dlq.recorded`.
|
|
933
|
-
*
|
|
934
|
-
* Listeners run AFTER the corresponding lifecycle action commits — they
|
|
935
|
-
* observe what actually happened, not in-flight intent. Throwing in a
|
|
936
|
-
* listener is caught and logged; it never breaks domain dispatch.
|
|
937
|
-
*
|
|
938
|
-
* Returns an unsubscribe function.
|
|
939
|
-
*/
|
|
940
|
-
onTelemetry(listener) {
|
|
941
|
-
this.telemetryListeners.push(listener);
|
|
942
|
-
return () => this.offTelemetry(listener);
|
|
943
|
-
}
|
|
944
|
-
offTelemetry(listener) {
|
|
945
|
-
const i = this.telemetryListeners.indexOf(listener);
|
|
946
|
-
if (i >= 0)
|
|
947
|
-
this.telemetryListeners.splice(i, 1);
|
|
948
|
-
}
|
|
949
|
-
emit(record) {
|
|
950
|
-
if (this.telemetryListeners.length === 0)
|
|
951
|
-
return;
|
|
952
|
-
for (const listener of this.telemetryListeners) {
|
|
953
|
-
try {
|
|
954
|
-
listener(record);
|
|
955
|
-
}
|
|
956
|
-
catch (err) {
|
|
957
|
-
// Best-effort — don't let a bad listener break dispatch.
|
|
958
|
-
// eslint-disable-next-line no-console
|
|
959
|
-
console.error("Runtime.onTelemetry listener threw:", err);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
809
|
/**
|
|
964
810
|
* Apply an event that arrived from the cross-service bus. Same pipeline
|
|
965
811
|
* as `publish` (actors → projections → workflows) but does NOT re-publish
|
|
@@ -1084,96 +930,109 @@ export class Runtime {
|
|
|
1084
930
|
}
|
|
1085
931
|
}
|
|
1086
932
|
async applyEventToActorLocked(actor, key, tenant, event, candidateReactions, envelope) {
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
state: nextStateName,
|
|
1127
|
-
data: validated,
|
|
1128
|
-
activeTimers: nextTimers,
|
|
1129
|
-
};
|
|
1130
|
-
await this.actorStore.save(nextInstance);
|
|
1131
|
-
if (stateChanged) {
|
|
1132
|
-
this.emit({
|
|
1133
|
-
kind: "actor.transitioned",
|
|
1134
|
-
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,
|
|
1135
972
|
key,
|
|
1136
973
|
tenant,
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1145
|
-
// Fire actor-transition hooks (registered by plugins). Hooks run AFTER
|
|
1146
|
-
// the save so they observe committed state. Errors propagate — plugins
|
|
1147
|
-
// are infrastructure; we want loud failures, not silent skips.
|
|
1148
|
-
if (this.actorTransitionHooks.length > 0 && stateChanged) {
|
|
1149
|
-
for (const hook of this.actorTransitionHooks) {
|
|
1150
|
-
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 });
|
|
1151
981
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
// Runs only on actual transitions (state changed) so observers don't
|
|
1156
|
-
// see no-op events.
|
|
1157
|
-
if (stateChanged) {
|
|
1158
|
-
const perActorHook = this.perActorHooks.get(actor.name);
|
|
1159
|
-
if (perActorHook) {
|
|
1160
|
-
try {
|
|
1161
|
-
await perActorHook.run({
|
|
1162
|
-
actor,
|
|
1163
|
-
key,
|
|
1164
|
-
fromState: instance.state,
|
|
1165
|
-
toState: nextStateName,
|
|
1166
|
-
triggeringEvent: event,
|
|
1167
|
-
envelope,
|
|
1168
|
-
});
|
|
982
|
+
catch (err) {
|
|
983
|
+
if (err instanceof ActorVersionConflictError && occAttempt < maxOccRetries - 1) {
|
|
984
|
+
continue;
|
|
1169
985
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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);
|
|
1175
1008
|
}
|
|
1176
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
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return;
|
|
1177
1036
|
}
|
|
1178
1037
|
}
|
|
1179
1038
|
computeTimersForState(actor, stateName, actorKey) {
|
|
@@ -1421,8 +1280,7 @@ export class Runtime {
|
|
|
1421
1280
|
return envelope.messageId;
|
|
1422
1281
|
},
|
|
1423
1282
|
async request(action, input) {
|
|
1424
|
-
|
|
1425
|
-
return result;
|
|
1283
|
+
return self.dispatch(action, input, envelope, { signal: ctxSignal });
|
|
1426
1284
|
},
|
|
1427
1285
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1428
1286
|
async query(
|