@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.
- package/README.md +132 -71
- package/dist/__tests__/actor-workflow-hooks.test.js +3 -1
- package/dist/__tests__/actor-workflow-hooks.test.js.map +1 -1
- package/dist/__tests__/lifecycle-logging.test.js.map +1 -1
- package/dist/__tests__/plugin-app-narrow.test.d.ts +12 -0
- package/dist/__tests__/plugin-app-narrow.test.d.ts.map +1 -0
- package/dist/__tests__/plugin-app-narrow.test.js +77 -0
- package/dist/__tests__/plugin-app-narrow.test.js.map +1 -0
- package/dist/__tests__/plugin-stress.test.js +9 -9
- package/dist/__tests__/plugin-stress.test.js.map +1 -1
- package/dist/__tests__/public-marker.test.js +7 -3
- package/dist/__tests__/public-marker.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 +10 -2
- package/dist/create-app.d.ts.map +1 -1
- package/dist/create-app.js +36 -17
- package/dist/create-app.js.map +1 -1
- package/dist/define-action.d.ts +85 -9
- package/dist/define-action.d.ts.map +1 -1
- package/dist/define-action.js +101 -27
- package/dist/define-action.js.map +1 -1
- package/dist/define-actor.js.map +1 -1
- package/dist/define-handler.d.ts +21 -4
- package/dist/define-handler.d.ts.map +1 -1
- package/dist/define-handler.js +7 -0
- package/dist/define-handler.js.map +1 -1
- package/dist/define-plugin.d.ts +21 -0
- package/dist/define-plugin.d.ts.map +1 -1
- package/dist/define-plugin.js +66 -0
- package/dist/define-plugin.js.map +1 -1
- package/dist/define-query.d.ts +70 -12
- package/dist/define-query.d.ts.map +1 -1
- package/dist/define-query.js +49 -18
- 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/dev-logger.d.ts.map +1 -1
- package/dist/dev-logger.js +5 -2
- package/dist/dev-logger.js.map +1 -1
- package/dist/event-message.d.ts +9 -2
- package/dist/event-message.d.ts.map +1 -1
- package/dist/event-message.js +10 -3
- package/dist/event-message.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +53 -123
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +166 -289
- package/dist/runtime.js.map +1 -1
- 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 {
|
|
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) {
|
|
@@ -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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
query.projection.
|
|
493
|
-
|
|
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
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
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(
|