@nwire/forge 0.7.0 → 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.
Files changed (75) hide show
  1. package/README.md +104 -48
  2. package/dist/__tests__/action-hooks.test.d.ts +8 -0
  3. package/dist/__tests__/action-hooks.test.d.ts.map +1 -0
  4. package/dist/__tests__/action-hooks.test.js +95 -0
  5. package/dist/__tests__/action-hooks.test.js.map +1 -0
  6. package/dist/__tests__/actor-workflow-hooks.test.d.ts +8 -0
  7. package/dist/__tests__/actor-workflow-hooks.test.d.ts.map +1 -0
  8. package/dist/__tests__/actor-workflow-hooks.test.js +104 -0
  9. package/dist/__tests__/actor-workflow-hooks.test.js.map +1 -0
  10. package/dist/__tests__/lifecycle-logging.test.js +4 -2
  11. package/dist/__tests__/lifecycle-logging.test.js.map +1 -1
  12. package/dist/__tests__/plugin-stress.test.d.ts +21 -0
  13. package/dist/__tests__/plugin-stress.test.d.ts.map +1 -0
  14. package/dist/__tests__/plugin-stress.test.js +203 -0
  15. package/dist/__tests__/plugin-stress.test.js.map +1 -0
  16. package/dist/actor-store.d.ts +24 -0
  17. package/dist/actor-store.d.ts.map +1 -1
  18. package/dist/actor-store.js +29 -0
  19. package/dist/actor-store.js.map +1 -1
  20. package/dist/create-app.d.ts +7 -0
  21. package/dist/create-app.d.ts.map +1 -1
  22. package/dist/create-app.js +101 -10
  23. package/dist/create-app.js.map +1 -1
  24. package/dist/define-action.d.ts +4 -2
  25. package/dist/define-action.d.ts.map +1 -1
  26. package/dist/define-action.js +9 -6
  27. package/dist/define-action.js.map +1 -1
  28. package/dist/define-actor.d.ts +3 -1
  29. package/dist/define-actor.d.ts.map +1 -1
  30. package/dist/define-actor.js +11 -4
  31. package/dist/define-actor.js.map +1 -1
  32. package/dist/define-handler.d.ts +21 -2
  33. package/dist/define-handler.d.ts.map +1 -1
  34. package/dist/define-handler.js +3 -1
  35. package/dist/define-handler.js.map +1 -1
  36. package/dist/define-module.d.ts +3 -0
  37. package/dist/define-module.d.ts.map +1 -1
  38. package/dist/define-module.js +3 -0
  39. package/dist/define-module.js.map +1 -1
  40. package/dist/define-plugin.d.ts +4 -1
  41. package/dist/define-plugin.d.ts.map +1 -1
  42. package/dist/define-plugin.js +34 -14
  43. package/dist/define-plugin.js.map +1 -1
  44. package/dist/define-projection.d.ts +3 -1
  45. package/dist/define-projection.d.ts.map +1 -1
  46. package/dist/define-projection.js +3 -0
  47. package/dist/define-projection.js.map +1 -1
  48. package/dist/define-query.d.ts +3 -1
  49. package/dist/define-query.d.ts.map +1 -1
  50. package/dist/define-query.js +2 -0
  51. package/dist/define-query.js.map +1 -1
  52. package/dist/define-workflow.d.ts +19 -1
  53. package/dist/define-workflow.d.ts.map +1 -1
  54. package/dist/define-workflow.js +4 -0
  55. package/dist/define-workflow.js.map +1 -1
  56. package/dist/dev-logger.d.ts.map +1 -1
  57. package/dist/dev-logger.js +19 -1
  58. package/dist/dev-logger.js.map +1 -1
  59. package/dist/framework-events.d.ts +1 -64
  60. package/dist/framework-events.d.ts.map +1 -1
  61. package/dist/framework-events.js +3 -0
  62. package/dist/framework-events.js.map +1 -1
  63. package/dist/idempotency-store.d.ts +35 -0
  64. package/dist/idempotency-store.d.ts.map +1 -0
  65. package/dist/idempotency-store.js +32 -0
  66. package/dist/idempotency-store.js.map +1 -0
  67. package/dist/index.d.ts +2 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +1 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/runtime.d.ts +197 -3
  72. package/dist/runtime.d.ts.map +1 -1
  73. package/dist/runtime.js +495 -44
  74. package/dist/runtime.js.map +1 -1
  75. package/package.json +14 -11
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
- middlewares = [];
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.frameworkEvents = new FrameworkEventBus(this.logger);
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
- this.middlewares.push(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 });
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 = query.schema.parse(input);
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
- const validated = action.schema.parse(input);
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
- const ctx = this.buildHandlerContext(envelope, log);
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
- // Compose middlewares around the core. Outermost first (registration
589
- // order is execution order first `use(m)` is the outermost layer).
590
- let pipeline = core;
591
- for (let i = this.middlewares.length - 1; i >= 0; i--) {
592
- const mw = this.middlewares[i];
593
- const inner = pipeline;
594
- pipeline = () => mw(inner, action, validated, ctx);
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 (const event of events) {
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
- const current = (await this.projectionStore.load(projection.name, tenant)) ?? projection.initial();
714
- const next = reducer(current, event.payload);
715
- await this.projectionStore.save(projection.name, next, tenant);
716
- this.emit({
717
- kind: "projection.folded",
718
- projection: projection.name,
719
- event: event.eventName,
720
- tenant,
721
- durationMs: performance.now() - t0,
722
- envelope,
723
- appName: this.appName,
724
- ts: new Date().toISOString(),
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
- await workflow._fire(event, fireCtx);
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
- this.emit({
928
- kind: "reaction.failed",
929
- sourceEvent: event.eventName,
930
- error: serializeError(err),
931
- envelope,
932
- appName: this.appName,
933
- ts: new Date().toISOString(),
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) {