@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,76 @@
|
|
|
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 { InMemoryDeadLetterSink } from "@nwire/dead-letter";
|
|
32
|
+
import { ActionRunner } from "./actions-chain.js";
|
|
33
|
+
export const FORGE_ACTION_RUNNER_BINDING = "forge.actionRunner";
|
|
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 = {}) {
|
|
38
|
+
return {
|
|
39
|
+
name: "forge.actions",
|
|
40
|
+
register({ bind, container }) {
|
|
41
|
+
// Don't shadow a sink that dlqPlugin already bound. The option
|
|
42
|
+
// override still wins when explicitly passed.
|
|
43
|
+
if (opts.deadLetterSink) {
|
|
44
|
+
bind(FORGE_DEAD_LETTER_SINK_BINDING, () => opts.deadLetterSink);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (container.has(FORGE_DEAD_LETTER_SINK_BINDING))
|
|
48
|
+
return;
|
|
49
|
+
const sink = new InMemoryDeadLetterSink();
|
|
50
|
+
bind(FORGE_DEAD_LETTER_SINK_BINDING, () => sink);
|
|
51
|
+
},
|
|
52
|
+
setup({ runtime, container }) {
|
|
53
|
+
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) => {
|
|
58
|
+
for (let i = 0; i < events.length; i++) {
|
|
59
|
+
const event = events[i];
|
|
60
|
+
const dedupKey = events.length === 1 ? envelope.messageId : `${envelope.messageId}#${i}`;
|
|
61
|
+
const payload = {
|
|
62
|
+
event,
|
|
63
|
+
envelope,
|
|
64
|
+
dedupKey,
|
|
65
|
+
deduped: false,
|
|
66
|
+
};
|
|
67
|
+
await runtime.hooks.EventPublishing.run(payload);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const runner = new ActionRunner(runtime, container, sink, publishEvents);
|
|
71
|
+
for (const handler of handlers)
|
|
72
|
+
runner.register(handler);
|
|
73
|
+
container.register(FORGE_ACTION_RUNNER_BINDING, runner);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor chain runner — the EventPublishing step that applies an event to
|
|
3
|
+
* every registered actor type whose reactions match.
|
|
4
|
+
*
|
|
5
|
+
* Self-contained: takes its dependencies as constructor arguments. Both
|
|
6
|
+
* `actorsPlugin` and the bundled `ForgeDispatcher` instantiate one of
|
|
7
|
+
* these and call `apply(event, envelope)` from their respective hook
|
|
8
|
+
* attachment.
|
|
9
|
+
*
|
|
10
|
+
* Behavior matches the original dispatcher implementation: per-actor
|
|
11
|
+
* locking, optimistic concurrency retry, schema validation, per-actor
|
|
12
|
+
* transition hook fan-out, and `actor.transitioned` telemetry.
|
|
13
|
+
*/
|
|
14
|
+
import { type Hook } from "@nwire/hooks";
|
|
15
|
+
import { type MessageEnvelope } from "@nwire/envelope";
|
|
16
|
+
import { type Runtime } from "@nwire/app";
|
|
17
|
+
import type { ActorDefinition } from "../primitives/define-actor.js";
|
|
18
|
+
import type { ActorTransitionHookCtx } from "../runtime/forge-types.js";
|
|
19
|
+
import type { EventMessage } from "../messages/event-message.js";
|
|
20
|
+
import { type ActorStore } from "../stores/actor-store.js";
|
|
21
|
+
/**
|
|
22
|
+
* Listener fired after any actor transition (any type). Plugins observe
|
|
23
|
+
* cross-actor side effects with these (the workflow timer scheduler is
|
|
24
|
+
* the canonical example).
|
|
25
|
+
*/
|
|
26
|
+
export type ActorTransitionListener = (actor: ActorDefinition, key: string, fromState: string, toState: string, triggeringEvent: EventMessage, envelope: MessageEnvelope) => void | Promise<void>;
|
|
27
|
+
export declare class ActorChainRunner {
|
|
28
|
+
private readonly runtime;
|
|
29
|
+
readonly store: ActorStore;
|
|
30
|
+
readonly actors: Map<string, ActorDefinition>;
|
|
31
|
+
readonly perActorHooks: Map<string, Hook<ActorTransitionHookCtx>>;
|
|
32
|
+
readonly globalListeners: ActorTransitionListener[];
|
|
33
|
+
constructor(runtime: Runtime, store: ActorStore);
|
|
34
|
+
/** Register an actor type. Throws on duplicate names. */
|
|
35
|
+
register(actor: ActorDefinition): void;
|
|
36
|
+
/** All registered actor type names, in registration order. */
|
|
37
|
+
listActors(): readonly string[];
|
|
38
|
+
/** Register a global transition listener. */
|
|
39
|
+
observeTransitions(listener: ActorTransitionListener): void;
|
|
40
|
+
/** Lazy-create the per-actor transition hook. */
|
|
41
|
+
ensureTransitionHook(actorName: string): Hook<ActorTransitionHookCtx>;
|
|
42
|
+
/**
|
|
43
|
+
* Apply one event to every actor type whose reactions match. Called
|
|
44
|
+
* by the EventPublishing step at priority 800.
|
|
45
|
+
*/
|
|
46
|
+
apply(event: EventMessage, envelope: MessageEnvelope): Promise<void>;
|
|
47
|
+
private extractKey;
|
|
48
|
+
private applyToOne;
|
|
49
|
+
private applyLocked;
|
|
50
|
+
private computeTimersForState;
|
|
51
|
+
private envelopeLogger;
|
|
52
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor chain runner — the EventPublishing step that applies an event to
|
|
3
|
+
* every registered actor type whose reactions match.
|
|
4
|
+
*
|
|
5
|
+
* Self-contained: takes its dependencies as constructor arguments. Both
|
|
6
|
+
* `actorsPlugin` and the bundled `ForgeDispatcher` instantiate one of
|
|
7
|
+
* these and call `apply(event, envelope)` from their respective hook
|
|
8
|
+
* attachment.
|
|
9
|
+
*
|
|
10
|
+
* Behavior matches the original dispatcher implementation: per-actor
|
|
11
|
+
* locking, optimistic concurrency retry, schema validation, per-actor
|
|
12
|
+
* transition hook fan-out, and `actor.transitioned` telemetry.
|
|
13
|
+
*/
|
|
14
|
+
import { hook } from "@nwire/hooks";
|
|
15
|
+
import { loggerForEnvelope } from "@nwire/logger";
|
|
16
|
+
import { parseDelay } from "../helpers/retry-helpers.js";
|
|
17
|
+
import { createInitialInstance, ActorVersionConflictError, } from "../stores/actor-store.js";
|
|
18
|
+
export class ActorChainRunner {
|
|
19
|
+
runtime;
|
|
20
|
+
store;
|
|
21
|
+
actors = new Map();
|
|
22
|
+
perActorHooks = new Map();
|
|
23
|
+
globalListeners = [];
|
|
24
|
+
constructor(runtime, store) {
|
|
25
|
+
this.runtime = runtime;
|
|
26
|
+
this.store = store;
|
|
27
|
+
}
|
|
28
|
+
/** Register an actor type. Throws on duplicate names. */
|
|
29
|
+
register(actor) {
|
|
30
|
+
if (this.actors.has(actor.name)) {
|
|
31
|
+
throw new Error(`actorsPlugin: actor "${actor.name}" already registered.`);
|
|
32
|
+
}
|
|
33
|
+
this.actors.set(actor.name, actor);
|
|
34
|
+
this.ensureTransitionHook(actor.name);
|
|
35
|
+
}
|
|
36
|
+
/** All registered actor type names, in registration order. */
|
|
37
|
+
listActors() {
|
|
38
|
+
return [...this.actors.keys()];
|
|
39
|
+
}
|
|
40
|
+
/** Register a global transition listener. */
|
|
41
|
+
observeTransitions(listener) {
|
|
42
|
+
this.globalListeners.push(listener);
|
|
43
|
+
}
|
|
44
|
+
/** Lazy-create the per-actor transition hook. */
|
|
45
|
+
ensureTransitionHook(actorName) {
|
|
46
|
+
let h = this.perActorHooks.get(actorName);
|
|
47
|
+
if (h)
|
|
48
|
+
return h;
|
|
49
|
+
h = hook(`actor.transition:${actorName}`);
|
|
50
|
+
this.perActorHooks.set(actorName, h);
|
|
51
|
+
return h;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Apply one event to every actor type whose reactions match. Called
|
|
55
|
+
* by the EventPublishing step at priority 800.
|
|
56
|
+
*/
|
|
57
|
+
async apply(event, envelope) {
|
|
58
|
+
const tenant = envelope.tenant ?? "";
|
|
59
|
+
for (const actor of this.actors.values()) {
|
|
60
|
+
const reactionsForEvent = actor.eventIndex.get(event.eventName);
|
|
61
|
+
if (!reactionsForEvent || reactionsForEvent.length === 0)
|
|
62
|
+
continue;
|
|
63
|
+
const key = this.extractKey(event, actor);
|
|
64
|
+
if (key === undefined || key === null)
|
|
65
|
+
continue;
|
|
66
|
+
await this.applyToOne(actor, String(key), tenant, event, reactionsForEvent, envelope);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
extractKey(event, actor) {
|
|
70
|
+
const payload = event.payload;
|
|
71
|
+
if (!payload || typeof payload !== "object")
|
|
72
|
+
return undefined;
|
|
73
|
+
return payload[actor.key];
|
|
74
|
+
}
|
|
75
|
+
async applyToOne(actor, key, tenant, event, candidateReactions, envelope) {
|
|
76
|
+
const release = (await this.store.lockKey?.(actor.name, key, tenant)) ?? (() => { });
|
|
77
|
+
try {
|
|
78
|
+
await this.applyLocked(actor, key, tenant, event, candidateReactions, envelope);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
release();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async applyLocked(actor, key, tenant, event, candidateReactions, envelope) {
|
|
85
|
+
const appName = this.runtime.appName;
|
|
86
|
+
const maxOccRetries = 3;
|
|
87
|
+
for (let occAttempt = 0; occAttempt < maxOccRetries; occAttempt++) {
|
|
88
|
+
const existing = await this.store.load(actor.name, key, tenant);
|
|
89
|
+
const instance = existing ?? createInitialInstance(actor, key, tenant);
|
|
90
|
+
const matching = candidateReactions.find((c) => c.state === instance.state);
|
|
91
|
+
if (!matching)
|
|
92
|
+
return;
|
|
93
|
+
const stateConfig = actor.states[instance.state];
|
|
94
|
+
if (stateConfig?.final)
|
|
95
|
+
return;
|
|
96
|
+
const partial = matching.reaction.assign
|
|
97
|
+
? matching.reaction.assign(instance.data, event.payload)
|
|
98
|
+
: {};
|
|
99
|
+
const nextData = { ...instance.data, ...partial };
|
|
100
|
+
const nextStateName = matching.reaction.target ?? instance.state;
|
|
101
|
+
const nextStateConfig = actor.states[nextStateName];
|
|
102
|
+
if (!nextStateConfig) {
|
|
103
|
+
throw new Error(`actorsPlugin: actor "${actor.name}" reaction targets undeclared state "${nextStateName}" from "${instance.state}".`);
|
|
104
|
+
}
|
|
105
|
+
const validated = actor.schema.parse(nextData);
|
|
106
|
+
const stateChanged = nextStateName !== instance.state;
|
|
107
|
+
const isNewActor = !existing;
|
|
108
|
+
const nextTimers = stateChanged || isNewActor
|
|
109
|
+
? this.computeTimersForState(actor, nextStateName, key)
|
|
110
|
+
: instance.activeTimers;
|
|
111
|
+
const nextInstance = {
|
|
112
|
+
actorName: actor.name,
|
|
113
|
+
key,
|
|
114
|
+
tenant,
|
|
115
|
+
state: nextStateName,
|
|
116
|
+
data: validated,
|
|
117
|
+
activeTimers: nextTimers,
|
|
118
|
+
version: instance.version,
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
await this.store.save(nextInstance, { expectedVersion: instance.version });
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (err instanceof ActorVersionConflictError && occAttempt < maxOccRetries - 1)
|
|
125
|
+
continue;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
if (stateChanged) {
|
|
129
|
+
this.runtime.pushTelemetry({
|
|
130
|
+
kind: "actor.transitioned",
|
|
131
|
+
actor: actor.name,
|
|
132
|
+
key,
|
|
133
|
+
tenant,
|
|
134
|
+
from: instance.state,
|
|
135
|
+
to: nextStateName,
|
|
136
|
+
triggeringEvent: event.eventName,
|
|
137
|
+
envelope,
|
|
138
|
+
appName,
|
|
139
|
+
ts: new Date().toISOString(),
|
|
140
|
+
});
|
|
141
|
+
for (const listener of this.globalListeners) {
|
|
142
|
+
await listener(actor, key, instance.state, nextStateName, event, envelope);
|
|
143
|
+
}
|
|
144
|
+
const perActorHook = this.perActorHooks.get(actor.name);
|
|
145
|
+
if (perActorHook) {
|
|
146
|
+
try {
|
|
147
|
+
await perActorHook.run({
|
|
148
|
+
actor,
|
|
149
|
+
key,
|
|
150
|
+
fromState: instance.state,
|
|
151
|
+
toState: nextStateName,
|
|
152
|
+
triggeringEvent: event,
|
|
153
|
+
envelope,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
this.envelopeLogger(envelope).error(`actor.transition hook threw`, {
|
|
158
|
+
actor: actor.name,
|
|
159
|
+
error: err?.message,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
computeTimersForState(actor, stateName, actorKey) {
|
|
168
|
+
const stateConfig = actor.states[stateName];
|
|
169
|
+
if (!stateConfig?.after)
|
|
170
|
+
return {};
|
|
171
|
+
const appName = this.runtime.appName;
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
const timers = {};
|
|
174
|
+
for (const [timerName, spec] of Object.entries(stateConfig.after)) {
|
|
175
|
+
const delayString = typeof spec === "string" ? timerName : spec.delay;
|
|
176
|
+
const action = typeof spec === "string" ? spec : spec.action;
|
|
177
|
+
const input = typeof spec === "string" || !spec.buildInput
|
|
178
|
+
? { [actor.key]: actorKey }
|
|
179
|
+
: spec.buildInput({}, actorKey);
|
|
180
|
+
const handle = {
|
|
181
|
+
scheduledAt: now,
|
|
182
|
+
fireAt: now + parseDelay(delayString),
|
|
183
|
+
action,
|
|
184
|
+
input,
|
|
185
|
+
};
|
|
186
|
+
timers[timerName] = handle;
|
|
187
|
+
this.runtime.pushTelemetry({
|
|
188
|
+
kind: "timer.scheduled",
|
|
189
|
+
actor: actor.name,
|
|
190
|
+
key: actorKey,
|
|
191
|
+
timer: timerName,
|
|
192
|
+
action,
|
|
193
|
+
fireAt: handle.fireAt,
|
|
194
|
+
tenant: "",
|
|
195
|
+
appName,
|
|
196
|
+
ts: new Date().toISOString(),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return timers;
|
|
200
|
+
}
|
|
201
|
+
envelopeLogger(envelope) {
|
|
202
|
+
return loggerForEnvelope(this.runtime.logger, envelope);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `actorsPlugin` — standalone forge actor concern.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import { actorsPlugin, forgePlugin } from "@nwire/forge";
|
|
6
|
+
*
|
|
7
|
+
* const app = createApp({
|
|
8
|
+
* appName: "orders",
|
|
9
|
+
* plugins: [
|
|
10
|
+
* forgePlugin, // publish orchestration + idempotency + bus
|
|
11
|
+
* actorsPlugin([Order, Cart]), // actor state transitions
|
|
12
|
+
* ],
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Owns:
|
|
16
|
+
* - the `forge.actorStore` container binding
|
|
17
|
+
* - the actor types registry (private to the plugin's `ActorChainRunner`)
|
|
18
|
+
* - the `forge.publish.actors` step on the EventPublishing chain
|
|
19
|
+
* (priority 800 — between idempotency and projections)
|
|
20
|
+
* - the `forge.actorChain` container binding so other plugins can
|
|
21
|
+
* resolve the runner (workflow timer scheduler, ctx.use binding, …)
|
|
22
|
+
*
|
|
23
|
+
* If `forgePlugin`'s `options.actors` is also passed, both installs run
|
|
24
|
+
* actor work for every event — install ONE path, not both. A diagnostic
|
|
25
|
+
* warning fires at `AppReady` when both attachments are present.
|
|
26
|
+
*/
|
|
27
|
+
import type { PluginDefinition } from "@nwire/app";
|
|
28
|
+
import { type ActorStore } from "../stores/actor-store.js";
|
|
29
|
+
import type { ActorDefinition } from "../primitives/define-actor.js";
|
|
30
|
+
export declare const FORGE_ACTOR_CHAIN_BINDING: "forge.actorChain";
|
|
31
|
+
export declare const FORGE_ACTOR_STORE_BINDING: "forge.actorStore";
|
|
32
|
+
export interface ActorsPluginOptions {
|
|
33
|
+
/** Override the actor store. Defaults to `InMemoryActorStore`. */
|
|
34
|
+
readonly actorStore?: ActorStore;
|
|
35
|
+
}
|
|
36
|
+
export declare function actorsPlugin(actorTypes: readonly ActorDefinition[], opts?: ActorsPluginOptions): PluginDefinition;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `actorsPlugin` — standalone forge actor concern.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import { actorsPlugin, forgePlugin } from "@nwire/forge";
|
|
6
|
+
*
|
|
7
|
+
* const app = createApp({
|
|
8
|
+
* appName: "orders",
|
|
9
|
+
* plugins: [
|
|
10
|
+
* forgePlugin, // publish orchestration + idempotency + bus
|
|
11
|
+
* actorsPlugin([Order, Cart]), // actor state transitions
|
|
12
|
+
* ],
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Owns:
|
|
16
|
+
* - the `forge.actorStore` container binding
|
|
17
|
+
* - the actor types registry (private to the plugin's `ActorChainRunner`)
|
|
18
|
+
* - the `forge.publish.actors` step on the EventPublishing chain
|
|
19
|
+
* (priority 800 — between idempotency and projections)
|
|
20
|
+
* - the `forge.actorChain` container binding so other plugins can
|
|
21
|
+
* resolve the runner (workflow timer scheduler, ctx.use binding, …)
|
|
22
|
+
*
|
|
23
|
+
* If `forgePlugin`'s `options.actors` is also passed, both installs run
|
|
24
|
+
* actor work for every event — install ONE path, not both. A diagnostic
|
|
25
|
+
* warning fires at `AppReady` when both attachments are present.
|
|
26
|
+
*/
|
|
27
|
+
import { InMemoryActorStore } from "../stores/actor-store.js";
|
|
28
|
+
import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
|
|
29
|
+
import { ActorChainRunner } from "./actors-chain.js";
|
|
30
|
+
export const FORGE_ACTOR_CHAIN_BINDING = "forge.actorChain";
|
|
31
|
+
export const FORGE_ACTOR_STORE_BINDING = "forge.actorStore";
|
|
32
|
+
export function actorsPlugin(actorTypes, opts = {}) {
|
|
33
|
+
return {
|
|
34
|
+
name: "forge.actors",
|
|
35
|
+
register({ bind }) {
|
|
36
|
+
const store = opts.actorStore ?? new InMemoryActorStore();
|
|
37
|
+
bind(FORGE_ACTOR_STORE_BINDING, () => store);
|
|
38
|
+
},
|
|
39
|
+
setup({ runtime, container, on }) {
|
|
40
|
+
const store = container.resolve(FORGE_ACTOR_STORE_BINDING);
|
|
41
|
+
const chain = new ActorChainRunner(runtime, store);
|
|
42
|
+
for (const actor of actorTypes)
|
|
43
|
+
chain.register(actor);
|
|
44
|
+
container.register(FORGE_ACTOR_CHAIN_BINDING, chain);
|
|
45
|
+
runtime.hooks.EventPublishing.use(async (payload, next) => {
|
|
46
|
+
await chain.apply(payload.event, payload.envelope);
|
|
47
|
+
await next();
|
|
48
|
+
}, { name: "forge.publish.actors", priority: EVENT_PUBLISHING_PRIORITIES.actors });
|
|
49
|
+
// Diagnostic — warn if forgePlugin's bundled mode also attached an
|
|
50
|
+
// actors step. Both would run and produce duplicate work.
|
|
51
|
+
on("AppReady", () => {
|
|
52
|
+
const hookChain = runtime.hooks.EventPublishing;
|
|
53
|
+
const steps = (hookChain.chain ?? []).filter((s) => s.name === "forge.publish.actors");
|
|
54
|
+
if (steps.length > 1) {
|
|
55
|
+
// eslint-disable-next-line no-console
|
|
56
|
+
console.warn(`actorsPlugin: detected ${steps.length} "forge.publish.actors" steps on the EventPublishing chain. ` +
|
|
57
|
+
`Install either actorsPlugin OR forgePlugin's options.actors path, not both.`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dlqPlugin` — owns the dead-letter sink binding.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import { dlqPlugin, actionsPlugin } from "@nwire/forge";
|
|
6
|
+
*
|
|
7
|
+
* const app = createApp({
|
|
8
|
+
* appName: "orders",
|
|
9
|
+
* plugins: [
|
|
10
|
+
* dlqPlugin({ deadLetterSink: pgDeadLetterSink }),
|
|
11
|
+
* actionsPlugin(handlers),
|
|
12
|
+
* ],
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Binds `forge.deadLetterSink` (overridable). `actionsPlugin` reads from
|
|
16
|
+
* this binding when it constructs its `ActionRunner`. Install dlqPlugin
|
|
17
|
+
* BEFORE actionsPlugin so the binding exists at the time actionsPlugin's
|
|
18
|
+
* setup runs.
|
|
19
|
+
*
|
|
20
|
+
* If neither dlqPlugin nor a binding override is present, actionsPlugin
|
|
21
|
+
* falls back to an `InMemoryDeadLetterSink`.
|
|
22
|
+
*/
|
|
23
|
+
import type { PluginDefinition } from "@nwire/app";
|
|
24
|
+
import { type DeadLetterSink } from "@nwire/dead-letter";
|
|
25
|
+
export interface DlqPluginOptions {
|
|
26
|
+
/** Override the dead-letter sink. Defaults to `InMemoryDeadLetterSink`. */
|
|
27
|
+
readonly deadLetterSink?: DeadLetterSink;
|
|
28
|
+
}
|
|
29
|
+
export declare function dlqPlugin(opts?: DlqPluginOptions): PluginDefinition;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dlqPlugin` — owns the dead-letter sink binding.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import { dlqPlugin, actionsPlugin } from "@nwire/forge";
|
|
6
|
+
*
|
|
7
|
+
* const app = createApp({
|
|
8
|
+
* appName: "orders",
|
|
9
|
+
* plugins: [
|
|
10
|
+
* dlqPlugin({ deadLetterSink: pgDeadLetterSink }),
|
|
11
|
+
* actionsPlugin(handlers),
|
|
12
|
+
* ],
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Binds `forge.deadLetterSink` (overridable). `actionsPlugin` reads from
|
|
16
|
+
* this binding when it constructs its `ActionRunner`. Install dlqPlugin
|
|
17
|
+
* BEFORE actionsPlugin so the binding exists at the time actionsPlugin's
|
|
18
|
+
* setup runs.
|
|
19
|
+
*
|
|
20
|
+
* If neither dlqPlugin nor a binding override is present, actionsPlugin
|
|
21
|
+
* falls back to an `InMemoryDeadLetterSink`.
|
|
22
|
+
*/
|
|
23
|
+
import { InMemoryDeadLetterSink } from "@nwire/dead-letter";
|
|
24
|
+
import { FORGE_DEAD_LETTER_SINK_BINDING } from "./actions-plugin.js";
|
|
25
|
+
export function dlqPlugin(opts = {}) {
|
|
26
|
+
return {
|
|
27
|
+
name: "forge.dlq",
|
|
28
|
+
register({ bind }) {
|
|
29
|
+
const sink = opts.deadLetterSink ?? new InMemoryDeadLetterSink();
|
|
30
|
+
bind(FORGE_DEAD_LETTER_SINK_BINDING, () => sink);
|
|
31
|
+
},
|
|
32
|
+
setup() {
|
|
33
|
+
// No setup work — the binding is the contract. ActionsPlugin reads
|
|
34
|
+
// it during its own setup.
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `idempotencyPlugin` — owns the per-event dedup gate at the head of
|
|
3
|
+
* the EventPublishing chain.
|
|
4
|
+
*
|
|
5
|
+
* import { createApp } from "@nwire/app";
|
|
6
|
+
* import { idempotencyPlugin, forgePlugin } from "@nwire/forge";
|
|
7
|
+
*
|
|
8
|
+
* const app = createApp({
|
|
9
|
+
* appName: "orders",
|
|
10
|
+
* plugins: [forgePlugin, idempotencyPlugin()],
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* Binds `forge.idempotencyStore` and attaches the
|
|
14
|
+
* `forge.publish.idempotency` step at priority 1000. The step checks
|
|
15
|
+
* whether the event's `dedupKey` has been seen and short-circuits when
|
|
16
|
+
* it has, marking `payload.deduped = true`.
|
|
17
|
+
*
|
|
18
|
+
* If `forgePlugin`'s bundled mode is also installed, a duplicate step
|
|
19
|
+
* runs; warn at AppReady.
|
|
20
|
+
*/
|
|
21
|
+
import type { PluginDefinition } from "@nwire/app";
|
|
22
|
+
import { type IdempotencyStore } from "../stores/idempotency-store.js";
|
|
23
|
+
export declare const FORGE_IDEMPOTENCY_STORE_BINDING: "forge.idempotencyStore";
|
|
24
|
+
export interface IdempotencyPluginOptions {
|
|
25
|
+
/** Override the idempotency store. Defaults to `InMemoryIdempotencyStore`. */
|
|
26
|
+
readonly idempotencyStore?: IdempotencyStore;
|
|
27
|
+
}
|
|
28
|
+
export declare function idempotencyPlugin(opts?: IdempotencyPluginOptions): PluginDefinition;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `idempotencyPlugin` — owns the per-event dedup gate at the head of
|
|
3
|
+
* the EventPublishing chain.
|
|
4
|
+
*
|
|
5
|
+
* import { createApp } from "@nwire/app";
|
|
6
|
+
* import { idempotencyPlugin, forgePlugin } from "@nwire/forge";
|
|
7
|
+
*
|
|
8
|
+
* const app = createApp({
|
|
9
|
+
* appName: "orders",
|
|
10
|
+
* plugins: [forgePlugin, idempotencyPlugin()],
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* Binds `forge.idempotencyStore` and attaches the
|
|
14
|
+
* `forge.publish.idempotency` step at priority 1000. The step checks
|
|
15
|
+
* whether the event's `dedupKey` has been seen and short-circuits when
|
|
16
|
+
* it has, marking `payload.deduped = true`.
|
|
17
|
+
*
|
|
18
|
+
* If `forgePlugin`'s bundled mode is also installed, a duplicate step
|
|
19
|
+
* runs; warn at AppReady.
|
|
20
|
+
*/
|
|
21
|
+
import { InMemoryIdempotencyStore } from "../stores/idempotency-store.js";
|
|
22
|
+
import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
|
|
23
|
+
export const FORGE_IDEMPOTENCY_STORE_BINDING = "forge.idempotencyStore";
|
|
24
|
+
export function idempotencyPlugin(opts = {}) {
|
|
25
|
+
return {
|
|
26
|
+
name: "forge.idempotency",
|
|
27
|
+
register({ bind }) {
|
|
28
|
+
const store = opts.idempotencyStore ?? new InMemoryIdempotencyStore();
|
|
29
|
+
bind(FORGE_IDEMPOTENCY_STORE_BINDING, () => store);
|
|
30
|
+
},
|
|
31
|
+
setup({ runtime, container, on }) {
|
|
32
|
+
const store = container.resolve(FORGE_IDEMPOTENCY_STORE_BINDING);
|
|
33
|
+
runtime.hooks.EventPublishing.use(async (payload, next) => {
|
|
34
|
+
// Atomic check-and-record so concurrent publishes with the
|
|
35
|
+
// same dedupKey see exactly one winner.
|
|
36
|
+
const isNew = await store.recordIfNew(payload.dedupKey);
|
|
37
|
+
if (!isNew) {
|
|
38
|
+
payload.deduped = true;
|
|
39
|
+
return; // veto — chain stops here
|
|
40
|
+
}
|
|
41
|
+
await next();
|
|
42
|
+
}, {
|
|
43
|
+
name: "forge.publish.idempotency",
|
|
44
|
+
priority: EVENT_PUBLISHING_PRIORITIES.idempotency,
|
|
45
|
+
});
|
|
46
|
+
on("AppReady", () => {
|
|
47
|
+
const hookChain = runtime.hooks.EventPublishing;
|
|
48
|
+
const steps = (hookChain.chain ?? []).filter((s) => s.name === "forge.publish.idempotency");
|
|
49
|
+
if (steps.length > 1) {
|
|
50
|
+
// eslint-disable-next-line no-console
|
|
51
|
+
console.warn(`idempotencyPlugin: detected ${steps.length} "forge.publish.idempotency" steps on the EventPublishing chain. ` +
|
|
52
|
+
`Install either idempotencyPlugin OR forgePlugin's bundled idempotency path, not both.`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection chain runner — the EventPublishing step that folds an
|
|
3
|
+
* event into every registered projection that listens for it.
|
|
4
|
+
*
|
|
5
|
+
* Self-contained: takes its dependencies as constructor arguments. Both
|
|
6
|
+
* `projectionsPlugin` and the bundled `ForgeDispatcher` instantiate one
|
|
7
|
+
* of these and call `apply(event, envelope)` from their respective hook
|
|
8
|
+
* attachment.
|
|
9
|
+
*
|
|
10
|
+
* Per-event behaviour:
|
|
11
|
+
* 1. Look up projections that listen for `event.eventName`.
|
|
12
|
+
* 2. For each, load the per-tenant state, run the reducer, save.
|
|
13
|
+
* 3. Push `projection.folded` telemetry on success or `projection.failed`
|
|
14
|
+
* on a reducer throw (then re-raise so the EventPublishing chain
|
|
15
|
+
* surfaces the failure).
|
|
16
|
+
*/
|
|
17
|
+
import { type MessageEnvelope } from "@nwire/envelope";
|
|
18
|
+
import { type Runtime } from "@nwire/app";
|
|
19
|
+
import type { ProjectionDefinition } from "../primitives/define-projection.js";
|
|
20
|
+
import type { EventMessage } from "../messages/event-message.js";
|
|
21
|
+
import { type ProjectionStore } from "../stores/projection-store.js";
|
|
22
|
+
export declare class ProjectionChainRunner {
|
|
23
|
+
private readonly runtime;
|
|
24
|
+
readonly store: ProjectionStore;
|
|
25
|
+
readonly projections: Map<string, ProjectionDefinition<unknown>>;
|
|
26
|
+
readonly projectionsByEvent: Map<string, ProjectionDefinition<unknown>[]>;
|
|
27
|
+
constructor(runtime: Runtime, store: ProjectionStore);
|
|
28
|
+
/** Register a projection. Throws on duplicate names. */
|
|
29
|
+
register(projection: ProjectionDefinition<unknown>): void;
|
|
30
|
+
/** All registered projection names, in registration order. */
|
|
31
|
+
listProjections(): readonly string[];
|
|
32
|
+
/** Apply one event to every projection that listens for it. */
|
|
33
|
+
apply(event: EventMessage, envelope: MessageEnvelope): Promise<void>;
|
|
34
|
+
}
|