@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.
Files changed (53) hide show
  1. package/README.md +100 -83
  2. package/dist/framework-events.d.ts +8 -37
  3. package/dist/framework-events.js +7 -3
  4. package/dist/helpers/cli-runner.js +21 -10
  5. package/dist/index.d.ts +8 -7
  6. package/dist/index.js +7 -6
  7. package/dist/plugins/actions-chain.d.ts +39 -22
  8. package/dist/plugins/actions-chain.js +117 -78
  9. package/dist/plugins/actions-plugin.d.ts +26 -23
  10. package/dist/plugins/actions-plugin.js +122 -44
  11. package/dist/plugins/actors-chain.d.ts +9 -2
  12. package/dist/plugins/actors-chain.js +62 -2
  13. package/dist/plugins/actors-plugin.d.ts +1 -1
  14. package/dist/plugins/actors-plugin.js +24 -14
  15. package/dist/plugins/external-calls-plugin.d.ts +28 -0
  16. package/dist/plugins/external-calls-plugin.js +136 -0
  17. package/dist/plugins/idempotency-plugin.d.ts +15 -1
  18. package/dist/plugins/idempotency-plugin.js +56 -11
  19. package/dist/plugins/projections-chain.d.ts +2 -2
  20. package/dist/plugins/projections-chain.js +2 -2
  21. package/dist/plugins/projections-plugin.d.ts +1 -1
  22. package/dist/plugins/projections-plugin.js +4 -13
  23. package/dist/plugins/queries-chain.d.ts +4 -3
  24. package/dist/plugins/queries-chain.js +8 -5
  25. package/dist/plugins/queries-plugin.d.ts +15 -29
  26. package/dist/plugins/queries-plugin.js +36 -49
  27. package/dist/plugins/workflows-chain.d.ts +9 -2
  28. package/dist/plugins/workflows-chain.js +19 -1
  29. package/dist/plugins/workflows-plugin.d.ts +1 -1
  30. package/dist/plugins/workflows-plugin.js +12 -20
  31. package/dist/primitives/define-action.d.ts +80 -115
  32. package/dist/primitives/define-action.js +111 -56
  33. package/dist/primitives/define-actor.d.ts +103 -214
  34. package/dist/primitives/define-actor.js +157 -216
  35. package/dist/primitives/define-handler.d.ts +42 -112
  36. package/dist/primitives/define-handler.js +14 -45
  37. package/dist/primitives/define-projection.d.ts +23 -28
  38. package/dist/primitives/define-projection.js +29 -32
  39. package/dist/primitives/define-query.d.ts +52 -42
  40. package/dist/primitives/define-query.js +65 -28
  41. package/dist/primitives/define-workflow.d.ts +8 -11
  42. package/dist/primitives/define-workflow.js +14 -8
  43. package/dist/runtime/forge-dispatcher.d.ts +30 -12
  44. package/dist/runtime/forge-dispatcher.js +199 -237
  45. package/dist/runtime/forge-plugin.d.ts +8 -0
  46. package/dist/runtime/forge-plugin.js +113 -31
  47. package/dist/runtime/forge-plugins.d.ts +55 -0
  48. package/dist/runtime/forge-plugins.js +57 -0
  49. package/dist/runtime/forge-types.d.ts +8 -2
  50. package/dist/runtime/with-forge.d.ts +8 -11
  51. package/dist/runtime/with-forge.js +9 -11
  52. package/dist/stores/idempotency-store.d.ts +1 -1
  53. package/package.json +12 -12
@@ -1,48 +1,86 @@
1
1
  /**
2
- * Action runner — handler registry + dispatch loop.
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
- * Owns the per-action retry policy, DLQ integration, and the
5
- * `action.dispatched` / `action.completed` / `action.failed` / `dlq.recorded`
6
- * telemetry. Calls `runtime.hooks.ActionDispatching` (vetoable),
7
- * `ActionCompleted`, and `ActionFailed` at the right transitions.
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
- register(handler) {
40
- if (this.handlers.has(handler.action.name)) {
41
- throw new Error(`actionsPlugin: handler for action "${handler.action.name}" already registered.`);
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(handler.action.name, handler);
44
- this.ensureBeforeHook(handler.action.name);
45
- this.ensureAfterHook(handler.action.name);
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)?.action;
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
- /** Plugins can hang extra ctx members on each handler invocation. */
72
- registerCtxAugmenter(fn) {
73
- this.augmenters.push(fn);
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
- const handler = this.handlers.get(action.name);
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 = parentEnvelope ? deriveEnvelope(parentEnvelope) : seedEnvelope({});
82
- const validated = isValidated(input) ? input : markValidated(action.schema.parse(input));
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 signal = opts?.signal ?? new AbortController().signal;
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
- const rawResult = await handler.handler(validated, ctx);
127
- const events = normalizeEventReturn(rawResult ?? null);
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` — standalone forge action concern.
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
- * plugins: [
14
- * forgePlugin,
15
- * actionsPlugin([placeOrderHandler, sendReceiptHandler]),
16
- * ],
9
+ * handlers: [placeOrder, sendReceipt], // app registers handlers
10
+ * plugins: [idempotencyPlugin(), actionsPlugin()],
17
11
  * });
18
12
  *
19
- * Owns:
20
- * - the action handler registry through `ActionRunner`
21
- * - the `forge.deadLetterSink` container binding (overridable)
22
- * - the `forge.actionRunner` container binding so other plugins can
23
- * resolve it (queue-worker, http adapter, please CLI, etc.)
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
- * Handlers returning events feed the `EventPublishing` chain so the
28
- * actors / projections / workflows / bus steps all run in order. The
29
- * standalone plugin is equivalent to forgePlugin's bundled handler path.
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 { HandlerDefinition } from "../primitives/define-handler.js";
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(handlers: readonly HandlerDefinition<any>[], opts?: ActionsPluginOptions): PluginDefinition;
43
+ export declare function actionsPlugin(opts?: ActionsPluginOptions): PluginDefinition;
@@ -1,76 +1,154 @@
1
1
  /**
2
- * `actionsPlugin` — standalone forge action concern.
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
- * plugins: [
14
- * forgePlugin,
15
- * actionsPlugin([placeOrderHandler, sendReceiptHandler]),
16
- * ],
9
+ * handlers: [placeOrder, sendReceipt], // app registers handlers
10
+ * plugins: [idempotencyPlugin(), actionsPlugin()],
17
11
  * });
18
12
  *
19
- * Owns:
20
- * - the action handler registry through `ActionRunner`
21
- * - the `forge.deadLetterSink` container binding (overridable)
22
- * - the `forge.actionRunner` container binding so other plugins can
23
- * resolve it (queue-worker, http adapter, please CLI, etc.)
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
- * Handlers returning events feed the `EventPublishing` chain so the
28
- * actors / projections / workflows / bus steps all run in order. The
29
- * standalone plugin is equivalent to forgePlugin's bundled handler path.
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
- export function actionsPlugin(
36
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
- handlers, opts = {}) {
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
- return;
49
- const sink = new InMemoryDeadLetterSink();
50
- bind(FORGE_DEAD_LETTER_SINK_BINDING, () => sink);
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
- // Returned events from a handler flow into the EventPublishing chain
55
- // each event becomes a payload that walks the priority steps
56
- // (idempotency actors projections workflows bus).
57
- const publishEvents = async (events, envelope) => {
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 ? envelope.messageId : `${envelope.messageId}#${i}`;
61
- const payload = {
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
- dedupKey,
65
- deduped: false,
66
- };
67
- await runtime.hooks.EventPublishing.run(payload);
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
- for (const handler of handlers)
72
- runner.register(handler);
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 EventPublishing step that applies an event to
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 EventPublishing step at priority 800.
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
  }