@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.
Files changed (291) hide show
  1. package/README.md +133 -155
  2. package/dist/foundation.d.ts +0 -13
  3. package/dist/foundation.js +2 -13
  4. package/dist/framework-events.d.ts +51 -54
  5. package/dist/framework-events.js +18 -65
  6. package/dist/{cli-runner.d.ts → helpers/cli-runner.d.ts} +2 -3
  7. package/dist/{cli-runner.js → helpers/cli-runner.js} +11 -27
  8. package/dist/{public-marker.d.ts → helpers/public-marker.d.ts} +0 -1
  9. package/dist/{public-marker.js → helpers/public-marker.js} +0 -1
  10. package/dist/{response.d.ts → helpers/response.d.ts} +0 -1
  11. package/dist/{response.js → helpers/response.js} +0 -1
  12. package/dist/helpers/retry-helpers.d.ts +22 -0
  13. package/dist/helpers/retry-helpers.js +43 -0
  14. package/dist/{validate.d.ts → helpers/validate.d.ts} +0 -1
  15. package/dist/{validate.js → helpers/validate.js} +0 -1
  16. package/dist/index.d.ts +42 -48
  17. package/dist/index.js +47 -55
  18. package/dist/{event-message.d.ts → messages/event-message.d.ts} +0 -1
  19. package/dist/{event-message.js → messages/event-message.js} +0 -1
  20. package/dist/{dev-logger.d.ts → observability/dev-logger.d.ts} +2 -2
  21. package/dist/{dev-logger.js → observability/dev-logger.js} +0 -30
  22. package/dist/{define-action.d.ts → primitives/define-action.d.ts} +0 -1
  23. package/dist/{define-action.js → primitives/define-action.js} +0 -1
  24. package/dist/{define-actor.d.ts → primitives/define-actor.d.ts} +0 -1
  25. package/dist/{define-actor.js → primitives/define-actor.js} +0 -1
  26. package/dist/{define-cron.d.ts → primitives/define-cron.d.ts} +0 -1
  27. package/dist/{define-cron.js → primitives/define-cron.js} +0 -1
  28. package/dist/{define-error.d.ts → primitives/define-error.d.ts} +0 -1
  29. package/dist/{define-error.js → primitives/define-error.js} +0 -1
  30. package/dist/{define-external-call.d.ts → primitives/define-external-call.d.ts} +0 -1
  31. package/dist/{define-external-call.js → primitives/define-external-call.js} +0 -1
  32. package/dist/{define-handler.d.ts → primitives/define-handler.d.ts} +29 -2
  33. package/dist/{define-handler.js → primitives/define-handler.js} +13 -2
  34. package/dist/{define-inbound-webhook.d.ts → primitives/define-inbound-webhook.d.ts} +1 -2
  35. package/dist/{define-inbound-webhook.js → primitives/define-inbound-webhook.js} +1 -2
  36. package/dist/{define-inbox.d.ts → primitives/define-inbox.d.ts} +0 -1
  37. package/dist/{define-inbox.js → primitives/define-inbox.js} +0 -1
  38. package/dist/{define-outbox.d.ts → primitives/define-outbox.d.ts} +0 -1
  39. package/dist/{define-outbox.js → primitives/define-outbox.js} +0 -1
  40. package/dist/{define-projection.d.ts → primitives/define-projection.d.ts} +0 -1
  41. package/dist/{define-projection.js → primitives/define-projection.js} +0 -1
  42. package/dist/{define-query.d.ts → primitives/define-query.d.ts} +1 -2
  43. package/dist/{define-query.js → primitives/define-query.js} +1 -2
  44. package/dist/{define-schema.d.ts → primitives/define-schema.d.ts} +1 -2
  45. package/dist/{define-schema.js → primitives/define-schema.js} +1 -2
  46. package/dist/{define-upcaster.d.ts → primitives/define-upcaster.d.ts} +0 -1
  47. package/dist/{define-upcaster.js → primitives/define-upcaster.js} +0 -1
  48. package/dist/{define-workflow.d.ts → primitives/define-workflow.d.ts} +2 -3
  49. package/dist/{define-workflow.js → primitives/define-workflow.js} +2 -3
  50. package/dist/runtime/create-forge-app.d.ts +64 -0
  51. package/dist/runtime/create-forge-app.js +78 -0
  52. package/dist/runtime/forge-dispatcher.d.ts +148 -0
  53. package/dist/{runtime.js → runtime/forge-dispatcher.js} +242 -571
  54. package/dist/runtime/forge-plugin.d.ts +59 -0
  55. package/dist/runtime/forge-plugin.js +121 -0
  56. package/dist/runtime/forge-types.d.ts +204 -0
  57. package/dist/runtime/forge-types.js +5 -0
  58. package/dist/{actor-store.d.ts → stores/actor-store.d.ts} +1 -2
  59. package/dist/{actor-store.js → stores/actor-store.js} +0 -1
  60. package/dist/{idempotency-store.d.ts → stores/idempotency-store.d.ts} +0 -1
  61. package/dist/{idempotency-store.js → stores/idempotency-store.js} +0 -1
  62. package/dist/{projection-store.d.ts → stores/projection-store.d.ts} +0 -1
  63. package/dist/{projection-store.js → stores/projection-store.js} +0 -1
  64. package/dist/{workflow-timer-store.d.ts → stores/workflow-timer-store.d.ts} +0 -1
  65. package/dist/{workflow-timer-store.js → stores/workflow-timer-store.js} +0 -1
  66. package/package.json +11 -11
  67. package/dist/__tests__/action-hooks.test.d.ts +0 -8
  68. package/dist/__tests__/action-hooks.test.d.ts.map +0 -1
  69. package/dist/__tests__/action-hooks.test.js +0 -95
  70. package/dist/__tests__/action-hooks.test.js.map +0 -1
  71. package/dist/__tests__/actor-methods.test.d.ts +0 -9
  72. package/dist/__tests__/actor-methods.test.d.ts.map +0 -1
  73. package/dist/__tests__/actor-methods.test.js +0 -210
  74. package/dist/__tests__/actor-methods.test.js.map +0 -1
  75. package/dist/__tests__/actor-schema-bound.test.d.ts +0 -6
  76. package/dist/__tests__/actor-schema-bound.test.d.ts.map +0 -1
  77. package/dist/__tests__/actor-schema-bound.test.js +0 -112
  78. package/dist/__tests__/actor-schema-bound.test.js.map +0 -1
  79. package/dist/__tests__/actor-workflow-hooks.test.d.ts +0 -8
  80. package/dist/__tests__/actor-workflow-hooks.test.d.ts.map +0 -1
  81. package/dist/__tests__/actor-workflow-hooks.test.js +0 -106
  82. package/dist/__tests__/actor-workflow-hooks.test.js.map +0 -1
  83. package/dist/__tests__/app-capabilities.test.d.ts +0 -19
  84. package/dist/__tests__/app-capabilities.test.d.ts.map +0 -1
  85. package/dist/__tests__/app-capabilities.test.js +0 -57
  86. package/dist/__tests__/app-capabilities.test.js.map +0 -1
  87. package/dist/__tests__/cli-runner.test.d.ts +0 -6
  88. package/dist/__tests__/cli-runner.test.d.ts.map +0 -1
  89. package/dist/__tests__/cli-runner.test.js +0 -158
  90. package/dist/__tests__/cli-runner.test.js.map +0 -1
  91. package/dist/__tests__/create-app.test.d.ts +0 -18
  92. package/dist/__tests__/create-app.test.d.ts.map +0 -1
  93. package/dist/__tests__/create-app.test.js +0 -189
  94. package/dist/__tests__/create-app.test.js.map +0 -1
  95. package/dist/__tests__/cross-service-bus.test.d.ts +0 -8
  96. package/dist/__tests__/cross-service-bus.test.d.ts.map +0 -1
  97. package/dist/__tests__/cross-service-bus.test.js +0 -139
  98. package/dist/__tests__/cross-service-bus.test.js.map +0 -1
  99. package/dist/__tests__/define-schema.test.d.ts +0 -5
  100. package/dist/__tests__/define-schema.test.d.ts.map +0 -1
  101. package/dist/__tests__/define-schema.test.js +0 -83
  102. package/dist/__tests__/define-schema.test.js.map +0 -1
  103. package/dist/__tests__/dev-logger.test.d.ts +0 -9
  104. package/dist/__tests__/dev-logger.test.d.ts.map +0 -1
  105. package/dist/__tests__/dev-logger.test.js +0 -126
  106. package/dist/__tests__/dev-logger.test.js.map +0 -1
  107. package/dist/__tests__/external-call.test.d.ts +0 -14
  108. package/dist/__tests__/external-call.test.d.ts.map +0 -1
  109. package/dist/__tests__/external-call.test.js +0 -99
  110. package/dist/__tests__/external-call.test.js.map +0 -1
  111. package/dist/__tests__/framework-events.test.d.ts +0 -13
  112. package/dist/__tests__/framework-events.test.d.ts.map +0 -1
  113. package/dist/__tests__/framework-events.test.js +0 -204
  114. package/dist/__tests__/framework-events.test.js.map +0 -1
  115. package/dist/__tests__/inline-handler.test.d.ts +0 -8
  116. package/dist/__tests__/inline-handler.test.d.ts.map +0 -1
  117. package/dist/__tests__/inline-handler.test.js +0 -101
  118. package/dist/__tests__/inline-handler.test.js.map +0 -1
  119. package/dist/__tests__/lifecycle-logging.test.d.ts +0 -12
  120. package/dist/__tests__/lifecycle-logging.test.d.ts.map +0 -1
  121. package/dist/__tests__/lifecycle-logging.test.js +0 -114
  122. package/dist/__tests__/lifecycle-logging.test.js.map +0 -1
  123. package/dist/__tests__/middleware.test.d.ts +0 -7
  124. package/dist/__tests__/middleware.test.d.ts.map +0 -1
  125. package/dist/__tests__/middleware.test.js +0 -109
  126. package/dist/__tests__/middleware.test.js.map +0 -1
  127. package/dist/__tests__/module-needs.test.d.ts +0 -10
  128. package/dist/__tests__/module-needs.test.d.ts.map +0 -1
  129. package/dist/__tests__/module-needs.test.js +0 -77
  130. package/dist/__tests__/module-needs.test.js.map +0 -1
  131. package/dist/__tests__/module-topo-sort.test.d.ts +0 -15
  132. package/dist/__tests__/module-topo-sort.test.d.ts.map +0 -1
  133. package/dist/__tests__/module-topo-sort.test.js +0 -105
  134. package/dist/__tests__/module-topo-sort.test.js.map +0 -1
  135. package/dist/__tests__/multi-tenancy.test.d.ts +0 -10
  136. package/dist/__tests__/multi-tenancy.test.d.ts.map +0 -1
  137. package/dist/__tests__/multi-tenancy.test.js +0 -122
  138. package/dist/__tests__/multi-tenancy.test.js.map +0 -1
  139. package/dist/__tests__/needs-topology.test.d.ts +0 -11
  140. package/dist/__tests__/needs-topology.test.d.ts.map +0 -1
  141. package/dist/__tests__/needs-topology.test.js +0 -82
  142. package/dist/__tests__/needs-topology.test.js.map +0 -1
  143. package/dist/__tests__/plugin-app-narrow.test.d.ts +0 -12
  144. package/dist/__tests__/plugin-app-narrow.test.d.ts.map +0 -1
  145. package/dist/__tests__/plugin-app-narrow.test.js +0 -77
  146. package/dist/__tests__/plugin-app-narrow.test.js.map +0 -1
  147. package/dist/__tests__/plugin-closure.test.d.ts +0 -15
  148. package/dist/__tests__/plugin-closure.test.d.ts.map +0 -1
  149. package/dist/__tests__/plugin-closure.test.js +0 -140
  150. package/dist/__tests__/plugin-closure.test.js.map +0 -1
  151. package/dist/__tests__/plugin-stress.test.d.ts +0 -21
  152. package/dist/__tests__/plugin-stress.test.d.ts.map +0 -1
  153. package/dist/__tests__/plugin-stress.test.js +0 -203
  154. package/dist/__tests__/plugin-stress.test.js.map +0 -1
  155. package/dist/__tests__/plugin.test.d.ts +0 -10
  156. package/dist/__tests__/plugin.test.d.ts.map +0 -1
  157. package/dist/__tests__/plugin.test.js +0 -225
  158. package/dist/__tests__/plugin.test.js.map +0 -1
  159. package/dist/__tests__/primitives.test.d.ts +0 -9
  160. package/dist/__tests__/primitives.test.d.ts.map +0 -1
  161. package/dist/__tests__/primitives.test.js +0 -434
  162. package/dist/__tests__/primitives.test.js.map +0 -1
  163. package/dist/__tests__/production-readiness.test.d.ts +0 -22
  164. package/dist/__tests__/production-readiness.test.d.ts.map +0 -1
  165. package/dist/__tests__/production-readiness.test.js +0 -196
  166. package/dist/__tests__/production-readiness.test.js.map +0 -1
  167. package/dist/__tests__/provider.test.d.ts +0 -6
  168. package/dist/__tests__/provider.test.d.ts.map +0 -1
  169. package/dist/__tests__/provider.test.js +0 -122
  170. package/dist/__tests__/provider.test.js.map +0 -1
  171. package/dist/__tests__/public-marker.test.d.ts +0 -7
  172. package/dist/__tests__/public-marker.test.d.ts.map +0 -1
  173. package/dist/__tests__/public-marker.test.js +0 -58
  174. package/dist/__tests__/public-marker.test.js.map +0 -1
  175. package/dist/__tests__/retry-dlq.test.d.ts +0 -6
  176. package/dist/__tests__/retry-dlq.test.d.ts.map +0 -1
  177. package/dist/__tests__/retry-dlq.test.js +0 -68
  178. package/dist/__tests__/retry-dlq.test.js.map +0 -1
  179. package/dist/__tests__/validate.test.d.ts +0 -5
  180. package/dist/__tests__/validate.test.d.ts.map +0 -1
  181. package/dist/__tests__/validate.test.js +0 -53
  182. package/dist/__tests__/validate.test.js.map +0 -1
  183. package/dist/__tests__/workflow-saga.test.d.ts +0 -7
  184. package/dist/__tests__/workflow-saga.test.d.ts.map +0 -1
  185. package/dist/__tests__/workflow-saga.test.js +0 -265
  186. package/dist/__tests__/workflow-saga.test.js.map +0 -1
  187. package/dist/actor-store.d.ts.map +0 -1
  188. package/dist/actor-store.js.map +0 -1
  189. package/dist/cli-runner.d.ts.map +0 -1
  190. package/dist/cli-runner.js.map +0 -1
  191. package/dist/create-app.d.ts +0 -146
  192. package/dist/create-app.d.ts.map +0 -1
  193. package/dist/create-app.js +0 -703
  194. package/dist/create-app.js.map +0 -1
  195. package/dist/define-action.d.ts.map +0 -1
  196. package/dist/define-action.js.map +0 -1
  197. package/dist/define-actor.d.ts.map +0 -1
  198. package/dist/define-actor.js.map +0 -1
  199. package/dist/define-app.d.ts +0 -104
  200. package/dist/define-app.d.ts.map +0 -1
  201. package/dist/define-app.js +0 -49
  202. package/dist/define-app.js.map +0 -1
  203. package/dist/define-cron.d.ts.map +0 -1
  204. package/dist/define-cron.js.map +0 -1
  205. package/dist/define-error.d.ts.map +0 -1
  206. package/dist/define-error.js.map +0 -1
  207. package/dist/define-external-call.d.ts.map +0 -1
  208. package/dist/define-external-call.js.map +0 -1
  209. package/dist/define-handler.d.ts.map +0 -1
  210. package/dist/define-handler.js.map +0 -1
  211. package/dist/define-inbound-webhook.d.ts.map +0 -1
  212. package/dist/define-inbound-webhook.js.map +0 -1
  213. package/dist/define-inbox.d.ts.map +0 -1
  214. package/dist/define-inbox.js.map +0 -1
  215. package/dist/define-initializer.d.ts +0 -54
  216. package/dist/define-initializer.d.ts.map +0 -1
  217. package/dist/define-initializer.js +0 -38
  218. package/dist/define-initializer.js.map +0 -1
  219. package/dist/define-middleware.d.ts +0 -8
  220. package/dist/define-middleware.d.ts.map +0 -1
  221. package/dist/define-middleware.js +0 -8
  222. package/dist/define-middleware.js.map +0 -1
  223. package/dist/define-model.d.ts +0 -10
  224. package/dist/define-model.d.ts.map +0 -1
  225. package/dist/define-model.js +0 -13
  226. package/dist/define-model.js.map +0 -1
  227. package/dist/define-module.d.ts +0 -160
  228. package/dist/define-module.d.ts.map +0 -1
  229. package/dist/define-module.js +0 -63
  230. package/dist/define-module.js.map +0 -1
  231. package/dist/define-outbox.d.ts.map +0 -1
  232. package/dist/define-outbox.js.map +0 -1
  233. package/dist/define-plugin.d.ts +0 -195
  234. package/dist/define-plugin.d.ts.map +0 -1
  235. package/dist/define-plugin.js +0 -220
  236. package/dist/define-plugin.js.map +0 -1
  237. package/dist/define-projection.d.ts.map +0 -1
  238. package/dist/define-projection.js.map +0 -1
  239. package/dist/define-provider.d.ts +0 -49
  240. package/dist/define-provider.d.ts.map +0 -1
  241. package/dist/define-provider.js +0 -45
  242. package/dist/define-provider.js.map +0 -1
  243. package/dist/define-query.d.ts.map +0 -1
  244. package/dist/define-query.js.map +0 -1
  245. package/dist/define-resolver.d.ts +0 -111
  246. package/dist/define-resolver.d.ts.map +0 -1
  247. package/dist/define-resolver.js +0 -146
  248. package/dist/define-resolver.js.map +0 -1
  249. package/dist/define-schema.d.ts.map +0 -1
  250. package/dist/define-schema.js.map +0 -1
  251. package/dist/define-upcaster.d.ts.map +0 -1
  252. package/dist/define-upcaster.js.map +0 -1
  253. package/dist/define-workflow.d.ts.map +0 -1
  254. package/dist/define-workflow.js.map +0 -1
  255. package/dist/dev-logger.d.ts.map +0 -1
  256. package/dist/dev-logger.js.map +0 -1
  257. package/dist/event-message.d.ts.map +0 -1
  258. package/dist/event-message.js.map +0 -1
  259. package/dist/foundation.d.ts.map +0 -1
  260. package/dist/foundation.js.map +0 -1
  261. package/dist/framework-event-bus.d.ts +0 -13
  262. package/dist/framework-event-bus.d.ts.map +0 -1
  263. package/dist/framework-event-bus.js +0 -13
  264. package/dist/framework-event-bus.js.map +0 -1
  265. package/dist/framework-events.d.ts.map +0 -1
  266. package/dist/framework-events.js.map +0 -1
  267. package/dist/idempotency-store.d.ts.map +0 -1
  268. package/dist/idempotency-store.js.map +0 -1
  269. package/dist/index.d.ts.map +0 -1
  270. package/dist/index.js.map +0 -1
  271. package/dist/module-surface.d.ts +0 -47
  272. package/dist/module-surface.d.ts.map +0 -1
  273. package/dist/module-surface.js +0 -65
  274. package/dist/module-surface.js.map +0 -1
  275. package/dist/projection-store.d.ts.map +0 -1
  276. package/dist/projection-store.js.map +0 -1
  277. package/dist/public-marker.d.ts.map +0 -1
  278. package/dist/public-marker.js.map +0 -1
  279. package/dist/response.d.ts.map +0 -1
  280. package/dist/response.js.map +0 -1
  281. package/dist/runtime.d.ts +0 -621
  282. package/dist/runtime.d.ts.map +0 -1
  283. package/dist/runtime.js.map +0 -1
  284. package/dist/validate.d.ts.map +0 -1
  285. package/dist/validate.js.map +0 -1
  286. package/dist/when.d.ts +0 -101
  287. package/dist/when.d.ts.map +0 -1
  288. package/dist/when.js +0 -57
  289. package/dist/when.js.map +0 -1
  290. package/dist/workflow-timer-store.d.ts.map +0 -1
  291. package/dist/workflow-timer-store.js.map +0 -1
@@ -1,60 +1,45 @@
1
1
  /**
2
- * Runtime — the framework's dispatch + apply pipeline.
2
+ * `ForgeDispatcher` — the CQRS state container that forge layers on top of
3
+ * the kernel runtime.
3
4
  *
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.
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 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.
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 { InMemoryDeadLetterSink, buildDeadLetterEntry, } from "@nwire/dead-letter";
34
- import { ActionDispatching, ActionCompleted, ActionFailed, builtInFrameworkEvents, } from "./framework-events.js";
35
- export class Runtime extends RuntimeBase {
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 slice 1; future adapters (Mongo, Redis)
47
- * plug in via a WorkflowStore contract analogous to ActorStore.
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
- actorStore;
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. Adopted into
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 registration. */
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
- /** Known external events — set by createApp from modules' needs.externalEvents. */
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') — set by createApp from modules' events. */
80
+ /** Public-event names (visibility: 'public') — populated by createApp from modules' events. */
96
81
  publicEventNames = new Set();
97
- /** Saga timer store (default in-memory; pluggable via RuntimeOptions). */
98
- workflowTimerStore;
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
- * Tap into the canonical telemetry stream — narrowed to forge's widened
128
- * `Telemetry` union. Delegates to the base `Runtime.onTelemetry` but
129
- * locks the listener's record type to forge's CQRS-aware shape so
130
- * `rec.kind === "event.published"` narrowing works for consumers.
131
- */
132
- onTelemetry(listener) {
133
- return super.onTelemetry(listener);
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 dispatch middleware. Narrows the base `use(middleware)` to
140
- * forge's tightened `DispatchMiddleware` shape so middleware authors
141
- * see typed `action: ActionDefinition` / `ctx: HandlerContext`. The base
142
- * runtime sees its `unknown`-typed shape; the cast is safe — every call
143
- * site we control hands the same `(action, input, ctx)` triple forge's
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
- registerHandler(handler) {
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.emit({
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: this.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.emit({
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: this.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.emit({
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: this.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.emit({
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: this.appName,
280
+ appName,
359
281
  ts: new Date().toISOString(),
360
282
  });
361
283
  }
362
284
  }
363
285
  throw lastError;
364
286
  }
365
- // ─── Query execution ─────────────────────────────────────────────
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) => this.container.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.emit({
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: this.appName,
360
+ appName,
400
361
  ts: new Date().toISOString(),
401
362
  });
402
363
  return result;
403
364
  }
404
- // ─── Introspection ───────────────────────────────────────────────
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
- ...instance,
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: this.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.emit({
431
+ this.runtime.pushTelemetry({
567
432
  kind: "action.dispatched",
568
433
  action: action.name,
569
434
  input: validated,
570
435
  envelope,
571
- appName: this.appName,
436
+ appName,
572
437
  ts: new Date().toISOString(),
573
438
  });
574
439
  const startedAt = performance.now();
575
- // Framework event: ActionDispatching — interceptable. A subscriber
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 (!dispatchAllowed) {
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.emit({
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: this.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
- // Framework event: ActionCompleted (parallel, observable).
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.emit({
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: this.appName,
543
+ appName,
702
544
  ts: new Date().toISOString(),
703
545
  });
704
546
  if (attempt >= maxAttempts) {
705
- // Final failure — fire ActionFailed exactly once (not per
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.emit({
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: this.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.run(hctx);
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.emit({
585
+ this.runtime.pushTelemetry({
763
586
  kind: "event.deduped",
764
587
  event,
765
588
  envelope: childEnvelope,
766
589
  source: "in-process",
767
- appName: this.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
- // Telemetry tap — emit `event.published` after committed apply.
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: this.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: this.appName,
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.emit({
624
+ this.runtime.pushTelemetry({
828
625
  kind: "event.deduped",
829
626
  event,
830
627
  envelope,
831
628
  source: "external",
832
- appName: this.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.emit({
638
+ this.runtime.pushTelemetry({
842
639
  kind: "event.published",
843
640
  event,
844
641
  envelope,
845
642
  source: "external",
846
- appName: this.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.emit({
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: this.appName,
670
+ appName,
872
671
  ts: new Date().toISOString(),
873
672
  });
874
673
  }
875
674
  catch (err) {
876
- // Fold failed — surface a first-class telemetry record so
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: this.appName,
683
+ appName,
890
684
  ts: new Date().toISOString(),
891
685
  });
892
686
  throw err;
893
687
  }
894
688
  }
895
689
  }
896
- // ─── Internal: actor dispatch ────────────────────────────────────
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.emit({
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: this.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 hook of this.actorTransitionHooks) {
1007
- await hook(actor, key, instance.state, nextStateName, event, envelope);
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.emit({
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: this.appName,
833
+ appName,
1066
834
  ts: new Date().toISOString(),
1067
835
  });
1068
836
  }
1069
837
  return timers;
1070
838
  }
1071
- // ─── Internal: workflows ─────────────────────────────────────────
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 log = loggerForEnvelope(this.logger, envelope).child({
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.emit({
923
+ this.runtime.pushTelemetry({
1190
924
  kind: "reaction.failed",
1191
925
  sourceEvent: event.eventName,
1192
926
  error: serializeError(err),
1193
927
  envelope,
1194
- appName: this.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
- // Only fire `reaction.exhausted` when retry was actually
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: this.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.emit({
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: this.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.emit({
968
+ this.runtime.pushTelemetry({
1249
969
  kind: "reaction.failed",
1250
970
  sourceEvent: event.eventName,
1251
971
  error: serializeError(err),
1252
972
  envelope,
1253
- appName: this.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
- // ─── Internal: handler context ───────────────────────────────────
982
+ // ─── Handler context (internal) ────────────────────────────────────
1263
983
  buildHandlerContext(envelope, log, signal) {
1264
984
  const self = this;
1265
- const logger = log ?? loggerForEnvelope(self.logger, envelope);
1266
- // Child dispatches inherit the parent's signal so a caller-side abort
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: self.container,
989
+ container,
1273
990
  envelope,
1274
991
  logger,
1275
992
  signal: ctxSignal,
1276
993
  resolve(name) {
1277
- return self.container.resolve(name);
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
- // ─── Internal: actor view (ctx.use) ──────────────────────────────
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