@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.
Files changed (41) hide show
  1. package/dist/framework-events.d.ts +45 -2
  2. package/dist/framework-events.js +11 -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 +24 -12
  6. package/dist/index.js +24 -12
  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 +27 -0
  34. package/dist/runtime/forge-dispatcher.js +100 -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
@@ -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
+ }