@nwire/forge 0.7.1 → 0.8.17
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 +169 -79
- package/dist/__tests__/action-hooks.test.d.ts +8 -0
- package/dist/__tests__/action-hooks.test.d.ts.map +1 -0
- package/dist/__tests__/action-hooks.test.js +95 -0
- package/dist/__tests__/action-hooks.test.js.map +1 -0
- package/dist/__tests__/actor-workflow-hooks.test.d.ts +8 -0
- package/dist/__tests__/actor-workflow-hooks.test.d.ts.map +1 -0
- package/dist/__tests__/actor-workflow-hooks.test.js +106 -0
- package/dist/__tests__/actor-workflow-hooks.test.js.map +1 -0
- package/dist/__tests__/lifecycle-logging.test.js +4 -2
- 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.d.ts +21 -0
- package/dist/__tests__/plugin-stress.test.d.ts.map +1 -0
- package/dist/__tests__/plugin-stress.test.js +203 -0
- package/dist/__tests__/plugin-stress.test.js.map +1 -0
- package/dist/__tests__/public-marker.test.js +7 -3
- package/dist/__tests__/public-marker.test.js.map +1 -1
- package/dist/actor-store.d.ts +24 -0
- package/dist/actor-store.d.ts.map +1 -1
- package/dist/actor-store.js +29 -0
- package/dist/actor-store.js.map +1 -1
- package/dist/create-app.d.ts +16 -1
- package/dist/create-app.d.ts.map +1 -1
- package/dist/create-app.js +126 -19
- package/dist/create-app.js.map +1 -1
- package/dist/define-action.d.ts +75 -9
- package/dist/define-action.d.ts.map +1 -1
- package/dist/define-action.js +103 -26
- package/dist/define-action.js.map +1 -1
- package/dist/define-actor.d.ts +3 -1
- package/dist/define-actor.d.ts.map +1 -1
- package/dist/define-actor.js +11 -4
- package/dist/define-actor.js.map +1 -1
- package/dist/define-handler.d.ts +39 -3
- package/dist/define-handler.d.ts.map +1 -1
- package/dist/define-handler.js +10 -1
- package/dist/define-handler.js.map +1 -1
- package/dist/define-module.d.ts +3 -0
- package/dist/define-module.d.ts.map +1 -1
- package/dist/define-module.js +3 -0
- package/dist/define-module.js.map +1 -1
- package/dist/define-plugin.d.ts +25 -1
- package/dist/define-plugin.d.ts.map +1 -1
- package/dist/define-plugin.js +100 -14
- package/dist/define-plugin.js.map +1 -1
- package/dist/define-projection.d.ts +3 -1
- package/dist/define-projection.d.ts.map +1 -1
- package/dist/define-projection.js +3 -0
- package/dist/define-projection.js.map +1 -1
- package/dist/define-query.d.ts +69 -13
- package/dist/define-query.d.ts.map +1 -1
- package/dist/define-query.js +48 -17
- package/dist/define-query.js.map +1 -1
- package/dist/define-workflow.d.ts +19 -1
- package/dist/define-workflow.d.ts.map +1 -1
- package/dist/define-workflow.js +4 -0
- package/dist/define-workflow.js.map +1 -1
- package/dist/dev-logger.d.ts.map +1 -1
- package/dist/dev-logger.js +22 -1
- 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/framework-events.d.ts +1 -64
- package/dist/framework-events.d.ts.map +1 -1
- package/dist/framework-events.js +3 -0
- package/dist/framework-events.js.map +1 -1
- package/dist/idempotency-store.d.ts +35 -0
- package/dist/idempotency-store.d.ts.map +1 -0
- package/dist/idempotency-store.js +32 -0
- package/dist/idempotency-store.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +205 -5
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +518 -48
- package/dist/runtime.js.map +1 -1
- package/package.json +11 -10
package/dist/runtime.js
CHANGED
|
@@ -20,16 +20,19 @@
|
|
|
20
20
|
* projections.
|
|
21
21
|
*/
|
|
22
22
|
import { rootContainer } from "@nwire/container";
|
|
23
|
+
import { hook } from "@nwire/hooks";
|
|
24
|
+
import { isValidated, markValidated } from "@nwire/messages";
|
|
23
25
|
import { seedEnvelope, deriveEnvelope } from "@nwire/envelope";
|
|
24
26
|
import { InMemoryWorkflowTimerStore, timerEventName, } from "./workflow-timer-store.js";
|
|
25
27
|
import { randomUUID } from "node:crypto";
|
|
26
28
|
import { InMemoryActorStore, createInitialInstance, } from "./actor-store.js";
|
|
27
29
|
import { normalizeEventReturn } from "./event-message.js";
|
|
28
30
|
import { InMemoryProjectionStore } from "./projection-store.js";
|
|
31
|
+
import { InMemoryIdempotencyStore } from "./idempotency-store.js";
|
|
29
32
|
import { NoopLogger, loggerForEnvelope } from "@nwire/logger";
|
|
30
33
|
import { InMemoryDeadLetterSink, buildDeadLetterEntry, } from "@nwire/dead-letter";
|
|
31
34
|
import { FrameworkEventBus } from "./framework-event-bus.js";
|
|
32
|
-
import { ActionDispatching, ActionCompleted, ActionFailed, } from "./framework-events.js";
|
|
35
|
+
import { ActionDispatching, ActionCompleted, ActionFailed, builtInFrameworkEvents, } from "./framework-events.js";
|
|
33
36
|
/** Serialize an unknown thrown value into the canonical SerializedError shape. */
|
|
34
37
|
function serializeError(err) {
|
|
35
38
|
if (err instanceof Error) {
|
|
@@ -85,7 +88,33 @@ export class Runtime {
|
|
|
85
88
|
projectionStore;
|
|
86
89
|
logger;
|
|
87
90
|
deadLetterSink;
|
|
88
|
-
|
|
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
|
+
/**
|
|
100
|
+
* Per-action `action.before:<name>` hooks. Pre-created at
|
|
101
|
+
* `registerHandler()` so they show up in `listHooks()` + scan + Studio
|
|
102
|
+
* even before any plugin's `before(name, …)` sugar runs. Adopted into
|
|
103
|
+
* the canonical telemetry stream so each chain step emits `hook.step`
|
|
104
|
+
* observations.
|
|
105
|
+
*/
|
|
106
|
+
actionBeforeHooks = new Map();
|
|
107
|
+
/** Per-action `action.after:<name>` hooks. Pre-created identically. */
|
|
108
|
+
actionAfterHooks = new Map();
|
|
109
|
+
/**
|
|
110
|
+
* Per-actor `actor.transition:<name>` hooks. Pre-created at
|
|
111
|
+
* `registerActor()` time. Adopted into the canonical telemetry stream
|
|
112
|
+
* so each chain step emits `hook.step` observations — same model as
|
|
113
|
+
* `actionBeforeHooks` / `actionAfterHooks`.
|
|
114
|
+
*/
|
|
115
|
+
perActorHooks = new Map();
|
|
116
|
+
/** Per-workflow `workflow.fire:<name>` hooks. Pre-created at registration. */
|
|
117
|
+
perWorkflowHooks = new Map();
|
|
89
118
|
actorTransitionHooks = [];
|
|
90
119
|
bus;
|
|
91
120
|
publishToBus;
|
|
@@ -97,6 +126,8 @@ export class Runtime {
|
|
|
97
126
|
publicEventNames = new Set();
|
|
98
127
|
/** Saga timer store (default in-memory; pluggable via RuntimeOptions). */
|
|
99
128
|
workflowTimerStore;
|
|
129
|
+
/** Envelope-level dedup table — see RuntimeOptions.idempotencyStore. */
|
|
130
|
+
idempotencyStore;
|
|
100
131
|
/**
|
|
101
132
|
* Framework event bus — lifecycle + dispatch hooks. Plugins and apps
|
|
102
133
|
* subscribe with `runtime.onFramework(Event, handler)`; the runtime
|
|
@@ -113,7 +144,14 @@ export class Runtime {
|
|
|
113
144
|
this.publishToBus = options.publishToBus ?? false;
|
|
114
145
|
this.appName = options.appName ?? "app";
|
|
115
146
|
this.workflowTimerStore = options.workflowTimerStore ?? new InMemoryWorkflowTimerStore();
|
|
116
|
-
this.
|
|
147
|
+
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
|
+
});
|
|
117
155
|
// Bridge framework-event firings into the telemetry stream so every
|
|
118
156
|
// existing telemetry consumer (dev logger, Studio Live SSE, OTel
|
|
119
157
|
// exporter) sees lifecycle activity through the same channel as
|
|
@@ -132,6 +170,32 @@ export class Runtime {
|
|
|
132
170
|
ts: rec.ts,
|
|
133
171
|
});
|
|
134
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");
|
|
178
|
+
this.dispatchHook.use(async (hctx, next) => {
|
|
179
|
+
hctx.result = await hctx.coreFn();
|
|
180
|
+
await next();
|
|
181
|
+
}, { 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
|
+
});
|
|
135
199
|
}
|
|
136
200
|
/**
|
|
137
201
|
* Subscribe to a framework event. Sugar over `runtime.frameworkEvents.on`
|
|
@@ -149,6 +213,38 @@ export class Runtime {
|
|
|
149
213
|
registerPublicEvent(eventName) {
|
|
150
214
|
this.publicEventNames.add(eventName);
|
|
151
215
|
}
|
|
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
|
+
}
|
|
152
248
|
/** Internal — createApp registers actor-transition hooks from plugins. */
|
|
153
249
|
registerActorTransitionHook(hook) {
|
|
154
250
|
this.actorTransitionHooks.push(hook);
|
|
@@ -157,10 +253,27 @@ export class Runtime {
|
|
|
157
253
|
* Register a dispatch middleware. Outermost first — the order you call
|
|
158
254
|
* `use()` is the order layers wrap (first `use` is the outermost layer).
|
|
159
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.
|
|
160
260
|
*/
|
|
161
261
|
use(middleware) {
|
|
162
|
-
|
|
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 });
|
|
163
275
|
}
|
|
276
|
+
userMiddlewareCount = 0;
|
|
164
277
|
// ─── Registration ────────────────────────────────────────────────
|
|
165
278
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
279
|
registerHandler(handler) {
|
|
@@ -169,12 +282,76 @@ export class Runtime {
|
|
|
169
282
|
throw new Error(`Runtime: handler for action "${name}" already registered.`);
|
|
170
283
|
}
|
|
171
284
|
this.handlers.set(name, handler);
|
|
285
|
+
// Pre-create the per-action observation hooks so `listHooks()` + scan +
|
|
286
|
+
// Studio show every action's before/after extension points whether or
|
|
287
|
+
// not a plugin has subscribed yet. Lazy-on-first-subscribe would hide
|
|
288
|
+
// them from static introspection.
|
|
289
|
+
this.ensureActionBeforeHook(name);
|
|
290
|
+
this.ensureActionAfterHook(name);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Get (or lazily create + adopt) the per-action `action.before:<name>`
|
|
294
|
+
* hook. Plugin authors normally reach this via `plugin.before(name, …)`;
|
|
295
|
+
* this method is also public so test code + custom orchestrators can
|
|
296
|
+
* register chain steps or taps directly.
|
|
297
|
+
*/
|
|
298
|
+
ensureActionBeforeHook(actionName) {
|
|
299
|
+
let h = this.actionBeforeHooks.get(actionName);
|
|
300
|
+
if (h)
|
|
301
|
+
return h;
|
|
302
|
+
h = hook(`action.before:${actionName}`);
|
|
303
|
+
this.actionBeforeHooks.set(actionName, h);
|
|
304
|
+
this.adoptHook(h);
|
|
305
|
+
return h;
|
|
306
|
+
}
|
|
307
|
+
/** Get (or lazily create + adopt) the per-action `action.after:<name>` hook. */
|
|
308
|
+
ensureActionAfterHook(actionName) {
|
|
309
|
+
let h = this.actionAfterHooks.get(actionName);
|
|
310
|
+
if (h)
|
|
311
|
+
return h;
|
|
312
|
+
h = hook(`action.after:${actionName}`);
|
|
313
|
+
this.actionAfterHooks.set(actionName, h);
|
|
314
|
+
this.adoptHook(h);
|
|
315
|
+
return h;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Get (or lazily create + adopt) the per-actor `actor.transition:<name>`
|
|
319
|
+
* hook. Plugin authors reach this via `runtime.ensureActorTransitionHook(name)`
|
|
320
|
+
* to attach chain steps or taps; the dispatcher runs the hook after the
|
|
321
|
+
* actor's state has been saved.
|
|
322
|
+
*/
|
|
323
|
+
ensureActorTransitionHook(actorName) {
|
|
324
|
+
let h = this.perActorHooks.get(actorName);
|
|
325
|
+
if (h)
|
|
326
|
+
return h;
|
|
327
|
+
h = hook(`actor.transition:${actorName}`);
|
|
328
|
+
this.perActorHooks.set(actorName, h);
|
|
329
|
+
this.adoptHook(h);
|
|
330
|
+
return h;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Get (or lazily create + adopt) the per-workflow `workflow.fire:<name>`
|
|
334
|
+
* hook. Runs around every workflow invocation triggered by a subscribed
|
|
335
|
+
* event — see `runWorkflows`.
|
|
336
|
+
*/
|
|
337
|
+
ensureWorkflowFireHook(workflowName) {
|
|
338
|
+
let h = this.perWorkflowHooks.get(workflowName);
|
|
339
|
+
if (h)
|
|
340
|
+
return h;
|
|
341
|
+
h = hook(`workflow.fire:${workflowName}`);
|
|
342
|
+
this.perWorkflowHooks.set(workflowName, h);
|
|
343
|
+
this.adoptHook(h);
|
|
344
|
+
return h;
|
|
172
345
|
}
|
|
173
346
|
registerActor(actor) {
|
|
174
347
|
if (this.actors.has(actor.name)) {
|
|
175
348
|
throw new Error(`Runtime: actor "${actor.name}" already registered.`);
|
|
176
349
|
}
|
|
177
350
|
this.actors.set(actor.name, actor);
|
|
351
|
+
// Pre-create the observation hook so scan + listHooks() + Studio see
|
|
352
|
+
// every actor's transition extension point even before any plugin
|
|
353
|
+
// subscribes.
|
|
354
|
+
this.ensureActorTransitionHook(actor.name);
|
|
178
355
|
}
|
|
179
356
|
/** Internal — createApp registers each module's workflows. */
|
|
180
357
|
registerWorkflow(workflow) {
|
|
@@ -183,6 +360,9 @@ export class Runtime {
|
|
|
183
360
|
list.push(workflow);
|
|
184
361
|
this.workflowsByEvent.set(eventName, list);
|
|
185
362
|
}
|
|
363
|
+
// Same eager-creation rationale as registerActor — surface workflow
|
|
364
|
+
// fire extension points statically.
|
|
365
|
+
this.ensureWorkflowFireHook(workflow.name);
|
|
186
366
|
}
|
|
187
367
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
188
368
|
registerProjection(projection) {
|
|
@@ -305,10 +485,27 @@ export class Runtime {
|
|
|
305
485
|
throw new Error(`Runtime: no query registered with name "${queryName}".`);
|
|
306
486
|
}
|
|
307
487
|
const t0 = performance.now();
|
|
308
|
-
const validated = query.schema.parse(input);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
488
|
+
const validated = isValidated(input) ? input : markValidated(query.schema.parse(input));
|
|
489
|
+
let result;
|
|
490
|
+
if (query.projection && query.execute) {
|
|
491
|
+
// Projection form — load state, hand it to `execute`.
|
|
492
|
+
const state = (await this.projectionStore.load(query.projection.name, tenant)) ??
|
|
493
|
+
query.projection.initial();
|
|
494
|
+
result = (await query.execute(state, validated));
|
|
495
|
+
}
|
|
496
|
+
else if (query.handler) {
|
|
497
|
+
// Handler form — no projection, hand a QueryContext (DI + tenant +
|
|
498
|
+
// signal) to the user's reader. Lets queries read from any source
|
|
499
|
+
// (Postgres, Redis, search) without a projection layer.
|
|
500
|
+
result = (await query.handler(validated, {
|
|
501
|
+
resolve: (name) => this.container.resolve(name),
|
|
502
|
+
tenant,
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
throw new Error(`Runtime: query "${queryName}" has neither projection+execute nor a handler — ` +
|
|
507
|
+
`the runtime cannot route the call. defineQuery must be given one or the other.`);
|
|
508
|
+
}
|
|
312
509
|
this.emit({
|
|
313
510
|
kind: "query.executed",
|
|
314
511
|
query: queryName,
|
|
@@ -417,6 +614,12 @@ export class Runtime {
|
|
|
417
614
|
}
|
|
418
615
|
return fired;
|
|
419
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Resolve an action definition by its routing name. Used by execute/send
|
|
619
|
+
* to support the callable form `execute(myAction(input))` — the
|
|
620
|
+
* `CommandMessage` carries only the name; the runtime looks up the actual
|
|
621
|
+
* action from the registered handler map.
|
|
622
|
+
*/
|
|
420
623
|
findActionByName(name) {
|
|
421
624
|
const handler = this.handlers.get(name);
|
|
422
625
|
return handler?.action;
|
|
@@ -459,15 +662,26 @@ export class Runtime {
|
|
|
459
662
|
* Run an action through its handler, then atomically apply returned events.
|
|
460
663
|
* Returns the handler's raw return value (events) for callers that want it.
|
|
461
664
|
*/
|
|
462
|
-
async dispatch(action, input, parentEnvelope) {
|
|
665
|
+
async dispatch(action, input, parentEnvelope, opts) {
|
|
463
666
|
const handler = this.handlers.get(action.name);
|
|
464
667
|
if (!handler) {
|
|
465
668
|
throw new Error(`Runtime: no handler registered for action "${action.name}".`);
|
|
466
669
|
}
|
|
467
670
|
const envelope = parentEnvelope ? deriveEnvelope(parentEnvelope) : seedEnvelope({});
|
|
468
|
-
|
|
671
|
+
// Skip re-parse if a trust boundary already validated this input
|
|
672
|
+
// (HTTP `parseAndValidate`, queue worker consume, bus inbound).
|
|
673
|
+
// Untrusted call sites (raw object from application code) hit the
|
|
674
|
+
// parse path because the brand is missing. The brand is dropped by
|
|
675
|
+
// serialization, structural copy, and primitive coercion — so it
|
|
676
|
+
// never produces a false "trust me."
|
|
677
|
+
const validated = isValidated(input) ? input : markValidated(action.schema.parse(input));
|
|
469
678
|
const log = loggerForEnvelope(this.logger, envelope);
|
|
470
|
-
|
|
679
|
+
// Caller-side cancellation: every dispatch carries an AbortSignal on
|
|
680
|
+
// ctx. When the caller doesn't supply one, we mint a never-aborted
|
|
681
|
+
// controller so handler code can call `ctx.signal.throwIfAborted()`
|
|
682
|
+
// unconditionally — existing handlers behave identically.
|
|
683
|
+
const signal = opts?.signal ?? new AbortController().signal;
|
|
684
|
+
const ctx = this.buildHandlerContext(envelope, log, signal);
|
|
471
685
|
this.emit({
|
|
472
686
|
kind: "action.dispatched",
|
|
473
687
|
action: action.name,
|
|
@@ -489,8 +703,23 @@ export class Runtime {
|
|
|
489
703
|
if (!dispatchAllowed) {
|
|
490
704
|
return undefined;
|
|
491
705
|
}
|
|
706
|
+
// Per-action `action.before:<name>` hook. Mirrors the ActionDispatching
|
|
707
|
+
// veto semantics but routes through a named hook so the chain is visible
|
|
708
|
+
// in `listHooks()`, scan, and Studio. Chain steps registered via the
|
|
709
|
+
// `plugin.before(name, …)` sugar set `vetoed = true` when their handler
|
|
710
|
+
// returns false; that cleanly cancels the dispatch with no events.
|
|
711
|
+
const beforeHook = this.actionBeforeHooks.get(action.name);
|
|
712
|
+
if (beforeHook) {
|
|
713
|
+
const beforeCtx = { action, input: validated, ctx };
|
|
714
|
+
await beforeHook.run(beforeCtx);
|
|
715
|
+
if (beforeCtx.vetoed) {
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
492
719
|
// Core: retry loop + handler invocation + event publishing.
|
|
493
|
-
// Returns the raw handler return for the dispatcher to type-cast.
|
|
720
|
+
// Returns the raw handler return for the dispatcher to type-cast. The
|
|
721
|
+
// return union mirrors `HandlerReturn` — plain records (e.g. `{ userId }`)
|
|
722
|
+
// surface here untouched so the dispatcher can hand them back to callers.
|
|
494
723
|
const core = async () => {
|
|
495
724
|
const retry = action.retry;
|
|
496
725
|
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
@@ -498,6 +727,21 @@ export class Runtime {
|
|
|
498
727
|
let lastError;
|
|
499
728
|
while (attempt < maxAttempts) {
|
|
500
729
|
attempt++;
|
|
730
|
+
// Caller already gave up — skip remaining retries (and the DLQ
|
|
731
|
+
// entry that would follow exhaustion). The original error still
|
|
732
|
+
// surfaces to whatever is `await`ing the dispatch, but we don't
|
|
733
|
+
// spend retry budget on a result no one will read. We re-throw
|
|
734
|
+
// outside the inner try so the catch's failed/DLQ telemetry does
|
|
735
|
+
// not fire for the skipped attempts.
|
|
736
|
+
if (attempt > 1 && signal.aborted) {
|
|
737
|
+
log.warn(`abort observed between attempts; skipping retries`, {
|
|
738
|
+
action: action.name,
|
|
739
|
+
attempt,
|
|
740
|
+
maxAttempts,
|
|
741
|
+
});
|
|
742
|
+
// eslint-disable-next-line no-throw-literal
|
|
743
|
+
throw lastError;
|
|
744
|
+
}
|
|
501
745
|
try {
|
|
502
746
|
if (attempt > 1) {
|
|
503
747
|
const delay = computeBackoff(retry, attempt - 1);
|
|
@@ -525,6 +769,28 @@ export class Runtime {
|
|
|
525
769
|
appName: this.appName,
|
|
526
770
|
ts: new Date().toISOString(),
|
|
527
771
|
});
|
|
772
|
+
// Per-action `action.after:<name>` hook. Observation-only —
|
|
773
|
+
// chain steps registered by `plugin.after(name, …)` see the
|
|
774
|
+
// result + durationMs but can't undo. Awaited so `hook.step`
|
|
775
|
+
// taps land in telemetry before the action returns, the way
|
|
776
|
+
// the dispatch hook's taps already do.
|
|
777
|
+
const afterHook = this.actionAfterHooks.get(action.name);
|
|
778
|
+
if (afterHook) {
|
|
779
|
+
try {
|
|
780
|
+
await afterHook.run({
|
|
781
|
+
action,
|
|
782
|
+
input: validated,
|
|
783
|
+
result: rawResult ?? undefined,
|
|
784
|
+
durationMs,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
catch (err) {
|
|
788
|
+
log.error(`action.after hook threw`, {
|
|
789
|
+
action: action.name,
|
|
790
|
+
error: err?.message,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
528
794
|
// Framework event: ActionCompleted (parallel, observable).
|
|
529
795
|
// Don't await — observers shouldn't block the response path.
|
|
530
796
|
void this.frameworkEvents.fire(ActionCompleted, {
|
|
@@ -585,16 +851,13 @@ export class Runtime {
|
|
|
585
851
|
});
|
|
586
852
|
throw lastError;
|
|
587
853
|
};
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
const result = await pipeline();
|
|
597
|
-
return result;
|
|
854
|
+
// Run through the dispatch hook. Every user-registered middleware sits
|
|
855
|
+
// outside the pinned "handler" chain step (-Infinity); the hook calls
|
|
856
|
+
// `coreFn` in that innermost step and writes the result back on hctx.
|
|
857
|
+
// Taps fire per step into runtime.onTelemetry as `kind: "hook.step"`.
|
|
858
|
+
const hctx = { action, input: validated, ctx, coreFn: core };
|
|
859
|
+
await this.dispatchHook.run(hctx);
|
|
860
|
+
return hctx.result;
|
|
598
861
|
}
|
|
599
862
|
/**
|
|
600
863
|
* Apply a batch of events: route to actors (state transitions + assigns +
|
|
@@ -602,8 +865,30 @@ export class Runtime {
|
|
|
602
865
|
* by `dispatch` and exposed for tests and rare external publishes.
|
|
603
866
|
*/
|
|
604
867
|
async publish(events, parentEnvelope) {
|
|
605
|
-
for (
|
|
868
|
+
for (let i = 0; i < events.length; i++) {
|
|
869
|
+
const event = events[i];
|
|
870
|
+
// Envelope-level idempotency: short-circuit when we've already
|
|
871
|
+
// applied this exact `messageId`. The dedup key is the PARENT
|
|
872
|
+
// envelope's id — that's the identity a queue/bus driver carries
|
|
873
|
+
// through verbatim on redelivery, and the identity a caller
|
|
874
|
+
// can pin to make two publish() calls share a fate. When more than
|
|
875
|
+
// one event ships under the same parent envelope we tag the
|
|
876
|
+
// dedup key with the event index so each one applies on the
|
|
877
|
+
// first delivery and dedups on the second.
|
|
878
|
+
const dedupKey = events.length === 1 ? parentEnvelope.messageId : `${parentEnvelope.messageId}#${i}`;
|
|
606
879
|
const childEnvelope = deriveEnvelope(parentEnvelope);
|
|
880
|
+
if (await this.idempotencyStore.seen(dedupKey)) {
|
|
881
|
+
this.emit({
|
|
882
|
+
kind: "event.deduped",
|
|
883
|
+
event,
|
|
884
|
+
envelope: childEnvelope,
|
|
885
|
+
source: "in-process",
|
|
886
|
+
appName: this.appName,
|
|
887
|
+
ts: new Date().toISOString(),
|
|
888
|
+
});
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
await this.idempotencyStore.record(dedupKey);
|
|
607
892
|
// Ordering: actors → projections → workflows.
|
|
608
893
|
// - actors first: state must be coherent before observers see it.
|
|
609
894
|
// - projections second: workflows often read state via queries
|
|
@@ -688,6 +973,22 @@ export class Runtime {
|
|
|
688
973
|
throw new Error(`Runtime.applyExternalEvent: "${eventName}" was not declared in any module's needs.externalEvents.`);
|
|
689
974
|
}
|
|
690
975
|
const event = { eventName, payload };
|
|
976
|
+
// Bus inbound dedup mirrors the in-process publish path: a queue
|
|
977
|
+
// redrive or bus replay carries the same `envelope.messageId`,
|
|
978
|
+
// so an identical second delivery short-circuits without touching
|
|
979
|
+
// actor or projection state.
|
|
980
|
+
if (await this.idempotencyStore.seen(envelope.messageId)) {
|
|
981
|
+
this.emit({
|
|
982
|
+
kind: "event.deduped",
|
|
983
|
+
event,
|
|
984
|
+
envelope,
|
|
985
|
+
source: "external",
|
|
986
|
+
appName: this.appName,
|
|
987
|
+
ts: new Date().toISOString(),
|
|
988
|
+
});
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
await this.idempotencyStore.record(envelope.messageId);
|
|
691
992
|
await this.applyToActors(event, envelope);
|
|
692
993
|
await this.foldProjections(event, envelope);
|
|
693
994
|
await this.runWorkflows(event, envelope);
|
|
@@ -710,19 +1011,40 @@ export class Runtime {
|
|
|
710
1011
|
if (!reducer)
|
|
711
1012
|
continue;
|
|
712
1013
|
const t0 = performance.now();
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1014
|
+
try {
|
|
1015
|
+
const current = (await this.projectionStore.load(projection.name, tenant)) ?? projection.initial();
|
|
1016
|
+
const next = reducer(current, event.payload);
|
|
1017
|
+
await this.projectionStore.save(projection.name, next, tenant);
|
|
1018
|
+
this.emit({
|
|
1019
|
+
kind: "projection.folded",
|
|
1020
|
+
projection: projection.name,
|
|
1021
|
+
event: event.eventName,
|
|
1022
|
+
tenant,
|
|
1023
|
+
durationMs: performance.now() - t0,
|
|
1024
|
+
envelope,
|
|
1025
|
+
appName: this.appName,
|
|
1026
|
+
ts: new Date().toISOString(),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
// Fold failed — surface a first-class telemetry record so
|
|
1031
|
+
// observability can alarm on projection drift directly (gap 4).
|
|
1032
|
+
// We re-throw so the surrounding apply path still fails fast;
|
|
1033
|
+
// the only behavior change is that consumers no longer have to
|
|
1034
|
+
// deduce drift from missing `projection.folded` records.
|
|
1035
|
+
this.emit({
|
|
1036
|
+
kind: "projection.failed",
|
|
1037
|
+
projection: projection.name,
|
|
1038
|
+
event: event.eventName,
|
|
1039
|
+
tenant,
|
|
1040
|
+
durationMs: performance.now() - t0,
|
|
1041
|
+
error: serializeError(err),
|
|
1042
|
+
envelope,
|
|
1043
|
+
appName: this.appName,
|
|
1044
|
+
ts: new Date().toISOString(),
|
|
1045
|
+
});
|
|
1046
|
+
throw err;
|
|
1047
|
+
}
|
|
726
1048
|
}
|
|
727
1049
|
}
|
|
728
1050
|
// ─── Internal: actor dispatch ────────────────────────────────────
|
|
@@ -747,6 +1069,21 @@ export class Runtime {
|
|
|
747
1069
|
return payload[actor.key];
|
|
748
1070
|
}
|
|
749
1071
|
async applyEventToActor(actor, key, tenant, event, candidateReactions, envelope) {
|
|
1072
|
+
// Per-(actor, key, tenant) lock around the load → reduce → save
|
|
1073
|
+
// window. Without it, two concurrent dispatches racing for the same
|
|
1074
|
+
// actor key both observe the same pre-state and the second save
|
|
1075
|
+
// wins — silent invariant loss. Production adapters (Mongo, SQL)
|
|
1076
|
+
// should treat lockKey as a no-op and rely on row-level locks; the
|
|
1077
|
+
// in-memory store needs it explicitly.
|
|
1078
|
+
const release = (await this.actorStore.lockKey?.(actor.name, key, tenant)) ?? (() => { });
|
|
1079
|
+
try {
|
|
1080
|
+
await this.applyEventToActorLocked(actor, key, tenant, event, candidateReactions, envelope);
|
|
1081
|
+
}
|
|
1082
|
+
finally {
|
|
1083
|
+
release();
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async applyEventToActorLocked(actor, key, tenant, event, candidateReactions, envelope) {
|
|
750
1087
|
const existing = await this.actorStore.load(actor.name, key, tenant);
|
|
751
1088
|
const instance = existing ?? createInitialInstance(actor, key, tenant);
|
|
752
1089
|
const matching = candidateReactions.find((c) => c.state === instance.state);
|
|
@@ -813,6 +1150,31 @@ export class Runtime {
|
|
|
813
1150
|
await hook(actor, key, instance.state, nextStateName, event, envelope);
|
|
814
1151
|
}
|
|
815
1152
|
}
|
|
1153
|
+
// Per-actor `actor.transition:<name>` hook — named, observable in
|
|
1154
|
+
// listHooks(), tap-able by plugins via runtime.ensureActorTransitionHook.
|
|
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
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
catch (err) {
|
|
1171
|
+
loggerForEnvelope(this.logger, envelope).error(`actor.transition hook threw`, {
|
|
1172
|
+
actor: actor.name,
|
|
1173
|
+
error: err?.message,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
816
1178
|
}
|
|
817
1179
|
computeTimersForState(actor, stateName, actorKey) {
|
|
818
1180
|
const stateConfig = actor.states[stateName];
|
|
@@ -913,7 +1275,101 @@ export class Runtime {
|
|
|
913
1275
|
});
|
|
914
1276
|
},
|
|
915
1277
|
};
|
|
916
|
-
|
|
1278
|
+
// Per-workflow `workflow.fire:<name>` hook — observation-only.
|
|
1279
|
+
// Runs BEFORE the saga fires so chain steps see input + context;
|
|
1280
|
+
// failures here are logged, never block the saga. Chain steps
|
|
1281
|
+
// intending to gate workflow execution should subscribe to the
|
|
1282
|
+
// `WorkflowFiring` framework event (future surface) — the hook
|
|
1283
|
+
// is for telemetry + drift detection.
|
|
1284
|
+
const perWorkflowHook = this.perWorkflowHooks.get(workflow.name);
|
|
1285
|
+
if (perWorkflowHook) {
|
|
1286
|
+
try {
|
|
1287
|
+
await perWorkflowHook.run({
|
|
1288
|
+
workflow,
|
|
1289
|
+
event,
|
|
1290
|
+
envelope,
|
|
1291
|
+
correlationKey,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
catch (err) {
|
|
1295
|
+
log.error(`workflow.fire hook threw`, {
|
|
1296
|
+
workflow: workflow.name,
|
|
1297
|
+
error: err?.message,
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
// Honor the workflow's retry policy around `_fire`. Without a
|
|
1302
|
+
// declared policy, the historical contract holds: one attempt,
|
|
1303
|
+
// failure emits `reaction.failed` and re-raises. With a policy,
|
|
1304
|
+
// we retry per the same back-off math the action dispatch loop
|
|
1305
|
+
// uses; each failure emits `reaction.failed` with attempt info,
|
|
1306
|
+
// and the FINAL failure additionally emits `reaction.exhausted`
|
|
1307
|
+
// so alarms can fire on saga death distinctly from transient
|
|
1308
|
+
// drift.
|
|
1309
|
+
const retry = workflow.retry;
|
|
1310
|
+
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
1311
|
+
let attempt = 0;
|
|
1312
|
+
let lastError;
|
|
1313
|
+
let fired = false;
|
|
1314
|
+
let exhausted = false;
|
|
1315
|
+
while (attempt < maxAttempts) {
|
|
1316
|
+
attempt++;
|
|
1317
|
+
try {
|
|
1318
|
+
if (attempt > 1) {
|
|
1319
|
+
const delay = computeBackoff(retry, attempt - 1);
|
|
1320
|
+
if (delay > 0)
|
|
1321
|
+
await sleep(delay);
|
|
1322
|
+
}
|
|
1323
|
+
await workflow._fire(event, fireCtx);
|
|
1324
|
+
fired = true;
|
|
1325
|
+
break;
|
|
1326
|
+
}
|
|
1327
|
+
catch (err) {
|
|
1328
|
+
lastError = err;
|
|
1329
|
+
const willRetry = attempt < maxAttempts;
|
|
1330
|
+
this.emit({
|
|
1331
|
+
kind: "reaction.failed",
|
|
1332
|
+
sourceEvent: event.eventName,
|
|
1333
|
+
error: serializeError(err),
|
|
1334
|
+
envelope,
|
|
1335
|
+
appName: this.appName,
|
|
1336
|
+
ts: new Date().toISOString(),
|
|
1337
|
+
workflow: workflow.name,
|
|
1338
|
+
attempt,
|
|
1339
|
+
maxAttempts,
|
|
1340
|
+
willRetry,
|
|
1341
|
+
});
|
|
1342
|
+
if (!willRetry && retry) {
|
|
1343
|
+
// Only fire `reaction.exhausted` when retry was actually
|
|
1344
|
+
// declared — otherwise a one-shot failure (no policy) is
|
|
1345
|
+
// semantically just "the saga threw," not "the saga
|
|
1346
|
+
// burned through its retry budget." Alarms should target
|
|
1347
|
+
// the policy-aware signal.
|
|
1348
|
+
this.emit({
|
|
1349
|
+
kind: "reaction.exhausted",
|
|
1350
|
+
workflow: workflow.name,
|
|
1351
|
+
sourceEvent: event.eventName,
|
|
1352
|
+
attempts: attempt,
|
|
1353
|
+
error: serializeError(err),
|
|
1354
|
+
envelope,
|
|
1355
|
+
appName: this.appName,
|
|
1356
|
+
ts: new Date().toISOString(),
|
|
1357
|
+
});
|
|
1358
|
+
exhausted = true;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (!fired) {
|
|
1363
|
+
// Mark so the outer catch knows the loop already emitted
|
|
1364
|
+
// `reaction.failed` for this failure — avoids double-emit.
|
|
1365
|
+
// `exhausted` is unused for marking; the loop emits one
|
|
1366
|
+
// `reaction.failed` per attempt regardless of policy.
|
|
1367
|
+
void exhausted;
|
|
1368
|
+
if (lastError && typeof lastError === "object") {
|
|
1369
|
+
lastError.__nwireWorkflowEmitted = true;
|
|
1370
|
+
}
|
|
1371
|
+
throw lastError;
|
|
1372
|
+
}
|
|
917
1373
|
this.emit({
|
|
918
1374
|
kind: "reaction.fired",
|
|
919
1375
|
sourceEvent: event.eventName,
|
|
@@ -924,26 +1380,40 @@ export class Runtime {
|
|
|
924
1380
|
});
|
|
925
1381
|
}
|
|
926
1382
|
catch (err) {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1383
|
+
// The retry loop above already emitted `reaction.failed` for
|
|
1384
|
+
// every saga-body failure. If we see one tagged with the
|
|
1385
|
+
// internal marker, the loop owns the telemetry — skip the
|
|
1386
|
+
// duplicate emission here. Anything untagged (correlate() throws,
|
|
1387
|
+
// hook bug, etc.) lands as a single bare `reaction.failed`.
|
|
1388
|
+
if (!err?.__nwireWorkflowEmitted) {
|
|
1389
|
+
this.emit({
|
|
1390
|
+
kind: "reaction.failed",
|
|
1391
|
+
sourceEvent: event.eventName,
|
|
1392
|
+
error: serializeError(err),
|
|
1393
|
+
envelope,
|
|
1394
|
+
appName: this.appName,
|
|
1395
|
+
ts: new Date().toISOString(),
|
|
1396
|
+
workflow: workflow.name,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
935
1399
|
throw err;
|
|
936
1400
|
}
|
|
937
1401
|
}
|
|
938
1402
|
}
|
|
939
1403
|
// ─── Internal: handler context ───────────────────────────────────
|
|
940
|
-
buildHandlerContext(envelope, log) {
|
|
1404
|
+
buildHandlerContext(envelope, log, signal) {
|
|
941
1405
|
const self = this;
|
|
942
1406
|
const logger = log ?? loggerForEnvelope(self.logger, envelope);
|
|
1407
|
+
// Child dispatches inherit the parent's signal so a caller-side abort
|
|
1408
|
+
// travels through every nested `ctx.request(...)`. The runtime never
|
|
1409
|
+
// mutates the incoming signal — handlers observe; the wire owns the
|
|
1410
|
+
// controller.
|
|
1411
|
+
const ctxSignal = signal ?? new AbortController().signal;
|
|
943
1412
|
const ctx = {
|
|
944
1413
|
container: self.container,
|
|
945
1414
|
envelope,
|
|
946
1415
|
logger,
|
|
1416
|
+
signal: ctxSignal,
|
|
947
1417
|
resolve(name) {
|
|
948
1418
|
return self.container.resolve(name);
|
|
949
1419
|
},
|
|
@@ -951,7 +1421,7 @@ export class Runtime {
|
|
|
951
1421
|
return envelope.messageId;
|
|
952
1422
|
},
|
|
953
1423
|
async request(action, input) {
|
|
954
|
-
const result = await self.dispatch(action, input, envelope);
|
|
1424
|
+
const result = await self.dispatch(action, input, envelope, { signal: ctxSignal });
|
|
955
1425
|
return result;
|
|
956
1426
|
},
|
|
957
1427
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -963,7 +1433,7 @@ export class Runtime {
|
|
|
963
1433
|
async send(action, input) {
|
|
964
1434
|
// For now, send is identical to request but result is ignored.
|
|
965
1435
|
// Real fire-and-forget arrives with the queue transport.
|
|
966
|
-
await self.dispatch(action, input, envelope);
|
|
1436
|
+
await self.dispatch(action, input, envelope, { signal: ctxSignal });
|
|
967
1437
|
},
|
|
968
1438
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
969
1439
|
async use(actor, id) {
|