@nwire/forge 0.9.1 → 0.10.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 +133 -155
- package/dist/foundation.d.ts +0 -13
- package/dist/foundation.js +2 -13
- package/dist/framework-events.d.ts +51 -54
- package/dist/framework-events.js +18 -65
- package/dist/{cli-runner.d.ts → helpers/cli-runner.d.ts} +2 -3
- package/dist/{cli-runner.js → helpers/cli-runner.js} +11 -27
- package/dist/{public-marker.d.ts → helpers/public-marker.d.ts} +0 -1
- package/dist/{public-marker.js → helpers/public-marker.js} +0 -1
- package/dist/{response.d.ts → helpers/response.d.ts} +0 -1
- package/dist/{response.js → helpers/response.js} +0 -1
- package/dist/helpers/retry-helpers.d.ts +22 -0
- package/dist/helpers/retry-helpers.js +43 -0
- package/dist/{validate.d.ts → helpers/validate.d.ts} +0 -1
- package/dist/{validate.js → helpers/validate.js} +0 -1
- package/dist/index.d.ts +42 -48
- package/dist/index.js +47 -55
- package/dist/{event-message.d.ts → messages/event-message.d.ts} +0 -1
- package/dist/{event-message.js → messages/event-message.js} +0 -1
- package/dist/{dev-logger.d.ts → observability/dev-logger.d.ts} +2 -2
- package/dist/{dev-logger.js → observability/dev-logger.js} +0 -30
- package/dist/{define-action.d.ts → primitives/define-action.d.ts} +0 -1
- package/dist/{define-action.js → primitives/define-action.js} +0 -1
- package/dist/{define-actor.d.ts → primitives/define-actor.d.ts} +0 -1
- package/dist/{define-actor.js → primitives/define-actor.js} +0 -1
- package/dist/{define-cron.d.ts → primitives/define-cron.d.ts} +0 -1
- package/dist/{define-cron.js → primitives/define-cron.js} +0 -1
- package/dist/{define-error.d.ts → primitives/define-error.d.ts} +0 -1
- package/dist/{define-error.js → primitives/define-error.js} +0 -1
- package/dist/{define-external-call.d.ts → primitives/define-external-call.d.ts} +0 -1
- package/dist/{define-external-call.js → primitives/define-external-call.js} +0 -1
- package/dist/{define-handler.d.ts → primitives/define-handler.d.ts} +29 -2
- package/dist/{define-handler.js → primitives/define-handler.js} +13 -2
- package/dist/{define-inbound-webhook.d.ts → primitives/define-inbound-webhook.d.ts} +1 -2
- package/dist/{define-inbound-webhook.js → primitives/define-inbound-webhook.js} +1 -2
- package/dist/{define-inbox.d.ts → primitives/define-inbox.d.ts} +0 -1
- package/dist/{define-inbox.js → primitives/define-inbox.js} +0 -1
- package/dist/{define-outbox.d.ts → primitives/define-outbox.d.ts} +0 -1
- package/dist/{define-outbox.js → primitives/define-outbox.js} +0 -1
- package/dist/{define-projection.d.ts → primitives/define-projection.d.ts} +0 -1
- package/dist/{define-projection.js → primitives/define-projection.js} +0 -1
- package/dist/{define-query.d.ts → primitives/define-query.d.ts} +1 -2
- package/dist/{define-query.js → primitives/define-query.js} +1 -2
- package/dist/{define-schema.d.ts → primitives/define-schema.d.ts} +1 -2
- package/dist/{define-schema.js → primitives/define-schema.js} +1 -2
- package/dist/{define-upcaster.d.ts → primitives/define-upcaster.d.ts} +0 -1
- package/dist/{define-upcaster.js → primitives/define-upcaster.js} +0 -1
- package/dist/{define-workflow.d.ts → primitives/define-workflow.d.ts} +2 -3
- package/dist/{define-workflow.js → primitives/define-workflow.js} +2 -3
- package/dist/runtime/create-forge-app.d.ts +64 -0
- package/dist/runtime/create-forge-app.js +78 -0
- package/dist/runtime/forge-dispatcher.d.ts +148 -0
- package/dist/{runtime.js → runtime/forge-dispatcher.js} +242 -571
- package/dist/runtime/forge-plugin.d.ts +59 -0
- package/dist/runtime/forge-plugin.js +121 -0
- package/dist/runtime/forge-types.d.ts +204 -0
- package/dist/runtime/forge-types.js +5 -0
- package/dist/{actor-store.d.ts → stores/actor-store.d.ts} +1 -2
- package/dist/{actor-store.js → stores/actor-store.js} +0 -1
- package/dist/{idempotency-store.d.ts → stores/idempotency-store.d.ts} +0 -1
- package/dist/{idempotency-store.js → stores/idempotency-store.js} +0 -1
- package/dist/{projection-store.d.ts → stores/projection-store.d.ts} +0 -1
- package/dist/{projection-store.js → stores/projection-store.js} +0 -1
- package/dist/{workflow-timer-store.d.ts → stores/workflow-timer-store.d.ts} +0 -1
- package/dist/{workflow-timer-store.js → stores/workflow-timer-store.js} +0 -1
- package/package.json +11 -11
- package/dist/__tests__/action-hooks.test.d.ts +0 -8
- package/dist/__tests__/action-hooks.test.d.ts.map +0 -1
- package/dist/__tests__/action-hooks.test.js +0 -95
- package/dist/__tests__/action-hooks.test.js.map +0 -1
- package/dist/__tests__/actor-methods.test.d.ts +0 -9
- package/dist/__tests__/actor-methods.test.d.ts.map +0 -1
- package/dist/__tests__/actor-methods.test.js +0 -210
- package/dist/__tests__/actor-methods.test.js.map +0 -1
- package/dist/__tests__/actor-schema-bound.test.d.ts +0 -6
- package/dist/__tests__/actor-schema-bound.test.d.ts.map +0 -1
- package/dist/__tests__/actor-schema-bound.test.js +0 -112
- package/dist/__tests__/actor-schema-bound.test.js.map +0 -1
- package/dist/__tests__/actor-workflow-hooks.test.d.ts +0 -8
- package/dist/__tests__/actor-workflow-hooks.test.d.ts.map +0 -1
- package/dist/__tests__/actor-workflow-hooks.test.js +0 -106
- package/dist/__tests__/actor-workflow-hooks.test.js.map +0 -1
- package/dist/__tests__/app-capabilities.test.d.ts +0 -19
- package/dist/__tests__/app-capabilities.test.d.ts.map +0 -1
- package/dist/__tests__/app-capabilities.test.js +0 -57
- package/dist/__tests__/app-capabilities.test.js.map +0 -1
- package/dist/__tests__/cli-runner.test.d.ts +0 -6
- package/dist/__tests__/cli-runner.test.d.ts.map +0 -1
- package/dist/__tests__/cli-runner.test.js +0 -158
- package/dist/__tests__/cli-runner.test.js.map +0 -1
- package/dist/__tests__/create-app.test.d.ts +0 -18
- package/dist/__tests__/create-app.test.d.ts.map +0 -1
- package/dist/__tests__/create-app.test.js +0 -189
- package/dist/__tests__/create-app.test.js.map +0 -1
- package/dist/__tests__/cross-service-bus.test.d.ts +0 -8
- package/dist/__tests__/cross-service-bus.test.d.ts.map +0 -1
- package/dist/__tests__/cross-service-bus.test.js +0 -139
- package/dist/__tests__/cross-service-bus.test.js.map +0 -1
- package/dist/__tests__/define-schema.test.d.ts +0 -5
- package/dist/__tests__/define-schema.test.d.ts.map +0 -1
- package/dist/__tests__/define-schema.test.js +0 -83
- package/dist/__tests__/define-schema.test.js.map +0 -1
- package/dist/__tests__/dev-logger.test.d.ts +0 -9
- package/dist/__tests__/dev-logger.test.d.ts.map +0 -1
- package/dist/__tests__/dev-logger.test.js +0 -126
- package/dist/__tests__/dev-logger.test.js.map +0 -1
- package/dist/__tests__/external-call.test.d.ts +0 -14
- package/dist/__tests__/external-call.test.d.ts.map +0 -1
- package/dist/__tests__/external-call.test.js +0 -99
- package/dist/__tests__/external-call.test.js.map +0 -1
- package/dist/__tests__/framework-events.test.d.ts +0 -13
- package/dist/__tests__/framework-events.test.d.ts.map +0 -1
- package/dist/__tests__/framework-events.test.js +0 -204
- package/dist/__tests__/framework-events.test.js.map +0 -1
- package/dist/__tests__/inline-handler.test.d.ts +0 -8
- package/dist/__tests__/inline-handler.test.d.ts.map +0 -1
- package/dist/__tests__/inline-handler.test.js +0 -101
- package/dist/__tests__/inline-handler.test.js.map +0 -1
- package/dist/__tests__/lifecycle-logging.test.d.ts +0 -12
- package/dist/__tests__/lifecycle-logging.test.d.ts.map +0 -1
- package/dist/__tests__/lifecycle-logging.test.js +0 -114
- package/dist/__tests__/lifecycle-logging.test.js.map +0 -1
- package/dist/__tests__/middleware.test.d.ts +0 -7
- package/dist/__tests__/middleware.test.d.ts.map +0 -1
- package/dist/__tests__/middleware.test.js +0 -109
- package/dist/__tests__/middleware.test.js.map +0 -1
- package/dist/__tests__/module-needs.test.d.ts +0 -10
- package/dist/__tests__/module-needs.test.d.ts.map +0 -1
- package/dist/__tests__/module-needs.test.js +0 -77
- package/dist/__tests__/module-needs.test.js.map +0 -1
- package/dist/__tests__/module-topo-sort.test.d.ts +0 -15
- package/dist/__tests__/module-topo-sort.test.d.ts.map +0 -1
- package/dist/__tests__/module-topo-sort.test.js +0 -105
- package/dist/__tests__/module-topo-sort.test.js.map +0 -1
- package/dist/__tests__/multi-tenancy.test.d.ts +0 -10
- package/dist/__tests__/multi-tenancy.test.d.ts.map +0 -1
- package/dist/__tests__/multi-tenancy.test.js +0 -122
- package/dist/__tests__/multi-tenancy.test.js.map +0 -1
- package/dist/__tests__/needs-topology.test.d.ts +0 -11
- package/dist/__tests__/needs-topology.test.d.ts.map +0 -1
- package/dist/__tests__/needs-topology.test.js +0 -82
- package/dist/__tests__/needs-topology.test.js.map +0 -1
- package/dist/__tests__/plugin-app-narrow.test.d.ts +0 -12
- package/dist/__tests__/plugin-app-narrow.test.d.ts.map +0 -1
- package/dist/__tests__/plugin-app-narrow.test.js +0 -77
- package/dist/__tests__/plugin-app-narrow.test.js.map +0 -1
- package/dist/__tests__/plugin-closure.test.d.ts +0 -15
- package/dist/__tests__/plugin-closure.test.d.ts.map +0 -1
- package/dist/__tests__/plugin-closure.test.js +0 -140
- package/dist/__tests__/plugin-closure.test.js.map +0 -1
- package/dist/__tests__/plugin-stress.test.d.ts +0 -21
- package/dist/__tests__/plugin-stress.test.d.ts.map +0 -1
- package/dist/__tests__/plugin-stress.test.js +0 -203
- package/dist/__tests__/plugin-stress.test.js.map +0 -1
- package/dist/__tests__/plugin.test.d.ts +0 -10
- package/dist/__tests__/plugin.test.d.ts.map +0 -1
- package/dist/__tests__/plugin.test.js +0 -225
- package/dist/__tests__/plugin.test.js.map +0 -1
- package/dist/__tests__/primitives.test.d.ts +0 -9
- package/dist/__tests__/primitives.test.d.ts.map +0 -1
- package/dist/__tests__/primitives.test.js +0 -434
- package/dist/__tests__/primitives.test.js.map +0 -1
- package/dist/__tests__/production-readiness.test.d.ts +0 -22
- package/dist/__tests__/production-readiness.test.d.ts.map +0 -1
- package/dist/__tests__/production-readiness.test.js +0 -196
- package/dist/__tests__/production-readiness.test.js.map +0 -1
- package/dist/__tests__/provider.test.d.ts +0 -6
- package/dist/__tests__/provider.test.d.ts.map +0 -1
- package/dist/__tests__/provider.test.js +0 -122
- package/dist/__tests__/provider.test.js.map +0 -1
- package/dist/__tests__/public-marker.test.d.ts +0 -7
- package/dist/__tests__/public-marker.test.d.ts.map +0 -1
- package/dist/__tests__/public-marker.test.js +0 -58
- package/dist/__tests__/public-marker.test.js.map +0 -1
- package/dist/__tests__/retry-dlq.test.d.ts +0 -6
- package/dist/__tests__/retry-dlq.test.d.ts.map +0 -1
- package/dist/__tests__/retry-dlq.test.js +0 -68
- package/dist/__tests__/retry-dlq.test.js.map +0 -1
- package/dist/__tests__/validate.test.d.ts +0 -5
- package/dist/__tests__/validate.test.d.ts.map +0 -1
- package/dist/__tests__/validate.test.js +0 -53
- package/dist/__tests__/validate.test.js.map +0 -1
- package/dist/__tests__/workflow-saga.test.d.ts +0 -7
- package/dist/__tests__/workflow-saga.test.d.ts.map +0 -1
- package/dist/__tests__/workflow-saga.test.js +0 -265
- package/dist/__tests__/workflow-saga.test.js.map +0 -1
- package/dist/actor-store.d.ts.map +0 -1
- package/dist/actor-store.js.map +0 -1
- package/dist/cli-runner.d.ts.map +0 -1
- package/dist/cli-runner.js.map +0 -1
- package/dist/create-app.d.ts +0 -146
- package/dist/create-app.d.ts.map +0 -1
- package/dist/create-app.js +0 -703
- package/dist/create-app.js.map +0 -1
- package/dist/define-action.d.ts.map +0 -1
- package/dist/define-action.js.map +0 -1
- package/dist/define-actor.d.ts.map +0 -1
- package/dist/define-actor.js.map +0 -1
- package/dist/define-app.d.ts +0 -104
- package/dist/define-app.d.ts.map +0 -1
- package/dist/define-app.js +0 -49
- package/dist/define-app.js.map +0 -1
- package/dist/define-cron.d.ts.map +0 -1
- package/dist/define-cron.js.map +0 -1
- package/dist/define-error.d.ts.map +0 -1
- package/dist/define-error.js.map +0 -1
- package/dist/define-external-call.d.ts.map +0 -1
- package/dist/define-external-call.js.map +0 -1
- package/dist/define-handler.d.ts.map +0 -1
- package/dist/define-handler.js.map +0 -1
- package/dist/define-inbound-webhook.d.ts.map +0 -1
- package/dist/define-inbound-webhook.js.map +0 -1
- package/dist/define-inbox.d.ts.map +0 -1
- package/dist/define-inbox.js.map +0 -1
- package/dist/define-initializer.d.ts +0 -54
- package/dist/define-initializer.d.ts.map +0 -1
- package/dist/define-initializer.js +0 -38
- package/dist/define-initializer.js.map +0 -1
- package/dist/define-middleware.d.ts +0 -8
- package/dist/define-middleware.d.ts.map +0 -1
- package/dist/define-middleware.js +0 -8
- package/dist/define-middleware.js.map +0 -1
- package/dist/define-model.d.ts +0 -10
- package/dist/define-model.d.ts.map +0 -1
- package/dist/define-model.js +0 -13
- package/dist/define-model.js.map +0 -1
- package/dist/define-module.d.ts +0 -160
- package/dist/define-module.d.ts.map +0 -1
- package/dist/define-module.js +0 -63
- package/dist/define-module.js.map +0 -1
- package/dist/define-outbox.d.ts.map +0 -1
- package/dist/define-outbox.js.map +0 -1
- package/dist/define-plugin.d.ts +0 -195
- package/dist/define-plugin.d.ts.map +0 -1
- package/dist/define-plugin.js +0 -220
- package/dist/define-plugin.js.map +0 -1
- package/dist/define-projection.d.ts.map +0 -1
- package/dist/define-projection.js.map +0 -1
- package/dist/define-provider.d.ts +0 -49
- package/dist/define-provider.d.ts.map +0 -1
- package/dist/define-provider.js +0 -45
- package/dist/define-provider.js.map +0 -1
- package/dist/define-query.d.ts.map +0 -1
- package/dist/define-query.js.map +0 -1
- package/dist/define-resolver.d.ts +0 -111
- package/dist/define-resolver.d.ts.map +0 -1
- package/dist/define-resolver.js +0 -146
- package/dist/define-resolver.js.map +0 -1
- package/dist/define-schema.d.ts.map +0 -1
- package/dist/define-schema.js.map +0 -1
- package/dist/define-upcaster.d.ts.map +0 -1
- package/dist/define-upcaster.js.map +0 -1
- package/dist/define-workflow.d.ts.map +0 -1
- package/dist/define-workflow.js.map +0 -1
- package/dist/dev-logger.d.ts.map +0 -1
- package/dist/dev-logger.js.map +0 -1
- package/dist/event-message.d.ts.map +0 -1
- package/dist/event-message.js.map +0 -1
- package/dist/foundation.d.ts.map +0 -1
- package/dist/foundation.js.map +0 -1
- package/dist/framework-event-bus.d.ts +0 -13
- package/dist/framework-event-bus.d.ts.map +0 -1
- package/dist/framework-event-bus.js +0 -13
- package/dist/framework-event-bus.js.map +0 -1
- package/dist/framework-events.d.ts.map +0 -1
- package/dist/framework-events.js.map +0 -1
- package/dist/idempotency-store.d.ts.map +0 -1
- package/dist/idempotency-store.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/module-surface.d.ts +0 -47
- package/dist/module-surface.d.ts.map +0 -1
- package/dist/module-surface.js +0 -65
- package/dist/module-surface.js.map +0 -1
- package/dist/projection-store.d.ts.map +0 -1
- package/dist/projection-store.js.map +0 -1
- package/dist/public-marker.d.ts.map +0 -1
- package/dist/public-marker.js.map +0 -1
- package/dist/response.d.ts.map +0 -1
- package/dist/response.js.map +0 -1
- package/dist/runtime.d.ts +0 -621
- package/dist/runtime.d.ts.map +0 -1
- package/dist/runtime.js.map +0 -1
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js.map +0 -1
- package/dist/when.d.ts +0 -101
- package/dist/when.d.ts.map +0 -1
- package/dist/when.js +0 -57
- package/dist/when.js.map +0 -1
- package/dist/workflow-timer-store.d.ts.map +0 -1
- package/dist/workflow-timer-store.js.map +0 -1
|
@@ -1,60 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* `ForgeDispatcher` — the CQRS state container that forge layers on top of
|
|
3
|
+
* the kernel runtime.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* another action with a derived child envelope. Returns the inner
|
|
13
|
-
* handler's result.
|
|
5
|
+
* Stage 1 of the forge-as-plugin refactor: pull all the registries, stores,
|
|
6
|
+
* and per-domain hook maps out of `forge.Runtime` into a single composable
|
|
7
|
+
* object. `forge.Runtime` keeps its public methods (`dispatch`, `publish`,
|
|
8
|
+
* `registerHandler`, etc.) and reads from this dispatcher; Stage 2 moves
|
|
9
|
+
* the methods here too and ships a `forgePlugin` that constructs a
|
|
10
|
+
* dispatcher and binds it into the container — at which point
|
|
11
|
+
* `forge.Runtime` disappears entirely and any `kernel.Runtime` becomes
|
|
12
|
+
* forge-capable by installing the plugin.
|
|
14
13
|
*
|
|
15
|
-
* The
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* on the adapter's transaction primitives).
|
|
19
|
-
* - Events fan out to actors first, then workflow reactions, then
|
|
20
|
-
* projections.
|
|
14
|
+
* The dispatcher holds a reference to its kernel `Runtime` so per-domain
|
|
15
|
+
* hooks can be created via `runtime.observe(h)` and telemetry pushed via
|
|
16
|
+
* `runtime.emit(rec)` without subclassing.
|
|
21
17
|
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { serializeError } from "@nwire/app";
|
|
22
20
|
import { hook } from "@nwire/hooks";
|
|
23
|
-
import { Runtime as RuntimeBase, serializeError, } from "@nwire/app";
|
|
24
21
|
import { isValidated, markValidated } from "@nwire/messages";
|
|
25
22
|
import { seedEnvelope, deriveEnvelope } from "@nwire/envelope";
|
|
26
|
-
import { InMemoryWorkflowTimerStore, timerEventName, } from "./workflow-timer-store.js";
|
|
27
|
-
import { randomUUID } from "node:crypto";
|
|
28
|
-
import { InMemoryActorStore, createInitialInstance, ActorVersionConflictError, } from "./actor-store.js";
|
|
29
|
-
import { normalizeEventReturn } from "./event-message.js";
|
|
30
|
-
import { InMemoryProjectionStore } from "./projection-store.js";
|
|
31
|
-
import { InMemoryIdempotencyStore } from "./idempotency-store.js";
|
|
32
23
|
import { loggerForEnvelope } from "@nwire/logger";
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
|
|
24
|
+
import { buildDeadLetterEntry, InMemoryDeadLetterSink } from "@nwire/dead-letter";
|
|
25
|
+
import { sleep, computeBackoff, parseDelay } from "../helpers/retry-helpers.js";
|
|
26
|
+
import { InMemoryActorStore, createInitialInstance, ActorVersionConflictError, } from "../stores/actor-store.js";
|
|
27
|
+
import { InMemoryProjectionStore } from "../stores/projection-store.js";
|
|
28
|
+
import { InMemoryIdempotencyStore } from "../stores/idempotency-store.js";
|
|
29
|
+
import { InMemoryWorkflowTimerStore, timerEventName, } from "../stores/workflow-timer-store.js";
|
|
30
|
+
import { normalizeEventReturn } from "../messages/event-message.js";
|
|
31
|
+
export class ForgeDispatcher {
|
|
32
|
+
runtime;
|
|
33
|
+
// ─── Domain registries ──────────────────────────────────────────────
|
|
36
34
|
handlers = new Map();
|
|
37
35
|
actors = new Map();
|
|
38
|
-
/**
|
|
39
|
-
* Workflows keyed by event name. Built from each registered workflow's
|
|
40
|
-
* `subscribedEvents`. On publish, every workflow listening to an event
|
|
41
|
-
* gets `_fire`d with runtime-bound effects.
|
|
42
|
-
*/
|
|
43
36
|
workflowsByEvent = new Map();
|
|
44
37
|
/**
|
|
45
38
|
* Per-workflow instance state — keyed first by workflow name, then by
|
|
46
|
-
* correlation key. In-memory for
|
|
47
|
-
*
|
|
39
|
+
* correlation key. In-memory for now; durable adapters plug in via a
|
|
40
|
+
* future WorkflowStore contract analogous to ActorStore.
|
|
48
41
|
*/
|
|
49
42
|
workflowInstances = new Map();
|
|
50
|
-
workflowInstanceStore(workflowName) {
|
|
51
|
-
let store = this.workflowInstances.get(workflowName);
|
|
52
|
-
if (!store) {
|
|
53
|
-
store = new Map();
|
|
54
|
-
this.workflowInstances.set(workflowName, store);
|
|
55
|
-
}
|
|
56
|
-
return store;
|
|
57
|
-
}
|
|
58
43
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
44
|
projections = new Map();
|
|
60
45
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -65,187 +50,85 @@ export class Runtime extends RuntimeBase {
|
|
|
65
50
|
externalCalls = new Map();
|
|
66
51
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
52
|
externalCallExecutors = new Map();
|
|
68
|
-
|
|
69
|
-
projectionStore;
|
|
70
|
-
deadLetterSink;
|
|
53
|
+
// ─── Per-domain hooks ───────────────────────────────────────────────
|
|
71
54
|
/**
|
|
72
55
|
* Per-action `action.before:<name>` hooks. Pre-created at
|
|
73
56
|
* `registerHandler()` so they show up in `listHooks()` + scan + Studio
|
|
74
|
-
* even before any plugin's `before(name, …)` sugar runs.
|
|
75
|
-
* the canonical telemetry stream so each chain step emits `hook.step
|
|
76
|
-
* observations.
|
|
57
|
+
* even before any plugin's `before(name, …)` sugar runs. Observed into
|
|
58
|
+
* the canonical telemetry stream so each chain step emits `hook.step`.
|
|
77
59
|
*/
|
|
78
60
|
actionBeforeHooks = new Map();
|
|
79
61
|
/** Per-action `action.after:<name>` hooks. Pre-created identically. */
|
|
80
62
|
actionAfterHooks = new Map();
|
|
81
|
-
/**
|
|
82
|
-
* Per-actor `actor.transition:<name>` hooks. Pre-created at
|
|
83
|
-
* `registerActor()` time. Adopted into the canonical telemetry stream
|
|
84
|
-
* so each chain step emits `hook.step` observations — same model as
|
|
85
|
-
* `actionBeforeHooks` / `actionAfterHooks`.
|
|
86
|
-
*/
|
|
63
|
+
/** Per-actor `actor.transition:<name>` hooks. Pre-created at `registerActor()`. */
|
|
87
64
|
perActorHooks = new Map();
|
|
88
|
-
/** Per-workflow `workflow.fire:<name>` hooks. Pre-created at
|
|
65
|
+
/** Per-workflow `workflow.fire:<name>` hooks. Pre-created at `registerWorkflow()`. */
|
|
89
66
|
perWorkflowHooks = new Map();
|
|
67
|
+
/** Legacy plugin-supplied actor-transition listeners — runs after the per-actor hook. */
|
|
90
68
|
actorTransitionHooks = [];
|
|
69
|
+
// ─── Persistence + cross-service seams ───────────────────────────────
|
|
70
|
+
actorStore;
|
|
71
|
+
projectionStore;
|
|
72
|
+
deadLetterSink;
|
|
73
|
+
idempotencyStore;
|
|
74
|
+
workflowTimerStore;
|
|
91
75
|
bus;
|
|
92
76
|
publishToBus;
|
|
93
|
-
|
|
77
|
+
// ─── Module-derived metadata ────────────────────────────────────────
|
|
78
|
+
/** Known external events — populated by createApp from modules' needs.externalEvents. */
|
|
94
79
|
externalEventNames = new Set();
|
|
95
|
-
/** Public-event names (visibility: 'public') —
|
|
80
|
+
/** Public-event names (visibility: 'public') — populated by createApp from modules' events. */
|
|
96
81
|
publicEventNames = new Set();
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
/** Envelope-level dedup table — see RuntimeOptions.idempotencyStore. */
|
|
100
|
-
idempotencyStore;
|
|
101
|
-
constructor(options = {}) {
|
|
102
|
-
// Container / logger / appName / frameworkEvents / dispatchHook / use /
|
|
103
|
-
// adoptHook / onTelemetry / offTelemetry / emit / getContainer are all
|
|
104
|
-
// owned by the base. Forge layers the CQRS engine on top.
|
|
105
|
-
super({
|
|
106
|
-
container: options.container,
|
|
107
|
-
logger: options.logger,
|
|
108
|
-
appName: options.appName,
|
|
109
|
-
events: builtInFrameworkEvents,
|
|
110
|
-
});
|
|
82
|
+
constructor(runtime, options = {}) {
|
|
83
|
+
this.runtime = runtime;
|
|
111
84
|
this.actorStore = options.actorStore ?? new InMemoryActorStore();
|
|
112
85
|
this.projectionStore = options.projectionStore ?? new InMemoryProjectionStore();
|
|
113
86
|
this.deadLetterSink = options.deadLetterSink ?? new InMemoryDeadLetterSink();
|
|
87
|
+
this.idempotencyStore = options.idempotencyStore ?? new InMemoryIdempotencyStore();
|
|
88
|
+
this.workflowTimerStore = options.workflowTimerStore ?? new InMemoryWorkflowTimerStore();
|
|
114
89
|
this.bus = options.bus;
|
|
115
90
|
this.publishToBus = options.publishToBus ?? false;
|
|
116
|
-
this.workflowTimerStore = options.workflowTimerStore ?? new InMemoryWorkflowTimerStore();
|
|
117
|
-
this.idempotencyStore = options.idempotencyStore ?? new InMemoryIdempotencyStore();
|
|
118
|
-
// Pin the innermost dispatch step that calls forge's per-action retry +
|
|
119
|
-
// handler-invocation + event-publishing closure. priority `-Infinity`
|
|
120
|
-
// keeps user `runtime.use()` middleware strictly outside it.
|
|
121
|
-
this.dispatchHook.use(async (hctx, next) => {
|
|
122
|
-
hctx.result = await hctx.coreFn();
|
|
123
|
-
await next();
|
|
124
|
-
}, { name: "handler", priority: -Infinity });
|
|
125
91
|
}
|
|
126
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return
|
|
134
|
-
}
|
|
135
|
-
offTelemetry(listener) {
|
|
136
|
-
super.offTelemetry(listener);
|
|
92
|
+
/** Get-or-create the per-workflow instance store. */
|
|
93
|
+
workflowInstanceStore(workflowName) {
|
|
94
|
+
let store = this.workflowInstances.get(workflowName);
|
|
95
|
+
if (!store) {
|
|
96
|
+
store = new Map();
|
|
97
|
+
this.workflowInstances.set(workflowName, store);
|
|
98
|
+
}
|
|
99
|
+
return store;
|
|
137
100
|
}
|
|
101
|
+
// ─── Registration ──────────────────────────────────────────────────
|
|
138
102
|
/**
|
|
139
|
-
* Register a
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* dispatcher pushes through `dispatchHook.run(...)`.
|
|
103
|
+
* Register a forge action handler (ActionDefinition + handler closure).
|
|
104
|
+
* Distinct from kernel.Runtime.registerHandler, which registers the
|
|
105
|
+
* canonical @nwire/handler HandlerDefinition. Forge actions carry
|
|
106
|
+
* retry + event-publishing metadata that the bare kernel doesn't know
|
|
107
|
+
* how to apply.
|
|
145
108
|
*/
|
|
146
|
-
use(middleware) {
|
|
147
|
-
super.use(middleware);
|
|
148
|
-
}
|
|
149
|
-
/** Internal — createApp registers known external event names. */
|
|
150
|
-
registerExternalEvent(eventName) {
|
|
151
|
-
this.externalEventNames.add(eventName);
|
|
152
|
-
}
|
|
153
|
-
/** Internal — createApp registers public event names (visibility: 'public'). */
|
|
154
|
-
registerPublicEvent(eventName) {
|
|
155
|
-
this.publicEventNames.add(eventName);
|
|
156
|
-
}
|
|
157
|
-
/** Internal — createApp registers actor-transition hooks from plugins. */
|
|
158
|
-
registerActorTransitionHook(hook) {
|
|
159
|
-
this.actorTransitionHooks.push(hook);
|
|
160
|
-
}
|
|
161
|
-
// ─── Registration ────────────────────────────────────────────────
|
|
162
109
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
-
|
|
110
|
+
registerActionHandler(handler) {
|
|
164
111
|
const name = handler.action.name;
|
|
165
112
|
if (this.handlers.has(name)) {
|
|
166
113
|
throw new Error(`Runtime: handler for action "${name}" already registered.`);
|
|
167
114
|
}
|
|
168
115
|
this.handlers.set(name, handler);
|
|
169
|
-
// Pre-create the per-action observation hooks so `listHooks()` + scan +
|
|
170
|
-
// Studio show every action's before/after extension points whether or
|
|
171
|
-
// not a plugin has subscribed yet. Lazy-on-first-subscribe would hide
|
|
172
|
-
// them from static introspection.
|
|
173
116
|
this.ensureActionBeforeHook(name);
|
|
174
117
|
this.ensureActionAfterHook(name);
|
|
175
118
|
}
|
|
176
|
-
/**
|
|
177
|
-
* Get (or lazily create + adopt) the per-action `action.before:<name>`
|
|
178
|
-
* hook. Plugin authors normally reach this via `plugin.before(name, …)`;
|
|
179
|
-
* this method is also public so test code + custom orchestrators can
|
|
180
|
-
* register chain steps or taps directly.
|
|
181
|
-
*/
|
|
182
|
-
ensureActionBeforeHook(actionName) {
|
|
183
|
-
let h = this.actionBeforeHooks.get(actionName);
|
|
184
|
-
if (h)
|
|
185
|
-
return h;
|
|
186
|
-
h = hook(`action.before:${actionName}`);
|
|
187
|
-
this.actionBeforeHooks.set(actionName, h);
|
|
188
|
-
this.adoptHook(h);
|
|
189
|
-
return h;
|
|
190
|
-
}
|
|
191
|
-
/** Get (or lazily create + adopt) the per-action `action.after:<name>` hook. */
|
|
192
|
-
ensureActionAfterHook(actionName) {
|
|
193
|
-
let h = this.actionAfterHooks.get(actionName);
|
|
194
|
-
if (h)
|
|
195
|
-
return h;
|
|
196
|
-
h = hook(`action.after:${actionName}`);
|
|
197
|
-
this.actionAfterHooks.set(actionName, h);
|
|
198
|
-
this.adoptHook(h);
|
|
199
|
-
return h;
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Get (or lazily create + adopt) the per-actor `actor.transition:<name>`
|
|
203
|
-
* hook. Plugin authors reach this via `runtime.ensureActorTransitionHook(name)`
|
|
204
|
-
* to attach chain steps or taps; the dispatcher runs the hook after the
|
|
205
|
-
* actor's state has been saved.
|
|
206
|
-
*/
|
|
207
|
-
ensureActorTransitionHook(actorName) {
|
|
208
|
-
let h = this.perActorHooks.get(actorName);
|
|
209
|
-
if (h)
|
|
210
|
-
return h;
|
|
211
|
-
h = hook(`actor.transition:${actorName}`);
|
|
212
|
-
this.perActorHooks.set(actorName, h);
|
|
213
|
-
this.adoptHook(h);
|
|
214
|
-
return h;
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Get (or lazily create + adopt) the per-workflow `workflow.fire:<name>`
|
|
218
|
-
* hook. Runs around every workflow invocation triggered by a subscribed
|
|
219
|
-
* event — see `runWorkflows`.
|
|
220
|
-
*/
|
|
221
|
-
ensureWorkflowFireHook(workflowName) {
|
|
222
|
-
let h = this.perWorkflowHooks.get(workflowName);
|
|
223
|
-
if (h)
|
|
224
|
-
return h;
|
|
225
|
-
h = hook(`workflow.fire:${workflowName}`);
|
|
226
|
-
this.perWorkflowHooks.set(workflowName, h);
|
|
227
|
-
this.adoptHook(h);
|
|
228
|
-
return h;
|
|
229
|
-
}
|
|
230
119
|
registerActor(actor) {
|
|
231
120
|
if (this.actors.has(actor.name)) {
|
|
232
121
|
throw new Error(`Runtime: actor "${actor.name}" already registered.`);
|
|
233
122
|
}
|
|
234
123
|
this.actors.set(actor.name, actor);
|
|
235
|
-
// Pre-create the observation hook so scan + listHooks() + Studio see
|
|
236
|
-
// every actor's transition extension point even before any plugin
|
|
237
|
-
// subscribes.
|
|
238
124
|
this.ensureActorTransitionHook(actor.name);
|
|
239
125
|
}
|
|
240
|
-
/** Internal — createApp registers each module's workflows. */
|
|
241
126
|
registerWorkflow(workflow) {
|
|
242
127
|
for (const eventName of workflow.subscribedEvents) {
|
|
243
128
|
const list = this.workflowsByEvent.get(eventName) ?? [];
|
|
244
129
|
list.push(workflow);
|
|
245
130
|
this.workflowsByEvent.set(eventName, list);
|
|
246
131
|
}
|
|
247
|
-
// Same eager-creation rationale as registerActor — surface workflow
|
|
248
|
-
// fire extension points statically.
|
|
249
132
|
this.ensureWorkflowFireHook(workflow.name);
|
|
250
133
|
}
|
|
251
134
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -267,11 +150,6 @@ export class Runtime extends RuntimeBase {
|
|
|
267
150
|
}
|
|
268
151
|
this.queries.set(query.name, query);
|
|
269
152
|
}
|
|
270
|
-
/**
|
|
271
|
-
* Register an external-call declaration. Modules announce their external
|
|
272
|
-
* calls so Studio + the static graph see them; wires (adapters) call
|
|
273
|
-
* `registerExternalCallExecutor` to provide the transport.
|
|
274
|
-
*/
|
|
275
153
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
276
154
|
registerExternalCall(def) {
|
|
277
155
|
if (this.externalCalls.has(def.name)) {
|
|
@@ -279,24 +157,68 @@ export class Runtime extends RuntimeBase {
|
|
|
279
157
|
}
|
|
280
158
|
this.externalCalls.set(def.name, def);
|
|
281
159
|
}
|
|
282
|
-
|
|
283
|
-
* Bind an executor (HTTP client / SDK wrapper / test mock) to a declared
|
|
284
|
-
* external call. Lookup is by `def.name`. Called by wires/adapters at
|
|
285
|
-
* boot. Idempotent: re-registering replaces the executor (useful for
|
|
286
|
-
* swapping mocks in tests).
|
|
287
|
-
*/
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
288
161
|
registerExternalCallExecutor(def, executor) {
|
|
289
162
|
this.externalCallExecutors.set(def.name, executor);
|
|
290
163
|
}
|
|
164
|
+
registerExternalEvent(eventName) {
|
|
165
|
+
this.externalEventNames.add(eventName);
|
|
166
|
+
}
|
|
167
|
+
registerPublicEvent(eventName) {
|
|
168
|
+
this.publicEventNames.add(eventName);
|
|
169
|
+
}
|
|
170
|
+
registerActorTransitionHook(listener) {
|
|
171
|
+
this.actorTransitionHooks.push(listener);
|
|
172
|
+
}
|
|
173
|
+
// ─── Per-domain hook factories ─────────────────────────────────────
|
|
174
|
+
/** Get-or-create the per-action `action.before:<name>` hook, observed for telemetry. */
|
|
175
|
+
ensureActionBeforeHook(actionName) {
|
|
176
|
+
let h = this.actionBeforeHooks.get(actionName);
|
|
177
|
+
if (h)
|
|
178
|
+
return h;
|
|
179
|
+
h = hook(`action.before:${actionName}`);
|
|
180
|
+
this.actionBeforeHooks.set(actionName, h);
|
|
181
|
+
this.runtime.observe(h);
|
|
182
|
+
return h;
|
|
183
|
+
}
|
|
184
|
+
ensureActionAfterHook(actionName) {
|
|
185
|
+
let h = this.actionAfterHooks.get(actionName);
|
|
186
|
+
if (h)
|
|
187
|
+
return h;
|
|
188
|
+
h = hook(`action.after:${actionName}`);
|
|
189
|
+
this.actionAfterHooks.set(actionName, h);
|
|
190
|
+
this.runtime.observe(h);
|
|
191
|
+
return h;
|
|
192
|
+
}
|
|
193
|
+
ensureActorTransitionHook(actorName) {
|
|
194
|
+
let h = this.perActorHooks.get(actorName);
|
|
195
|
+
if (h)
|
|
196
|
+
return h;
|
|
197
|
+
h = hook(`actor.transition:${actorName}`);
|
|
198
|
+
this.perActorHooks.set(actorName, h);
|
|
199
|
+
this.runtime.observe(h);
|
|
200
|
+
return h;
|
|
201
|
+
}
|
|
202
|
+
ensureWorkflowFireHook(workflowName) {
|
|
203
|
+
let h = this.perWorkflowHooks.get(workflowName);
|
|
204
|
+
if (h)
|
|
205
|
+
return h;
|
|
206
|
+
h = hook(`workflow.fire:${workflowName}`);
|
|
207
|
+
this.perWorkflowHooks.set(workflowName, h);
|
|
208
|
+
this.runtime.observe(h);
|
|
209
|
+
return h;
|
|
210
|
+
}
|
|
211
|
+
// ─── External calls ────────────────────────────────────────────────
|
|
291
212
|
async executeExternalCall(def, request, envelope) {
|
|
292
213
|
const validated = def.request.parse(request);
|
|
293
214
|
const executor = this.externalCallExecutors.get(def.name);
|
|
294
215
|
const idempotencyKey = def.idempotencyKey?.(validated);
|
|
295
216
|
const target = `${def.target.provider}/${def.target.endpoint}`;
|
|
217
|
+
const appName = this.runtime.appName;
|
|
296
218
|
if (!executor) {
|
|
297
219
|
const err = new Error(`Runtime.externalCall: no executor registered for "${def.name}". ` +
|
|
298
220
|
`Wires/adapters must call runtime.registerExternalCallExecutor() at boot.`);
|
|
299
|
-
this.
|
|
221
|
+
this.runtime.pushTelemetry({
|
|
300
222
|
kind: "external.call.failed",
|
|
301
223
|
call: def.name,
|
|
302
224
|
target,
|
|
@@ -304,7 +226,7 @@ export class Runtime extends RuntimeBase {
|
|
|
304
226
|
willRetry: false,
|
|
305
227
|
error: serializeError(err),
|
|
306
228
|
envelope,
|
|
307
|
-
appName
|
|
229
|
+
appName,
|
|
308
230
|
ts: new Date().toISOString(),
|
|
309
231
|
});
|
|
310
232
|
throw err;
|
|
@@ -315,13 +237,13 @@ export class Runtime extends RuntimeBase {
|
|
|
315
237
|
let lastError;
|
|
316
238
|
while (attempt < maxAttempts) {
|
|
317
239
|
attempt++;
|
|
318
|
-
this.
|
|
240
|
+
this.runtime.pushTelemetry({
|
|
319
241
|
kind: "external.call.started",
|
|
320
242
|
call: def.name,
|
|
321
243
|
target,
|
|
322
244
|
idempotencyKey,
|
|
323
245
|
envelope,
|
|
324
|
-
appName
|
|
246
|
+
appName,
|
|
325
247
|
ts: new Date().toISOString(),
|
|
326
248
|
});
|
|
327
249
|
const t0 = performance.now();
|
|
@@ -333,21 +255,21 @@ export class Runtime extends RuntimeBase {
|
|
|
333
255
|
}
|
|
334
256
|
const raw = await executor(validated, { idempotencyKey, attempt });
|
|
335
257
|
const response = def.response ? def.response.parse(raw) : raw;
|
|
336
|
-
this.
|
|
258
|
+
this.runtime.pushTelemetry({
|
|
337
259
|
kind: "external.call.completed",
|
|
338
260
|
call: def.name,
|
|
339
261
|
target,
|
|
340
262
|
durationMs: performance.now() - t0,
|
|
341
263
|
idempotencyKey,
|
|
342
264
|
envelope,
|
|
343
|
-
appName
|
|
265
|
+
appName,
|
|
344
266
|
ts: new Date().toISOString(),
|
|
345
267
|
});
|
|
346
268
|
return response;
|
|
347
269
|
}
|
|
348
270
|
catch (err) {
|
|
349
271
|
lastError = err;
|
|
350
|
-
this.
|
|
272
|
+
this.runtime.pushTelemetry({
|
|
351
273
|
kind: "external.call.failed",
|
|
352
274
|
call: def.name,
|
|
353
275
|
target,
|
|
@@ -355,14 +277,55 @@ export class Runtime extends RuntimeBase {
|
|
|
355
277
|
willRetry: attempt < maxAttempts,
|
|
356
278
|
error: serializeError(err),
|
|
357
279
|
envelope,
|
|
358
|
-
appName
|
|
280
|
+
appName,
|
|
359
281
|
ts: new Date().toISOString(),
|
|
360
282
|
});
|
|
361
283
|
}
|
|
362
284
|
}
|
|
363
285
|
throw lastError;
|
|
364
286
|
}
|
|
365
|
-
// ───
|
|
287
|
+
// ─── Introspection ──────────────────────────────────────────────────
|
|
288
|
+
listHandlers() {
|
|
289
|
+
return [...this.handlers.keys()];
|
|
290
|
+
}
|
|
291
|
+
listActors() {
|
|
292
|
+
return [...this.actors.keys()];
|
|
293
|
+
}
|
|
294
|
+
listProjections() {
|
|
295
|
+
return [...this.projections.keys()];
|
|
296
|
+
}
|
|
297
|
+
listQueries() {
|
|
298
|
+
return [...this.queries.keys()];
|
|
299
|
+
}
|
|
300
|
+
listExternalCalls() {
|
|
301
|
+
return [...this.externalCalls.keys()];
|
|
302
|
+
}
|
|
303
|
+
/** Deduplicated list of every registered workflow definition. */
|
|
304
|
+
listWorkflows() {
|
|
305
|
+
const seen = new Set();
|
|
306
|
+
for (const list of this.workflowsByEvent.values()) {
|
|
307
|
+
for (const w of list)
|
|
308
|
+
seen.add(w);
|
|
309
|
+
}
|
|
310
|
+
return [...seen];
|
|
311
|
+
}
|
|
312
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
313
|
+
getExternalCall(name) {
|
|
314
|
+
return this.externalCalls.get(name);
|
|
315
|
+
}
|
|
316
|
+
/** Resolve a handler definition by action name. */
|
|
317
|
+
findHandler(name) {
|
|
318
|
+
return this.handlers.get(name);
|
|
319
|
+
}
|
|
320
|
+
/** Resolve an action by routing name — useful when only the name is known. */
|
|
321
|
+
findActionByName(name) {
|
|
322
|
+
return this.handlers.get(name)?.action;
|
|
323
|
+
}
|
|
324
|
+
/** Has a handler been registered for this action name? */
|
|
325
|
+
hasHandler(name) {
|
|
326
|
+
return this.handlers.has(name);
|
|
327
|
+
}
|
|
328
|
+
// ─── Query execution ────────────────────────────────────────────────
|
|
366
329
|
async query(queryName, input, tenant = "") {
|
|
367
330
|
const query = this.queries.get(queryName);
|
|
368
331
|
if (!query) {
|
|
@@ -370,19 +333,17 @@ export class Runtime extends RuntimeBase {
|
|
|
370
333
|
}
|
|
371
334
|
const t0 = performance.now();
|
|
372
335
|
const validated = isValidated(input) ? input : markValidated(query.schema.parse(input));
|
|
336
|
+
const container = this.runtime.getContainer();
|
|
337
|
+
const appName = this.runtime.appName;
|
|
373
338
|
let result;
|
|
374
339
|
if (query.projection && query.execute) {
|
|
375
|
-
// Projection form — load state, hand it to `execute`.
|
|
376
340
|
const state = (await this.projectionStore.load(query.projection.name, tenant)) ??
|
|
377
341
|
query.projection.initial();
|
|
378
342
|
result = (await query.execute(state, validated));
|
|
379
343
|
}
|
|
380
344
|
else if (query.handler) {
|
|
381
|
-
// Handler form — no projection, hand a QueryContext (DI + tenant +
|
|
382
|
-
// signal) to the user's reader. Lets queries read from any source
|
|
383
|
-
// (Postgres, Redis, search) without a projection layer.
|
|
384
345
|
result = (await query.handler(validated, {
|
|
385
|
-
resolve: (name) =>
|
|
346
|
+
resolve: (name) => container.resolve(name),
|
|
386
347
|
tenant,
|
|
387
348
|
}));
|
|
388
349
|
}
|
|
@@ -390,59 +351,21 @@ export class Runtime extends RuntimeBase {
|
|
|
390
351
|
throw new Error(`Runtime: query "${queryName}" has neither projection+execute nor a handler — ` +
|
|
391
352
|
`the runtime cannot route the call. defineQuery must be given one or the other.`);
|
|
392
353
|
}
|
|
393
|
-
this.
|
|
354
|
+
this.runtime.pushTelemetry({
|
|
394
355
|
kind: "query.executed",
|
|
395
356
|
query: queryName,
|
|
396
357
|
input: validated,
|
|
397
358
|
durationMs: performance.now() - t0,
|
|
398
359
|
tenant,
|
|
399
|
-
appName
|
|
360
|
+
appName,
|
|
400
361
|
ts: new Date().toISOString(),
|
|
401
362
|
});
|
|
402
363
|
return result;
|
|
403
364
|
}
|
|
404
|
-
// ───
|
|
405
|
-
getActorStore() {
|
|
406
|
-
return this.actorStore;
|
|
407
|
-
}
|
|
408
|
-
getProjectionStore() {
|
|
409
|
-
return this.projectionStore;
|
|
410
|
-
}
|
|
411
|
-
listHandlers() {
|
|
412
|
-
return [...this.handlers.keys()];
|
|
413
|
-
}
|
|
414
|
-
listActors() {
|
|
415
|
-
return [...this.actors.keys()];
|
|
416
|
-
}
|
|
417
|
-
listProjections() {
|
|
418
|
-
return [...this.projections.keys()];
|
|
419
|
-
}
|
|
420
|
-
listQueries() {
|
|
421
|
-
return [...this.queries.keys()];
|
|
422
|
-
}
|
|
423
|
-
listExternalCalls() {
|
|
424
|
-
return [...this.externalCalls.keys()];
|
|
425
|
-
}
|
|
426
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
427
|
-
getExternalCall(name) {
|
|
428
|
-
return this.externalCalls.get(name);
|
|
429
|
-
}
|
|
430
|
-
// ─── Timer firing (durable scheduler primitive) ──────────────────
|
|
431
|
-
/**
|
|
432
|
-
* Walk every registered actor's instances; for each `activeTimers[name]`
|
|
433
|
-
* with `fireAt <= now`, dispatch the timer's action. Removes fired timers
|
|
434
|
-
* from the instance's `activeTimers` map.
|
|
435
|
-
*
|
|
436
|
-
* Returns the count of timers that fired. Idempotent: a timer's `fireAt`
|
|
437
|
-
* is not bumped, so a second call after `now` advances will not re-fire
|
|
438
|
-
* the same timer (it was removed).
|
|
439
|
-
*
|
|
440
|
-
* For tests: pass `now` to fast-forward (`runtime.fireDueTimers(Date.now() + 3 * 86400_000)`).
|
|
441
|
-
* For production: a transport (BullMQ, polling worker) calls this on an
|
|
442
|
-
* interval; see `startTimerScheduler(app, intervalMs)` in `create-app.ts`.
|
|
443
|
-
*/
|
|
365
|
+
// ─── Timer firing ──────────────────────────────────────────────────
|
|
444
366
|
async fireDueTimers(now = Date.now()) {
|
|
445
367
|
let fired = 0;
|
|
368
|
+
const appName = this.runtime.appName;
|
|
446
369
|
for (const actor of this.actors.values()) {
|
|
447
370
|
const instances = await this.actorStore.listInstances(actor.name);
|
|
448
371
|
for (const instance of instances) {
|
|
@@ -454,30 +377,18 @@ export class Runtime extends RuntimeBase {
|
|
|
454
377
|
}
|
|
455
378
|
if (due.length === 0)
|
|
456
379
|
continue;
|
|
457
|
-
// Remove fired timers BEFORE dispatching, so handlers that
|
|
458
|
-
// re-enter `fireDueTimers` (rare but possible) don't see them.
|
|
459
380
|
const remainingTimers = {};
|
|
460
381
|
for (const [name, handle] of Object.entries(instance.activeTimers)) {
|
|
461
382
|
if (handle.fireAt > now)
|
|
462
383
|
remainingTimers[name] = handle;
|
|
463
384
|
}
|
|
464
|
-
await this.actorStore.save({
|
|
465
|
-
|
|
466
|
-
activeTimers: remainingTimers,
|
|
467
|
-
});
|
|
468
|
-
// Dispatch each fired timer's action in the actor's tenant
|
|
469
|
-
// scope so the handler chain stays inside the right partition.
|
|
470
|
-
const tenantEnvelope = seedEnvelope({
|
|
471
|
-
tenant: instance.tenant || undefined,
|
|
472
|
-
});
|
|
385
|
+
await this.actorStore.save({ ...instance, activeTimers: remainingTimers });
|
|
386
|
+
const tenantEnvelope = seedEnvelope({ tenant: instance.tenant || undefined });
|
|
473
387
|
for (const [timerName, handle] of due) {
|
|
474
388
|
const action = this.findActionByName(handle.action);
|
|
475
|
-
if (!action)
|
|
476
|
-
// Action not registered — skip gracefully; the timer
|
|
477
|
-
// fired but its target is gone.
|
|
389
|
+
if (!action)
|
|
478
390
|
continue;
|
|
479
|
-
|
|
480
|
-
this.emit({
|
|
391
|
+
this.runtime.pushTelemetry({
|
|
481
392
|
kind: "timer.fired",
|
|
482
393
|
actor: actor.name,
|
|
483
394
|
key: instance.key,
|
|
@@ -485,7 +396,7 @@ export class Runtime extends RuntimeBase {
|
|
|
485
396
|
action: handle.action,
|
|
486
397
|
lateByMs: Math.max(0, now - handle.fireAt),
|
|
487
398
|
tenant: instance.tenant,
|
|
488
|
-
appName
|
|
399
|
+
appName,
|
|
489
400
|
ts: new Date().toISOString(),
|
|
490
401
|
});
|
|
491
402
|
await this.dispatch(action, handle.input, tenantEnvelope);
|
|
@@ -495,100 +406,48 @@ export class Runtime extends RuntimeBase {
|
|
|
495
406
|
}
|
|
496
407
|
return fired;
|
|
497
408
|
}
|
|
498
|
-
/**
|
|
499
|
-
* Resolve an action definition by its routing name. Used by execute/send
|
|
500
|
-
* to support the callable form `execute(myAction(input))` — the
|
|
501
|
-
* `CommandMessage` carries only the name; the runtime looks up the actual
|
|
502
|
-
* action from the registered handler map.
|
|
503
|
-
*/
|
|
504
|
-
findActionByName(name) {
|
|
505
|
-
const handler = this.handlers.get(name);
|
|
506
|
-
return handler?.action;
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
509
|
-
* Drain every due saga timer from the workflow timer store and route
|
|
510
|
-
* each as a synthetic event back into the originating workflow. The
|
|
511
|
-
* store removes drained timers atomically (see `WorkflowTimerStore`
|
|
512
|
-
* contract); calling `fireDueWorkflowTimers` twice with the same `now`
|
|
513
|
-
* MUST be a no-op the second time.
|
|
514
|
-
*
|
|
515
|
-
* Returns the count of timers fired.
|
|
516
|
-
*
|
|
517
|
-
* Tests: pass `now` to fast-forward
|
|
518
|
-
* (`runtime.fireDueWorkflowTimers(new Date(Date.now() + 8 * 86400_000))`).
|
|
519
|
-
* Production: the same polling loop that calls `fireDueTimers` calls
|
|
520
|
-
* this — they share the timer tick infrastructure.
|
|
521
|
-
*/
|
|
522
409
|
async fireDueWorkflowTimers(now = new Date()) {
|
|
523
410
|
let fired = 0;
|
|
524
411
|
const envelope = seedEnvelope({});
|
|
525
412
|
for await (const timer of this.workflowTimerStore.drainDue(now)) {
|
|
526
|
-
// Synthesize an event so the standard `runWorkflows` path handles
|
|
527
|
-
// routing. The event name is the canonical timer name; the payload
|
|
528
|
-
// carries through what `schedule()` was called with.
|
|
529
|
-
//
|
|
530
|
-
// Critical: the timer record carries the originating saga's
|
|
531
|
-
// `correlationKey`. We must thread it through so the workflow loads
|
|
532
|
-
// the right instance (the one that scheduled the timer) — not a
|
|
533
|
-
// fresh "__default__" instance. The synthetic timer event has no
|
|
534
|
-
// shape the user's `correlate()` map can recognize.
|
|
535
413
|
const eventName = timerEventName(timer.workflowName, timer.timerName);
|
|
536
414
|
await this.runWorkflows({ eventName, payload: timer.payload }, envelope, timer.correlationKey);
|
|
537
415
|
fired++;
|
|
538
416
|
}
|
|
539
417
|
return fired;
|
|
540
418
|
}
|
|
541
|
-
// ─── Dispatch
|
|
542
|
-
/**
|
|
543
|
-
* Run an action through its handler, then atomically apply returned events.
|
|
544
|
-
* Returns the handler's raw return value (events) for callers that want it.
|
|
545
|
-
*/
|
|
419
|
+
// ─── Dispatch ──────────────────────────────────────────────────────
|
|
546
420
|
async dispatch(action, input, parentEnvelope, opts) {
|
|
547
421
|
const handler = this.handlers.get(action.name);
|
|
548
422
|
if (!handler) {
|
|
549
423
|
throw new Error(`Runtime: no handler registered for action "${action.name}".`);
|
|
550
424
|
}
|
|
425
|
+
const appName = this.runtime.appName;
|
|
551
426
|
const envelope = parentEnvelope ? deriveEnvelope(parentEnvelope) : seedEnvelope({});
|
|
552
|
-
// Skip re-parse if a trust boundary already validated this input
|
|
553
|
-
// (HTTP `parseAndValidate`, queue worker consume, bus inbound).
|
|
554
|
-
// Untrusted call sites (raw object from application code) hit the
|
|
555
|
-
// parse path because the brand is missing. The brand is dropped by
|
|
556
|
-
// serialization, structural copy, and primitive coercion — so it
|
|
557
|
-
// never produces a false "trust me."
|
|
558
427
|
const validated = isValidated(input) ? input : markValidated(action.schema.parse(input));
|
|
559
|
-
const log = loggerForEnvelope(this.logger, envelope);
|
|
560
|
-
// Caller-side cancellation: every dispatch carries an AbortSignal on
|
|
561
|
-
// ctx. When the caller doesn't supply one, we mint a never-aborted
|
|
562
|
-
// controller so handler code can call `ctx.signal.throwIfAborted()`
|
|
563
|
-
// unconditionally — existing handlers behave identically.
|
|
428
|
+
const log = loggerForEnvelope(this.runtime.logger, envelope);
|
|
564
429
|
const signal = opts?.signal ?? new AbortController().signal;
|
|
565
430
|
const ctx = this.buildHandlerContext(envelope, log, signal);
|
|
566
|
-
this.
|
|
431
|
+
this.runtime.pushTelemetry({
|
|
567
432
|
kind: "action.dispatched",
|
|
568
433
|
action: action.name,
|
|
569
434
|
input: validated,
|
|
570
435
|
envelope,
|
|
571
|
-
appName
|
|
436
|
+
appName,
|
|
572
437
|
ts: new Date().toISOString(),
|
|
573
438
|
});
|
|
574
439
|
const startedAt = performance.now();
|
|
575
|
-
|
|
576
|
-
// returning `false` cleanly cancels the dispatch (no throw, no events,
|
|
577
|
-
// empty handler return). Throwing from a subscriber fails the dispatch
|
|
578
|
-
// as if the handler itself threw.
|
|
579
|
-
const dispatchAllowed = await this.frameworkEvents.fire(ActionDispatching, {
|
|
440
|
+
const dispatchResult = await this.runtime.hooks.ActionDispatching.runDetailed({
|
|
580
441
|
action,
|
|
581
442
|
input: validated,
|
|
582
443
|
ctx,
|
|
583
444
|
});
|
|
584
|
-
if (
|
|
445
|
+
if (dispatchResult.outcome === "failed") {
|
|
446
|
+
throw dispatchResult.error;
|
|
447
|
+
}
|
|
448
|
+
if (dispatchResult.outcome !== "completed") {
|
|
585
449
|
return undefined;
|
|
586
450
|
}
|
|
587
|
-
// Per-action `action.before:<name>` hook. Mirrors the ActionDispatching
|
|
588
|
-
// veto semantics but routes through a named hook so the chain is visible
|
|
589
|
-
// in `listHooks()`, scan, and Studio. Chain steps registered via the
|
|
590
|
-
// `plugin.before(name, …)` sugar set `vetoed = true` when their handler
|
|
591
|
-
// returns false; that cleanly cancels the dispatch with no events.
|
|
592
451
|
const beforeHook = this.actionBeforeHooks.get(action.name);
|
|
593
452
|
if (beforeHook) {
|
|
594
453
|
const beforeCtx = { action, input: validated, ctx };
|
|
@@ -597,10 +456,6 @@ export class Runtime extends RuntimeBase {
|
|
|
597
456
|
return undefined;
|
|
598
457
|
}
|
|
599
458
|
}
|
|
600
|
-
// Core: retry loop + handler invocation + event publishing.
|
|
601
|
-
// Returns the raw handler return for the dispatcher to type-cast. The
|
|
602
|
-
// return union mirrors `HandlerReturn` — plain records (e.g. `{ userId }`)
|
|
603
|
-
// surface here untouched so the dispatcher can hand them back to callers.
|
|
604
459
|
const core = async () => {
|
|
605
460
|
const retry = action.retry;
|
|
606
461
|
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
@@ -608,12 +463,6 @@ export class Runtime extends RuntimeBase {
|
|
|
608
463
|
let lastError;
|
|
609
464
|
while (attempt < maxAttempts) {
|
|
610
465
|
attempt++;
|
|
611
|
-
// Caller already gave up — skip remaining retries (and the DLQ
|
|
612
|
-
// entry that would follow exhaustion). The original error still
|
|
613
|
-
// surfaces to whatever is `await`ing the dispatch, but we don't
|
|
614
|
-
// spend retry budget on a result no one will read. We re-throw
|
|
615
|
-
// outside the inner try so the catch's failed/DLQ telemetry does
|
|
616
|
-
// not fire for the skipped attempts.
|
|
617
466
|
if (attempt > 1 && signal.aborted) {
|
|
618
467
|
log.warn(`abort observed between attempts; skipping retries`, {
|
|
619
468
|
action: action.name,
|
|
@@ -641,20 +490,15 @@ export class Runtime extends RuntimeBase {
|
|
|
641
490
|
await this.publish(events, envelope);
|
|
642
491
|
}
|
|
643
492
|
const durationMs = performance.now() - startedAt;
|
|
644
|
-
this.
|
|
493
|
+
this.runtime.pushTelemetry({
|
|
645
494
|
kind: "action.completed",
|
|
646
495
|
action: action.name,
|
|
647
496
|
durationMs,
|
|
648
497
|
emittedEvents: events.map((e) => e.eventName),
|
|
649
498
|
envelope,
|
|
650
|
-
appName
|
|
499
|
+
appName,
|
|
651
500
|
ts: new Date().toISOString(),
|
|
652
501
|
});
|
|
653
|
-
// Per-action `action.after:<name>` hook. Observation-only —
|
|
654
|
-
// chain steps registered by `plugin.after(name, …)` see the
|
|
655
|
-
// result + durationMs but can't undo. Awaited so `hook.step`
|
|
656
|
-
// taps land in telemetry before the action returns, the way
|
|
657
|
-
// the dispatch hook's taps already do.
|
|
658
502
|
const afterHook = this.actionAfterHooks.get(action.name);
|
|
659
503
|
if (afterHook) {
|
|
660
504
|
try {
|
|
@@ -672,9 +516,7 @@ export class Runtime extends RuntimeBase {
|
|
|
672
516
|
});
|
|
673
517
|
}
|
|
674
518
|
}
|
|
675
|
-
|
|
676
|
-
// Don't await — observers shouldn't block the response path.
|
|
677
|
-
void this.frameworkEvents.fire(ActionCompleted, {
|
|
519
|
+
void this.runtime.hooks.ActionCompleted.run({
|
|
678
520
|
action,
|
|
679
521
|
input: validated,
|
|
680
522
|
result: rawResult ?? undefined,
|
|
@@ -690,7 +532,7 @@ export class Runtime extends RuntimeBase {
|
|
|
690
532
|
maxAttempts,
|
|
691
533
|
error: err?.message,
|
|
692
534
|
});
|
|
693
|
-
this.
|
|
535
|
+
this.runtime.pushTelemetry({
|
|
694
536
|
kind: "action.failed",
|
|
695
537
|
action: action.name,
|
|
696
538
|
attempt,
|
|
@@ -698,14 +540,11 @@ export class Runtime extends RuntimeBase {
|
|
|
698
540
|
willRetry: attempt < maxAttempts,
|
|
699
541
|
error: serializeError(err),
|
|
700
542
|
envelope,
|
|
701
|
-
appName
|
|
543
|
+
appName,
|
|
702
544
|
ts: new Date().toISOString(),
|
|
703
545
|
});
|
|
704
546
|
if (attempt >= maxAttempts) {
|
|
705
|
-
|
|
706
|
-
// retry attempt; the observable view is "this dispatch failed",
|
|
707
|
-
// not "the n-th attempt threw"). Parallel + non-awaited.
|
|
708
|
-
void this.frameworkEvents.fire(ActionFailed, {
|
|
547
|
+
void this.runtime.hooks.ActionFailed.run({
|
|
709
548
|
action,
|
|
710
549
|
input: validated,
|
|
711
550
|
error: err,
|
|
@@ -714,122 +553,80 @@ export class Runtime extends RuntimeBase {
|
|
|
714
553
|
}
|
|
715
554
|
}
|
|
716
555
|
}
|
|
717
|
-
// All attempts failed → dead-letter and re-raise.
|
|
718
556
|
const entry = buildDeadLetterEntry(action.name, validated, envelope, attempt, lastError);
|
|
719
557
|
await this.deadLetterSink.record(entry);
|
|
720
558
|
log.error(`dead-lettered after ${attempt} attempts`, {
|
|
721
559
|
action: action.name,
|
|
722
560
|
error: entry.lastError.message,
|
|
723
561
|
});
|
|
724
|
-
this.
|
|
562
|
+
this.runtime.pushTelemetry({
|
|
725
563
|
kind: "dlq.recorded",
|
|
726
564
|
action: action.name,
|
|
727
565
|
attempts: attempt,
|
|
728
566
|
error: serializeError(lastError),
|
|
729
567
|
envelope,
|
|
730
|
-
appName
|
|
568
|
+
appName,
|
|
731
569
|
ts: new Date().toISOString(),
|
|
732
570
|
});
|
|
733
571
|
throw lastError;
|
|
734
572
|
};
|
|
735
|
-
// Run through the dispatch hook. Every user-registered middleware sits
|
|
736
|
-
// outside the pinned "handler" chain step (-Infinity); the hook calls
|
|
737
|
-
// `coreFn` in that innermost step and writes the result back on hctx.
|
|
738
|
-
// Taps fire per step into runtime.onTelemetry as `kind: "hook.step"`.
|
|
739
573
|
const hctx = { action, input: validated, ctx, coreFn: core };
|
|
740
|
-
await this.dispatchHook
|
|
574
|
+
await this.runtime.dispatchHook$.run(hctx);
|
|
741
575
|
return hctx.result;
|
|
742
576
|
}
|
|
743
|
-
|
|
744
|
-
* Apply a batch of events: route to actors (state transitions + assigns +
|
|
745
|
-
* timer scheduling), fold projections, then fire workflows. Used internally
|
|
746
|
-
* by `dispatch` and exposed for tests and rare external publishes.
|
|
747
|
-
*/
|
|
577
|
+
// ─── Publish + applyExternalEvent ──────────────────────────────────
|
|
748
578
|
async publish(events, parentEnvelope) {
|
|
579
|
+
const appName = this.runtime.appName;
|
|
749
580
|
for (let i = 0; i < events.length; i++) {
|
|
750
581
|
const event = events[i];
|
|
751
|
-
// Envelope-level idempotency: short-circuit when we've already
|
|
752
|
-
// applied this exact `messageId`. The dedup key is the PARENT
|
|
753
|
-
// envelope's id — that's the identity a queue/bus driver carries
|
|
754
|
-
// through verbatim on redelivery, and the identity a caller
|
|
755
|
-
// can pin to make two publish() calls share a fate. When more than
|
|
756
|
-
// one event ships under the same parent envelope we tag the
|
|
757
|
-
// dedup key with the event index so each one applies on the
|
|
758
|
-
// first delivery and dedups on the second.
|
|
759
582
|
const dedupKey = events.length === 1 ? parentEnvelope.messageId : `${parentEnvelope.messageId}#${i}`;
|
|
760
583
|
const childEnvelope = deriveEnvelope(parentEnvelope);
|
|
761
584
|
if (await this.idempotencyStore.seen(dedupKey)) {
|
|
762
|
-
this.
|
|
585
|
+
this.runtime.pushTelemetry({
|
|
763
586
|
kind: "event.deduped",
|
|
764
587
|
event,
|
|
765
588
|
envelope: childEnvelope,
|
|
766
589
|
source: "in-process",
|
|
767
|
-
appName
|
|
590
|
+
appName,
|
|
768
591
|
ts: new Date().toISOString(),
|
|
769
592
|
});
|
|
770
593
|
continue;
|
|
771
594
|
}
|
|
772
595
|
await this.idempotencyStore.record(dedupKey);
|
|
773
|
-
// Ordering: actors → projections → workflows.
|
|
774
|
-
// - actors first: state must be coherent before observers see it.
|
|
775
|
-
// - projections second: workflows often read state via queries
|
|
776
|
-
// (`ctx.request(query, ...)`), and chained dispatches via
|
|
777
|
-
// `ctx.request(action, ...)` will themselves fold projections —
|
|
778
|
-
// we must avoid stale reads, so projections fold before any
|
|
779
|
-
// workflow-triggered chain begins.
|
|
780
|
-
// - workflows last. Workflows produced events (translator pattern)
|
|
781
|
-
// publish recursively through this same method, so the full
|
|
782
|
-
// pipeline applies to derived events too.
|
|
783
596
|
await this.applyToActors(event, childEnvelope);
|
|
784
597
|
await this.foldProjections(event, childEnvelope);
|
|
785
598
|
await this.runWorkflows(event, childEnvelope);
|
|
786
|
-
|
|
787
|
-
this.emit({
|
|
599
|
+
this.runtime.pushTelemetry({
|
|
788
600
|
kind: "event.published",
|
|
789
601
|
event,
|
|
790
602
|
envelope: childEnvelope,
|
|
791
603
|
source: "in-process",
|
|
792
|
-
appName
|
|
604
|
+
appName,
|
|
793
605
|
ts: new Date().toISOString(),
|
|
794
606
|
});
|
|
795
|
-
// Cross-service fan-out: send public events to the bus AFTER the
|
|
796
|
-
// in-process apply succeeded. Subscribers in other services will
|
|
797
|
-
// call `applyExternalEvent` on their own runtime. Internal events
|
|
798
|
-
// (visibility: 'internal') stay in-process — explicit gate.
|
|
799
607
|
if (this.publishToBus && this.bus && this.publicEventNames.has(event.eventName)) {
|
|
800
608
|
await this.bus.publish({
|
|
801
609
|
eventName: event.eventName,
|
|
802
610
|
payload: event.payload,
|
|
803
611
|
envelope: childEnvelope,
|
|
804
|
-
origin:
|
|
612
|
+
origin: appName,
|
|
805
613
|
});
|
|
806
614
|
}
|
|
807
615
|
}
|
|
808
616
|
}
|
|
809
|
-
/**
|
|
810
|
-
* Apply an event that arrived from the cross-service bus. Same pipeline
|
|
811
|
-
* as `publish` (actors → projections → workflows) but does NOT re-publish
|
|
812
|
-
* to the bus — avoids fan-out loops between services. The runtime tracks
|
|
813
|
-
* which event names it declared as external (via createApp's wiring of
|
|
814
|
-
* modules' `needs.externalEvents`); calls for other names throw to catch
|
|
815
|
-
* misconfigured subscriptions early.
|
|
816
|
-
*/
|
|
817
617
|
async applyExternalEvent(eventName, payload, envelope) {
|
|
818
618
|
if (!this.externalEventNames.has(eventName)) {
|
|
819
619
|
throw new Error(`Runtime.applyExternalEvent: "${eventName}" was not declared in any module's needs.externalEvents.`);
|
|
820
620
|
}
|
|
621
|
+
const appName = this.runtime.appName;
|
|
821
622
|
const event = { eventName, payload };
|
|
822
|
-
// Bus inbound dedup mirrors the in-process publish path: a queue
|
|
823
|
-
// redrive or bus replay carries the same `envelope.messageId`,
|
|
824
|
-
// so an identical second delivery short-circuits without touching
|
|
825
|
-
// actor or projection state.
|
|
826
623
|
if (await this.idempotencyStore.seen(envelope.messageId)) {
|
|
827
|
-
this.
|
|
624
|
+
this.runtime.pushTelemetry({
|
|
828
625
|
kind: "event.deduped",
|
|
829
626
|
event,
|
|
830
627
|
envelope,
|
|
831
628
|
source: "external",
|
|
832
|
-
appName
|
|
629
|
+
appName,
|
|
833
630
|
ts: new Date().toISOString(),
|
|
834
631
|
});
|
|
835
632
|
return;
|
|
@@ -838,20 +635,22 @@ export class Runtime extends RuntimeBase {
|
|
|
838
635
|
await this.applyToActors(event, envelope);
|
|
839
636
|
await this.foldProjections(event, envelope);
|
|
840
637
|
await this.runWorkflows(event, envelope);
|
|
841
|
-
this.
|
|
638
|
+
this.runtime.pushTelemetry({
|
|
842
639
|
kind: "event.published",
|
|
843
640
|
event,
|
|
844
641
|
envelope,
|
|
845
642
|
source: "external",
|
|
846
|
-
appName
|
|
643
|
+
appName,
|
|
847
644
|
ts: new Date().toISOString(),
|
|
848
645
|
});
|
|
849
646
|
}
|
|
647
|
+
// ─── Projection folding ────────────────────────────────────────────
|
|
850
648
|
async foldProjections(event, envelope) {
|
|
851
649
|
const projections = this.projectionsByEvent.get(event.eventName);
|
|
852
650
|
if (!projections || projections.length === 0)
|
|
853
651
|
return;
|
|
854
652
|
const tenant = envelope.tenant ?? "";
|
|
653
|
+
const appName = this.runtime.appName;
|
|
855
654
|
for (const projection of projections) {
|
|
856
655
|
const reducer = projection.on[event.eventName];
|
|
857
656
|
if (!reducer)
|
|
@@ -861,24 +660,19 @@ export class Runtime extends RuntimeBase {
|
|
|
861
660
|
const current = (await this.projectionStore.load(projection.name, tenant)) ?? projection.initial();
|
|
862
661
|
const next = reducer(current, event.payload);
|
|
863
662
|
await this.projectionStore.save(projection.name, next, tenant);
|
|
864
|
-
this.
|
|
663
|
+
this.runtime.pushTelemetry({
|
|
865
664
|
kind: "projection.folded",
|
|
866
665
|
projection: projection.name,
|
|
867
666
|
event: event.eventName,
|
|
868
667
|
tenant,
|
|
869
668
|
durationMs: performance.now() - t0,
|
|
870
669
|
envelope,
|
|
871
|
-
appName
|
|
670
|
+
appName,
|
|
872
671
|
ts: new Date().toISOString(),
|
|
873
672
|
});
|
|
874
673
|
}
|
|
875
674
|
catch (err) {
|
|
876
|
-
|
|
877
|
-
// observability can alarm on projection drift directly (gap 4).
|
|
878
|
-
// We re-throw so the surrounding apply path still fails fast;
|
|
879
|
-
// the only behavior change is that consumers no longer have to
|
|
880
|
-
// deduce drift from missing `projection.folded` records.
|
|
881
|
-
this.emit({
|
|
675
|
+
this.runtime.pushTelemetry({
|
|
882
676
|
kind: "projection.failed",
|
|
883
677
|
projection: projection.name,
|
|
884
678
|
event: event.eventName,
|
|
@@ -886,14 +680,14 @@ export class Runtime extends RuntimeBase {
|
|
|
886
680
|
durationMs: performance.now() - t0,
|
|
887
681
|
error: serializeError(err),
|
|
888
682
|
envelope,
|
|
889
|
-
appName
|
|
683
|
+
appName,
|
|
890
684
|
ts: new Date().toISOString(),
|
|
891
685
|
});
|
|
892
686
|
throw err;
|
|
893
687
|
}
|
|
894
688
|
}
|
|
895
689
|
}
|
|
896
|
-
// ───
|
|
690
|
+
// ─── Actor dispatch (internal) ─────────────────────────────────────
|
|
897
691
|
async applyToActors(event, envelope) {
|
|
898
692
|
const tenant = envelope.tenant ?? "";
|
|
899
693
|
for (const actor of this.actors.values()) {
|
|
@@ -901,10 +695,8 @@ export class Runtime extends RuntimeBase {
|
|
|
901
695
|
if (!reactionsForEvent || reactionsForEvent.length === 0)
|
|
902
696
|
continue;
|
|
903
697
|
const key = this.extractKey(event, actor);
|
|
904
|
-
if (key === undefined || key === null)
|
|
905
|
-
// Event doesn't carry this actor's key — not addressed to it.
|
|
698
|
+
if (key === undefined || key === null)
|
|
906
699
|
continue;
|
|
907
|
-
}
|
|
908
700
|
await this.applyEventToActor(actor, String(key), tenant, event, reactionsForEvent, envelope);
|
|
909
701
|
}
|
|
910
702
|
}
|
|
@@ -915,12 +707,6 @@ export class Runtime extends RuntimeBase {
|
|
|
915
707
|
return payload[actor.key];
|
|
916
708
|
}
|
|
917
709
|
async applyEventToActor(actor, key, tenant, event, candidateReactions, envelope) {
|
|
918
|
-
// Per-(actor, key, tenant) lock around the load → reduce → save
|
|
919
|
-
// window. Without it, two concurrent dispatches racing for the same
|
|
920
|
-
// actor key both observe the same pre-state and the second save
|
|
921
|
-
// wins — silent invariant loss. Production adapters (Mongo, SQL)
|
|
922
|
-
// should treat lockKey as a no-op and rely on row-level locks; the
|
|
923
|
-
// in-memory store needs it explicitly.
|
|
924
710
|
const release = (await this.actorStore.lockKey?.(actor.name, key, tenant)) ?? (() => { });
|
|
925
711
|
try {
|
|
926
712
|
await this.applyEventToActorLocked(actor, key, tenant, event, candidateReactions, envelope);
|
|
@@ -930,23 +716,17 @@ export class Runtime extends RuntimeBase {
|
|
|
930
716
|
}
|
|
931
717
|
}
|
|
932
718
|
async applyEventToActorLocked(actor, key, tenant, event, candidateReactions, envelope) {
|
|
719
|
+
const appName = this.runtime.appName;
|
|
933
720
|
const maxOccRetries = 3;
|
|
934
721
|
for (let occAttempt = 0; occAttempt < maxOccRetries; occAttempt++) {
|
|
935
722
|
const existing = await this.actorStore.load(actor.name, key, tenant);
|
|
936
723
|
const instance = existing ?? createInitialInstance(actor, key, tenant);
|
|
937
724
|
const matching = candidateReactions.find((c) => c.state === instance.state);
|
|
938
|
-
if (!matching)
|
|
939
|
-
// Actor is in a state that doesn't react to this event — silently
|
|
940
|
-
// skip. (Future: dead-letter / log; surfacing here is too noisy for
|
|
941
|
-
// events that fan out to many actors, only some of which match.)
|
|
725
|
+
if (!matching)
|
|
942
726
|
return;
|
|
943
|
-
}
|
|
944
727
|
const stateConfig = actor.states[instance.state];
|
|
945
|
-
if (stateConfig?.final)
|
|
946
|
-
// Defensive: a final state shouldn't have entries in eventIndex,
|
|
947
|
-
// but guard anyway.
|
|
728
|
+
if (stateConfig?.final)
|
|
948
729
|
return;
|
|
949
|
-
}
|
|
950
730
|
const partial = matching.reaction.assign
|
|
951
731
|
? matching.reaction.assign(instance.data, event.payload)
|
|
952
732
|
: {};
|
|
@@ -956,14 +736,9 @@ export class Runtime extends RuntimeBase {
|
|
|
956
736
|
if (!nextStateConfig) {
|
|
957
737
|
throw new Error(`Runtime: actor "${actor.name}" reaction targets undeclared state "${nextStateName}" from "${instance.state}".`);
|
|
958
738
|
}
|
|
959
|
-
// Schema validation on save — invalid partial → throw, atomically
|
|
960
|
-
// skip persistence, re-raise to caller.
|
|
961
739
|
const validated = actor.schema.parse(nextData);
|
|
962
740
|
const stateChanged = nextStateName !== instance.state;
|
|
963
741
|
const isNewActor = !existing;
|
|
964
|
-
// Timers are owned by a state, not the actor. Compute them on:
|
|
965
|
-
// - state change (cancel old, schedule new), or
|
|
966
|
-
// - actor creation (born into a state — schedule its timers).
|
|
967
742
|
const nextTimers = stateChanged || isNewActor
|
|
968
743
|
? this.computeTimersForState(actor, nextStateName, key)
|
|
969
744
|
: instance.activeTimers;
|
|
@@ -980,13 +755,12 @@ export class Runtime extends RuntimeBase {
|
|
|
980
755
|
await this.actorStore.save(nextInstance, { expectedVersion: instance.version });
|
|
981
756
|
}
|
|
982
757
|
catch (err) {
|
|
983
|
-
if (err instanceof ActorVersionConflictError && occAttempt < maxOccRetries - 1)
|
|
758
|
+
if (err instanceof ActorVersionConflictError && occAttempt < maxOccRetries - 1)
|
|
984
759
|
continue;
|
|
985
|
-
}
|
|
986
760
|
throw err;
|
|
987
761
|
}
|
|
988
762
|
if (stateChanged) {
|
|
989
|
-
this.
|
|
763
|
+
this.runtime.pushTelemetry({
|
|
990
764
|
kind: "actor.transitioned",
|
|
991
765
|
actor: actor.name,
|
|
992
766
|
key,
|
|
@@ -995,22 +769,15 @@ export class Runtime extends RuntimeBase {
|
|
|
995
769
|
to: nextStateName,
|
|
996
770
|
triggeringEvent: event.eventName,
|
|
997
771
|
envelope,
|
|
998
|
-
appName
|
|
772
|
+
appName,
|
|
999
773
|
ts: new Date().toISOString(),
|
|
1000
774
|
});
|
|
1001
775
|
}
|
|
1002
|
-
// Fire actor-transition hooks (registered by plugins). Hooks run AFTER
|
|
1003
|
-
// the save so they observe committed state. Errors propagate — plugins
|
|
1004
|
-
// are infrastructure; we want loud failures, not silent skips.
|
|
1005
776
|
if (this.actorTransitionHooks.length > 0 && stateChanged) {
|
|
1006
|
-
for (const
|
|
1007
|
-
await
|
|
777
|
+
for (const hookFn of this.actorTransitionHooks) {
|
|
778
|
+
await hookFn(actor, key, instance.state, nextStateName, event, envelope);
|
|
1008
779
|
}
|
|
1009
780
|
}
|
|
1010
|
-
// Per-actor `actor.transition:<name>` hook — named, observable in
|
|
1011
|
-
// listHooks(), tap-able by plugins via runtime.ensureActorTransitionHook.
|
|
1012
|
-
// Runs only on actual transitions (state changed) so observers don't
|
|
1013
|
-
// see no-op events.
|
|
1014
781
|
if (stateChanged) {
|
|
1015
782
|
const perActorHook = this.perActorHooks.get(actor.name);
|
|
1016
783
|
if (perActorHook) {
|
|
@@ -1025,7 +792,7 @@ export class Runtime extends RuntimeBase {
|
|
|
1025
792
|
});
|
|
1026
793
|
}
|
|
1027
794
|
catch (err) {
|
|
1028
|
-
loggerForEnvelope(this.logger, envelope).error(`actor.transition hook threw`, {
|
|
795
|
+
loggerForEnvelope(this.runtime.logger, envelope).error(`actor.transition hook threw`, {
|
|
1029
796
|
actor: actor.name,
|
|
1030
797
|
error: err?.message,
|
|
1031
798
|
});
|
|
@@ -1039,6 +806,7 @@ export class Runtime extends RuntimeBase {
|
|
|
1039
806
|
const stateConfig = actor.states[stateName];
|
|
1040
807
|
if (!stateConfig?.after)
|
|
1041
808
|
return {};
|
|
809
|
+
const appName = this.runtime.appName;
|
|
1042
810
|
const now = Date.now();
|
|
1043
811
|
const timers = {};
|
|
1044
812
|
for (const [timerName, spec] of Object.entries(stateConfig.after)) {
|
|
@@ -1054,7 +822,7 @@ export class Runtime extends RuntimeBase {
|
|
|
1054
822
|
input,
|
|
1055
823
|
};
|
|
1056
824
|
timers[timerName] = handle;
|
|
1057
|
-
this.
|
|
825
|
+
this.runtime.pushTelemetry({
|
|
1058
826
|
kind: "timer.scheduled",
|
|
1059
827
|
actor: actor.name,
|
|
1060
828
|
key: actorKey,
|
|
@@ -1062,34 +830,19 @@ export class Runtime extends RuntimeBase {
|
|
|
1062
830
|
action,
|
|
1063
831
|
fireAt: handle.fireAt,
|
|
1064
832
|
tenant: "",
|
|
1065
|
-
appName
|
|
833
|
+
appName,
|
|
1066
834
|
ts: new Date().toISOString(),
|
|
1067
835
|
});
|
|
1068
836
|
}
|
|
1069
837
|
return timers;
|
|
1070
838
|
}
|
|
1071
|
-
// ───
|
|
1072
|
-
|
|
1073
|
-
* Fire every workflow subscribed to `event`. Each workflow receives a
|
|
1074
|
-
* runtime-bound effects bag: `send`/`enqueue` go through `dispatch` for
|
|
1075
|
-
* retry + telemetry parity with action handlers; `publish` goes back
|
|
1076
|
-
* through `this.publish` so derived events flow through the full
|
|
1077
|
-
* actors → projections → workflows pipeline (translator pattern).
|
|
1078
|
-
*
|
|
1079
|
-
* Telemetry emits as `workflow.fired` / `workflow.failed`.
|
|
1080
|
-
*/
|
|
1081
|
-
async runWorkflows(event, envelope,
|
|
1082
|
-
/**
|
|
1083
|
-
* Optional override for correlationKey. Used by `fireDueWorkflowTimers`
|
|
1084
|
-
* to route a synthetic timer event back to the saga instance that
|
|
1085
|
-
* scheduled it — `workflow.correlate(event)` can't determine the key
|
|
1086
|
-
* from a synthetic timer payload alone.
|
|
1087
|
-
*/
|
|
1088
|
-
correlationKeyOverride) {
|
|
839
|
+
// ─── Workflows (internal) ──────────────────────────────────────────
|
|
840
|
+
async runWorkflows(event, envelope, correlationKeyOverride) {
|
|
1089
841
|
const workflows = this.workflowsByEvent.get(event.eventName);
|
|
1090
842
|
if (!workflows || workflows.length === 0)
|
|
1091
843
|
return;
|
|
1092
|
-
const
|
|
844
|
+
const appName = this.runtime.appName;
|
|
845
|
+
const log = loggerForEnvelope(this.runtime.logger, envelope).child({
|
|
1093
846
|
event: event.eventName,
|
|
1094
847
|
});
|
|
1095
848
|
const handlerCtx = this.buildHandlerContext(envelope, log);
|
|
@@ -1109,11 +862,6 @@ export class Runtime extends RuntimeBase {
|
|
|
1109
862
|
const t0 = performance.now();
|
|
1110
863
|
try {
|
|
1111
864
|
const store = this.workflowInstanceStore(workflow.name);
|
|
1112
|
-
// Workflow correlation MUST include the tenant axis. Without it,
|
|
1113
|
-
// two tenants with the same business key (e.g. subscriptionId)
|
|
1114
|
-
// share a saga instance — the second tenant's PaymentFailed sees
|
|
1115
|
-
// the first tenant's state. The override path (from timer fires)
|
|
1116
|
-
// already carries tenant context; only derive when no override.
|
|
1117
865
|
const userKey = workflow.correlate?.(event) ?? "__default__";
|
|
1118
866
|
const tenantPrefix = envelope.tenant ? `${envelope.tenant}::` : "";
|
|
1119
867
|
const correlationKey = correlationKeyOverride ?? `${tenantPrefix}${userKey}`;
|
|
@@ -1134,12 +882,6 @@ export class Runtime extends RuntimeBase {
|
|
|
1134
882
|
});
|
|
1135
883
|
},
|
|
1136
884
|
};
|
|
1137
|
-
// Per-workflow `workflow.fire:<name>` hook — observation-only.
|
|
1138
|
-
// Runs BEFORE the saga fires so chain steps see input + context;
|
|
1139
|
-
// failures here are logged, never block the saga. Chain steps
|
|
1140
|
-
// intending to gate workflow execution should subscribe to the
|
|
1141
|
-
// `WorkflowFiring` framework event (future surface) — the hook
|
|
1142
|
-
// is for telemetry + drift detection.
|
|
1143
885
|
const perWorkflowHook = this.perWorkflowHooks.get(workflow.name);
|
|
1144
886
|
if (perWorkflowHook) {
|
|
1145
887
|
try {
|
|
@@ -1157,14 +899,6 @@ export class Runtime extends RuntimeBase {
|
|
|
1157
899
|
});
|
|
1158
900
|
}
|
|
1159
901
|
}
|
|
1160
|
-
// Honor the workflow's retry policy around `_fire`. Without a
|
|
1161
|
-
// declared policy, the historical contract holds: one attempt,
|
|
1162
|
-
// failure emits `reaction.failed` and re-raises. With a policy,
|
|
1163
|
-
// we retry per the same back-off math the action dispatch loop
|
|
1164
|
-
// uses; each failure emits `reaction.failed` with attempt info,
|
|
1165
|
-
// and the FINAL failure additionally emits `reaction.exhausted`
|
|
1166
|
-
// so alarms can fire on saga death distinctly from transient
|
|
1167
|
-
// drift.
|
|
1168
902
|
const retry = workflow.retry;
|
|
1169
903
|
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
1170
904
|
let attempt = 0;
|
|
@@ -1186,12 +920,12 @@ export class Runtime extends RuntimeBase {
|
|
|
1186
920
|
catch (err) {
|
|
1187
921
|
lastError = err;
|
|
1188
922
|
const willRetry = attempt < maxAttempts;
|
|
1189
|
-
this.
|
|
923
|
+
this.runtime.pushTelemetry({
|
|
1190
924
|
kind: "reaction.failed",
|
|
1191
925
|
sourceEvent: event.eventName,
|
|
1192
926
|
error: serializeError(err),
|
|
1193
927
|
envelope,
|
|
1194
|
-
appName
|
|
928
|
+
appName,
|
|
1195
929
|
ts: new Date().toISOString(),
|
|
1196
930
|
workflow: workflow.name,
|
|
1197
931
|
attempt,
|
|
@@ -1199,19 +933,14 @@ export class Runtime extends RuntimeBase {
|
|
|
1199
933
|
willRetry,
|
|
1200
934
|
});
|
|
1201
935
|
if (!willRetry && retry) {
|
|
1202
|
-
|
|
1203
|
-
// declared — otherwise a one-shot failure (no policy) is
|
|
1204
|
-
// semantically just "the saga threw," not "the saga
|
|
1205
|
-
// burned through its retry budget." Alarms should target
|
|
1206
|
-
// the policy-aware signal.
|
|
1207
|
-
this.emit({
|
|
936
|
+
this.runtime.pushTelemetry({
|
|
1208
937
|
kind: "reaction.exhausted",
|
|
1209
938
|
workflow: workflow.name,
|
|
1210
939
|
sourceEvent: event.eventName,
|
|
1211
940
|
attempts: attempt,
|
|
1212
941
|
error: serializeError(err),
|
|
1213
942
|
envelope,
|
|
1214
|
-
appName
|
|
943
|
+
appName,
|
|
1215
944
|
ts: new Date().toISOString(),
|
|
1216
945
|
});
|
|
1217
946
|
exhausted = true;
|
|
@@ -1219,38 +948,29 @@ export class Runtime extends RuntimeBase {
|
|
|
1219
948
|
}
|
|
1220
949
|
}
|
|
1221
950
|
if (!fired) {
|
|
1222
|
-
// Mark so the outer catch knows the loop already emitted
|
|
1223
|
-
// `reaction.failed` for this failure — avoids double-emit.
|
|
1224
|
-
// `exhausted` is unused for marking; the loop emits one
|
|
1225
|
-
// `reaction.failed` per attempt regardless of policy.
|
|
1226
951
|
void exhausted;
|
|
1227
952
|
if (lastError && typeof lastError === "object") {
|
|
1228
953
|
lastError.__nwireWorkflowEmitted = true;
|
|
1229
954
|
}
|
|
1230
955
|
throw lastError;
|
|
1231
956
|
}
|
|
1232
|
-
this.
|
|
957
|
+
this.runtime.pushTelemetry({
|
|
1233
958
|
kind: "reaction.fired",
|
|
1234
959
|
sourceEvent: event.eventName,
|
|
1235
960
|
durationMs: performance.now() - t0,
|
|
1236
961
|
envelope,
|
|
1237
|
-
appName
|
|
962
|
+
appName,
|
|
1238
963
|
ts: new Date().toISOString(),
|
|
1239
964
|
});
|
|
1240
965
|
}
|
|
1241
966
|
catch (err) {
|
|
1242
|
-
// The retry loop above already emitted `reaction.failed` for
|
|
1243
|
-
// every saga-body failure. If we see one tagged with the
|
|
1244
|
-
// internal marker, the loop owns the telemetry — skip the
|
|
1245
|
-
// duplicate emission here. Anything untagged (correlate() throws,
|
|
1246
|
-
// hook bug, etc.) lands as a single bare `reaction.failed`.
|
|
1247
967
|
if (!err?.__nwireWorkflowEmitted) {
|
|
1248
|
-
this.
|
|
968
|
+
this.runtime.pushTelemetry({
|
|
1249
969
|
kind: "reaction.failed",
|
|
1250
970
|
sourceEvent: event.eventName,
|
|
1251
971
|
error: serializeError(err),
|
|
1252
972
|
envelope,
|
|
1253
|
-
appName
|
|
973
|
+
appName,
|
|
1254
974
|
ts: new Date().toISOString(),
|
|
1255
975
|
workflow: workflow.name,
|
|
1256
976
|
});
|
|
@@ -1259,22 +979,19 @@ export class Runtime extends RuntimeBase {
|
|
|
1259
979
|
}
|
|
1260
980
|
}
|
|
1261
981
|
}
|
|
1262
|
-
// ───
|
|
982
|
+
// ─── Handler context (internal) ────────────────────────────────────
|
|
1263
983
|
buildHandlerContext(envelope, log, signal) {
|
|
1264
984
|
const self = this;
|
|
1265
|
-
const
|
|
1266
|
-
|
|
1267
|
-
// travels through every nested `ctx.request(...)`. The runtime never
|
|
1268
|
-
// mutates the incoming signal — handlers observe; the wire owns the
|
|
1269
|
-
// controller.
|
|
985
|
+
const container = this.runtime.getContainer();
|
|
986
|
+
const logger = log ?? loggerForEnvelope(this.runtime.logger, envelope);
|
|
1270
987
|
const ctxSignal = signal ?? new AbortController().signal;
|
|
1271
988
|
const ctx = {
|
|
1272
|
-
container
|
|
989
|
+
container,
|
|
1273
990
|
envelope,
|
|
1274
991
|
logger,
|
|
1275
992
|
signal: ctxSignal,
|
|
1276
993
|
resolve(name) {
|
|
1277
|
-
return
|
|
994
|
+
return container.resolve(name);
|
|
1278
995
|
},
|
|
1279
996
|
get requestId() {
|
|
1280
997
|
return envelope.messageId;
|
|
@@ -1289,8 +1006,6 @@ export class Runtime extends RuntimeBase {
|
|
|
1289
1006
|
return self.query(queryDef.name, input, envelope.tenant ?? "");
|
|
1290
1007
|
},
|
|
1291
1008
|
async send(action, input) {
|
|
1292
|
-
// For now, send is identical to request but result is ignored.
|
|
1293
|
-
// Real fire-and-forget arrives with the queue transport.
|
|
1294
1009
|
await self.dispatch(action, input, envelope, { signal: ctxSignal });
|
|
1295
1010
|
},
|
|
1296
1011
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -1303,16 +1018,13 @@ export class Runtime extends RuntimeBase {
|
|
|
1303
1018
|
};
|
|
1304
1019
|
return ctx;
|
|
1305
1020
|
}
|
|
1306
|
-
// ───
|
|
1021
|
+
// ─── Actor view (ctx.use) ──────────────────────────────────────────
|
|
1307
1022
|
async loadActorView(actor, id, envelope) {
|
|
1308
1023
|
if (!this.actors.has(actor.name)) {
|
|
1309
1024
|
throw new Error(`Runtime.use: actor "${actor.name}" is not registered. ` +
|
|
1310
1025
|
`Add it to a module's manifest.actors and pass that module to createApp.`);
|
|
1311
1026
|
}
|
|
1312
1027
|
const loaded = await this.actorStore.load(actor.name, id, envelope.tenant ?? "");
|
|
1313
|
-
// Virgin instance — let `create` methods bootstrap a new actor.
|
|
1314
|
-
// The bootstrap event flows through the actor's `on` transitions and
|
|
1315
|
-
// populates state for subsequent dispatches.
|
|
1316
1028
|
const instance = loaded ?? {
|
|
1317
1029
|
name: actor.name,
|
|
1318
1030
|
key: id,
|
|
@@ -1328,8 +1040,6 @@ export class Runtime extends RuntimeBase {
|
|
|
1328
1040
|
key: instance.key,
|
|
1329
1041
|
stateName: instance.state,
|
|
1330
1042
|
};
|
|
1331
|
-
// Closure-form actor: bind methods via the closure binder. recordThat
|
|
1332
|
-
// calls accumulate events; we publish them after each method call.
|
|
1333
1043
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1334
1044
|
const closureBinder = actor.closureBinder;
|
|
1335
1045
|
if (closureBinder) {
|
|
@@ -1337,8 +1047,6 @@ export class Runtime extends RuntimeBase {
|
|
|
1337
1047
|
for (const [methodName, fn] of Object.entries(bound.methods)) {
|
|
1338
1048
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1339
1049
|
view[methodName] = async (...args) => {
|
|
1340
|
-
// Re-bind for the live state at call time (the actor may have been
|
|
1341
|
-
// updated since the view was created).
|
|
1342
1050
|
const fresh = await this.actorStore.load(actor.name, id, envelope.tenant ?? "");
|
|
1343
1051
|
const liveData = fresh?.data ?? instance.data;
|
|
1344
1052
|
const localBound = closureBinder(liveData, instance.key);
|
|
@@ -1347,7 +1055,6 @@ export class Runtime extends RuntimeBase {
|
|
|
1347
1055
|
throw new Error(`Actor "${actor.name}" has no method "${methodName}".`);
|
|
1348
1056
|
}
|
|
1349
1057
|
const result = localFn(...args);
|
|
1350
|
-
// Publish recorded events through the runtime pipeline.
|
|
1351
1058
|
if (localBound.recorded.length > 0) {
|
|
1352
1059
|
const messages = localBound.recorded.map((r) => ({
|
|
1353
1060
|
eventName: r.eventName,
|
|
@@ -1361,7 +1068,6 @@ export class Runtime extends RuntimeBase {
|
|
|
1361
1068
|
}
|
|
1362
1069
|
return view;
|
|
1363
1070
|
}
|
|
1364
|
-
// Classic / schema-bound-object form: methods are (state, ...args) => event.
|
|
1365
1071
|
const methods = actor.methods ?? {};
|
|
1366
1072
|
for (const [methodName, fn] of Object.entries(methods)) {
|
|
1367
1073
|
const method = fn;
|
|
@@ -1370,42 +1076,7 @@ export class Runtime extends RuntimeBase {
|
|
|
1370
1076
|
}
|
|
1371
1077
|
return view;
|
|
1372
1078
|
}
|
|
1373
|
-
/** Test/inspection seam — read what's in the DLQ. */
|
|
1374
1079
|
getDeadLetterSink() {
|
|
1375
1080
|
return this.deadLetterSink;
|
|
1376
1081
|
}
|
|
1377
1082
|
}
|
|
1378
|
-
function sleep(ms) {
|
|
1379
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1380
|
-
}
|
|
1381
|
-
function computeBackoff(retry, attemptIndex) {
|
|
1382
|
-
if (!retry)
|
|
1383
|
-
return 0;
|
|
1384
|
-
const base = retry.baseDelayMs ?? 100;
|
|
1385
|
-
const cap = retry.maxDelayMs ?? 30_000;
|
|
1386
|
-
if (retry.backoff === "fixed")
|
|
1387
|
-
return Math.min(base, cap);
|
|
1388
|
-
// Exponential default — 2^(attempt-1) * base, capped.
|
|
1389
|
-
return Math.min(Math.floor(Math.pow(2, attemptIndex - 1) * base), cap);
|
|
1390
|
-
}
|
|
1391
|
-
/**
|
|
1392
|
-
* Parse a delay string like '3d', '90s', '4h' into milliseconds.
|
|
1393
|
-
* Supports: ms, s, m, h, d. Numbers without units treated as milliseconds.
|
|
1394
|
-
*/
|
|
1395
|
-
export function parseDelay(value) {
|
|
1396
|
-
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(value);
|
|
1397
|
-
if (!match) {
|
|
1398
|
-
throw new Error(`Runtime: cannot parse delay "${value}". Expected '3d', '90s', '4h', '500ms'.`);
|
|
1399
|
-
}
|
|
1400
|
-
const n = Number(match[1]);
|
|
1401
|
-
const unit = match[2] ?? "ms";
|
|
1402
|
-
const multipliers = {
|
|
1403
|
-
ms: 1,
|
|
1404
|
-
s: 1000,
|
|
1405
|
-
m: 60_000,
|
|
1406
|
-
h: 3_600_000,
|
|
1407
|
-
d: 86_400_000,
|
|
1408
|
-
};
|
|
1409
|
-
return n * (multipliers[unit] ?? 1);
|
|
1410
|
-
}
|
|
1411
|
-
//# sourceMappingURL=runtime.js.map
|