@nwire/forge 0.12.1 → 0.13.1

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 (53) hide show
  1. package/README.md +100 -83
  2. package/dist/framework-events.d.ts +8 -37
  3. package/dist/framework-events.js +7 -3
  4. package/dist/helpers/cli-runner.js +21 -10
  5. package/dist/index.d.ts +8 -7
  6. package/dist/index.js +7 -6
  7. package/dist/plugins/actions-chain.d.ts +39 -22
  8. package/dist/plugins/actions-chain.js +117 -78
  9. package/dist/plugins/actions-plugin.d.ts +26 -23
  10. package/dist/plugins/actions-plugin.js +122 -44
  11. package/dist/plugins/actors-chain.d.ts +9 -2
  12. package/dist/plugins/actors-chain.js +62 -2
  13. package/dist/plugins/actors-plugin.d.ts +1 -1
  14. package/dist/plugins/actors-plugin.js +24 -14
  15. package/dist/plugins/external-calls-plugin.d.ts +28 -0
  16. package/dist/plugins/external-calls-plugin.js +136 -0
  17. package/dist/plugins/idempotency-plugin.d.ts +15 -1
  18. package/dist/plugins/idempotency-plugin.js +56 -11
  19. package/dist/plugins/projections-chain.d.ts +2 -2
  20. package/dist/plugins/projections-chain.js +2 -2
  21. package/dist/plugins/projections-plugin.d.ts +1 -1
  22. package/dist/plugins/projections-plugin.js +4 -13
  23. package/dist/plugins/queries-chain.d.ts +4 -3
  24. package/dist/plugins/queries-chain.js +8 -5
  25. package/dist/plugins/queries-plugin.d.ts +15 -29
  26. package/dist/plugins/queries-plugin.js +36 -49
  27. package/dist/plugins/workflows-chain.d.ts +9 -2
  28. package/dist/plugins/workflows-chain.js +19 -1
  29. package/dist/plugins/workflows-plugin.d.ts +1 -1
  30. package/dist/plugins/workflows-plugin.js +12 -20
  31. package/dist/primitives/define-action.d.ts +80 -115
  32. package/dist/primitives/define-action.js +111 -56
  33. package/dist/primitives/define-actor.d.ts +103 -214
  34. package/dist/primitives/define-actor.js +157 -216
  35. package/dist/primitives/define-handler.d.ts +42 -112
  36. package/dist/primitives/define-handler.js +14 -45
  37. package/dist/primitives/define-projection.d.ts +23 -28
  38. package/dist/primitives/define-projection.js +29 -32
  39. package/dist/primitives/define-query.d.ts +52 -42
  40. package/dist/primitives/define-query.js +65 -28
  41. package/dist/primitives/define-workflow.d.ts +8 -11
  42. package/dist/primitives/define-workflow.js +14 -8
  43. package/dist/runtime/forge-dispatcher.d.ts +30 -12
  44. package/dist/runtime/forge-dispatcher.js +199 -237
  45. package/dist/runtime/forge-plugin.d.ts +8 -0
  46. package/dist/runtime/forge-plugin.js +113 -31
  47. package/dist/runtime/forge-plugins.d.ts +55 -0
  48. package/dist/runtime/forge-plugins.js +57 -0
  49. package/dist/runtime/forge-types.d.ts +8 -2
  50. package/dist/runtime/with-forge.d.ts +8 -11
  51. package/dist/runtime/with-forge.js +9 -11
  52. package/dist/stores/idempotency-store.d.ts +1 -1
  53. package/package.json +12 -12
@@ -23,7 +23,7 @@ import { type DeadLetterSink } from "@nwire/dead-letter";
23
23
  import type { EventBus } from "@nwire/bus";
24
24
  import type { z } from "zod";
25
25
  import type { ActionDefinition, ActionInput, ActionResult } from "../primitives/define-action.js";
26
- import type { HandlerDefinition } from "../primitives/define-handler.js";
26
+ import type { HandlerContext, HandlerDefinition } from "../primitives/define-handler.js";
27
27
  import type { ActorDefinition } from "../primitives/define-actor.js";
28
28
  import type { WorkflowDefinition, WorkflowInstance } from "../primitives/define-workflow.js";
29
29
  import type { ProjectionDefinition } from "../primitives/define-projection.js";
@@ -133,9 +133,19 @@ export declare class ForgeDispatcher {
133
133
  fireDueTimers(now?: number): Promise<number>;
134
134
  fireDueWorkflowTimers(now?: Date): Promise<number>;
135
135
  dispatch<A extends ActionDefinition>(action: A, input: ActionInput<A>, parentEnvelope?: MessageEnvelope, opts?: DispatchOptions): Promise<ActionResult<A>>;
136
+ /**
137
+ * The forge command pipeline — runs as a registered action handler's core
138
+ * under `runtime.execute`: `action.dispatched` telemetry, the
139
+ * `ActionDispatching` veto hook, the per-action `before`/`after` hooks,
140
+ * retry + backoff invoking the raw handler, dead-letter on exhaustion,
141
+ * completion/failure telemetry, and event-return publishing. `ctx` is
142
+ * execute's handler ctx (capCtx + verbs); `rawHandler` is the action's
143
+ * original handler fn.
144
+ */
145
+ runActionPipeline(action: ActionDefinition<any>, rawHandler: (input: unknown, ctx: HandlerContext) => unknown, ctx: HandlerContext, signal: AbortSignal): Promise<unknown>;
136
146
  /**
137
147
  * Publish a batch of events. Each event flows through the
138
- * `EventPublishing` hook chain — idempotency → actors → projections →
148
+ * `LocalDelivery` hook chain — idempotency → actors → projections →
139
149
  * workflows → bus — at structurally enforced priorities. The chain
140
150
  * participants are attached once at plugin setup via
141
151
  * {@link attachPublishChain}.
@@ -145,23 +155,29 @@ export declare class ForgeDispatcher {
145
155
  */
146
156
  publish(events: readonly EventMessage[], parentEnvelope: MessageEnvelope): Promise<void>;
147
157
  /**
148
- * Attach forge's atomic publish chain to the `EventPublishing` hook.
158
+ * Attach forge's bundled fold steps to the core `LocalDelivery` hook.
149
159
  * Priority slots are defined in `EVENT_PUBLISHING_PRIORITIES`:
150
160
  *
151
- * 1000 — idempotency gate. Short-circuits on duplicate.
161
+ * 1000 — idempotency gate. Short-circuits (vetoes) on duplicate.
152
162
  * 800 — actor state transitions.
153
163
  * 600 — projection folds.
154
164
  * 400 — workflow correlation + fire.
155
- * 200 — cross-process bus delivery (public events only).
156
- * 100 — outbound sink drain (public events only) — feeds
157
- * adopters that install via `installSinkStage` (bullmq,
158
- * AMQP, telemetry-otel, …).
159
165
  *
160
- * Called once at plugin setup. Idempotent re-attaching is a no-op
161
- * because the hook engine guards against duplicate step names.
166
+ * Cross-process bus delivery and the outbound sink drain are NOT chain
167
+ * steps they're the outgoing `publish` concern, applied by
168
+ * `dispatcher.publish` after `runtime.deliver` for public, non-deduped
169
+ * events. Keeping them out of the local chain means local `emit` (and
170
+ * `publishCapability`) never double-drain.
171
+ *
172
+ * Called once at plugin setup (bundled mode). NOTE: the hook engine does
173
+ * NOT dedup step names — bundled (`createForgePlugin`) and à-la-carte (the
174
+ * sub-plugins) are mutually exclusive; mixing them double-registers a fold
175
+ * step. Each sub-plugin warns at `AppReady` if it detects a duplicate
176
+ * (the `AppReady` hook fires `.on` listeners via `allSettled`, so a thrown
177
+ * guard would be swallowed — a hard-fail needs a registration-time check;
178
+ * tracked as a follow-up).
162
179
  */
163
180
  attachPublishChain(): void;
164
- applyExternalEvent(eventName: string, payload: unknown, envelope: MessageEnvelope): Promise<void>;
165
181
  private foldProjections;
166
182
  private applyToActors;
167
183
  private extractKey;
@@ -169,7 +185,9 @@ export declare class ForgeDispatcher {
169
185
  private applyEventToActorLocked;
170
186
  private computeTimersForState;
171
187
  private runWorkflows;
172
- private buildHandlerContext;
188
+ /** Public actor-view accessor — backs the `actor(actor, id)` ctx member. */
189
+ actorView<TActor extends ActorDefinition>(actor: TActor, id: string, envelope: MessageEnvelope): Promise<any>;
173
190
  private loadActorView;
174
191
  getDeadLetterSink(): DeadLetterSink;
175
192
  }
193
+ /** Local alias for the dispatch-hook ctx shape; lifted into kernel.Runtime. */
@@ -116,9 +116,20 @@ export class ForgeDispatcher {
116
116
  this.handlers.set(name, handler);
117
117
  this.ensureActionBeforeHook(name);
118
118
  this.ensureActionAfterHook(name);
119
+ // Make the handler's `run` the forge command pipeline, so dispatching it
120
+ // through `runtime.execute` (whether from `dispatch`, a wire, or HTTP)
121
+ // runs retry/DLQ/hooks/telemetry/event-publish as the core. Mutate in
122
+ // place so every reference (dispatcher, runtime registry, wires) shares
123
+ // it. The retry loop re-invokes the raw `handler.handler` directly, as
124
+ // before — no nested `.use` chain.
125
+ const rawHandler = handler.handler;
126
+ const action = handler.action;
127
+ handler.run = async (ctx, opts) => ({
128
+ result: await this.runActionPipeline(action, rawHandler, ctx, opts?.signal ?? new AbortController().signal),
129
+ });
119
130
  // The action declares `emits: [SomeEvent, ...]`. Any event the
120
131
  // author marked `.public()` carries `$public: true` — surface it
121
- // to the dispatcher so the EventPublishing chain's bus + outbound
132
+ // to the dispatcher so the LocalDelivery chain's bus + outbound
122
133
  // steps fire when that event flows through.
123
134
  const emits = handler.action.emits;
124
135
  if (emits) {
@@ -434,12 +445,35 @@ export class ForgeDispatcher {
434
445
  if (!handler) {
435
446
  throw new Error(`Runtime: no handler registered for action "${action.name}".`);
436
447
  }
448
+ // One dispatch path. The handler's `run` IS the forge command pipeline
449
+ // (installed at registerActionHandler), so retry / DLQ / ActionDispatching /
450
+ // before-after / telemetry / event-publish run as the handler's core under
451
+ // `runtime.execute` — capCtx, one per-request scope, one dispatch pin.
452
+ return this.runtime.execute(handler, input, {
453
+ parent: parentEnvelope,
454
+ signal: opts?.signal,
455
+ });
456
+ }
457
+ /**
458
+ * The forge command pipeline — runs as a registered action handler's core
459
+ * under `runtime.execute`: `action.dispatched` telemetry, the
460
+ * `ActionDispatching` veto hook, the per-action `before`/`after` hooks,
461
+ * retry + backoff invoking the raw handler, dead-letter on exhaustion,
462
+ * completion/failure telemetry, and event-return publishing. `ctx` is
463
+ * execute's handler ctx (capCtx + verbs); `rawHandler` is the action's
464
+ * original handler fn.
465
+ */
466
+ async runActionPipeline(
467
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
468
+ action, rawHandler, ctx, signal) {
437
469
  const appName = this.runtime.appName;
438
- const envelope = parentEnvelope ? deriveEnvelope(parentEnvelope) : seedEnvelope({});
439
- const validated = isValidated(input) ? input : markValidated(action.schema.parse(input));
470
+ const envelope = ctx.envelope;
471
+ const rawInput = ctx.input;
472
+ const validated = isValidated(rawInput)
473
+ ? rawInput
474
+ : markValidated(action.schema.parse(rawInput));
440
475
  const log = loggerForEnvelope(this.runtime.logger, envelope);
441
- const signal = opts?.signal ?? new AbortController().signal;
442
- const ctx = this.buildHandlerContext(envelope, log, signal);
476
+ const startedAt = performance.now();
443
477
  this.runtime.pushTelemetry({
444
478
  kind: "action.dispatched",
445
479
  action: action.name,
@@ -448,7 +482,6 @@ export class ForgeDispatcher {
448
482
  appName,
449
483
  ts: new Date().toISOString(),
450
484
  });
451
- const startedAt = performance.now();
452
485
  const dispatchResult = await this.runtime.hooks.ActionDispatching.runDetailed({
453
486
  action,
454
487
  input: validated,
@@ -468,128 +501,123 @@ export class ForgeDispatcher {
468
501
  return undefined;
469
502
  }
470
503
  }
471
- const core = async () => {
472
- const retry = action.retry;
473
- const maxAttempts = 1 + (retry?.max ?? 0);
474
- let attempt = 0;
475
- let lastError;
476
- while (attempt < maxAttempts) {
477
- attempt++;
478
- if (attempt > 1 && signal.aborted) {
479
- log.warn(`abort observed between attempts; skipping retries`, {
504
+ const retry = action.retry;
505
+ const maxAttempts = 1 + (retry?.max ?? 0);
506
+ let attempt = 0;
507
+ let lastError;
508
+ while (attempt < maxAttempts) {
509
+ attempt++;
510
+ if (attempt > 1 && signal.aborted) {
511
+ log.warn(`abort observed between attempts; skipping retries`, {
512
+ action: action.name,
513
+ attempt,
514
+ maxAttempts,
515
+ });
516
+ // eslint-disable-next-line no-throw-literal
517
+ throw lastError;
518
+ }
519
+ try {
520
+ if (attempt > 1) {
521
+ const delay = computeBackoff(retry, attempt - 1);
522
+ log.warn(`retrying handler`, {
480
523
  action: action.name,
481
524
  attempt,
482
525
  maxAttempts,
526
+ delayMs: delay,
483
527
  });
484
- // eslint-disable-next-line no-throw-literal
485
- throw lastError;
528
+ if (delay > 0)
529
+ await sleep(delay);
486
530
  }
487
- try {
488
- if (attempt > 1) {
489
- const delay = computeBackoff(retry, attempt - 1);
490
- log.warn(`retrying handler`, {
491
- action: action.name,
492
- attempt,
493
- maxAttempts,
494
- delayMs: delay,
531
+ const rawResult = await rawHandler(validated, ctx);
532
+ const events = normalizeEventReturn((rawResult ?? null));
533
+ if (events.length > 0) {
534
+ await this.publish(events, envelope);
535
+ }
536
+ const durationMs = performance.now() - startedAt;
537
+ this.runtime.pushTelemetry({
538
+ kind: "action.completed",
539
+ action: action.name,
540
+ durationMs,
541
+ emittedEvents: events.map((e) => e.eventName),
542
+ envelope,
543
+ appName,
544
+ ts: new Date().toISOString(),
545
+ });
546
+ const afterHook = this.actionAfterHooks.get(action.name);
547
+ if (afterHook) {
548
+ try {
549
+ await afterHook.run({
550
+ action,
551
+ input: validated,
552
+ result: rawResult ?? undefined,
553
+ durationMs,
495
554
  });
496
- if (delay > 0)
497
- await sleep(delay);
498
555
  }
499
- const rawResult = await handler.handler(validated, ctx);
500
- const events = normalizeEventReturn(rawResult ?? null);
501
- if (events.length > 0) {
502
- await this.publish(events, envelope);
503
- }
504
- const durationMs = performance.now() - startedAt;
505
- this.runtime.pushTelemetry({
506
- kind: "action.completed",
507
- action: action.name,
508
- durationMs,
509
- emittedEvents: events.map((e) => e.eventName),
510
- envelope,
511
- appName,
512
- ts: new Date().toISOString(),
513
- });
514
- const afterHook = this.actionAfterHooks.get(action.name);
515
- if (afterHook) {
516
- try {
517
- await afterHook.run({
518
- action,
519
- input: validated,
520
- result: rawResult ?? undefined,
521
- durationMs,
522
- });
523
- }
524
- catch (err) {
525
- log.error(`action.after hook threw`, {
526
- action: action.name,
527
- error: err?.message,
528
- });
529
- }
556
+ catch (err) {
557
+ log.error(`action.after hook threw`, {
558
+ action: action.name,
559
+ error: err?.message,
560
+ });
530
561
  }
531
- void this.runtime.hooks.ActionCompleted.run({
562
+ }
563
+ void this.runtime.hooks.ActionCompleted.run({
564
+ action,
565
+ input: validated,
566
+ result: rawResult ?? undefined,
567
+ durationMs,
568
+ });
569
+ return rawResult ?? undefined;
570
+ }
571
+ catch (err) {
572
+ lastError = err;
573
+ log.error(`handler threw`, {
574
+ action: action.name,
575
+ attempt,
576
+ maxAttempts,
577
+ error: err?.message,
578
+ });
579
+ this.runtime.pushTelemetry({
580
+ kind: "action.failed",
581
+ action: action.name,
582
+ attempt,
583
+ maxAttempts,
584
+ willRetry: attempt < maxAttempts,
585
+ error: serializeError(err),
586
+ envelope,
587
+ appName,
588
+ ts: new Date().toISOString(),
589
+ });
590
+ if (attempt >= maxAttempts) {
591
+ void this.runtime.hooks.ActionFailed.run({
532
592
  action,
533
593
  input: validated,
534
- result: rawResult ?? undefined,
535
- durationMs,
594
+ error: err,
595
+ durationMs: performance.now() - startedAt,
536
596
  });
537
- return rawResult ?? undefined;
538
- }
539
- catch (err) {
540
- lastError = err;
541
- log.error(`handler threw`, {
542
- action: action.name,
543
- attempt,
544
- maxAttempts,
545
- error: err?.message,
546
- });
547
- this.runtime.pushTelemetry({
548
- kind: "action.failed",
549
- action: action.name,
550
- attempt,
551
- maxAttempts,
552
- willRetry: attempt < maxAttempts,
553
- error: serializeError(err),
554
- envelope,
555
- appName,
556
- ts: new Date().toISOString(),
557
- });
558
- if (attempt >= maxAttempts) {
559
- void this.runtime.hooks.ActionFailed.run({
560
- action,
561
- input: validated,
562
- error: err,
563
- durationMs: performance.now() - startedAt,
564
- });
565
- }
566
597
  }
567
598
  }
568
- const entry = buildDeadLetterEntry(action.name, validated, envelope, attempt, lastError);
569
- await this.deadLetterSink.record(entry);
570
- log.error(`dead-lettered after ${attempt} attempts`, {
571
- action: action.name,
572
- error: entry.lastError.message,
573
- });
574
- this.runtime.pushTelemetry({
575
- kind: "dlq.recorded",
576
- action: action.name,
577
- attempts: attempt,
578
- error: serializeError(lastError),
579
- envelope,
580
- appName,
581
- ts: new Date().toISOString(),
582
- });
583
- throw lastError;
584
- };
585
- const hctx = { action, input: validated, ctx, coreFn: core };
586
- await this.runtime.dispatchHook$.run(hctx);
587
- return hctx.result;
599
+ }
600
+ const entry = buildDeadLetterEntry(action.name, validated, envelope, attempt, lastError);
601
+ await this.deadLetterSink.record(entry);
602
+ log.error(`dead-lettered after ${attempt} attempts`, {
603
+ action: action.name,
604
+ error: entry.lastError.message,
605
+ });
606
+ this.runtime.pushTelemetry({
607
+ kind: "dlq.recorded",
608
+ action: action.name,
609
+ attempts: attempt,
610
+ error: serializeError(lastError),
611
+ envelope,
612
+ appName,
613
+ ts: new Date().toISOString(),
614
+ });
615
+ throw lastError;
588
616
  }
589
- // ─── Publish + applyExternalEvent ──────────────────────────────────
617
+ // ─── Publish ───────────────────────────────────────────────────────
590
618
  /**
591
619
  * Publish a batch of events. Each event flows through the
592
- * `EventPublishing` hook chain — idempotency → actors → projections →
620
+ * `LocalDelivery` hook chain — idempotency → actors → projections →
593
621
  * workflows → bus — at structurally enforced priorities. The chain
594
622
  * participants are attached once at plugin setup via
595
623
  * {@link attachPublishChain}.
@@ -603,14 +631,10 @@ export class ForgeDispatcher {
603
631
  const event = events[i];
604
632
  const dedupKey = events.length === 1 ? parentEnvelope.messageId : `${parentEnvelope.messageId}#${i}`;
605
633
  const childEnvelope = deriveEnvelope(parentEnvelope);
606
- const payload = {
607
- event,
608
- envelope: childEnvelope,
609
- dedupKey,
610
- deduped: false,
611
- };
612
- await this.runtime.hooks.EventPublishing.run(payload);
613
- if (payload.deduped) {
634
+ // Local delivery (incoming-to-all): the LocalDelivery fold chain
635
+ // (idempotency → actors → projections → workflows) + core listeners.
636
+ const { deduped } = await this.runtime.deliver(event.eventName, event.payload, childEnvelope, dedupKey);
637
+ if (deduped) {
614
638
  this.runtime.pushTelemetry({
615
639
  kind: "event.deduped",
616
640
  event,
@@ -619,108 +643,79 @@ export class ForgeDispatcher {
619
643
  appName,
620
644
  ts: new Date().toISOString(),
621
645
  });
646
+ continue;
622
647
  }
623
- else {
624
- this.runtime.pushTelemetry({
625
- kind: "event.published",
626
- event,
627
- envelope: childEnvelope,
628
- source: "in-process",
629
- appName,
630
- ts: new Date().toISOString(),
631
- });
648
+ // Outbound (publish = outgoing only): cross-process bus + sink drain
649
+ // for public, non-deduped events. Moved OUT of the local chain so
650
+ // local `emit` / `publishCapability` never double-drain (trap b), and
651
+ // a dedup veto skips outbound (trap c).
652
+ if (this.publicEventNames.has(event.eventName)) {
653
+ if (this.publishToBus && this.bus) {
654
+ await this.bus.publish({
655
+ eventName: event.eventName,
656
+ payload: event.payload,
657
+ envelope: childEnvelope,
658
+ origin: this.runtime.appName,
659
+ });
660
+ }
661
+ const eventRef = { name: event.eventName };
662
+ await this.runtime.sinkDrain(eventRef, event.payload, childEnvelope);
632
663
  }
664
+ this.runtime.pushTelemetry({
665
+ kind: "event.published",
666
+ event,
667
+ envelope: childEnvelope,
668
+ source: "in-process",
669
+ appName,
670
+ ts: new Date().toISOString(),
671
+ });
633
672
  }
634
673
  }
635
674
  /**
636
- * Attach forge's atomic publish chain to the `EventPublishing` hook.
675
+ * Attach forge's bundled fold steps to the core `LocalDelivery` hook.
637
676
  * Priority slots are defined in `EVENT_PUBLISHING_PRIORITIES`:
638
677
  *
639
- * 1000 — idempotency gate. Short-circuits on duplicate.
678
+ * 1000 — idempotency gate. Short-circuits (vetoes) on duplicate.
640
679
  * 800 — actor state transitions.
641
680
  * 600 — projection folds.
642
681
  * 400 — workflow correlation + fire.
643
- * 200 — cross-process bus delivery (public events only).
644
- * 100 — outbound sink drain (public events only) — feeds
645
- * adopters that install via `installSinkStage` (bullmq,
646
- * AMQP, telemetry-otel, …).
647
682
  *
648
- * Called once at plugin setup. Idempotent re-attaching is a no-op
649
- * because the hook engine guards against duplicate step names.
683
+ * Cross-process bus delivery and the outbound sink drain are NOT chain
684
+ * steps they're the outgoing `publish` concern, applied by
685
+ * `dispatcher.publish` after `runtime.deliver` for public, non-deduped
686
+ * events. Keeping them out of the local chain means local `emit` (and
687
+ * `publishCapability`) never double-drain.
688
+ *
689
+ * Called once at plugin setup (bundled mode). NOTE: the hook engine does
690
+ * NOT dedup step names — bundled (`createForgePlugin`) and à-la-carte (the
691
+ * sub-plugins) are mutually exclusive; mixing them double-registers a fold
692
+ * step. Each sub-plugin warns at `AppReady` if it detects a duplicate
693
+ * (the `AppReady` hook fires `.on` listeners via `allSettled`, so a thrown
694
+ * guard would be swallowed — a hard-fail needs a registration-time check;
695
+ * tracked as a follow-up).
650
696
  */
651
697
  attachPublishChain() {
652
- const hook = this.runtime.hooks.EventPublishing;
698
+ const hook = this.runtime.hooks.LocalDelivery;
653
699
  hook.use(async (payload, next) => {
654
700
  const isNew = await this.idempotencyStore.recordIfNew(payload.dedupKey);
655
701
  if (!isNew) {
656
702
  payload.deduped = true;
657
- return; // veto — downstream steps skipped
703
+ return; // veto — downstream steps + listeners skipped
658
704
  }
659
705
  await next();
660
706
  }, { name: "forge.publish.idempotency", priority: EVENT_PUBLISHING_PRIORITIES.idempotency });
661
707
  hook.use(async (payload, next) => {
662
- await this.applyToActors(payload.event, payload.envelope);
708
+ await this.applyToActors({ eventName: payload.eventName, payload: payload.payload }, payload.envelope);
663
709
  await next();
664
710
  }, { name: "forge.publish.actors", priority: EVENT_PUBLISHING_PRIORITIES.actors });
665
711
  hook.use(async (payload, next) => {
666
- await this.foldProjections(payload.event, payload.envelope);
712
+ await this.foldProjections({ eventName: payload.eventName, payload: payload.payload }, payload.envelope);
667
713
  await next();
668
714
  }, { name: "forge.publish.projections", priority: EVENT_PUBLISHING_PRIORITIES.projections });
669
715
  hook.use(async (payload, next) => {
670
- await this.runWorkflows(payload.event, payload.envelope);
716
+ await this.runWorkflows({ eventName: payload.eventName, payload: payload.payload }, payload.envelope);
671
717
  await next();
672
718
  }, { name: "forge.publish.workflows", priority: EVENT_PUBLISHING_PRIORITIES.workflows });
673
- hook.use(async (payload, next) => {
674
- if (this.publishToBus && this.bus && this.publicEventNames.has(payload.event.eventName)) {
675
- await this.bus.publish({
676
- eventName: payload.event.eventName,
677
- payload: payload.event.payload,
678
- envelope: payload.envelope,
679
- origin: this.runtime.appName,
680
- });
681
- }
682
- await next();
683
- }, { name: "forge.publish.bus", priority: EVENT_PUBLISHING_PRIORITIES.bus });
684
- hook.use(async (payload, next) => {
685
- if (this.publicEventNames.has(payload.event.eventName)) {
686
- // Outbound sink chain — adopters like bullmq, AMQP, telemetry-otel
687
- // install terminal stages via `installSinkStage`. `sinkDrain`
688
- // reads `.name` off the event; the schema is not consulted here.
689
- const eventRef = { name: payload.event.eventName };
690
- await this.runtime.sinkDrain(eventRef, payload.event.payload, payload.envelope);
691
- }
692
- await next();
693
- }, { name: "forge.publish.outbound", priority: EVENT_PUBLISHING_PRIORITIES.outbound });
694
- }
695
- async applyExternalEvent(eventName, payload, envelope) {
696
- if (!this.externalEventNames.has(eventName)) {
697
- throw new Error(`Runtime.applyExternalEvent: "${eventName}" was not declared in any module's needs.externalEvents.`);
698
- }
699
- const appName = this.runtime.appName;
700
- const event = { eventName, payload };
701
- const isNew = await this.idempotencyStore.recordIfNew(envelope.messageId);
702
- if (!isNew) {
703
- this.runtime.pushTelemetry({
704
- kind: "event.deduped",
705
- event,
706
- envelope,
707
- source: "external",
708
- appName,
709
- ts: new Date().toISOString(),
710
- });
711
- return;
712
- }
713
- await this.applyToActors(event, envelope);
714
- await this.foldProjections(event, envelope);
715
- await this.runWorkflows(event, envelope);
716
- this.runtime.pushTelemetry({
717
- kind: "event.published",
718
- event,
719
- envelope,
720
- source: "external",
721
- appName,
722
- ts: new Date().toISOString(),
723
- });
724
719
  }
725
720
  // ─── Projection folding ────────────────────────────────────────────
726
721
  async foldProjections(event, envelope) {
@@ -923,14 +918,14 @@ export class ForgeDispatcher {
923
918
  const log = loggerForEnvelope(this.runtime.logger, envelope).child({
924
919
  event: event.eventName,
925
920
  });
926
- const handlerCtx = this.buildHandlerContext(envelope, log);
927
921
  const self = this;
922
+ const container = this.runtime.getContainer();
928
923
  const baseEffects = {
929
924
  async send(action, input) {
930
- return handlerCtx.request(action, input);
925
+ return self.dispatch(action, input, envelope);
931
926
  },
932
927
  async enqueue(action, input) {
933
- await handlerCtx.send(action, input);
928
+ await self.dispatch(action, input, envelope);
934
929
  },
935
930
  async publish(eventMsg) {
936
931
  await self.publish([eventMsg], envelope);
@@ -951,7 +946,7 @@ export class ForgeDispatcher {
951
946
  // event is .public() and a sink is installed. Built via the event
952
947
  // factory so messageId / correlation / idempotency hold.
953
948
  emit: (eventDef, payload) => self.publish([eventFactory(eventDef)(payload)], envelope),
954
- resolve: (name) => handlerCtx.resolve(name),
949
+ resolve: (name) => container.resolve(name),
955
950
  logger: log,
956
951
  load: (key) => store.get(key),
957
952
  save: (key, instance) => store.set(key, instance),
@@ -1065,49 +1060,15 @@ export class ForgeDispatcher {
1065
1060
  }
1066
1061
  }
1067
1062
  }
1068
- // ─── Handler context (internal) ────────────────────────────────────
1069
- buildHandlerContext(envelope, log, signal) {
1070
- const self = this;
1071
- const container = this.runtime.getContainer();
1072
- const logger = log ?? loggerForEnvelope(this.runtime.logger, envelope);
1073
- const ctxSignal = signal ?? new AbortController().signal;
1074
- const ctx = {
1075
- container,
1076
- envelope,
1077
- logger,
1078
- signal: ctxSignal,
1079
- resolve(name) {
1080
- return container.resolve(name);
1081
- },
1082
- get requestId() {
1083
- return envelope.messageId;
1084
- },
1085
- async request(action, input) {
1086
- return self.dispatch(action, input, envelope, { signal: ctxSignal });
1087
- },
1088
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1089
- async query(
1090
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1091
- queryDef, input) {
1092
- return self.query(queryDef.name, input, envelope.tenant ?? "");
1093
- },
1094
- async send(action, input) {
1095
- await self.dispatch(action, input, envelope, { signal: ctxSignal });
1096
- },
1097
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1098
- async use(actor, id) {
1099
- return self.loadActorView(actor, id, envelope);
1100
- },
1101
- async externalCall(call, request) {
1102
- return self.executeExternalCall(call, request, envelope);
1103
- },
1104
- };
1105
- return ctx;
1106
- }
1107
1063
  // ─── Actor view (ctx.use) ──────────────────────────────────────────
1064
+ /** Public actor-view accessor — backs the `actor(actor, id)` ctx member. */
1065
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1066
+ actorView(actor, id, envelope) {
1067
+ return this.loadActorView(actor, id, envelope);
1068
+ }
1108
1069
  async loadActorView(actor, id, envelope) {
1109
1070
  if (!this.actors.has(actor.name)) {
1110
- throw new Error(`Runtime.use: actor "${actor.name}" is not registered. ` +
1071
+ throw new Error(`ctx.actor: actor "${actor.name}" is not registered. ` +
1111
1072
  `Add it to a module's manifest.actors and pass that module to createApp.`);
1112
1073
  }
1113
1074
  const loaded = await this.actorStore.load(actor.name, id, envelope.tenant ?? "");
@@ -1166,3 +1127,4 @@ export class ForgeDispatcher {
1166
1127
  return this.deadLetterSink;
1167
1128
  }
1168
1129
  }
1130
+ /** Local alias for the dispatch-hook ctx shape; lifted into kernel.Runtime. */
@@ -53,6 +53,14 @@ export interface ForgePluginOptions extends ForgeDispatcherOptions {
53
53
  readonly queries?: readonly QueryDefinition<any, any, any>[];
54
54
  readonly workflows?: readonly WorkflowDefinition[];
55
55
  readonly externalCalls?: readonly ExternalCallDefinition<any, any>[];
56
+ /**
57
+ * Event names this app accepts from the bus (the inbound counterpart to
58
+ * `publishToBus`). When a `bus` is configured, each declared name is
59
+ * subscribed at boot and folded through `runtime.receive` — the same source
60
+ * chain + delivery path local events use. An app's own published echoes are
61
+ * skipped by origin.
62
+ */
63
+ readonly externalEvents?: readonly string[];
56
64
  }
57
65
  /**
58
66
  * Construct a forge plugin with custom stores, bus, and/or domain