@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.
- package/dist/framework-events.d.ts +1 -0
- package/dist/framework-events.js +1 -0
- package/dist/plugins/workflows-chain.d.ts +1 -1
- package/dist/plugins/workflows-chain.js +5 -0
- package/dist/primitives/define-workflow.d.ts +44 -2
- package/dist/primitives/define-workflow.js +52 -12
- package/dist/runtime/forge-dispatcher.d.ts +3 -0
- package/dist/runtime/forge-dispatcher.js +33 -1
- package/package.json +11 -11
package/dist/framework-events.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
169
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
38
|
-
"@nwire/bus": "0.
|
|
39
|
-
"@nwire/
|
|
40
|
-
"@nwire/
|
|
41
|
-
"@nwire/
|
|
42
|
-
"@nwire/
|
|
43
|
-
"@nwire/
|
|
44
|
-
"@nwire/
|
|
45
|
-
"@nwire/
|
|
46
|
-
"@nwire/
|
|
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",
|