@nwire/forge 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -93,5 +93,6 @@ export declare const EVENT_PUBLISHING_PRIORITIES: {
93
93
  readonly projections: 600;
94
94
  readonly workflows: 400;
95
95
  readonly bus: 200;
96
+ readonly outbound: 100;
96
97
  };
97
98
  export type ForgeFrameworkSlot = (typeof forgeFrameworkSlots)[number];
@@ -29,4 +29,5 @@ export const EVENT_PUBLISHING_PRIORITIES = {
29
29
  projections: 600,
30
30
  workflows: 400,
31
31
  bus: 200,
32
+ outbound: 100,
32
33
  };
@@ -20,7 +20,7 @@ import { type Hook } from "@nwire/hooks";
20
20
  import { type MessageEnvelope } from "@nwire/envelope";
21
21
  import { type Runtime } from "@nwire/app";
22
22
  import type { WorkflowDefinition, WorkflowInstance, WorkflowEffects } from "../primitives/define-workflow.js";
23
- import type { EventMessage } from "../messages/event-message.js";
23
+ import { type EventMessage } from "../messages/event-message.js";
24
24
  import type { WorkflowFireHookCtx } from "../runtime/forge-types.js";
25
25
  import type { WorkflowTimerStore } from "../stores/workflow-timer-store.js";
26
26
  /**
@@ -20,6 +20,7 @@ import { randomUUID } from "node:crypto";
20
20
  import { hook } from "@nwire/hooks";
21
21
  import { loggerForEnvelope } from "@nwire/logger";
22
22
  import { serializeError } from "@nwire/app";
23
+ import { eventFactory } from "../messages/event-message.js";
23
24
  import { computeBackoff, parseDelay, sleep } from "../helpers/retry-helpers.js";
24
25
  export class WorkflowChainRunner {
25
26
  runtime;
@@ -88,6 +89,10 @@ export class WorkflowChainRunner {
88
89
  const correlationKey = correlationKeyOverride ?? `${tenantPrefix}${userKey}`;
89
90
  const fireCtx = {
90
91
  ...baseEffects,
92
+ envelope,
93
+ emit: (eventDef, payload) => baseEffects.publish(eventFactory(eventDef)(payload)),
94
+ resolve: (name) => this.runtime.resolve(name),
95
+ logger: log,
91
96
  load: (key) => store.get(key),
92
97
  save: (key, instance) => store.set(key, instance),
93
98
  drop: (key) => store.delete(key),
@@ -19,6 +19,8 @@
19
19
  import type { z } from "zod";
20
20
  import type { ZodTypeAny, SourceLocation } from "@nwire/messages";
21
21
  import type { EventDefinition, EventPayload } from "@nwire/messages";
22
+ import type { MessageEnvelope } from "@nwire/envelope";
23
+ import type { Logger } from "@nwire/logger";
22
24
  import type { ActionDefinition, ActionInput, ActionResult } from "./define-action.js";
23
25
  import type { EventMessage } from "../messages/event-message.js";
24
26
  import { type PublicMarker } from "../helpers/public-marker.js";
@@ -85,11 +87,13 @@ export type StateBody = () => Awaitable<Transition>;
85
87
  * `on()` returns are ignored (there's no state machine to transition).
86
88
  */
87
89
  export interface StatelessWorkflowContext extends WorkflowEffects {
88
- on<E extends EventDefinition>(event: E, handler: (payload: EventPayload<E>) => Awaitable<Transition>, opts?: WorkflowOnOptions): void;
90
+ on<E extends EventDefinition>(event: E, handler: (payload: EventPayload<E>, ctx: WorkflowReactionContext) => Awaitable<Transition>, opts?: WorkflowOnOptions): void;
89
91
  /** Subscribe to the `complete` pseudo-event. Fires after each handler. */
90
92
  on(event: CompleteEvent, handler: () => Awaitable<void>, opts?: WorkflowOnOptions): void;
91
93
  /** Subscribe to a saga timer fire. */
92
- on(event: TimerDefinition, handler: (payload?: unknown) => Awaitable<Transition>, opts?: WorkflowOnOptions): void;
94
+ on(event: TimerDefinition, handler: (payload: unknown, ctx: WorkflowReactionContext) => Awaitable<Transition>, opts?: WorkflowOnOptions): void;
95
+ /** Canonical event verb — alias of `on`. Prefer `when`; `on` is retained for now. */
96
+ when: StatelessWorkflowContext["on"];
93
97
  }
94
98
  /**
95
99
  * Stateful workflow context — adds `data`, `assign`, `states`, and the
@@ -174,6 +178,19 @@ export interface WorkflowDefinition extends PublicMarker {
174
178
  * and run effects. Supplied by the kernel-side runtime.
175
179
  */
176
180
  export interface FireContext extends WorkflowEffects {
181
+ /** The triggering event's envelope. Threaded onto the reaction ctx. */
182
+ readonly envelope: MessageEnvelope;
183
+ /** Publish an event by definition + payload (listener-parity `emit`). */
184
+ readonly emit: (event: EventDefinition & {
185
+ readonly name: string;
186
+ readonly schema: {
187
+ parse(input: unknown): unknown;
188
+ };
189
+ }, payload: unknown) => Promise<void>;
190
+ /** Resolve a dependency from the container. */
191
+ readonly resolve: <T = unknown>(name: string) => T;
192
+ /** The envelope-scoped logger. */
193
+ readonly logger: Logger;
177
194
  /** Load or create the instance row for this correlation key. */
178
195
  readonly load: (key: string) => WorkflowInstance | undefined;
179
196
  readonly save: (key: string, instance: WorkflowInstance) => void;
@@ -199,6 +216,31 @@ export interface WorkflowInstance {
199
216
  /** Names of events the current state has registered handlers for. */
200
217
  scopedEvents: string[];
201
218
  }
219
+ /**
220
+ * Reaction context handed to a `when` handler as its second argument. The
221
+ * listener-subset (`envelope` · `send` · `emit` · `resolve` · `logger`) is
222
+ * identical to `ListenerContext`, so a `when(Event, (payload, ctx) => …)`
223
+ * block written for a listener runs unchanged here; the workflow adds
224
+ * `enqueue` · `publish` · `assign` · `schedule`.
225
+ */
226
+ export interface WorkflowReactionContext {
227
+ readonly envelope: MessageEnvelope;
228
+ send<A extends ActionDefinition>(action: A, input: ActionInput<A>): Promise<ActionResult<A>>;
229
+ emit<E extends EventDefinition & {
230
+ readonly name: string;
231
+ readonly schema: {
232
+ parse(input: unknown): unknown;
233
+ };
234
+ }>(event: E, payload: EventPayload<E>): Promise<void>;
235
+ resolve<T = unknown>(name: string): T;
236
+ readonly logger: Logger;
237
+ enqueue<A extends ActionDefinition>(action: A, input: ActionInput<A>): Promise<void>;
238
+ publish(event: EventMessage): Promise<void>;
239
+ /** Stateful only — merge a patch into the instance data. No-op when stateless. */
240
+ assign(patch: Record<string, unknown>): Promise<void>;
241
+ /** Stateful only — enqueue a declared timer. No-op when stateless. */
242
+ schedule(timer: TimerDefinition, payload?: unknown): Promise<void>;
243
+ }
202
244
  /**
203
245
  * Stateful overload — when `options.data` is declared, the closure
204
246
  * receives a `StatefulWorkflowContext` with `data`, `assign`, `states`,
@@ -81,6 +81,7 @@ closure, options) {
81
81
  const probeCtx = stateful
82
82
  ? {
83
83
  on: probeOn,
84
+ when: probeOn,
84
85
  send: async () => undefined,
85
86
  enqueue: async () => undefined,
86
87
  publish: async () => undefined,
@@ -93,6 +94,7 @@ closure, options) {
93
94
  }
94
95
  : {
95
96
  on: probeOn,
97
+ when: probeOn,
96
98
  send: async () => undefined,
97
99
  enqueue: async () => undefined,
98
100
  publish: async () => undefined,
@@ -164,24 +166,43 @@ closure, options) {
164
166
  async function fireStateless({ closure, event, fireCtx, completeMarker, }) {
165
167
  const matched = [];
166
168
  const completeHandlers = [];
169
+ const register = ((subscribedEvent, handler) => {
170
+ const eventName = subscribedEvent.name;
171
+ if (eventName === completeMarker.name) {
172
+ completeHandlers.push(handler);
173
+ return;
174
+ }
175
+ if (eventName === event.eventName) {
176
+ matched.push(handler);
177
+ }
178
+ });
167
179
  const ctx = {
168
- on: ((subscribedEvent, handler) => {
169
- const eventName = subscribedEvent.name;
170
- if (eventName === completeMarker.name) {
171
- completeHandlers.push(handler);
172
- return;
173
- }
174
- if (eventName === event.eventName) {
175
- matched.push(handler);
176
- }
177
- }),
180
+ on: register,
181
+ when: register,
178
182
  send: fireCtx.send,
179
183
  enqueue: fireCtx.enqueue,
180
184
  publish: fireCtx.publish,
181
185
  };
182
186
  closure(ctx);
187
+ // Reaction ctx — the listener-subset + workflow effects. Stateless, so
188
+ // assign/schedule are no-ops (no per-correlation instance or timers).
189
+ const reactionCtx = {
190
+ envelope: fireCtx.envelope,
191
+ send: fireCtx.send,
192
+ emit: fireCtx.emit,
193
+ resolve: fireCtx.resolve,
194
+ logger: fireCtx.logger,
195
+ enqueue: fireCtx.enqueue,
196
+ publish: fireCtx.publish,
197
+ assign: async () => {
198
+ fireCtx.logger.warn?.("ctx.assign() is a no-op in a stateless workflow — declare `data` to make it stateful, or move this reaction to a listener.");
199
+ },
200
+ schedule: async () => {
201
+ fireCtx.logger.warn?.("ctx.schedule() is a no-op in a stateless workflow — declare `data`/`states` to use timers.");
202
+ },
203
+ };
183
204
  for (const h of matched) {
184
- await h(event.payload);
205
+ await h(event.payload, reactionCtx);
185
206
  }
186
207
  // `complete` for a stateless workflow fires after each successful event.
187
208
  if (matched.length > 0) {
@@ -248,6 +269,7 @@ async function fireStateful(args) {
248
269
  };
249
270
  const ctx = {
250
271
  on: onImpl,
272
+ when: onImpl,
251
273
  async send(action, input) {
252
274
  if (scope.kind === "collect")
253
275
  return undefined;
@@ -322,7 +344,25 @@ async function fireStateful(args) {
322
344
  fireCtx.save(key, instance);
323
345
  return;
324
346
  }
325
- const next = await chosen.handler(event.payload);
347
+ // Reaction ctx listener-subset + workflow effects. assign/schedule
348
+ // do the real per-instance work (handlers run in global scope here).
349
+ const reactionCtx = {
350
+ envelope: fireCtx.envelope,
351
+ send: fireCtx.send,
352
+ emit: fireCtx.emit,
353
+ resolve: fireCtx.resolve,
354
+ logger: fireCtx.logger,
355
+ enqueue: fireCtx.enqueue,
356
+ publish: fireCtx.publish,
357
+ async assign(patch) {
358
+ instance.data = { ...instance.data, ...patch };
359
+ fireCtx.save(key, instance);
360
+ },
361
+ async schedule(timer, payload) {
362
+ await fireCtx.scheduleTimer(timer.name, timer.delay, payload);
363
+ },
364
+ };
365
+ const next = await chosen.handler(event.payload, reactionCtx);
326
366
  // Persist any assign() effects before the transition.
327
367
  fireCtx.save(key, instance);
328
368
  if (next && typeof next === "string" && stateSpecs[next]) {
@@ -153,6 +153,9 @@ export declare class ForgeDispatcher {
153
153
  * 600 — projection folds.
154
154
  * 400 — workflow correlation + fire.
155
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, …).
156
159
  *
157
160
  * Called once at plugin setup. Idempotent — re-attaching is a no-op
158
161
  * because the hook engine guards against duplicate step names.
@@ -27,7 +27,7 @@ import { InMemoryActorStore, createInitialInstance, ActorVersionConflictError, }
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
- import { normalizeEventReturn } from "../messages/event-message.js";
30
+ import { normalizeEventReturn, eventFactory } from "../messages/event-message.js";
31
31
  import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
32
32
  export class ForgeDispatcher {
33
33
  runtime;
@@ -116,6 +116,17 @@ export class ForgeDispatcher {
116
116
  this.handlers.set(name, handler);
117
117
  this.ensureActionBeforeHook(name);
118
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
+ }
119
130
  }
120
131
  registerActor(actor) {
121
132
  if (this.actors.has(actor.name)) {
@@ -630,6 +641,9 @@ export class ForgeDispatcher {
630
641
  * 600 — projection folds.
631
642
  * 400 — workflow correlation + fire.
632
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, …).
633
647
  *
634
648
  * Called once at plugin setup. Idempotent — re-attaching is a no-op
635
649
  * because the hook engine guards against duplicate step names.
@@ -667,6 +681,16 @@ export class ForgeDispatcher {
667
681
  }
668
682
  await next();
669
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 });
670
694
  }
671
695
  async applyExternalEvent(eventName, payload, envelope) {
672
696
  if (!this.externalEventNames.has(eventName)) {
@@ -921,6 +945,14 @@ export class ForgeDispatcher {
921
945
  const correlationKey = correlationKeyOverride ?? `${tenantPrefix}${userKey}`;
922
946
  const fireCtx = {
923
947
  ...baseEffects,
948
+ envelope,
949
+ // Local publish through the forge chain — fans out to in-process
950
+ // reactions; the outbound stage drains it to a sink only if the
951
+ // event is .public() and a sink is installed. Built via the event
952
+ // factory so messageId / correlation / idempotency hold.
953
+ emit: (eventDef, payload) => self.publish([eventFactory(eventDef)(payload)], envelope),
954
+ resolve: (name) => handlerCtx.resolve(name),
955
+ logger: log,
924
956
  load: (key) => store.get(key),
925
957
  save: (key, instance) => store.set(key, instance),
926
958
  drop: (key) => store.delete(key),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/forge",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Nwire — the framework's core primitives. defineAction, defineEvent, defineHandler, defineActor, defineProjection, defineQuery, defineWorkflow, defineModule, defineApp, definePlugin, createApp. MessageEnvelope with correlation/causation. The runtime is the bus.",
5
5
  "keywords": [
6
6
  "actions",
@@ -34,16 +34,16 @@
34
34
  "emittery": "1.0.1",
35
35
  "koa": "^2.16.4",
36
36
  "zod": "^4.0.0",
37
- "@nwire/app": "0.11.0",
38
- "@nwire/bus": "0.11.0",
39
- "@nwire/dead-letter": "0.11.0",
40
- "@nwire/handler": "0.11.0",
41
- "@nwire/container": "0.11.0",
42
- "@nwire/messages": "0.11.0",
43
- "@nwire/wires": "0.11.0",
44
- "@nwire/logger": "0.11.0",
45
- "@nwire/envelope": "0.11.0",
46
- "@nwire/hooks": "0.11.0"
37
+ "@nwire/app": "0.12.0",
38
+ "@nwire/bus": "0.12.0",
39
+ "@nwire/container": "0.12.0",
40
+ "@nwire/dead-letter": "0.12.0",
41
+ "@nwire/envelope": "0.12.0",
42
+ "@nwire/hooks": "0.12.0",
43
+ "@nwire/messages": "0.12.0",
44
+ "@nwire/handler": "0.12.0",
45
+ "@nwire/wires": "0.12.0",
46
+ "@nwire/logger": "0.12.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/koa": "^2.15.0",