@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.
Files changed (85) hide show
  1. package/README.md +169 -79
  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 +106 -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-app-narrow.test.d.ts +12 -0
  13. package/dist/__tests__/plugin-app-narrow.test.d.ts.map +1 -0
  14. package/dist/__tests__/plugin-app-narrow.test.js +77 -0
  15. package/dist/__tests__/plugin-app-narrow.test.js.map +1 -0
  16. package/dist/__tests__/plugin-stress.test.d.ts +21 -0
  17. package/dist/__tests__/plugin-stress.test.d.ts.map +1 -0
  18. package/dist/__tests__/plugin-stress.test.js +203 -0
  19. package/dist/__tests__/plugin-stress.test.js.map +1 -0
  20. package/dist/__tests__/public-marker.test.js +7 -3
  21. package/dist/__tests__/public-marker.test.js.map +1 -1
  22. package/dist/actor-store.d.ts +24 -0
  23. package/dist/actor-store.d.ts.map +1 -1
  24. package/dist/actor-store.js +29 -0
  25. package/dist/actor-store.js.map +1 -1
  26. package/dist/create-app.d.ts +16 -1
  27. package/dist/create-app.d.ts.map +1 -1
  28. package/dist/create-app.js +126 -19
  29. package/dist/create-app.js.map +1 -1
  30. package/dist/define-action.d.ts +75 -9
  31. package/dist/define-action.d.ts.map +1 -1
  32. package/dist/define-action.js +103 -26
  33. package/dist/define-action.js.map +1 -1
  34. package/dist/define-actor.d.ts +3 -1
  35. package/dist/define-actor.d.ts.map +1 -1
  36. package/dist/define-actor.js +11 -4
  37. package/dist/define-actor.js.map +1 -1
  38. package/dist/define-handler.d.ts +39 -3
  39. package/dist/define-handler.d.ts.map +1 -1
  40. package/dist/define-handler.js +10 -1
  41. package/dist/define-handler.js.map +1 -1
  42. package/dist/define-module.d.ts +3 -0
  43. package/dist/define-module.d.ts.map +1 -1
  44. package/dist/define-module.js +3 -0
  45. package/dist/define-module.js.map +1 -1
  46. package/dist/define-plugin.d.ts +25 -1
  47. package/dist/define-plugin.d.ts.map +1 -1
  48. package/dist/define-plugin.js +100 -14
  49. package/dist/define-plugin.js.map +1 -1
  50. package/dist/define-projection.d.ts +3 -1
  51. package/dist/define-projection.d.ts.map +1 -1
  52. package/dist/define-projection.js +3 -0
  53. package/dist/define-projection.js.map +1 -1
  54. package/dist/define-query.d.ts +69 -13
  55. package/dist/define-query.d.ts.map +1 -1
  56. package/dist/define-query.js +48 -17
  57. package/dist/define-query.js.map +1 -1
  58. package/dist/define-workflow.d.ts +19 -1
  59. package/dist/define-workflow.d.ts.map +1 -1
  60. package/dist/define-workflow.js +4 -0
  61. package/dist/define-workflow.js.map +1 -1
  62. package/dist/dev-logger.d.ts.map +1 -1
  63. package/dist/dev-logger.js +22 -1
  64. package/dist/dev-logger.js.map +1 -1
  65. package/dist/event-message.d.ts +9 -2
  66. package/dist/event-message.d.ts.map +1 -1
  67. package/dist/event-message.js +10 -3
  68. package/dist/event-message.js.map +1 -1
  69. package/dist/framework-events.d.ts +1 -64
  70. package/dist/framework-events.d.ts.map +1 -1
  71. package/dist/framework-events.js +3 -0
  72. package/dist/framework-events.js.map +1 -1
  73. package/dist/idempotency-store.d.ts +35 -0
  74. package/dist/idempotency-store.d.ts.map +1 -0
  75. package/dist/idempotency-store.js +32 -0
  76. package/dist/idempotency-store.js.map +1 -0
  77. package/dist/index.d.ts +3 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +2 -1
  80. package/dist/index.js.map +1 -1
  81. package/dist/runtime.d.ts +205 -5
  82. package/dist/runtime.d.ts.map +1 -1
  83. package/dist/runtime.js +518 -48
  84. package/dist/runtime.js.map +1 -1
  85. 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
- 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,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
- const state = (await this.projectionStore.load(query.projection.name, tenant)) ??
310
- query.projection.initial();
311
- const result = (await query.execute(state, validated));
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
- const validated = action.schema.parse(input);
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
- const ctx = this.buildHandlerContext(envelope, log);
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
- // 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;
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 (const event of events) {
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
- 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
- });
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
- await workflow._fire(event, fireCtx);
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
- 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
- });
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) {