@kronos-ts/axon-server 0.2.11 → 0.3.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 (56) hide show
  1. package/dist/axon-server-event-store.d.ts +1 -1
  2. package/dist/axon-server-event-store.d.ts.map +1 -1
  3. package/dist/axon-server-event-store.js +3 -3
  4. package/dist/axon-server-event-store.js.map +1 -1
  5. package/dist/axon-server-snapshot-store.d.ts +1 -1
  6. package/dist/axon-server-snapshot-store.d.ts.map +1 -1
  7. package/dist/axon-server-snapshot-store.js +1 -1
  8. package/dist/axon-server-snapshot-store.js.map +1 -1
  9. package/dist/axon-server.d.ts +169 -43
  10. package/dist/axon-server.d.ts.map +1 -1
  11. package/dist/axon-server.js +191 -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-manager.js +1 -1
  16. package/dist/connection-manager.js.map +1 -1
  17. package/dist/control-plane.d.ts +108 -0
  18. package/dist/control-plane.d.ts.map +1 -0
  19. package/dist/control-plane.js +96 -0
  20. package/dist/control-plane.js.map +1 -0
  21. package/dist/flow-controlled-sender.d.ts +1 -1
  22. package/dist/flow-controlled-sender.d.ts.map +1 -1
  23. package/dist/flow-controlled-sender.js +1 -1
  24. package/dist/flow-controlled-sender.js.map +1 -1
  25. package/dist/index.d.ts +10 -8
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +9 -8
  28. package/dist/index.js.map +1 -1
  29. package/dist/message-size.d.ts +1 -1
  30. package/dist/message-size.d.ts.map +1 -1
  31. package/dist/message-size.js +1 -1
  32. package/dist/message-size.js.map +1 -1
  33. package/dist/outbound-stream.d.ts +1 -1
  34. package/dist/outbound-stream.d.ts.map +1 -1
  35. package/dist/outbound-stream.js +1 -1
  36. package/dist/outbound-stream.js.map +1 -1
  37. package/dist/platform-service.d.ts +34 -3
  38. package/dist/platform-service.d.ts.map +1 -1
  39. package/dist/platform-service.js +113 -42
  40. package/dist/platform-service.js.map +1 -1
  41. package/dist/shutdown-latch.d.ts +1 -1
  42. package/dist/shutdown-latch.d.ts.map +1 -1
  43. package/dist/shutdown-latch.js +1 -1
  44. package/dist/shutdown-latch.js.map +1 -1
  45. package/package.json +5 -5
  46. package/src/axon-server-event-store.ts +3 -3
  47. package/src/axon-server-snapshot-store.ts +1 -1
  48. package/src/axon-server.ts +283 -367
  49. package/src/connection-manager.ts +2 -2
  50. package/src/control-plane.ts +195 -0
  51. package/src/flow-controlled-sender.ts +1 -1
  52. package/src/index.ts +19 -8
  53. package/src/message-size.ts +1 -1
  54. package/src/outbound-stream.ts +1 -1
  55. package/src/platform-service.ts +152 -48
  56. package/src/shutdown-latch.ts +1 -1
@@ -1,34 +1,55 @@
1
1
  /**
2
- * Native Axon Server extension (Phase 9, D-95 / D-101 / D-102).
2
+ * Axon Server backend for kronos.
3
3
  *
4
- * Replaces the legacy enhancer surface (now deleted) with a
5
- * `(app: App) => void` extension that:
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:
6
10
  *
7
- * - populates four typed slots (eventStore, snapshotStore, commandBus,
8
- * queryBus) via app.set(...) using the canonical Resolved slot names
9
- * (in particular `resolved.unitOfWorkFactory`, NOT `unitOfWorkRunner`);
10
- * - wires connect-stage transport bring-up under the @kronos-ts/common
11
- * resilience helper (initial-connect + health-check + platform setup +
12
- * instruction handlers + platform.start);
13
- * - wires processors-stage subscription-ack wait via withRetry against
14
- * `platform.subscriptionsAcked()` REPLACES the 1-second sleep hack
15
- * that lived at line 264 of the legacy file (D-102 — Axon equivalent);
16
- * - reverses shutdown deterministically in a single onStop('connect') hook
17
- * (busLatches platform.stop connection.close D-101.b).
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:
35
+ *
36
+ * ```ts
37
+ * const control = await axonServerControlPlane(axon.platform, app.processors.values())
38
+ * ```
18
39
  *
19
- * Mirrors `kronosdb.ts` (Plan 09-03) STRUCTURALLY same slot+lifecycle
20
- * pattern, same resilience helper, same shutdown ordering, same
21
- * subscription-ack derivation strategy — but preserves Axon-specific
22
- * protocol invariants byte-for-byte:
40
+ * `start()` therefore takes NO arguments and does exactly one thing: the
41
+ * data-path readiness barrier. See `control-plane.ts`.
42
+ *
43
+ * Axon-specific protocol invariants are preserved byte-for-byte:
23
44
  *
24
45
  * - CLIENT_SUPPORTS_STREAMING capability advertised on every dispatched
25
46
  * query via `defaultQueryInstructions(...)`;
26
47
  * - AxonIQ-Context + AxonIQ-Access-Token gRPC metadata headers built by
27
48
  * `createAxonMetadata(...)` and attached to every outbound stream/RPC;
28
49
  * - permits-AFTER-subscriptions stream ordering preserved on the initial
29
- * handshake AND on reconnect (legacy semantics in `ensureStreamStarted`
30
- * issued permits before subscriptions; this implementation matches that
31
- * exact ordering see `ensureStreamStarted` / `reestablishStreamBody`).
50
+ * handshake AND on reconnect (see `ensureStreamStarted` /
51
+ * `reestablishStreamBody`);
52
+ * - shutdown ordering: busLatches platform.stop connection.close.
32
53
  */
33
54
  import {
34
55
  qualifiedNameToString,
@@ -39,11 +60,9 @@ import {
39
60
  healthCheck,
40
61
  type ResilienceConfig,
41
62
  } from "@kronos-ts/common"
42
- import type { App } from "@kronos-ts/app"
43
63
  import type {
44
64
  CommandBus,
45
65
  CommandMessage,
46
- EventProcessorModule,
47
66
  QueryBus,
48
67
  QueryMessage,
49
68
  SubscriptionFilter,
@@ -51,18 +70,25 @@ import type {
51
70
  UoWRunner,
52
71
  UpdateHandler,
53
72
  } from "@kronos-ts/messaging"
54
- import { applySubscriptionFilter, createUpdateHandler, runAfterCommitOrImmediately } from "@kronos-ts/messaging"
73
+ import {
74
+ applySubscriptionFilter,
75
+ correlationDataDispatchInterceptor,
76
+ interceptingCommandBus,
77
+ interceptingQueryBus,
78
+ updateHandler,
79
+ runAfterCommitOrImmediately,
80
+ } from "@kronos-ts/messaging"
55
81
  import { Metadata } from "nice-grpc"
56
82
  import type { AxonServerConnectionConfig } from "./connection.js"
57
83
  import { connectToAxonServer, type AxonServerConnection } from "./connection.js"
58
- import { createAxonServerEventStore } from "./axon-server-event-store.js"
59
- import { createAxonServerSnapshotStore } from "./axon-server-snapshot-store.js"
84
+ import { axonServerEventStore } from "./axon-server-event-store.js"
85
+ import { axonServerSnapshotStore } from "./axon-server-snapshot-store.js"
60
86
  import { metadataToProto, metadataFromProto } from "./metadata-conversion.js"
61
- import { createOutboundStream } from "./outbound-stream.js"
87
+ import { outboundStream } from "./outbound-stream.js"
62
88
  import { mapErrorCode, AxonServerErrorCode } from "./errors.js"
63
- import { createShutdownLatch, type ShutdownLatch } from "./shutdown-latch.js"
89
+ import { shutdownLatch, type ShutdownLatch } from "./shutdown-latch.js"
64
90
  import {
65
- createPlatformConnection,
91
+ platformConnection,
66
92
  type PlatformConnection,
67
93
  type PlatformServiceOptions,
68
94
  } from "./platform-service.js"
@@ -150,7 +176,7 @@ function createAxonMetadata(config: { context: string; token: string }): Metadat
150
176
  return metadata
151
177
  }
152
178
 
153
- export interface AxonServerExtensionConfig extends AxonServerConnectionConfig {
179
+ export interface AxonServerConfig extends AxonServerConnectionConfig {
154
180
  /** Flow control for the command bus channel. */
155
181
  commandFlowControl?: FlowControlConfig
156
182
  /** Flow control for the query bus channel. */
@@ -187,336 +213,176 @@ export interface AxonServerExtensionConfig extends AxonServerConnectionConfig {
187
213
  /** Per-extension resilience config (D-100 / D-101). */
188
214
  resilience?: Partial<ResilienceConfig>
189
215
  /**
190
- * Delay in ms after the platform-stream ack to give Axon Server's
191
- * routing tables time to register the subscribe frames sent on the
192
- * command/query streams. The platform stream cannot observe these (they
193
- * travel on different streams). Default: 1000 — matches the legacy
194
- * enhancer's wait. Tests against a freshly-booted server can tighten
195
- * this once subscriptions are observed to land faster.
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.
196
226
  */
197
227
  busSubscriptionAckDelayMs?: number
198
228
  }
199
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
+
200
238
  /**
201
- * Native Axon Server extension factory. Returns an Extension closure shaped
202
- * as `(app: App) => void` per D-95.
203
- *
204
- * ```ts
205
- * await kronos()
206
- * .use(axonServer({ componentName: "university-service" }))
207
- * .start()
208
- * ```
239
+ * A live Axon Server backend: the components it provides plus the two calls
240
+ * that used to be lifecycle stages.
209
241
  */
210
- export function axonServer(serverConfig: AxonServerExtensionConfig): (app: App) => void {
211
- return (app) => {
212
- let connection: AxonServerConnection | undefined
213
- let platform: PlatformConnection | undefined
214
- const busLatches: ShutdownLatch[] = []
215
-
216
- function getConnection(): AxonServerConnection {
217
- if (!connection) {
218
- throw new Error(
219
- "[kronos:axon-server] connection not yet established — wait for onStart('connect')",
220
- )
221
- }
222
- return connection
223
- }
224
-
225
- // ---- Slot population (D-95) -----------------------------------------
226
- //
227
- // AppImpl.start() in @kronos-ts/app eagerly resolves all 8 slots and
228
- // runs `commandBus.subscribe(...)` for every registered handler BEFORE
229
- // any onStart('connect') hook fires (see app.ts §3 / §5c). The Axon
230
- // bus factories open real gRPC streams against the live channel during
231
- // construction (createAxonMetadata / connection.onReconnect / inbound
232
- // stream openers), so they CANNOT run until the connect hook has
233
- // populated `connection`.
234
- //
235
- // Solution: the slot factories return wrappers around lazily-built
236
- // inner instances. EventStore/SnapshotStore use a lightweight lazy
237
- // proxy because their factories never dereference `connection` at
238
- // construction time (only inside method bodies). CommandBus/QueryBus
239
- // use a `subscribe()`-buffering wrapper that queues subscriptions
240
- // synchronously and replays them once the connect hook completes —
241
- // dispatch / query calls await the same readiness promise.
242
-
243
- /** Latches once the connect hook has populated `connection`. */
244
- let resolveConnected: () => void = () => {}
245
- const connected: Promise<void> = new Promise((res) => {
246
- resolveConnected = res
247
- })
248
-
249
- app.set("eventStore", (resolved) => {
250
- // Lazy proxy: createAxonServerEventStore stores `connection` in
251
- // closure scope but only dereferences it inside method bodies, so
252
- // a Proxy that forwards property access to getConnection() works
253
- // — by the time framework code calls source/append/stream the
254
- // connect hook has populated the closure.
255
- const lazyConnection = new Proxy({} as AxonServerConnection, {
256
- get(_t, prop) {
257
- return (getConnection() as any)[prop]
258
- },
259
- })
260
- return createAxonServerEventStore(lazyConnection, resolved.serializer)
261
- })
262
-
263
- app.set("snapshotStore", (resolved) => {
264
- const lazyConnection = new Proxy({} as AxonServerConnection, {
265
- get(_t, prop) {
266
- return (getConnection() as any)[prop]
267
- },
268
- })
269
- return createAxonServerSnapshotStore(lazyConnection, resolved.serializer)
270
- })
271
-
272
- app.set("commandBus", (resolved) => {
273
- const latch = createShutdownLatch()
274
- busLatches.push(latch)
275
-
276
- let inner: CommandBus | undefined
277
- const pendingSubs: Array<[string, (m: CommandMessage) => Promise<unknown>]> = []
278
-
279
- // Build the real bus once the connect hook fires + replay buffered subs.
280
- connected.then(() => {
281
- inner = createDistributedCommandBus(
282
- getConnection(),
283
- resolved.unitOfWorkFactory,
284
- latch,
285
- resolved.serializer,
286
- serverConfig.commandFlowControl,
287
- serverConfig.commandLoadFactor,
288
- serverConfig.resilience,
289
- )
290
- for (const [name, h] of pendingSubs) inner.subscribe(name, h)
291
- pendingSubs.length = 0
292
- })
293
-
294
- const wrapper: CommandBus = {
295
- async dispatch(message) {
296
- await connected
297
- return inner!.dispatch(message)
298
- },
299
- subscribe(name, handler) {
300
- if (inner) inner.subscribe(name, handler)
301
- else pendingSubs.push([name, handler])
302
- },
303
- }
304
- return wrapper
305
- })
306
-
307
- app.set("queryBus", (resolved) => {
308
- const latch = createShutdownLatch()
309
- busLatches.push(latch)
310
-
311
- let inner: QueryBus | undefined
312
- const pendingSubs: Array<[string, (m: QueryMessage) => Promise<unknown>]> = []
313
-
314
- connected.then(() => {
315
- inner = createDistributedQueryBus(
316
- getConnection(),
317
- resolved.unitOfWorkFactory,
318
- latch,
319
- resolved.serializer,
320
- serverConfig.queryFlowControl,
321
- serverConfig.shortcutQueriesToLocalHandlers,
322
- serverConfig.queryTimeoutMs,
323
- serverConfig.resilience,
324
- )
325
- for (const [name, h] of pendingSubs) inner.subscribe(name, h)
326
- pendingSubs.length = 0
327
- })
328
-
329
- const wrapper: QueryBus = {
330
- async query(message) {
331
- await connected
332
- return inner!.query(message)
333
- },
334
- subscribe(name, handler) {
335
- if (inner) inner.subscribe(name, handler)
336
- else pendingSubs.push([name, handler])
337
- },
338
- subscriptionQuery(message, bufferSize) {
339
- if (!inner) {
340
- throw new Error(
341
- "[kronos:axon-server] subscriptionQuery called before connect hook completed",
342
- )
343
- }
344
- return inner.subscriptionQuery(message, bufferSize)
345
- },
346
- subscribeToUpdates(message, bufferSize) {
347
- if (!inner) {
348
- throw new Error(
349
- "[kronos:axon-server] subscribeToUpdates called before connect hook completed",
350
- )
351
- }
352
- return inner.subscribeToUpdates(message, bufferSize)
353
- },
354
- async emitUpdate(name, filter, update) {
355
- await connected
356
- return inner!.emitUpdate(name, filter, update)
357
- },
358
- async completeSubscription(name, filter) {
359
- await connected
360
- return inner!.completeSubscription(name, filter)
361
- },
362
- async completeSubscriptionExceptionally(name, error, filter) {
363
- await connected
364
- return inner!.completeSubscriptionExceptionally(name, error, filter)
365
- },
366
- }
367
- return wrapper
368
- })
242
+ /** Everything axonServer() needs: its own config plus the framework values it borrows. */
243
+ export type AxonServerOptions = AxonServerConfig & { serializer: Serializer; unitOfWorkFactory: UoWRunner }
369
244
 
370
- // ---- Lifecycle: connect (D-101 normative split) ---------------------
371
- // connect = initial connect + health-check + platform setup +
372
- // instruction wiring + platform.start.
373
- app.onStart("connect", async () => {
374
- connection = await withRetry(
375
- async () => connectToAxonServer(serverConfig),
376
- { event: "initial-connect", ...serverConfig.resilience },
377
- )
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
+ }
378
279
 
379
- // Health-check ping with warn-then-continue (D-100). AxonServerConnection
380
- // has no dedicated probe surface today; the gRPC channel itself is
381
- // created eagerly in connectToAxonServer so the meaningful probe is a
382
- // round-trip we approximate via a soft no-op promise that satisfies
383
- // the threshold contract. Real network failure is surfaced by the
384
- // first bus call against the live channel.
385
- await healthCheck(async () => undefined, {
386
- thresholdMs: serverConfig.resilience?.healthCheckThresholdMs,
387
- log: serverConfig.resilience?.log,
388
- })
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
+ })
389
298
 
390
- platform = createPlatformConnection(connection!, serverConfig.platformService)
391
-
392
- // Build a name-keyed view of the EventProcessorModule list so server-
393
- // initiated instructions can route to the right module. We resolve via
394
- // `app.processors()` Plan 09-01's zero-arg read accessor (D-103).
395
- const processors = app.processors()
396
- const processorMap = new Map<string, EventProcessorModule>()
397
- for (const proc of processors) processorMap.set(proc.name, proc)
398
-
399
- platform.onInstruction(async (instruction) => {
400
- switch (instruction.kind) {
401
- case "pause-processor": {
402
- const proc = processorMap.get(instruction.processorName) as any
403
- if (proc?.stop) proc.stop()
404
- break
405
- }
406
- case "start-processor": {
407
- const proc = processorMap.get(instruction.processorName) as any
408
- if (proc?.start) await proc.start()
409
- break
410
- }
411
- case "release-segment": {
412
- const proc = processorMap.get(instruction.processorName) as any
413
- if (proc?.releaseSegment) await proc.releaseSegment(instruction.segmentId)
414
- break
415
- }
416
- case "split-segment": {
417
- const proc = processorMap.get(instruction.processorName) as any
418
- if (proc?.splitSegment) await proc.splitSegment(instruction.segmentId)
419
- break
420
- }
421
- case "merge-segment": {
422
- const proc = processorMap.get(instruction.processorName) as any
423
- if (proc?.mergeSegment) await proc.mergeSegment(instruction.segmentId)
424
- break
425
- }
426
- }
427
- })
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
+ })
428
308
 
429
- platform.registerProcessorStatusSupplier(() => {
430
- return processors.map((proc: any) => ({
431
- name: proc.name,
432
- running: proc.running ?? false,
433
- mode: proc.supportsReset?.() === false ? "Subscribing" : "Tracking",
434
- isStreamingProcessor: proc.supportsReset?.() !== false,
435
- activeThreads: proc.running ? 1 : 0,
436
- availableThreads: 0,
437
- error: false,
438
- tokenStoreIdentifier: "",
439
- segments: proc.processingStatus
440
- ? Array.from(proc.processingStatus().entries() as Iterable<[number, any]>).map(
441
- ([segId, status]: [number, any]) => ({
442
- segmentId: segId,
443
- caughtUp: status.caughtUp ?? false,
444
- replaying: status.replaying ?? false,
445
- onePartOf: 1,
446
- tokenPosition: status.position ?? 0n,
447
- errorState: status.error?.message ?? "",
448
- }),
449
- )
450
- : [
451
- {
452
- segmentId: 0,
453
- caughtUp: true,
454
- replaying: proc.replaying ?? false,
455
- onePartOf: 1,
456
- tokenPosition: proc.position ?? 0n,
457
- errorState: "",
458
- },
459
- ],
460
- }))
461
- })
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
+ }
462
340
 
463
- await platform.start()
464
-
465
- // Latch the connected promise so the deferred bus wrappers built in
466
- // the slot factories above construct their inner instances and replay
467
- // any subscriptions that were buffered while connect was running.
468
- // This MUST happen synchronously before any subsequent stage hook so
469
- // register/processors-stage code sees the fully-wired buses. The
470
- // microtask queue drains the `.then(...)` callbacks attached in the
471
- // slot factories before this hook resolves.
472
- resolveConnected()
473
- await Promise.resolve()
474
- })
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)
475
346
 
476
- // ---- Lifecycle: processors (D-101 / D-102) --------------------------
477
- // processors = subscription-ack wait. The two-step shape mirrors the
478
- // kronosdb sibling (Plan 09-03 / D-102) but is adapted for Axon Server's
479
- // protocol shape, which differs from kronosdb's in one observable way:
480
- //
481
- // - kronosdb's PlatformService proactively emits a frame in response
482
- // to `register`, so its `subscriptionsAcked` latches on the first
483
- // inbound platform-stream message.
484
- //
485
- // - Axon Server's PlatformService holds the stream open silently
486
- // until either a topology change or a heartbeat round-trip occurs.
487
- // The platform stream therefore latches `acked` synchronously once
488
- // the `register` frame has been flushed (see platform-service.ts).
489
- //
490
- // The bus-side subscription frames (sent on the command/query streams,
491
- // not the platform stream) need a small processing window on the
492
- // server before commands dispatched here are routed back to our
493
- // handler. Empirically Axon Server processes the subscribe within
494
- // 1 second — same number the legacy enhancer used. Wrapped in the same
495
- // `withRetry({event: "per-operation"})` shape as kronosdb so per-extension
496
- // resilience overrides still apply uniformly.
497
- app.onStart("processors", async () => {
498
- await withRetry(
499
- async () => {
500
- const ok = await platform!.subscriptionsAcked()
501
- if (!ok) throw new Error("axon-server subscriptions not yet acked")
502
- },
503
- { event: "per-operation", ...serverConfig.resilience },
504
- )
505
- // Axon-specific: give the server's command/query routing tables a
506
- // beat to register the subscribe frames we just sent on the bus
507
- // streams. The legacy enhancer carried this same 1s wait at line 264;
508
- // it cannot be derived from the platform stream because subscribes
509
- // travel on a different stream entirely.
510
- await new Promise((r) => setTimeout(r, serverConfig.busSubscriptionAckDelayMs ?? 1000))
511
- })
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
+ },
512
378
 
513
- // ---- Lifecycle: stop (D-101.b — preserves legacy ordering) ----------
514
- // busLatches drained first → platform.stop → connection.close.
515
- app.onStop("connect", async () => {
379
+ async close() {
516
380
  await Promise.all(busLatches.map((l) => l.initiateShutdown()))
517
- platform?.stop()
518
- connection?.close()
519
- })
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
+ },
520
386
  }
521
387
  }
522
388
 
@@ -563,7 +429,36 @@ function createPayloadHelpers(serializer: Serializer) {
563
429
  * routes an inbound command to this node, it's executed on the local segment
564
430
  * within a UnitOfWork.
565
431
  */
566
- function createDistributedCommandBus(
432
+ /**
433
+ * A command bus backed by Axon Server.
434
+ *
435
+ * ## Correlation lineage and the interceptor layer
436
+ *
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.
440
+ *
441
+ * This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
442
+ * holds its own `DispatchInterceptors` and dispatches as
443
+ * `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`.
449
+ *
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.
460
+ */
461
+ export function distributedCommandBus(
567
462
  connection: AxonServerConnection,
568
463
  unitOfWorkRunner: UoWRunner,
569
464
  shutdownLatch: ShutdownLatch,
@@ -581,7 +476,7 @@ function createDistributedCommandBus(
581
476
  const localSegment = new Map<string, (message: CommandMessage) => Promise<unknown>>()
582
477
 
583
478
  // Bidirectional stream for handler registration + inbound command handling
584
- let outbound = createOutboundStream<any>()
479
+ let outbound = outboundStream<any>()
585
480
  let streamStarted = false
586
481
  let permits = 0n
587
482
 
@@ -612,7 +507,7 @@ function createDistributedCommandBus(
612
507
  */
613
508
  function reestablishStreamBody() {
614
509
  outbound.close()
615
- outbound = createOutboundStream<any>()
510
+ outbound = outboundStream<any>()
616
511
  streamStarted = false
617
512
  permits = 0n
618
513
  ensureStreamStarted()
@@ -726,7 +621,7 @@ function createDistributedCommandBus(
726
621
  }
727
622
  }
728
623
 
729
- return {
624
+ const routing: CommandBus = {
730
625
  async dispatch(message: CommandMessage): Promise<unknown> {
731
626
  const activity = shutdownLatch.registerActivity()
732
627
  try {
@@ -775,6 +670,11 @@ function createDistributedCommandBus(
775
670
  grantPermits()
776
671
  },
777
672
  }
673
+
674
+ // Interception OUTSIDE routing — see the note on this function.
675
+ const bus = interceptingCommandBus(routing)
676
+ bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
677
+ return bus
778
678
  }
779
679
 
780
680
  // ---------------------------------------------------------------------------
@@ -789,8 +689,20 @@ function createDistributedCommandBus(
789
689
  * - **Local segment**: Handlers registered here are stored locally and
790
690
  * registered with Axon Server for inbound routing. Inbound queries
791
691
  * are executed within a UnitOfWork.
692
+ *
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.
699
+ *
700
+ * KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
701
+ * 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.
792
704
  */
793
- export function createDistributedQueryBus(
705
+ export function distributedQueryBus(
794
706
  connection: AxonServerConnection,
795
707
  unitOfWorkRunner: UoWRunner,
796
708
  shutdownLatch: ShutdownLatch,
@@ -818,7 +730,7 @@ export function createDistributedQueryBus(
818
730
  // exact subscriber.
819
731
  const handlerSubscriptions = new Map<string, { queryName: string; payload: unknown }>()
820
732
 
821
- let outbound = createOutboundStream<any>()
733
+ let outbound = outboundStream<any>()
822
734
  let streamStarted = false
823
735
  let permits = 0n
824
736
 
@@ -847,7 +759,7 @@ export function createDistributedQueryBus(
847
759
  */
848
760
  function reestablishStreamBody() {
849
761
  outbound.close()
850
- outbound = createOutboundStream<any>()
762
+ outbound = outboundStream<any>()
851
763
  streamStarted = false
852
764
  permits = 0n
853
765
  ensureStreamStarted()
@@ -1040,7 +952,7 @@ export function createDistributedQueryBus(
1040
952
  }
1041
953
  }
1042
954
 
1043
- return {
955
+ const routing: QueryBus = {
1044
956
  async query(message: QueryMessage): Promise<unknown> {
1045
957
  const activity = shutdownLatch.registerActivity()
1046
958
  try {
@@ -1107,13 +1019,13 @@ export function createDistributedQueryBus(
1107
1019
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
1108
1020
  }
1109
1021
 
1110
- const updateHandler = createUpdateHandler(message, bufferSize)
1111
- subscriptions.set(queryId, updateHandler)
1022
+ const handler = updateHandler(message, bufferSize)
1023
+ subscriptions.set(queryId, handler)
1112
1024
 
1113
1025
  const queryName = qualifiedNameToString(message.name)
1114
1026
  const subscriptionId = generateIdentifier()
1115
1027
 
1116
- const outboundSub = createOutboundStream<any>()
1028
+ const outboundSub = outboundStream<any>()
1117
1029
 
1118
1030
  outboundSub.send({
1119
1031
  subscribe: {
@@ -1174,12 +1086,12 @@ export function createDistributedQueryBus(
1174
1086
  }
1175
1087
  } else if (response.update) {
1176
1088
  const update = deserializePayload(response.update.payload?.data as Uint8Array | undefined)
1177
- updateHandler.offer(update)
1089
+ handler.offer(update)
1178
1090
  } else if (response.complete) {
1179
- updateHandler.complete()
1091
+ handler.complete()
1180
1092
  break
1181
1093
  } else if (response.completeExceptionally) {
1182
- updateHandler.completeExceptionally(
1094
+ handler.completeExceptionally(
1183
1095
  new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed"),
1184
1096
  )
1185
1097
  break
@@ -1191,7 +1103,7 @@ export function createDistributedQueryBus(
1191
1103
  rejectInitial(error)
1192
1104
  initialSettled = true
1193
1105
  }
1194
- updateHandler.completeExceptionally(error)
1106
+ handler.completeExceptionally(error)
1195
1107
  } finally {
1196
1108
  subscriptions.delete(queryId)
1197
1109
  }
@@ -1199,7 +1111,7 @@ export function createDistributedQueryBus(
1199
1111
 
1200
1112
  return {
1201
1113
  initialResult,
1202
- updates: updateHandler.iterable,
1114
+ updates: handler.iterable,
1203
1115
  close: () => {
1204
1116
  outboundSub.send({
1205
1117
  unsubscribe: {
@@ -1208,7 +1120,7 @@ export function createDistributedQueryBus(
1208
1120
  })
1209
1121
  outboundSub.close()
1210
1122
  subscriptions.delete(queryId)
1211
- updateHandler.complete()
1123
+ handler.complete()
1212
1124
  },
1213
1125
  }
1214
1126
  },
@@ -1219,14 +1131,14 @@ export function createDistributedQueryBus(
1219
1131
  throw new Error(`Subscription query already registered for identifier "${queryId}"`)
1220
1132
  }
1221
1133
 
1222
- const updateHandler = createUpdateHandler(message, bufferSize)
1223
- subscriptions.set(queryId, updateHandler)
1134
+ const handler = updateHandler(message, bufferSize)
1135
+ subscriptions.set(queryId, handler)
1224
1136
 
1225
1137
  return {
1226
- [Symbol.asyncIterator]: () => updateHandler.iterable[Symbol.asyncIterator](),
1138
+ [Symbol.asyncIterator]: () => handler.iterable[Symbol.asyncIterator](),
1227
1139
  close: () => {
1228
1140
  subscriptions.delete(queryId)
1229
- updateHandler.complete()
1141
+ handler.complete()
1230
1142
  },
1231
1143
  }
1232
1144
  },
@@ -1320,4 +1232,8 @@ export function createDistributedQueryBus(
1320
1232
  })
1321
1233
  },
1322
1234
  }
1235
+
1236
+ const bus = interceptingQueryBus(routing)
1237
+ bus.registerDispatchInterceptor(correlationDataDispatchInterceptor())
1238
+ return bus
1323
1239
  }