@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,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
+ }