@nwire/forge 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -83
- package/dist/framework-events.d.ts +8 -37
- package/dist/framework-events.js +7 -3
- package/dist/helpers/cli-runner.js +21 -10
- package/dist/index.d.ts +8 -7
- package/dist/index.js +7 -6
- package/dist/plugins/actions-chain.d.ts +39 -22
- package/dist/plugins/actions-chain.js +117 -78
- package/dist/plugins/actions-plugin.d.ts +26 -23
- package/dist/plugins/actions-plugin.js +122 -44
- package/dist/plugins/actors-chain.d.ts +9 -2
- package/dist/plugins/actors-chain.js +62 -2
- package/dist/plugins/actors-plugin.d.ts +1 -1
- package/dist/plugins/actors-plugin.js +24 -14
- package/dist/plugins/external-calls-plugin.d.ts +28 -0
- package/dist/plugins/external-calls-plugin.js +136 -0
- package/dist/plugins/idempotency-plugin.d.ts +15 -1
- package/dist/plugins/idempotency-plugin.js +56 -11
- package/dist/plugins/projections-chain.d.ts +2 -2
- package/dist/plugins/projections-chain.js +2 -2
- package/dist/plugins/projections-plugin.d.ts +1 -1
- package/dist/plugins/projections-plugin.js +4 -13
- package/dist/plugins/queries-chain.d.ts +4 -3
- package/dist/plugins/queries-chain.js +8 -5
- package/dist/plugins/queries-plugin.d.ts +15 -29
- package/dist/plugins/queries-plugin.js +36 -49
- package/dist/plugins/workflows-chain.d.ts +9 -2
- package/dist/plugins/workflows-chain.js +19 -1
- package/dist/plugins/workflows-plugin.d.ts +1 -1
- package/dist/plugins/workflows-plugin.js +12 -20
- package/dist/primitives/define-action.d.ts +80 -115
- package/dist/primitives/define-action.js +111 -56
- package/dist/primitives/define-actor.d.ts +103 -214
- package/dist/primitives/define-actor.js +157 -216
- package/dist/primitives/define-handler.d.ts +42 -112
- package/dist/primitives/define-handler.js +14 -45
- package/dist/primitives/define-projection.d.ts +23 -28
- package/dist/primitives/define-projection.js +29 -32
- package/dist/primitives/define-query.d.ts +52 -42
- package/dist/primitives/define-query.js +65 -28
- package/dist/primitives/define-workflow.d.ts +8 -11
- package/dist/primitives/define-workflow.js +14 -8
- package/dist/runtime/forge-dispatcher.d.ts +30 -12
- package/dist/runtime/forge-dispatcher.js +199 -237
- package/dist/runtime/forge-plugin.d.ts +8 -0
- package/dist/runtime/forge-plugin.js +113 -31
- package/dist/runtime/forge-plugins.d.ts +55 -0
- package/dist/runtime/forge-plugins.js +57 -0
- package/dist/runtime/forge-types.d.ts +8 -2
- package/dist/runtime/with-forge.d.ts +8 -11
- package/dist/runtime/with-forge.js +9 -11
- package/dist/stores/idempotency-store.d.ts +1 -1
- package/package.json +12 -12
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Actor chain runner — the
|
|
2
|
+
* Actor chain runner — the LocalDelivery step that applies an event to
|
|
3
3
|
* every registered actor type whose reactions match.
|
|
4
4
|
*
|
|
5
5
|
* Self-contained: takes its dependencies as constructor arguments. Both
|
|
@@ -52,7 +52,7 @@ export class ActorChainRunner {
|
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
54
|
* Apply one event to every actor type whose reactions match. Called
|
|
55
|
-
* by the
|
|
55
|
+
* by the LocalDelivery step at priority 800.
|
|
56
56
|
*/
|
|
57
57
|
async apply(event, envelope) {
|
|
58
58
|
const tenant = envelope.tenant ?? "";
|
|
@@ -201,4 +201,64 @@ export class ActorChainRunner {
|
|
|
201
201
|
envelopeLogger(envelope) {
|
|
202
202
|
return loggerForEnvelope(this.runtime.logger, envelope);
|
|
203
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Load an actor instance as a method-bound view — backs `ctx.actor`. Methods
|
|
206
|
+
* are pure (state-first or closure-bound); events a method records are
|
|
207
|
+
* published via the injected `publish` (the shared event-publish path) so a
|
|
208
|
+
* view method's facts ride the same LocalDelivery fold + outbound drain.
|
|
209
|
+
*/
|
|
210
|
+
async view(actor, id, envelope, publish) {
|
|
211
|
+
if (!this.actors.has(actor.name)) {
|
|
212
|
+
throw new Error(`ctx.actor: actor "${actor.name}" is not registered. Add it to actorsPlugin([...]).`);
|
|
213
|
+
}
|
|
214
|
+
const loaded = await this.store.load(actor.name, id, envelope.tenant ?? "");
|
|
215
|
+
const instance = loaded ?? {
|
|
216
|
+
name: actor.name,
|
|
217
|
+
key: id,
|
|
218
|
+
state: actor.initial,
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
220
|
+
data: {},
|
|
221
|
+
version: 0,
|
|
222
|
+
timers: [],
|
|
223
|
+
};
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
225
|
+
const view = {
|
|
226
|
+
state: instance.data,
|
|
227
|
+
key: instance.key,
|
|
228
|
+
stateName: instance.state,
|
|
229
|
+
};
|
|
230
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
231
|
+
const closureBinder = actor.closureBinder;
|
|
232
|
+
if (closureBinder) {
|
|
233
|
+
const bound = closureBinder(instance.data, instance.key);
|
|
234
|
+
for (const methodName of Object.keys(bound.methods)) {
|
|
235
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
236
|
+
view[methodName] = async (...args) => {
|
|
237
|
+
const fresh = await this.store.load(actor.name, id, envelope.tenant ?? "");
|
|
238
|
+
const liveData = fresh?.data ?? instance.data;
|
|
239
|
+
const localBound = closureBinder(liveData, instance.key);
|
|
240
|
+
const localFn = localBound.methods[methodName];
|
|
241
|
+
if (!localFn)
|
|
242
|
+
throw new Error(`Actor "${actor.name}" has no method "${methodName}".`);
|
|
243
|
+
const result = localFn(...args);
|
|
244
|
+
if (localBound.recorded.length > 0) {
|
|
245
|
+
const messages = localBound.recorded.map((r) => ({
|
|
246
|
+
eventName: r.eventName,
|
|
247
|
+
payload: r.payload,
|
|
248
|
+
}));
|
|
249
|
+
await publish(messages, envelope);
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return view;
|
|
255
|
+
}
|
|
256
|
+
const methods = actor.methods ?? {};
|
|
257
|
+
for (const [methodName, fn] of Object.entries(methods)) {
|
|
258
|
+
const method = fn;
|
|
259
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
260
|
+
view[methodName] = (...args) => method(instance.data, ...args);
|
|
261
|
+
}
|
|
262
|
+
return view;
|
|
263
|
+
}
|
|
204
264
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Owns:
|
|
16
16
|
* - the `forge.actorStore` container binding
|
|
17
17
|
* - the actor types registry (private to the plugin's `ActorChainRunner`)
|
|
18
|
-
* - the `forge.publish.actors` step on the
|
|
18
|
+
* - the `forge.publish.actors` step on the LocalDelivery chain
|
|
19
19
|
* (priority 800 — between idempotency and projections)
|
|
20
20
|
* - the `forge.actorChain` container binding so other plugins can
|
|
21
21
|
* resolve the runner (workflow timer scheduler, ctx.use binding, …)
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Owns:
|
|
16
16
|
* - the `forge.actorStore` container binding
|
|
17
17
|
* - the actor types registry (private to the plugin's `ActorChainRunner`)
|
|
18
|
-
* - the `forge.publish.actors` step on the
|
|
18
|
+
* - the `forge.publish.actors` step on the LocalDelivery chain
|
|
19
19
|
* (priority 800 — between idempotency and projections)
|
|
20
20
|
* - the `forge.actorChain` container binding so other plugins can
|
|
21
21
|
* resolve the runner (workflow timer scheduler, ctx.use binding, …)
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { InMemoryActorStore } from "../stores/actor-store.js";
|
|
28
28
|
import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
|
|
29
29
|
import { ActorChainRunner } from "./actors-chain.js";
|
|
30
|
+
import { FORGE_PUBLISH_BINDING } from "./actions-plugin.js";
|
|
30
31
|
export const FORGE_ACTOR_CHAIN_BINDING = "forge.actorChain";
|
|
31
32
|
export const FORGE_ACTOR_STORE_BINDING = "forge.actorStore";
|
|
32
33
|
export function actorsPlugin(actorTypes, opts = {}) {
|
|
@@ -36,27 +37,36 @@ export function actorsPlugin(actorTypes, opts = {}) {
|
|
|
36
37
|
const store = opts.actorStore ?? new InMemoryActorStore();
|
|
37
38
|
bind(FORGE_ACTOR_STORE_BINDING, () => store);
|
|
38
39
|
},
|
|
39
|
-
setup({ runtime, container
|
|
40
|
+
setup({ runtime, container }) {
|
|
40
41
|
const store = container.resolve(FORGE_ACTOR_STORE_BINDING);
|
|
41
42
|
const chain = new ActorChainRunner(runtime, store);
|
|
42
43
|
for (const actor of actorTypes)
|
|
43
44
|
chain.register(actor);
|
|
44
45
|
container.register(FORGE_ACTOR_CHAIN_BINDING, chain);
|
|
45
|
-
runtime.hooks.
|
|
46
|
-
await chain.apply(payload.
|
|
46
|
+
runtime.hooks.LocalDelivery.use(async (payload, next) => {
|
|
47
|
+
await chain.apply({ eventName: payload.eventName, payload: payload.payload }, payload.envelope);
|
|
47
48
|
await next();
|
|
48
49
|
}, { name: "forge.publish.actors", priority: EVENT_PUBLISHING_PRIORITIES.actors });
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
`Install either actorsPlugin OR forgePlugin's options.actors path, not both.`);
|
|
58
|
-
}
|
|
50
|
+
// Contribute the `ctx.actor` verb — a method-bound actor view. Events a
|
|
51
|
+
// view method records publish through the shared `forge.publishEvents`
|
|
52
|
+
// path (bound by actionsPlugin); falls back to local fold if absent.
|
|
53
|
+
runtime.add({
|
|
54
|
+
name: "forge.actor-ctx",
|
|
55
|
+
provideCtx: ({ envelope, container: c }) => ({
|
|
56
|
+
actor: (actor, id) => chain.view(actor, id, envelope, resolvePublish(c, runtime)),
|
|
57
|
+
}),
|
|
59
58
|
});
|
|
60
59
|
},
|
|
61
60
|
};
|
|
62
61
|
}
|
|
62
|
+
/** Resolve the shared event-publish fn, or fall back to local LocalDelivery. */
|
|
63
|
+
function resolvePublish(container, runtime) {
|
|
64
|
+
if (container.has(FORGE_PUBLISH_BINDING)) {
|
|
65
|
+
return container.resolve(FORGE_PUBLISH_BINDING);
|
|
66
|
+
}
|
|
67
|
+
return async (events, envelope) => {
|
|
68
|
+
for (const e of events) {
|
|
69
|
+
await runtime.deliver(e.eventName, e.payload, envelope, envelope.messageId);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `externalCallsPlugin` — the forge external-call concern as a plugin.
|
|
3
|
+
*
|
|
4
|
+
* Owns the external-call registry + executor table and contributes the
|
|
5
|
+
* `ctx.externalCall` verb. Wires/adapters register the real transport via
|
|
6
|
+
* the `forge.externalCalls` runner (`registerExecutor`). The runner applies
|
|
7
|
+
* the declared idempotency key + retry policy and emits
|
|
8
|
+
* `external.call.started / .completed / .failed` telemetry.
|
|
9
|
+
*/
|
|
10
|
+
import type { PluginDefinition } from "@nwire/app";
|
|
11
|
+
import { type Runtime } from "@nwire/app";
|
|
12
|
+
import type { MessageEnvelope } from "@nwire/envelope";
|
|
13
|
+
import type { ZodTypeAny } from "@nwire/messages";
|
|
14
|
+
import type { z } from "zod";
|
|
15
|
+
import type { ExternalCallDefinition, ExternalCallExecutor } from "../primitives/define-external-call.js";
|
|
16
|
+
export declare const FORGE_EXTERNAL_CALLS_BINDING: "forge.externalCalls";
|
|
17
|
+
export declare class ExternalCallRunner {
|
|
18
|
+
private readonly runtime;
|
|
19
|
+
readonly calls: Map<string, ExternalCallDefinition<any, any>>;
|
|
20
|
+
readonly executors: Map<string, ExternalCallExecutor<any, any>>;
|
|
21
|
+
constructor(runtime: Runtime);
|
|
22
|
+
register(def: ExternalCallDefinition<any, any>): void;
|
|
23
|
+
registerExecutor<TReq extends ZodTypeAny, TRes extends ZodTypeAny>(def: ExternalCallDefinition<TReq, TRes>, executor: ExternalCallExecutor<TReq, TRes>): void;
|
|
24
|
+
listExternalCalls(): readonly string[];
|
|
25
|
+
getExternalCall(name: string): ExternalCallDefinition<any, any> | undefined;
|
|
26
|
+
execute<TReq extends ZodTypeAny, TRes extends ZodTypeAny>(def: ExternalCallDefinition<TReq, TRes>, request: z.output<TReq>, envelope?: MessageEnvelope): Promise<z.output<TRes>>;
|
|
27
|
+
}
|
|
28
|
+
export declare function externalCallsPlugin(calls?: readonly ExternalCallDefinition<any, any>[]): PluginDefinition;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `externalCallsPlugin` — the forge external-call concern as a plugin.
|
|
3
|
+
*
|
|
4
|
+
* Owns the external-call registry + executor table and contributes the
|
|
5
|
+
* `ctx.externalCall` verb. Wires/adapters register the real transport via
|
|
6
|
+
* the `forge.externalCalls` runner (`registerExecutor`). The runner applies
|
|
7
|
+
* the declared idempotency key + retry policy and emits
|
|
8
|
+
* `external.call.started / .completed / .failed` telemetry.
|
|
9
|
+
*/
|
|
10
|
+
import { serializeError } from "@nwire/app";
|
|
11
|
+
import { computeBackoff, sleep } from "../helpers/retry-helpers.js";
|
|
12
|
+
export const FORGE_EXTERNAL_CALLS_BINDING = "forge.externalCalls";
|
|
13
|
+
export class ExternalCallRunner {
|
|
14
|
+
runtime;
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
calls = new Map();
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
executors = new Map();
|
|
19
|
+
constructor(runtime) {
|
|
20
|
+
this.runtime = runtime;
|
|
21
|
+
}
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
register(def) {
|
|
24
|
+
if (this.calls.has(def.name)) {
|
|
25
|
+
throw new Error(`externalCallsPlugin: external call "${def.name}" already registered.`);
|
|
26
|
+
}
|
|
27
|
+
this.calls.set(def.name, def);
|
|
28
|
+
}
|
|
29
|
+
registerExecutor(def, executor) {
|
|
30
|
+
this.executors.set(def.name, executor);
|
|
31
|
+
}
|
|
32
|
+
listExternalCalls() {
|
|
33
|
+
return [...this.calls.keys()];
|
|
34
|
+
}
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
getExternalCall(name) {
|
|
37
|
+
return this.calls.get(name);
|
|
38
|
+
}
|
|
39
|
+
async execute(def, request, envelope) {
|
|
40
|
+
const validated = def.request.parse(request);
|
|
41
|
+
const executor = this.executors.get(def.name);
|
|
42
|
+
const idempotencyKey = def.idempotencyKey?.(validated);
|
|
43
|
+
const target = `${def.target.provider}/${def.target.endpoint}`;
|
|
44
|
+
const appName = this.runtime.appName;
|
|
45
|
+
if (!executor) {
|
|
46
|
+
const err = new Error(`ctx.externalCall: no executor registered for "${def.name}". ` +
|
|
47
|
+
`Wires/adapters must call the forge.externalCalls runner's registerExecutor() at boot.`);
|
|
48
|
+
this.runtime.pushTelemetry({
|
|
49
|
+
kind: "external.call.failed",
|
|
50
|
+
call: def.name,
|
|
51
|
+
target,
|
|
52
|
+
attempt: 1,
|
|
53
|
+
willRetry: false,
|
|
54
|
+
error: serializeError(err),
|
|
55
|
+
envelope,
|
|
56
|
+
appName,
|
|
57
|
+
ts: new Date().toISOString(),
|
|
58
|
+
});
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
const retry = def.retry;
|
|
62
|
+
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
63
|
+
let attempt = 0;
|
|
64
|
+
let lastError;
|
|
65
|
+
while (attempt < maxAttempts) {
|
|
66
|
+
attempt++;
|
|
67
|
+
this.runtime.pushTelemetry({
|
|
68
|
+
kind: "external.call.started",
|
|
69
|
+
call: def.name,
|
|
70
|
+
target,
|
|
71
|
+
idempotencyKey,
|
|
72
|
+
envelope,
|
|
73
|
+
appName,
|
|
74
|
+
ts: new Date().toISOString(),
|
|
75
|
+
});
|
|
76
|
+
const t0 = performance.now();
|
|
77
|
+
try {
|
|
78
|
+
if (attempt > 1) {
|
|
79
|
+
const delay = computeBackoff(retry, attempt - 1);
|
|
80
|
+
if (delay > 0)
|
|
81
|
+
await sleep(delay);
|
|
82
|
+
}
|
|
83
|
+
const raw = await executor(validated, { idempotencyKey, attempt });
|
|
84
|
+
const response = def.response ? def.response.parse(raw) : raw;
|
|
85
|
+
this.runtime.pushTelemetry({
|
|
86
|
+
kind: "external.call.completed",
|
|
87
|
+
call: def.name,
|
|
88
|
+
target,
|
|
89
|
+
durationMs: performance.now() - t0,
|
|
90
|
+
idempotencyKey,
|
|
91
|
+
envelope,
|
|
92
|
+
appName,
|
|
93
|
+
ts: new Date().toISOString(),
|
|
94
|
+
});
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
lastError = err;
|
|
99
|
+
this.runtime.pushTelemetry({
|
|
100
|
+
kind: "external.call.failed",
|
|
101
|
+
call: def.name,
|
|
102
|
+
target,
|
|
103
|
+
attempt,
|
|
104
|
+
willRetry: attempt < maxAttempts,
|
|
105
|
+
error: serializeError(err),
|
|
106
|
+
envelope,
|
|
107
|
+
appName,
|
|
108
|
+
ts: new Date().toISOString(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
throw lastError;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function externalCallsPlugin(
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
+
calls = []) {
|
|
118
|
+
return {
|
|
119
|
+
name: "forge.external-calls",
|
|
120
|
+
setup({ runtime, container, bind }) {
|
|
121
|
+
const runner = new ExternalCallRunner(runtime);
|
|
122
|
+
for (const def of calls)
|
|
123
|
+
runner.register(def);
|
|
124
|
+
bind(FORGE_EXTERNAL_CALLS_BINDING, () => runner);
|
|
125
|
+
runtime.add({
|
|
126
|
+
name: "forge.external-call-ctx",
|
|
127
|
+
provideCtx: ({ envelope, container: c }) => ({
|
|
128
|
+
externalCall: (def, request) => c
|
|
129
|
+
.resolve(FORGE_EXTERNAL_CALLS_BINDING)
|
|
130
|
+
.execute(def, request, envelope),
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
void container;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `idempotencyPlugin` — owns the per-event dedup gate at the head of
|
|
3
|
-
* the
|
|
3
|
+
* the LocalDelivery chain.
|
|
4
4
|
*
|
|
5
5
|
* import { createApp } from "@nwire/app";
|
|
6
6
|
* import { idempotencyPlugin, forgePlugin } from "@nwire/forge";
|
|
@@ -19,10 +19,24 @@
|
|
|
19
19
|
* runs; warn at AppReady.
|
|
20
20
|
*/
|
|
21
21
|
import type { PluginDefinition } from "@nwire/app";
|
|
22
|
+
import type { EventBus } from "@nwire/bus";
|
|
22
23
|
import { type IdempotencyStore } from "../stores/idempotency-store.js";
|
|
23
24
|
export declare const FORGE_IDEMPOTENCY_STORE_BINDING: "forge.idempotencyStore";
|
|
24
25
|
export interface IdempotencyPluginOptions {
|
|
25
26
|
/** Override the idempotency store. Defaults to `InMemoryIdempotencyStore`. */
|
|
26
27
|
readonly idempotencyStore?: IdempotencyStore;
|
|
28
|
+
/**
|
|
29
|
+
* Event names accepted from the bus/source chain. Each is folded through the
|
|
30
|
+
* one LocalDelivery path (deduped by the inbound messageId) when it arrives
|
|
31
|
+
* via `runtime.receive({ kind: "event" })`.
|
|
32
|
+
*/
|
|
33
|
+
readonly externalEvents?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Cross-service bus. When set, each declared external event is subscribed at
|
|
36
|
+
* boot and folded through `runtime.receive` — the inbound counterpart to
|
|
37
|
+
* `actionsPlugin`'s `publishToBus`. An app's own published echoes are skipped
|
|
38
|
+
* by origin.
|
|
39
|
+
*/
|
|
40
|
+
readonly bus?: EventBus;
|
|
27
41
|
}
|
|
28
42
|
export declare function idempotencyPlugin(opts?: IdempotencyPluginOptions): PluginDefinition;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `idempotencyPlugin` — owns the per-event dedup gate at the head of
|
|
3
|
-
* the
|
|
3
|
+
* the LocalDelivery chain.
|
|
4
4
|
*
|
|
5
5
|
* import { createApp } from "@nwire/app";
|
|
6
6
|
* import { idempotencyPlugin, forgePlugin } from "@nwire/forge";
|
|
@@ -18,19 +18,38 @@
|
|
|
18
18
|
* If `forgePlugin`'s bundled mode is also installed, a duplicate step
|
|
19
19
|
* runs; warn at AppReady.
|
|
20
20
|
*/
|
|
21
|
+
import { seedEnvelope } from "@nwire/envelope";
|
|
21
22
|
import { InMemoryIdempotencyStore } from "../stores/idempotency-store.js";
|
|
22
23
|
import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
|
|
23
24
|
export const FORGE_IDEMPOTENCY_STORE_BINDING = "forge.idempotencyStore";
|
|
24
25
|
export function idempotencyPlugin(opts = {}) {
|
|
26
|
+
const externalEventNames = new Set(opts.externalEvents ?? []);
|
|
25
27
|
return {
|
|
26
28
|
name: "forge.idempotency",
|
|
27
29
|
register({ bind }) {
|
|
28
30
|
const store = opts.idempotencyStore ?? new InMemoryIdempotencyStore();
|
|
29
31
|
bind(FORGE_IDEMPOTENCY_STORE_BINDING, () => store);
|
|
30
32
|
},
|
|
31
|
-
setup({ runtime, container,
|
|
33
|
+
setup({ runtime, container, boot }) {
|
|
32
34
|
const store = container.resolve(FORGE_IDEMPOTENCY_STORE_BINDING);
|
|
33
|
-
|
|
35
|
+
// Bus inbound — subscribe each declared external event and fold it
|
|
36
|
+
// through `runtime.receive`; the external-delivery source stage below
|
|
37
|
+
// lands it on the one delivery path. Skips this app's own echoes by
|
|
38
|
+
// origin. The symmetric counterpart to outbound `publishToBus`.
|
|
39
|
+
// Deferred to boot so the wiring lands once the app is starting.
|
|
40
|
+
if (opts.bus) {
|
|
41
|
+
const bus = opts.bus;
|
|
42
|
+
boot(() => {
|
|
43
|
+
for (const eventName of externalEventNames) {
|
|
44
|
+
bus.subscribe(eventName, async (msg) => {
|
|
45
|
+
if (msg.origin === runtime.appName)
|
|
46
|
+
return;
|
|
47
|
+
await runtime.receive({ kind: "event", name: msg.eventName, input: msg.payload }, { envelope: msg.envelope });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
runtime.hooks.LocalDelivery.use(async (payload, next) => {
|
|
34
53
|
// Atomic check-and-record so concurrent publishes with the
|
|
35
54
|
// same dedupKey see exactly one winner.
|
|
36
55
|
const isNew = await store.recordIfNew(payload.dedupKey);
|
|
@@ -43,14 +62,40 @@ export function idempotencyPlugin(opts = {}) {
|
|
|
43
62
|
name: "forge.publish.idempotency",
|
|
44
63
|
priority: EVENT_PUBLISHING_PRIORITIES.idempotency,
|
|
45
64
|
});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
// External / bus-inbound events ride the ONE delivery path. This source
|
|
66
|
+
// stage folds a declared external event through `runtime.deliver` (same
|
|
67
|
+
// LocalDelivery fold local emit uses), deduped by the inbound messageId,
|
|
68
|
+
// then stops so the terminal router doesn't re-handle it.
|
|
69
|
+
runtime.source({
|
|
70
|
+
name: "forge.external-delivery",
|
|
71
|
+
position: "terminal",
|
|
72
|
+
run: async (ctx) => {
|
|
73
|
+
const msg = ctx.message;
|
|
74
|
+
if (!msg || msg.kind !== "event")
|
|
75
|
+
return;
|
|
76
|
+
if (!externalEventNames.has(msg.name))
|
|
77
|
+
return;
|
|
78
|
+
const seed = ctx.envelope;
|
|
79
|
+
const envelope = seed?.messageId
|
|
80
|
+
? seed
|
|
81
|
+
: seedEnvelope({
|
|
82
|
+
tenant: ctx.envelope.tenant,
|
|
83
|
+
userId: ctx.envelope.userId,
|
|
84
|
+
user: ctx.envelope.user,
|
|
85
|
+
correlationId: ctx.envelope.correlationId,
|
|
86
|
+
causationId: ctx.envelope.causationId,
|
|
87
|
+
});
|
|
88
|
+
const { deduped } = await runtime.deliver(msg.name, msg.input, envelope, envelope.messageId);
|
|
89
|
+
runtime.pushTelemetry({
|
|
90
|
+
kind: deduped ? "event.deduped" : "event.published",
|
|
91
|
+
event: { eventName: msg.name, payload: msg.input },
|
|
92
|
+
envelope,
|
|
93
|
+
source: "external",
|
|
94
|
+
appName: runtime.appName,
|
|
95
|
+
ts: new Date().toISOString(),
|
|
96
|
+
});
|
|
97
|
+
return { continue: false };
|
|
98
|
+
},
|
|
54
99
|
});
|
|
55
100
|
},
|
|
56
101
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Projection chain runner — the
|
|
2
|
+
* Projection chain runner — the LocalDelivery step that folds an
|
|
3
3
|
* event into every registered projection that listens for it.
|
|
4
4
|
*
|
|
5
5
|
* Self-contained: takes its dependencies as constructor arguments. Both
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 1. Look up projections that listen for `event.eventName`.
|
|
12
12
|
* 2. For each, load the per-tenant state, run the reducer, save.
|
|
13
13
|
* 3. Push `projection.folded` telemetry on success or `projection.failed`
|
|
14
|
-
* on a reducer throw (then re-raise so the
|
|
14
|
+
* on a reducer throw (then re-raise so the LocalDelivery chain
|
|
15
15
|
* surfaces the failure).
|
|
16
16
|
*/
|
|
17
17
|
import { type MessageEnvelope } from "@nwire/envelope";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Projection chain runner — the
|
|
2
|
+
* Projection chain runner — the LocalDelivery step that folds an
|
|
3
3
|
* event into every registered projection that listens for it.
|
|
4
4
|
*
|
|
5
5
|
* Self-contained: takes its dependencies as constructor arguments. Both
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 1. Look up projections that listen for `event.eventName`.
|
|
12
12
|
* 2. For each, load the per-tenant state, run the reducer, save.
|
|
13
13
|
* 3. Push `projection.folded` telemetry on success or `projection.failed`
|
|
14
|
-
* on a reducer throw (then re-raise so the
|
|
14
|
+
* on a reducer throw (then re-raise so the LocalDelivery chain
|
|
15
15
|
* surfaces the failure).
|
|
16
16
|
*/
|
|
17
17
|
import { serializeError } from "@nwire/app";
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Owns:
|
|
16
16
|
* - the `forge.projectionStore` container binding
|
|
17
17
|
* - the projection registry (private to the plugin's `ProjectionChainRunner`)
|
|
18
|
-
* - the `forge.publish.projections` step on the
|
|
18
|
+
* - the `forge.publish.projections` step on the LocalDelivery chain
|
|
19
19
|
* (priority 600 — between actors and workflows)
|
|
20
20
|
* - the `forge.projectionChain` container binding so queriesPlugin (and
|
|
21
21
|
* other consumers) can resolve the runner
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Owns:
|
|
16
16
|
* - the `forge.projectionStore` container binding
|
|
17
17
|
* - the projection registry (private to the plugin's `ProjectionChainRunner`)
|
|
18
|
-
* - the `forge.publish.projections` step on the
|
|
18
|
+
* - the `forge.publish.projections` step on the LocalDelivery chain
|
|
19
19
|
* (priority 600 — between actors and workflows)
|
|
20
20
|
* - the `forge.projectionChain` container binding so queriesPlugin (and
|
|
21
21
|
* other consumers) can resolve the runner
|
|
@@ -38,26 +38,17 @@ projections, opts = {}) {
|
|
|
38
38
|
const store = opts.projectionStore ?? new InMemoryProjectionStore();
|
|
39
39
|
bind(FORGE_PROJECTION_STORE_BINDING, () => store);
|
|
40
40
|
},
|
|
41
|
-
setup({ runtime, container
|
|
41
|
+
setup({ runtime, container }) {
|
|
42
42
|
const store = container.resolve(FORGE_PROJECTION_STORE_BINDING);
|
|
43
43
|
const chain = new ProjectionChainRunner(runtime, store);
|
|
44
44
|
for (const projection of projections) {
|
|
45
45
|
chain.register(projection);
|
|
46
46
|
}
|
|
47
47
|
container.register(FORGE_PROJECTION_CHAIN_BINDING, chain);
|
|
48
|
-
runtime.hooks.
|
|
49
|
-
await chain.apply(payload.
|
|
48
|
+
runtime.hooks.LocalDelivery.use(async (payload, next) => {
|
|
49
|
+
await chain.apply({ eventName: payload.eventName, payload: payload.payload }, payload.envelope);
|
|
50
50
|
await next();
|
|
51
51
|
}, { name: "forge.publish.projections", priority: EVENT_PUBLISHING_PRIORITIES.projections });
|
|
52
|
-
on("AppReady", () => {
|
|
53
|
-
const hookChain = runtime.hooks.EventPublishing;
|
|
54
|
-
const steps = (hookChain.chain ?? []).filter((s) => s.name === "forge.publish.projections");
|
|
55
|
-
if (steps.length > 1) {
|
|
56
|
-
// eslint-disable-next-line no-console
|
|
57
|
-
console.warn(`projectionsPlugin: detected ${steps.length} "forge.publish.projections" steps on the EventPublishing chain. ` +
|
|
58
|
-
`Install either projectionsPlugin OR forgePlugin's options.projections path, not both.`);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
52
|
},
|
|
62
53
|
};
|
|
63
54
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Query runner — the read-side equivalent of the
|
|
2
|
+
* Query runner — the read-side equivalent of the LocalDelivery chain.
|
|
3
3
|
*
|
|
4
4
|
* Queries don't participate in the publish chain; they execute on
|
|
5
5
|
* demand against the projection store (`projection.execute(state, input)`)
|
|
6
|
-
* or via a handler closure (`query.handler(
|
|
6
|
+
* or via a handler closure (`query.handler(ctx)`). The runner is
|
|
7
7
|
* a thin holder around the registry + execution logic that both the
|
|
8
8
|
* standalone `queriesPlugin` and the bundled `ForgeDispatcher` use.
|
|
9
9
|
*
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { type Runtime } from "@nwire/app";
|
|
20
20
|
import type { Container } from "@nwire/container";
|
|
21
|
+
import type { MessageEnvelope } from "@nwire/envelope";
|
|
21
22
|
import type { QueryDefinition } from "../primitives/define-query.js";
|
|
22
23
|
import type { ProjectionStore } from "../stores/projection-store.js";
|
|
23
24
|
export declare class QueryRunner {
|
|
@@ -29,5 +30,5 @@ export declare class QueryRunner {
|
|
|
29
30
|
register(query: QueryDefinition<any, any, any>): void;
|
|
30
31
|
/** All registered query names, in registration order. */
|
|
31
32
|
listQueries(): readonly string[];
|
|
32
|
-
run<TResult = unknown>(queryName: string, input: unknown, tenant?: string): Promise<TResult>;
|
|
33
|
+
run<TResult = unknown>(queryName: string, input: unknown, tenant?: string, envelope?: MessageEnvelope): Promise<TResult>;
|
|
33
34
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Query runner — the read-side equivalent of the
|
|
2
|
+
* Query runner — the read-side equivalent of the LocalDelivery chain.
|
|
3
3
|
*
|
|
4
4
|
* Queries don't participate in the publish chain; they execute on
|
|
5
5
|
* demand against the projection store (`projection.execute(state, input)`)
|
|
6
|
-
* or via a handler closure (`query.handler(
|
|
6
|
+
* or via a handler closure (`query.handler(ctx)`). The runner is
|
|
7
7
|
* a thin holder around the registry + execution logic that both the
|
|
8
8
|
* standalone `queriesPlugin` and the bundled `ForgeDispatcher` use.
|
|
9
9
|
*
|
|
@@ -39,13 +39,13 @@ export class QueryRunner {
|
|
|
39
39
|
listQueries() {
|
|
40
40
|
return [...this.queries.keys()];
|
|
41
41
|
}
|
|
42
|
-
async run(queryName, input, tenant = "") {
|
|
42
|
+
async run(queryName, input, tenant = "", envelope) {
|
|
43
43
|
const query = this.queries.get(queryName);
|
|
44
44
|
if (!query) {
|
|
45
45
|
throw new Error(`queriesPlugin: no query registered with name "${queryName}".`);
|
|
46
46
|
}
|
|
47
47
|
const t0 = performance.now();
|
|
48
|
-
const validated = isValidated(input) ? input : markValidated(query.
|
|
48
|
+
const validated = isValidated(input) ? input : markValidated(query.input.parse(input));
|
|
49
49
|
const appName = this.runtime.appName;
|
|
50
50
|
let result;
|
|
51
51
|
if (query.projection && query.execute) {
|
|
@@ -54,9 +54,12 @@ export class QueryRunner {
|
|
|
54
54
|
result = (await query.execute(state, validated));
|
|
55
55
|
}
|
|
56
56
|
else if (query.handler) {
|
|
57
|
-
result = (await query.handler(
|
|
57
|
+
result = (await query.handler({
|
|
58
|
+
input: validated,
|
|
58
59
|
resolve: (name) => this.container.resolve(name),
|
|
59
60
|
tenant,
|
|
61
|
+
envelope,
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
63
|
}));
|
|
61
64
|
}
|
|
62
65
|
else {
|