@nwire/forge 0.10.1 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/framework-events.d.ts +45 -2
- package/dist/framework-events.js +11 -1
- package/dist/helpers/cli-runner.d.ts +1 -1
- package/dist/helpers/cli-runner.js +5 -4
- package/dist/index.d.ts +24 -12
- package/dist/index.js +24 -12
- package/dist/plugins/actions-chain.d.ts +48 -0
- package/dist/plugins/actions-chain.js +251 -0
- package/dist/plugins/actions-plugin.d.ts +40 -0
- package/dist/plugins/actions-plugin.js +76 -0
- package/dist/plugins/actors-chain.d.ts +52 -0
- package/dist/plugins/actors-chain.js +204 -0
- package/dist/plugins/actors-plugin.d.ts +36 -0
- package/dist/plugins/actors-plugin.js +62 -0
- package/dist/plugins/dlq-plugin.d.ts +29 -0
- package/dist/plugins/dlq-plugin.js +37 -0
- package/dist/plugins/idempotency-plugin.d.ts +28 -0
- package/dist/plugins/idempotency-plugin.js +57 -0
- package/dist/plugins/projections-chain.d.ts +34 -0
- package/dist/plugins/projections-chain.js +86 -0
- package/dist/plugins/projections-plugin.d.ts +36 -0
- package/dist/plugins/projections-plugin.js +63 -0
- package/dist/plugins/queries-chain.d.ts +33 -0
- package/dist/plugins/queries-chain.js +77 -0
- package/dist/plugins/queries-plugin.d.ts +41 -0
- package/dist/plugins/queries-plugin.js +74 -0
- package/dist/plugins/workflows-chain.d.ts +51 -0
- package/dist/plugins/workflows-chain.js +203 -0
- package/dist/plugins/workflows-plugin.d.ts +47 -0
- package/dist/plugins/workflows-plugin.js +81 -0
- package/dist/runtime/create-forge-app.d.ts +11 -11
- package/dist/runtime/create-forge-app.js +28 -32
- package/dist/runtime/forge-dispatcher.d.ts +27 -0
- package/dist/runtime/forge-dispatcher.js +100 -22
- package/dist/runtime/forge-plugin.d.ts +57 -42
- package/dist/runtime/forge-plugin.js +72 -59
- package/dist/runtime/with-forge.d.ts +26 -0
- package/dist/runtime/with-forge.js +30 -0
- package/dist/stores/idempotency-store.d.ts +15 -0
- package/dist/stores/idempotency-store.js +7 -0
- package/package.json +11 -11
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `workflowsPlugin` — standalone forge workflow concern.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import { workflowsPlugin, forgePlugin } from "@nwire/forge";
|
|
6
|
+
*
|
|
7
|
+
* const app = createApp({
|
|
8
|
+
* appName: "orders",
|
|
9
|
+
* plugins: [
|
|
10
|
+
* forgePlugin,
|
|
11
|
+
* workflowsPlugin([autoCharge, sendReceipt]),
|
|
12
|
+
* ],
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Owns:
|
|
16
|
+
* - the `forge.workflowTimerStore` container binding
|
|
17
|
+
* - the workflow registry (private to the plugin's `WorkflowChainRunner`)
|
|
18
|
+
* - the `forge.publish.workflows` step on the EventPublishing chain
|
|
19
|
+
* (priority 400 — between projections and bus delivery)
|
|
20
|
+
* - the `forge.workflowChain` container binding so other plugins can
|
|
21
|
+
* resolve the runner (the timer scheduler fires through it)
|
|
22
|
+
*
|
|
23
|
+
* Workflow effects (`send` / `enqueue` / `publish`) are resolved through
|
|
24
|
+
* the forge dispatcher bound on the container. `forgePlugin` (or a
|
|
25
|
+
* future `actionsPlugin`) must be installed alongside this plugin.
|
|
26
|
+
*
|
|
27
|
+
* If `forgePlugin`'s `options.workflows` is also passed, both installs
|
|
28
|
+
* fire every event — install ONE path, not both. A diagnostic warning
|
|
29
|
+
* fires at `AppReady` when both attachments are present.
|
|
30
|
+
*/
|
|
31
|
+
import type { PluginDefinition } from "@nwire/app";
|
|
32
|
+
import type { EventBus } from "@nwire/bus";
|
|
33
|
+
import { type WorkflowTimerStore } from "../stores/workflow-timer-store.js";
|
|
34
|
+
import type { WorkflowDefinition } from "../primitives/define-workflow.js";
|
|
35
|
+
export declare const FORGE_WORKFLOW_CHAIN_BINDING: "forge.workflowChain";
|
|
36
|
+
export declare const FORGE_WORKFLOW_TIMER_STORE_BINDING: "forge.workflowTimerStore";
|
|
37
|
+
export interface WorkflowsPluginOptions {
|
|
38
|
+
/** Override the timer store. Defaults to `InMemoryWorkflowTimerStore`. */
|
|
39
|
+
readonly workflowTimerStore?: WorkflowTimerStore;
|
|
40
|
+
/**
|
|
41
|
+
* Optional bus reference. Not used directly by workflows, but plugins
|
|
42
|
+
* that wrap the timer scheduler may need it; surfaced here so the
|
|
43
|
+
* standalone plugin doesn't force them through a re-resolve.
|
|
44
|
+
*/
|
|
45
|
+
readonly bus?: EventBus;
|
|
46
|
+
}
|
|
47
|
+
export declare function workflowsPlugin(workflows: readonly WorkflowDefinition[], opts?: WorkflowsPluginOptions): PluginDefinition;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `workflowsPlugin` — standalone forge workflow concern.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import { workflowsPlugin, forgePlugin } from "@nwire/forge";
|
|
6
|
+
*
|
|
7
|
+
* const app = createApp({
|
|
8
|
+
* appName: "orders",
|
|
9
|
+
* plugins: [
|
|
10
|
+
* forgePlugin,
|
|
11
|
+
* workflowsPlugin([autoCharge, sendReceipt]),
|
|
12
|
+
* ],
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Owns:
|
|
16
|
+
* - the `forge.workflowTimerStore` container binding
|
|
17
|
+
* - the workflow registry (private to the plugin's `WorkflowChainRunner`)
|
|
18
|
+
* - the `forge.publish.workflows` step on the EventPublishing chain
|
|
19
|
+
* (priority 400 — between projections and bus delivery)
|
|
20
|
+
* - the `forge.workflowChain` container binding so other plugins can
|
|
21
|
+
* resolve the runner (the timer scheduler fires through it)
|
|
22
|
+
*
|
|
23
|
+
* Workflow effects (`send` / `enqueue` / `publish`) are resolved through
|
|
24
|
+
* the forge dispatcher bound on the container. `forgePlugin` (or a
|
|
25
|
+
* future `actionsPlugin`) must be installed alongside this plugin.
|
|
26
|
+
*
|
|
27
|
+
* If `forgePlugin`'s `options.workflows` is also passed, both installs
|
|
28
|
+
* fire every event — install ONE path, not both. A diagnostic warning
|
|
29
|
+
* fires at `AppReady` when both attachments are present.
|
|
30
|
+
*/
|
|
31
|
+
import { InMemoryWorkflowTimerStore, } from "../stores/workflow-timer-store.js";
|
|
32
|
+
import { FORGE_DISPATCHER_BINDING } from "../runtime/forge-plugin.js";
|
|
33
|
+
import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
|
|
34
|
+
import { WorkflowChainRunner } from "./workflows-chain.js";
|
|
35
|
+
export const FORGE_WORKFLOW_CHAIN_BINDING = "forge.workflowChain";
|
|
36
|
+
export const FORGE_WORKFLOW_TIMER_STORE_BINDING = "forge.workflowTimerStore";
|
|
37
|
+
export function workflowsPlugin(workflows, opts = {}) {
|
|
38
|
+
return {
|
|
39
|
+
name: "forge.workflows",
|
|
40
|
+
register({ bind }) {
|
|
41
|
+
const store = opts.workflowTimerStore ?? new InMemoryWorkflowTimerStore();
|
|
42
|
+
bind(FORGE_WORKFLOW_TIMER_STORE_BINDING, () => store);
|
|
43
|
+
},
|
|
44
|
+
setup({ runtime, container, on }) {
|
|
45
|
+
const timerStore = container.resolve(FORGE_WORKFLOW_TIMER_STORE_BINDING);
|
|
46
|
+
const chain = new WorkflowChainRunner(runtime, timerStore, (envelope) => {
|
|
47
|
+
// Effects resolve the dispatcher lazily so the binding exists by
|
|
48
|
+
// the time a workflow actually fires.
|
|
49
|
+
const dispatcher = container.resolve(FORGE_DISPATCHER_BINDING);
|
|
50
|
+
const effects = {
|
|
51
|
+
async send(action, input) {
|
|
52
|
+
return dispatcher.dispatch(action, input, envelope);
|
|
53
|
+
},
|
|
54
|
+
async enqueue(action, input) {
|
|
55
|
+
void dispatcher.dispatch(action, input, envelope);
|
|
56
|
+
},
|
|
57
|
+
async publish(eventMsg) {
|
|
58
|
+
await dispatcher.publish([eventMsg], envelope);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return effects;
|
|
62
|
+
});
|
|
63
|
+
for (const workflow of workflows)
|
|
64
|
+
chain.register(workflow);
|
|
65
|
+
container.register(FORGE_WORKFLOW_CHAIN_BINDING, chain);
|
|
66
|
+
runtime.hooks.EventPublishing.use(async (payload, next) => {
|
|
67
|
+
await chain.apply(payload.event, payload.envelope);
|
|
68
|
+
await next();
|
|
69
|
+
}, { name: "forge.publish.workflows", priority: EVENT_PUBLISHING_PRIORITIES.workflows });
|
|
70
|
+
on("AppReady", () => {
|
|
71
|
+
const hookChain = runtime.hooks.EventPublishing;
|
|
72
|
+
const steps = (hookChain.chain ?? []).filter((s) => s.name === "forge.publish.workflows");
|
|
73
|
+
if (steps.length > 1) {
|
|
74
|
+
// eslint-disable-next-line no-console
|
|
75
|
+
console.warn(`workflowsPlugin: detected ${steps.length} "forge.publish.workflows" steps on the EventPublishing chain. ` +
|
|
76
|
+
`Install either workflowsPlugin OR forgePlugin's options.workflows path, not both.`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `createForgeApp` —
|
|
2
|
+
* `createForgeApp` — composition entry point for forge-shaped apps.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A thin wrapper over `createApp({appName, plugins})` that installs forge,
|
|
5
|
+
* registers direct domain primitives (actors / projections / workflows /
|
|
6
|
+
* etc.) during boot, and surfaces forge's dispatch verbs (`dispatch`,
|
|
7
|
+
* `publish`, `query`, …) as methods on the returned handle. Each verb
|
|
8
|
+
* resolves the `ForgeDispatcher` bound by `forgePlugin` and delegates.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* plugin's boot() runs).
|
|
10
|
+
* The App is the bounded context. Pass actors / projections / queries /
|
|
11
|
+
* workflows / external calls / handlers directly on opts; they register
|
|
12
|
+
* against the dispatcher during `AppBooting`, after every plugin's setup
|
|
13
|
+
* ran (so the dispatcher is bound) but before plugin boot queues fire.
|
|
14
14
|
*/
|
|
15
|
-
import { type Container } from "@nwire/container/awilix";
|
|
16
15
|
import { type PluginDefinition, type Runtime } from "@nwire/app";
|
|
16
|
+
import type { Container } from "@nwire/container";
|
|
17
17
|
import type { Logger } from "@nwire/logger";
|
|
18
18
|
import type { MessageEnvelope } from "@nwire/envelope";
|
|
19
19
|
import type { z } from "zod";
|
|
@@ -1,39 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `createForgeApp` —
|
|
2
|
+
* `createForgeApp` — composition entry point for forge-shaped apps.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A thin wrapper over `createApp({appName, plugins})` that installs forge,
|
|
5
|
+
* registers direct domain primitives (actors / projections / workflows /
|
|
6
|
+
* etc.) during boot, and surfaces forge's dispatch verbs (`dispatch`,
|
|
7
|
+
* `publish`, `query`, …) as methods on the returned handle. Each verb
|
|
8
|
+
* resolves the `ForgeDispatcher` bound by `forgePlugin` and delegates.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* plugin's boot() runs).
|
|
10
|
+
* The App is the bounded context. Pass actors / projections / queries /
|
|
11
|
+
* workflows / external calls / handlers directly on opts; they register
|
|
12
|
+
* against the dispatcher during `AppBooting`, after every plugin's setup
|
|
13
|
+
* ran (so the dispatcher is bound) but before plugin boot queues fire.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
16
|
-
import { createRuntime } from "@nwire/app";
|
|
15
|
+
import { createApp } from "@nwire/app";
|
|
17
16
|
import { forgePlugin as defaultForgePlugin, FORGE_DISPATCHER_BINDING, } from "./forge-plugin.js";
|
|
18
17
|
export function createForgeApp(options) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
// Forge plugin first; user plugins after. Users can pass an explicit
|
|
19
|
+
// `createForgePlugin(opts)` in their list to control stores.
|
|
20
|
+
const plugins = ensureForgePlugin(options.plugins ?? []);
|
|
21
|
+
const base = createApp({
|
|
23
22
|
appName: options.name,
|
|
23
|
+
container: options.container,
|
|
24
|
+
logger: options.logger,
|
|
25
|
+
plugins,
|
|
24
26
|
});
|
|
25
|
-
// Forge plugin first; user plugins after. The user can also pass an
|
|
26
|
-
// explicit `createForgePlugin(opts)` in their list to control stores.
|
|
27
|
-
const plugins = ensureForgePlugin(options.plugins ?? []);
|
|
28
|
-
for (const p of plugins) {
|
|
29
|
-
runtime.registerPlugin(p);
|
|
30
|
-
}
|
|
31
27
|
// Direct registrations land on the dispatcher during AppBooting —
|
|
32
28
|
// after every plugin's setup ran (so forge's dispatcher is bound)
|
|
33
29
|
// but before plugin boot queues fire (so domain routes are wired
|
|
34
|
-
// when downstream
|
|
35
|
-
runtime.hooks.AppBooting.use(async (_, next) => {
|
|
36
|
-
const d = container.resolve(FORGE_DISPATCHER_BINDING);
|
|
30
|
+
// when downstream plugin boot work might reach for them).
|
|
31
|
+
base.runtime.hooks.AppBooting.use(async (_, next) => {
|
|
32
|
+
const d = base.container.resolve(FORGE_DISPATCHER_BINDING);
|
|
37
33
|
for (const h of options.handlers ?? []) {
|
|
38
34
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
35
|
d.registerActionHandler(h);
|
|
@@ -50,16 +46,16 @@ export function createForgeApp(options) {
|
|
|
50
46
|
d.registerExternalCall(call);
|
|
51
47
|
await next();
|
|
52
48
|
}, { name: "forge.app.register-domain", priority: 1_000_000 });
|
|
53
|
-
const dispatcher = () => container.resolve(FORGE_DISPATCHER_BINDING);
|
|
49
|
+
const dispatcher = () => base.container.resolve(FORGE_DISPATCHER_BINDING);
|
|
54
50
|
const app = {
|
|
55
51
|
$nwireApp: true,
|
|
56
52
|
appName: options.name,
|
|
57
|
-
runtime,
|
|
58
|
-
container,
|
|
59
|
-
start: () =>
|
|
60
|
-
stop: (reason) =>
|
|
61
|
-
boot: () =>
|
|
62
|
-
shutdown: () =>
|
|
53
|
+
runtime: base.runtime,
|
|
54
|
+
container: base.container,
|
|
55
|
+
start: () => base.start(),
|
|
56
|
+
stop: (reason) => base.stop(reason),
|
|
57
|
+
boot: () => base.start(),
|
|
58
|
+
shutdown: () => base.stop(),
|
|
63
59
|
dispatcher,
|
|
64
60
|
dispatch: (action, input, parentEnvelope, opts) => dispatcher().dispatch(action, input, parentEnvelope, opts),
|
|
65
61
|
publish: (events, parentEnvelope) => dispatcher().publish(events, parentEnvelope),
|
|
@@ -133,7 +133,34 @@ export declare class ForgeDispatcher {
|
|
|
133
133
|
fireDueTimers(now?: number): Promise<number>;
|
|
134
134
|
fireDueWorkflowTimers(now?: Date): Promise<number>;
|
|
135
135
|
dispatch<A extends ActionDefinition>(action: A, input: ActionInput<A>, parentEnvelope?: MessageEnvelope, opts?: DispatchOptions): Promise<ActionResult<A>>;
|
|
136
|
+
/**
|
|
137
|
+
* Publish a batch of events. Each event flows through the
|
|
138
|
+
* `EventPublishing` hook chain — idempotency → actors → projections →
|
|
139
|
+
* workflows → bus — at structurally enforced priorities. The chain
|
|
140
|
+
* participants are attached once at plugin setup via
|
|
141
|
+
* {@link attachPublishChain}.
|
|
142
|
+
*
|
|
143
|
+
* Telemetry (`event.deduped` / `event.published`) is pushed here based
|
|
144
|
+
* on whether the idempotency step short-circuited the chain.
|
|
145
|
+
*/
|
|
136
146
|
publish(events: readonly EventMessage[], parentEnvelope: MessageEnvelope): Promise<void>;
|
|
147
|
+
/**
|
|
148
|
+
* Attach forge's atomic publish chain to the `EventPublishing` hook.
|
|
149
|
+
* Priority slots are defined in `EVENT_PUBLISHING_PRIORITIES`:
|
|
150
|
+
*
|
|
151
|
+
* 1000 — idempotency gate. Short-circuits on duplicate.
|
|
152
|
+
* 800 — actor state transitions.
|
|
153
|
+
* 600 — projection folds.
|
|
154
|
+
* 400 — workflow correlation + fire.
|
|
155
|
+
* 200 — cross-process bus delivery (public events only).
|
|
156
|
+
* 100 — outbound sink drain (public events only) — feeds
|
|
157
|
+
* adopters that install via `installSinkStage` (bullmq,
|
|
158
|
+
* AMQP, telemetry-otel, …).
|
|
159
|
+
*
|
|
160
|
+
* Called once at plugin setup. Idempotent — re-attaching is a no-op
|
|
161
|
+
* because the hook engine guards against duplicate step names.
|
|
162
|
+
*/
|
|
163
|
+
attachPublishChain(): void;
|
|
137
164
|
applyExternalEvent(eventName: string, payload: unknown, envelope: MessageEnvelope): Promise<void>;
|
|
138
165
|
private foldProjections;
|
|
139
166
|
private applyToActors;
|
|
@@ -21,13 +21,14 @@ import { hook } from "@nwire/hooks";
|
|
|
21
21
|
import { isValidated, markValidated } from "@nwire/messages";
|
|
22
22
|
import { seedEnvelope, deriveEnvelope } from "@nwire/envelope";
|
|
23
23
|
import { loggerForEnvelope } from "@nwire/logger";
|
|
24
|
-
import { buildDeadLetterEntry, InMemoryDeadLetterSink } from "@nwire/dead-letter";
|
|
24
|
+
import { buildDeadLetterEntry, InMemoryDeadLetterSink, } from "@nwire/dead-letter";
|
|
25
25
|
import { sleep, computeBackoff, parseDelay } from "../helpers/retry-helpers.js";
|
|
26
26
|
import { InMemoryActorStore, createInitialInstance, ActorVersionConflictError, } from "../stores/actor-store.js";
|
|
27
27
|
import { InMemoryProjectionStore } from "../stores/projection-store.js";
|
|
28
28
|
import { InMemoryIdempotencyStore } from "../stores/idempotency-store.js";
|
|
29
29
|
import { InMemoryWorkflowTimerStore, timerEventName, } from "../stores/workflow-timer-store.js";
|
|
30
30
|
import { normalizeEventReturn } from "../messages/event-message.js";
|
|
31
|
+
import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
|
|
31
32
|
export class ForgeDispatcher {
|
|
32
33
|
runtime;
|
|
33
34
|
// ─── Domain registries ──────────────────────────────────────────────
|
|
@@ -115,6 +116,17 @@ export class ForgeDispatcher {
|
|
|
115
116
|
this.handlers.set(name, handler);
|
|
116
117
|
this.ensureActionBeforeHook(name);
|
|
117
118
|
this.ensureActionAfterHook(name);
|
|
119
|
+
// The action declares `emits: [SomeEvent, ...]`. Any event the
|
|
120
|
+
// author marked `.public()` carries `$public: true` — surface it
|
|
121
|
+
// to the dispatcher so the EventPublishing chain's bus + outbound
|
|
122
|
+
// steps fire when that event flows through.
|
|
123
|
+
const emits = handler.action.emits;
|
|
124
|
+
if (emits) {
|
|
125
|
+
for (const ev of emits) {
|
|
126
|
+
if (ev.$public === true)
|
|
127
|
+
this.publicEventNames.add(ev.name);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
118
130
|
}
|
|
119
131
|
registerActor(actor) {
|
|
120
132
|
if (this.actors.has(actor.name)) {
|
|
@@ -575,13 +587,30 @@ export class ForgeDispatcher {
|
|
|
575
587
|
return hctx.result;
|
|
576
588
|
}
|
|
577
589
|
// ─── Publish + applyExternalEvent ──────────────────────────────────
|
|
590
|
+
/**
|
|
591
|
+
* Publish a batch of events. Each event flows through the
|
|
592
|
+
* `EventPublishing` hook chain — idempotency → actors → projections →
|
|
593
|
+
* workflows → bus — at structurally enforced priorities. The chain
|
|
594
|
+
* participants are attached once at plugin setup via
|
|
595
|
+
* {@link attachPublishChain}.
|
|
596
|
+
*
|
|
597
|
+
* Telemetry (`event.deduped` / `event.published`) is pushed here based
|
|
598
|
+
* on whether the idempotency step short-circuited the chain.
|
|
599
|
+
*/
|
|
578
600
|
async publish(events, parentEnvelope) {
|
|
579
601
|
const appName = this.runtime.appName;
|
|
580
602
|
for (let i = 0; i < events.length; i++) {
|
|
581
603
|
const event = events[i];
|
|
582
604
|
const dedupKey = events.length === 1 ? parentEnvelope.messageId : `${parentEnvelope.messageId}#${i}`;
|
|
583
605
|
const childEnvelope = deriveEnvelope(parentEnvelope);
|
|
584
|
-
|
|
606
|
+
const payload = {
|
|
607
|
+
event,
|
|
608
|
+
envelope: childEnvelope,
|
|
609
|
+
dedupKey,
|
|
610
|
+
deduped: false,
|
|
611
|
+
};
|
|
612
|
+
await this.runtime.hooks.EventPublishing.run(payload);
|
|
613
|
+
if (payload.deduped) {
|
|
585
614
|
this.runtime.pushTelemetry({
|
|
586
615
|
kind: "event.deduped",
|
|
587
616
|
event,
|
|
@@ -590,37 +619,87 @@ export class ForgeDispatcher {
|
|
|
590
619
|
appName,
|
|
591
620
|
ts: new Date().toISOString(),
|
|
592
621
|
});
|
|
593
|
-
continue;
|
|
594
622
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
this.runtime.pushTelemetry({
|
|
600
|
-
kind: "event.published",
|
|
601
|
-
event,
|
|
602
|
-
envelope: childEnvelope,
|
|
603
|
-
source: "in-process",
|
|
604
|
-
appName,
|
|
605
|
-
ts: new Date().toISOString(),
|
|
606
|
-
});
|
|
607
|
-
if (this.publishToBus && this.bus && this.publicEventNames.has(event.eventName)) {
|
|
608
|
-
await this.bus.publish({
|
|
609
|
-
eventName: event.eventName,
|
|
610
|
-
payload: event.payload,
|
|
623
|
+
else {
|
|
624
|
+
this.runtime.pushTelemetry({
|
|
625
|
+
kind: "event.published",
|
|
626
|
+
event,
|
|
611
627
|
envelope: childEnvelope,
|
|
612
|
-
|
|
628
|
+
source: "in-process",
|
|
629
|
+
appName,
|
|
630
|
+
ts: new Date().toISOString(),
|
|
613
631
|
});
|
|
614
632
|
}
|
|
615
633
|
}
|
|
616
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* Attach forge's atomic publish chain to the `EventPublishing` hook.
|
|
637
|
+
* Priority slots are defined in `EVENT_PUBLISHING_PRIORITIES`:
|
|
638
|
+
*
|
|
639
|
+
* 1000 — idempotency gate. Short-circuits on duplicate.
|
|
640
|
+
* 800 — actor state transitions.
|
|
641
|
+
* 600 — projection folds.
|
|
642
|
+
* 400 — workflow correlation + fire.
|
|
643
|
+
* 200 — cross-process bus delivery (public events only).
|
|
644
|
+
* 100 — outbound sink drain (public events only) — feeds
|
|
645
|
+
* adopters that install via `installSinkStage` (bullmq,
|
|
646
|
+
* AMQP, telemetry-otel, …).
|
|
647
|
+
*
|
|
648
|
+
* Called once at plugin setup. Idempotent — re-attaching is a no-op
|
|
649
|
+
* because the hook engine guards against duplicate step names.
|
|
650
|
+
*/
|
|
651
|
+
attachPublishChain() {
|
|
652
|
+
const hook = this.runtime.hooks.EventPublishing;
|
|
653
|
+
hook.use(async (payload, next) => {
|
|
654
|
+
const isNew = await this.idempotencyStore.recordIfNew(payload.dedupKey);
|
|
655
|
+
if (!isNew) {
|
|
656
|
+
payload.deduped = true;
|
|
657
|
+
return; // veto — downstream steps skipped
|
|
658
|
+
}
|
|
659
|
+
await next();
|
|
660
|
+
}, { name: "forge.publish.idempotency", priority: EVENT_PUBLISHING_PRIORITIES.idempotency });
|
|
661
|
+
hook.use(async (payload, next) => {
|
|
662
|
+
await this.applyToActors(payload.event, payload.envelope);
|
|
663
|
+
await next();
|
|
664
|
+
}, { name: "forge.publish.actors", priority: EVENT_PUBLISHING_PRIORITIES.actors });
|
|
665
|
+
hook.use(async (payload, next) => {
|
|
666
|
+
await this.foldProjections(payload.event, payload.envelope);
|
|
667
|
+
await next();
|
|
668
|
+
}, { name: "forge.publish.projections", priority: EVENT_PUBLISHING_PRIORITIES.projections });
|
|
669
|
+
hook.use(async (payload, next) => {
|
|
670
|
+
await this.runWorkflows(payload.event, payload.envelope);
|
|
671
|
+
await next();
|
|
672
|
+
}, { name: "forge.publish.workflows", priority: EVENT_PUBLISHING_PRIORITIES.workflows });
|
|
673
|
+
hook.use(async (payload, next) => {
|
|
674
|
+
if (this.publishToBus && this.bus && this.publicEventNames.has(payload.event.eventName)) {
|
|
675
|
+
await this.bus.publish({
|
|
676
|
+
eventName: payload.event.eventName,
|
|
677
|
+
payload: payload.event.payload,
|
|
678
|
+
envelope: payload.envelope,
|
|
679
|
+
origin: this.runtime.appName,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
await next();
|
|
683
|
+
}, { name: "forge.publish.bus", priority: EVENT_PUBLISHING_PRIORITIES.bus });
|
|
684
|
+
hook.use(async (payload, next) => {
|
|
685
|
+
if (this.publicEventNames.has(payload.event.eventName)) {
|
|
686
|
+
// Outbound sink chain — adopters like bullmq, AMQP, telemetry-otel
|
|
687
|
+
// install terminal stages via `installSinkStage`. `sinkDrain`
|
|
688
|
+
// reads `.name` off the event; the schema is not consulted here.
|
|
689
|
+
const eventRef = { name: payload.event.eventName };
|
|
690
|
+
await this.runtime.sinkDrain(eventRef, payload.event.payload, payload.envelope);
|
|
691
|
+
}
|
|
692
|
+
await next();
|
|
693
|
+
}, { name: "forge.publish.outbound", priority: EVENT_PUBLISHING_PRIORITIES.outbound });
|
|
694
|
+
}
|
|
617
695
|
async applyExternalEvent(eventName, payload, envelope) {
|
|
618
696
|
if (!this.externalEventNames.has(eventName)) {
|
|
619
697
|
throw new Error(`Runtime.applyExternalEvent: "${eventName}" was not declared in any module's needs.externalEvents.`);
|
|
620
698
|
}
|
|
621
699
|
const appName = this.runtime.appName;
|
|
622
700
|
const event = { eventName, payload };
|
|
623
|
-
|
|
701
|
+
const isNew = await this.idempotencyStore.recordIfNew(envelope.messageId);
|
|
702
|
+
if (!isNew) {
|
|
624
703
|
this.runtime.pushTelemetry({
|
|
625
704
|
kind: "event.deduped",
|
|
626
705
|
event,
|
|
@@ -631,7 +710,6 @@ export class ForgeDispatcher {
|
|
|
631
710
|
});
|
|
632
711
|
return;
|
|
633
712
|
}
|
|
634
|
-
await this.idempotencyStore.record(envelope.messageId);
|
|
635
713
|
await this.applyToActors(event, envelope);
|
|
636
714
|
await this.foldProjections(event, envelope);
|
|
637
715
|
await this.runWorkflows(event, envelope);
|
|
@@ -1,59 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Forge plugin — installs the dispatcher, materialises forge's framework
|
|
3
|
+
* hooks, pins the action handler step on the dispatch chain, and registers
|
|
4
|
+
* any domain primitives passed in options.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
6
|
+
* import { createApp } from "@nwire/app";
|
|
7
|
+
* import { createForgePlugin, FORGE_DISPATCHER_BINDING } from "@nwire/forge";
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
9
|
+
* const app = createApp({
|
|
10
|
+
* appName: "orders",
|
|
11
|
+
* plugins: [createForgePlugin({
|
|
12
|
+
* actors: [Order],
|
|
13
|
+
* projections: [OrdersDashboard],
|
|
14
|
+
* handlers: [placeOrder, sendReceipt],
|
|
15
|
+
* })],
|
|
16
|
+
* });
|
|
8
17
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
18
|
+
* await app.start();
|
|
19
|
+
* const dispatcher = app.container.resolve(FORGE_DISPATCHER_BINDING);
|
|
11
20
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* there to call `dispatch / publish / fireDueTimers / …`.
|
|
17
|
-
* 3. Materialise the Action* / Event* framework-hook slots forge's
|
|
18
|
-
* module augmentation declares — kernel pre-instantiates only
|
|
19
|
-
* App* / Plugin* / Wire* slots; forge's domain slots come in via
|
|
20
|
-
* the plugin so a non-forge app's runtime stays uncluttered.
|
|
21
|
-
* 4. Pin the action handler chain step on `runtime.dispatchHook$` at
|
|
22
|
-
* priority `-Infinity` so user-registered `runtime.use()` middleware
|
|
23
|
-
* stays strictly OUTSIDE the handler invocation.
|
|
21
|
+
* Handlers and direct registrations land on the dispatcher during the
|
|
22
|
+
* `AppBooting` chain — after every plugin's setup ran (so the dispatcher
|
|
23
|
+
* is bound) but before plugin boot queues fire (so downstream plugins'
|
|
24
|
+
* boot work can reach domain routes already wired).
|
|
24
25
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* their own `bind({ dispose })` registrations.
|
|
26
|
+
* `forgePlugin` is the default-options shortcut for tests, demos, and
|
|
27
|
+
* single-process apps. For custom stores or a cross-service bus, use
|
|
28
|
+
* `createForgePlugin(opts)`.
|
|
29
29
|
*/
|
|
30
30
|
import type { PluginDefinition } from "@nwire/app";
|
|
31
|
-
import { type ForgeDispatcherOptions } from "./forge-dispatcher.js";
|
|
32
|
-
|
|
31
|
+
import { ForgeDispatcher, type ForgeDispatcherOptions } from "./forge-dispatcher.js";
|
|
32
|
+
import type { ActorDefinition } from "../primitives/define-actor.js";
|
|
33
|
+
import type { ProjectionDefinition } from "../primitives/define-projection.js";
|
|
34
|
+
import type { QueryDefinition } from "../primitives/define-query.js";
|
|
35
|
+
import type { WorkflowDefinition } from "../primitives/define-workflow.js";
|
|
36
|
+
import type { ExternalCallDefinition } from "../primitives/define-external-call.js";
|
|
37
|
+
import type { HandlerDefinition as ForgeHandlerDef } from "../primitives/define-handler.js";
|
|
38
|
+
/** Container binding the forge dispatcher is registered under. */
|
|
33
39
|
export declare const FORGE_DISPATCHER_BINDING: "forge.dispatcher";
|
|
34
|
-
/** Capability bindings
|
|
40
|
+
/** Capability bindings consumed by handler / resolver ctx via `ctx.resolve(...)`. */
|
|
35
41
|
export declare const FORGE_EXECUTE_BINDING: "execute";
|
|
36
42
|
export declare const FORGE_SEND_BINDING: "send";
|
|
37
43
|
export declare const FORGE_USE_PROJECTION_BINDING: "useProjection";
|
|
38
44
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*/
|
|
43
|
-
export declare const forgePlugin: PluginDefinition;
|
|
44
|
-
/**
|
|
45
|
-
* Configurable forge plugin. Pass:
|
|
46
|
-
* - durable stores (actorStore, projectionStore, deadLetterSink, …)
|
|
47
|
-
* - a cross-service EventBus to bridge in-process events out
|
|
48
|
-
* - domain registrations (actors, workflows, projections, queries,
|
|
49
|
-
* externalCalls) — picked up at AppBooting and forwarded to the
|
|
50
|
-
* dispatcher so consumers don't need to wire them manually.
|
|
45
|
+
* Options accepted by `createForgePlugin`. Includes the dispatcher's own
|
|
46
|
+
* options (stores, bus) and the domain primitives that register against
|
|
47
|
+
* the dispatcher during the boot phase.
|
|
51
48
|
*/
|
|
52
49
|
export interface ForgePluginOptions extends ForgeDispatcherOptions {
|
|
53
|
-
readonly
|
|
54
|
-
readonly
|
|
55
|
-
readonly projections?: readonly any[];
|
|
56
|
-
readonly queries?: readonly any[];
|
|
57
|
-
readonly
|
|
50
|
+
readonly handlers?: readonly ForgeHandlerDef<any>[];
|
|
51
|
+
readonly actors?: readonly ActorDefinition[];
|
|
52
|
+
readonly projections?: readonly ProjectionDefinition<any>[];
|
|
53
|
+
readonly queries?: readonly QueryDefinition<any, any, any>[];
|
|
54
|
+
readonly workflows?: readonly WorkflowDefinition[];
|
|
55
|
+
readonly externalCalls?: readonly ExternalCallDefinition<any, any>[];
|
|
58
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Construct a forge plugin with custom stores, bus, and/or domain
|
|
59
|
+
* primitives. The plugin's setup is synchronous; domain registration
|
|
60
|
+
* happens during the App's `AppBooting` chain.
|
|
61
|
+
*/
|
|
59
62
|
export declare function createForgePlugin(options?: ForgePluginOptions): PluginDefinition;
|
|
63
|
+
/** Default forge plugin — in-memory stores, no bus, no domain options. */
|
|
64
|
+
export declare const forgePlugin: PluginDefinition;
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the forge dispatcher from an app's container. Convenience for
|
|
67
|
+
* test code and tooling that needs direct dispatcher access; handler code
|
|
68
|
+
* uses `ctx.execute` / `ctx.send` / `ctx.publish` instead.
|
|
69
|
+
*/
|
|
70
|
+
export declare function forgeDispatcher(app: {
|
|
71
|
+
container: {
|
|
72
|
+
resolve<T>(name: string): T;
|
|
73
|
+
};
|
|
74
|
+
}): ForgeDispatcher;
|