@nwire/forge 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/framework-events.d.ts +44 -2
- package/dist/framework-events.js +10 -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 +24 -0
- package/dist/runtime/forge-dispatcher.js +76 -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,86 @@
|
|
|
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 { serializeError } from "@nwire/app";
|
|
18
|
+
export class ProjectionChainRunner {
|
|
19
|
+
runtime;
|
|
20
|
+
store;
|
|
21
|
+
projections = new Map();
|
|
22
|
+
projectionsByEvent = new Map();
|
|
23
|
+
constructor(runtime, store) {
|
|
24
|
+
this.runtime = runtime;
|
|
25
|
+
this.store = store;
|
|
26
|
+
}
|
|
27
|
+
/** Register a projection. Throws on duplicate names. */
|
|
28
|
+
register(projection) {
|
|
29
|
+
if (this.projections.has(projection.name)) {
|
|
30
|
+
throw new Error(`projectionsPlugin: projection "${projection.name}" already registered.`);
|
|
31
|
+
}
|
|
32
|
+
this.projections.set(projection.name, projection);
|
|
33
|
+
for (const event of projection.listens) {
|
|
34
|
+
const list = this.projectionsByEvent.get(event.name) ?? [];
|
|
35
|
+
list.push(projection);
|
|
36
|
+
this.projectionsByEvent.set(event.name, list);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** All registered projection names, in registration order. */
|
|
40
|
+
listProjections() {
|
|
41
|
+
return [...this.projections.keys()];
|
|
42
|
+
}
|
|
43
|
+
/** Apply one event to every projection that listens for it. */
|
|
44
|
+
async apply(event, envelope) {
|
|
45
|
+
const projections = this.projectionsByEvent.get(event.eventName);
|
|
46
|
+
if (!projections || projections.length === 0)
|
|
47
|
+
return;
|
|
48
|
+
const tenant = envelope.tenant ?? "";
|
|
49
|
+
const appName = this.runtime.appName;
|
|
50
|
+
for (const projection of projections) {
|
|
51
|
+
const reducer = projection.on[event.eventName];
|
|
52
|
+
if (!reducer)
|
|
53
|
+
continue;
|
|
54
|
+
const t0 = performance.now();
|
|
55
|
+
try {
|
|
56
|
+
const current = (await this.store.load(projection.name, tenant)) ?? projection.initial();
|
|
57
|
+
const next = reducer(current, event.payload);
|
|
58
|
+
await this.store.save(projection.name, next, tenant);
|
|
59
|
+
this.runtime.pushTelemetry({
|
|
60
|
+
kind: "projection.folded",
|
|
61
|
+
projection: projection.name,
|
|
62
|
+
event: event.eventName,
|
|
63
|
+
tenant,
|
|
64
|
+
durationMs: performance.now() - t0,
|
|
65
|
+
envelope,
|
|
66
|
+
appName,
|
|
67
|
+
ts: new Date().toISOString(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
this.runtime.pushTelemetry({
|
|
72
|
+
kind: "projection.failed",
|
|
73
|
+
projection: projection.name,
|
|
74
|
+
event: event.eventName,
|
|
75
|
+
tenant,
|
|
76
|
+
durationMs: performance.now() - t0,
|
|
77
|
+
error: serializeError(err),
|
|
78
|
+
envelope,
|
|
79
|
+
appName,
|
|
80
|
+
ts: new Date().toISOString(),
|
|
81
|
+
});
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `projectionsPlugin` — standalone forge projection concern.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import { projectionsPlugin, forgePlugin } from "@nwire/forge";
|
|
6
|
+
*
|
|
7
|
+
* const app = createApp({
|
|
8
|
+
* appName: "orders",
|
|
9
|
+
* plugins: [
|
|
10
|
+
* forgePlugin,
|
|
11
|
+
* projectionsPlugin([OrdersDashboard, ItemsByStatus]),
|
|
12
|
+
* ],
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Owns:
|
|
16
|
+
* - the `forge.projectionStore` container binding
|
|
17
|
+
* - the projection registry (private to the plugin's `ProjectionChainRunner`)
|
|
18
|
+
* - the `forge.publish.projections` step on the EventPublishing chain
|
|
19
|
+
* (priority 600 — between actors and workflows)
|
|
20
|
+
* - the `forge.projectionChain` container binding so queriesPlugin (and
|
|
21
|
+
* other consumers) can resolve the runner
|
|
22
|
+
*
|
|
23
|
+
* If `forgePlugin`'s `options.projections` is also passed, both installs
|
|
24
|
+
* fold every event — install ONE path, not both. A diagnostic warning
|
|
25
|
+
* fires at `AppReady` when both attachments are present.
|
|
26
|
+
*/
|
|
27
|
+
import type { PluginDefinition } from "@nwire/app";
|
|
28
|
+
import { type ProjectionStore } from "../stores/projection-store.js";
|
|
29
|
+
import type { ProjectionDefinition } from "../primitives/define-projection.js";
|
|
30
|
+
export declare const FORGE_PROJECTION_CHAIN_BINDING: "forge.projectionChain";
|
|
31
|
+
export declare const FORGE_PROJECTION_STORE_BINDING: "forge.projectionStore";
|
|
32
|
+
export interface ProjectionsPluginOptions {
|
|
33
|
+
/** Override the projection store. Defaults to `InMemoryProjectionStore`. */
|
|
34
|
+
readonly projectionStore?: ProjectionStore;
|
|
35
|
+
}
|
|
36
|
+
export declare function projectionsPlugin(projections: readonly ProjectionDefinition<any>[], opts?: ProjectionsPluginOptions): PluginDefinition;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `projectionsPlugin` — standalone forge projection concern.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import { projectionsPlugin, forgePlugin } from "@nwire/forge";
|
|
6
|
+
*
|
|
7
|
+
* const app = createApp({
|
|
8
|
+
* appName: "orders",
|
|
9
|
+
* plugins: [
|
|
10
|
+
* forgePlugin,
|
|
11
|
+
* projectionsPlugin([OrdersDashboard, ItemsByStatus]),
|
|
12
|
+
* ],
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Owns:
|
|
16
|
+
* - the `forge.projectionStore` container binding
|
|
17
|
+
* - the projection registry (private to the plugin's `ProjectionChainRunner`)
|
|
18
|
+
* - the `forge.publish.projections` step on the EventPublishing chain
|
|
19
|
+
* (priority 600 — between actors and workflows)
|
|
20
|
+
* - the `forge.projectionChain` container binding so queriesPlugin (and
|
|
21
|
+
* other consumers) can resolve the runner
|
|
22
|
+
*
|
|
23
|
+
* If `forgePlugin`'s `options.projections` is also passed, both installs
|
|
24
|
+
* fold every event — install ONE path, not both. A diagnostic warning
|
|
25
|
+
* fires at `AppReady` when both attachments are present.
|
|
26
|
+
*/
|
|
27
|
+
import { InMemoryProjectionStore } from "../stores/projection-store.js";
|
|
28
|
+
import { EVENT_PUBLISHING_PRIORITIES } from "../framework-events.js";
|
|
29
|
+
import { ProjectionChainRunner } from "./projections-chain.js";
|
|
30
|
+
export const FORGE_PROJECTION_CHAIN_BINDING = "forge.projectionChain";
|
|
31
|
+
export const FORGE_PROJECTION_STORE_BINDING = "forge.projectionStore";
|
|
32
|
+
export function projectionsPlugin(
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
projections, opts = {}) {
|
|
35
|
+
return {
|
|
36
|
+
name: "forge.projections",
|
|
37
|
+
register({ bind }) {
|
|
38
|
+
const store = opts.projectionStore ?? new InMemoryProjectionStore();
|
|
39
|
+
bind(FORGE_PROJECTION_STORE_BINDING, () => store);
|
|
40
|
+
},
|
|
41
|
+
setup({ runtime, container, on }) {
|
|
42
|
+
const store = container.resolve(FORGE_PROJECTION_STORE_BINDING);
|
|
43
|
+
const chain = new ProjectionChainRunner(runtime, store);
|
|
44
|
+
for (const projection of projections) {
|
|
45
|
+
chain.register(projection);
|
|
46
|
+
}
|
|
47
|
+
container.register(FORGE_PROJECTION_CHAIN_BINDING, chain);
|
|
48
|
+
runtime.hooks.EventPublishing.use(async (payload, next) => {
|
|
49
|
+
await chain.apply(payload.event, payload.envelope);
|
|
50
|
+
await next();
|
|
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
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query runner — the read-side equivalent of the EventPublishing chain.
|
|
3
|
+
*
|
|
4
|
+
* Queries don't participate in the publish chain; they execute on
|
|
5
|
+
* demand against the projection store (`projection.execute(state, input)`)
|
|
6
|
+
* or via a handler closure (`query.handler(input, ctx)`). The runner is
|
|
7
|
+
* a thin holder around the registry + execution logic that both the
|
|
8
|
+
* standalone `queriesPlugin` and the bundled `ForgeDispatcher` use.
|
|
9
|
+
*
|
|
10
|
+
* Per-call behaviour:
|
|
11
|
+
* 1. Look up the query by name.
|
|
12
|
+
* 2. Validate input against the query's schema.
|
|
13
|
+
* 3. If `projection + execute` is configured, load the projection's
|
|
14
|
+
* per-tenant state and run execute.
|
|
15
|
+
* 4. If `handler` is configured, run it with a minimal ctx exposing
|
|
16
|
+
* `resolve` (container) and `tenant`.
|
|
17
|
+
* 5. Push `query.executed` telemetry on success.
|
|
18
|
+
*/
|
|
19
|
+
import { type Runtime } from "@nwire/app";
|
|
20
|
+
import type { Container } from "@nwire/container";
|
|
21
|
+
import type { QueryDefinition } from "../primitives/define-query.js";
|
|
22
|
+
import type { ProjectionStore } from "../stores/projection-store.js";
|
|
23
|
+
export declare class QueryRunner {
|
|
24
|
+
private readonly runtime;
|
|
25
|
+
private readonly container;
|
|
26
|
+
private readonly projectionStore;
|
|
27
|
+
readonly queries: Map<string, QueryDefinition<any, any, any>>;
|
|
28
|
+
constructor(runtime: Runtime, container: Container, projectionStore: ProjectionStore);
|
|
29
|
+
register(query: QueryDefinition<any, any, any>): void;
|
|
30
|
+
/** All registered query names, in registration order. */
|
|
31
|
+
listQueries(): readonly string[];
|
|
32
|
+
run<TResult = unknown>(queryName: string, input: unknown, tenant?: string): Promise<TResult>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query runner — the read-side equivalent of the EventPublishing chain.
|
|
3
|
+
*
|
|
4
|
+
* Queries don't participate in the publish chain; they execute on
|
|
5
|
+
* demand against the projection store (`projection.execute(state, input)`)
|
|
6
|
+
* or via a handler closure (`query.handler(input, ctx)`). The runner is
|
|
7
|
+
* a thin holder around the registry + execution logic that both the
|
|
8
|
+
* standalone `queriesPlugin` and the bundled `ForgeDispatcher` use.
|
|
9
|
+
*
|
|
10
|
+
* Per-call behaviour:
|
|
11
|
+
* 1. Look up the query by name.
|
|
12
|
+
* 2. Validate input against the query's schema.
|
|
13
|
+
* 3. If `projection + execute` is configured, load the projection's
|
|
14
|
+
* per-tenant state and run execute.
|
|
15
|
+
* 4. If `handler` is configured, run it with a minimal ctx exposing
|
|
16
|
+
* `resolve` (container) and `tenant`.
|
|
17
|
+
* 5. Push `query.executed` telemetry on success.
|
|
18
|
+
*/
|
|
19
|
+
import { isValidated, markValidated } from "@nwire/messages";
|
|
20
|
+
export class QueryRunner {
|
|
21
|
+
runtime;
|
|
22
|
+
container;
|
|
23
|
+
projectionStore;
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
queries = new Map();
|
|
26
|
+
constructor(runtime, container, projectionStore) {
|
|
27
|
+
this.runtime = runtime;
|
|
28
|
+
this.container = container;
|
|
29
|
+
this.projectionStore = projectionStore;
|
|
30
|
+
}
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
register(query) {
|
|
33
|
+
if (this.queries.has(query.name)) {
|
|
34
|
+
throw new Error(`queriesPlugin: query "${query.name}" already registered.`);
|
|
35
|
+
}
|
|
36
|
+
this.queries.set(query.name, query);
|
|
37
|
+
}
|
|
38
|
+
/** All registered query names, in registration order. */
|
|
39
|
+
listQueries() {
|
|
40
|
+
return [...this.queries.keys()];
|
|
41
|
+
}
|
|
42
|
+
async run(queryName, input, tenant = "") {
|
|
43
|
+
const query = this.queries.get(queryName);
|
|
44
|
+
if (!query) {
|
|
45
|
+
throw new Error(`queriesPlugin: no query registered with name "${queryName}".`);
|
|
46
|
+
}
|
|
47
|
+
const t0 = performance.now();
|
|
48
|
+
const validated = isValidated(input) ? input : markValidated(query.schema.parse(input));
|
|
49
|
+
const appName = this.runtime.appName;
|
|
50
|
+
let result;
|
|
51
|
+
if (query.projection && query.execute) {
|
|
52
|
+
const state = (await this.projectionStore.load(query.projection.name, tenant)) ??
|
|
53
|
+
query.projection.initial();
|
|
54
|
+
result = (await query.execute(state, validated));
|
|
55
|
+
}
|
|
56
|
+
else if (query.handler) {
|
|
57
|
+
result = (await query.handler(validated, {
|
|
58
|
+
resolve: (name) => this.container.resolve(name),
|
|
59
|
+
tenant,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
throw new Error(`queriesPlugin: query "${queryName}" has neither projection+execute nor a handler — ` +
|
|
64
|
+
`defineQuery must be given one or the other.`);
|
|
65
|
+
}
|
|
66
|
+
this.runtime.pushTelemetry({
|
|
67
|
+
kind: "query.executed",
|
|
68
|
+
query: queryName,
|
|
69
|
+
input: validated,
|
|
70
|
+
tenant,
|
|
71
|
+
durationMs: performance.now() - t0,
|
|
72
|
+
appName,
|
|
73
|
+
ts: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `queriesPlugin` — standalone forge query concern.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import {
|
|
6
|
+
* projectionsPlugin,
|
|
7
|
+
* queriesPlugin,
|
|
8
|
+
* forgePlugin,
|
|
9
|
+
* } from "@nwire/forge";
|
|
10
|
+
*
|
|
11
|
+
* const app = createApp({
|
|
12
|
+
* appName: "orders",
|
|
13
|
+
* plugins: [
|
|
14
|
+
* forgePlugin,
|
|
15
|
+
* projectionsPlugin([OrdersDashboard]),
|
|
16
|
+
* queriesPlugin([listOrders, getOrderById]),
|
|
17
|
+
* ],
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* Owns:
|
|
21
|
+
* - the query registry (private to the plugin's `QueryRunner`)
|
|
22
|
+
* - the `forge.queryRunner` container binding
|
|
23
|
+
* - the `forge.query` capability (binding consumed by handler ctx as
|
|
24
|
+
* `ctx.resolve("useProjection")` and equivalent paths)
|
|
25
|
+
*
|
|
26
|
+
* Queries don't participate in the EventPublishing chain. They execute
|
|
27
|
+
* on demand against either a projection's per-tenant state (when
|
|
28
|
+
* `projection + execute` is set) or a handler closure. Projection state
|
|
29
|
+
* comes from the `forge.projectionStore` binding, which `projectionsPlugin`
|
|
30
|
+
* or `forgePlugin` provides.
|
|
31
|
+
*
|
|
32
|
+
* If `forgePlugin`'s `options.queries` is also passed, two QueryRunners
|
|
33
|
+
* exist and the bundled one is reachable through the existing forge
|
|
34
|
+
* dispatcher binding. Both work, but the standalone plugin's runner is
|
|
35
|
+
* the one bound on `FORGE_QUERY_RUNNER_BINDING`. A warning at AppReady
|
|
36
|
+
* flags the duplicate install.
|
|
37
|
+
*/
|
|
38
|
+
import type { PluginDefinition } from "@nwire/app";
|
|
39
|
+
import type { QueryDefinition } from "../primitives/define-query.js";
|
|
40
|
+
export declare const FORGE_QUERY_RUNNER_BINDING: "forge.queryRunner";
|
|
41
|
+
export declare function queriesPlugin(queries: readonly QueryDefinition<any, any, any>[]): PluginDefinition;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `queriesPlugin` — standalone forge query concern.
|
|
3
|
+
*
|
|
4
|
+
* import { createApp } from "@nwire/app";
|
|
5
|
+
* import {
|
|
6
|
+
* projectionsPlugin,
|
|
7
|
+
* queriesPlugin,
|
|
8
|
+
* forgePlugin,
|
|
9
|
+
* } from "@nwire/forge";
|
|
10
|
+
*
|
|
11
|
+
* const app = createApp({
|
|
12
|
+
* appName: "orders",
|
|
13
|
+
* plugins: [
|
|
14
|
+
* forgePlugin,
|
|
15
|
+
* projectionsPlugin([OrdersDashboard]),
|
|
16
|
+
* queriesPlugin([listOrders, getOrderById]),
|
|
17
|
+
* ],
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* Owns:
|
|
21
|
+
* - the query registry (private to the plugin's `QueryRunner`)
|
|
22
|
+
* - the `forge.queryRunner` container binding
|
|
23
|
+
* - the `forge.query` capability (binding consumed by handler ctx as
|
|
24
|
+
* `ctx.resolve("useProjection")` and equivalent paths)
|
|
25
|
+
*
|
|
26
|
+
* Queries don't participate in the EventPublishing chain. They execute
|
|
27
|
+
* on demand against either a projection's per-tenant state (when
|
|
28
|
+
* `projection + execute` is set) or a handler closure. Projection state
|
|
29
|
+
* comes from the `forge.projectionStore` binding, which `projectionsPlugin`
|
|
30
|
+
* or `forgePlugin` provides.
|
|
31
|
+
*
|
|
32
|
+
* If `forgePlugin`'s `options.queries` is also passed, two QueryRunners
|
|
33
|
+
* exist and the bundled one is reachable through the existing forge
|
|
34
|
+
* dispatcher binding. Both work, but the standalone plugin's runner is
|
|
35
|
+
* the one bound on `FORGE_QUERY_RUNNER_BINDING`. A warning at AppReady
|
|
36
|
+
* flags the duplicate install.
|
|
37
|
+
*/
|
|
38
|
+
import { FORGE_PROJECTION_STORE_BINDING } from "./projections-plugin.js";
|
|
39
|
+
import { QueryRunner } from "./queries-chain.js";
|
|
40
|
+
export const FORGE_QUERY_RUNNER_BINDING = "forge.queryRunner";
|
|
41
|
+
export function queriesPlugin(
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
+
queries) {
|
|
44
|
+
return {
|
|
45
|
+
name: "forge.queries",
|
|
46
|
+
setup({ runtime, container, on }) {
|
|
47
|
+
if (!container.has(FORGE_PROJECTION_STORE_BINDING)) {
|
|
48
|
+
throw new Error(`queriesPlugin: ${FORGE_PROJECTION_STORE_BINDING} is not bound. ` +
|
|
49
|
+
`Install projectionsPlugin or forgePlugin before queriesPlugin so the projection store exists.`);
|
|
50
|
+
}
|
|
51
|
+
const projectionStore = container.resolve(FORGE_PROJECTION_STORE_BINDING);
|
|
52
|
+
const runner = new QueryRunner(runtime, container, projectionStore);
|
|
53
|
+
for (const query of queries)
|
|
54
|
+
runner.register(query);
|
|
55
|
+
container.register(FORGE_QUERY_RUNNER_BINDING, runner);
|
|
56
|
+
on("AppReady", () => {
|
|
57
|
+
// Diagnostic only — the bundled forge dispatcher also exposes
|
|
58
|
+
// queries via its own internal registry. Both work, but the
|
|
59
|
+
// standalone runner is the binding consumers see at
|
|
60
|
+
// FORGE_QUERY_RUNNER_BINDING.
|
|
61
|
+
const dispatcher = container.has("forge.dispatcher")
|
|
62
|
+
? container.resolve("forge.dispatcher")
|
|
63
|
+
: undefined;
|
|
64
|
+
const bundledNames = dispatcher?.listQueries?.() ?? [];
|
|
65
|
+
const overlap = runner.listQueries().filter((n) => bundledNames.includes(n));
|
|
66
|
+
if (overlap.length > 0) {
|
|
67
|
+
// eslint-disable-next-line no-console
|
|
68
|
+
console.warn(`queriesPlugin: ${overlap.length} query name(s) (${overlap.join(", ")}) are also registered on forgePlugin's bundled queries. ` +
|
|
69
|
+
`Install either queriesPlugin OR forgePlugin's options.queries path, not both.`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow chain runner — the EventPublishing step that drives every
|
|
3
|
+
* registered workflow whose subscribed events include the current one.
|
|
4
|
+
*
|
|
5
|
+
* Self-contained: takes its dependencies (runtime, timer store, effects
|
|
6
|
+
* factory) as constructor arguments. Both `workflowsPlugin` and the
|
|
7
|
+
* bundled `ForgeDispatcher` instantiate one of these and call
|
|
8
|
+
* `apply(event, envelope)` from their respective hook attachment.
|
|
9
|
+
*
|
|
10
|
+
* Per-event behaviour:
|
|
11
|
+
* 1. Look up workflows by subscribed event name.
|
|
12
|
+
* 2. For each, build a per-fire `FireContext` (load/save/drop the
|
|
13
|
+
* correlated instance, schedule timers, emit effects).
|
|
14
|
+
* 3. Fire the workflow with retry per `workflow.retry`, then push
|
|
15
|
+
* `reaction.fired` / `reaction.failed` / `reaction.exhausted` telemetry.
|
|
16
|
+
* 4. Per-workflow fire hook (`workflow.fire:<name>`) runs before each
|
|
17
|
+
* attempt; throws there are logged, not fatal.
|
|
18
|
+
*/
|
|
19
|
+
import { type Hook } from "@nwire/hooks";
|
|
20
|
+
import { type MessageEnvelope } from "@nwire/envelope";
|
|
21
|
+
import { type Runtime } from "@nwire/app";
|
|
22
|
+
import type { WorkflowDefinition, WorkflowInstance, WorkflowEffects } from "../primitives/define-workflow.js";
|
|
23
|
+
import type { EventMessage } from "../messages/event-message.js";
|
|
24
|
+
import type { WorkflowFireHookCtx } from "../runtime/forge-types.js";
|
|
25
|
+
import type { WorkflowTimerStore } from "../stores/workflow-timer-store.js";
|
|
26
|
+
/**
|
|
27
|
+
* Resolves the dispatch effects (send / enqueue / publish) the workflow
|
|
28
|
+
* sees on each fire. Returned in a fresh closure per event so envelope
|
|
29
|
+
* propagation stays correct.
|
|
30
|
+
*/
|
|
31
|
+
export type WorkflowEffectsFactory = (envelope: MessageEnvelope) => WorkflowEffects;
|
|
32
|
+
export declare class WorkflowChainRunner {
|
|
33
|
+
private readonly runtime;
|
|
34
|
+
readonly timerStore: WorkflowTimerStore;
|
|
35
|
+
private readonly buildEffects;
|
|
36
|
+
readonly workflows: Map<string, WorkflowDefinition>;
|
|
37
|
+
readonly workflowsByEvent: Map<string, WorkflowDefinition[]>;
|
|
38
|
+
readonly perWorkflowHooks: Map<string, Hook<WorkflowFireHookCtx>>;
|
|
39
|
+
/** In-memory per-workflow instance stores, keyed by correlationKey. */
|
|
40
|
+
readonly workflowInstances: Map<string, Map<string, WorkflowInstance>>;
|
|
41
|
+
constructor(runtime: Runtime, timerStore: WorkflowTimerStore, buildEffects: WorkflowEffectsFactory);
|
|
42
|
+
register(workflow: WorkflowDefinition): void;
|
|
43
|
+
/** All registered workflow definitions, in registration order. */
|
|
44
|
+
listWorkflows(): readonly WorkflowDefinition[];
|
|
45
|
+
/** Lazy-create the per-workflow `workflow.fire:<name>` hook. */
|
|
46
|
+
ensureFireHook(workflowName: string): Hook<WorkflowFireHookCtx>;
|
|
47
|
+
/** Get-or-create the in-memory per-workflow instance store. */
|
|
48
|
+
instanceStore(workflowName: string): Map<string, WorkflowInstance>;
|
|
49
|
+
/** Fire every workflow that subscribes to this event. */
|
|
50
|
+
apply(event: EventMessage, envelope: MessageEnvelope, correlationKeyOverride?: string): Promise<void>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow chain runner — the EventPublishing step that drives every
|
|
3
|
+
* registered workflow whose subscribed events include the current one.
|
|
4
|
+
*
|
|
5
|
+
* Self-contained: takes its dependencies (runtime, timer store, effects
|
|
6
|
+
* factory) as constructor arguments. Both `workflowsPlugin` and the
|
|
7
|
+
* bundled `ForgeDispatcher` instantiate one of these and call
|
|
8
|
+
* `apply(event, envelope)` from their respective hook attachment.
|
|
9
|
+
*
|
|
10
|
+
* Per-event behaviour:
|
|
11
|
+
* 1. Look up workflows by subscribed event name.
|
|
12
|
+
* 2. For each, build a per-fire `FireContext` (load/save/drop the
|
|
13
|
+
* correlated instance, schedule timers, emit effects).
|
|
14
|
+
* 3. Fire the workflow with retry per `workflow.retry`, then push
|
|
15
|
+
* `reaction.fired` / `reaction.failed` / `reaction.exhausted` telemetry.
|
|
16
|
+
* 4. Per-workflow fire hook (`workflow.fire:<name>`) runs before each
|
|
17
|
+
* attempt; throws there are logged, not fatal.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { hook } from "@nwire/hooks";
|
|
21
|
+
import { loggerForEnvelope } from "@nwire/logger";
|
|
22
|
+
import { serializeError } from "@nwire/app";
|
|
23
|
+
import { computeBackoff, parseDelay, sleep } from "../helpers/retry-helpers.js";
|
|
24
|
+
export class WorkflowChainRunner {
|
|
25
|
+
runtime;
|
|
26
|
+
timerStore;
|
|
27
|
+
buildEffects;
|
|
28
|
+
workflows = new Map();
|
|
29
|
+
workflowsByEvent = new Map();
|
|
30
|
+
perWorkflowHooks = new Map();
|
|
31
|
+
/** In-memory per-workflow instance stores, keyed by correlationKey. */
|
|
32
|
+
workflowInstances = new Map();
|
|
33
|
+
constructor(runtime, timerStore, buildEffects) {
|
|
34
|
+
this.runtime = runtime;
|
|
35
|
+
this.timerStore = timerStore;
|
|
36
|
+
this.buildEffects = buildEffects;
|
|
37
|
+
}
|
|
38
|
+
register(workflow) {
|
|
39
|
+
if (this.workflows.has(workflow.name)) {
|
|
40
|
+
throw new Error(`workflowsPlugin: workflow "${workflow.name}" already registered.`);
|
|
41
|
+
}
|
|
42
|
+
this.workflows.set(workflow.name, workflow);
|
|
43
|
+
for (const eventName of workflow.subscribedEvents) {
|
|
44
|
+
const list = this.workflowsByEvent.get(eventName) ?? [];
|
|
45
|
+
list.push(workflow);
|
|
46
|
+
this.workflowsByEvent.set(eventName, list);
|
|
47
|
+
}
|
|
48
|
+
this.ensureFireHook(workflow.name);
|
|
49
|
+
}
|
|
50
|
+
/** All registered workflow definitions, in registration order. */
|
|
51
|
+
listWorkflows() {
|
|
52
|
+
return [...this.workflows.values()];
|
|
53
|
+
}
|
|
54
|
+
/** Lazy-create the per-workflow `workflow.fire:<name>` hook. */
|
|
55
|
+
ensureFireHook(workflowName) {
|
|
56
|
+
let h = this.perWorkflowHooks.get(workflowName);
|
|
57
|
+
if (h)
|
|
58
|
+
return h;
|
|
59
|
+
h = hook(`workflow.fire:${workflowName}`);
|
|
60
|
+
this.perWorkflowHooks.set(workflowName, h);
|
|
61
|
+
return h;
|
|
62
|
+
}
|
|
63
|
+
/** Get-or-create the in-memory per-workflow instance store. */
|
|
64
|
+
instanceStore(workflowName) {
|
|
65
|
+
let store = this.workflowInstances.get(workflowName);
|
|
66
|
+
if (!store) {
|
|
67
|
+
store = new Map();
|
|
68
|
+
this.workflowInstances.set(workflowName, store);
|
|
69
|
+
}
|
|
70
|
+
return store;
|
|
71
|
+
}
|
|
72
|
+
/** Fire every workflow that subscribes to this event. */
|
|
73
|
+
async apply(event, envelope, correlationKeyOverride) {
|
|
74
|
+
const workflows = this.workflowsByEvent.get(event.eventName);
|
|
75
|
+
if (!workflows || workflows.length === 0)
|
|
76
|
+
return;
|
|
77
|
+
const appName = this.runtime.appName;
|
|
78
|
+
const log = loggerForEnvelope(this.runtime.logger, envelope).child({
|
|
79
|
+
event: event.eventName,
|
|
80
|
+
});
|
|
81
|
+
const baseEffects = this.buildEffects(envelope);
|
|
82
|
+
for (const workflow of workflows) {
|
|
83
|
+
const t0 = performance.now();
|
|
84
|
+
try {
|
|
85
|
+
const store = this.instanceStore(workflow.name);
|
|
86
|
+
const userKey = workflow.correlate?.(event) ?? "__default__";
|
|
87
|
+
const tenantPrefix = envelope.tenant ? `${envelope.tenant}::` : "";
|
|
88
|
+
const correlationKey = correlationKeyOverride ?? `${tenantPrefix}${userKey}`;
|
|
89
|
+
const fireCtx = {
|
|
90
|
+
...baseEffects,
|
|
91
|
+
load: (key) => store.get(key),
|
|
92
|
+
save: (key, instance) => store.set(key, instance),
|
|
93
|
+
drop: (key) => store.delete(key),
|
|
94
|
+
correlationKey,
|
|
95
|
+
scheduleTimer: async (timerName, delay, payload) => {
|
|
96
|
+
await this.timerStore.schedule({
|
|
97
|
+
id: randomUUID(),
|
|
98
|
+
workflowName: workflow.name,
|
|
99
|
+
correlationKey,
|
|
100
|
+
timerName,
|
|
101
|
+
fireAt: new Date(Date.now() + parseDelay(delay)).toISOString(),
|
|
102
|
+
payload,
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const perWorkflowHook = this.perWorkflowHooks.get(workflow.name);
|
|
107
|
+
if (perWorkflowHook) {
|
|
108
|
+
try {
|
|
109
|
+
await perWorkflowHook.run({
|
|
110
|
+
workflow,
|
|
111
|
+
event,
|
|
112
|
+
envelope,
|
|
113
|
+
correlationKey,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
log.error(`workflow.fire hook threw`, {
|
|
118
|
+
workflow: workflow.name,
|
|
119
|
+
error: err?.message,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const retry = workflow.retry;
|
|
124
|
+
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
125
|
+
let attempt = 0;
|
|
126
|
+
let lastError;
|
|
127
|
+
let fired = false;
|
|
128
|
+
let exhausted = false;
|
|
129
|
+
while (attempt < maxAttempts) {
|
|
130
|
+
attempt++;
|
|
131
|
+
try {
|
|
132
|
+
if (attempt > 1) {
|
|
133
|
+
const delay = computeBackoff(retry, attempt - 1);
|
|
134
|
+
if (delay > 0)
|
|
135
|
+
await sleep(delay);
|
|
136
|
+
}
|
|
137
|
+
await workflow._fire(event, fireCtx);
|
|
138
|
+
fired = true;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
lastError = err;
|
|
143
|
+
const willRetry = attempt < maxAttempts;
|
|
144
|
+
this.runtime.pushTelemetry({
|
|
145
|
+
kind: "reaction.failed",
|
|
146
|
+
sourceEvent: event.eventName,
|
|
147
|
+
error: serializeError(err),
|
|
148
|
+
envelope,
|
|
149
|
+
appName,
|
|
150
|
+
ts: new Date().toISOString(),
|
|
151
|
+
workflow: workflow.name,
|
|
152
|
+
attempt,
|
|
153
|
+
maxAttempts,
|
|
154
|
+
willRetry,
|
|
155
|
+
});
|
|
156
|
+
if (!willRetry && retry) {
|
|
157
|
+
this.runtime.pushTelemetry({
|
|
158
|
+
kind: "reaction.exhausted",
|
|
159
|
+
workflow: workflow.name,
|
|
160
|
+
sourceEvent: event.eventName,
|
|
161
|
+
attempts: attempt,
|
|
162
|
+
error: serializeError(err),
|
|
163
|
+
envelope,
|
|
164
|
+
appName,
|
|
165
|
+
ts: new Date().toISOString(),
|
|
166
|
+
});
|
|
167
|
+
exhausted = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!fired) {
|
|
172
|
+
void exhausted;
|
|
173
|
+
if (lastError && typeof lastError === "object") {
|
|
174
|
+
lastError.__nwireWorkflowEmitted = true;
|
|
175
|
+
}
|
|
176
|
+
throw lastError;
|
|
177
|
+
}
|
|
178
|
+
this.runtime.pushTelemetry({
|
|
179
|
+
kind: "reaction.fired",
|
|
180
|
+
sourceEvent: event.eventName,
|
|
181
|
+
durationMs: performance.now() - t0,
|
|
182
|
+
envelope,
|
|
183
|
+
appName,
|
|
184
|
+
ts: new Date().toISOString(),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (!err?.__nwireWorkflowEmitted) {
|
|
189
|
+
this.runtime.pushTelemetry({
|
|
190
|
+
kind: "reaction.failed",
|
|
191
|
+
sourceEvent: event.eventName,
|
|
192
|
+
error: serializeError(err),
|
|
193
|
+
envelope,
|
|
194
|
+
appName,
|
|
195
|
+
ts: new Date().toISOString(),
|
|
196
|
+
workflow: workflow.name,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|