@nwire/forge 0.7.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/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/__tests__/actor-methods.test.d.ts +9 -0
- package/dist/__tests__/actor-methods.test.d.ts.map +1 -0
- package/dist/__tests__/actor-methods.test.js +210 -0
- package/dist/__tests__/actor-methods.test.js.map +1 -0
- package/dist/__tests__/actor-schema-bound.test.d.ts +6 -0
- package/dist/__tests__/actor-schema-bound.test.d.ts.map +1 -0
- package/dist/__tests__/actor-schema-bound.test.js +112 -0
- package/dist/__tests__/actor-schema-bound.test.js.map +1 -0
- package/dist/__tests__/app-capabilities.test.d.ts +19 -0
- package/dist/__tests__/app-capabilities.test.d.ts.map +1 -0
- package/dist/__tests__/app-capabilities.test.js +57 -0
- package/dist/__tests__/app-capabilities.test.js.map +1 -0
- package/dist/__tests__/cli-runner.test.d.ts +6 -0
- package/dist/__tests__/cli-runner.test.d.ts.map +1 -0
- package/dist/__tests__/cli-runner.test.js +158 -0
- package/dist/__tests__/cli-runner.test.js.map +1 -0
- package/dist/__tests__/create-app.test.d.ts +18 -0
- package/dist/__tests__/create-app.test.d.ts.map +1 -0
- package/dist/__tests__/create-app.test.js +189 -0
- package/dist/__tests__/create-app.test.js.map +1 -0
- package/dist/__tests__/cross-service-bus.test.d.ts +8 -0
- package/dist/__tests__/cross-service-bus.test.d.ts.map +1 -0
- package/dist/__tests__/cross-service-bus.test.js +139 -0
- package/dist/__tests__/cross-service-bus.test.js.map +1 -0
- package/dist/__tests__/define-schema.test.d.ts +5 -0
- package/dist/__tests__/define-schema.test.d.ts.map +1 -0
- package/dist/__tests__/define-schema.test.js +83 -0
- package/dist/__tests__/define-schema.test.js.map +1 -0
- package/dist/__tests__/dev-logger.test.d.ts +9 -0
- package/dist/__tests__/dev-logger.test.d.ts.map +1 -0
- package/dist/__tests__/dev-logger.test.js +126 -0
- package/dist/__tests__/dev-logger.test.js.map +1 -0
- package/dist/__tests__/external-call.test.d.ts +14 -0
- package/dist/__tests__/external-call.test.d.ts.map +1 -0
- package/dist/__tests__/external-call.test.js +99 -0
- package/dist/__tests__/external-call.test.js.map +1 -0
- package/dist/__tests__/framework-events.test.d.ts +13 -0
- package/dist/__tests__/framework-events.test.d.ts.map +1 -0
- package/dist/__tests__/framework-events.test.js +204 -0
- package/dist/__tests__/framework-events.test.js.map +1 -0
- package/dist/__tests__/inline-handler.test.d.ts +8 -0
- package/dist/__tests__/inline-handler.test.d.ts.map +1 -0
- package/dist/__tests__/inline-handler.test.js +101 -0
- package/dist/__tests__/inline-handler.test.js.map +1 -0
- package/dist/__tests__/lifecycle-logging.test.d.ts +12 -0
- package/dist/__tests__/lifecycle-logging.test.d.ts.map +1 -0
- package/dist/__tests__/lifecycle-logging.test.js +112 -0
- package/dist/__tests__/lifecycle-logging.test.js.map +1 -0
- package/dist/__tests__/middleware.test.d.ts +7 -0
- package/dist/__tests__/middleware.test.d.ts.map +1 -0
- package/dist/__tests__/middleware.test.js +109 -0
- package/dist/__tests__/middleware.test.js.map +1 -0
- package/dist/__tests__/module-needs.test.d.ts +10 -0
- package/dist/__tests__/module-needs.test.d.ts.map +1 -0
- package/dist/__tests__/module-needs.test.js +77 -0
- package/dist/__tests__/module-needs.test.js.map +1 -0
- package/dist/__tests__/module-topo-sort.test.d.ts +15 -0
- package/dist/__tests__/module-topo-sort.test.d.ts.map +1 -0
- package/dist/__tests__/module-topo-sort.test.js +105 -0
- package/dist/__tests__/module-topo-sort.test.js.map +1 -0
- package/dist/__tests__/multi-tenancy.test.d.ts +10 -0
- package/dist/__tests__/multi-tenancy.test.d.ts.map +1 -0
- package/dist/__tests__/multi-tenancy.test.js +122 -0
- package/dist/__tests__/multi-tenancy.test.js.map +1 -0
- package/dist/__tests__/needs-topology.test.d.ts +11 -0
- package/dist/__tests__/needs-topology.test.d.ts.map +1 -0
- package/dist/__tests__/needs-topology.test.js +82 -0
- package/dist/__tests__/needs-topology.test.js.map +1 -0
- package/dist/__tests__/plugin-closure.test.d.ts +15 -0
- package/dist/__tests__/plugin-closure.test.d.ts.map +1 -0
- package/dist/__tests__/plugin-closure.test.js +140 -0
- package/dist/__tests__/plugin-closure.test.js.map +1 -0
- package/dist/__tests__/plugin.test.d.ts +10 -0
- package/dist/__tests__/plugin.test.d.ts.map +1 -0
- package/dist/__tests__/plugin.test.js +225 -0
- package/dist/__tests__/plugin.test.js.map +1 -0
- package/dist/__tests__/primitives.test.d.ts +9 -0
- package/dist/__tests__/primitives.test.d.ts.map +1 -0
- package/dist/__tests__/primitives.test.js +434 -0
- package/dist/__tests__/primitives.test.js.map +1 -0
- package/dist/__tests__/production-readiness.test.d.ts +22 -0
- package/dist/__tests__/production-readiness.test.d.ts.map +1 -0
- package/dist/__tests__/production-readiness.test.js +196 -0
- package/dist/__tests__/production-readiness.test.js.map +1 -0
- package/dist/__tests__/provider.test.d.ts +6 -0
- package/dist/__tests__/provider.test.d.ts.map +1 -0
- package/dist/__tests__/provider.test.js +122 -0
- package/dist/__tests__/provider.test.js.map +1 -0
- package/dist/__tests__/public-marker.test.d.ts +7 -0
- package/dist/__tests__/public-marker.test.d.ts.map +1 -0
- package/dist/__tests__/public-marker.test.js +54 -0
- package/dist/__tests__/public-marker.test.js.map +1 -0
- package/dist/__tests__/retry-dlq.test.d.ts +6 -0
- package/dist/__tests__/retry-dlq.test.d.ts.map +1 -0
- package/dist/__tests__/retry-dlq.test.js +68 -0
- package/dist/__tests__/retry-dlq.test.js.map +1 -0
- package/dist/__tests__/validate.test.d.ts +5 -0
- package/dist/__tests__/validate.test.d.ts.map +1 -0
- package/dist/__tests__/validate.test.js +53 -0
- package/dist/__tests__/validate.test.js.map +1 -0
- package/dist/__tests__/workflow-saga.test.d.ts +7 -0
- package/dist/__tests__/workflow-saga.test.d.ts.map +1 -0
- package/dist/__tests__/workflow-saga.test.js +239 -0
- package/dist/__tests__/workflow-saga.test.js.map +1 -0
- package/dist/actor-store.d.ts +83 -0
- package/dist/actor-store.d.ts.map +1 -0
- package/dist/actor-store.js +85 -0
- package/dist/actor-store.js.map +1 -0
- package/dist/cli-runner.d.ts +46 -0
- package/dist/cli-runner.d.ts.map +1 -0
- package/dist/cli-runner.js +164 -0
- package/dist/cli-runner.js.map +1 -0
- package/dist/create-app.d.ts +131 -0
- package/dist/create-app.d.ts.map +1 -0
- package/dist/create-app.js +593 -0
- package/dist/create-app.js.map +1 -0
- package/dist/define-action.d.ts +148 -0
- package/dist/define-action.d.ts.map +1 -0
- package/dist/define-action.js +52 -0
- package/dist/define-action.js.map +1 -0
- package/dist/define-actor.d.ts +302 -0
- package/dist/define-actor.d.ts.map +1 -0
- package/dist/define-actor.js +294 -0
- package/dist/define-actor.js.map +1 -0
- package/dist/define-app.d.ts +104 -0
- package/dist/define-app.d.ts.map +1 -0
- package/dist/define-app.js +49 -0
- package/dist/define-app.js.map +1 -0
- package/dist/define-cron.d.ts +50 -0
- package/dist/define-cron.d.ts.map +1 -0
- package/dist/define-cron.js +34 -0
- package/dist/define-cron.js.map +1 -0
- package/dist/define-error.d.ts +10 -0
- package/dist/define-error.d.ts.map +1 -0
- package/dist/define-error.js +10 -0
- package/dist/define-error.js.map +1 -0
- package/dist/define-external-call.d.ts +85 -0
- package/dist/define-external-call.d.ts.map +1 -0
- package/dist/define-external-call.js +38 -0
- package/dist/define-external-call.js.map +1 -0
- package/dist/define-handler.d.ts +98 -0
- package/dist/define-handler.d.ts.map +1 -0
- package/dist/define-handler.js +29 -0
- package/dist/define-handler.js.map +1 -0
- package/dist/define-inbound-webhook.d.ts +82 -0
- package/dist/define-inbound-webhook.d.ts.map +1 -0
- package/dist/define-inbound-webhook.js +42 -0
- package/dist/define-inbound-webhook.js.map +1 -0
- package/dist/define-inbox.d.ts +40 -0
- package/dist/define-inbox.d.ts.map +1 -0
- package/dist/define-inbox.js +31 -0
- package/dist/define-inbox.js.map +1 -0
- package/dist/define-initializer.d.ts +54 -0
- package/dist/define-initializer.d.ts.map +1 -0
- package/dist/define-initializer.js +38 -0
- package/dist/define-initializer.js.map +1 -0
- package/dist/define-middleware.d.ts +8 -0
- package/dist/define-middleware.d.ts.map +1 -0
- package/dist/define-middleware.js +8 -0
- package/dist/define-middleware.js.map +1 -0
- package/dist/define-model.d.ts +10 -0
- package/dist/define-model.d.ts.map +1 -0
- package/dist/define-model.js +13 -0
- package/dist/define-model.js.map +1 -0
- package/dist/define-module.d.ts +157 -0
- package/dist/define-module.d.ts.map +1 -0
- package/dist/define-module.js +60 -0
- package/dist/define-module.js.map +1 -0
- package/dist/define-outbox.d.ts +47 -0
- package/dist/define-outbox.d.ts.map +1 -0
- package/dist/define-outbox.js +36 -0
- package/dist/define-outbox.js.map +1 -0
- package/dist/define-plugin.d.ts +171 -0
- package/dist/define-plugin.d.ts.map +1 -0
- package/dist/define-plugin.js +134 -0
- package/dist/define-plugin.js.map +1 -0
- package/dist/define-projection.d.ts +56 -0
- package/dist/define-projection.d.ts.map +1 -0
- package/dist/define-projection.js +44 -0
- package/dist/define-projection.js.map +1 -0
- package/dist/define-provider.d.ts +49 -0
- package/dist/define-provider.d.ts.map +1 -0
- package/dist/define-provider.js +45 -0
- package/dist/define-provider.js.map +1 -0
- package/dist/define-query.d.ts +50 -0
- package/dist/define-query.d.ts.map +1 -0
- package/dist/define-query.js +33 -0
- package/dist/define-query.js.map +1 -0
- package/dist/define-resolver.d.ts +111 -0
- package/dist/define-resolver.d.ts.map +1 -0
- package/dist/define-resolver.js +146 -0
- package/dist/define-resolver.js.map +1 -0
- package/dist/define-schema.d.ts +88 -0
- package/dist/define-schema.d.ts.map +1 -0
- package/dist/define-schema.js +72 -0
- package/dist/define-schema.js.map +1 -0
- package/dist/define-workflow.d.ts +193 -0
- package/dist/define-workflow.d.ts.map +1 -0
- package/dist/define-workflow.js +345 -0
- package/dist/define-workflow.js.map +1 -0
- package/dist/dev-logger.d.ts +41 -0
- package/dist/dev-logger.d.ts.map +1 -0
- package/dist/dev-logger.js +135 -0
- package/dist/dev-logger.js.map +1 -0
- package/dist/event-message.d.ts +37 -0
- package/dist/event-message.d.ts.map +1 -0
- package/dist/event-message.js +51 -0
- package/dist/event-message.js.map +1 -0
- package/dist/foundation.d.ts +14 -0
- package/dist/foundation.d.ts.map +1 -0
- package/dist/foundation.js +14 -0
- package/dist/foundation.js.map +1 -0
- package/dist/framework-event-bus.d.ts +13 -0
- package/dist/framework-event-bus.d.ts.map +1 -0
- package/dist/framework-event-bus.js +13 -0
- package/dist/framework-event-bus.js.map +1 -0
- package/dist/framework-events.d.ts +121 -0
- package/dist/framework-events.d.ts.map +1 -0
- package/dist/framework-events.js +67 -0
- package/dist/framework-events.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/module-surface.d.ts +47 -0
- package/dist/module-surface.d.ts.map +1 -0
- package/dist/module-surface.js +65 -0
- package/dist/module-surface.js.map +1 -0
- package/dist/projection-store.d.ts +26 -0
- package/dist/projection-store.d.ts.map +1 -0
- package/dist/projection-store.js +28 -0
- package/dist/projection-store.js.map +1 -0
- package/dist/public-marker.d.ts +35 -0
- package/dist/public-marker.d.ts.map +1 -0
- package/dist/public-marker.js +45 -0
- package/dist/public-marker.js.map +1 -0
- package/dist/response.d.ts +8 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +8 -0
- package/dist/response.js.map +1 -0
- package/dist/runtime.d.ts +497 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +1083 -0
- package/dist/runtime.js.map +1 -0
- package/dist/validate.d.ts +33 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +48 -0
- package/dist/validate.js.map +1 -0
- package/dist/when.d.ts +101 -0
- package/dist/when.d.ts.map +1 -0
- package/dist/when.js +57 -0
- package/dist/when.js.map +1 -0
- package/dist/workflow-timer-store.d.ts +78 -0
- package/dist/workflow-timer-store.d.ts.map +1 -0
- package/dist/workflow-timer-store.js +56 -0
- package/dist/workflow-timer-store.js.map +1 -0
- package/package.json +60 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime — the framework's dispatch + apply pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* 1. Register actions, actors, handlers, and `when` reactions.
|
|
6
|
+
* 2. `dispatch(action, input, envelope?)` — runs the action's handler,
|
|
7
|
+
* collects returned events, atomically: applies them to actors, runs
|
|
8
|
+
* reactions, schedules new timers, cancels old timers.
|
|
9
|
+
* 3. `publish(events, envelope)` — runs the apply-actors-and-reactions step
|
|
10
|
+
* for events produced outside a handler (rare; mostly internal).
|
|
11
|
+
* 4. `request(action, input)` — invoked from inside a handler to call
|
|
12
|
+
* another action with a derived child envelope. Returns the inner
|
|
13
|
+
* handler's result.
|
|
14
|
+
*
|
|
15
|
+
* The runtime owns commit atomicity:
|
|
16
|
+
* - All actor state changes from one event-publish either ALL persist or
|
|
17
|
+
* NONE persist (in-memory is naturally atomic; persistent stores rely
|
|
18
|
+
* on the adapter's transaction primitives).
|
|
19
|
+
* - Events fan out to actors first, then workflow reactions, then
|
|
20
|
+
* projections.
|
|
21
|
+
*/
|
|
22
|
+
import { rootContainer } from "@nwire/container";
|
|
23
|
+
import { seedEnvelope, deriveEnvelope } from "@nwire/envelope";
|
|
24
|
+
import { InMemoryWorkflowTimerStore, timerEventName, } from "./workflow-timer-store.js";
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
import { InMemoryActorStore, createInitialInstance, } from "./actor-store.js";
|
|
27
|
+
import { normalizeEventReturn } from "./event-message.js";
|
|
28
|
+
import { InMemoryProjectionStore } from "./projection-store.js";
|
|
29
|
+
import { NoopLogger, loggerForEnvelope } from "@nwire/logger";
|
|
30
|
+
import { InMemoryDeadLetterSink, buildDeadLetterEntry, } from "@nwire/dead-letter";
|
|
31
|
+
import { FrameworkEventBus } from "./framework-event-bus.js";
|
|
32
|
+
import { ActionDispatching, ActionCompleted, ActionFailed, } from "./framework-events.js";
|
|
33
|
+
/** Serialize an unknown thrown value into the canonical SerializedError shape. */
|
|
34
|
+
function serializeError(err) {
|
|
35
|
+
if (err instanceof Error) {
|
|
36
|
+
const out = {
|
|
37
|
+
name: err.name,
|
|
38
|
+
message: err.message,
|
|
39
|
+
stack: err.stack,
|
|
40
|
+
};
|
|
41
|
+
for (const k of Object.keys(err)) {
|
|
42
|
+
if (k === "name" || k === "message" || k === "stack")
|
|
43
|
+
continue;
|
|
44
|
+
out[k] = err[k];
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
return { name: "NonError", message: String(err) };
|
|
49
|
+
}
|
|
50
|
+
export class Runtime {
|
|
51
|
+
handlers = new Map();
|
|
52
|
+
actors = new Map();
|
|
53
|
+
/**
|
|
54
|
+
* Workflows keyed by event name. Built from each registered workflow's
|
|
55
|
+
* `subscribedEvents`. On publish, every workflow listening to an event
|
|
56
|
+
* gets `_fire`d with runtime-bound effects.
|
|
57
|
+
*/
|
|
58
|
+
workflowsByEvent = new Map();
|
|
59
|
+
/**
|
|
60
|
+
* Per-workflow instance state — keyed first by workflow name, then by
|
|
61
|
+
* correlation key. In-memory for slice 1; future adapters (Mongo, Redis)
|
|
62
|
+
* plug in via a WorkflowStore contract analogous to ActorStore.
|
|
63
|
+
*/
|
|
64
|
+
workflowInstances = new Map();
|
|
65
|
+
workflowInstanceStore(workflowName) {
|
|
66
|
+
let store = this.workflowInstances.get(workflowName);
|
|
67
|
+
if (!store) {
|
|
68
|
+
store = new Map();
|
|
69
|
+
this.workflowInstances.set(workflowName, store);
|
|
70
|
+
}
|
|
71
|
+
return store;
|
|
72
|
+
}
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
+
projections = new Map();
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
projectionsByEvent = new Map();
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
queries = new Map();
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
externalCalls = new Map();
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
+
externalCallExecutors = new Map();
|
|
83
|
+
container;
|
|
84
|
+
actorStore;
|
|
85
|
+
projectionStore;
|
|
86
|
+
logger;
|
|
87
|
+
deadLetterSink;
|
|
88
|
+
middlewares = [];
|
|
89
|
+
actorTransitionHooks = [];
|
|
90
|
+
bus;
|
|
91
|
+
publishToBus;
|
|
92
|
+
appName;
|
|
93
|
+
telemetryListeners = [];
|
|
94
|
+
/** Known external events — set by createApp from modules' needs.externalEvents. */
|
|
95
|
+
externalEventNames = new Set();
|
|
96
|
+
/** Public-event names (visibility: 'public') — set by createApp from modules' events. */
|
|
97
|
+
publicEventNames = new Set();
|
|
98
|
+
/** Saga timer store (default in-memory; pluggable via RuntimeOptions). */
|
|
99
|
+
workflowTimerStore;
|
|
100
|
+
/**
|
|
101
|
+
* Framework event bus — lifecycle + dispatch hooks. Plugins and apps
|
|
102
|
+
* subscribe with `runtime.onFramework(Event, handler)`; the runtime
|
|
103
|
+
* fires events at the appropriate sites in `dispatch`, `start`, `stop`.
|
|
104
|
+
*/
|
|
105
|
+
frameworkEvents;
|
|
106
|
+
constructor(options = {}) {
|
|
107
|
+
this.container = options.container ?? rootContainer;
|
|
108
|
+
this.actorStore = options.actorStore ?? new InMemoryActorStore();
|
|
109
|
+
this.projectionStore = options.projectionStore ?? new InMemoryProjectionStore();
|
|
110
|
+
this.logger = options.logger ?? new NoopLogger();
|
|
111
|
+
this.deadLetterSink = options.deadLetterSink ?? new InMemoryDeadLetterSink();
|
|
112
|
+
this.bus = options.bus;
|
|
113
|
+
this.publishToBus = options.publishToBus ?? false;
|
|
114
|
+
this.appName = options.appName ?? "app";
|
|
115
|
+
this.workflowTimerStore = options.workflowTimerStore ?? new InMemoryWorkflowTimerStore();
|
|
116
|
+
this.frameworkEvents = new FrameworkEventBus(this.logger);
|
|
117
|
+
// Bridge framework-event firings into the telemetry stream so every
|
|
118
|
+
// existing telemetry consumer (dev logger, Studio Live SSE, OTel
|
|
119
|
+
// exporter) sees lifecycle activity through the same channel as
|
|
120
|
+
// domain events. `namespace` = the second dotted segment so dev
|
|
121
|
+
// logger / Studio can group by phase (app / plugin / wire / …).
|
|
122
|
+
this.frameworkEvents.onFire((rec) => {
|
|
123
|
+
const parts = rec.eventName.split(".");
|
|
124
|
+
const namespace = parts.length >= 2 ? parts[1] : "framework";
|
|
125
|
+
this.emit({
|
|
126
|
+
kind: "lifecycle",
|
|
127
|
+
event: rec.eventName,
|
|
128
|
+
namespace,
|
|
129
|
+
payload: rec.payload,
|
|
130
|
+
phase: rec.phase,
|
|
131
|
+
appName: this.appName,
|
|
132
|
+
ts: rec.ts,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Subscribe to a framework event. Sugar over `runtime.frameworkEvents.on`
|
|
138
|
+
* so plugins can write `runtime.onFramework(ActionDispatching, ...)`.
|
|
139
|
+
* Returns an unsubscribe function.
|
|
140
|
+
*/
|
|
141
|
+
onFramework(event, handler, priority = 0) {
|
|
142
|
+
return this.frameworkEvents.on(event, handler, priority);
|
|
143
|
+
}
|
|
144
|
+
/** Internal — createApp registers known external event names. */
|
|
145
|
+
registerExternalEvent(eventName) {
|
|
146
|
+
this.externalEventNames.add(eventName);
|
|
147
|
+
}
|
|
148
|
+
/** Internal — createApp registers public event names (visibility: 'public'). */
|
|
149
|
+
registerPublicEvent(eventName) {
|
|
150
|
+
this.publicEventNames.add(eventName);
|
|
151
|
+
}
|
|
152
|
+
/** Internal — createApp registers actor-transition hooks from plugins. */
|
|
153
|
+
registerActorTransitionHook(hook) {
|
|
154
|
+
this.actorTransitionHooks.push(hook);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Register a dispatch middleware. Outermost first — the order you call
|
|
158
|
+
* `use()` is the order layers wrap (first `use` is the outermost layer).
|
|
159
|
+
* Middlewares run once per dispatch, outside the retry loop.
|
|
160
|
+
*/
|
|
161
|
+
use(middleware) {
|
|
162
|
+
this.middlewares.push(middleware);
|
|
163
|
+
}
|
|
164
|
+
// ─── Registration ────────────────────────────────────────────────
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
|
+
registerHandler(handler) {
|
|
167
|
+
const name = handler.action.name;
|
|
168
|
+
if (this.handlers.has(name)) {
|
|
169
|
+
throw new Error(`Runtime: handler for action "${name}" already registered.`);
|
|
170
|
+
}
|
|
171
|
+
this.handlers.set(name, handler);
|
|
172
|
+
}
|
|
173
|
+
registerActor(actor) {
|
|
174
|
+
if (this.actors.has(actor.name)) {
|
|
175
|
+
throw new Error(`Runtime: actor "${actor.name}" already registered.`);
|
|
176
|
+
}
|
|
177
|
+
this.actors.set(actor.name, actor);
|
|
178
|
+
}
|
|
179
|
+
/** Internal — createApp registers each module's workflows. */
|
|
180
|
+
registerWorkflow(workflow) {
|
|
181
|
+
for (const eventName of workflow.subscribedEvents) {
|
|
182
|
+
const list = this.workflowsByEvent.get(eventName) ?? [];
|
|
183
|
+
list.push(workflow);
|
|
184
|
+
this.workflowsByEvent.set(eventName, list);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
188
|
+
registerProjection(projection) {
|
|
189
|
+
if (this.projections.has(projection.name)) {
|
|
190
|
+
throw new Error(`Runtime: projection "${projection.name}" already registered.`);
|
|
191
|
+
}
|
|
192
|
+
this.projections.set(projection.name, projection);
|
|
193
|
+
for (const event of projection.listens) {
|
|
194
|
+
const list = this.projectionsByEvent.get(event.name) ?? [];
|
|
195
|
+
list.push(projection);
|
|
196
|
+
this.projectionsByEvent.set(event.name, list);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
200
|
+
registerQuery(query) {
|
|
201
|
+
if (this.queries.has(query.name)) {
|
|
202
|
+
throw new Error(`Runtime: query "${query.name}" already registered.`);
|
|
203
|
+
}
|
|
204
|
+
this.queries.set(query.name, query);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Register an external-call declaration. Modules announce their external
|
|
208
|
+
* calls so Studio + the static graph see them; wires (adapters) call
|
|
209
|
+
* `registerExternalCallExecutor` to provide the transport.
|
|
210
|
+
*/
|
|
211
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
212
|
+
registerExternalCall(def) {
|
|
213
|
+
if (this.externalCalls.has(def.name)) {
|
|
214
|
+
throw new Error(`Runtime: external call "${def.name}" already registered.`);
|
|
215
|
+
}
|
|
216
|
+
this.externalCalls.set(def.name, def);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Bind an executor (HTTP client / SDK wrapper / test mock) to a declared
|
|
220
|
+
* external call. Lookup is by `def.name`. Called by wires/adapters at
|
|
221
|
+
* boot. Idempotent: re-registering replaces the executor (useful for
|
|
222
|
+
* swapping mocks in tests).
|
|
223
|
+
*/
|
|
224
|
+
registerExternalCallExecutor(def, executor) {
|
|
225
|
+
this.externalCallExecutors.set(def.name, executor);
|
|
226
|
+
}
|
|
227
|
+
async executeExternalCall(def, request, envelope) {
|
|
228
|
+
const validated = def.request.parse(request);
|
|
229
|
+
const executor = this.externalCallExecutors.get(def.name);
|
|
230
|
+
const idempotencyKey = def.idempotencyKey?.(validated);
|
|
231
|
+
const target = `${def.target.provider}/${def.target.endpoint}`;
|
|
232
|
+
if (!executor) {
|
|
233
|
+
const err = new Error(`Runtime.externalCall: no executor registered for "${def.name}". ` +
|
|
234
|
+
`Wires/adapters must call runtime.registerExternalCallExecutor() at boot.`);
|
|
235
|
+
this.emit({
|
|
236
|
+
kind: "external.call.failed",
|
|
237
|
+
call: def.name,
|
|
238
|
+
target,
|
|
239
|
+
attempt: 1,
|
|
240
|
+
willRetry: false,
|
|
241
|
+
error: serializeError(err),
|
|
242
|
+
envelope,
|
|
243
|
+
appName: this.appName,
|
|
244
|
+
ts: new Date().toISOString(),
|
|
245
|
+
});
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
const retry = def.retry;
|
|
249
|
+
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
250
|
+
let attempt = 0;
|
|
251
|
+
let lastError;
|
|
252
|
+
while (attempt < maxAttempts) {
|
|
253
|
+
attempt++;
|
|
254
|
+
this.emit({
|
|
255
|
+
kind: "external.call.started",
|
|
256
|
+
call: def.name,
|
|
257
|
+
target,
|
|
258
|
+
idempotencyKey,
|
|
259
|
+
envelope,
|
|
260
|
+
appName: this.appName,
|
|
261
|
+
ts: new Date().toISOString(),
|
|
262
|
+
});
|
|
263
|
+
const t0 = performance.now();
|
|
264
|
+
try {
|
|
265
|
+
if (attempt > 1) {
|
|
266
|
+
const delay = computeBackoff(retry, attempt - 1);
|
|
267
|
+
if (delay > 0)
|
|
268
|
+
await sleep(delay);
|
|
269
|
+
}
|
|
270
|
+
const raw = await executor(validated, { idempotencyKey, attempt });
|
|
271
|
+
const response = def.response ? def.response.parse(raw) : raw;
|
|
272
|
+
this.emit({
|
|
273
|
+
kind: "external.call.completed",
|
|
274
|
+
call: def.name,
|
|
275
|
+
target,
|
|
276
|
+
durationMs: performance.now() - t0,
|
|
277
|
+
idempotencyKey,
|
|
278
|
+
envelope,
|
|
279
|
+
appName: this.appName,
|
|
280
|
+
ts: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
return response;
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
lastError = err;
|
|
286
|
+
this.emit({
|
|
287
|
+
kind: "external.call.failed",
|
|
288
|
+
call: def.name,
|
|
289
|
+
target,
|
|
290
|
+
attempt,
|
|
291
|
+
willRetry: attempt < maxAttempts,
|
|
292
|
+
error: serializeError(err),
|
|
293
|
+
envelope,
|
|
294
|
+
appName: this.appName,
|
|
295
|
+
ts: new Date().toISOString(),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
throw lastError;
|
|
300
|
+
}
|
|
301
|
+
// ─── Query execution ─────────────────────────────────────────────
|
|
302
|
+
async query(queryName, input, tenant = "") {
|
|
303
|
+
const query = this.queries.get(queryName);
|
|
304
|
+
if (!query) {
|
|
305
|
+
throw new Error(`Runtime: no query registered with name "${queryName}".`);
|
|
306
|
+
}
|
|
307
|
+
const t0 = performance.now();
|
|
308
|
+
const validated = query.schema.parse(input);
|
|
309
|
+
const state = (await this.projectionStore.load(query.projection.name, tenant)) ??
|
|
310
|
+
query.projection.initial();
|
|
311
|
+
const result = (await query.execute(state, validated));
|
|
312
|
+
this.emit({
|
|
313
|
+
kind: "query.executed",
|
|
314
|
+
query: queryName,
|
|
315
|
+
input: validated,
|
|
316
|
+
durationMs: performance.now() - t0,
|
|
317
|
+
tenant,
|
|
318
|
+
appName: this.appName,
|
|
319
|
+
ts: new Date().toISOString(),
|
|
320
|
+
});
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
// ─── Introspection ───────────────────────────────────────────────
|
|
324
|
+
getActorStore() {
|
|
325
|
+
return this.actorStore;
|
|
326
|
+
}
|
|
327
|
+
getProjectionStore() {
|
|
328
|
+
return this.projectionStore;
|
|
329
|
+
}
|
|
330
|
+
getContainer() {
|
|
331
|
+
return this.container;
|
|
332
|
+
}
|
|
333
|
+
listHandlers() {
|
|
334
|
+
return [...this.handlers.keys()];
|
|
335
|
+
}
|
|
336
|
+
listActors() {
|
|
337
|
+
return [...this.actors.keys()];
|
|
338
|
+
}
|
|
339
|
+
listProjections() {
|
|
340
|
+
return [...this.projections.keys()];
|
|
341
|
+
}
|
|
342
|
+
listQueries() {
|
|
343
|
+
return [...this.queries.keys()];
|
|
344
|
+
}
|
|
345
|
+
listExternalCalls() {
|
|
346
|
+
return [...this.externalCalls.keys()];
|
|
347
|
+
}
|
|
348
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
349
|
+
getExternalCall(name) {
|
|
350
|
+
return this.externalCalls.get(name);
|
|
351
|
+
}
|
|
352
|
+
// ─── Timer firing (durable scheduler primitive) ──────────────────
|
|
353
|
+
/**
|
|
354
|
+
* Walk every registered actor's instances; for each `activeTimers[name]`
|
|
355
|
+
* with `fireAt <= now`, dispatch the timer's action. Removes fired timers
|
|
356
|
+
* from the instance's `activeTimers` map.
|
|
357
|
+
*
|
|
358
|
+
* Returns the count of timers that fired. Idempotent: a timer's `fireAt`
|
|
359
|
+
* is not bumped, so a second call after `now` advances will not re-fire
|
|
360
|
+
* the same timer (it was removed).
|
|
361
|
+
*
|
|
362
|
+
* For tests: pass `now` to fast-forward (`runtime.fireDueTimers(Date.now() + 3 * 86400_000)`).
|
|
363
|
+
* For production: a transport (BullMQ, polling worker) calls this on an
|
|
364
|
+
* interval; see `startTimerScheduler(app, intervalMs)` in `create-app.ts`.
|
|
365
|
+
*/
|
|
366
|
+
async fireDueTimers(now = Date.now()) {
|
|
367
|
+
let fired = 0;
|
|
368
|
+
for (const actor of this.actors.values()) {
|
|
369
|
+
const instances = await this.actorStore.listInstances(actor.name);
|
|
370
|
+
for (const instance of instances) {
|
|
371
|
+
const due = [];
|
|
372
|
+
for (const [timerName, handle] of Object.entries(instance.activeTimers)) {
|
|
373
|
+
if (handle.fireAt <= now) {
|
|
374
|
+
due.push([timerName, handle]);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (due.length === 0)
|
|
378
|
+
continue;
|
|
379
|
+
// Remove fired timers BEFORE dispatching, so handlers that
|
|
380
|
+
// re-enter `fireDueTimers` (rare but possible) don't see them.
|
|
381
|
+
const remainingTimers = {};
|
|
382
|
+
for (const [name, handle] of Object.entries(instance.activeTimers)) {
|
|
383
|
+
if (handle.fireAt > now)
|
|
384
|
+
remainingTimers[name] = handle;
|
|
385
|
+
}
|
|
386
|
+
await this.actorStore.save({
|
|
387
|
+
...instance,
|
|
388
|
+
activeTimers: remainingTimers,
|
|
389
|
+
});
|
|
390
|
+
// Dispatch each fired timer's action in the actor's tenant
|
|
391
|
+
// scope so the handler chain stays inside the right partition.
|
|
392
|
+
const tenantEnvelope = seedEnvelope({
|
|
393
|
+
tenant: instance.tenant || undefined,
|
|
394
|
+
});
|
|
395
|
+
for (const [timerName, handle] of due) {
|
|
396
|
+
const action = this.findActionByName(handle.action);
|
|
397
|
+
if (!action) {
|
|
398
|
+
// Action not registered — skip gracefully; the timer
|
|
399
|
+
// fired but its target is gone.
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
this.emit({
|
|
403
|
+
kind: "timer.fired",
|
|
404
|
+
actor: actor.name,
|
|
405
|
+
key: instance.key,
|
|
406
|
+
timer: timerName,
|
|
407
|
+
action: handle.action,
|
|
408
|
+
lateByMs: Math.max(0, now - handle.fireAt),
|
|
409
|
+
tenant: instance.tenant,
|
|
410
|
+
appName: this.appName,
|
|
411
|
+
ts: new Date().toISOString(),
|
|
412
|
+
});
|
|
413
|
+
await this.dispatch(action, handle.input, tenantEnvelope);
|
|
414
|
+
fired++;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return fired;
|
|
419
|
+
}
|
|
420
|
+
findActionByName(name) {
|
|
421
|
+
const handler = this.handlers.get(name);
|
|
422
|
+
return handler?.action;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Drain every due saga timer from the workflow timer store and route
|
|
426
|
+
* each as a synthetic event back into the originating workflow. The
|
|
427
|
+
* store removes drained timers atomically (see `WorkflowTimerStore`
|
|
428
|
+
* contract); calling `fireDueWorkflowTimers` twice with the same `now`
|
|
429
|
+
* MUST be a no-op the second time.
|
|
430
|
+
*
|
|
431
|
+
* Returns the count of timers fired.
|
|
432
|
+
*
|
|
433
|
+
* Tests: pass `now` to fast-forward
|
|
434
|
+
* (`runtime.fireDueWorkflowTimers(new Date(Date.now() + 8 * 86400_000))`).
|
|
435
|
+
* Production: the same polling loop that calls `fireDueTimers` calls
|
|
436
|
+
* this — they share the timer tick infrastructure.
|
|
437
|
+
*/
|
|
438
|
+
async fireDueWorkflowTimers(now = new Date()) {
|
|
439
|
+
let fired = 0;
|
|
440
|
+
const envelope = seedEnvelope({});
|
|
441
|
+
for await (const timer of this.workflowTimerStore.drainDue(now)) {
|
|
442
|
+
// Synthesize an event so the standard `runWorkflows` path handles
|
|
443
|
+
// routing. The event name is the canonical timer name; the payload
|
|
444
|
+
// carries through what `schedule()` was called with.
|
|
445
|
+
//
|
|
446
|
+
// Critical: the timer record carries the originating saga's
|
|
447
|
+
// `correlationKey`. We must thread it through so the workflow loads
|
|
448
|
+
// the right instance (the one that scheduled the timer) — not a
|
|
449
|
+
// fresh "__default__" instance. The synthetic timer event has no
|
|
450
|
+
// shape the user's `correlate()` map can recognize.
|
|
451
|
+
const eventName = timerEventName(timer.workflowName, timer.timerName);
|
|
452
|
+
await this.runWorkflows({ eventName, payload: timer.payload }, envelope, timer.correlationKey);
|
|
453
|
+
fired++;
|
|
454
|
+
}
|
|
455
|
+
return fired;
|
|
456
|
+
}
|
|
457
|
+
// ─── Dispatch ────────────────────────────────────────────────────
|
|
458
|
+
/**
|
|
459
|
+
* Run an action through its handler, then atomically apply returned events.
|
|
460
|
+
* Returns the handler's raw return value (events) for callers that want it.
|
|
461
|
+
*/
|
|
462
|
+
async dispatch(action, input, parentEnvelope) {
|
|
463
|
+
const handler = this.handlers.get(action.name);
|
|
464
|
+
if (!handler) {
|
|
465
|
+
throw new Error(`Runtime: no handler registered for action "${action.name}".`);
|
|
466
|
+
}
|
|
467
|
+
const envelope = parentEnvelope ? deriveEnvelope(parentEnvelope) : seedEnvelope({});
|
|
468
|
+
const validated = action.schema.parse(input);
|
|
469
|
+
const log = loggerForEnvelope(this.logger, envelope);
|
|
470
|
+
const ctx = this.buildHandlerContext(envelope, log);
|
|
471
|
+
this.emit({
|
|
472
|
+
kind: "action.dispatched",
|
|
473
|
+
action: action.name,
|
|
474
|
+
input: validated,
|
|
475
|
+
envelope,
|
|
476
|
+
appName: this.appName,
|
|
477
|
+
ts: new Date().toISOString(),
|
|
478
|
+
});
|
|
479
|
+
const startedAt = performance.now();
|
|
480
|
+
// Framework event: ActionDispatching — interceptable. A subscriber
|
|
481
|
+
// returning `false` cleanly cancels the dispatch (no throw, no events,
|
|
482
|
+
// empty handler return). Throwing from a subscriber fails the dispatch
|
|
483
|
+
// as if the handler itself threw.
|
|
484
|
+
const dispatchAllowed = await this.frameworkEvents.fire(ActionDispatching, {
|
|
485
|
+
action,
|
|
486
|
+
input: validated,
|
|
487
|
+
ctx,
|
|
488
|
+
});
|
|
489
|
+
if (!dispatchAllowed) {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
// Core: retry loop + handler invocation + event publishing.
|
|
493
|
+
// Returns the raw handler return for the dispatcher to type-cast.
|
|
494
|
+
const core = async () => {
|
|
495
|
+
const retry = action.retry;
|
|
496
|
+
const maxAttempts = 1 + (retry?.max ?? 0);
|
|
497
|
+
let attempt = 0;
|
|
498
|
+
let lastError;
|
|
499
|
+
while (attempt < maxAttempts) {
|
|
500
|
+
attempt++;
|
|
501
|
+
try {
|
|
502
|
+
if (attempt > 1) {
|
|
503
|
+
const delay = computeBackoff(retry, attempt - 1);
|
|
504
|
+
log.warn(`retrying handler`, {
|
|
505
|
+
action: action.name,
|
|
506
|
+
attempt,
|
|
507
|
+
maxAttempts,
|
|
508
|
+
delayMs: delay,
|
|
509
|
+
});
|
|
510
|
+
if (delay > 0)
|
|
511
|
+
await sleep(delay);
|
|
512
|
+
}
|
|
513
|
+
const rawResult = await handler.handler(validated, ctx);
|
|
514
|
+
const events = normalizeEventReturn(rawResult ?? null);
|
|
515
|
+
if (events.length > 0) {
|
|
516
|
+
await this.publish(events, envelope);
|
|
517
|
+
}
|
|
518
|
+
const durationMs = performance.now() - startedAt;
|
|
519
|
+
this.emit({
|
|
520
|
+
kind: "action.completed",
|
|
521
|
+
action: action.name,
|
|
522
|
+
durationMs,
|
|
523
|
+
emittedEvents: events.map((e) => e.eventName),
|
|
524
|
+
envelope,
|
|
525
|
+
appName: this.appName,
|
|
526
|
+
ts: new Date().toISOString(),
|
|
527
|
+
});
|
|
528
|
+
// Framework event: ActionCompleted (parallel, observable).
|
|
529
|
+
// Don't await — observers shouldn't block the response path.
|
|
530
|
+
void this.frameworkEvents.fire(ActionCompleted, {
|
|
531
|
+
action,
|
|
532
|
+
input: validated,
|
|
533
|
+
result: rawResult ?? undefined,
|
|
534
|
+
durationMs,
|
|
535
|
+
});
|
|
536
|
+
return rawResult ?? undefined;
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
lastError = err;
|
|
540
|
+
log.error(`handler threw`, {
|
|
541
|
+
action: action.name,
|
|
542
|
+
attempt,
|
|
543
|
+
maxAttempts,
|
|
544
|
+
error: err?.message,
|
|
545
|
+
});
|
|
546
|
+
this.emit({
|
|
547
|
+
kind: "action.failed",
|
|
548
|
+
action: action.name,
|
|
549
|
+
attempt,
|
|
550
|
+
maxAttempts,
|
|
551
|
+
willRetry: attempt < maxAttempts,
|
|
552
|
+
error: serializeError(err),
|
|
553
|
+
envelope,
|
|
554
|
+
appName: this.appName,
|
|
555
|
+
ts: new Date().toISOString(),
|
|
556
|
+
});
|
|
557
|
+
if (attempt >= maxAttempts) {
|
|
558
|
+
// Final failure — fire ActionFailed exactly once (not per
|
|
559
|
+
// retry attempt; the observable view is "this dispatch failed",
|
|
560
|
+
// not "the n-th attempt threw"). Parallel + non-awaited.
|
|
561
|
+
void this.frameworkEvents.fire(ActionFailed, {
|
|
562
|
+
action,
|
|
563
|
+
input: validated,
|
|
564
|
+
error: err,
|
|
565
|
+
durationMs: performance.now() - startedAt,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// All attempts failed → dead-letter and re-raise.
|
|
571
|
+
const entry = buildDeadLetterEntry(action.name, validated, envelope, attempt, lastError);
|
|
572
|
+
await this.deadLetterSink.record(entry);
|
|
573
|
+
log.error(`dead-lettered after ${attempt} attempts`, {
|
|
574
|
+
action: action.name,
|
|
575
|
+
error: entry.lastError.message,
|
|
576
|
+
});
|
|
577
|
+
this.emit({
|
|
578
|
+
kind: "dlq.recorded",
|
|
579
|
+
action: action.name,
|
|
580
|
+
attempts: attempt,
|
|
581
|
+
error: serializeError(lastError),
|
|
582
|
+
envelope,
|
|
583
|
+
appName: this.appName,
|
|
584
|
+
ts: new Date().toISOString(),
|
|
585
|
+
});
|
|
586
|
+
throw lastError;
|
|
587
|
+
};
|
|
588
|
+
// Compose middlewares around the core. Outermost first (registration
|
|
589
|
+
// order is execution order — first `use(m)` is the outermost layer).
|
|
590
|
+
let pipeline = core;
|
|
591
|
+
for (let i = this.middlewares.length - 1; i >= 0; i--) {
|
|
592
|
+
const mw = this.middlewares[i];
|
|
593
|
+
const inner = pipeline;
|
|
594
|
+
pipeline = () => mw(inner, action, validated, ctx);
|
|
595
|
+
}
|
|
596
|
+
const result = await pipeline();
|
|
597
|
+
return result;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Apply a batch of events: route to actors (state transitions + assigns +
|
|
601
|
+
* timer scheduling), fold projections, then fire workflows. Used internally
|
|
602
|
+
* by `dispatch` and exposed for tests and rare external publishes.
|
|
603
|
+
*/
|
|
604
|
+
async publish(events, parentEnvelope) {
|
|
605
|
+
for (const event of events) {
|
|
606
|
+
const childEnvelope = deriveEnvelope(parentEnvelope);
|
|
607
|
+
// Ordering: actors → projections → workflows.
|
|
608
|
+
// - actors first: state must be coherent before observers see it.
|
|
609
|
+
// - projections second: workflows often read state via queries
|
|
610
|
+
// (`ctx.request(query, ...)`), and chained dispatches via
|
|
611
|
+
// `ctx.request(action, ...)` will themselves fold projections —
|
|
612
|
+
// we must avoid stale reads, so projections fold before any
|
|
613
|
+
// workflow-triggered chain begins.
|
|
614
|
+
// - workflows last. Workflows produced events (translator pattern)
|
|
615
|
+
// publish recursively through this same method, so the full
|
|
616
|
+
// pipeline applies to derived events too.
|
|
617
|
+
await this.applyToActors(event, childEnvelope);
|
|
618
|
+
await this.foldProjections(event, childEnvelope);
|
|
619
|
+
await this.runWorkflows(event, childEnvelope);
|
|
620
|
+
// Telemetry tap — emit `event.published` after committed apply.
|
|
621
|
+
this.emit({
|
|
622
|
+
kind: "event.published",
|
|
623
|
+
event,
|
|
624
|
+
envelope: childEnvelope,
|
|
625
|
+
source: "in-process",
|
|
626
|
+
appName: this.appName,
|
|
627
|
+
ts: new Date().toISOString(),
|
|
628
|
+
});
|
|
629
|
+
// Cross-service fan-out: send public events to the bus AFTER the
|
|
630
|
+
// in-process apply succeeded. Subscribers in other services will
|
|
631
|
+
// call `applyExternalEvent` on their own runtime. Internal events
|
|
632
|
+
// (visibility: 'internal') stay in-process — explicit gate.
|
|
633
|
+
if (this.publishToBus && this.bus && this.publicEventNames.has(event.eventName)) {
|
|
634
|
+
await this.bus.publish({
|
|
635
|
+
eventName: event.eventName,
|
|
636
|
+
payload: event.payload,
|
|
637
|
+
envelope: childEnvelope,
|
|
638
|
+
origin: this.appName,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Tap into the canonical telemetry stream. One subscriber sees every kind:
|
|
645
|
+
* `action.dispatched` / `.completed` / `.failed`, `event.published`,
|
|
646
|
+
* `actor.transitioned`, `projection.folded`, `reaction.fired` / `.failed`,
|
|
647
|
+
* `query.executed`, `timer.scheduled` / `.fired`, `dlq.recorded`.
|
|
648
|
+
*
|
|
649
|
+
* Listeners run AFTER the corresponding lifecycle action commits — they
|
|
650
|
+
* observe what actually happened, not in-flight intent. Throwing in a
|
|
651
|
+
* listener is caught and logged; it never breaks domain dispatch.
|
|
652
|
+
*
|
|
653
|
+
* Returns an unsubscribe function.
|
|
654
|
+
*/
|
|
655
|
+
onTelemetry(listener) {
|
|
656
|
+
this.telemetryListeners.push(listener);
|
|
657
|
+
return () => this.offTelemetry(listener);
|
|
658
|
+
}
|
|
659
|
+
offTelemetry(listener) {
|
|
660
|
+
const i = this.telemetryListeners.indexOf(listener);
|
|
661
|
+
if (i >= 0)
|
|
662
|
+
this.telemetryListeners.splice(i, 1);
|
|
663
|
+
}
|
|
664
|
+
emit(record) {
|
|
665
|
+
if (this.telemetryListeners.length === 0)
|
|
666
|
+
return;
|
|
667
|
+
for (const listener of this.telemetryListeners) {
|
|
668
|
+
try {
|
|
669
|
+
listener(record);
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
// Best-effort — don't let a bad listener break dispatch.
|
|
673
|
+
// eslint-disable-next-line no-console
|
|
674
|
+
console.error("Runtime.onTelemetry listener threw:", err);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Apply an event that arrived from the cross-service bus. Same pipeline
|
|
680
|
+
* as `publish` (actors → projections → workflows) but does NOT re-publish
|
|
681
|
+
* to the bus — avoids fan-out loops between services. The runtime tracks
|
|
682
|
+
* which event names it declared as external (via createApp's wiring of
|
|
683
|
+
* modules' `needs.externalEvents`); calls for other names throw to catch
|
|
684
|
+
* misconfigured subscriptions early.
|
|
685
|
+
*/
|
|
686
|
+
async applyExternalEvent(eventName, payload, envelope) {
|
|
687
|
+
if (!this.externalEventNames.has(eventName)) {
|
|
688
|
+
throw new Error(`Runtime.applyExternalEvent: "${eventName}" was not declared in any module's needs.externalEvents.`);
|
|
689
|
+
}
|
|
690
|
+
const event = { eventName, payload };
|
|
691
|
+
await this.applyToActors(event, envelope);
|
|
692
|
+
await this.foldProjections(event, envelope);
|
|
693
|
+
await this.runWorkflows(event, envelope);
|
|
694
|
+
this.emit({
|
|
695
|
+
kind: "event.published",
|
|
696
|
+
event,
|
|
697
|
+
envelope,
|
|
698
|
+
source: "external",
|
|
699
|
+
appName: this.appName,
|
|
700
|
+
ts: new Date().toISOString(),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
async foldProjections(event, envelope) {
|
|
704
|
+
const projections = this.projectionsByEvent.get(event.eventName);
|
|
705
|
+
if (!projections || projections.length === 0)
|
|
706
|
+
return;
|
|
707
|
+
const tenant = envelope.tenant ?? "";
|
|
708
|
+
for (const projection of projections) {
|
|
709
|
+
const reducer = projection.on[event.eventName];
|
|
710
|
+
if (!reducer)
|
|
711
|
+
continue;
|
|
712
|
+
const t0 = performance.now();
|
|
713
|
+
const current = (await this.projectionStore.load(projection.name, tenant)) ?? projection.initial();
|
|
714
|
+
const next = reducer(current, event.payload);
|
|
715
|
+
await this.projectionStore.save(projection.name, next, tenant);
|
|
716
|
+
this.emit({
|
|
717
|
+
kind: "projection.folded",
|
|
718
|
+
projection: projection.name,
|
|
719
|
+
event: event.eventName,
|
|
720
|
+
tenant,
|
|
721
|
+
durationMs: performance.now() - t0,
|
|
722
|
+
envelope,
|
|
723
|
+
appName: this.appName,
|
|
724
|
+
ts: new Date().toISOString(),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
// ─── Internal: actor dispatch ────────────────────────────────────
|
|
729
|
+
async applyToActors(event, envelope) {
|
|
730
|
+
const tenant = envelope.tenant ?? "";
|
|
731
|
+
for (const actor of this.actors.values()) {
|
|
732
|
+
const reactionsForEvent = actor.eventIndex.get(event.eventName);
|
|
733
|
+
if (!reactionsForEvent || reactionsForEvent.length === 0)
|
|
734
|
+
continue;
|
|
735
|
+
const key = this.extractKey(event, actor);
|
|
736
|
+
if (key === undefined || key === null) {
|
|
737
|
+
// Event doesn't carry this actor's key — not addressed to it.
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
await this.applyEventToActor(actor, String(key), tenant, event, reactionsForEvent, envelope);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
extractKey(event, actor) {
|
|
744
|
+
const payload = event.payload;
|
|
745
|
+
if (!payload || typeof payload !== "object")
|
|
746
|
+
return undefined;
|
|
747
|
+
return payload[actor.key];
|
|
748
|
+
}
|
|
749
|
+
async applyEventToActor(actor, key, tenant, event, candidateReactions, envelope) {
|
|
750
|
+
const existing = await this.actorStore.load(actor.name, key, tenant);
|
|
751
|
+
const instance = existing ?? createInitialInstance(actor, key, tenant);
|
|
752
|
+
const matching = candidateReactions.find((c) => c.state === instance.state);
|
|
753
|
+
if (!matching) {
|
|
754
|
+
// Actor is in a state that doesn't react to this event — silently
|
|
755
|
+
// skip. (Future: dead-letter / log; surfacing here is too noisy for
|
|
756
|
+
// events that fan out to many actors, only some of which match.)
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const stateConfig = actor.states[instance.state];
|
|
760
|
+
if (stateConfig?.final) {
|
|
761
|
+
// Defensive: a final state shouldn't have entries in eventIndex,
|
|
762
|
+
// but guard anyway.
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const partial = matching.reaction.assign
|
|
766
|
+
? matching.reaction.assign(instance.data, event.payload)
|
|
767
|
+
: {};
|
|
768
|
+
const nextData = { ...instance.data, ...partial };
|
|
769
|
+
const nextStateName = matching.reaction.target ?? instance.state;
|
|
770
|
+
const nextStateConfig = actor.states[nextStateName];
|
|
771
|
+
if (!nextStateConfig) {
|
|
772
|
+
throw new Error(`Runtime: actor "${actor.name}" reaction targets undeclared state "${nextStateName}" from "${instance.state}".`);
|
|
773
|
+
}
|
|
774
|
+
// Schema validation on save — invalid partial → throw, atomically
|
|
775
|
+
// skip persistence, re-raise to caller.
|
|
776
|
+
const validated = actor.schema.parse(nextData);
|
|
777
|
+
const stateChanged = nextStateName !== instance.state;
|
|
778
|
+
const isNewActor = !existing;
|
|
779
|
+
// Timers are owned by a state, not the actor. Compute them on:
|
|
780
|
+
// - state change (cancel old, schedule new), or
|
|
781
|
+
// - actor creation (born into a state — schedule its timers).
|
|
782
|
+
const nextTimers = stateChanged || isNewActor
|
|
783
|
+
? this.computeTimersForState(actor, nextStateName, key)
|
|
784
|
+
: instance.activeTimers;
|
|
785
|
+
const nextInstance = {
|
|
786
|
+
actorName: actor.name,
|
|
787
|
+
key,
|
|
788
|
+
tenant,
|
|
789
|
+
state: nextStateName,
|
|
790
|
+
data: validated,
|
|
791
|
+
activeTimers: nextTimers,
|
|
792
|
+
};
|
|
793
|
+
await this.actorStore.save(nextInstance);
|
|
794
|
+
if (stateChanged) {
|
|
795
|
+
this.emit({
|
|
796
|
+
kind: "actor.transitioned",
|
|
797
|
+
actor: actor.name,
|
|
798
|
+
key,
|
|
799
|
+
tenant,
|
|
800
|
+
from: instance.state,
|
|
801
|
+
to: nextStateName,
|
|
802
|
+
triggeringEvent: event.eventName,
|
|
803
|
+
envelope,
|
|
804
|
+
appName: this.appName,
|
|
805
|
+
ts: new Date().toISOString(),
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
// Fire actor-transition hooks (registered by plugins). Hooks run AFTER
|
|
809
|
+
// the save so they observe committed state. Errors propagate — plugins
|
|
810
|
+
// are infrastructure; we want loud failures, not silent skips.
|
|
811
|
+
if (this.actorTransitionHooks.length > 0 && stateChanged) {
|
|
812
|
+
for (const hook of this.actorTransitionHooks) {
|
|
813
|
+
await hook(actor, key, instance.state, nextStateName, event, envelope);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
computeTimersForState(actor, stateName, actorKey) {
|
|
818
|
+
const stateConfig = actor.states[stateName];
|
|
819
|
+
if (!stateConfig?.after)
|
|
820
|
+
return {};
|
|
821
|
+
const now = Date.now();
|
|
822
|
+
const timers = {};
|
|
823
|
+
for (const [timerName, spec] of Object.entries(stateConfig.after)) {
|
|
824
|
+
const delayString = typeof spec === "string" ? timerName : spec.delay;
|
|
825
|
+
const action = typeof spec === "string" ? spec : spec.action;
|
|
826
|
+
const input = typeof spec === "string" || !spec.buildInput
|
|
827
|
+
? { [actor.key]: actorKey }
|
|
828
|
+
: spec.buildInput({}, actorKey);
|
|
829
|
+
const handle = {
|
|
830
|
+
scheduledAt: now,
|
|
831
|
+
fireAt: now + parseDelay(delayString),
|
|
832
|
+
action,
|
|
833
|
+
input,
|
|
834
|
+
};
|
|
835
|
+
timers[timerName] = handle;
|
|
836
|
+
this.emit({
|
|
837
|
+
kind: "timer.scheduled",
|
|
838
|
+
actor: actor.name,
|
|
839
|
+
key: actorKey,
|
|
840
|
+
timer: timerName,
|
|
841
|
+
action,
|
|
842
|
+
fireAt: handle.fireAt,
|
|
843
|
+
tenant: "",
|
|
844
|
+
appName: this.appName,
|
|
845
|
+
ts: new Date().toISOString(),
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
return timers;
|
|
849
|
+
}
|
|
850
|
+
// ─── Internal: workflows ─────────────────────────────────────────
|
|
851
|
+
/**
|
|
852
|
+
* Fire every workflow subscribed to `event`. Each workflow receives a
|
|
853
|
+
* runtime-bound effects bag: `send`/`enqueue` go through `dispatch` for
|
|
854
|
+
* retry + telemetry parity with action handlers; `publish` goes back
|
|
855
|
+
* through `this.publish` so derived events flow through the full
|
|
856
|
+
* actors → projections → workflows pipeline (translator pattern).
|
|
857
|
+
*
|
|
858
|
+
* Telemetry emits as `workflow.fired` / `workflow.failed`.
|
|
859
|
+
*/
|
|
860
|
+
async runWorkflows(event, envelope,
|
|
861
|
+
/**
|
|
862
|
+
* Optional override for correlationKey. Used by `fireDueWorkflowTimers`
|
|
863
|
+
* to route a synthetic timer event back to the saga instance that
|
|
864
|
+
* scheduled it — `workflow.correlate(event)` can't determine the key
|
|
865
|
+
* from a synthetic timer payload alone.
|
|
866
|
+
*/
|
|
867
|
+
correlationKeyOverride) {
|
|
868
|
+
const workflows = this.workflowsByEvent.get(event.eventName);
|
|
869
|
+
if (!workflows || workflows.length === 0)
|
|
870
|
+
return;
|
|
871
|
+
const log = loggerForEnvelope(this.logger, envelope).child({
|
|
872
|
+
event: event.eventName,
|
|
873
|
+
});
|
|
874
|
+
const handlerCtx = this.buildHandlerContext(envelope, log);
|
|
875
|
+
const self = this;
|
|
876
|
+
const baseEffects = {
|
|
877
|
+
async send(action, input) {
|
|
878
|
+
return handlerCtx.request(action, input);
|
|
879
|
+
},
|
|
880
|
+
async enqueue(action, input) {
|
|
881
|
+
await handlerCtx.send(action, input);
|
|
882
|
+
},
|
|
883
|
+
async publish(eventMsg) {
|
|
884
|
+
await self.publish([eventMsg], envelope);
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
for (const workflow of workflows) {
|
|
888
|
+
const t0 = performance.now();
|
|
889
|
+
try {
|
|
890
|
+
const store = this.workflowInstanceStore(workflow.name);
|
|
891
|
+
// Workflow correlation MUST include the tenant axis. Without it,
|
|
892
|
+
// two tenants with the same business key (e.g. subscriptionId)
|
|
893
|
+
// share a saga instance — the second tenant's PaymentFailed sees
|
|
894
|
+
// the first tenant's state. The override path (from timer fires)
|
|
895
|
+
// already carries tenant context; only derive when no override.
|
|
896
|
+
const userKey = workflow.correlate?.(event) ?? "__default__";
|
|
897
|
+
const tenantPrefix = envelope.tenant ? `${envelope.tenant}::` : "";
|
|
898
|
+
const correlationKey = correlationKeyOverride ?? `${tenantPrefix}${userKey}`;
|
|
899
|
+
const fireCtx = {
|
|
900
|
+
...baseEffects,
|
|
901
|
+
load: (key) => store.get(key),
|
|
902
|
+
save: (key, instance) => store.set(key, instance),
|
|
903
|
+
drop: (key) => store.delete(key),
|
|
904
|
+
correlationKey,
|
|
905
|
+
scheduleTimer: async (timerName, delay, payload) => {
|
|
906
|
+
await this.workflowTimerStore.schedule({
|
|
907
|
+
id: randomUUID(),
|
|
908
|
+
workflowName: workflow.name,
|
|
909
|
+
correlationKey,
|
|
910
|
+
timerName,
|
|
911
|
+
fireAt: new Date(Date.now() + parseDelay(delay)).toISOString(),
|
|
912
|
+
payload,
|
|
913
|
+
});
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
await workflow._fire(event, fireCtx);
|
|
917
|
+
this.emit({
|
|
918
|
+
kind: "reaction.fired",
|
|
919
|
+
sourceEvent: event.eventName,
|
|
920
|
+
durationMs: performance.now() - t0,
|
|
921
|
+
envelope,
|
|
922
|
+
appName: this.appName,
|
|
923
|
+
ts: new Date().toISOString(),
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
this.emit({
|
|
928
|
+
kind: "reaction.failed",
|
|
929
|
+
sourceEvent: event.eventName,
|
|
930
|
+
error: serializeError(err),
|
|
931
|
+
envelope,
|
|
932
|
+
appName: this.appName,
|
|
933
|
+
ts: new Date().toISOString(),
|
|
934
|
+
});
|
|
935
|
+
throw err;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
// ─── Internal: handler context ───────────────────────────────────
|
|
940
|
+
buildHandlerContext(envelope, log) {
|
|
941
|
+
const self = this;
|
|
942
|
+
const logger = log ?? loggerForEnvelope(self.logger, envelope);
|
|
943
|
+
const ctx = {
|
|
944
|
+
container: self.container,
|
|
945
|
+
envelope,
|
|
946
|
+
logger,
|
|
947
|
+
resolve(name) {
|
|
948
|
+
return self.container.resolve(name);
|
|
949
|
+
},
|
|
950
|
+
get requestId() {
|
|
951
|
+
return envelope.messageId;
|
|
952
|
+
},
|
|
953
|
+
async request(action, input) {
|
|
954
|
+
const result = await self.dispatch(action, input, envelope);
|
|
955
|
+
return result;
|
|
956
|
+
},
|
|
957
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
958
|
+
async query(
|
|
959
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
960
|
+
queryDef, input) {
|
|
961
|
+
return self.query(queryDef.name, input, envelope.tenant ?? "");
|
|
962
|
+
},
|
|
963
|
+
async send(action, input) {
|
|
964
|
+
// For now, send is identical to request but result is ignored.
|
|
965
|
+
// Real fire-and-forget arrives with the queue transport.
|
|
966
|
+
await self.dispatch(action, input, envelope);
|
|
967
|
+
},
|
|
968
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
969
|
+
async use(actor, id) {
|
|
970
|
+
return self.loadActorView(actor, id, envelope);
|
|
971
|
+
},
|
|
972
|
+
async externalCall(call, request) {
|
|
973
|
+
return self.executeExternalCall(call, request, envelope);
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
return ctx;
|
|
977
|
+
}
|
|
978
|
+
// ─── Internal: actor view (ctx.use) ──────────────────────────────
|
|
979
|
+
async loadActorView(actor, id, envelope) {
|
|
980
|
+
if (!this.actors.has(actor.name)) {
|
|
981
|
+
throw new Error(`Runtime.use: actor "${actor.name}" is not registered. ` +
|
|
982
|
+
`Add it to a module's manifest.actors and pass that module to createApp.`);
|
|
983
|
+
}
|
|
984
|
+
const loaded = await this.actorStore.load(actor.name, id, envelope.tenant ?? "");
|
|
985
|
+
// Virgin instance — let `create` methods bootstrap a new actor.
|
|
986
|
+
// The bootstrap event flows through the actor's `on` transitions and
|
|
987
|
+
// populates state for subsequent dispatches.
|
|
988
|
+
const instance = loaded ?? {
|
|
989
|
+
name: actor.name,
|
|
990
|
+
key: id,
|
|
991
|
+
state: actor.initial,
|
|
992
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
993
|
+
data: {},
|
|
994
|
+
version: 0,
|
|
995
|
+
timers: [],
|
|
996
|
+
};
|
|
997
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
998
|
+
const view = {
|
|
999
|
+
state: instance.data,
|
|
1000
|
+
key: instance.key,
|
|
1001
|
+
stateName: instance.state,
|
|
1002
|
+
};
|
|
1003
|
+
// Closure-form actor: bind methods via the closure binder. recordThat
|
|
1004
|
+
// calls accumulate events; we publish them after each method call.
|
|
1005
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1006
|
+
const closureBinder = actor.closureBinder;
|
|
1007
|
+
if (closureBinder) {
|
|
1008
|
+
const bound = closureBinder(instance.data, instance.key);
|
|
1009
|
+
for (const [methodName, fn] of Object.entries(bound.methods)) {
|
|
1010
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1011
|
+
view[methodName] = async (...args) => {
|
|
1012
|
+
// Re-bind for the live state at call time (the actor may have been
|
|
1013
|
+
// updated since the view was created).
|
|
1014
|
+
const fresh = await this.actorStore.load(actor.name, id, envelope.tenant ?? "");
|
|
1015
|
+
const liveData = fresh?.data ?? instance.data;
|
|
1016
|
+
const localBound = closureBinder(liveData, instance.key);
|
|
1017
|
+
const localFn = localBound.methods[methodName];
|
|
1018
|
+
if (!localFn) {
|
|
1019
|
+
throw new Error(`Actor "${actor.name}" has no method "${methodName}".`);
|
|
1020
|
+
}
|
|
1021
|
+
const result = localFn(...args);
|
|
1022
|
+
// Publish recorded events through the runtime pipeline.
|
|
1023
|
+
if (localBound.recorded.length > 0) {
|
|
1024
|
+
const messages = localBound.recorded.map((r) => ({
|
|
1025
|
+
eventName: r.eventName,
|
|
1026
|
+
payload: r.payload,
|
|
1027
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1028
|
+
}));
|
|
1029
|
+
await this.publish(messages, envelope);
|
|
1030
|
+
}
|
|
1031
|
+
return result;
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
return view;
|
|
1035
|
+
}
|
|
1036
|
+
// Classic / schema-bound-object form: methods are (state, ...args) => event.
|
|
1037
|
+
const methods = actor.methods ?? {};
|
|
1038
|
+
for (const [methodName, fn] of Object.entries(methods)) {
|
|
1039
|
+
const method = fn;
|
|
1040
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1041
|
+
view[methodName] = (...args) => method(instance.data, ...args);
|
|
1042
|
+
}
|
|
1043
|
+
return view;
|
|
1044
|
+
}
|
|
1045
|
+
/** Test/inspection seam — read what's in the DLQ. */
|
|
1046
|
+
getDeadLetterSink() {
|
|
1047
|
+
return this.deadLetterSink;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
function sleep(ms) {
|
|
1051
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1052
|
+
}
|
|
1053
|
+
function computeBackoff(retry, attemptIndex) {
|
|
1054
|
+
if (!retry)
|
|
1055
|
+
return 0;
|
|
1056
|
+
const base = retry.baseDelayMs ?? 100;
|
|
1057
|
+
const cap = retry.maxDelayMs ?? 30_000;
|
|
1058
|
+
if (retry.backoff === "fixed")
|
|
1059
|
+
return Math.min(base, cap);
|
|
1060
|
+
// Exponential default — 2^(attempt-1) * base, capped.
|
|
1061
|
+
return Math.min(Math.floor(Math.pow(2, attemptIndex - 1) * base), cap);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Parse a delay string like '3d', '90s', '4h' into milliseconds.
|
|
1065
|
+
* Supports: ms, s, m, h, d. Numbers without units treated as milliseconds.
|
|
1066
|
+
*/
|
|
1067
|
+
export function parseDelay(value) {
|
|
1068
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(value);
|
|
1069
|
+
if (!match) {
|
|
1070
|
+
throw new Error(`Runtime: cannot parse delay "${value}". Expected '3d', '90s', '4h', '500ms'.`);
|
|
1071
|
+
}
|
|
1072
|
+
const n = Number(match[1]);
|
|
1073
|
+
const unit = match[2] ?? "ms";
|
|
1074
|
+
const multipliers = {
|
|
1075
|
+
ms: 1,
|
|
1076
|
+
s: 1000,
|
|
1077
|
+
m: 60_000,
|
|
1078
|
+
h: 3_600_000,
|
|
1079
|
+
d: 86_400_000,
|
|
1080
|
+
};
|
|
1081
|
+
return n * (multipliers[unit] ?? 1);
|
|
1082
|
+
}
|
|
1083
|
+
//# sourceMappingURL=runtime.js.map
|