@nwire/forge 0.7.1 → 0.8.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 +88 -59
- 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 +104 -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-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/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 +7 -0
- package/dist/create-app.d.ts.map +1 -1
- package/dist/create-app.js +101 -10
- package/dist/create-app.js.map +1 -1
- package/dist/define-action.d.ts +4 -2
- package/dist/define-action.d.ts.map +1 -1
- package/dist/define-action.js +9 -6
- 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 +21 -2
- package/dist/define-handler.d.ts.map +1 -1
- package/dist/define-handler.js +3 -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 +4 -1
- package/dist/define-plugin.d.ts.map +1 -1
- package/dist/define-plugin.js +34 -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 +3 -1
- package/dist/define-query.d.ts.map +1 -1
- package/dist/define-query.js +2 -0
- 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 +19 -1
- package/dist/dev-logger.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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +197 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +495 -44
- 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,7 +485,9 @@ 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 =
|
|
488
|
+
const validated = isValidated(input)
|
|
489
|
+
? input
|
|
490
|
+
: markValidated(query.schema.parse(input));
|
|
309
491
|
const state = (await this.projectionStore.load(query.projection.name, tenant)) ??
|
|
310
492
|
query.projection.initial();
|
|
311
493
|
const result = (await query.execute(state, validated));
|
|
@@ -459,15 +641,28 @@ export class Runtime {
|
|
|
459
641
|
* Run an action through its handler, then atomically apply returned events.
|
|
460
642
|
* Returns the handler's raw return value (events) for callers that want it.
|
|
461
643
|
*/
|
|
462
|
-
async dispatch(action, input, parentEnvelope) {
|
|
644
|
+
async dispatch(action, input, parentEnvelope, opts) {
|
|
463
645
|
const handler = this.handlers.get(action.name);
|
|
464
646
|
if (!handler) {
|
|
465
647
|
throw new Error(`Runtime: no handler registered for action "${action.name}".`);
|
|
466
648
|
}
|
|
467
649
|
const envelope = parentEnvelope ? deriveEnvelope(parentEnvelope) : seedEnvelope({});
|
|
468
|
-
|
|
650
|
+
// Skip re-parse if a trust boundary already validated this input
|
|
651
|
+
// (HTTP `parseAndValidate`, queue worker consume, bus inbound).
|
|
652
|
+
// Untrusted call sites (raw object from application code) hit the
|
|
653
|
+
// parse path because the brand is missing. The brand is dropped by
|
|
654
|
+
// serialization, structural copy, and primitive coercion — so it
|
|
655
|
+
// never produces a false "trust me."
|
|
656
|
+
const validated = isValidated(input)
|
|
657
|
+
? input
|
|
658
|
+
: markValidated(action.schema.parse(input));
|
|
469
659
|
const log = loggerForEnvelope(this.logger, envelope);
|
|
470
|
-
|
|
660
|
+
// Caller-side cancellation: every dispatch carries an AbortSignal on
|
|
661
|
+
// ctx. When the caller doesn't supply one, we mint a never-aborted
|
|
662
|
+
// controller so handler code can call `ctx.signal.throwIfAborted()`
|
|
663
|
+
// unconditionally — existing handlers behave identically.
|
|
664
|
+
const signal = opts?.signal ?? new AbortController().signal;
|
|
665
|
+
const ctx = this.buildHandlerContext(envelope, log, signal);
|
|
471
666
|
this.emit({
|
|
472
667
|
kind: "action.dispatched",
|
|
473
668
|
action: action.name,
|
|
@@ -489,6 +684,19 @@ export class Runtime {
|
|
|
489
684
|
if (!dispatchAllowed) {
|
|
490
685
|
return undefined;
|
|
491
686
|
}
|
|
687
|
+
// Per-action `action.before:<name>` hook. Mirrors the ActionDispatching
|
|
688
|
+
// veto semantics but routes through a named hook so the chain is visible
|
|
689
|
+
// in `listHooks()`, scan, and Studio. Chain steps registered via the
|
|
690
|
+
// `plugin.before(name, …)` sugar set `vetoed = true` when their handler
|
|
691
|
+
// returns false; that cleanly cancels the dispatch with no events.
|
|
692
|
+
const beforeHook = this.actionBeforeHooks.get(action.name);
|
|
693
|
+
if (beforeHook) {
|
|
694
|
+
const beforeCtx = { action, input: validated, ctx };
|
|
695
|
+
await beforeHook.run(beforeCtx);
|
|
696
|
+
if (beforeCtx.vetoed) {
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
492
700
|
// Core: retry loop + handler invocation + event publishing.
|
|
493
701
|
// Returns the raw handler return for the dispatcher to type-cast.
|
|
494
702
|
const core = async () => {
|
|
@@ -498,6 +706,21 @@ export class Runtime {
|
|
|
498
706
|
let lastError;
|
|
499
707
|
while (attempt < maxAttempts) {
|
|
500
708
|
attempt++;
|
|
709
|
+
// Caller already gave up — skip remaining retries (and the DLQ
|
|
710
|
+
// entry that would follow exhaustion). The original error still
|
|
711
|
+
// surfaces to whatever is `await`ing the dispatch, but we don't
|
|
712
|
+
// spend retry budget on a result no one will read. We re-throw
|
|
713
|
+
// outside the inner try so the catch's failed/DLQ telemetry does
|
|
714
|
+
// not fire for the skipped attempts.
|
|
715
|
+
if (attempt > 1 && signal.aborted) {
|
|
716
|
+
log.warn(`abort observed between attempts; skipping retries`, {
|
|
717
|
+
action: action.name,
|
|
718
|
+
attempt,
|
|
719
|
+
maxAttempts,
|
|
720
|
+
});
|
|
721
|
+
// eslint-disable-next-line no-throw-literal
|
|
722
|
+
throw lastError;
|
|
723
|
+
}
|
|
501
724
|
try {
|
|
502
725
|
if (attempt > 1) {
|
|
503
726
|
const delay = computeBackoff(retry, attempt - 1);
|
|
@@ -525,6 +748,28 @@ export class Runtime {
|
|
|
525
748
|
appName: this.appName,
|
|
526
749
|
ts: new Date().toISOString(),
|
|
527
750
|
});
|
|
751
|
+
// Per-action `action.after:<name>` hook. Observation-only —
|
|
752
|
+
// chain steps registered by `plugin.after(name, …)` see the
|
|
753
|
+
// result + durationMs but can't undo. Awaited so `hook.step`
|
|
754
|
+
// taps land in telemetry before the action returns, the way
|
|
755
|
+
// the dispatch hook's taps already do.
|
|
756
|
+
const afterHook = this.actionAfterHooks.get(action.name);
|
|
757
|
+
if (afterHook) {
|
|
758
|
+
try {
|
|
759
|
+
await afterHook.run({
|
|
760
|
+
action,
|
|
761
|
+
input: validated,
|
|
762
|
+
result: rawResult ?? undefined,
|
|
763
|
+
durationMs,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
log.error(`action.after hook threw`, {
|
|
768
|
+
action: action.name,
|
|
769
|
+
error: err?.message,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
528
773
|
// Framework event: ActionCompleted (parallel, observable).
|
|
529
774
|
// Don't await — observers shouldn't block the response path.
|
|
530
775
|
void this.frameworkEvents.fire(ActionCompleted, {
|
|
@@ -585,16 +830,13 @@ export class Runtime {
|
|
|
585
830
|
});
|
|
586
831
|
throw lastError;
|
|
587
832
|
};
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
const result = await pipeline();
|
|
597
|
-
return result;
|
|
833
|
+
// Run through the dispatch hook. Every user-registered middleware sits
|
|
834
|
+
// outside the pinned "handler" chain step (-Infinity); the hook calls
|
|
835
|
+
// `coreFn` in that innermost step and writes the result back on hctx.
|
|
836
|
+
// Taps fire per step into runtime.onTelemetry as `kind: "hook.step"`.
|
|
837
|
+
const hctx = { action, input: validated, ctx, coreFn: core };
|
|
838
|
+
await this.dispatchHook.run(hctx);
|
|
839
|
+
return hctx.result;
|
|
598
840
|
}
|
|
599
841
|
/**
|
|
600
842
|
* Apply a batch of events: route to actors (state transitions + assigns +
|
|
@@ -602,8 +844,32 @@ export class Runtime {
|
|
|
602
844
|
* by `dispatch` and exposed for tests and rare external publishes.
|
|
603
845
|
*/
|
|
604
846
|
async publish(events, parentEnvelope) {
|
|
605
|
-
for (
|
|
847
|
+
for (let i = 0; i < events.length; i++) {
|
|
848
|
+
const event = events[i];
|
|
849
|
+
// Envelope-level idempotency: short-circuit when we've already
|
|
850
|
+
// applied this exact `messageId`. The dedup key is the PARENT
|
|
851
|
+
// envelope's id — that's the identity a queue/bus driver carries
|
|
852
|
+
// through verbatim on redelivery, and the identity a caller
|
|
853
|
+
// can pin to make two publish() calls share a fate. When more than
|
|
854
|
+
// one event ships under the same parent envelope we tag the
|
|
855
|
+
// dedup key with the event index so each one applies on the
|
|
856
|
+
// first delivery and dedups on the second.
|
|
857
|
+
const dedupKey = events.length === 1
|
|
858
|
+
? parentEnvelope.messageId
|
|
859
|
+
: `${parentEnvelope.messageId}#${i}`;
|
|
606
860
|
const childEnvelope = deriveEnvelope(parentEnvelope);
|
|
861
|
+
if (await this.idempotencyStore.seen(dedupKey)) {
|
|
862
|
+
this.emit({
|
|
863
|
+
kind: "event.deduped",
|
|
864
|
+
event,
|
|
865
|
+
envelope: childEnvelope,
|
|
866
|
+
source: "in-process",
|
|
867
|
+
appName: this.appName,
|
|
868
|
+
ts: new Date().toISOString(),
|
|
869
|
+
});
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
await this.idempotencyStore.record(dedupKey);
|
|
607
873
|
// Ordering: actors → projections → workflows.
|
|
608
874
|
// - actors first: state must be coherent before observers see it.
|
|
609
875
|
// - projections second: workflows often read state via queries
|
|
@@ -688,6 +954,22 @@ export class Runtime {
|
|
|
688
954
|
throw new Error(`Runtime.applyExternalEvent: "${eventName}" was not declared in any module's needs.externalEvents.`);
|
|
689
955
|
}
|
|
690
956
|
const event = { eventName, payload };
|
|
957
|
+
// Bus inbound dedup mirrors the in-process publish path: a queue
|
|
958
|
+
// redrive or bus replay carries the same `envelope.messageId`,
|
|
959
|
+
// so an identical second delivery short-circuits without touching
|
|
960
|
+
// actor or projection state.
|
|
961
|
+
if (await this.idempotencyStore.seen(envelope.messageId)) {
|
|
962
|
+
this.emit({
|
|
963
|
+
kind: "event.deduped",
|
|
964
|
+
event,
|
|
965
|
+
envelope,
|
|
966
|
+
source: "external",
|
|
967
|
+
appName: this.appName,
|
|
968
|
+
ts: new Date().toISOString(),
|
|
969
|
+
});
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
await this.idempotencyStore.record(envelope.messageId);
|
|
691
973
|
await this.applyToActors(event, envelope);
|
|
692
974
|
await this.foldProjections(event, envelope);
|
|
693
975
|
await this.runWorkflows(event, envelope);
|
|
@@ -710,19 +992,40 @@ export class Runtime {
|
|
|
710
992
|
if (!reducer)
|
|
711
993
|
continue;
|
|
712
994
|
const t0 = performance.now();
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
995
|
+
try {
|
|
996
|
+
const current = (await this.projectionStore.load(projection.name, tenant)) ?? projection.initial();
|
|
997
|
+
const next = reducer(current, event.payload);
|
|
998
|
+
await this.projectionStore.save(projection.name, next, tenant);
|
|
999
|
+
this.emit({
|
|
1000
|
+
kind: "projection.folded",
|
|
1001
|
+
projection: projection.name,
|
|
1002
|
+
event: event.eventName,
|
|
1003
|
+
tenant,
|
|
1004
|
+
durationMs: performance.now() - t0,
|
|
1005
|
+
envelope,
|
|
1006
|
+
appName: this.appName,
|
|
1007
|
+
ts: new Date().toISOString(),
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
catch (err) {
|
|
1011
|
+
// Fold failed — surface a first-class telemetry record so
|
|
1012
|
+
// observability can alarm on projection drift directly (gap 4).
|
|
1013
|
+
// We re-throw so the surrounding apply path still fails fast;
|
|
1014
|
+
// the only behavior change is that consumers no longer have to
|
|
1015
|
+
// deduce drift from missing `projection.folded` records.
|
|
1016
|
+
this.emit({
|
|
1017
|
+
kind: "projection.failed",
|
|
1018
|
+
projection: projection.name,
|
|
1019
|
+
event: event.eventName,
|
|
1020
|
+
tenant,
|
|
1021
|
+
durationMs: performance.now() - t0,
|
|
1022
|
+
error: serializeError(err),
|
|
1023
|
+
envelope,
|
|
1024
|
+
appName: this.appName,
|
|
1025
|
+
ts: new Date().toISOString(),
|
|
1026
|
+
});
|
|
1027
|
+
throw err;
|
|
1028
|
+
}
|
|
726
1029
|
}
|
|
727
1030
|
}
|
|
728
1031
|
// ─── Internal: actor dispatch ────────────────────────────────────
|
|
@@ -747,6 +1050,21 @@ export class Runtime {
|
|
|
747
1050
|
return payload[actor.key];
|
|
748
1051
|
}
|
|
749
1052
|
async applyEventToActor(actor, key, tenant, event, candidateReactions, envelope) {
|
|
1053
|
+
// Per-(actor, key, tenant) lock around the load → reduce → save
|
|
1054
|
+
// window. Without it, two concurrent dispatches racing for the same
|
|
1055
|
+
// actor key both observe the same pre-state and the second save
|
|
1056
|
+
// wins — silent invariant loss. Production adapters (Mongo, SQL)
|
|
1057
|
+
// should treat lockKey as a no-op and rely on row-level locks; the
|
|
1058
|
+
// in-memory store needs it explicitly.
|
|
1059
|
+
const release = (await this.actorStore.lockKey?.(actor.name, key, tenant)) ?? (() => { });
|
|
1060
|
+
try {
|
|
1061
|
+
await this.applyEventToActorLocked(actor, key, tenant, event, candidateReactions, envelope);
|
|
1062
|
+
}
|
|
1063
|
+
finally {
|
|
1064
|
+
release();
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
async applyEventToActorLocked(actor, key, tenant, event, candidateReactions, envelope) {
|
|
750
1068
|
const existing = await this.actorStore.load(actor.name, key, tenant);
|
|
751
1069
|
const instance = existing ?? createInitialInstance(actor, key, tenant);
|
|
752
1070
|
const matching = candidateReactions.find((c) => c.state === instance.state);
|
|
@@ -813,6 +1131,31 @@ export class Runtime {
|
|
|
813
1131
|
await hook(actor, key, instance.state, nextStateName, event, envelope);
|
|
814
1132
|
}
|
|
815
1133
|
}
|
|
1134
|
+
// Per-actor `actor.transition:<name>` hook — named, observable in
|
|
1135
|
+
// listHooks(), tap-able by plugins via runtime.ensureActorTransitionHook.
|
|
1136
|
+
// Runs only on actual transitions (state changed) so observers don't
|
|
1137
|
+
// see no-op events.
|
|
1138
|
+
if (stateChanged) {
|
|
1139
|
+
const perActorHook = this.perActorHooks.get(actor.name);
|
|
1140
|
+
if (perActorHook) {
|
|
1141
|
+
try {
|
|
1142
|
+
await perActorHook.run({
|
|
1143
|
+
actor,
|
|
1144
|
+
key,
|
|
1145
|
+
fromState: instance.state,
|
|
1146
|
+
toState: nextStateName,
|
|
1147
|
+
triggeringEvent: event,
|
|
1148
|
+
envelope,
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
catch (err) {
|
|
1152
|
+
loggerForEnvelope(this.logger, envelope).error(`actor.transition hook threw`, {
|
|
1153
|
+
actor: actor.name,
|
|
1154
|
+
error: err?.message,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
816
1159
|
}
|
|
817
1160
|
computeTimersForState(actor, stateName, actorKey) {
|
|
818
1161
|
const stateConfig = actor.states[stateName];
|
|
@@ -913,7 +1256,101 @@ export class Runtime {
|
|
|
913
1256
|
});
|
|
914
1257
|
},
|
|
915
1258
|
};
|
|
916
|
-
|
|
1259
|
+
// Per-workflow `workflow.fire:<name>` hook — observation-only.
|
|
1260
|
+
// Runs BEFORE the saga fires so chain steps see input + context;
|
|
1261
|
+
// failures here are logged, never block the saga. Chain steps
|
|
1262
|
+
// intending to gate workflow execution should subscribe to the
|
|
1263
|
+
// `WorkflowFiring` framework event (future surface) — the hook
|
|
1264
|
+
// is for telemetry + drift detection.
|
|
1265
|
+
const perWorkflowHook = this.perWorkflowHooks.get(workflow.name);
|
|
1266
|
+
if (perWorkflowHook) {
|
|
1267
|
+
try {
|
|
1268
|
+
await perWorkflowHook.run({
|
|
1269
|
+
workflow,
|
|
1270
|
+
event,
|
|
1271
|
+
envelope,
|
|
1272
|
+
correlationKey,
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
catch (err) {
|
|
1276
|
+
log.error(`workflow.fire hook threw`, {
|
|
1277
|
+
workflow: workflow.name,
|
|
1278
|
+
error: err?.message,
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
// Honor the workflow's retry policy around `_fire`. Without a
|
|
1283
|
+
// declared policy, the historical contract holds: one attempt,
|
|
1284
|
+
// failure emits `reaction.failed` and re-raises. With a policy,
|
|
1285
|
+
// we retry per the same back-off math the action dispatch loop
|
|
1286
|
+
// uses; each failure emits `reaction.failed` with attempt info,
|
|
1287
|
+
// and the FINAL failure additionally emits `reaction.exhausted`
|
|
1288
|
+
// so alarms can fire on saga death distinctly from transient
|
|
1289
|
+
// drift.
|
|
1290
|
+
const retry = workflow.retry;
|
|
1291
|
+
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
1292
|
+
let attempt = 0;
|
|
1293
|
+
let lastError;
|
|
1294
|
+
let fired = false;
|
|
1295
|
+
let exhausted = false;
|
|
1296
|
+
while (attempt < maxAttempts) {
|
|
1297
|
+
attempt++;
|
|
1298
|
+
try {
|
|
1299
|
+
if (attempt > 1) {
|
|
1300
|
+
const delay = computeBackoff(retry, attempt - 1);
|
|
1301
|
+
if (delay > 0)
|
|
1302
|
+
await sleep(delay);
|
|
1303
|
+
}
|
|
1304
|
+
await workflow._fire(event, fireCtx);
|
|
1305
|
+
fired = true;
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
catch (err) {
|
|
1309
|
+
lastError = err;
|
|
1310
|
+
const willRetry = attempt < maxAttempts;
|
|
1311
|
+
this.emit({
|
|
1312
|
+
kind: "reaction.failed",
|
|
1313
|
+
sourceEvent: event.eventName,
|
|
1314
|
+
error: serializeError(err),
|
|
1315
|
+
envelope,
|
|
1316
|
+
appName: this.appName,
|
|
1317
|
+
ts: new Date().toISOString(),
|
|
1318
|
+
workflow: workflow.name,
|
|
1319
|
+
attempt,
|
|
1320
|
+
maxAttempts,
|
|
1321
|
+
willRetry,
|
|
1322
|
+
});
|
|
1323
|
+
if (!willRetry && retry) {
|
|
1324
|
+
// Only fire `reaction.exhausted` when retry was actually
|
|
1325
|
+
// declared — otherwise a one-shot failure (no policy) is
|
|
1326
|
+
// semantically just "the saga threw," not "the saga
|
|
1327
|
+
// burned through its retry budget." Alarms should target
|
|
1328
|
+
// the policy-aware signal.
|
|
1329
|
+
this.emit({
|
|
1330
|
+
kind: "reaction.exhausted",
|
|
1331
|
+
workflow: workflow.name,
|
|
1332
|
+
sourceEvent: event.eventName,
|
|
1333
|
+
attempts: attempt,
|
|
1334
|
+
error: serializeError(err),
|
|
1335
|
+
envelope,
|
|
1336
|
+
appName: this.appName,
|
|
1337
|
+
ts: new Date().toISOString(),
|
|
1338
|
+
});
|
|
1339
|
+
exhausted = true;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (!fired) {
|
|
1344
|
+
// Mark so the outer catch knows the loop already emitted
|
|
1345
|
+
// `reaction.failed` for this failure — avoids double-emit.
|
|
1346
|
+
// `exhausted` is unused for marking; the loop emits one
|
|
1347
|
+
// `reaction.failed` per attempt regardless of policy.
|
|
1348
|
+
void exhausted;
|
|
1349
|
+
if (lastError && typeof lastError === "object") {
|
|
1350
|
+
lastError.__nwireWorkflowEmitted = true;
|
|
1351
|
+
}
|
|
1352
|
+
throw lastError;
|
|
1353
|
+
}
|
|
917
1354
|
this.emit({
|
|
918
1355
|
kind: "reaction.fired",
|
|
919
1356
|
sourceEvent: event.eventName,
|
|
@@ -924,26 +1361,40 @@ export class Runtime {
|
|
|
924
1361
|
});
|
|
925
1362
|
}
|
|
926
1363
|
catch (err) {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1364
|
+
// The retry loop above already emitted `reaction.failed` for
|
|
1365
|
+
// every saga-body failure. If we see one tagged with the
|
|
1366
|
+
// internal marker, the loop owns the telemetry — skip the
|
|
1367
|
+
// duplicate emission here. Anything untagged (correlate() throws,
|
|
1368
|
+
// hook bug, etc.) lands as a single bare `reaction.failed`.
|
|
1369
|
+
if (!err?.__nwireWorkflowEmitted) {
|
|
1370
|
+
this.emit({
|
|
1371
|
+
kind: "reaction.failed",
|
|
1372
|
+
sourceEvent: event.eventName,
|
|
1373
|
+
error: serializeError(err),
|
|
1374
|
+
envelope,
|
|
1375
|
+
appName: this.appName,
|
|
1376
|
+
ts: new Date().toISOString(),
|
|
1377
|
+
workflow: workflow.name,
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
935
1380
|
throw err;
|
|
936
1381
|
}
|
|
937
1382
|
}
|
|
938
1383
|
}
|
|
939
1384
|
// ─── Internal: handler context ───────────────────────────────────
|
|
940
|
-
buildHandlerContext(envelope, log) {
|
|
1385
|
+
buildHandlerContext(envelope, log, signal) {
|
|
941
1386
|
const self = this;
|
|
942
1387
|
const logger = log ?? loggerForEnvelope(self.logger, envelope);
|
|
1388
|
+
// Child dispatches inherit the parent's signal so a caller-side abort
|
|
1389
|
+
// travels through every nested `ctx.request(...)`. The runtime never
|
|
1390
|
+
// mutates the incoming signal — handlers observe; the wire owns the
|
|
1391
|
+
// controller.
|
|
1392
|
+
const ctxSignal = signal ?? new AbortController().signal;
|
|
943
1393
|
const ctx = {
|
|
944
1394
|
container: self.container,
|
|
945
1395
|
envelope,
|
|
946
1396
|
logger,
|
|
1397
|
+
signal: ctxSignal,
|
|
947
1398
|
resolve(name) {
|
|
948
1399
|
return self.container.resolve(name);
|
|
949
1400
|
},
|
|
@@ -951,7 +1402,7 @@ export class Runtime {
|
|
|
951
1402
|
return envelope.messageId;
|
|
952
1403
|
},
|
|
953
1404
|
async request(action, input) {
|
|
954
|
-
const result = await self.dispatch(action, input, envelope);
|
|
1405
|
+
const result = await self.dispatch(action, input, envelope, { signal: ctxSignal });
|
|
955
1406
|
return result;
|
|
956
1407
|
},
|
|
957
1408
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -963,7 +1414,7 @@ export class Runtime {
|
|
|
963
1414
|
async send(action, input) {
|
|
964
1415
|
// For now, send is identical to request but result is ignored.
|
|
965
1416
|
// Real fire-and-forget arrives with the queue transport.
|
|
966
|
-
await self.dispatch(action, input, envelope);
|
|
1417
|
+
await self.dispatch(action, input, envelope, { signal: ctxSignal });
|
|
967
1418
|
},
|
|
968
1419
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
969
1420
|
async use(actor, id) {
|