@nwire/forge 0.12.1 → 0.13.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/README.md +100 -83
- package/dist/framework-events.d.ts +8 -37
- package/dist/framework-events.js +7 -3
- package/dist/helpers/cli-runner.js +21 -10
- package/dist/index.d.ts +8 -7
- package/dist/index.js +7 -6
- package/dist/plugins/actions-chain.d.ts +39 -22
- package/dist/plugins/actions-chain.js +117 -78
- package/dist/plugins/actions-plugin.d.ts +26 -23
- package/dist/plugins/actions-plugin.js +122 -44
- package/dist/plugins/actors-chain.d.ts +9 -2
- package/dist/plugins/actors-chain.js +62 -2
- package/dist/plugins/actors-plugin.d.ts +1 -1
- package/dist/plugins/actors-plugin.js +24 -14
- package/dist/plugins/external-calls-plugin.d.ts +28 -0
- package/dist/plugins/external-calls-plugin.js +136 -0
- package/dist/plugins/idempotency-plugin.d.ts +15 -1
- package/dist/plugins/idempotency-plugin.js +56 -11
- package/dist/plugins/projections-chain.d.ts +2 -2
- package/dist/plugins/projections-chain.js +2 -2
- package/dist/plugins/projections-plugin.d.ts +1 -1
- package/dist/plugins/projections-plugin.js +4 -13
- package/dist/plugins/queries-chain.d.ts +4 -3
- package/dist/plugins/queries-chain.js +8 -5
- package/dist/plugins/queries-plugin.d.ts +15 -29
- package/dist/plugins/queries-plugin.js +36 -49
- package/dist/plugins/workflows-chain.d.ts +9 -2
- package/dist/plugins/workflows-chain.js +19 -1
- package/dist/plugins/workflows-plugin.d.ts +1 -1
- package/dist/plugins/workflows-plugin.js +12 -20
- package/dist/primitives/define-action.d.ts +80 -115
- package/dist/primitives/define-action.js +111 -56
- package/dist/primitives/define-actor.d.ts +103 -214
- package/dist/primitives/define-actor.js +157 -216
- package/dist/primitives/define-handler.d.ts +42 -112
- package/dist/primitives/define-handler.js +14 -45
- package/dist/primitives/define-projection.d.ts +23 -28
- package/dist/primitives/define-projection.js +29 -32
- package/dist/primitives/define-query.d.ts +52 -42
- package/dist/primitives/define-query.js +65 -28
- package/dist/primitives/define-workflow.d.ts +8 -11
- package/dist/primitives/define-workflow.js +14 -8
- package/dist/runtime/forge-dispatcher.d.ts +30 -12
- package/dist/runtime/forge-dispatcher.js +199 -237
- package/dist/runtime/forge-plugin.d.ts +8 -0
- package/dist/runtime/forge-plugin.js +113 -31
- package/dist/runtime/forge-plugins.d.ts +55 -0
- package/dist/runtime/forge-plugins.js +57 -0
- package/dist/runtime/forge-types.d.ts +8 -2
- package/dist/runtime/with-forge.d.ts +8 -11
- package/dist/runtime/with-forge.js +9 -11
- package/dist/stores/idempotency-store.d.ts +1 -1
- package/package.json +12 -12
|
@@ -1,48 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Action runner —
|
|
2
|
+
* Action runner — the forge command pipeline, composed onto the real handler's
|
|
3
|
+
* own hook chain. There is no parallel dispatcher and no `run`-override:
|
|
4
|
+
* `install(action)` attaches the pipeline as a high-priority `.use()` step on
|
|
5
|
+
* the action handler's hook, so dispatching it through `runtime.execute` (from
|
|
6
|
+
* a wire, queue, cron, or another handler's `ctx.request`) runs retry / DLQ /
|
|
7
|
+
* ActionDispatching / before-after / telemetry / event-publish as the outermost
|
|
8
|
+
* step. The step invokes the handler body directly in its retry loop and
|
|
9
|
+
* short-circuits (`@nwire/hooks` `compose` forbids calling `next()` twice, so
|
|
10
|
+
* retry cannot loop the chain).
|
|
3
11
|
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* The runner builds a handler ctx with `resolve`, `request`, `send`, and
|
|
10
|
-
* `query` wired through the container. Returned events from the handler
|
|
11
|
-
* flow into the `EventPublishing` chain via the injected `publishEvents`
|
|
12
|
-
* callback.
|
|
12
|
+
* The handler ctx (input, envelope, request/send/emit/query/actor/…) is built
|
|
13
|
+
* by `runtime.execute`'s capability ctx — each forge concern plugin contributes
|
|
14
|
+
* its verb. This runner owns the pipeline + the per-action before/after hooks +
|
|
15
|
+
* a `dispatch` convenience; events a handler returns flow out via the injected
|
|
16
|
+
* `publishEvents` (local LocalDelivery fold + outbound sink for public).
|
|
13
17
|
*/
|
|
14
18
|
import { serializeError } from "@nwire/app";
|
|
15
|
-
import { deriveEnvelope, seedEnvelope } from "@nwire/envelope";
|
|
16
19
|
import { loggerForEnvelope } from "@nwire/logger";
|
|
17
20
|
import { isValidated, markValidated } from "@nwire/messages";
|
|
18
21
|
import { hook } from "@nwire/hooks";
|
|
19
22
|
import { buildDeadLetterEntry } from "@nwire/dead-letter";
|
|
20
23
|
import { normalizeEventReturn } from "../messages/event-message.js";
|
|
21
24
|
import { computeBackoff, sleep } from "../helpers/retry-helpers.js";
|
|
25
|
+
/**
|
|
26
|
+
* Priority for the pipeline `.use()` step — outermost, above any user chain
|
|
27
|
+
* step and the kernel's `MIN_SAFE_INTEGER` terminal (which stays inert since
|
|
28
|
+
* the pipeline short-circuits and runs the body itself).
|
|
29
|
+
*/
|
|
30
|
+
const PIPELINE_PRIORITY = Number.MAX_SAFE_INTEGER;
|
|
31
|
+
/**
|
|
32
|
+
* Actions are module-level singletons, but the command pipeline is
|
|
33
|
+
* app-instance-specific. Track the pipeline step currently installed on each
|
|
34
|
+
* action object so re-building an app (tests, multi-app) REPLACES it instead of
|
|
35
|
+
* stacking a second step that closes over a dead runtime/store. Last install
|
|
36
|
+
* wins — matching the previous `run`-override semantics.
|
|
37
|
+
*/
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
const installedSteps = new WeakMap();
|
|
22
40
|
export class ActionRunner {
|
|
23
41
|
runtime;
|
|
24
42
|
container;
|
|
25
43
|
deadLetterSink;
|
|
26
44
|
publishEvents;
|
|
45
|
+
// Registered actions, keyed by name. The action IS the handler.
|
|
27
46
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
47
|
handlers = new Map();
|
|
29
48
|
beforeHooks = new Map();
|
|
30
49
|
afterHooks = new Map();
|
|
31
|
-
augmenters = [];
|
|
32
50
|
constructor(runtime, container, deadLetterSink, publishEvents) {
|
|
33
51
|
this.runtime = runtime;
|
|
34
52
|
this.container = container;
|
|
35
53
|
this.deadLetterSink = deadLetterSink;
|
|
36
54
|
this.publishEvents = publishEvents;
|
|
37
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Compose the command pipeline onto an action's own hook chain. The action is
|
|
58
|
+
* already registered on the runtime (by the app); this only attaches behavior.
|
|
59
|
+
*/
|
|
38
60
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
61
|
+
install(action) {
|
|
62
|
+
const name = action.name;
|
|
63
|
+
if (this.handlers.has(name)) {
|
|
64
|
+
throw new Error(`actionsPlugin: handler for action "${name}" already registered.`);
|
|
65
|
+
}
|
|
66
|
+
if (!action.config?.handler || typeof action.use !== "function") {
|
|
67
|
+
throw new Error(`actionsPlugin: action "${name}" has no inline handler to run. Declare it ` +
|
|
68
|
+
`with defineAction({ name, input, handler }).`);
|
|
42
69
|
}
|
|
43
|
-
this.handlers.set(
|
|
44
|
-
this.ensureBeforeHook(
|
|
45
|
-
this.ensureAfterHook(
|
|
70
|
+
this.handlers.set(name, action);
|
|
71
|
+
this.ensureBeforeHook(name);
|
|
72
|
+
this.ensureAfterHook(name);
|
|
73
|
+
// Replace any pipeline a prior app instance left on this singleton action.
|
|
74
|
+
const prior = installedSteps.get(action);
|
|
75
|
+
if (prior && typeof action.off === "function")
|
|
76
|
+
action.off(prior);
|
|
77
|
+
const rawHandler = action.config.handler;
|
|
78
|
+
const step = async (ctx) => {
|
|
79
|
+
ctx.result = await this.runActionPipeline(action, rawHandler, ctx, ctx.signal ?? new AbortController().signal);
|
|
80
|
+
// No next(): the pipeline ran the body; the kernel terminal stays inert.
|
|
81
|
+
};
|
|
82
|
+
action.use(step, { priority: PIPELINE_PRIORITY, name: `forge.action:${name}` });
|
|
83
|
+
installedSteps.set(action, step);
|
|
46
84
|
}
|
|
47
85
|
/** Action handler names, in registration order. */
|
|
48
86
|
listHandlers() {
|
|
@@ -50,7 +88,14 @@ export class ActionRunner {
|
|
|
50
88
|
}
|
|
51
89
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
90
|
findActionByName(name) {
|
|
53
|
-
return this.handlers.get(name)
|
|
91
|
+
return this.handlers.get(name);
|
|
92
|
+
}
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
94
|
+
findHandler(name) {
|
|
95
|
+
return this.handlers.get(name);
|
|
96
|
+
}
|
|
97
|
+
hasHandler(name) {
|
|
98
|
+
return this.handlers.has(name);
|
|
54
99
|
}
|
|
55
100
|
ensureBeforeHook(name) {
|
|
56
101
|
let h = this.beforeHooks.get(name);
|
|
@@ -58,6 +103,7 @@ export class ActionRunner {
|
|
|
58
103
|
return h;
|
|
59
104
|
h = hook(`action.before:${name}`);
|
|
60
105
|
this.beforeHooks.set(name, h);
|
|
106
|
+
this.runtime.observe(h);
|
|
61
107
|
return h;
|
|
62
108
|
}
|
|
63
109
|
ensureAfterHook(name) {
|
|
@@ -66,23 +112,44 @@ export class ActionRunner {
|
|
|
66
112
|
return h;
|
|
67
113
|
h = hook(`action.after:${name}`);
|
|
68
114
|
this.afterHooks.set(name, h);
|
|
115
|
+
this.runtime.observe(h);
|
|
69
116
|
return h;
|
|
70
117
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Thin dispatch convenience — routes through `runtime.execute`, whose pinned
|
|
120
|
+
* core runs the action's hook chain (this pipeline). Used by `ctx.request`
|
|
121
|
+
* and any caller holding the action reference.
|
|
122
|
+
*/
|
|
75
123
|
async dispatch(action, input, parentEnvelope, opts) {
|
|
76
|
-
|
|
77
|
-
if (!handler) {
|
|
124
|
+
if (!this.handlers.has(action.name)) {
|
|
78
125
|
throw new Error(`actionsPlugin: no handler registered for action "${action.name}".`);
|
|
79
126
|
}
|
|
127
|
+
return this.runtime.execute(action, input, {
|
|
128
|
+
parent: parentEnvelope,
|
|
129
|
+
signal: opts?.signal,
|
|
130
|
+
envelope: opts?.envelope,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The command pipeline — runs as the action's outermost hook step under
|
|
135
|
+
* `runtime.execute`: `action.dispatched` telemetry, the `ActionDispatching`
|
|
136
|
+
* veto hook, per-action before/after hooks, retry + backoff invoking the
|
|
137
|
+
* handler body, dead-letter on exhaustion, completion/failure telemetry, and
|
|
138
|
+
* event-return publishing. `ctx` is execute's capability ctx; `rawHandler` is
|
|
139
|
+
* the action's adapted `(ctx)` body.
|
|
140
|
+
*/
|
|
141
|
+
async runActionPipeline(
|
|
142
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
143
|
+
action, rawHandler, ctx, signal) {
|
|
80
144
|
const appName = this.runtime.appName;
|
|
81
|
-
const envelope =
|
|
82
|
-
const
|
|
145
|
+
const envelope = ctx.envelope;
|
|
146
|
+
const rawInput = ctx.input;
|
|
147
|
+
const validated = isValidated(rawInput)
|
|
148
|
+
? rawInput
|
|
149
|
+
: markValidated(action.input.parse(rawInput));
|
|
150
|
+
ctx.input = validated;
|
|
83
151
|
const log = loggerForEnvelope(this.runtime.logger, envelope);
|
|
84
|
-
const
|
|
85
|
-
const ctx = this.buildHandlerContext(envelope, signal);
|
|
152
|
+
const startedAt = performance.now();
|
|
86
153
|
this.runtime.pushTelemetry({
|
|
87
154
|
kind: "action.dispatched",
|
|
88
155
|
action: action.name,
|
|
@@ -91,7 +158,6 @@ export class ActionRunner {
|
|
|
91
158
|
appName,
|
|
92
159
|
ts: new Date().toISOString(),
|
|
93
160
|
});
|
|
94
|
-
const startedAt = performance.now();
|
|
95
161
|
const dispatchResult = await this.runtime.hooks.ActionDispatching.runDetailed({
|
|
96
162
|
action,
|
|
97
163
|
input: validated,
|
|
@@ -115,16 +181,28 @@ export class ActionRunner {
|
|
|
115
181
|
while (attempt < maxAttempts) {
|
|
116
182
|
attempt++;
|
|
117
183
|
if (attempt > 1 && signal.aborted) {
|
|
184
|
+
log.warn(`abort observed between attempts; skipping retries`, {
|
|
185
|
+
action: action.name,
|
|
186
|
+
attempt,
|
|
187
|
+
maxAttempts,
|
|
188
|
+
});
|
|
118
189
|
throw lastError;
|
|
119
190
|
}
|
|
120
191
|
try {
|
|
121
192
|
if (attempt > 1) {
|
|
122
193
|
const delay = computeBackoff(retry, attempt - 1);
|
|
194
|
+
log.warn(`retrying handler`, {
|
|
195
|
+
action: action.name,
|
|
196
|
+
attempt,
|
|
197
|
+
maxAttempts,
|
|
198
|
+
delayMs: delay,
|
|
199
|
+
});
|
|
123
200
|
if (delay > 0)
|
|
124
201
|
await sleep(delay);
|
|
125
202
|
}
|
|
126
|
-
|
|
127
|
-
const
|
|
203
|
+
ctx.input = validated;
|
|
204
|
+
const rawResult = await rawHandler(ctx);
|
|
205
|
+
const events = normalizeEventReturn((rawResult ?? null));
|
|
128
206
|
if (events.length > 0) {
|
|
129
207
|
await this.publishEvents(events, envelope);
|
|
130
208
|
}
|
|
@@ -161,10 +239,16 @@ export class ActionRunner {
|
|
|
161
239
|
result: rawResult ?? undefined,
|
|
162
240
|
durationMs,
|
|
163
241
|
});
|
|
164
|
-
return rawResult;
|
|
242
|
+
return rawResult ?? undefined;
|
|
165
243
|
}
|
|
166
244
|
catch (err) {
|
|
167
245
|
lastError = err;
|
|
246
|
+
log.error(`handler threw`, {
|
|
247
|
+
action: action.name,
|
|
248
|
+
attempt,
|
|
249
|
+
maxAttempts,
|
|
250
|
+
error: err?.message,
|
|
251
|
+
});
|
|
168
252
|
this.runtime.pushTelemetry({
|
|
169
253
|
kind: "action.failed",
|
|
170
254
|
action: action.name,
|
|
@@ -203,49 +287,4 @@ export class ActionRunner {
|
|
|
203
287
|
});
|
|
204
288
|
throw lastError;
|
|
205
289
|
}
|
|
206
|
-
/** Build the handler ctx for one dispatch. */
|
|
207
|
-
buildHandlerContext(envelope, signal) {
|
|
208
|
-
const self = this;
|
|
209
|
-
const container = this.container;
|
|
210
|
-
const ctx = {
|
|
211
|
-
container,
|
|
212
|
-
envelope,
|
|
213
|
-
logger: loggerForEnvelope(this.runtime.logger, envelope),
|
|
214
|
-
signal,
|
|
215
|
-
resolve(name) {
|
|
216
|
-
return container.resolve(name);
|
|
217
|
-
},
|
|
218
|
-
get requestId() {
|
|
219
|
-
return envelope.messageId;
|
|
220
|
-
},
|
|
221
|
-
async request(action, input) {
|
|
222
|
-
return self.dispatch(action, input, envelope, { signal });
|
|
223
|
-
},
|
|
224
|
-
async send(action, input) {
|
|
225
|
-
await self.dispatch(action, input, envelope, { signal });
|
|
226
|
-
},
|
|
227
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
228
|
-
async query(queryDef, input) {
|
|
229
|
-
// Resolve the standalone QueryRunner when present; otherwise fall
|
|
230
|
-
// through to the bundled forge dispatcher.
|
|
231
|
-
if (container.has("forge.queryRunner")) {
|
|
232
|
-
const runner = container.resolve("forge.queryRunner");
|
|
233
|
-
return runner.run(queryDef.name, input, envelope.tenant ?? "");
|
|
234
|
-
}
|
|
235
|
-
const dispatcher = container.resolve("forge.dispatcher");
|
|
236
|
-
return dispatcher.query(queryDef.name, input, envelope.tenant ?? "");
|
|
237
|
-
},
|
|
238
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
239
|
-
async use() {
|
|
240
|
-
throw new Error("actionsPlugin: ctx.use requires the bundled forge dispatcher. Standalone ctx.use is not yet implemented.");
|
|
241
|
-
},
|
|
242
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
243
|
-
async externalCall() {
|
|
244
|
-
throw new Error("actionsPlugin: ctx.externalCall requires the bundled forge dispatcher.");
|
|
245
|
-
},
|
|
246
|
-
};
|
|
247
|
-
for (const augment of this.augmenters)
|
|
248
|
-
augment(ctx, envelope);
|
|
249
|
-
return ctx;
|
|
250
|
-
}
|
|
251
290
|
}
|
|
@@ -1,40 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `actionsPlugin` —
|
|
2
|
+
* `actionsPlugin` — the forge command concern as a runtime-native plugin.
|
|
3
3
|
*
|
|
4
4
|
* import { createApp } from "@nwire/app";
|
|
5
|
-
* import {
|
|
6
|
-
* actionsPlugin,
|
|
7
|
-
* idempotencyPlugin,
|
|
8
|
-
* forgePlugin,
|
|
9
|
-
* } from "@nwire/forge";
|
|
5
|
+
* import { actionsPlugin, idempotencyPlugin } from "@nwire/forge";
|
|
10
6
|
*
|
|
11
7
|
* const app = createApp({
|
|
12
8
|
* appName: "orders",
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* actionsPlugin([placeOrderHandler, sendReceiptHandler]),
|
|
16
|
-
* ],
|
|
9
|
+
* handlers: [placeOrder, sendReceipt], // app registers handlers
|
|
10
|
+
* plugins: [idempotencyPlugin(), actionsPlugin()],
|
|
17
11
|
* });
|
|
18
12
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* - the per-action retry loop, DLQ recording, and action.dispatched /
|
|
25
|
-
* action.completed / action.failed / dlq.recorded telemetry
|
|
13
|
+
* It owns no handler list. At setup it scans the runtime's registered handlers
|
|
14
|
+
* for `config.kind === "action"` and composes the command pipeline onto each
|
|
15
|
+
* (retry / DLQ / before-after / telemetry / event-publish) via the action's own
|
|
16
|
+
* hook chain. Registration is the app's job (`createApp({ handlers })`); forge
|
|
17
|
+
* only adds behavior keyed on `kind`.
|
|
26
18
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
19
|
+
* Owns:
|
|
20
|
+
* - the `ActionRunner` (pipeline + per-action before/after hooks + dispatch
|
|
21
|
+
* convenience) and the `forge.actionRunner` / `forge.deadLetterSink` binds.
|
|
22
|
+
* - the event-publish path (`forge.publishEvents` binding): local
|
|
23
|
+
* `LocalDelivery` fold + outbound sink/bus for public events.
|
|
24
|
+
* - the forge handler-ctx verbs `request` / `send` / `emit` plus the ambient
|
|
25
|
+
* `container` / `requestId` / `logger`, contributed as a capability.
|
|
30
26
|
*/
|
|
31
27
|
import type { PluginDefinition } from "@nwire/app";
|
|
32
28
|
import { type DeadLetterSink } from "@nwire/dead-letter";
|
|
33
|
-
import type {
|
|
29
|
+
import type { EventBus } from "@nwire/bus";
|
|
34
30
|
export declare const FORGE_ACTION_RUNNER_BINDING: "forge.actionRunner";
|
|
35
31
|
export declare const FORGE_DEAD_LETTER_SINK_BINDING: "forge.deadLetterSink";
|
|
32
|
+
/** Shared event-publish fn — actor views and workflow effects resolve it too. */
|
|
33
|
+
export declare const FORGE_PUBLISH_BINDING: "forge.publishEvents";
|
|
34
|
+
/** Names of events declared `.public()` — consulted by the outbound drain. */
|
|
35
|
+
export declare const FORGE_PUBLIC_EVENTS_BINDING: "forge.publicEvents";
|
|
36
36
|
export interface ActionsPluginOptions {
|
|
37
|
-
/** Override the dead-letter sink. Defaults to `InMemoryDeadLetterSink`. */
|
|
38
37
|
readonly deadLetterSink?: DeadLetterSink;
|
|
38
|
+
/** Cross-service bus for public events. */
|
|
39
|
+
readonly bus?: EventBus;
|
|
40
|
+
/** Drain public events to the bus. Default false. */
|
|
41
|
+
readonly publishToBus?: boolean;
|
|
39
42
|
}
|
|
40
|
-
export declare function actionsPlugin(
|
|
43
|
+
export declare function actionsPlugin(opts?: ActionsPluginOptions): PluginDefinition;
|
|
@@ -1,76 +1,154 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `actionsPlugin` —
|
|
2
|
+
* `actionsPlugin` — the forge command concern as a runtime-native plugin.
|
|
3
3
|
*
|
|
4
4
|
* import { createApp } from "@nwire/app";
|
|
5
|
-
* import {
|
|
6
|
-
* actionsPlugin,
|
|
7
|
-
* idempotencyPlugin,
|
|
8
|
-
* forgePlugin,
|
|
9
|
-
* } from "@nwire/forge";
|
|
5
|
+
* import { actionsPlugin, idempotencyPlugin } from "@nwire/forge";
|
|
10
6
|
*
|
|
11
7
|
* const app = createApp({
|
|
12
8
|
* appName: "orders",
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* actionsPlugin([placeOrderHandler, sendReceiptHandler]),
|
|
16
|
-
* ],
|
|
9
|
+
* handlers: [placeOrder, sendReceipt], // app registers handlers
|
|
10
|
+
* plugins: [idempotencyPlugin(), actionsPlugin()],
|
|
17
11
|
* });
|
|
18
12
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* - the per-action retry loop, DLQ recording, and action.dispatched /
|
|
25
|
-
* action.completed / action.failed / dlq.recorded telemetry
|
|
13
|
+
* It owns no handler list. At setup it scans the runtime's registered handlers
|
|
14
|
+
* for `config.kind === "action"` and composes the command pipeline onto each
|
|
15
|
+
* (retry / DLQ / before-after / telemetry / event-publish) via the action's own
|
|
16
|
+
* hook chain. Registration is the app's job (`createApp({ handlers })`); forge
|
|
17
|
+
* only adds behavior keyed on `kind`.
|
|
26
18
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
19
|
+
* Owns:
|
|
20
|
+
* - the `ActionRunner` (pipeline + per-action before/after hooks + dispatch
|
|
21
|
+
* convenience) and the `forge.actionRunner` / `forge.deadLetterSink` binds.
|
|
22
|
+
* - the event-publish path (`forge.publishEvents` binding): local
|
|
23
|
+
* `LocalDelivery` fold + outbound sink/bus for public events.
|
|
24
|
+
* - the forge handler-ctx verbs `request` / `send` / `emit` plus the ambient
|
|
25
|
+
* `container` / `requestId` / `logger`, contributed as a capability.
|
|
30
26
|
*/
|
|
27
|
+
import { deriveEnvelope } from "@nwire/envelope";
|
|
28
|
+
import { loggerForEnvelope } from "@nwire/logger";
|
|
31
29
|
import { InMemoryDeadLetterSink } from "@nwire/dead-letter";
|
|
30
|
+
import { eventFactory } from "../messages/event-message.js";
|
|
31
|
+
import { forgeFrameworkSlots } from "../framework-events.js";
|
|
32
32
|
import { ActionRunner } from "./actions-chain.js";
|
|
33
33
|
export const FORGE_ACTION_RUNNER_BINDING = "forge.actionRunner";
|
|
34
34
|
export const FORGE_DEAD_LETTER_SINK_BINDING = "forge.deadLetterSink";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
/** Shared event-publish fn — actor views and workflow effects resolve it too. */
|
|
36
|
+
export const FORGE_PUBLISH_BINDING = "forge.publishEvents";
|
|
37
|
+
/** Names of events declared `.public()` — consulted by the outbound drain. */
|
|
38
|
+
export const FORGE_PUBLIC_EVENTS_BINDING = "forge.publicEvents";
|
|
39
|
+
/** Is this registered handler a forge action? */
|
|
40
|
+
function isActionHandler(h) {
|
|
41
|
+
return typeof h === "function" && h.config?.kind === "action";
|
|
42
|
+
}
|
|
43
|
+
export function actionsPlugin(opts = {}) {
|
|
38
44
|
return {
|
|
39
45
|
name: "forge.actions",
|
|
40
46
|
register({ bind, container }) {
|
|
41
|
-
// Don't shadow a sink that dlqPlugin already bound. The option
|
|
42
|
-
// override still wins when explicitly passed.
|
|
43
47
|
if (opts.deadLetterSink) {
|
|
44
48
|
bind(FORGE_DEAD_LETTER_SINK_BINDING, () => opts.deadLetterSink);
|
|
45
|
-
return;
|
|
46
49
|
}
|
|
47
|
-
if (container.has(FORGE_DEAD_LETTER_SINK_BINDING))
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
else if (!container.has(FORGE_DEAD_LETTER_SINK_BINDING)) {
|
|
51
|
+
const sink = new InMemoryDeadLetterSink();
|
|
52
|
+
bind(FORGE_DEAD_LETTER_SINK_BINDING, () => sink);
|
|
53
|
+
}
|
|
54
|
+
// Shared public-event registry (other plugins may add to it).
|
|
55
|
+
if (!container.has(FORGE_PUBLIC_EVENTS_BINDING)) {
|
|
56
|
+
const publicEvents = new Set();
|
|
57
|
+
bind(FORGE_PUBLIC_EVENTS_BINDING, () => publicEvents);
|
|
58
|
+
}
|
|
51
59
|
},
|
|
52
|
-
setup({ runtime, container }) {
|
|
60
|
+
setup({ runtime, container, bind }) {
|
|
61
|
+
// Materialise forge's Action* / Event* hook slots — apps without forge
|
|
62
|
+
// don't carry these. The action pipeline (and the event fold steps)
|
|
63
|
+
// read them off `runtime.hooks`, so they must exist before any dispatch.
|
|
64
|
+
for (const slot of forgeFrameworkSlots)
|
|
65
|
+
runtime.defineHook(slot);
|
|
53
66
|
const sink = container.resolve(FORGE_DEAD_LETTER_SINK_BINDING);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
67
|
+
const publicEvents = container.resolve(FORGE_PUBLIC_EVENTS_BINDING);
|
|
68
|
+
const appName = runtime.appName;
|
|
69
|
+
// The one event-publish path: local LocalDelivery fold, then outbound
|
|
70
|
+
// sink/bus for public, non-deduped events.
|
|
71
|
+
const publishEvents = async (events, parentEnvelope) => {
|
|
58
72
|
for (let i = 0; i < events.length; i++) {
|
|
59
73
|
const event = events[i];
|
|
60
|
-
const dedupKey = events.length === 1 ?
|
|
61
|
-
const
|
|
74
|
+
const dedupKey = events.length === 1 ? parentEnvelope.messageId : `${parentEnvelope.messageId}#${i}`;
|
|
75
|
+
const childEnvelope = deriveEnvelope(parentEnvelope);
|
|
76
|
+
const { deduped } = await runtime.deliver(event.eventName, event.payload, childEnvelope, dedupKey);
|
|
77
|
+
if (deduped) {
|
|
78
|
+
runtime.pushTelemetry({
|
|
79
|
+
kind: "event.deduped",
|
|
80
|
+
event,
|
|
81
|
+
envelope: childEnvelope,
|
|
82
|
+
source: "in-process",
|
|
83
|
+
appName,
|
|
84
|
+
ts: new Date().toISOString(),
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (publicEvents.has(event.eventName)) {
|
|
89
|
+
if (opts.publishToBus && opts.bus) {
|
|
90
|
+
await opts.bus.publish({
|
|
91
|
+
eventName: event.eventName,
|
|
92
|
+
payload: event.payload,
|
|
93
|
+
envelope: childEnvelope,
|
|
94
|
+
origin: appName,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
await runtime.sinkDrain({ name: event.eventName }, event.payload, childEnvelope);
|
|
98
|
+
}
|
|
99
|
+
runtime.pushTelemetry({
|
|
100
|
+
kind: "event.published",
|
|
62
101
|
event,
|
|
63
|
-
envelope,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
102
|
+
envelope: childEnvelope,
|
|
103
|
+
source: "in-process",
|
|
104
|
+
appName,
|
|
105
|
+
ts: new Date().toISOString(),
|
|
106
|
+
});
|
|
68
107
|
}
|
|
69
108
|
};
|
|
109
|
+
bind(FORGE_PUBLISH_BINDING, () => publishEvents);
|
|
70
110
|
const runner = new ActionRunner(runtime, container, sink, publishEvents);
|
|
71
|
-
|
|
72
|
-
|
|
111
|
+
// Scan the runtime's registered handlers — the app owns registration;
|
|
112
|
+
// forge composes the pipeline onto every action-kind handler.
|
|
113
|
+
for (const name of runtime.listHandlers()) {
|
|
114
|
+
const handler = runtime.getHandler(name);
|
|
115
|
+
if (!isActionHandler(handler))
|
|
116
|
+
continue;
|
|
117
|
+
runner.install(handler);
|
|
118
|
+
const emits = handler.emits;
|
|
119
|
+
if (emits)
|
|
120
|
+
for (const ev of emits)
|
|
121
|
+
if (ev.$public === true)
|
|
122
|
+
publicEvents.add(ev.name);
|
|
123
|
+
}
|
|
73
124
|
container.register(FORGE_ACTION_RUNNER_BINDING, runner);
|
|
125
|
+
// Forge handler-ctx verbs contributed per dispatch. `request` awaits the
|
|
126
|
+
// result through the one execute path; `send` is fire-and-forget via
|
|
127
|
+
// enqueue (returns a MessageRef); `emit` announces a fact via the shared
|
|
128
|
+
// publish. Ambient `container` / `requestId` / `logger` ride along.
|
|
129
|
+
// Ambient ctx — every dispatch (universal). What the verb closes over is
|
|
130
|
+
// only the envelope/container, so it's not kind-specific.
|
|
131
|
+
runtime.add({
|
|
132
|
+
name: "forge.ambient-ctx",
|
|
133
|
+
provideCtx: ({ envelope, container: c }) => ({
|
|
134
|
+
container: c,
|
|
135
|
+
requestId: envelope.messageId,
|
|
136
|
+
logger: loggerForEnvelope(runtime.logger, envelope),
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
// Dispatch verbs — kind-scoped to the write/orchestrate side (actions,
|
|
140
|
+
// plain handlers/resolvers, commands), so a read-only `query` body never
|
|
141
|
+
// structurally carries `request`/`send`/`emit`. (Workflow/actor reactions
|
|
142
|
+
// build their own send/publish from their router ctx.)
|
|
143
|
+
runtime.add({
|
|
144
|
+
name: "forge.action-ctx",
|
|
145
|
+
kinds: ["handler", "action", "command"],
|
|
146
|
+
provideCtx: ({ envelope, signal }) => ({
|
|
147
|
+
request: (action, input) => runtime.execute(action, input, { parent: envelope, signal }),
|
|
148
|
+
send: (action, input) => runtime.enqueue(action, input, { parent: envelope, signal }),
|
|
149
|
+
emit: (event, payload) => publishEvents([eventFactory(event)(payload)], envelope),
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
74
152
|
},
|
|
75
153
|
};
|
|
76
154
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Actor chain runner — the
|
|
2
|
+
* Actor chain runner — the LocalDelivery step that applies an event to
|
|
3
3
|
* every registered actor type whose reactions match.
|
|
4
4
|
*
|
|
5
5
|
* Self-contained: takes its dependencies as constructor arguments. Both
|
|
@@ -41,7 +41,7 @@ export declare class ActorChainRunner {
|
|
|
41
41
|
ensureTransitionHook(actorName: string): Hook<ActorTransitionHookCtx>;
|
|
42
42
|
/**
|
|
43
43
|
* Apply one event to every actor type whose reactions match. Called
|
|
44
|
-
* by the
|
|
44
|
+
* by the LocalDelivery step at priority 800.
|
|
45
45
|
*/
|
|
46
46
|
apply(event: EventMessage, envelope: MessageEnvelope): Promise<void>;
|
|
47
47
|
private extractKey;
|
|
@@ -49,4 +49,11 @@ export declare class ActorChainRunner {
|
|
|
49
49
|
private applyLocked;
|
|
50
50
|
private computeTimersForState;
|
|
51
51
|
private envelopeLogger;
|
|
52
|
+
/**
|
|
53
|
+
* Load an actor instance as a method-bound view — backs `ctx.actor`. Methods
|
|
54
|
+
* are pure (state-first or closure-bound); events a method records are
|
|
55
|
+
* published via the injected `publish` (the shared event-publish path) so a
|
|
56
|
+
* view method's facts ride the same LocalDelivery fold + outbound drain.
|
|
57
|
+
*/
|
|
58
|
+
view<TActor extends ActorDefinition>(actor: TActor, id: string, envelope: MessageEnvelope, publish: (events: readonly EventMessage[], envelope: MessageEnvelope) => Promise<void>): Promise<any>;
|
|
52
59
|
}
|