@kronos-ts/axon-server 0.3.2 → 0.5.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 (102) hide show
  1. package/dist/axon-server-event-store.d.ts +3 -15
  2. package/dist/axon-server-event-store.d.ts.map +1 -1
  3. package/dist/axon-server-event-store.js +85 -44
  4. package/dist/axon-server-event-store.js.map +1 -1
  5. package/dist/axon-server-snapshotting-event-store.d.ts +30 -0
  6. package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -0
  7. package/dist/axon-server-snapshotting-event-store.js +153 -0
  8. package/dist/axon-server-snapshotting-event-store.js.map +1 -0
  9. package/dist/axon-server.d.ts +92 -208
  10. package/dist/axon-server.d.ts.map +1 -1
  11. package/dist/axon-server.js +221 -323
  12. package/dist/axon-server.js.map +1 -1
  13. package/dist/connection-manager.d.ts +2 -2
  14. package/dist/connection-manager.d.ts.map +1 -1
  15. package/dist/connection.d.ts +134 -4
  16. package/dist/connection.d.ts.map +1 -1
  17. package/dist/connection.js +135 -10
  18. package/dist/connection.js.map +1 -1
  19. package/dist/context-view.d.ts +30 -0
  20. package/dist/context-view.d.ts.map +1 -0
  21. package/dist/context-view.js +19 -0
  22. package/dist/context-view.js.map +1 -0
  23. package/dist/control-plane.d.ts +18 -17
  24. package/dist/control-plane.d.ts.map +1 -1
  25. package/dist/control-plane.js +8 -6
  26. package/dist/control-plane.js.map +1 -1
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js.map +1 -1
  29. package/dist/event-processor-info.d.ts +4 -4
  30. package/dist/event-processor-info.d.ts.map +1 -1
  31. package/dist/flow-controlled-sender.d.ts +2 -2
  32. package/dist/flow-controlled-sender.d.ts.map +1 -1
  33. package/dist/flow-controlled-sender.js.map +1 -1
  34. package/dist/generated/command.d.ts +20 -20
  35. package/dist/generated/command.d.ts.map +1 -1
  36. package/dist/generated/command.js.map +1 -1
  37. package/dist/generated/common.d.ts +20 -20
  38. package/dist/generated/common.d.ts.map +1 -1
  39. package/dist/generated/common.js.map +1 -1
  40. package/dist/generated/control.d.ts +36 -40
  41. package/dist/generated/control.d.ts.map +1 -1
  42. package/dist/generated/control.js.map +1 -1
  43. package/dist/generated/dcb.d.ts +88 -94
  44. package/dist/generated/dcb.d.ts.map +1 -1
  45. package/dist/generated/dcb.js.map +1 -1
  46. package/dist/generated/event.d.ts +81 -84
  47. package/dist/generated/event.d.ts.map +1 -1
  48. package/dist/generated/event.js.map +1 -1
  49. package/dist/generated/google/protobuf/empty.d.ts +3 -4
  50. package/dist/generated/google/protobuf/empty.d.ts.map +1 -1
  51. package/dist/generated/google/protobuf/empty.js.map +1 -1
  52. package/dist/generated/query.d.ts +40 -40
  53. package/dist/generated/query.d.ts.map +1 -1
  54. package/dist/generated/query.js.map +1 -1
  55. package/dist/index.d.ts +4 -6
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +4 -5
  58. package/dist/index.js.map +1 -1
  59. package/dist/message-size.d.ts +2 -2
  60. package/dist/message-size.d.ts.map +1 -1
  61. package/dist/metadata-conversion.d.ts +1 -1
  62. package/dist/metadata-conversion.d.ts.map +1 -1
  63. package/dist/outbound-stream.d.ts +2 -2
  64. package/dist/outbound-stream.d.ts.map +1 -1
  65. package/dist/platform-service.d.ts +4 -4
  66. package/dist/platform-service.d.ts.map +1 -1
  67. package/dist/resilience.d.ts +62 -0
  68. package/dist/resilience.d.ts.map +1 -0
  69. package/dist/resilience.js +103 -0
  70. package/dist/resilience.js.map +1 -0
  71. package/dist/shutdown-latch.d.ts +4 -4
  72. package/dist/shutdown-latch.d.ts.map +1 -1
  73. package/dist/shutdown-latch.js.map +1 -1
  74. package/package.json +5 -9
  75. package/src/axon-server-event-store.ts +97 -57
  76. package/src/axon-server-snapshotting-event-store.ts +191 -0
  77. package/src/axon-server.ts +365 -524
  78. package/src/connection-manager.ts +1 -1
  79. package/src/connection.ts +274 -20
  80. package/src/context-view.ts +46 -0
  81. package/src/control-plane.ts +19 -19
  82. package/src/event-processor-info.ts +2 -2
  83. package/src/flow-controlled-sender.ts +1 -1
  84. package/src/generated/command.ts +10 -10
  85. package/src/generated/common.ts +10 -10
  86. package/src/generated/control.ts +20 -20
  87. package/src/generated/dcb.ts +47 -47
  88. package/src/generated/event.ts +42 -42
  89. package/src/generated/google/protobuf/empty.ts +2 -2
  90. package/src/generated/query.ts +20 -20
  91. package/src/index.ts +12 -18
  92. package/src/message-size.ts +1 -1
  93. package/src/metadata-conversion.ts +1 -1
  94. package/src/outbound-stream.ts +1 -1
  95. package/src/platform-service.ts +2 -2
  96. package/src/resilience.ts +149 -0
  97. package/src/shutdown-latch.ts +2 -2
  98. package/dist/axon-server-snapshot-store.d.ts +0 -12
  99. package/dist/axon-server-snapshot-store.d.ts.map +0 -1
  100. package/dist/axon-server-snapshot-store.js +0 -88
  101. package/dist/axon-server-snapshot-store.js.map +0 -1
  102. package/src/axon-server-snapshot-store.ts +0 -118
@@ -1,65 +1,37 @@
1
1
  /**
2
- * Axon Server backend for kronos.
2
+ * The Axon Server command and query buses.
3
3
  *
4
- * `axonServer(config)` is an async factory: it connects eagerly, hands back
5
- * the four components it provides (eventStore, snapshotStore, commandBus,
6
- * queryBus), and gives you a `start`/`close` pair. There is no lifecycle
7
- * framework the ordering that used to be encoded as `onStart("connect")` /
8
- * `onStart("processors")` / `onStop("connect")` is now three lines you write
9
- * in your composition root:
4
+ * Axon Server is a SMART HUB: outbound dispatch always goes to the server, and
5
+ * the server decides which node handles it there is no client-side
6
+ * prefer-next fork here, which is the whole difference from the dumb-pipe
7
+ * broker in `@kronos-ts/rabbitmq`.
10
8
  *
11
- * ```ts
12
- * const axon = await axonServer({
13
- * componentName: "university-service",
14
- * serializer,
15
- * unitOfWorkFactory,
16
- * })
17
- * const app = kronos({
18
- * components: { ...inMemoryComponents({ serializer, unitOfWorkFactory }), ...axon.components },
19
- * modules,
20
- * })
21
- * await axon.start() // readiness barrier: the server can route to our handlers
22
- * // …
23
- * await app.stop(); await axon.close()
24
- * ```
25
- *
26
- * Connecting before the app is built is what removes the lazy proxies and
27
- * subscribe-buffering wrappers the container version needed: by the time
28
- * `kronos` subscribes a handler, the gRPC streams are already live.
29
- *
30
- * REMOTE ADMINISTRATION IS NOT IN HERE. Processor instructions (pause / start /
31
- * release / split / merge) and processor status reporting are the platform
32
- * CONTROL PLANE — they are neither persistence nor transport, and lived here
33
- * only because they share this gRPC connection. They are now an opt-in second
34
- * object built on the platform stream this backend exposes:
9
+ * Both buses are plain functions over the shared connection and YOUR next bus:
35
10
  *
36
11
  * ```ts
37
- * const control = await axonServerControlPlane(axon.platform, app.processors.values())
12
+ * const commandBus = interceptingCommandBus(
13
+ * axonServerCommandBus(localCommandBus(unitOfWork), axon), correlation)
14
+ * const queryBus = interceptingQueryBus(
15
+ * axonServerQueryBus(localQueryBus(unitOfWork), axon), correlation)
38
16
  * ```
39
17
  *
40
- * `start()` therefore takes NO arguments and does exactly one thing: the
41
- * data-path readiness barrier. See `control-plane.ts`.
42
- *
43
18
  * Axon-specific protocol invariants are preserved byte-for-byte:
44
19
  *
45
20
  * - CLIENT_SUPPORTS_STREAMING capability advertised on every dispatched
46
21
  * query via `defaultQueryInstructions(...)`;
47
22
  * - AxonIQ-Context + AxonIQ-Access-Token gRPC metadata headers built by
48
- * `createAxonMetadata(...)` and attached to every outbound stream/RPC;
23
+ * `contextView(...)` and attached to every outbound stream/RPC;
49
24
  * - permits-AFTER-subscriptions stream ordering preserved on the initial
50
25
  * handshake AND on reconnect (see `ensureStreamStarted` /
51
- * `reestablishStreamBody`);
52
- * - shutdown ordering: busLatches → platform.stop → connection.close.
26
+ * `reestablishStreamBody`).
53
27
  */
54
28
  import {
55
29
  qualifiedNameToString,
56
30
  qualifiedNameFromString,
57
31
  generateIdentifier,
58
32
  type Serializer,
59
- withRetry,
60
- healthCheck,
61
- type ResilienceConfig,
62
- } from "@kronos-ts/common"
33
+ } from "@kronos-ts/core"
34
+ import { withRetry, type ResilienceConfig } from "./resilience.js"
63
35
  import type {
64
36
  CommandBus,
65
37
  CommandMessage,
@@ -67,40 +39,34 @@ import type {
67
39
  QueryMessage,
68
40
  SubscriptionFilter,
69
41
  SubscriptionQueryResult,
70
- UoWRunner,
42
+ UnitOfWork,
71
43
  UpdateHandler,
72
- } from "@kronos-ts/messaging"
44
+ } from "@kronos-ts/core"
73
45
  import {
74
46
  applySubscriptionFilter,
75
- correlationDataDispatchInterceptor,
76
- interceptingCommandBus,
77
- interceptingQueryBus,
78
47
  updateHandler,
79
48
  runAfterCommitOrImmediately,
80
- } from "@kronos-ts/messaging"
81
- import { Metadata } from "nice-grpc"
82
- import type { AxonServerConnectionConfig } from "./connection.js"
83
- import { connectToAxonServer, type AxonServerConnection } from "./connection.js"
84
- import { axonServerEventStore } from "./axon-server-event-store.js"
85
- import { axonServerSnapshotStore } from "./axon-server-snapshot-store.js"
49
+ } from "@kronos-ts/core"
50
+ import type { AxonServerBusSource } from "./connection.js"
51
+ import { contextView } from "./context-view.js"
86
52
  import { metadataToProto, metadataFromProto } from "./metadata-conversion.js"
87
53
  import { outboundStream } from "./outbound-stream.js"
88
54
  import { mapErrorCode, AxonServerErrorCode } from "./errors.js"
89
- import { shutdownLatch, type ShutdownLatch } from "./shutdown-latch.js"
90
- import {
91
- platformConnection,
92
- type PlatformConnection,
93
- type PlatformServiceOptions,
94
- } from "./platform-service.js"
95
55
 
96
56
  /** Default flow control settings — aligned with Java's 5000 permits. */
97
57
  const DEFAULT_PERMITS = 5000n
98
58
  const DEFAULT_THRESHOLD = 2500n
99
59
 
60
+ /** Default query dispatch timeout — aligned with Java's one hour. */
61
+ const DEFAULT_QUERY_TIMEOUT_MS = 3_600_000
62
+
63
+ /** Default command handler load factor — aligned with Java's 100. */
64
+ const DEFAULT_LOAD_FACTOR = 100
65
+
100
66
  /**
101
67
  * Flow control configuration for a bus channel.
102
68
  */
103
- export interface FlowControlConfig {
69
+ export type FlowControlConfig = {
104
70
  /** Initial permits granted to Axon Server. Default: 5000 (aligned with Java). */
105
71
  permits?: number
106
72
  /** Threshold at which to request more permits. Default: 2500 (aligned with Java). */
@@ -111,7 +77,7 @@ export interface FlowControlConfig {
111
77
  * Processing instructions attached to outbound messages.
112
78
  * Controls routing, priority, and timeout behavior on Axon Server.
113
79
  */
114
- export interface ProcessingInstructions {
80
+ export type ProcessingInstructions = {
115
81
  /** Routing key for consistent hashing (e.g., aggregate ID). */
116
82
  routingKey?: string
117
83
  /** Priority (higher = processed first). Default: 0 */
@@ -120,10 +86,57 @@ export interface ProcessingInstructions {
120
86
  timeoutMs?: number
121
87
  }
122
88
 
89
+ /**
90
+ * Tuning for {@link axonServerCommandBus}. Every field has a working default;
91
+ * the two arguments that carry meaning — the connection and your next bus —
92
+ * are positional, and this record is the trailing remainder.
93
+ */
94
+ export type AxonServerCommandBusOptions = {
95
+ /** Axon Server context for this bus's stream. Default: the connection's. */
96
+ context?: string
97
+ /** Flow control for the command stream. */
98
+ flowControl?: FlowControlConfig
99
+ /**
100
+ * Load factor for this command handler. Signals to Axon Server how much
101
+ * capacity this node has — higher value = more commands routed here.
102
+ * Aligned with Java's `commandLoadFactor`. Default: 100.
103
+ */
104
+ loadFactor?: number
105
+ /** Retry policy for stream re-establishment. Default: the connection's. */
106
+ resilience?: Partial<ResilienceConfig>
107
+ }
108
+
109
+ /**
110
+ * Tuning for {@link axonServerQueryBus}. See {@link AxonServerCommandBusOptions}.
111
+ */
112
+ export type AxonServerQueryBusOptions = {
113
+ /** Axon Server context for this bus's stream. Default: the connection's. */
114
+ context?: string
115
+ /** Flow control for the query stream. */
116
+ flowControl?: FlowControlConfig
117
+ /**
118
+ * When true, queries are first checked against locally subscribed handlers
119
+ * before being dispatched through Axon Server. Avoids a network round-trip
120
+ * when the handler is co-located.
121
+ *
122
+ * This is NOT the rabbitmq `preferLocal` fork by another name: it is Java's
123
+ * `shortcutQueriesToLocalHandlers`, it is off by default, and commands have
124
+ * no equivalent — Axon Server routes those, always.
125
+ */
126
+ shortcutQueriesToLocalHandlers?: boolean
127
+ /**
128
+ * Default timeout for query dispatch in ms. Default: 3600000 (1 hour).
129
+ * Aligned with Java's processing instruction timeout.
130
+ */
131
+ timeoutMs?: number
132
+ /** Retry policy for stream re-establishment. Default: the connection's. */
133
+ resilience?: Partial<ResilienceConfig>
134
+ }
135
+
123
136
  // Processing instruction keys — aligned with proto ProcessingKey enum.
124
137
  // CLIENT_SUPPORTS_STREAMING (key=8) is an Axon-Server-specific capability
125
- // advertisement that MUST survive the migration verbatim — see file-level
126
- // JSDoc above and `defaultQueryInstructions` below.
138
+ // advertisement that MUST survive verbatim — see file-level JSDoc above and
139
+ // `defaultQueryInstructions` below.
127
140
  const INSTRUCTION_KEY = {
128
141
  ROUTING_KEY: 0,
129
142
  PRIORITY: 1,
@@ -139,10 +152,16 @@ function toProtoProcessingInstructions(instructions?: ProcessingInstructions): a
139
152
  result.push({ key: INSTRUCTION_KEY.ROUTING_KEY, value: { textValue: instructions.routingKey } })
140
153
  }
141
154
  if (instructions.priority !== undefined) {
142
- result.push({ key: INSTRUCTION_KEY.PRIORITY, value: { numberValue: BigInt(instructions.priority) } })
155
+ result.push({
156
+ key: INSTRUCTION_KEY.PRIORITY,
157
+ value: { numberValue: BigInt(instructions.priority) },
158
+ })
143
159
  }
144
160
  if (instructions.timeoutMs !== undefined) {
145
- result.push({ key: INSTRUCTION_KEY.TIMEOUT, value: { numberValue: BigInt(instructions.timeoutMs) } })
161
+ result.push({
162
+ key: INSTRUCTION_KEY.TIMEOUT,
163
+ value: { numberValue: BigInt(instructions.timeoutMs) },
164
+ })
146
165
  }
147
166
  return result
148
167
  }
@@ -161,233 +180,8 @@ function defaultQueryInstructions(timeoutMs: number): any[] {
161
180
  ]
162
181
  }
163
182
 
164
- /**
165
- * Build the gRPC metadata headers required by Axon Server. AxonIQ-Context
166
- * is mandatory (identifies the tenant/context); AxonIQ-Access-Token is
167
- * optional auth. Both must be attached to every outbound stream/RPC —
168
- * preserved verbatim from the legacy enhancer.
169
- */
170
- function createAxonMetadata(config: { context: string; token: string }): Metadata {
171
- const metadata = new Metadata()
172
- metadata.set("AxonIQ-Context", config.context)
173
- if (config.token) {
174
- metadata.set("AxonIQ-Access-Token", config.token)
175
- }
176
- return metadata
177
- }
178
-
179
- export interface AxonServerConfig extends AxonServerConnectionConfig {
180
- /** Flow control for the command bus channel. */
181
- commandFlowControl?: FlowControlConfig
182
- /** Flow control for the query bus channel. */
183
- queryFlowControl?: FlowControlConfig
184
- /** Platform service configuration (heartbeat, etc.). */
185
- platformService?: PlatformServiceOptions
186
- /**
187
- * When true, queries are first checked against locally registered handlers
188
- * before being dispatched through Axon Server. Avoids a network round-trip
189
- * when the handler is co-located.
190
- *
191
- * Aligned with Java's `shortcutQueriesToLocalHandlers`.
192
- * Default: false.
193
- */
194
- shortcutQueriesToLocalHandlers?: boolean
195
- /**
196
- * Load factor for command handler registration.
197
- * Signals to Axon Server how much capacity this handler has.
198
- * Higher value = handler can take more commands.
199
- *
200
- * Aligned with Java's `commandLoadFactor`. Default: 100.
201
- */
202
- commandLoadFactor?: number
203
- /**
204
- * Default timeout for command dispatch in ms. Default: 300000 (5 min).
205
- * Aligned with Java's processing instruction timeout.
206
- */
207
- commandTimeoutMs?: number
208
- /**
209
- * Default timeout for query dispatch in ms. Default: 3600000 (1 hour).
210
- * Aligned with Java's processing instruction timeout.
211
- */
212
- queryTimeoutMs?: number
213
- /** Per-extension resilience config (D-100 / D-101). */
214
- resilience?: Partial<ResilienceConfig>
215
- /**
216
- * How long `start()` waits for Axon Server's routing tables to register the
217
- * subscribe frames sent on the command/query streams. This is the entire
218
- * data-path readiness barrier.
219
- *
220
- * It is a timed wait rather than an observed signal because nothing on the
221
- * client can observe it: subscribes travel on the bus streams, and the
222
- * platform stream — which is where an ack would arrive — is a different
223
- * stream that Axon Server holds open silently after `register`. Default:
224
- * 1000, matching the legacy enhancer. Tests against a freshly-booted server
225
- * can tighten this once subscriptions are observed to land faster.
226
- */
227
- busSubscriptionAckDelayMs?: number
228
- }
229
-
230
- /** The components an Axon Server backend provides. Spread into `kronos`. */
231
- export interface AxonServerComponents {
232
- eventStore: ReturnType<typeof axonServerEventStore>
233
- snapshotStore: ReturnType<typeof axonServerSnapshotStore>
234
- commandBus: CommandBus
235
- queryBus: QueryBus
236
- }
237
-
238
- /**
239
- * A live Axon Server backend: the components it provides plus the two calls
240
- * that used to be lifecycle stages.
241
- */
242
- /** Everything axonServer() needs: its own config plus the framework values it borrows. */
243
- export type AxonServerOptions = AxonServerConfig & { serializer: Serializer; unitOfWorkFactory: UoWRunner }
244
-
245
- export interface AxonServerBackend {
246
- readonly components: AxonServerComponents
247
- /**
248
- * The platform stream. It is built here because the backend owns the gRPC
249
- * connection it rides on and the `platformService` tuning that configures it.
250
- *
251
- * `start()` below brings it up for the DATA path — heartbeats and reconnect
252
- * detection — via `platform.armConnectionMonitoring()`. What it deliberately
253
- * does NOT do is arm processor status reporting or route instructions: that is
254
- * remote administration, and it stays opt-in behind
255
- * `axonServerControlPlane(axon.platform, …)`, which registers its handler and
256
- * supplier and then calls `platform.start()` on this same live stream.
257
- *
258
- * An instruction that arrives before a control plane exists is buffered by the
259
- * platform connection and drained on the first `onInstruction` registration,
260
- * so opening the stream early costs nothing.
261
- */
262
- readonly platform: PlatformConnection
263
- /**
264
- * DATA-PATH START. Two things, both data path:
265
- *
266
- * 1. arm heartbeat-driven reconnect detection on the platform stream, and
267
- * 2. wait until Axon Server can route to the handlers subscribed on the bus
268
- * streams.
269
- *
270
- * Call AFTER `kronos` — the subscribe frames must already be on the wire for
271
- * the readiness wait to mean anything.
272
- *
273
- * Takes no arguments and arms no control-plane state.
274
- */
275
- start(): Promise<void>
276
- /** Drain in-flight bus work, stop the platform stream, close the connection. */
277
- close(): Promise<void>
278
- }
279
-
280
- /**
281
- * Connect to Axon Server and build the components it backs.
282
- *
283
- * `serializer` and `unitOfWorkFactory` are arguments rather than slot lookups:
284
- * the buses serialize payloads with the former and run every inbound command /
285
- * query in the latter, so they must be the SAME instances the rest of the app
286
- * uses. Pass the ones you hand to `kronos`.
287
- */
288
- export async function axonServer(
289
- options: AxonServerOptions,
290
- ): Promise<AxonServerBackend> {
291
- const config = options
292
- const { serializer, unitOfWorkFactory, resilience } = config
293
-
294
- const connection = await withRetry(async () => connectToAxonServer(config), {
295
- event: "initial-connect",
296
- ...resilience,
297
- })
298
-
299
- // Health-check ping with warn-then-continue (D-100). AxonServerConnection has
300
- // no dedicated probe surface today; the gRPC channel itself is created
301
- // eagerly in connectToAxonServer so the meaningful probe is a round-trip — we
302
- // approximate via a soft no-op promise that satisfies the threshold contract.
303
- // Real network failure is surfaced by the first bus call against the channel.
304
- await healthCheck(async () => undefined, {
305
- thresholdMs: resilience?.healthCheckThresholdMs,
306
- log: resilience?.log,
307
- })
308
-
309
- // One latch per bus, drained in close() before the transport goes away.
310
- const commandLatch = shutdownLatch()
311
- const queryLatch = shutdownLatch()
312
- const busLatches: ShutdownLatch[] = [commandLatch, queryLatch]
313
-
314
- // The connection is live before anything below is built, so the buses open
315
- // their gRPC streams for real and `subscribe()` reaches the wire immediately —
316
- // no lazy proxy, no subscription buffering, no readiness promise.
317
- const components: AxonServerComponents = {
318
- eventStore: axonServerEventStore(connection, serializer),
319
- snapshotStore: axonServerSnapshotStore(connection, serializer),
320
- commandBus: distributedCommandBus(
321
- connection,
322
- unitOfWorkFactory,
323
- commandLatch,
324
- serializer,
325
- config.commandFlowControl,
326
- config.commandLoadFactor,
327
- resilience,
328
- ),
329
- queryBus: distributedQueryBus(
330
- connection,
331
- unitOfWorkFactory,
332
- queryLatch,
333
- serializer,
334
- config.queryFlowControl,
335
- config.shortcutQueriesToLocalHandlers,
336
- config.queryTimeoutMs,
337
- resilience,
338
- ),
339
- }
340
-
341
- // Built here, started by the control plane (or by the caller). Constructing it
342
- // eagerly is what lets the control plane be a separate object at all — and it
343
- // keeps `platformService` tuning and `stop()` ownership in one place, so the
344
- // documented shutdown order below holds whether or not anyone opted in.
345
- const platform = platformConnection(connection, config.platformService)
346
-
347
- return {
348
- components,
349
- platform,
350
-
351
- async start() {
352
- // RECONNECT DETECTION IS DATA PATH. The heartbeat on the platform stream
353
- // is what notices a dead channel and calls `connection.reconnect()`; both
354
- // buses above hook `connection.onReconnect(...)` to rebuild their own
355
- // streams. Arming it used to be a side effect of `platform.start()`, which
356
- // only `axonServerControlPlane(...)` calls — so a service that never opted
357
- // into remote administration had NO reconnect detection at all and would
358
- // sit on a dead channel forever. It is armed here, unconditionally,
359
- // independent of whether anyone administers this service.
360
- //
361
- // `armConnectionMonitoring()` opens the stream and starts heartbeats but
362
- // arms NO processor status reporting; that stays the control plane's, and
363
- // a later `platform.start()` adds it to this same live stream. Both calls
364
- // are idempotent, so either order works.
365
- await platform.armConnectionMonitoring()
366
-
367
- // The only thing the data path has to wait for: Axon Server's
368
- // command/query routing tables registering the subscribe frames sent on
369
- // the BUS streams. It cannot be derived from the platform stream, because
370
- // subscribes travel on a different stream entirely — and the platform
371
- // stream's own `subscriptionsAcked()` latch says nothing about them (it
372
- // latches unconditionally once `register` has been flushed; see
373
- // platform-service.ts). So this barrier is the settle wait, and it is
374
- // deliberately independent of whether the platform stream is up at all.
375
- // The legacy enhancer carried the same 1s wait.
376
- await new Promise((r) => setTimeout(r, config.busSubscriptionAckDelayMs ?? 1000))
377
- },
378
-
379
- async close() {
380
- await Promise.all(busLatches.map((l) => l.initiateShutdown()))
381
- // Idempotent, and independent of `control.close()` — a backend that was
382
- // never administered still stops a platform stream someone else started.
383
- platform.stop()
384
- connection.close()
385
- },
386
- }
387
- }
388
-
389
183
  // ---------------------------------------------------------------------------
390
- // Shared payload helpers (moved verbatim from legacy enhancer)
184
+ // Shared payload helpers
391
185
  // ---------------------------------------------------------------------------
392
186
 
393
187
  function createPayloadHelpers(serializer: Serializer) {
@@ -395,7 +189,11 @@ function createPayloadHelpers(serializer: Serializer) {
395
189
  serializePayload(name: string, payload: unknown, revision: string = "") {
396
190
  return serializer.serialize(payload, name, revision)
397
191
  },
398
- deserializePayload(data: Uint8Array | undefined, type: string = "", revision: string = ""): unknown {
192
+ deserializePayload(
193
+ data: Uint8Array | undefined,
194
+ type: string = "",
195
+ revision: string = "",
196
+ ): unknown {
399
197
  if (!data || data.length === 0) return undefined
400
198
  return serializer.deserialize({ data, type, revision })
401
199
  },
@@ -403,79 +201,77 @@ function createPayloadHelpers(serializer: Serializer) {
403
201
  }
404
202
 
405
203
  // ---------------------------------------------------------------------------
406
- // Distributed Command Bus
407
- //
408
- // Bus implementation moved verbatim from the legacy enhancer with TWO
409
- // behavioural additions per D-97:
410
- // 1) reestablishStream() body wrapped in withRetry({ event: "reconnect" })
411
- // 2) inbound-stream backoff replaced by the same withRetry path
412
- //
413
- // Axon-specific protocol invariants preserved BYTE-FOR-BYTE:
414
- // - AxonIQ-Context + AxonIQ-Access-Token gRPC metadata headers via
415
- // createAxonMetadata(connection.config)
416
- // - permits-AFTER-subscriptions ordering on reestablishStreamBody (subs
417
- // are sent BEFORE grantPermits() in the reconnect path; the initial
418
- // handshake matches this — see ensureStreamStarted's grantPermits call
419
- // in subscribe()).
204
+ // Axon Server Command Bus
420
205
  // ---------------------------------------------------------------------------
421
206
 
422
207
  /**
423
- * A command bus backed by Axon Server.
208
+ * A command bus backed by Axon Server, over YOUR next bus.
424
209
  *
425
- * - **Outbound dispatch**: Always goes through Axon Server via the unary Dispatch RPC.
426
- * Axon Server routes the command to the appropriate node (which may be this one).
427
- * - **Local segment**: Handlers subscribed via `subscribe()` are registered with
428
- * Axon Server (so other nodes can route to us) and stored locally. When Axon Server
429
- * routes an inbound command to this node, it's executed on the local segment
430
- * within a UnitOfWork.
431
- */
432
- /**
433
- * A command bus backed by Axon Server.
210
+ * - **Outbound dispatch**: ALWAYS through Axon Server, via the unary Dispatch
211
+ * RPC. Axon Server routes the command to the appropriate node (which may be
212
+ * this one). There is deliberately no client-side prefer-next fork: the hub
213
+ * is the router, and short-circuiting it would silently defeat load factors,
214
+ * priorities and routing keys.
215
+ * - **Inbound**: a command the server routes here is dispatched into `next` —
216
+ * not into a privately-held handler map. That is what makes the unit-of-work
217
+ * policy you chose for `next` (say `postgresUnitOfWork(unitOfWork, pg)`)
218
+ * apply to server-routed work exactly as it applies to work this process
219
+ * originated. It is also why this function takes no `unitOfWork` argument:
220
+ * `next` carries that policy now.
221
+ * - **subscribe**: registers the handler on `next` AND announces the name to
222
+ * Axon Server, so other nodes can route to us.
223
+ *
224
+ * ## correlation and the interceptor layer
225
+ *
226
+ * The returned bus stamps no correlation of its own. A host that wants it wraps the
227
+ * OUTERMOST bus:
434
228
  *
435
- * ## Correlation lineage and the interceptor layer
229
+ * ```ts
230
+ * interceptingCommandBus(axonServerCommandBus(next, conn), correlation)
231
+ * ```
436
232
  *
437
- * The returned bus is wrapped in {@link interceptingCommandBus} carrying
438
- * {@link correlationDataDispatchInterceptor}, so lineage is stamped onto the
439
- * outgoing message BEFORE it is serialized onto the wire.
233
+ * so whatever a host adds runs BEFORE the message is serialized onto the wire.
234
+ * Correlation itself is usually already on `message.metadata` by then — `ctx.send`
235
+ * stamps the unit of work's correlation data before any bus sees the message.
440
236
  *
441
237
  * This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
442
238
  * holds its own `DispatchInterceptors` and dispatches as
443
239
  * `doDispatch(dispatchInterceptors.intercept(commandMessage), cb)` — one call
444
- * site, at the top, ahead of any routing; and its `doDispatch` (like this one)
445
- * always goes to the server, letting Axon Server decide where the command lands.
446
- * AF5 keeps the property via decorator order:
447
- * `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
448
- * stacks `InterceptingCommandBus → DistributedCommandBus → SimpleCommandBus`.
240
+ * site, at the top, ahead of any routing. AF5 keeps the property via decorator
241
+ * order: `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
242
+ * stacks `InterceptingCommandBus DistributedCommandBus LocalCommandBus`.
449
243
  *
450
- * Before this wrap, an Axon-backed service lost lineage on EVERY command: the
451
- * only registration of `correlationDataDispatchInterceptor` lives in
452
- * `@kronos-ts/app`'s in-memory default bus, and `components.commandBus` from
453
- * this backend replaces it wholesale.
454
- *
455
- * No double-application risk: the local segment here is a plain handler map, not
456
- * a `CommandBus`, so this is the only interceptor in the chain. Inbound commands
457
- * from the server are invoked through that map directly, which matches AF —
458
- * `CommandProcessingTask` runs the local segment WITHOUT re-running dispatch
459
- * interceptors.
244
+ * If `next` is itself an intercepting bus, a server-routed command sees
245
+ * `correlation` twice. That is harmless: both of its fields are `??` seeds, so the
246
+ * second application finds them set and changes nothing.
460
247
  */
461
- export function distributedCommandBus(
462
- connection: AxonServerConnection,
463
- unitOfWorkRunner: UoWRunner,
464
- shutdownLatch: ShutdownLatch,
465
- serializer: Serializer,
466
- flowControl?: FlowControlConfig,
467
- commandLoadFactor?: number,
468
- resilience?: Partial<ResilienceConfig>,
469
- ): CommandBus {
470
- const metadata = createAxonMetadata(connection.config)
248
+ export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
249
+ next: CommandBus<U>,
250
+ conn: AxonServerBusSource,
251
+ options: AxonServerCommandBusOptions = {},
252
+ ): CommandBus<U> {
253
+ const {
254
+ connection,
255
+ serializer,
256
+ metadata: axonMetadata,
257
+ } = contextView(conn, options.context ?? conn.connection.config.context)
258
+ const shutdownLatch = conn.shutdown
259
+ const resilience = options.resilience ?? conn.resilience
260
+ const metadata = axonMetadata()
471
261
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
472
- const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS))
473
- const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
262
+ const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
263
+ const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
264
+ const loadFactor = options.loadFactor ?? DEFAULT_LOAD_FACTOR
474
265
 
475
- // Local segment — handlers that execute on this node
476
- const localSegment = new Map<string, (message: CommandMessage) => Promise<unknown>>()
266
+ /**
267
+ * The names this node announced to Axon Server. The handlers themselves live
268
+ * on `next`; this set exists so an inbound command for a name we never
269
+ * subscribed still answers NO_HANDLER_FOR_COMMAND rather than whatever
270
+ * `next.dispatch` happens to throw — and so a reconnect can re-announce.
271
+ */
272
+ const subscribedNames = new Set<string>()
477
273
 
478
- // Bidirectional stream for handler registration + inbound command handling
274
+ // Bidirectional stream for handler subscription + inbound command handling
479
275
  let outbound = outboundStream<any>()
480
276
  let streamStarted = false
481
277
  let permits = 0n
@@ -497,8 +293,21 @@ export function distributedCommandBus(
497
293
  permits += PERMITS
498
294
  }
499
295
 
296
+ function sendSubscribe(commandName: string) {
297
+ outbound.send({
298
+ subscribe: {
299
+ messageId: generateIdentifier(),
300
+ command: commandName,
301
+ componentName: connection.config.componentName,
302
+ clientId: connection.config.clientId,
303
+ loadFactor,
304
+ },
305
+ instructionId: generateIdentifier(),
306
+ })
307
+ }
308
+
500
309
  /**
501
- * Re-establish the bidirectional stream and re-subscribe all handlers.
310
+ * Re-establish the bidirectional stream and re-announce all handlers.
502
311
  * Called on stream error or when the connection reconnects.
503
312
  *
504
313
  * ORDER (preserves Axon-specific invariant): subscriptions are
@@ -512,18 +321,7 @@ export function distributedCommandBus(
512
321
  permits = 0n
513
322
  ensureStreamStarted()
514
323
  // Re-subscribe all handlers FIRST
515
- for (const commandName of localSegment.keys()) {
516
- outbound.send({
517
- subscribe: {
518
- messageId: generateIdentifier(),
519
- command: commandName,
520
- componentName: connection.config.componentName,
521
- clientId: connection.config.clientId,
522
- loadFactor: commandLoadFactor ?? 100,
523
- },
524
- instructionId: generateIdentifier(),
525
- })
526
- }
324
+ for (const commandName of subscribedNames) sendSubscribe(commandName)
527
325
  // Permits AFTER subscriptions (Axon-specific ordering invariant)
528
326
  grantPermits()
529
327
  }
@@ -540,7 +338,7 @@ export function distributedCommandBus(
540
338
  connection.onReconnect(() => {
541
339
  if (!shutdownLatch.shuttingDown && streamStarted) {
542
340
  reestablishStreamWithRetry().catch((err) => {
543
- console.error("Distributed command bus: reconnect retries exhausted", err)
341
+ console.error("Axon Server command bus: reconnect retries exhausted", err)
544
342
  })
545
343
  }
546
344
  })
@@ -553,13 +351,12 @@ export function distributedCommandBus(
553
351
  permits--
554
352
  const proto = message.command
555
353
  const commandName = proto.name
556
- const handler = localSegment.get(commandName)
557
354
 
558
355
  let resultPayload: unknown
559
356
  let errorCode = ""
560
357
  let errorMsg = ""
561
358
 
562
- if (handler) {
359
+ if (subscribedNames.has(commandName)) {
563
360
  try {
564
361
  const commandMessage: CommandMessage = {
565
362
  kind: "command",
@@ -570,17 +367,19 @@ export function distributedCommandBus(
570
367
  timestamp: Number(proto.timestamp),
571
368
  }
572
369
 
573
- // Execute inbound command within its own UnitOfWork (AF5 parity)
574
- resultPayload = await unitOfWorkRunner(commandMessage.metadata, () =>
575
- handler(commandMessage),
576
- )
370
+ // Through the LOCAL BUS, so the caller's unit-of-work policy runs.
371
+ // AF parity is preserved: `CommandProcessingTask` runs the next
372
+ // segment without re-running dispatch interceptors, and a `next`
373
+ // that happens to carry `correlation` re-applies a pair of `??` seeds
374
+ // that are already set.
375
+ resultPayload = await next.dispatch(commandMessage)
577
376
  } catch (err) {
578
377
  errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR
579
378
  errorMsg = err instanceof Error ? err.message : String(err)
580
379
  }
581
380
  } else {
582
381
  errorCode = AxonServerErrorCode.NO_HANDLER_FOR_COMMAND
583
- errorMsg = `No local handler for command "${commandName}"`
382
+ errorMsg = `No next handler for command "${commandName}"`
584
383
  }
585
384
 
586
385
  // Send response back to Axon Server
@@ -590,11 +389,15 @@ export function distributedCommandBus(
590
389
  requestIdentifier: proto.messageIdentifier,
591
390
  errorCode,
592
391
  errorMessage: errorCode
593
- ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
594
- : undefined,
595
- payload: resultPayload !== undefined
596
- ? serializePayload("result", resultPayload)
392
+ ? {
393
+ message: errorMsg,
394
+ location: connection.config.componentName,
395
+ details: [],
396
+ errorCode,
397
+ }
597
398
  : undefined,
399
+ payload:
400
+ resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
598
401
  metaData: {},
599
402
  processingInstructions: [],
600
403
  },
@@ -614,35 +417,46 @@ export function distributedCommandBus(
614
417
  if (shutdownLatch.shuttingDown) return
615
418
  if (String(err).includes("Connection dropped")) return
616
419
 
617
- console.error("Distributed command bus: inbound stream error, attempting re-establishment via withRetry", err)
420
+ console.error(
421
+ "Axon Server command bus: inbound stream error, attempting re-establishment via withRetry",
422
+ err,
423
+ )
618
424
  await reestablishStreamWithRetry().catch((retryErr) => {
619
- console.error("Distributed command bus: reconnect retries exhausted", retryErr)
425
+ console.error("Axon Server command bus: reconnect retries exhausted", retryErr)
620
426
  })
621
427
  }
622
428
  }
623
429
 
624
- const routing: CommandBus = {
625
- async dispatch(message: CommandMessage): Promise<unknown> {
430
+ return {
431
+ async dispatch(unstamped: CommandMessage): Promise<unknown> {
432
+ // A transport is not a task: it has no unit of work, so it has no clock.
433
+ // A message that reaches the wire with no instant yet gets one from system
434
+ // time here — the envelope crosses a process boundary and must be fully
435
+ // formed. A locally-shortcut message is handed to `next` untouched
436
+ // instead, so the task that handles it supplies the instant.
437
+ const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
626
438
  const activity = shutdownLatch.registerActivity()
627
439
  try {
628
440
  const commandName = qualifiedNameToString(message.name)
629
441
 
630
- const response = await connection.commands.dispatch({
631
- messageIdentifier: message.identifier,
632
- name: commandName,
633
- timestamp: BigInt(message.timestamp),
634
- payload: serializePayload(commandName, message.payload),
635
- metaData: metadataToProto(message.metadata),
636
- processingInstructions: toProtoProcessingInstructions(message.metadata?.processingInstructions as ProcessingInstructions | undefined),
637
- clientId: connection.config.clientId,
638
- componentName: connection.config.componentName,
639
- }, { metadata })
442
+ const response = await connection.commands.dispatch(
443
+ {
444
+ messageIdentifier: message.identifier,
445
+ name: commandName,
446
+ timestamp: BigInt(message.timestamp),
447
+ payload: serializePayload(commandName, message.payload),
448
+ metaData: metadataToProto(message.metadata),
449
+ processingInstructions: toProtoProcessingInstructions(
450
+ message.metadata?.processingInstructions as ProcessingInstructions | undefined,
451
+ ),
452
+ clientId: connection.config.clientId,
453
+ componentName: connection.config.componentName,
454
+ },
455
+ { metadata },
456
+ )
640
457
 
641
458
  if (response.errorCode && response.errorCode !== "") {
642
- throw mapErrorCode(
643
- response.errorCode,
644
- response.errorMessage?.message ?? "Unknown error",
645
- )
459
+ throw mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error")
646
460
  }
647
461
 
648
462
  return deserializePayload(response.payload?.data as Uint8Array | undefined)
@@ -651,73 +465,72 @@ export function distributedCommandBus(
651
465
  }
652
466
  },
653
467
 
654
- subscribe(commandName: string, handler: (message: CommandMessage) => Promise<unknown>) {
655
- localSegment.set(commandName, handler)
468
+ subscribe(
469
+ commandName: string,
470
+ handler: (message: CommandMessage, uow: U) => Promise<unknown>,
471
+ ) {
472
+ subscribedNames.add(commandName)
473
+ next.subscribe(commandName, handler)
656
474
 
657
475
  ensureStreamStarted()
658
476
  // Subscription FIRST
659
- outbound.send({
660
- subscribe: {
661
- messageId: generateIdentifier(),
662
- command: commandName,
663
- componentName: connection.config.componentName,
664
- clientId: connection.config.clientId,
665
- loadFactor: commandLoadFactor ?? 100,
666
- },
667
- instructionId: generateIdentifier(),
668
- })
477
+ sendSubscribe(commandName)
669
478
  // Permits AFTER subscription (Axon-specific ordering invariant)
670
479
  grantPermits()
671
480
  },
672
481
  }
673
-
674
- // Interception OUTSIDE routing — see the note on this function.
675
- const bus = interceptingCommandBus(routing)
676
- bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
677
- return bus
678
482
  }
679
483
 
680
484
  // ---------------------------------------------------------------------------
681
- // Distributed Query Bus
485
+ // Axon Server Query Bus
682
486
  // ---------------------------------------------------------------------------
683
487
 
684
488
  /**
685
- * A query bus backed by Axon Server.
489
+ * A query bus backed by Axon Server, over YOUR next bus.
490
+ *
491
+ * Same architecture as {@link axonServerCommandBus}: outbound dispatch goes
492
+ * through Axon Server, and a query the server routes here runs through `next`,
493
+ * so your unit-of-work policy applies to server-routed reads too. `subscribe`
494
+ * registers on `next` and announces the name to the server.
686
495
  *
687
- * Same architecture as the distributed command bus:
688
- * - **Outbound dispatch**: Always through Axon Server.
689
- * - **Local segment**: Handlers registered here are stored locally and
690
- * registered with Axon Server for inbound routing. Inbound queries
691
- * are executed within a UnitOfWork.
496
+ * The one asymmetry with commands is `shortcutQueriesToLocalHandlers` — Java
497
+ * has it for queries and not for commands, and so do we. When it is on and this
498
+ * node subscribed the name, `query()` goes straight to `next` and the caller's
499
+ * unit of work is passed through, so the next branch nests exactly as the
500
+ * in-process bus does.
692
501
  *
693
- * Wrapped in {@link interceptingQueryBus} with
694
- * {@link correlationDataDispatchInterceptor}, matching AF4's
695
- * `AxonServerQueryBus`, which calls `dispatchInterceptors.intercept(...)` at the
696
- * top of `query`, `streamingQuery`, `scatterGather` and `subscriptionQuery`.
697
- * Because the wrap is outside, the `shortcutQueriesToLocalHandlers` branch in
698
- * `query()` gets identical lineage to the remote branch.
502
+ * Correlation, if wanted, is `interceptingQueryBus(bus, correlation)` at the host,
503
+ * matching AF4's `AxonServerQueryBus`, which calls
504
+ * `dispatchInterceptors.intercept(...)` at the top of `query`, `streamingQuery`,
505
+ * `scatterGather` and `subscriptionQuery`. Because the wrap is outside, the
506
+ * shortcut branch gets identical correlation to the remote branch.
699
507
  *
700
508
  * KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
701
509
  * straight from `message.metadata`, and `interceptingQueryBus` (in
702
- * `@kronos-ts/messaging`) forwards those two calls to the delegate without
703
- * running the dispatch chain. Closing that needs a messaging-package change.
510
+ * `@kronos-ts/core`) forwards those two calls to the delegate without
511
+ * running the dispatch chain. Closing that needs a core change.
704
512
  */
705
- export function distributedQueryBus(
706
- connection: AxonServerConnection,
707
- unitOfWorkRunner: UoWRunner,
708
- shutdownLatch: ShutdownLatch,
709
- serializer: Serializer,
710
- flowControl?: FlowControlConfig,
711
- shortcutQueriesToLocalHandlers?: boolean,
712
- queryTimeoutMs?: number,
713
- resilience?: Partial<ResilienceConfig>,
714
- ): QueryBus {
715
- const metadata = createAxonMetadata(connection.config)
716
- const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS))
717
- const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
513
+ export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
514
+ next: QueryBus<U>,
515
+ conn: AxonServerBusSource,
516
+ options: AxonServerQueryBusOptions = {},
517
+ ): QueryBus<U> {
518
+ const {
519
+ connection,
520
+ serializer,
521
+ metadata: axonMetadata,
522
+ } = contextView(conn, options.context ?? conn.connection.config.context)
523
+ const shutdownLatch = conn.shutdown
524
+ const resilience = options.resilience ?? conn.resilience
525
+ const metadata = axonMetadata()
526
+ const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
527
+ const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
528
+ const shortcutQueriesToLocalHandlers = options.shortcutQueriesToLocalHandlers ?? false
529
+ const queryTimeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS
718
530
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
719
531
 
720
- const localSegment = new Map<string, (message: QueryMessage) => Promise<unknown>>()
532
+ /** Query names announced to Axon Server; the handlers live on `next`. */
533
+ const subscribedNames = new Set<string>()
721
534
 
722
535
  // Local subscription store — subscription queries opened by THIS instance.
723
536
  // Inbound updates from the server are offered into these via the Subscription RPC loop.
@@ -750,8 +563,21 @@ export function distributedQueryBus(
750
563
  permits += PERMITS
751
564
  }
752
565
 
566
+ function sendSubscribe(queryName: string) {
567
+ outbound.send({
568
+ subscribe: {
569
+ messageId: generateIdentifier(),
570
+ query: queryName,
571
+ resultName: "",
572
+ componentName: connection.config.componentName,
573
+ clientId: connection.config.clientId,
574
+ },
575
+ instructionId: generateIdentifier(),
576
+ })
577
+ }
578
+
753
579
  /**
754
- * Re-establish the bidirectional stream and re-subscribe all handlers.
580
+ * Re-establish the bidirectional stream and re-announce all handlers.
755
581
  * Called on stream error or when the connection reconnects.
756
582
  *
757
583
  * ORDER (preserves Axon-specific invariant): subscriptions are
@@ -763,18 +589,7 @@ export function distributedQueryBus(
763
589
  streamStarted = false
764
590
  permits = 0n
765
591
  ensureStreamStarted()
766
- for (const queryName of localSegment.keys()) {
767
- outbound.send({
768
- subscribe: {
769
- messageId: generateIdentifier(),
770
- query: queryName,
771
- resultName: "",
772
- componentName: connection.config.componentName,
773
- clientId: connection.config.clientId,
774
- },
775
- instructionId: generateIdentifier(),
776
- })
777
- }
592
+ for (const queryName of subscribedNames) sendSubscribe(queryName)
778
593
  grantQueryPermits()
779
594
  }
780
595
 
@@ -790,7 +605,7 @@ export function distributedQueryBus(
790
605
  connection.onReconnect(() => {
791
606
  if (!shutdownLatch.shuttingDown && streamStarted) {
792
607
  reestablishStreamWithRetry().catch((err) => {
793
- console.error("Distributed query bus: reconnect retries exhausted", err)
608
+ console.error("Axon Server query bus: reconnect retries exhausted", err)
794
609
  })
795
610
  }
796
611
  })
@@ -810,12 +625,11 @@ export function distributedQueryBus(
810
625
  )
811
626
  handlerSubscriptions.set(subId, { queryName, payload })
812
627
 
813
- const handler = localSegment.get(queryName)
814
628
  let resultPayload: unknown
815
629
  let errorCode = ""
816
630
  let errorMsg = ""
817
631
 
818
- if (handler) {
632
+ if (subscribedNames.has(queryName)) {
819
633
  try {
820
634
  const queryMessage: QueryMessage = {
821
635
  kind: "query",
@@ -825,21 +639,18 @@ export function distributedQueryBus(
825
639
  metadata: metadataFromProto(proto.metaData ?? {}),
826
640
  timestamp: Number(proto.timestamp),
827
641
  }
828
- resultPayload = await unitOfWorkRunner(queryMessage.metadata, async () => {
829
- return handler(queryMessage)
830
- })
642
+ resultPayload = await next.query(queryMessage)
831
643
  } catch (err) {
832
644
  errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
833
645
  errorMsg = err instanceof Error ? err.message : String(err)
834
646
  }
835
647
  } else {
836
648
  errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
837
- errorMsg = `No local handler for query "${queryName}"`
649
+ errorMsg = `No next handler for query "${queryName}"`
838
650
  }
839
651
 
840
- const responseSerialized = resultPayload !== undefined
841
- ? serializePayload("result", resultPayload)
842
- : undefined
652
+ const responseSerialized =
653
+ resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined
843
654
 
844
655
  outbound.send({
845
656
  subscriptionQueryResponse: {
@@ -850,7 +661,12 @@ export function distributedQueryBus(
850
661
  requestIdentifier: proto.messageIdentifier,
851
662
  errorCode,
852
663
  errorMessage: errorCode
853
- ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
664
+ ? {
665
+ message: errorMsg,
666
+ location: connection.config.componentName,
667
+ details: [],
668
+ errorCode,
669
+ }
854
670
  : undefined,
855
671
  payload: responseSerialized,
856
672
  metaData: {},
@@ -879,13 +695,12 @@ export function distributedQueryBus(
879
695
  permits--
880
696
  const proto = message.query
881
697
  const queryName = proto.query
882
- const handler = localSegment.get(queryName)
883
698
 
884
699
  let resultPayload: unknown
885
700
  let errorCode = ""
886
701
  let errorMsg = ""
887
702
 
888
- if (handler) {
703
+ if (subscribedNames.has(queryName)) {
889
704
  try {
890
705
  const queryMessage: QueryMessage = {
891
706
  kind: "query",
@@ -896,16 +711,16 @@ export function distributedQueryBus(
896
711
  timestamp: Number(proto.timestamp),
897
712
  }
898
713
 
899
- resultPayload = await unitOfWorkRunner(queryMessage.metadata, async () => {
900
- return handler(queryMessage)
901
- })
714
+ // Through the LOCAL BUS: no unit of work is handed in, so `next`
715
+ // opens one under whatever policy the caller gave it.
716
+ resultPayload = await next.query(queryMessage)
902
717
  } catch (err) {
903
718
  errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
904
719
  errorMsg = err instanceof Error ? err.message : String(err)
905
720
  }
906
721
  } else {
907
722
  errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
908
- errorMsg = `No local handler for query "${queryName}"`
723
+ errorMsg = `No next handler for query "${queryName}"`
909
724
  }
910
725
 
911
726
  outbound.send({
@@ -914,11 +729,15 @@ export function distributedQueryBus(
914
729
  requestIdentifier: proto.messageIdentifier,
915
730
  errorCode,
916
731
  errorMessage: errorCode
917
- ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
918
- : undefined,
919
- payload: resultPayload !== undefined
920
- ? serializePayload("result", resultPayload)
732
+ ? {
733
+ message: errorMsg,
734
+ location: connection.config.componentName,
735
+ details: [],
736
+ errorCode,
737
+ }
921
738
  : undefined,
739
+ payload:
740
+ resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
922
741
  metaData: {},
923
742
  processingInstructions: [],
924
743
  },
@@ -945,39 +764,51 @@ export function distributedQueryBus(
945
764
  if (shutdownLatch.shuttingDown) return
946
765
  if (String(err).includes("Connection dropped")) return
947
766
 
948
- console.error("Distributed query bus: inbound stream error, attempting re-establishment via withRetry", err)
767
+ console.error(
768
+ "Axon Server query bus: inbound stream error, attempting re-establishment via withRetry",
769
+ err,
770
+ )
949
771
  await reestablishStreamWithRetry().catch((retryErr) => {
950
- console.error("Distributed query bus: reconnect retries exhausted", retryErr)
772
+ console.error("Axon Server query bus: reconnect retries exhausted", retryErr)
951
773
  })
952
774
  }
953
775
  }
954
776
 
955
- const routing: QueryBus = {
956
- async query(message: QueryMessage): Promise<unknown> {
777
+ const routing: QueryBus<U> = {
778
+ async query(unstamped: QueryMessage, uow?: UnitOfWork): Promise<unknown> {
957
779
  const activity = shutdownLatch.registerActivity()
958
780
  try {
959
- const queryName = qualifiedNameToString(message.name)
960
-
961
- // Local shortcut — handle locally if handler is co-located
962
- if (shortcutQueriesToLocalHandlers) {
963
- const localHandler = localSegment.get(queryName)
964
- if (localHandler) {
965
- return unitOfWorkRunner(message.metadata, async () => {
966
- return localHandler(message)
967
- })
968
- }
781
+ const queryName = qualifiedNameToString(unstamped.name)
782
+
783
+ // Local shortcut — handle locally if a handler is co-located. The
784
+ // caller's unit of work is passed straight through, so `next` makes the
785
+ // nest-or-open decision on the HANDLE exactly as it does for an
786
+ // in-process read: a live unit of work handed in by `ctx.query` is
787
+ // reused so the consulting read shares the caller's transaction.
788
+ if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
789
+ return next.query(unstamped, uow)
969
790
  }
970
791
 
971
- const responseStream = connection.queries.query({
972
- messageIdentifier: message.identifier,
973
- query: queryName,
974
- timestamp: BigInt(message.timestamp),
975
- payload: serializePayload(queryName, message.payload),
976
- metaData: metadataToProto(message.metadata),
977
- processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
978
- clientId: connection.config.clientId,
979
- componentName: connection.config.componentName,
980
- }, { metadata })
792
+ // A transport is not a task: it has no unit of work, so it has no clock.
793
+ // A message that reaches the wire with no instant yet gets one from system
794
+ // time here — the envelope crosses a process boundary and must be fully
795
+ // formed. A locally-shortcut message is handed to `next` untouched
796
+ // instead, so the task that handles it supplies the instant.
797
+ const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
798
+
799
+ const responseStream = connection.queries.query(
800
+ {
801
+ messageIdentifier: message.identifier,
802
+ query: queryName,
803
+ timestamp: BigInt(message.timestamp),
804
+ payload: serializePayload(queryName, message.payload),
805
+ metaData: metadataToProto(message.metadata),
806
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs),
807
+ clientId: connection.config.clientId,
808
+ componentName: connection.config.componentName,
809
+ },
810
+ { metadata },
811
+ )
981
812
 
982
813
  for await (const response of responseStream) {
983
814
  if (response.errorCode && response.errorCode !== "") {
@@ -995,25 +826,24 @@ export function distributedQueryBus(
995
826
  }
996
827
  },
997
828
 
998
- subscribe(queryName: string, handler: (message: QueryMessage) => Promise<unknown>) {
999
- localSegment.set(queryName, handler)
829
+ subscribe(
830
+ queryName: string,
831
+ handler: (message: QueryMessage, uow: U) => Promise<unknown>,
832
+ ) {
833
+ subscribedNames.add(queryName)
834
+ next.subscribe(queryName, handler)
1000
835
 
1001
836
  ensureStreamStarted()
1002
- outbound.send({
1003
- subscribe: {
1004
- messageId: generateIdentifier(),
1005
- query: queryName,
1006
- resultName: "",
1007
- componentName: connection.config.componentName,
1008
- clientId: connection.config.clientId,
1009
- },
1010
- instructionId: generateIdentifier(),
1011
- })
837
+ sendSubscribe(queryName)
1012
838
  // Permits AFTER subscription (Axon-specific ordering invariant)
1013
839
  grantQueryPermits()
1014
840
  },
1015
841
 
1016
- subscriptionQuery(message: QueryMessage, bufferSize?: number): SubscriptionQueryResult {
842
+ subscriptionQuery(
843
+ unstamped: QueryMessage,
844
+ bufferSize?: number,
845
+ ): SubscriptionQueryResult {
846
+ const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
1017
847
  const queryId = message.identifier
1018
848
  if (subscriptions.has(queryId)) {
1019
849
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
@@ -1037,7 +867,7 @@ export function distributedQueryBus(
1037
867
  timestamp: BigInt(message.timestamp),
1038
868
  payload: serializePayload(queryName, message.payload),
1039
869
  metaData: metadataToProto(message.metadata),
1040
- processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
870
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs),
1041
871
  clientId: connection.config.clientId,
1042
872
  componentName: connection.config.componentName,
1043
873
  },
@@ -1054,7 +884,7 @@ export function distributedQueryBus(
1054
884
  timestamp: BigInt(message.timestamp),
1055
885
  payload: serializePayload(queryName, message.payload),
1056
886
  metaData: metadataToProto(message.metadata),
1057
- processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
887
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs),
1058
888
  clientId: connection.config.clientId,
1059
889
  componentName: connection.config.componentName,
1060
890
  },
@@ -1078,21 +908,33 @@ export function distributedQueryBus(
1078
908
  const initial = response.initialResult
1079
909
  if (!initialSettled) {
1080
910
  if (initial.errorCode && initial.errorCode !== "") {
1081
- rejectInitial(mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error"))
911
+ rejectInitial(
912
+ mapErrorCode(
913
+ initial.errorCode,
914
+ initial.errorMessage?.message ?? "Unknown error",
915
+ ),
916
+ )
1082
917
  } else {
1083
- resolveInitial(deserializePayload(initial.payload?.data as Uint8Array | undefined))
918
+ resolveInitial(
919
+ deserializePayload(initial.payload?.data as Uint8Array | undefined),
920
+ )
1084
921
  }
1085
922
  initialSettled = true
1086
923
  }
1087
924
  } else if (response.update) {
1088
- const update = deserializePayload(response.update.payload?.data as Uint8Array | undefined)
925
+ const update = deserializePayload(
926
+ response.update.payload?.data as Uint8Array | undefined,
927
+ )
1089
928
  handler.offer(update)
1090
929
  } else if (response.complete) {
1091
930
  handler.complete()
1092
931
  break
1093
932
  } else if (response.completeExceptionally) {
1094
933
  handler.completeExceptionally(
1095
- new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed"),
934
+ new Error(
935
+ response.completeExceptionally.errorMessage?.message ??
936
+ "Subscription query failed",
937
+ ),
1096
938
  )
1097
939
  break
1098
940
  }
@@ -1125,7 +967,11 @@ export function distributedQueryBus(
1125
967
  }
1126
968
  },
1127
969
 
1128
- subscribeToUpdates(message: QueryMessage, bufferSize?: number): AsyncIterable<unknown> & { close(): void } {
970
+ subscribeToUpdates(
971
+ unstamped: QueryMessage,
972
+ bufferSize?: number,
973
+ ): AsyncIterable<unknown> & { close(): void } {
974
+ const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
1129
975
  const queryId = message.identifier
1130
976
  if (subscriptions.has(queryId)) {
1131
977
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
@@ -1174,10 +1020,7 @@ export function distributedQueryBus(
1174
1020
  })
1175
1021
  },
1176
1022
 
1177
- async completeSubscription(
1178
- queryName: string,
1179
- filter?: SubscriptionFilter,
1180
- ): Promise<void> {
1023
+ async completeSubscription(queryName: string, filter?: SubscriptionFilter): Promise<void> {
1181
1024
  runAfterCommitOrImmediately(() => {
1182
1025
  for (const [subId, sub] of handlerSubscriptions) {
1183
1026
  if (sub.queryName !== queryName) continue
@@ -1233,7 +1076,5 @@ export function distributedQueryBus(
1233
1076
  },
1234
1077
  }
1235
1078
 
1236
- const bus = interceptingQueryBus(routing)
1237
- bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
1238
- return bus
1079
+ return routing
1239
1080
  }