@kronos-ts/axon-server 0.3.2 → 0.4.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 (57) hide show
  1. package/dist/axon-server-event-store.d.ts +9 -5
  2. package/dist/axon-server-event-store.d.ts.map +1 -1
  3. package/dist/axon-server-event-store.js +38 -30
  4. package/dist/axon-server-event-store.js.map +1 -1
  5. package/dist/axon-server-snapshot-store.d.ts +8 -8
  6. package/dist/axon-server-snapshot-store.d.ts.map +1 -1
  7. package/dist/axon-server-snapshot-store.js +8 -14
  8. package/dist/axon-server-snapshot-store.js.map +1 -1
  9. package/dist/axon-server.d.ts +97 -186
  10. package/dist/axon-server.d.ts.map +1 -1
  11. package/dist/axon-server.js +217 -318
  12. package/dist/axon-server.js.map +1 -1
  13. package/dist/connection.d.ts +128 -0
  14. package/dist/connection.d.ts.map +1 -1
  15. package/dist/connection.js +134 -10
  16. package/dist/connection.js.map +1 -1
  17. package/dist/context-view.d.ts +30 -0
  18. package/dist/context-view.d.ts.map +1 -0
  19. package/dist/context-view.js +19 -0
  20. package/dist/context-view.js.map +1 -0
  21. package/dist/control-plane.d.ts +14 -13
  22. package/dist/control-plane.d.ts.map +1 -1
  23. package/dist/control-plane.js +8 -6
  24. package/dist/control-plane.js.map +1 -1
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js.map +1 -1
  27. package/dist/flow-controlled-sender.js.map +1 -1
  28. package/dist/generated/command.d.ts.map +1 -1
  29. package/dist/generated/command.js.map +1 -1
  30. package/dist/generated/common.js.map +1 -1
  31. package/dist/generated/control.d.ts.map +1 -1
  32. package/dist/generated/control.js.map +1 -1
  33. package/dist/generated/dcb.d.ts.map +1 -1
  34. package/dist/generated/dcb.js.map +1 -1
  35. package/dist/generated/event.d.ts.map +1 -1
  36. package/dist/generated/event.js.map +1 -1
  37. package/dist/generated/google/protobuf/empty.js.map +1 -1
  38. package/dist/generated/query.d.ts.map +1 -1
  39. package/dist/generated/query.js.map +1 -1
  40. package/dist/index.d.ts +4 -6
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +4 -5
  43. package/dist/index.js.map +1 -1
  44. package/dist/message-size.d.ts.map +1 -1
  45. package/dist/metadata-conversion.d.ts +1 -1
  46. package/dist/metadata-conversion.d.ts.map +1 -1
  47. package/dist/shutdown-latch.d.ts.map +1 -1
  48. package/dist/shutdown-latch.js.map +1 -1
  49. package/package.json +4 -8
  50. package/src/axon-server-event-store.ts +51 -43
  51. package/src/axon-server-snapshot-store.ts +17 -25
  52. package/src/axon-server.ts +358 -512
  53. package/src/connection.ts +270 -18
  54. package/src/context-view.ts +46 -0
  55. package/src/control-plane.ts +17 -17
  56. package/src/index.ts +12 -18
  57. package/src/metadata-conversion.ts +1 -1
@@ -1,55 +1,29 @@
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-local 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 local bus:
35
10
  *
36
11
  * ```ts
37
- * const control = await axonServerControlPlane(axon.platform, app.processors.values())
12
+ * const commandBus = interceptingCommandBus(
13
+ * axonServerCommandBus(axon, simpleCommandBus(unitOfWork)), lineage)
14
+ * const queryBus = interceptingQueryBus(
15
+ * axonServerQueryBus(axon, simpleQueryBus(unitOfWork)), lineage)
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,
@@ -57,9 +31,8 @@ import {
57
31
  generateIdentifier,
58
32
  type Serializer,
59
33
  withRetry,
60
- healthCheck,
61
34
  type ResilienceConfig,
62
- } from "@kronos-ts/common"
35
+ } from "@kronos-ts/core"
63
36
  import type {
64
37
  CommandBus,
65
38
  CommandMessage,
@@ -67,36 +40,32 @@ import type {
67
40
  QueryMessage,
68
41
  SubscriptionFilter,
69
42
  SubscriptionQueryResult,
70
- UoWRunner,
43
+ UnitOfWork,
44
+ Unstamped,
71
45
  UpdateHandler,
72
- } from "@kronos-ts/messaging"
46
+ } from "@kronos-ts/core"
73
47
  import {
74
48
  applySubscriptionFilter,
75
- correlationDataDispatchInterceptor,
76
- interceptingCommandBus,
77
- interceptingQueryBus,
49
+ stamped,
78
50
  updateHandler,
79
51
  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"
52
+ } from "@kronos-ts/core"
53
+ import type { AxonServerBusSource } from "./connection.js"
54
+ import { contextView } from "./context-view.js"
86
55
  import { metadataToProto, metadataFromProto } from "./metadata-conversion.js"
87
56
  import { outboundStream } from "./outbound-stream.js"
88
57
  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
58
 
96
59
  /** Default flow control settings — aligned with Java's 5000 permits. */
97
60
  const DEFAULT_PERMITS = 5000n
98
61
  const DEFAULT_THRESHOLD = 2500n
99
62
 
63
+ /** Default query dispatch timeout — aligned with Java's one hour. */
64
+ const DEFAULT_QUERY_TIMEOUT_MS = 3_600_000
65
+
66
+ /** Default command handler load factor — aligned with Java's 100. */
67
+ const DEFAULT_LOAD_FACTOR = 100
68
+
100
69
  /**
101
70
  * Flow control configuration for a bus channel.
102
71
  */
@@ -120,10 +89,57 @@ export interface ProcessingInstructions {
120
89
  timeoutMs?: number
121
90
  }
122
91
 
92
+ /**
93
+ * Tuning for {@link axonServerCommandBus}. Every field has a working default;
94
+ * the two arguments that carry meaning — the connection and your local bus —
95
+ * are positional, and this record is the trailing remainder.
96
+ */
97
+ export interface AxonServerCommandBusOptions {
98
+ /** Axon Server context for this bus's stream. Default: the connection's. */
99
+ context?: string
100
+ /** Flow control for the command stream. */
101
+ flowControl?: FlowControlConfig
102
+ /**
103
+ * Load factor for this command handler. Signals to Axon Server how much
104
+ * capacity this node has — higher value = more commands routed here.
105
+ * Aligned with Java's `commandLoadFactor`. Default: 100.
106
+ */
107
+ loadFactor?: number
108
+ /** Retry policy for stream re-establishment. Default: the connection's. */
109
+ resilience?: Partial<ResilienceConfig>
110
+ }
111
+
112
+ /**
113
+ * Tuning for {@link axonServerQueryBus}. See {@link AxonServerCommandBusOptions}.
114
+ */
115
+ export interface AxonServerQueryBusOptions {
116
+ /** Axon Server context for this bus's stream. Default: the connection's. */
117
+ context?: string
118
+ /** Flow control for the query stream. */
119
+ flowControl?: FlowControlConfig
120
+ /**
121
+ * When true, queries are first checked against locally subscribed handlers
122
+ * before being dispatched through Axon Server. Avoids a network round-trip
123
+ * when the handler is co-located.
124
+ *
125
+ * This is NOT the rabbitmq `preferLocal` fork by another name: it is Java's
126
+ * `shortcutQueriesToLocalHandlers`, it is off by default, and commands have
127
+ * no equivalent — Axon Server routes those, always.
128
+ */
129
+ shortcutQueriesToLocalHandlers?: boolean
130
+ /**
131
+ * Default timeout for query dispatch in ms. Default: 3600000 (1 hour).
132
+ * Aligned with Java's processing instruction timeout.
133
+ */
134
+ timeoutMs?: number
135
+ /** Retry policy for stream re-establishment. Default: the connection's. */
136
+ resilience?: Partial<ResilienceConfig>
137
+ }
138
+
123
139
  // Processing instruction keys — aligned with proto ProcessingKey enum.
124
140
  // 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.
141
+ // advertisement that MUST survive verbatim — see file-level JSDoc above and
142
+ // `defaultQueryInstructions` below.
127
143
  const INSTRUCTION_KEY = {
128
144
  ROUTING_KEY: 0,
129
145
  PRIORITY: 1,
@@ -139,10 +155,16 @@ function toProtoProcessingInstructions(instructions?: ProcessingInstructions): a
139
155
  result.push({ key: INSTRUCTION_KEY.ROUTING_KEY, value: { textValue: instructions.routingKey } })
140
156
  }
141
157
  if (instructions.priority !== undefined) {
142
- result.push({ key: INSTRUCTION_KEY.PRIORITY, value: { numberValue: BigInt(instructions.priority) } })
158
+ result.push({
159
+ key: INSTRUCTION_KEY.PRIORITY,
160
+ value: { numberValue: BigInt(instructions.priority) },
161
+ })
143
162
  }
144
163
  if (instructions.timeoutMs !== undefined) {
145
- result.push({ key: INSTRUCTION_KEY.TIMEOUT, value: { numberValue: BigInt(instructions.timeoutMs) } })
164
+ result.push({
165
+ key: INSTRUCTION_KEY.TIMEOUT,
166
+ value: { numberValue: BigInt(instructions.timeoutMs) },
167
+ })
146
168
  }
147
169
  return result
148
170
  }
@@ -161,233 +183,8 @@ function defaultQueryInstructions(timeoutMs: number): any[] {
161
183
  ]
162
184
  }
163
185
 
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
186
  // ---------------------------------------------------------------------------
390
- // Shared payload helpers (moved verbatim from legacy enhancer)
187
+ // Shared payload helpers
391
188
  // ---------------------------------------------------------------------------
392
189
 
393
190
  function createPayloadHelpers(serializer: Serializer) {
@@ -395,7 +192,11 @@ function createPayloadHelpers(serializer: Serializer) {
395
192
  serializePayload(name: string, payload: unknown, revision: string = "") {
396
193
  return serializer.serialize(payload, name, revision)
397
194
  },
398
- deserializePayload(data: Uint8Array | undefined, type: string = "", revision: string = ""): unknown {
195
+ deserializePayload(
196
+ data: Uint8Array | undefined,
197
+ type: string = "",
198
+ revision: string = "",
199
+ ): unknown {
399
200
  if (!data || data.length === 0) return undefined
400
201
  return serializer.deserialize({ data, type, revision })
401
202
  },
@@ -403,79 +204,77 @@ function createPayloadHelpers(serializer: Serializer) {
403
204
  }
404
205
 
405
206
  // ---------------------------------------------------------------------------
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()).
207
+ // Axon Server Command Bus
420
208
  // ---------------------------------------------------------------------------
421
209
 
422
210
  /**
423
- * A command bus backed by Axon Server.
211
+ * A command bus backed by Axon Server, over YOUR local bus.
424
212
  *
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.
213
+ * - **Outbound dispatch**: ALWAYS through Axon Server, via the unary Dispatch
214
+ * RPC. Axon Server routes the command to the appropriate node (which may be
215
+ * this one). There is deliberately no client-side prefer-local fork: the hub
216
+ * is the router, and short-circuiting it would silently defeat load factors,
217
+ * priorities and routing keys.
218
+ * - **Inbound**: a command the server routes here is dispatched into `local` —
219
+ * not into a privately-held handler map. That is what makes the unit-of-work
220
+ * policy you chose for `local` (say `postgresUnitOfWork(pg, unitOfWork)`)
221
+ * apply to server-routed work exactly as it applies to work this process
222
+ * originated. It is also why this function takes no `unitOfWork` argument:
223
+ * `local` carries that policy now.
224
+ * - **subscribe**: registers the handler on `local` AND announces the name to
225
+ * Axon Server, so other nodes can route to us.
434
226
  *
435
227
  * ## Correlation lineage and the interceptor layer
436
228
  *
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.
229
+ * The returned bus stamps no lineage of its own. A host that wants it wraps the
230
+ * OUTERMOST bus:
231
+ *
232
+ * ```ts
233
+ * interceptingCommandBus(axonServerCommandBus(conn, local), lineage)
234
+ * ```
235
+ *
236
+ * so whatever a host adds runs BEFORE the message is serialized onto the wire.
237
+ * Lineage itself is usually already on `message.metadata` by then — `ctx.send`
238
+ * stamps the unit of work's correlation data before any bus sees the message.
440
239
  *
441
240
  * This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
442
241
  * holds its own `DispatchInterceptors` and dispatches as
443
242
  * `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`
243
+ * site, at the top, ahead of any routing. AF5 keeps the property via decorator
244
+ * order: `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
448
245
  * stacks `InterceptingCommandBus → DistributedCommandBus → SimpleCommandBus`.
449
246
  *
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.
247
+ * If `local` is itself an intercepting bus, a server-routed command sees
248
+ * `lineage` twice. That is harmless: both of its fields are `??` seeds, so the
249
+ * second application finds them set and changes nothing.
460
250
  */
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>,
251
+ export function axonServerCommandBus(
252
+ conn: AxonServerBusSource,
253
+ local: CommandBus,
254
+ options: AxonServerCommandBusOptions = {},
469
255
  ): CommandBus {
470
- const metadata = createAxonMetadata(connection.config)
256
+ const {
257
+ connection,
258
+ serializer,
259
+ metadata: axonMetadata,
260
+ } = contextView(conn, options.context ?? conn.connection.config.context)
261
+ const shutdownLatch = conn.shutdown
262
+ const resilience = options.resilience ?? conn.resilience
263
+ const metadata = axonMetadata()
471
264
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
472
- const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS))
473
- const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
265
+ const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
266
+ const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
267
+ const loadFactor = options.loadFactor ?? DEFAULT_LOAD_FACTOR
474
268
 
475
- // Local segment — handlers that execute on this node
476
- const localSegment = new Map<string, (message: CommandMessage) => Promise<unknown>>()
269
+ /**
270
+ * The names this node announced to Axon Server. The handlers themselves live
271
+ * on `local`; this set exists so an inbound command for a name we never
272
+ * subscribed still answers NO_HANDLER_FOR_COMMAND rather than whatever
273
+ * `local.dispatch` happens to throw — and so a reconnect can re-announce.
274
+ */
275
+ const subscribedNames = new Set<string>()
477
276
 
478
- // Bidirectional stream for handler registration + inbound command handling
277
+ // Bidirectional stream for handler subscription + inbound command handling
479
278
  let outbound = outboundStream<any>()
480
279
  let streamStarted = false
481
280
  let permits = 0n
@@ -497,8 +296,21 @@ export function distributedCommandBus(
497
296
  permits += PERMITS
498
297
  }
499
298
 
299
+ function sendSubscribe(commandName: string) {
300
+ outbound.send({
301
+ subscribe: {
302
+ messageId: generateIdentifier(),
303
+ command: commandName,
304
+ componentName: connection.config.componentName,
305
+ clientId: connection.config.clientId,
306
+ loadFactor,
307
+ },
308
+ instructionId: generateIdentifier(),
309
+ })
310
+ }
311
+
500
312
  /**
501
- * Re-establish the bidirectional stream and re-subscribe all handlers.
313
+ * Re-establish the bidirectional stream and re-announce all handlers.
502
314
  * Called on stream error or when the connection reconnects.
503
315
  *
504
316
  * ORDER (preserves Axon-specific invariant): subscriptions are
@@ -512,18 +324,7 @@ export function distributedCommandBus(
512
324
  permits = 0n
513
325
  ensureStreamStarted()
514
326
  // 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
- }
327
+ for (const commandName of subscribedNames) sendSubscribe(commandName)
527
328
  // Permits AFTER subscriptions (Axon-specific ordering invariant)
528
329
  grantPermits()
529
330
  }
@@ -540,7 +341,7 @@ export function distributedCommandBus(
540
341
  connection.onReconnect(() => {
541
342
  if (!shutdownLatch.shuttingDown && streamStarted) {
542
343
  reestablishStreamWithRetry().catch((err) => {
543
- console.error("Distributed command bus: reconnect retries exhausted", err)
344
+ console.error("Axon Server command bus: reconnect retries exhausted", err)
544
345
  })
545
346
  }
546
347
  })
@@ -553,13 +354,12 @@ export function distributedCommandBus(
553
354
  permits--
554
355
  const proto = message.command
555
356
  const commandName = proto.name
556
- const handler = localSegment.get(commandName)
557
357
 
558
358
  let resultPayload: unknown
559
359
  let errorCode = ""
560
360
  let errorMsg = ""
561
361
 
562
- if (handler) {
362
+ if (subscribedNames.has(commandName)) {
563
363
  try {
564
364
  const commandMessage: CommandMessage = {
565
365
  kind: "command",
@@ -570,10 +370,12 @@ export function distributedCommandBus(
570
370
  timestamp: Number(proto.timestamp),
571
371
  }
572
372
 
573
- // Execute inbound command within its own UnitOfWork (AF5 parity)
574
- resultPayload = await unitOfWorkRunner(commandMessage.metadata, () =>
575
- handler(commandMessage),
576
- )
373
+ // Through the LOCAL BUS, so the caller's unit-of-work policy runs.
374
+ // AF parity is preserved: `CommandProcessingTask` runs the local
375
+ // segment without re-running dispatch interceptors, and a `local`
376
+ // that happens to carry `lineage` re-applies a pair of `??` seeds
377
+ // that are already set.
378
+ resultPayload = await local.dispatch(commandMessage)
577
379
  } catch (err) {
578
380
  errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR
579
381
  errorMsg = err instanceof Error ? err.message : String(err)
@@ -590,11 +392,15 @@ export function distributedCommandBus(
590
392
  requestIdentifier: proto.messageIdentifier,
591
393
  errorCode,
592
394
  errorMessage: errorCode
593
- ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
594
- : undefined,
595
- payload: resultPayload !== undefined
596
- ? serializePayload("result", resultPayload)
395
+ ? {
396
+ message: errorMsg,
397
+ location: connection.config.componentName,
398
+ details: [],
399
+ errorCode,
400
+ }
597
401
  : undefined,
402
+ payload:
403
+ resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
598
404
  metaData: {},
599
405
  processingInstructions: [],
600
406
  },
@@ -614,35 +420,47 @@ export function distributedCommandBus(
614
420
  if (shutdownLatch.shuttingDown) return
615
421
  if (String(err).includes("Connection dropped")) return
616
422
 
617
- console.error("Distributed command bus: inbound stream error, attempting re-establishment via withRetry", err)
423
+ console.error(
424
+ "Axon Server command bus: inbound stream error, attempting re-establishment via withRetry",
425
+ err,
426
+ )
618
427
  await reestablishStreamWithRetry().catch((retryErr) => {
619
- console.error("Distributed command bus: reconnect retries exhausted", retryErr)
428
+ console.error("Axon Server command bus: reconnect retries exhausted", retryErr)
620
429
  })
621
430
  }
622
431
  }
623
432
 
624
- const routing: CommandBus = {
625
- async dispatch(message: CommandMessage): Promise<unknown> {
433
+ return {
434
+ async dispatch(unstamped: Unstamped<CommandMessage>): Promise<unknown> {
435
+ // A transport is not a task: it has no unit of work, so it has no clock.
436
+ // A message that reaches the wire still {@link Unstamped} is therefore
437
+ // stamped from system time here — the envelope crosses a process boundary
438
+ // and must be fully formed. A locally-shortcut message is handed to
439
+ // `local` unstamped instead, so the task that handles it supplies the
440
+ // instant.
441
+ const message = stamped(unstamped, Date.now)
626
442
  const activity = shutdownLatch.registerActivity()
627
443
  try {
628
444
  const commandName = qualifiedNameToString(message.name)
629
445
 
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 })
446
+ const response = await connection.commands.dispatch(
447
+ {
448
+ messageIdentifier: message.identifier,
449
+ name: commandName,
450
+ timestamp: BigInt(message.timestamp),
451
+ payload: serializePayload(commandName, message.payload),
452
+ metaData: metadataToProto(message.metadata),
453
+ processingInstructions: toProtoProcessingInstructions(
454
+ message.metadata?.processingInstructions as ProcessingInstructions | undefined,
455
+ ),
456
+ clientId: connection.config.clientId,
457
+ componentName: connection.config.componentName,
458
+ },
459
+ { metadata },
460
+ )
640
461
 
641
462
  if (response.errorCode && response.errorCode !== "") {
642
- throw mapErrorCode(
643
- response.errorCode,
644
- response.errorMessage?.message ?? "Unknown error",
645
- )
463
+ throw mapErrorCode(response.errorCode, response.errorMessage?.message ?? "Unknown error")
646
464
  }
647
465
 
648
466
  return deserializePayload(response.payload?.data as Uint8Array | undefined)
@@ -651,73 +469,72 @@ export function distributedCommandBus(
651
469
  }
652
470
  },
653
471
 
654
- subscribe(commandName: string, handler: (message: CommandMessage) => Promise<unknown>) {
655
- localSegment.set(commandName, handler)
472
+ subscribe(
473
+ commandName: string,
474
+ handler: (message: CommandMessage, uow: UnitOfWork) => Promise<unknown>,
475
+ ) {
476
+ subscribedNames.add(commandName)
477
+ local.subscribe(commandName, handler)
656
478
 
657
479
  ensureStreamStarted()
658
480
  // 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
- })
481
+ sendSubscribe(commandName)
669
482
  // Permits AFTER subscription (Axon-specific ordering invariant)
670
483
  grantPermits()
671
484
  },
672
485
  }
673
-
674
- // Interception OUTSIDE routing — see the note on this function.
675
- const bus = interceptingCommandBus(routing)
676
- bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
677
- return bus
678
486
  }
679
487
 
680
488
  // ---------------------------------------------------------------------------
681
- // Distributed Query Bus
489
+ // Axon Server Query Bus
682
490
  // ---------------------------------------------------------------------------
683
491
 
684
492
  /**
685
- * A query bus backed by Axon Server.
493
+ * A query bus backed by Axon Server, over YOUR local bus.
494
+ *
495
+ * Same architecture as {@link axonServerCommandBus}: outbound dispatch goes
496
+ * through Axon Server, and a query the server routes here runs through `local`,
497
+ * so your unit-of-work policy applies to server-routed reads too. `subscribe`
498
+ * registers on `local` and announces the name to the server.
686
499
  *
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.
500
+ * The one asymmetry with commands is `shortcutQueriesToLocalHandlers` — Java
501
+ * has it for queries and not for commands, and so do we. When it is on and this
502
+ * node subscribed the name, `query()` goes straight to `local` and the caller's
503
+ * unit of work is passed through, so the local branch nests exactly as the
504
+ * in-process bus does.
692
505
  *
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.
506
+ * Lineage, if wanted, is `interceptingQueryBus(bus, lineage)` at the host,
507
+ * matching AF4's `AxonServerQueryBus`, which calls
508
+ * `dispatchInterceptors.intercept(...)` at the top of `query`, `streamingQuery`,
509
+ * `scatterGather` and `subscriptionQuery`. Because the wrap is outside, the
510
+ * shortcut branch gets identical lineage to the remote branch.
699
511
  *
700
512
  * KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
701
513
  * 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.
514
+ * `@kronos-ts/core`) forwards those two calls to the delegate without
515
+ * running the dispatch chain. Closing that needs a core change.
704
516
  */
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>,
517
+ export function axonServerQueryBus(
518
+ conn: AxonServerBusSource,
519
+ local: QueryBus,
520
+ options: AxonServerQueryBusOptions = {},
714
521
  ): 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))
522
+ const {
523
+ connection,
524
+ serializer,
525
+ metadata: axonMetadata,
526
+ } = contextView(conn, options.context ?? conn.connection.config.context)
527
+ const shutdownLatch = conn.shutdown
528
+ const resilience = options.resilience ?? conn.resilience
529
+ const metadata = axonMetadata()
530
+ const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS))
531
+ const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
532
+ const shortcutQueriesToLocalHandlers = options.shortcutQueriesToLocalHandlers ?? false
533
+ const queryTimeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS
718
534
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
719
535
 
720
- const localSegment = new Map<string, (message: QueryMessage) => Promise<unknown>>()
536
+ /** Query names announced to Axon Server; the handlers live on `local`. */
537
+ const subscribedNames = new Set<string>()
721
538
 
722
539
  // Local subscription store — subscription queries opened by THIS instance.
723
540
  // Inbound updates from the server are offered into these via the Subscription RPC loop.
@@ -750,8 +567,21 @@ export function distributedQueryBus(
750
567
  permits += PERMITS
751
568
  }
752
569
 
570
+ function sendSubscribe(queryName: string) {
571
+ outbound.send({
572
+ subscribe: {
573
+ messageId: generateIdentifier(),
574
+ query: queryName,
575
+ resultName: "",
576
+ componentName: connection.config.componentName,
577
+ clientId: connection.config.clientId,
578
+ },
579
+ instructionId: generateIdentifier(),
580
+ })
581
+ }
582
+
753
583
  /**
754
- * Re-establish the bidirectional stream and re-subscribe all handlers.
584
+ * Re-establish the bidirectional stream and re-announce all handlers.
755
585
  * Called on stream error or when the connection reconnects.
756
586
  *
757
587
  * ORDER (preserves Axon-specific invariant): subscriptions are
@@ -763,18 +593,7 @@ export function distributedQueryBus(
763
593
  streamStarted = false
764
594
  permits = 0n
765
595
  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
- }
596
+ for (const queryName of subscribedNames) sendSubscribe(queryName)
778
597
  grantQueryPermits()
779
598
  }
780
599
 
@@ -790,7 +609,7 @@ export function distributedQueryBus(
790
609
  connection.onReconnect(() => {
791
610
  if (!shutdownLatch.shuttingDown && streamStarted) {
792
611
  reestablishStreamWithRetry().catch((err) => {
793
- console.error("Distributed query bus: reconnect retries exhausted", err)
612
+ console.error("Axon Server query bus: reconnect retries exhausted", err)
794
613
  })
795
614
  }
796
615
  })
@@ -810,12 +629,11 @@ export function distributedQueryBus(
810
629
  )
811
630
  handlerSubscriptions.set(subId, { queryName, payload })
812
631
 
813
- const handler = localSegment.get(queryName)
814
632
  let resultPayload: unknown
815
633
  let errorCode = ""
816
634
  let errorMsg = ""
817
635
 
818
- if (handler) {
636
+ if (subscribedNames.has(queryName)) {
819
637
  try {
820
638
  const queryMessage: QueryMessage = {
821
639
  kind: "query",
@@ -825,9 +643,7 @@ export function distributedQueryBus(
825
643
  metadata: metadataFromProto(proto.metaData ?? {}),
826
644
  timestamp: Number(proto.timestamp),
827
645
  }
828
- resultPayload = await unitOfWorkRunner(queryMessage.metadata, async () => {
829
- return handler(queryMessage)
830
- })
646
+ resultPayload = await local.query(queryMessage)
831
647
  } catch (err) {
832
648
  errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
833
649
  errorMsg = err instanceof Error ? err.message : String(err)
@@ -837,9 +653,8 @@ export function distributedQueryBus(
837
653
  errorMsg = `No local handler for query "${queryName}"`
838
654
  }
839
655
 
840
- const responseSerialized = resultPayload !== undefined
841
- ? serializePayload("result", resultPayload)
842
- : undefined
656
+ const responseSerialized =
657
+ resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined
843
658
 
844
659
  outbound.send({
845
660
  subscriptionQueryResponse: {
@@ -850,7 +665,12 @@ export function distributedQueryBus(
850
665
  requestIdentifier: proto.messageIdentifier,
851
666
  errorCode,
852
667
  errorMessage: errorCode
853
- ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
668
+ ? {
669
+ message: errorMsg,
670
+ location: connection.config.componentName,
671
+ details: [],
672
+ errorCode,
673
+ }
854
674
  : undefined,
855
675
  payload: responseSerialized,
856
676
  metaData: {},
@@ -879,13 +699,12 @@ export function distributedQueryBus(
879
699
  permits--
880
700
  const proto = message.query
881
701
  const queryName = proto.query
882
- const handler = localSegment.get(queryName)
883
702
 
884
703
  let resultPayload: unknown
885
704
  let errorCode = ""
886
705
  let errorMsg = ""
887
706
 
888
- if (handler) {
707
+ if (subscribedNames.has(queryName)) {
889
708
  try {
890
709
  const queryMessage: QueryMessage = {
891
710
  kind: "query",
@@ -896,9 +715,9 @@ export function distributedQueryBus(
896
715
  timestamp: Number(proto.timestamp),
897
716
  }
898
717
 
899
- resultPayload = await unitOfWorkRunner(queryMessage.metadata, async () => {
900
- return handler(queryMessage)
901
- })
718
+ // Through the LOCAL BUS: no unit of work is handed in, so `local`
719
+ // opens one under whatever policy the caller gave it.
720
+ resultPayload = await local.query(queryMessage)
902
721
  } catch (err) {
903
722
  errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
904
723
  errorMsg = err instanceof Error ? err.message : String(err)
@@ -914,11 +733,15 @@ export function distributedQueryBus(
914
733
  requestIdentifier: proto.messageIdentifier,
915
734
  errorCode,
916
735
  errorMessage: errorCode
917
- ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
918
- : undefined,
919
- payload: resultPayload !== undefined
920
- ? serializePayload("result", resultPayload)
736
+ ? {
737
+ message: errorMsg,
738
+ location: connection.config.componentName,
739
+ details: [],
740
+ errorCode,
741
+ }
921
742
  : undefined,
743
+ payload:
744
+ resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
922
745
  metaData: {},
923
746
  processingInstructions: [],
924
747
  },
@@ -945,39 +768,52 @@ export function distributedQueryBus(
945
768
  if (shutdownLatch.shuttingDown) return
946
769
  if (String(err).includes("Connection dropped")) return
947
770
 
948
- console.error("Distributed query bus: inbound stream error, attempting re-establishment via withRetry", err)
771
+ console.error(
772
+ "Axon Server query bus: inbound stream error, attempting re-establishment via withRetry",
773
+ err,
774
+ )
949
775
  await reestablishStreamWithRetry().catch((retryErr) => {
950
- console.error("Distributed query bus: reconnect retries exhausted", retryErr)
776
+ console.error("Axon Server query bus: reconnect retries exhausted", retryErr)
951
777
  })
952
778
  }
953
779
  }
954
780
 
955
781
  const routing: QueryBus = {
956
- async query(message: QueryMessage): Promise<unknown> {
782
+ async query(unstamped: Unstamped<QueryMessage>, uow?: UnitOfWork): Promise<unknown> {
957
783
  const activity = shutdownLatch.registerActivity()
958
784
  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
- }
785
+ const queryName = qualifiedNameToString(unstamped.name)
786
+
787
+ // Local shortcut — handle locally if a handler is co-located. The
788
+ // caller's unit of work is passed straight through, so `local` makes the
789
+ // nest-or-open decision on the HANDLE exactly as it does for an
790
+ // in-process read: a live unit of work handed in by `ctx.query` is
791
+ // reused so the consulting read shares the caller's transaction.
792
+ if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
793
+ return local.query(unstamped, uow)
969
794
  }
970
795
 
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 })
796
+ // A transport is not a task: it has no unit of work, so it has no clock.
797
+ // A message that reaches the wire still {@link Unstamped} is therefore
798
+ // stamped from system time here — the envelope crosses a process boundary
799
+ // and must be fully formed. A locally-shortcut message is handed to
800
+ // `local` unstamped instead, so the task that handles it supplies the
801
+ // instant.
802
+ const message = stamped(unstamped, Date.now)
803
+
804
+ const responseStream = connection.queries.query(
805
+ {
806
+ messageIdentifier: message.identifier,
807
+ query: queryName,
808
+ timestamp: BigInt(message.timestamp),
809
+ payload: serializePayload(queryName, message.payload),
810
+ metaData: metadataToProto(message.metadata),
811
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs),
812
+ clientId: connection.config.clientId,
813
+ componentName: connection.config.componentName,
814
+ },
815
+ { metadata },
816
+ )
981
817
 
982
818
  for await (const response of responseStream) {
983
819
  if (response.errorCode && response.errorCode !== "") {
@@ -995,25 +831,24 @@ export function distributedQueryBus(
995
831
  }
996
832
  },
997
833
 
998
- subscribe(queryName: string, handler: (message: QueryMessage) => Promise<unknown>) {
999
- localSegment.set(queryName, handler)
834
+ subscribe(
835
+ queryName: string,
836
+ handler: (message: QueryMessage, uow: UnitOfWork) => Promise<unknown>,
837
+ ) {
838
+ subscribedNames.add(queryName)
839
+ local.subscribe(queryName, handler)
1000
840
 
1001
841
  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
- })
842
+ sendSubscribe(queryName)
1012
843
  // Permits AFTER subscription (Axon-specific ordering invariant)
1013
844
  grantQueryPermits()
1014
845
  },
1015
846
 
1016
- subscriptionQuery(message: QueryMessage, bufferSize?: number): SubscriptionQueryResult {
847
+ subscriptionQuery(
848
+ unstamped: Unstamped<QueryMessage>,
849
+ bufferSize?: number,
850
+ ): SubscriptionQueryResult {
851
+ const message = stamped(unstamped, Date.now)
1017
852
  const queryId = message.identifier
1018
853
  if (subscriptions.has(queryId)) {
1019
854
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
@@ -1037,7 +872,7 @@ export function distributedQueryBus(
1037
872
  timestamp: BigInt(message.timestamp),
1038
873
  payload: serializePayload(queryName, message.payload),
1039
874
  metaData: metadataToProto(message.metadata),
1040
- processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
875
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs),
1041
876
  clientId: connection.config.clientId,
1042
877
  componentName: connection.config.componentName,
1043
878
  },
@@ -1054,7 +889,7 @@ export function distributedQueryBus(
1054
889
  timestamp: BigInt(message.timestamp),
1055
890
  payload: serializePayload(queryName, message.payload),
1056
891
  metaData: metadataToProto(message.metadata),
1057
- processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
892
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs),
1058
893
  clientId: connection.config.clientId,
1059
894
  componentName: connection.config.componentName,
1060
895
  },
@@ -1078,21 +913,33 @@ export function distributedQueryBus(
1078
913
  const initial = response.initialResult
1079
914
  if (!initialSettled) {
1080
915
  if (initial.errorCode && initial.errorCode !== "") {
1081
- rejectInitial(mapErrorCode(initial.errorCode, initial.errorMessage?.message ?? "Unknown error"))
916
+ rejectInitial(
917
+ mapErrorCode(
918
+ initial.errorCode,
919
+ initial.errorMessage?.message ?? "Unknown error",
920
+ ),
921
+ )
1082
922
  } else {
1083
- resolveInitial(deserializePayload(initial.payload?.data as Uint8Array | undefined))
923
+ resolveInitial(
924
+ deserializePayload(initial.payload?.data as Uint8Array | undefined),
925
+ )
1084
926
  }
1085
927
  initialSettled = true
1086
928
  }
1087
929
  } else if (response.update) {
1088
- const update = deserializePayload(response.update.payload?.data as Uint8Array | undefined)
930
+ const update = deserializePayload(
931
+ response.update.payload?.data as Uint8Array | undefined,
932
+ )
1089
933
  handler.offer(update)
1090
934
  } else if (response.complete) {
1091
935
  handler.complete()
1092
936
  break
1093
937
  } else if (response.completeExceptionally) {
1094
938
  handler.completeExceptionally(
1095
- new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed"),
939
+ new Error(
940
+ response.completeExceptionally.errorMessage?.message ??
941
+ "Subscription query failed",
942
+ ),
1096
943
  )
1097
944
  break
1098
945
  }
@@ -1125,7 +972,11 @@ export function distributedQueryBus(
1125
972
  }
1126
973
  },
1127
974
 
1128
- subscribeToUpdates(message: QueryMessage, bufferSize?: number): AsyncIterable<unknown> & { close(): void } {
975
+ subscribeToUpdates(
976
+ unstamped: Unstamped<QueryMessage>,
977
+ bufferSize?: number,
978
+ ): AsyncIterable<unknown> & { close(): void } {
979
+ const message = stamped(unstamped, Date.now)
1129
980
  const queryId = message.identifier
1130
981
  if (subscriptions.has(queryId)) {
1131
982
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
@@ -1174,10 +1025,7 @@ export function distributedQueryBus(
1174
1025
  })
1175
1026
  },
1176
1027
 
1177
- async completeSubscription(
1178
- queryName: string,
1179
- filter?: SubscriptionFilter,
1180
- ): Promise<void> {
1028
+ async completeSubscription(queryName: string, filter?: SubscriptionFilter): Promise<void> {
1181
1029
  runAfterCommitOrImmediately(() => {
1182
1030
  for (const [subId, sub] of handlerSubscriptions) {
1183
1031
  if (sub.queryName !== queryName) continue
@@ -1233,7 +1081,5 @@ export function distributedQueryBus(
1233
1081
  },
1234
1082
  }
1235
1083
 
1236
- const bus = interceptingQueryBus(routing)
1237
- bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
1238
- return bus
1084
+ return routing
1239
1085
  }