@nwire/forge 0.10.0 → 0.11.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 (41) hide show
  1. package/dist/framework-events.d.ts +44 -2
  2. package/dist/framework-events.js +10 -1
  3. package/dist/helpers/cli-runner.d.ts +1 -1
  4. package/dist/helpers/cli-runner.js +5 -4
  5. package/dist/index.d.ts +25 -13
  6. package/dist/index.js +25 -13
  7. package/dist/plugins/actions-chain.d.ts +48 -0
  8. package/dist/plugins/actions-chain.js +251 -0
  9. package/dist/plugins/actions-plugin.d.ts +40 -0
  10. package/dist/plugins/actions-plugin.js +76 -0
  11. package/dist/plugins/actors-chain.d.ts +52 -0
  12. package/dist/plugins/actors-chain.js +204 -0
  13. package/dist/plugins/actors-plugin.d.ts +36 -0
  14. package/dist/plugins/actors-plugin.js +62 -0
  15. package/dist/plugins/dlq-plugin.d.ts +29 -0
  16. package/dist/plugins/dlq-plugin.js +37 -0
  17. package/dist/plugins/idempotency-plugin.d.ts +28 -0
  18. package/dist/plugins/idempotency-plugin.js +57 -0
  19. package/dist/plugins/projections-chain.d.ts +34 -0
  20. package/dist/plugins/projections-chain.js +86 -0
  21. package/dist/plugins/projections-plugin.d.ts +36 -0
  22. package/dist/plugins/projections-plugin.js +63 -0
  23. package/dist/plugins/queries-chain.d.ts +33 -0
  24. package/dist/plugins/queries-chain.js +77 -0
  25. package/dist/plugins/queries-plugin.d.ts +41 -0
  26. package/dist/plugins/queries-plugin.js +74 -0
  27. package/dist/plugins/workflows-chain.d.ts +51 -0
  28. package/dist/plugins/workflows-chain.js +203 -0
  29. package/dist/plugins/workflows-plugin.d.ts +47 -0
  30. package/dist/plugins/workflows-plugin.js +81 -0
  31. package/dist/runtime/create-forge-app.d.ts +11 -11
  32. package/dist/runtime/create-forge-app.js +28 -32
  33. package/dist/runtime/forge-dispatcher.d.ts +24 -0
  34. package/dist/runtime/forge-dispatcher.js +76 -22
  35. package/dist/runtime/forge-plugin.d.ts +57 -42
  36. package/dist/runtime/forge-plugin.js +72 -59
  37. package/dist/runtime/with-forge.d.ts +26 -0
  38. package/dist/runtime/with-forge.js +30 -0
  39. package/dist/stores/idempotency-store.d.ts +15 -0
  40. package/dist/stores/idempotency-store.js +7 -0
  41. package/package.json +11 -11
@@ -14,8 +14,26 @@
14
14
  * domain-specific slots.
15
15
  */
16
16
  import type { Hook } from "@nwire/hooks";
17
+ import type { MessageEnvelope } from "@nwire/envelope";
17
18
  import type { ActionDefinition } from "./primitives/define-action.js";
18
19
  import type { HandlerContext } from "./primitives/define-handler.js";
20
+ import type { EventMessage } from "./messages/event-message.js";
21
+ /**
22
+ * Payload threaded through the `EventPublishing` hook chain. Each chain
23
+ * step does its work for one event in order. Steps may set `deduped` to
24
+ * short-circuit (idempotency), or read it to skip work when an earlier
25
+ * step already deduplicated.
26
+ *
27
+ * Mutable so steps can flag deduplication without restructuring the
28
+ * chain return shape.
29
+ */
30
+ export interface EventPublishingPayload {
31
+ readonly event: EventMessage;
32
+ readonly envelope: MessageEnvelope;
33
+ readonly dedupKey: string;
34
+ /** Set by the idempotency step when this event was seen before. */
35
+ deduped: boolean;
36
+ }
19
37
  declare module "@nwire/app" {
20
38
  interface FrameworkHooks {
21
39
  /** Fired before a dispatch reaches the handler. Veto by skipping next(). */
@@ -48,8 +66,32 @@ declare module "@nwire/app" {
48
66
  readonly eventName: string;
49
67
  readonly payload: unknown;
50
68
  }>;
69
+ /**
70
+ * The atomic event-publish chain. Each domain concern attaches one
71
+ * `.use()` step at its priority slot; the hook engine enforces the
72
+ * order. Priority slots used by forge:
73
+ *
74
+ * 1000 — idempotency (dedup gate; short-circuits when seen)
75
+ * 800 — actor state transitions
76
+ * 600 — projection folds
77
+ * 400 — workflow correlation + fire
78
+ * 200 — cross-process bus delivery (public events only)
79
+ *
80
+ * External plugins can attach at intermediate priorities to inject
81
+ * domain-specific work (audit log, cross-tenant fan-out, etc.) while
82
+ * preserving the forge ordering.
83
+ */
84
+ EventPublishing: Hook<EventPublishingPayload>;
51
85
  }
52
86
  }
53
- /** Forge-specific framework-hook slot names — used by Runtime to materialise. */
54
- export declare const forgeFrameworkSlots: readonly ["ActionDispatching", "ActionCompleted", "ActionFailed", "EventRecording", "EventRecorded"];
87
+ /** Forge-specific framework-hook slot names — materialised by `createForgePlugin`. */
88
+ export declare const forgeFrameworkSlots: readonly ["ActionDispatching", "ActionCompleted", "ActionFailed", "EventRecording", "EventRecorded", "EventPublishing"];
89
+ /** Priority slots forge's own chain participants attach at. */
90
+ export declare const EVENT_PUBLISHING_PRIORITIES: {
91
+ readonly idempotency: 1000;
92
+ readonly actors: 800;
93
+ readonly projections: 600;
94
+ readonly workflows: 400;
95
+ readonly bus: 200;
96
+ };
55
97
  export type ForgeFrameworkSlot = (typeof forgeFrameworkSlots)[number];
@@ -13,11 +13,20 @@
13
13
  * are pre-instantiated on every Runtime; this file only adds forge's
14
14
  * domain-specific slots.
15
15
  */
16
- /** Forge-specific framework-hook slot names — used by Runtime to materialise. */
16
+ /** Forge-specific framework-hook slot names — materialised by `createForgePlugin`. */
17
17
  export const forgeFrameworkSlots = [
18
18
  "ActionDispatching",
19
19
  "ActionCompleted",
20
20
  "ActionFailed",
21
21
  "EventRecording",
22
22
  "EventRecorded",
23
+ "EventPublishing",
23
24
  ];
25
+ /** Priority slots forge's own chain participants attach at. */
26
+ export const EVENT_PUBLISHING_PRIORITIES = {
27
+ idempotency: 1000,
28
+ actors: 800,
29
+ projections: 600,
30
+ workflows: 400,
31
+ bus: 200,
32
+ };
@@ -33,7 +33,7 @@
33
33
  * Wire-mode invariance: same domain handlers run in HTTP, queue, and CLI.
34
34
  * The CLI doesn't know about HTTP routes; it dispatches via the runtime.
35
35
  */
36
- import type { ForgeApp as App } from "../runtime/create-forge-app.js";
36
+ import type { App } from "@nwire/app";
37
37
  export interface RunCliOptions {
38
38
  /**
39
39
  * Override stdout/stderr — useful for tests. Defaults to console.log /
@@ -34,6 +34,7 @@
34
34
  * The CLI doesn't know about HTTP routes; it dispatches via the runtime.
35
35
  */
36
36
  import { seedEnvelope } from "@nwire/envelope";
37
+ import { forgeDispatcher } from "../runtime/forge-plugin.js";
37
38
  export async function runCli(app, argv, options = {}) {
38
39
  const stdout = options.stdout ?? ((line) => console.log(line));
39
40
  const stderr = options.stderr ?? ((line) => console.error(line));
@@ -43,14 +44,14 @@ export async function runCli(app, argv, options = {}) {
43
44
  }
44
45
  const targetName = argv[0];
45
46
  const rest = argv.slice(1);
46
- const dispatcher = app.dispatcher();
47
+ const dispatcher = forgeDispatcher(app);
47
48
  const action = dispatcher.findActionByName(targetName);
48
49
  if (action) {
49
50
  const { tenant, parsed } = parseFlags(rest);
50
51
  try {
51
52
  const validated = action.schema.parse(parsed);
52
53
  const envelope = tenant ? seedEnvelope({ tenant }) : undefined;
53
- await app.dispatch(action, validated, envelope);
54
+ await dispatcher.dispatch(action, validated, envelope);
54
55
  stdout("OK");
55
56
  return 0;
56
57
  }
@@ -63,7 +64,7 @@ export async function runCli(app, argv, options = {}) {
63
64
  if (dispatcher.listQueries().includes(targetName)) {
64
65
  const { tenant, parsed } = parseFlags(rest);
65
66
  try {
66
- const result = await app.query(targetName, parsed, tenant);
67
+ const result = await dispatcher.query(targetName, parsed, tenant);
67
68
  stdout(JSON.stringify(result, null, 2));
68
69
  return 0;
69
70
  }
@@ -76,7 +77,7 @@ export async function runCli(app, argv, options = {}) {
76
77
  return 1;
77
78
  }
78
79
  function printHelp(app, stdout) {
79
- const dispatcher = app.dispatcher();
80
+ const dispatcher = forgeDispatcher(app);
80
81
  stdout("nwire — action CLI runner\n");
81
82
  stdout("Usage: amit <action-or-query-name> [--field value ...] [--tenant <id>]\n");
82
83
  stdout("Actions:");
package/dist/index.d.ts CHANGED
@@ -2,21 +2,33 @@
2
2
  * `@nwire/forge` — CQRS engine delivered as an app plugin.
3
3
  *
4
4
  * import { createApp } from "@nwire/app";
5
- * import { forgePlugin } from "@nwire/forge";
5
+ * import { createForgePlugin, forgeDispatcher } from "@nwire/forge";
6
6
  *
7
- * const app = createApp({ appName: "orders", plugins: [forgePlugin, …] });
7
+ * const app = createApp({
8
+ * appName: "orders",
9
+ * plugins: [createForgePlugin({ handlers, actors, projections, … })],
10
+ * });
11
+ * await app.start();
8
12
  *
9
- * Or via the convenience wrapper that auto-installs forgePlugin and adds
10
- * the dispatch/publish/query/… verbs as methods on the app handle:
11
- *
12
- * import { createForgeApp } from "@nwire/forge";
13
- *
14
- * const app = createForgeApp({ name: "orders", handlers, actors, … });
13
+ * const dispatcher = forgeDispatcher(app);
14
+ * await dispatcher.dispatch(placeOrder, { });
15
15
  */
16
- export { forgePlugin, createForgePlugin, FORGE_DISPATCHER_BINDING, } from "./runtime/forge-plugin.js";
16
+ export { forgePlugin, createForgePlugin, forgeDispatcher, type ForgePluginOptions, FORGE_DISPATCHER_BINDING, } from "./runtime/forge-plugin.js";
17
+ export { withForge } from "./runtime/with-forge.js";
18
+ export { actorsPlugin, FORGE_ACTOR_CHAIN_BINDING, FORGE_ACTOR_STORE_BINDING, type ActorsPluginOptions, } from "./plugins/actors-plugin.js";
19
+ export { ActorChainRunner, type ActorTransitionListener } from "./plugins/actors-chain.js";
20
+ export { projectionsPlugin, FORGE_PROJECTION_CHAIN_BINDING, FORGE_PROJECTION_STORE_BINDING, type ProjectionsPluginOptions, } from "./plugins/projections-plugin.js";
21
+ export { ProjectionChainRunner } from "./plugins/projections-chain.js";
22
+ export { workflowsPlugin, FORGE_WORKFLOW_CHAIN_BINDING, FORGE_WORKFLOW_TIMER_STORE_BINDING, type WorkflowsPluginOptions, } from "./plugins/workflows-plugin.js";
23
+ export { WorkflowChainRunner, type WorkflowEffectsFactory } from "./plugins/workflows-chain.js";
24
+ export { queriesPlugin, FORGE_QUERY_RUNNER_BINDING } from "./plugins/queries-plugin.js";
25
+ export { QueryRunner } from "./plugins/queries-chain.js";
26
+ export { actionsPlugin, FORGE_ACTION_RUNNER_BINDING, FORGE_DEAD_LETTER_SINK_BINDING, type ActionsPluginOptions, } from "./plugins/actions-plugin.js";
27
+ export { ActionRunner, type PublishEventsFn, type CtxAugmenter } from "./plugins/actions-chain.js";
28
+ export { idempotencyPlugin, FORGE_IDEMPOTENCY_STORE_BINDING, type IdempotencyPluginOptions, } from "./plugins/idempotency-plugin.js";
29
+ export { dlqPlugin, type DlqPluginOptions } from "./plugins/dlq-plugin.js";
17
30
  export { ForgeDispatcher, type ForgeDispatcherOptions } from "./runtime/forge-dispatcher.js";
18
31
  export type { ForgeTelemetry, DispatchOptions, ActionBeforeHookCtx, ActionAfterHookCtx, ActorTransitionHookCtx, WorkflowFireHookCtx, } from "./runtime/forge-types.js";
19
- export { createForgeApp, type ForgeApp, type ForgeAppOptions, } from "./runtime/create-forge-app.js";
20
32
  export { defineAction, isCommandMessage, resolveDispatch, type ActionDefinition, type CallableActionDefinition, type CommandMessage, type ActionMeta, type ActionInput, type ActionResult, type ActionPolicy, type NwireActionRegistry, type RetryPolicy, } from "./primitives/define-action.js";
21
33
  export { defineActor, eventKey, type ActorDefinition, type ActorOptions, type ActorOptionsBound, type ActorReaction, type ActorStateConfig, type ActorTimerSpec, type ActorMethod, type ActorInstanceView, } from "./primitives/define-actor.js";
22
34
  export { defineSchema, isSchemaDefinition, type SchemaDefinition, type SchemaOptions, type SchemaStateSpec, type SchemaStorageHints, } from "./primitives/define-schema.js";
@@ -26,7 +38,7 @@ export { eventFactory, isEventMessage, normalizeEventReturn, type EventMessage,
26
38
  export { defineWorkflow, COMPLETE_EVENT_NAME, type WorkflowDefinition, type WorkflowContext, type StatelessWorkflowContext, type StatefulWorkflowContext, type WorkflowEffects, type WorkflowOptions, type StatelessWorkflowOptions, type StatefulWorkflowOptions, type WorkflowOnOptions, type WorkflowRetryPolicy, type WorkflowStateSpec, type WorkflowInstance, type WorkflowCorrelateMap, type FireContext, type CompleteEvent, type StateCallable, type TimerDefinition, } from "./primitives/define-workflow.js";
27
39
  export { defineProjection, type ProjectionDefinition, type ProjectionOptions, type ProjectionReducer, } from "./primitives/define-projection.js";
28
40
  export { defineQuery, type QueryDefinition, type QueryOptions } from "./primitives/define-query.js";
29
- export { defineUpcaster, applyUpcasters, type UpcasterDefinition } from "./primitives/define-upcaster.js";
41
+ export { defineUpcaster, applyUpcasters, type UpcasterDefinition, } from "./primitives/define-upcaster.js";
30
42
  export { defineExternalCall, type ExternalCallDefinition, type ExternalCallMeta, type ExternalCallTarget, type ExternalCallSlo, type ExternalCallRetry, type ExternalCallExecutor, } from "./primitives/define-external-call.js";
31
43
  export { defineInboundWebhook, type InboundWebhookDefinition, type InboundWebhookMeta, type WebhookSignatureVerifier, type WebhookDedupe, } from "./primitives/define-inbound-webhook.js";
32
44
  export { defineOutbox, type OutboxDefinition, type OutboxMeta } from "./primitives/define-outbox.js";
@@ -39,11 +51,11 @@ export { InMemoryIdempotencyStore, type IdempotencyStore } from "./stores/idempo
39
51
  export { InMemoryWorkflowTimerStore, timerEventName, type WorkflowTimer, type WorkflowTimerStore, } from "./stores/workflow-timer-store.js";
40
52
  export { parseDelay } from "./helpers/retry-helpers.js";
41
53
  export { isPublic, type PublicMarker } from "./helpers/public-marker.js";
42
- export { attachDevLogger, formatTelemetry, type DevLoggerOptions } from "./observability/dev-logger.js";
54
+ export { attachDevLogger, formatTelemetry, type DevLoggerOptions, } from "./observability/dev-logger.js";
43
55
  export { runCli, type RunCliOptions } from "./helpers/cli-runner.js";
44
56
  export { NoopLogger, ConsoleLogger, loggerForEnvelope, type Logger } from "@nwire/logger";
45
57
  export { InMemoryDeadLetterSink, buildDeadLetterEntry, type DeadLetterSink, type DeadLetterEntry, } from "@nwire/dead-letter";
46
58
  export { defineResource, isResourceDefinition, type ResourceDefinition, type DefineResourceOptions, } from "@nwire/handler";
47
59
  export { response, isResponseSpec, isResponseInstance, ok, created, accepted, noContent, notModified, gone, type ResponseSpec, type ListResponseSpec, type ResponseInstance, type ResponseKind, } from "./helpers/response.js";
48
- import "./framework-events";
60
+ import "./framework-events.js";
49
61
  export type { FrameworkHooks, PluginKind } from "@nwire/app";
package/dist/index.js CHANGED
@@ -2,21 +2,33 @@
2
2
  * `@nwire/forge` — CQRS engine delivered as an app plugin.
3
3
  *
4
4
  * import { createApp } from "@nwire/app";
5
- * import { forgePlugin } from "@nwire/forge";
5
+ * import { createForgePlugin, forgeDispatcher } from "@nwire/forge";
6
6
  *
7
- * const app = createApp({ appName: "orders", plugins: [forgePlugin, …] });
7
+ * const app = createApp({
8
+ * appName: "orders",
9
+ * plugins: [createForgePlugin({ handlers, actors, projections, … })],
10
+ * });
11
+ * await app.start();
8
12
  *
9
- * Or via the convenience wrapper that auto-installs forgePlugin and adds
10
- * the dispatch/publish/query/… verbs as methods on the app handle:
11
- *
12
- * import { createForgeApp } from "@nwire/forge";
13
- *
14
- * const app = createForgeApp({ name: "orders", handlers, actors, … });
13
+ * const dispatcher = forgeDispatcher(app);
14
+ * await dispatcher.dispatch(placeOrder, { });
15
15
  */
16
16
  // ─── Plugin + composition ──────────────────────────────────────────
17
- export { forgePlugin, createForgePlugin, FORGE_DISPATCHER_BINDING, } from "./runtime/forge-plugin.js";
17
+ export { forgePlugin, createForgePlugin, forgeDispatcher, FORGE_DISPATCHER_BINDING, } from "./runtime/forge-plugin.js";
18
+ export { withForge } from "./runtime/with-forge.js";
19
+ export { actorsPlugin, FORGE_ACTOR_CHAIN_BINDING, FORGE_ACTOR_STORE_BINDING, } from "./plugins/actors-plugin.js";
20
+ export { ActorChainRunner } from "./plugins/actors-chain.js";
21
+ export { projectionsPlugin, FORGE_PROJECTION_CHAIN_BINDING, FORGE_PROJECTION_STORE_BINDING, } from "./plugins/projections-plugin.js";
22
+ export { ProjectionChainRunner } from "./plugins/projections-chain.js";
23
+ export { workflowsPlugin, FORGE_WORKFLOW_CHAIN_BINDING, FORGE_WORKFLOW_TIMER_STORE_BINDING, } from "./plugins/workflows-plugin.js";
24
+ export { WorkflowChainRunner } from "./plugins/workflows-chain.js";
25
+ export { queriesPlugin, FORGE_QUERY_RUNNER_BINDING } from "./plugins/queries-plugin.js";
26
+ export { QueryRunner } from "./plugins/queries-chain.js";
27
+ export { actionsPlugin, FORGE_ACTION_RUNNER_BINDING, FORGE_DEAD_LETTER_SINK_BINDING, } from "./plugins/actions-plugin.js";
28
+ export { ActionRunner } from "./plugins/actions-chain.js";
29
+ export { idempotencyPlugin, FORGE_IDEMPOTENCY_STORE_BINDING, } from "./plugins/idempotency-plugin.js";
30
+ export { dlqPlugin } from "./plugins/dlq-plugin.js";
18
31
  export { ForgeDispatcher } from "./runtime/forge-dispatcher.js";
19
- export { createForgeApp, } from "./runtime/create-forge-app.js";
20
32
  // ─── Domain primitives ─────────────────────────────────────────────
21
33
  export { defineAction, isCommandMessage, resolveDispatch, } from "./primitives/define-action.js";
22
34
  export { defineActor, eventKey, } from "./primitives/define-actor.js";
@@ -27,7 +39,7 @@ export { eventFactory, isEventMessage, normalizeEventReturn, } from "./messages/
27
39
  export { defineWorkflow, COMPLETE_EVENT_NAME, } from "./primitives/define-workflow.js";
28
40
  export { defineProjection, } from "./primitives/define-projection.js";
29
41
  export { defineQuery } from "./primitives/define-query.js";
30
- export { defineUpcaster, applyUpcasters } from "./primitives/define-upcaster.js";
42
+ export { defineUpcaster, applyUpcasters, } from "./primitives/define-upcaster.js";
31
43
  export { defineExternalCall, } from "./primitives/define-external-call.js";
32
44
  export { defineInboundWebhook, } from "./primitives/define-inbound-webhook.js";
33
45
  export { defineOutbox } from "./primitives/define-outbox.js";
@@ -42,7 +54,7 @@ export { InMemoryWorkflowTimerStore, timerEventName, } from "./stores/workflow-t
42
54
  // ─── Helpers ───────────────────────────────────────────────────────
43
55
  export { parseDelay } from "./helpers/retry-helpers.js";
44
56
  export { isPublic } from "./helpers/public-marker.js";
45
- export { attachDevLogger, formatTelemetry } from "./observability/dev-logger.js";
57
+ export { attachDevLogger, formatTelemetry, } from "./observability/dev-logger.js";
46
58
  export { runCli } from "./helpers/cli-runner.js";
47
59
  // ─── Re-exports from neighbours ────────────────────────────────────
48
60
  export { NoopLogger, ConsoleLogger, loggerForEnvelope } from "@nwire/logger";
@@ -51,4 +63,4 @@ export { defineResource, isResourceDefinition, } from "@nwire/handler";
51
63
  export { response, isResponseSpec, isResponseInstance, ok, created, accepted, noContent, notModified, gone, } from "./helpers/response.js";
52
64
  // Module augmentation side effect — adds Action* / Event* slots to
53
65
  // FrameworkHooks on @nwire/app's runtime.
54
- import "./framework-events";
66
+ import "./framework-events.js";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Action runner — handler registry + dispatch loop.
3
+ *
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.
13
+ */
14
+ import { type Runtime } from "@nwire/app";
15
+ import { type MessageEnvelope } from "@nwire/envelope";
16
+ import { type Hook } from "@nwire/hooks";
17
+ import type { Container } from "@nwire/container";
18
+ import { type DeadLetterSink } from "@nwire/dead-letter";
19
+ import type { ActionDefinition, ActionInput, ActionResult } from "../primitives/define-action.js";
20
+ import type { HandlerContext, HandlerDefinition } from "../primitives/define-handler.js";
21
+ import type { ActionBeforeHookCtx, ActionAfterHookCtx, DispatchOptions } from "../runtime/forge-types.js";
22
+ import { type EventMessage } from "../messages/event-message.js";
23
+ /** Publish callback — injected so the runner doesn't know about the chain hook directly. */
24
+ export type PublishEventsFn = (events: readonly EventMessage[], envelope: MessageEnvelope) => Promise<void>;
25
+ /** Optional ctx augmenter — actorsPlugin / externalCallsPlugin can extend ctx.use etc. */
26
+ export type CtxAugmenter = (ctx: HandlerContext, envelope: MessageEnvelope) => void;
27
+ export declare class ActionRunner {
28
+ private readonly runtime;
29
+ private readonly container;
30
+ private readonly deadLetterSink;
31
+ private readonly publishEvents;
32
+ readonly handlers: Map<string, HandlerDefinition<any>>;
33
+ readonly beforeHooks: Map<string, Hook<ActionBeforeHookCtx>>;
34
+ readonly afterHooks: Map<string, Hook<ActionAfterHookCtx>>;
35
+ readonly augmenters: CtxAugmenter[];
36
+ constructor(runtime: Runtime, container: Container, deadLetterSink: DeadLetterSink, publishEvents: PublishEventsFn);
37
+ register(handler: HandlerDefinition<any>): void;
38
+ /** Action handler names, in registration order. */
39
+ listHandlers(): readonly string[];
40
+ findActionByName(name: string): ActionDefinition<any> | undefined;
41
+ ensureBeforeHook(name: string): Hook<ActionBeforeHookCtx>;
42
+ ensureAfterHook(name: string): Hook<ActionAfterHookCtx>;
43
+ /** Plugins can hang extra ctx members on each handler invocation. */
44
+ registerCtxAugmenter(fn: CtxAugmenter): void;
45
+ dispatch<A extends ActionDefinition>(action: A, input: ActionInput<A>, parentEnvelope?: MessageEnvelope, opts?: DispatchOptions): Promise<ActionResult<A>>;
46
+ /** Build the handler ctx for one dispatch. */
47
+ private buildHandlerContext;
48
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Action runner — handler registry + dispatch loop.
3
+ *
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.
13
+ */
14
+ import { serializeError } from "@nwire/app";
15
+ import { deriveEnvelope, seedEnvelope } from "@nwire/envelope";
16
+ import { loggerForEnvelope } from "@nwire/logger";
17
+ import { isValidated, markValidated } from "@nwire/messages";
18
+ import { hook } from "@nwire/hooks";
19
+ import { buildDeadLetterEntry } from "@nwire/dead-letter";
20
+ import { normalizeEventReturn } from "../messages/event-message.js";
21
+ import { computeBackoff, sleep } from "../helpers/retry-helpers.js";
22
+ export class ActionRunner {
23
+ runtime;
24
+ container;
25
+ deadLetterSink;
26
+ publishEvents;
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ handlers = new Map();
29
+ beforeHooks = new Map();
30
+ afterHooks = new Map();
31
+ augmenters = [];
32
+ constructor(runtime, container, deadLetterSink, publishEvents) {
33
+ this.runtime = runtime;
34
+ this.container = container;
35
+ this.deadLetterSink = deadLetterSink;
36
+ this.publishEvents = publishEvents;
37
+ }
38
+ // 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.`);
42
+ }
43
+ this.handlers.set(handler.action.name, handler);
44
+ this.ensureBeforeHook(handler.action.name);
45
+ this.ensureAfterHook(handler.action.name);
46
+ }
47
+ /** Action handler names, in registration order. */
48
+ listHandlers() {
49
+ return [...this.handlers.keys()];
50
+ }
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ findActionByName(name) {
53
+ return this.handlers.get(name)?.action;
54
+ }
55
+ ensureBeforeHook(name) {
56
+ let h = this.beforeHooks.get(name);
57
+ if (h)
58
+ return h;
59
+ h = hook(`action.before:${name}`);
60
+ this.beforeHooks.set(name, h);
61
+ return h;
62
+ }
63
+ ensureAfterHook(name) {
64
+ let h = this.afterHooks.get(name);
65
+ if (h)
66
+ return h;
67
+ h = hook(`action.after:${name}`);
68
+ this.afterHooks.set(name, h);
69
+ return h;
70
+ }
71
+ /** Plugins can hang extra ctx members on each handler invocation. */
72
+ registerCtxAugmenter(fn) {
73
+ this.augmenters.push(fn);
74
+ }
75
+ async dispatch(action, input, parentEnvelope, opts) {
76
+ const handler = this.handlers.get(action.name);
77
+ if (!handler) {
78
+ throw new Error(`actionsPlugin: no handler registered for action "${action.name}".`);
79
+ }
80
+ const appName = this.runtime.appName;
81
+ const envelope = parentEnvelope ? deriveEnvelope(parentEnvelope) : seedEnvelope({});
82
+ const validated = isValidated(input) ? input : markValidated(action.schema.parse(input));
83
+ const log = loggerForEnvelope(this.runtime.logger, envelope);
84
+ const signal = opts?.signal ?? new AbortController().signal;
85
+ const ctx = this.buildHandlerContext(envelope, signal);
86
+ this.runtime.pushTelemetry({
87
+ kind: "action.dispatched",
88
+ action: action.name,
89
+ input: validated,
90
+ envelope,
91
+ appName,
92
+ ts: new Date().toISOString(),
93
+ });
94
+ const startedAt = performance.now();
95
+ const dispatchResult = await this.runtime.hooks.ActionDispatching.runDetailed({
96
+ action,
97
+ input: validated,
98
+ ctx,
99
+ });
100
+ if (dispatchResult.outcome === "failed")
101
+ throw dispatchResult.error;
102
+ if (dispatchResult.outcome !== "completed")
103
+ return undefined;
104
+ const beforeHook = this.beforeHooks.get(action.name);
105
+ if (beforeHook) {
106
+ const beforeCtx = { action, input: validated, ctx };
107
+ await beforeHook.run(beforeCtx);
108
+ if (beforeCtx.vetoed)
109
+ return undefined;
110
+ }
111
+ const retry = action.retry;
112
+ const maxAttempts = 1 + (retry?.max ?? 0);
113
+ let attempt = 0;
114
+ let lastError;
115
+ while (attempt < maxAttempts) {
116
+ attempt++;
117
+ if (attempt > 1 && signal.aborted) {
118
+ throw lastError;
119
+ }
120
+ try {
121
+ if (attempt > 1) {
122
+ const delay = computeBackoff(retry, attempt - 1);
123
+ if (delay > 0)
124
+ await sleep(delay);
125
+ }
126
+ const rawResult = await handler.handler(validated, ctx);
127
+ const events = normalizeEventReturn(rawResult ?? null);
128
+ if (events.length > 0) {
129
+ await this.publishEvents(events, envelope);
130
+ }
131
+ const durationMs = performance.now() - startedAt;
132
+ this.runtime.pushTelemetry({
133
+ kind: "action.completed",
134
+ action: action.name,
135
+ durationMs,
136
+ emittedEvents: events.map((e) => e.eventName),
137
+ envelope,
138
+ appName,
139
+ ts: new Date().toISOString(),
140
+ });
141
+ const afterHook = this.afterHooks.get(action.name);
142
+ if (afterHook) {
143
+ try {
144
+ await afterHook.run({
145
+ action,
146
+ input: validated,
147
+ result: rawResult ?? undefined,
148
+ durationMs,
149
+ });
150
+ }
151
+ catch (err) {
152
+ log.error(`action.after hook threw`, {
153
+ action: action.name,
154
+ error: err?.message,
155
+ });
156
+ }
157
+ }
158
+ void this.runtime.hooks.ActionCompleted.run({
159
+ action,
160
+ input: validated,
161
+ result: rawResult ?? undefined,
162
+ durationMs,
163
+ });
164
+ return rawResult;
165
+ }
166
+ catch (err) {
167
+ lastError = err;
168
+ this.runtime.pushTelemetry({
169
+ kind: "action.failed",
170
+ action: action.name,
171
+ attempt,
172
+ maxAttempts,
173
+ willRetry: attempt < maxAttempts,
174
+ error: serializeError(err),
175
+ envelope,
176
+ appName,
177
+ ts: new Date().toISOString(),
178
+ });
179
+ if (attempt >= maxAttempts) {
180
+ void this.runtime.hooks.ActionFailed.run({
181
+ action,
182
+ input: validated,
183
+ error: err,
184
+ durationMs: performance.now() - startedAt,
185
+ });
186
+ }
187
+ }
188
+ }
189
+ const entry = buildDeadLetterEntry(action.name, validated, envelope, attempt, lastError);
190
+ await this.deadLetterSink.record(entry);
191
+ log.error(`dead-lettered after ${attempt} attempts`, {
192
+ action: action.name,
193
+ error: entry.lastError.message,
194
+ });
195
+ this.runtime.pushTelemetry({
196
+ kind: "dlq.recorded",
197
+ action: action.name,
198
+ attempts: attempt,
199
+ error: serializeError(lastError),
200
+ envelope,
201
+ appName,
202
+ ts: new Date().toISOString(),
203
+ });
204
+ throw lastError;
205
+ }
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
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `actionsPlugin` — standalone forge action concern.
3
+ *
4
+ * import { createApp } from "@nwire/app";
5
+ * import {
6
+ * actionsPlugin,
7
+ * idempotencyPlugin,
8
+ * forgePlugin,
9
+ * } from "@nwire/forge";
10
+ *
11
+ * const app = createApp({
12
+ * appName: "orders",
13
+ * plugins: [
14
+ * forgePlugin,
15
+ * actionsPlugin([placeOrderHandler, sendReceiptHandler]),
16
+ * ],
17
+ * });
18
+ *
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
26
+ *
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.
30
+ */
31
+ import type { PluginDefinition } from "@nwire/app";
32
+ import { type DeadLetterSink } from "@nwire/dead-letter";
33
+ import type { HandlerDefinition } from "../primitives/define-handler.js";
34
+ export declare const FORGE_ACTION_RUNNER_BINDING: "forge.actionRunner";
35
+ export declare const FORGE_DEAD_LETTER_SINK_BINDING: "forge.deadLetterSink";
36
+ export interface ActionsPluginOptions {
37
+ /** Override the dead-letter sink. Defaults to `InMemoryDeadLetterSink`. */
38
+ readonly deadLetterSink?: DeadLetterSink;
39
+ }
40
+ export declare function actionsPlugin(handlers: readonly HandlerDefinition<any>[], opts?: ActionsPluginOptions): PluginDefinition;