@nwire/forge 0.10.1 → 0.11.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 (41) hide show
  1. package/dist/framework-events.d.ts +45 -2
  2. package/dist/framework-events.js +11 -1
  3. package/dist/helpers/cli-runner.d.ts +1 -1
  4. package/dist/helpers/cli-runner.js +5 -4
  5. package/dist/index.d.ts +24 -12
  6. package/dist/index.js +24 -12
  7. package/dist/plugins/actions-chain.d.ts +48 -0
  8. package/dist/plugins/actions-chain.js +251 -0
  9. package/dist/plugins/actions-plugin.d.ts +40 -0
  10. package/dist/plugins/actions-plugin.js +76 -0
  11. package/dist/plugins/actors-chain.d.ts +52 -0
  12. package/dist/plugins/actors-chain.js +204 -0
  13. package/dist/plugins/actors-plugin.d.ts +36 -0
  14. package/dist/plugins/actors-plugin.js +62 -0
  15. package/dist/plugins/dlq-plugin.d.ts +29 -0
  16. package/dist/plugins/dlq-plugin.js +37 -0
  17. package/dist/plugins/idempotency-plugin.d.ts +28 -0
  18. package/dist/plugins/idempotency-plugin.js +57 -0
  19. package/dist/plugins/projections-chain.d.ts +34 -0
  20. package/dist/plugins/projections-chain.js +86 -0
  21. package/dist/plugins/projections-plugin.d.ts +36 -0
  22. package/dist/plugins/projections-plugin.js +63 -0
  23. package/dist/plugins/queries-chain.d.ts +33 -0
  24. package/dist/plugins/queries-chain.js +77 -0
  25. package/dist/plugins/queries-plugin.d.ts +41 -0
  26. package/dist/plugins/queries-plugin.js +74 -0
  27. package/dist/plugins/workflows-chain.d.ts +51 -0
  28. package/dist/plugins/workflows-chain.js +203 -0
  29. package/dist/plugins/workflows-plugin.d.ts +47 -0
  30. package/dist/plugins/workflows-plugin.js +81 -0
  31. package/dist/runtime/create-forge-app.d.ts +11 -11
  32. package/dist/runtime/create-forge-app.js +28 -32
  33. package/dist/runtime/forge-dispatcher.d.ts +27 -0
  34. package/dist/runtime/forge-dispatcher.js +100 -22
  35. package/dist/runtime/forge-plugin.d.ts +57 -42
  36. package/dist/runtime/forge-plugin.js +72 -59
  37. package/dist/runtime/with-forge.d.ts +26 -0
  38. package/dist/runtime/with-forge.js +30 -0
  39. package/dist/stores/idempotency-store.d.ts +15 -0
  40. package/dist/stores/idempotency-store.js +7 -0
  41. package/package.json +11 -11
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `workflowsPlugin` — standalone forge workflow concern.
3
+ *
4
+ * import { createApp } from "@nwire/app";
5
+ * import { workflowsPlugin, forgePlugin } from "@nwire/forge";
6
+ *
7
+ * const app = createApp({
8
+ * appName: "orders",
9
+ * plugins: [
10
+ * forgePlugin,
11
+ * workflowsPlugin([autoCharge, sendReceipt]),
12
+ * ],
13
+ * });
14
+ *
15
+ * Owns:
16
+ * - the `forge.workflowTimerStore` container binding
17
+ * - the workflow registry (private to the plugin's `WorkflowChainRunner`)
18
+ * - the `forge.publish.workflows` step on the EventPublishing chain
19
+ * (priority 400 — between projections and bus delivery)
20
+ * - the `forge.workflowChain` container binding so other plugins can
21
+ * resolve the runner (the timer scheduler fires through it)
22
+ *
23
+ * Workflow effects (`send` / `enqueue` / `publish`) are resolved through
24
+ * the forge dispatcher bound on the container. `forgePlugin` (or a
25
+ * future `actionsPlugin`) must be installed alongside this plugin.
26
+ *
27
+ * If `forgePlugin`'s `options.workflows` is also passed, both installs
28
+ * fire every event — install ONE path, not both. A diagnostic warning
29
+ * fires at `AppReady` when both attachments are present.
30
+ */
31
+ import type { PluginDefinition } from "@nwire/app";
32
+ import type { EventBus } from "@nwire/bus";
33
+ import { type WorkflowTimerStore } from "../stores/workflow-timer-store.js";
34
+ import type { WorkflowDefinition } from "../primitives/define-workflow.js";
35
+ export declare const FORGE_WORKFLOW_CHAIN_BINDING: "forge.workflowChain";
36
+ export declare const FORGE_WORKFLOW_TIMER_STORE_BINDING: "forge.workflowTimerStore";
37
+ export interface WorkflowsPluginOptions {
38
+ /** Override the timer store. Defaults to `InMemoryWorkflowTimerStore`. */
39
+ readonly workflowTimerStore?: WorkflowTimerStore;
40
+ /**
41
+ * Optional bus reference. Not used directly by workflows, but plugins
42
+ * that wrap the timer scheduler may need it; surfaced here so the
43
+ * standalone plugin doesn't force them through a re-resolve.
44
+ */
45
+ readonly bus?: EventBus;
46
+ }
47
+ export declare function workflowsPlugin(workflows: readonly WorkflowDefinition[], opts?: WorkflowsPluginOptions): PluginDefinition;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `workflowsPlugin` — standalone forge workflow concern.
3
+ *
4
+ * import { createApp } from "@nwire/app";
5
+ * import { workflowsPlugin, forgePlugin } from "@nwire/forge";
6
+ *
7
+ * const app = createApp({
8
+ * appName: "orders",
9
+ * plugins: [
10
+ * forgePlugin,
11
+ * workflowsPlugin([autoCharge, sendReceipt]),
12
+ * ],
13
+ * });
14
+ *
15
+ * Owns:
16
+ * - the `forge.workflowTimerStore` container binding
17
+ * - the workflow registry (private to the plugin's `WorkflowChainRunner`)
18
+ * - the `forge.publish.workflows` step on the EventPublishing chain
19
+ * (priority 400 — between projections and bus delivery)
20
+ * - the `forge.workflowChain` container binding so other plugins can
21
+ * resolve the runner (the timer scheduler fires through it)
22
+ *
23
+ * Workflow effects (`send` / `enqueue` / `publish`) are resolved through
24
+ * the forge dispatcher bound on the container. `forgePlugin` (or a
25
+ * future `actionsPlugin`) must be installed alongside this plugin.
26
+ *
27
+ * If `forgePlugin`'s `options.workflows` is also passed, both installs
28
+ * fire every event — install ONE path, not both. A diagnostic warning
29
+ * fires at `AppReady` when both attachments are present.
30
+ */
31
+ import { InMemoryWorkflowTimerStore, } from "../stores/workflow-timer-store.js";
32
+ import { FORGE_DISPATCHER_BINDING } from "../runtime/forge-plugin.js";
33
+ import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
34
+ import { WorkflowChainRunner } from "./workflows-chain.js";
35
+ export const FORGE_WORKFLOW_CHAIN_BINDING = "forge.workflowChain";
36
+ export const FORGE_WORKFLOW_TIMER_STORE_BINDING = "forge.workflowTimerStore";
37
+ export function workflowsPlugin(workflows, opts = {}) {
38
+ return {
39
+ name: "forge.workflows",
40
+ register({ bind }) {
41
+ const store = opts.workflowTimerStore ?? new InMemoryWorkflowTimerStore();
42
+ bind(FORGE_WORKFLOW_TIMER_STORE_BINDING, () => store);
43
+ },
44
+ setup({ runtime, container, on }) {
45
+ const timerStore = container.resolve(FORGE_WORKFLOW_TIMER_STORE_BINDING);
46
+ const chain = new WorkflowChainRunner(runtime, timerStore, (envelope) => {
47
+ // Effects resolve the dispatcher lazily so the binding exists by
48
+ // the time a workflow actually fires.
49
+ const dispatcher = container.resolve(FORGE_DISPATCHER_BINDING);
50
+ const effects = {
51
+ async send(action, input) {
52
+ return dispatcher.dispatch(action, input, envelope);
53
+ },
54
+ async enqueue(action, input) {
55
+ void dispatcher.dispatch(action, input, envelope);
56
+ },
57
+ async publish(eventMsg) {
58
+ await dispatcher.publish([eventMsg], envelope);
59
+ },
60
+ };
61
+ return effects;
62
+ });
63
+ for (const workflow of workflows)
64
+ chain.register(workflow);
65
+ container.register(FORGE_WORKFLOW_CHAIN_BINDING, chain);
66
+ runtime.hooks.EventPublishing.use(async (payload, next) => {
67
+ await chain.apply(payload.event, payload.envelope);
68
+ await next();
69
+ }, { name: "forge.publish.workflows", priority: EVENT_PUBLISHING_PRIORITIES.workflows });
70
+ on("AppReady", () => {
71
+ const hookChain = runtime.hooks.EventPublishing;
72
+ const steps = (hookChain.chain ?? []).filter((s) => s.name === "forge.publish.workflows");
73
+ if (steps.length > 1) {
74
+ // eslint-disable-next-line no-console
75
+ console.warn(`workflowsPlugin: detected ${steps.length} "forge.publish.workflows" steps on the EventPublishing chain. ` +
76
+ `Install either workflowsPlugin OR forgePlugin's options.workflows path, not both.`);
77
+ }
78
+ });
79
+ },
80
+ };
81
+ }
@@ -1,19 +1,19 @@
1
1
  /**
2
- * `createForgeApp` — foundation-form composition entry point.
2
+ * `createForgeApp` — composition entry point for forge-shaped apps.
3
3
  *
4
- * Wraps `createRuntime() + runtime.registerPlugin(forgePlugin) +
5
- * runtime.start/stop` with the forge dispatch verbs added on the returned
6
- * app handle. Each verb resolves the `ForgeDispatcher` bound on the
7
- * container by `forgePlugin` and delegates.
4
+ * A thin wrapper over `createApp({appName, plugins})` that installs forge,
5
+ * registers direct domain primitives (actors / projections / workflows /
6
+ * etc.) during boot, and surfaces forge's dispatch verbs (`dispatch`,
7
+ * `publish`, `query`, …) as methods on the returned handle. Each verb
8
+ * resolves the `ForgeDispatcher` bound by `forgePlugin` and delegates.
8
9
  *
9
- * Modules are gone in 0.10 — the App is the bounded context. Pass
10
- * actors / projections / queries / workflows / external calls / handlers
11
- * directly on opts; they register against the dispatcher during plugin
12
- * boot (an AppBooting chain step does the registration before any
13
- * plugin's boot() runs).
10
+ * The App is the bounded context. Pass actors / projections / queries /
11
+ * workflows / external calls / handlers directly on opts; they register
12
+ * against the dispatcher during `AppBooting`, after every plugin's setup
13
+ * ran (so the dispatcher is bound) but before plugin boot queues fire.
14
14
  */
15
- import { type Container } from "@nwire/container/awilix";
16
15
  import { type PluginDefinition, type Runtime } from "@nwire/app";
16
+ import type { Container } from "@nwire/container";
17
17
  import type { Logger } from "@nwire/logger";
18
18
  import type { MessageEnvelope } from "@nwire/envelope";
19
19
  import type { z } from "zod";
@@ -1,39 +1,35 @@
1
1
  /**
2
- * `createForgeApp` — foundation-form composition entry point.
2
+ * `createForgeApp` — composition entry point for forge-shaped apps.
3
3
  *
4
- * Wraps `createRuntime() + runtime.registerPlugin(forgePlugin) +
5
- * runtime.start/stop` with the forge dispatch verbs added on the returned
6
- * app handle. Each verb resolves the `ForgeDispatcher` bound on the
7
- * container by `forgePlugin` and delegates.
4
+ * A thin wrapper over `createApp({appName, plugins})` that installs forge,
5
+ * registers direct domain primitives (actors / projections / workflows /
6
+ * etc.) during boot, and surfaces forge's dispatch verbs (`dispatch`,
7
+ * `publish`, `query`, …) as methods on the returned handle. Each verb
8
+ * resolves the `ForgeDispatcher` bound by `forgePlugin` and delegates.
8
9
  *
9
- * Modules are gone in 0.10 — the App is the bounded context. Pass
10
- * actors / projections / queries / workflows / external calls / handlers
11
- * directly on opts; they register against the dispatcher during plugin
12
- * boot (an AppBooting chain step does the registration before any
13
- * plugin's boot() runs).
10
+ * The App is the bounded context. Pass actors / projections / queries /
11
+ * workflows / external calls / handlers directly on opts; they register
12
+ * against the dispatcher during `AppBooting`, after every plugin's setup
13
+ * ran (so the dispatcher is bound) but before plugin boot queues fire.
14
14
  */
15
- import { createContainer } from "@nwire/container/awilix";
16
- import { createRuntime } from "@nwire/app";
15
+ import { createApp } from "@nwire/app";
17
16
  import { forgePlugin as defaultForgePlugin, FORGE_DISPATCHER_BINDING, } from "./forge-plugin.js";
18
17
  export function createForgeApp(options) {
19
- const container = options.container ?? createContainer();
20
- const runtime = createRuntime({
21
- container,
22
- logger: options.logger,
18
+ // Forge plugin first; user plugins after. Users can pass an explicit
19
+ // `createForgePlugin(opts)` in their list to control stores.
20
+ const plugins = ensureForgePlugin(options.plugins ?? []);
21
+ const base = createApp({
23
22
  appName: options.name,
23
+ container: options.container,
24
+ logger: options.logger,
25
+ plugins,
24
26
  });
25
- // Forge plugin first; user plugins after. The user can also pass an
26
- // explicit `createForgePlugin(opts)` in their list to control stores.
27
- const plugins = ensureForgePlugin(options.plugins ?? []);
28
- for (const p of plugins) {
29
- runtime.registerPlugin(p);
30
- }
31
27
  // Direct registrations land on the dispatcher during AppBooting —
32
28
  // after every plugin's setup ran (so forge's dispatcher is bound)
33
29
  // but before plugin boot queues fire (so domain routes are wired
34
- // when downstream plugins' boot work might reach for them).
35
- runtime.hooks.AppBooting.use(async (_, next) => {
36
- const d = container.resolve(FORGE_DISPATCHER_BINDING);
30
+ // when downstream plugin boot work might reach for them).
31
+ base.runtime.hooks.AppBooting.use(async (_, next) => {
32
+ const d = base.container.resolve(FORGE_DISPATCHER_BINDING);
37
33
  for (const h of options.handlers ?? []) {
38
34
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
35
  d.registerActionHandler(h);
@@ -50,16 +46,16 @@ export function createForgeApp(options) {
50
46
  d.registerExternalCall(call);
51
47
  await next();
52
48
  }, { name: "forge.app.register-domain", priority: 1_000_000 });
53
- const dispatcher = () => container.resolve(FORGE_DISPATCHER_BINDING);
49
+ const dispatcher = () => base.container.resolve(FORGE_DISPATCHER_BINDING);
54
50
  const app = {
55
51
  $nwireApp: true,
56
52
  appName: options.name,
57
- runtime,
58
- container,
59
- start: () => runtime.start(),
60
- stop: (reason) => runtime.stop(reason),
61
- boot: () => runtime.start(),
62
- shutdown: () => runtime.stop(),
53
+ runtime: base.runtime,
54
+ container: base.container,
55
+ start: () => base.start(),
56
+ stop: (reason) => base.stop(reason),
57
+ boot: () => base.start(),
58
+ shutdown: () => base.stop(),
63
59
  dispatcher,
64
60
  dispatch: (action, input, parentEnvelope, opts) => dispatcher().dispatch(action, input, parentEnvelope, opts),
65
61
  publish: (events, parentEnvelope) => dispatcher().publish(events, parentEnvelope),
@@ -133,7 +133,34 @@ 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
+ * Publish a batch of events. Each event flows through the
138
+ * `EventPublishing` hook chain — idempotency → actors → projections →
139
+ * workflows → bus — at structurally enforced priorities. The chain
140
+ * participants are attached once at plugin setup via
141
+ * {@link attachPublishChain}.
142
+ *
143
+ * Telemetry (`event.deduped` / `event.published`) is pushed here based
144
+ * on whether the idempotency step short-circuited the chain.
145
+ */
136
146
  publish(events: readonly EventMessage[], parentEnvelope: MessageEnvelope): Promise<void>;
147
+ /**
148
+ * Attach forge's atomic publish chain to the `EventPublishing` hook.
149
+ * Priority slots are defined in `EVENT_PUBLISHING_PRIORITIES`:
150
+ *
151
+ * 1000 — idempotency gate. Short-circuits on duplicate.
152
+ * 800 — actor state transitions.
153
+ * 600 — projection folds.
154
+ * 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
+ *
160
+ * Called once at plugin setup. Idempotent — re-attaching is a no-op
161
+ * because the hook engine guards against duplicate step names.
162
+ */
163
+ attachPublishChain(): void;
137
164
  applyExternalEvent(eventName: string, payload: unknown, envelope: MessageEnvelope): Promise<void>;
138
165
  private foldProjections;
139
166
  private applyToActors;
@@ -21,13 +21,14 @@ import { hook } from "@nwire/hooks";
21
21
  import { isValidated, markValidated } from "@nwire/messages";
22
22
  import { seedEnvelope, deriveEnvelope } from "@nwire/envelope";
23
23
  import { loggerForEnvelope } from "@nwire/logger";
24
- import { buildDeadLetterEntry, InMemoryDeadLetterSink } from "@nwire/dead-letter";
24
+ import { buildDeadLetterEntry, InMemoryDeadLetterSink, } from "@nwire/dead-letter";
25
25
  import { sleep, computeBackoff, parseDelay } from "../helpers/retry-helpers.js";
26
26
  import { InMemoryActorStore, createInitialInstance, ActorVersionConflictError, } from "../stores/actor-store.js";
27
27
  import { InMemoryProjectionStore } from "../stores/projection-store.js";
28
28
  import { InMemoryIdempotencyStore } from "../stores/idempotency-store.js";
29
29
  import { InMemoryWorkflowTimerStore, timerEventName, } from "../stores/workflow-timer-store.js";
30
30
  import { normalizeEventReturn } from "../messages/event-message.js";
31
+ import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
31
32
  export class ForgeDispatcher {
32
33
  runtime;
33
34
  // ─── Domain registries ──────────────────────────────────────────────
@@ -115,6 +116,17 @@ export class ForgeDispatcher {
115
116
  this.handlers.set(name, handler);
116
117
  this.ensureActionBeforeHook(name);
117
118
  this.ensureActionAfterHook(name);
119
+ // The action declares `emits: [SomeEvent, ...]`. Any event the
120
+ // author marked `.public()` carries `$public: true` — surface it
121
+ // to the dispatcher so the EventPublishing chain's bus + outbound
122
+ // steps fire when that event flows through.
123
+ const emits = handler.action.emits;
124
+ if (emits) {
125
+ for (const ev of emits) {
126
+ if (ev.$public === true)
127
+ this.publicEventNames.add(ev.name);
128
+ }
129
+ }
118
130
  }
119
131
  registerActor(actor) {
120
132
  if (this.actors.has(actor.name)) {
@@ -575,13 +587,30 @@ export class ForgeDispatcher {
575
587
  return hctx.result;
576
588
  }
577
589
  // ─── Publish + applyExternalEvent ──────────────────────────────────
590
+ /**
591
+ * Publish a batch of events. Each event flows through the
592
+ * `EventPublishing` hook chain — idempotency → actors → projections →
593
+ * workflows → bus — at structurally enforced priorities. The chain
594
+ * participants are attached once at plugin setup via
595
+ * {@link attachPublishChain}.
596
+ *
597
+ * Telemetry (`event.deduped` / `event.published`) is pushed here based
598
+ * on whether the idempotency step short-circuited the chain.
599
+ */
578
600
  async publish(events, parentEnvelope) {
579
601
  const appName = this.runtime.appName;
580
602
  for (let i = 0; i < events.length; i++) {
581
603
  const event = events[i];
582
604
  const dedupKey = events.length === 1 ? parentEnvelope.messageId : `${parentEnvelope.messageId}#${i}`;
583
605
  const childEnvelope = deriveEnvelope(parentEnvelope);
584
- if (await this.idempotencyStore.seen(dedupKey)) {
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) {
585
614
  this.runtime.pushTelemetry({
586
615
  kind: "event.deduped",
587
616
  event,
@@ -590,37 +619,87 @@ export class ForgeDispatcher {
590
619
  appName,
591
620
  ts: new Date().toISOString(),
592
621
  });
593
- continue;
594
622
  }
595
- await this.idempotencyStore.record(dedupKey);
596
- await this.applyToActors(event, childEnvelope);
597
- await this.foldProjections(event, childEnvelope);
598
- await this.runWorkflows(event, childEnvelope);
599
- this.runtime.pushTelemetry({
600
- kind: "event.published",
601
- event,
602
- envelope: childEnvelope,
603
- source: "in-process",
604
- appName,
605
- ts: new Date().toISOString(),
606
- });
607
- if (this.publishToBus && this.bus && this.publicEventNames.has(event.eventName)) {
608
- await this.bus.publish({
609
- eventName: event.eventName,
610
- payload: event.payload,
623
+ else {
624
+ this.runtime.pushTelemetry({
625
+ kind: "event.published",
626
+ event,
611
627
  envelope: childEnvelope,
612
- origin: appName,
628
+ source: "in-process",
629
+ appName,
630
+ ts: new Date().toISOString(),
613
631
  });
614
632
  }
615
633
  }
616
634
  }
635
+ /**
636
+ * Attach forge's atomic publish chain to the `EventPublishing` hook.
637
+ * Priority slots are defined in `EVENT_PUBLISHING_PRIORITIES`:
638
+ *
639
+ * 1000 — idempotency gate. Short-circuits on duplicate.
640
+ * 800 — actor state transitions.
641
+ * 600 — projection folds.
642
+ * 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
+ *
648
+ * Called once at plugin setup. Idempotent — re-attaching is a no-op
649
+ * because the hook engine guards against duplicate step names.
650
+ */
651
+ attachPublishChain() {
652
+ const hook = this.runtime.hooks.EventPublishing;
653
+ hook.use(async (payload, next) => {
654
+ const isNew = await this.idempotencyStore.recordIfNew(payload.dedupKey);
655
+ if (!isNew) {
656
+ payload.deduped = true;
657
+ return; // veto — downstream steps skipped
658
+ }
659
+ await next();
660
+ }, { name: "forge.publish.idempotency", priority: EVENT_PUBLISHING_PRIORITIES.idempotency });
661
+ hook.use(async (payload, next) => {
662
+ await this.applyToActors(payload.event, payload.envelope);
663
+ await next();
664
+ }, { name: "forge.publish.actors", priority: EVENT_PUBLISHING_PRIORITIES.actors });
665
+ hook.use(async (payload, next) => {
666
+ await this.foldProjections(payload.event, payload.envelope);
667
+ await next();
668
+ }, { name: "forge.publish.projections", priority: EVENT_PUBLISHING_PRIORITIES.projections });
669
+ hook.use(async (payload, next) => {
670
+ await this.runWorkflows(payload.event, payload.envelope);
671
+ await next();
672
+ }, { 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
+ }
617
695
  async applyExternalEvent(eventName, payload, envelope) {
618
696
  if (!this.externalEventNames.has(eventName)) {
619
697
  throw new Error(`Runtime.applyExternalEvent: "${eventName}" was not declared in any module's needs.externalEvents.`);
620
698
  }
621
699
  const appName = this.runtime.appName;
622
700
  const event = { eventName, payload };
623
- if (await this.idempotencyStore.seen(envelope.messageId)) {
701
+ const isNew = await this.idempotencyStore.recordIfNew(envelope.messageId);
702
+ if (!isNew) {
624
703
  this.runtime.pushTelemetry({
625
704
  kind: "event.deduped",
626
705
  event,
@@ -631,7 +710,6 @@ export class ForgeDispatcher {
631
710
  });
632
711
  return;
633
712
  }
634
- await this.idempotencyStore.record(envelope.messageId);
635
713
  await this.applyToActors(event, envelope);
636
714
  await this.foldProjections(event, envelope);
637
715
  await this.runWorkflows(event, envelope);
@@ -1,59 +1,74 @@
1
1
  /**
2
- * `forgePlugin` — the foundation-form delivery of forge's CQRS engine.
2
+ * Forge plugin installs the dispatcher, materialises forge's framework
3
+ * hooks, pins the action handler step on the dispatch chain, and registers
4
+ * any domain primitives passed in options.
3
5
  *
4
- * Used two ways:
6
+ * import { createApp } from "@nwire/app";
7
+ * import { createForgePlugin, FORGE_DISPATCHER_BINDING } from "@nwire/forge";
5
8
  *
6
- * // Default stores (InMemory*):
7
- * createApp({ plugins: [forgePlugin, ...] })
9
+ * const app = createApp({
10
+ * appName: "orders",
11
+ * plugins: [createForgePlugin({
12
+ * actors: [Order],
13
+ * projections: [OrdersDashboard],
14
+ * handlers: [placeOrder, sendReceipt],
15
+ * })],
16
+ * });
8
17
  *
9
- * // Custom stores / cross-service bus / persistent timers:
10
- * createApp({ plugins: [createForgePlugin({ actorStore, bus }), ...] })
18
+ * await app.start();
19
+ * const dispatcher = app.container.resolve(FORGE_DISPATCHER_BINDING);
11
20
  *
12
- * Setup work:
13
- * 1. Construct a `ForgeDispatcher(runtime, opts)`.
14
- * 2. Bind it on the container as `"forge.dispatcher"`; consumer code
15
- * (and forge.createApp's app-handle delegators) resolves it from
16
- * there to call `dispatch / publish / fireDueTimers / …`.
17
- * 3. Materialise the Action* / Event* framework-hook slots forge's
18
- * module augmentation declares — kernel pre-instantiates only
19
- * App* / Plugin* / Wire* slots; forge's domain slots come in via
20
- * the plugin so a non-forge app's runtime stays uncluttered.
21
- * 4. Pin the action handler chain step on `runtime.dispatchHook$` at
22
- * priority `-Infinity` so user-registered `runtime.use()` middleware
23
- * stays strictly OUTSIDE the handler invocation.
21
+ * Handlers and direct registrations land on the dispatcher during the
22
+ * `AppBooting` chain — after every plugin's setup ran (so the dispatcher
23
+ * is bound) but before plugin boot queues fire (so downstream plugins'
24
+ * boot work can reach domain routes already wired).
24
25
  *
25
- * Dispose work:
26
- * - Best-effort cleanup of the in-memory workflow timer scheduler if
27
- * the dispatcher started one. Persistent stores release through
28
- * their own `bind({ dispose })` registrations.
26
+ * `forgePlugin` is the default-options shortcut for tests, demos, and
27
+ * single-process apps. For custom stores or a cross-service bus, use
28
+ * `createForgePlugin(opts)`.
29
29
  */
30
30
  import type { PluginDefinition } from "@nwire/app";
31
- import { type ForgeDispatcherOptions } from "./forge-dispatcher.js";
32
- /** Canonical container binding name for the forge dispatcher. */
31
+ import { ForgeDispatcher, type ForgeDispatcherOptions } from "./forge-dispatcher.js";
32
+ import type { ActorDefinition } from "../primitives/define-actor.js";
33
+ import type { ProjectionDefinition } from "../primitives/define-projection.js";
34
+ import type { QueryDefinition } from "../primitives/define-query.js";
35
+ import type { WorkflowDefinition } from "../primitives/define-workflow.js";
36
+ import type { ExternalCallDefinition } from "../primitives/define-external-call.js";
37
+ import type { HandlerDefinition as ForgeHandlerDef } from "../primitives/define-handler.js";
38
+ /** Container binding the forge dispatcher is registered under. */
33
39
  export declare const FORGE_DISPATCHER_BINDING: "forge.dispatcher";
34
- /** Capability bindings handler / resolver ctx resolves these by name. */
40
+ /** Capability bindings consumed by handler / resolver ctx via `ctx.resolve(...)`. */
35
41
  export declare const FORGE_EXECUTE_BINDING: "execute";
36
42
  export declare const FORGE_SEND_BINDING: "send";
37
43
  export declare const FORGE_USE_PROJECTION_BINDING: "useProjection";
38
44
  /**
39
- * Default forge plugin uses in-memory stores for everything.
40
- * Suitable for tests, demos, and single-process apps. For custom
41
- * stores or a cross-service bus, use `createForgePlugin(opts)`.
42
- */
43
- export declare const forgePlugin: PluginDefinition;
44
- /**
45
- * Configurable forge plugin. Pass:
46
- * - durable stores (actorStore, projectionStore, deadLetterSink, …)
47
- * - a cross-service EventBus to bridge in-process events out
48
- * - domain registrations (actors, workflows, projections, queries,
49
- * externalCalls) — picked up at AppBooting and forwarded to the
50
- * dispatcher so consumers don't need to wire them manually.
45
+ * Options accepted by `createForgePlugin`. Includes the dispatcher's own
46
+ * options (stores, bus) and the domain primitives that register against
47
+ * the dispatcher during the boot phase.
51
48
  */
52
49
  export interface ForgePluginOptions extends ForgeDispatcherOptions {
53
- readonly actors?: readonly any[];
54
- readonly workflows?: readonly any[];
55
- readonly projections?: readonly any[];
56
- readonly queries?: readonly any[];
57
- readonly externalCalls?: readonly any[];
50
+ readonly handlers?: readonly ForgeHandlerDef<any>[];
51
+ readonly actors?: readonly ActorDefinition[];
52
+ readonly projections?: readonly ProjectionDefinition<any>[];
53
+ readonly queries?: readonly QueryDefinition<any, any, any>[];
54
+ readonly workflows?: readonly WorkflowDefinition[];
55
+ readonly externalCalls?: readonly ExternalCallDefinition<any, any>[];
58
56
  }
57
+ /**
58
+ * Construct a forge plugin with custom stores, bus, and/or domain
59
+ * primitives. The plugin's setup is synchronous; domain registration
60
+ * happens during the App's `AppBooting` chain.
61
+ */
59
62
  export declare function createForgePlugin(options?: ForgePluginOptions): PluginDefinition;
63
+ /** Default forge plugin — in-memory stores, no bus, no domain options. */
64
+ export declare const forgePlugin: PluginDefinition;
65
+ /**
66
+ * Resolve the forge dispatcher from an app's container. Convenience for
67
+ * test code and tooling that needs direct dispatcher access; handler code
68
+ * uses `ctx.execute` / `ctx.send` / `ctx.publish` instead.
69
+ */
70
+ export declare function forgeDispatcher(app: {
71
+ container: {
72
+ resolve<T>(name: string): T;
73
+ };
74
+ }): ForgeDispatcher;