@nwire/forge 0.11.1 → 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.
@@ -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]) {
@@ -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;
@@ -945,6 +945,14 @@ export class ForgeDispatcher {
945
945
  const correlationKey = correlationKeyOverride ?? `${tenantPrefix}${userKey}`;
946
946
  const fireCtx = {
947
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,
948
956
  load: (key) => store.get(key),
949
957
  save: (key, instance) => store.set(key, instance),
950
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.1",
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/messages": "0.11.1",
38
- "@nwire/app": "0.11.1",
39
- "@nwire/logger": "0.11.1",
40
- "@nwire/container": "0.11.1",
41
- "@nwire/wires": "0.11.1",
42
- "@nwire/hooks": "0.11.1",
43
- "@nwire/handler": "0.11.1",
44
- "@nwire/bus": "0.11.1",
45
- "@nwire/envelope": "0.11.1",
46
- "@nwire/dead-letter": "0.11.1"
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",