@kronos-ts/axon-server 0.2.11 → 0.3.1

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,46 +1,67 @@
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.
18
29
  *
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:
30
+ * REMOTE ADMINISTRATION IS NOT IN HERE. Processor instructions (pause / start /
31
+ * release / split / merge) and processor status reporting are the platform
32
+ * CONTROL PLANEthey 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
+ * ```
39
+ *
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 { qualifiedNameToString, qualifiedNameFromString, generateIdentifier, withRetry, healthCheck, } from "@kronos-ts/common";
34
- import { applySubscriptionFilter, createUpdateHandler, runAfterCommitOrImmediately } from "@kronos-ts/messaging";
55
+ import { applySubscriptionFilter, correlationDataDispatchInterceptor, interceptingCommandBus, interceptingQueryBus, updateHandler, runAfterCommitOrImmediately, } from "@kronos-ts/messaging";
35
56
  import { Metadata } from "nice-grpc";
36
57
  import { connectToAxonServer } from "./connection.js";
37
- import { createAxonServerEventStore } from "./axon-server-event-store.js";
38
- import { createAxonServerSnapshotStore } from "./axon-server-snapshot-store.js";
58
+ import { axonServerEventStore } from "./axon-server-event-store.js";
59
+ import { axonServerSnapshotStore } from "./axon-server-snapshot-store.js";
39
60
  import { metadataToProto, metadataFromProto } from "./metadata-conversion.js";
40
- import { createOutboundStream } from "./outbound-stream.js";
61
+ import { outboundStream } from "./outbound-stream.js";
41
62
  import { mapErrorCode, AxonServerErrorCode } from "./errors.js";
42
- import { createShutdownLatch } from "./shutdown-latch.js";
43
- import { createPlatformConnection, } from "./platform-service.js";
63
+ import { shutdownLatch } from "./shutdown-latch.js";
64
+ import { platformConnection, } from "./platform-service.js";
44
65
  /** Default flow control settings — aligned with Java's 5000 permits. */
45
66
  const DEFAULT_PERMITS = 5000n;
46
67
  const DEFAULT_THRESHOLD = 2500n;
@@ -98,284 +119,83 @@ function createAxonMetadata(config) {
98
119
  return metadata;
99
120
  }
100
121
  /**
101
- * Native Axon Server extension factory. Returns an Extension closure shaped
102
- * as `(app: App) => void` per D-95.
122
+ * Connect to Axon Server and build the components it backs.
103
123
  *
104
- * ```ts
105
- * await kronos()
106
- * .use(axonServer({ componentName: "university-service" }))
107
- * .start()
108
- * ```
124
+ * `serializer` and `unitOfWorkFactory` are arguments rather than slot lookups:
125
+ * the buses serialize payloads with the former and run every inbound command /
126
+ * query in the latter, so they must be the SAME instances the rest of the app
127
+ * uses. Pass the ones you hand to `kronos`.
109
128
  */
110
- export function axonServer(serverConfig) {
111
- return (app) => {
112
- let connection;
113
- let platform;
114
- const busLatches = [];
115
- function getConnection() {
116
- if (!connection) {
117
- throw new Error("[kronos:axon-server] connection not yet established wait for onStart('connect')");
118
- }
119
- return connection;
120
- }
121
- // ---- Slot population (D-95) -----------------------------------------
122
- //
123
- // AppImpl.start() in @kronos-ts/app eagerly resolves all 8 slots and
124
- // runs `commandBus.subscribe(...)` for every registered handler BEFORE
125
- // any onStart('connect') hook fires (see app.ts §3 / §5c). The Axon
126
- // bus factories open real gRPC streams against the live channel during
127
- // construction (createAxonMetadata / connection.onReconnect / inbound
128
- // stream openers), so they CANNOT run until the connect hook has
129
- // populated `connection`.
130
- //
131
- // Solution: the slot factories return wrappers around lazily-built
132
- // inner instances. EventStore/SnapshotStore use a lightweight lazy
133
- // proxy because their factories never dereference `connection` at
134
- // construction time (only inside method bodies). CommandBus/QueryBus
135
- // use a `subscribe()`-buffering wrapper that queues subscriptions
136
- // synchronously and replays them once the connect hook completes —
137
- // dispatch / query calls await the same readiness promise.
138
- /** Latches once the connect hook has populated `connection`. */
139
- let resolveConnected = () => { };
140
- const connected = new Promise((res) => {
141
- resolveConnected = res;
142
- });
143
- app.set("eventStore", (resolved) => {
144
- // Lazy proxy: createAxonServerEventStore stores `connection` in
145
- // closure scope but only dereferences it inside method bodies, so
146
- // a Proxy that forwards property access to getConnection() works
147
- // by the time framework code calls source/append/stream the
148
- // connect hook has populated the closure.
149
- const lazyConnection = new Proxy({}, {
150
- get(_t, prop) {
151
- return getConnection()[prop];
152
- },
153
- });
154
- return createAxonServerEventStore(lazyConnection, resolved.serializer);
155
- });
156
- app.set("snapshotStore", (resolved) => {
157
- const lazyConnection = new Proxy({}, {
158
- get(_t, prop) {
159
- return getConnection()[prop];
160
- },
161
- });
162
- return createAxonServerSnapshotStore(lazyConnection, resolved.serializer);
163
- });
164
- app.set("commandBus", (resolved) => {
165
- const latch = createShutdownLatch();
166
- busLatches.push(latch);
167
- let inner;
168
- const pendingSubs = [];
169
- // Build the real bus once the connect hook fires + replay buffered subs.
170
- connected.then(() => {
171
- inner = createDistributedCommandBus(getConnection(), resolved.unitOfWorkFactory, latch, resolved.serializer, serverConfig.commandFlowControl, serverConfig.commandLoadFactor, serverConfig.resilience);
172
- for (const [name, h] of pendingSubs)
173
- inner.subscribe(name, h);
174
- pendingSubs.length = 0;
175
- });
176
- const wrapper = {
177
- async dispatch(message) {
178
- await connected;
179
- return inner.dispatch(message);
180
- },
181
- subscribe(name, handler) {
182
- if (inner)
183
- inner.subscribe(name, handler);
184
- else
185
- pendingSubs.push([name, handler]);
186
- },
187
- };
188
- return wrapper;
189
- });
190
- app.set("queryBus", (resolved) => {
191
- const latch = createShutdownLatch();
192
- busLatches.push(latch);
193
- let inner;
194
- const pendingSubs = [];
195
- connected.then(() => {
196
- inner = createDistributedQueryBus(getConnection(), resolved.unitOfWorkFactory, latch, resolved.serializer, serverConfig.queryFlowControl, serverConfig.shortcutQueriesToLocalHandlers, serverConfig.queryTimeoutMs, serverConfig.resilience);
197
- for (const [name, h] of pendingSubs)
198
- inner.subscribe(name, h);
199
- pendingSubs.length = 0;
200
- });
201
- const wrapper = {
202
- async query(message) {
203
- await connected;
204
- return inner.query(message);
205
- },
206
- subscribe(name, handler) {
207
- if (inner)
208
- inner.subscribe(name, handler);
209
- else
210
- pendingSubs.push([name, handler]);
211
- },
212
- subscriptionQuery(message, bufferSize) {
213
- if (!inner) {
214
- throw new Error("[kronos:axon-server] subscriptionQuery called before connect hook completed");
215
- }
216
- return inner.subscriptionQuery(message, bufferSize);
217
- },
218
- subscribeToUpdates(message, bufferSize) {
219
- if (!inner) {
220
- throw new Error("[kronos:axon-server] subscribeToUpdates called before connect hook completed");
221
- }
222
- return inner.subscribeToUpdates(message, bufferSize);
223
- },
224
- async emitUpdate(name, filter, update) {
225
- await connected;
226
- return inner.emitUpdate(name, filter, update);
227
- },
228
- async completeSubscription(name, filter) {
229
- await connected;
230
- return inner.completeSubscription(name, filter);
231
- },
232
- async completeSubscriptionExceptionally(name, error, filter) {
233
- await connected;
234
- return inner.completeSubscriptionExceptionally(name, error, filter);
235
- },
236
- };
237
- return wrapper;
238
- });
239
- // ---- Lifecycle: connect (D-101 normative split) ---------------------
240
- // connect = initial connect + health-check + platform setup +
241
- // instruction wiring + platform.start.
242
- app.onStart("connect", async () => {
243
- connection = await withRetry(async () => connectToAxonServer(serverConfig), { event: "initial-connect", ...serverConfig.resilience });
244
- // Health-check ping with warn-then-continue (D-100). AxonServerConnection
245
- // has no dedicated probe surface today; the gRPC channel itself is
246
- // created eagerly in connectToAxonServer so the meaningful probe is a
247
- // round-trip — we approximate via a soft no-op promise that satisfies
248
- // the threshold contract. Real network failure is surfaced by the
249
- // first bus call against the live channel.
250
- await healthCheck(async () => undefined, {
251
- thresholdMs: serverConfig.resilience?.healthCheckThresholdMs,
252
- log: serverConfig.resilience?.log,
253
- });
254
- platform = createPlatformConnection(connection, serverConfig.platformService);
255
- // Build a name-keyed view of the EventProcessorModule list so server-
256
- // initiated instructions can route to the right module. We resolve via
257
- // `app.processors()` — Plan 09-01's zero-arg read accessor (D-103).
258
- const processors = app.processors();
259
- const processorMap = new Map();
260
- for (const proc of processors)
261
- processorMap.set(proc.name, proc);
262
- platform.onInstruction(async (instruction) => {
263
- switch (instruction.kind) {
264
- case "pause-processor": {
265
- const proc = processorMap.get(instruction.processorName);
266
- if (proc?.stop)
267
- proc.stop();
268
- break;
269
- }
270
- case "start-processor": {
271
- const proc = processorMap.get(instruction.processorName);
272
- if (proc?.start)
273
- await proc.start();
274
- break;
275
- }
276
- case "release-segment": {
277
- const proc = processorMap.get(instruction.processorName);
278
- if (proc?.releaseSegment)
279
- await proc.releaseSegment(instruction.segmentId);
280
- break;
281
- }
282
- case "split-segment": {
283
- const proc = processorMap.get(instruction.processorName);
284
- if (proc?.splitSegment)
285
- await proc.splitSegment(instruction.segmentId);
286
- break;
287
- }
288
- case "merge-segment": {
289
- const proc = processorMap.get(instruction.processorName);
290
- if (proc?.mergeSegment)
291
- await proc.mergeSegment(instruction.segmentId);
292
- break;
293
- }
294
- }
295
- });
296
- platform.registerProcessorStatusSupplier(() => {
297
- return processors.map((proc) => ({
298
- name: proc.name,
299
- running: proc.running ?? false,
300
- mode: proc.supportsReset?.() === false ? "Subscribing" : "Tracking",
301
- isStreamingProcessor: proc.supportsReset?.() !== false,
302
- activeThreads: proc.running ? 1 : 0,
303
- availableThreads: 0,
304
- error: false,
305
- tokenStoreIdentifier: "",
306
- segments: proc.processingStatus
307
- ? Array.from(proc.processingStatus().entries()).map(([segId, status]) => ({
308
- segmentId: segId,
309
- caughtUp: status.caughtUp ?? false,
310
- replaying: status.replaying ?? false,
311
- onePartOf: 1,
312
- tokenPosition: status.position ?? 0n,
313
- errorState: status.error?.message ?? "",
314
- }))
315
- : [
316
- {
317
- segmentId: 0,
318
- caughtUp: true,
319
- replaying: proc.replaying ?? false,
320
- onePartOf: 1,
321
- tokenPosition: proc.position ?? 0n,
322
- errorState: "",
323
- },
324
- ],
325
- }));
326
- });
327
- await platform.start();
328
- // Latch the connected promise so the deferred bus wrappers built in
329
- // the slot factories above construct their inner instances and replay
330
- // any subscriptions that were buffered while connect was running.
331
- // This MUST happen synchronously before any subsequent stage hook so
332
- // register/processors-stage code sees the fully-wired buses. The
333
- // microtask queue drains the `.then(...)` callbacks attached in the
334
- // slot factories before this hook resolves.
335
- resolveConnected();
336
- await Promise.resolve();
337
- });
338
- // ---- Lifecycle: processors (D-101 / D-102) --------------------------
339
- // processors = subscription-ack wait. The two-step shape mirrors the
340
- // kronosdb sibling (Plan 09-03 / D-102) but is adapted for Axon Server's
341
- // protocol shape, which differs from kronosdb's in one observable way:
342
- //
343
- // - kronosdb's PlatformService proactively emits a frame in response
344
- // to `register`, so its `subscriptionsAcked` latches on the first
345
- // inbound platform-stream message.
346
- //
347
- // - Axon Server's PlatformService holds the stream open silently
348
- // until either a topology change or a heartbeat round-trip occurs.
349
- // The platform stream therefore latches `acked` synchronously once
350
- // the `register` frame has been flushed (see platform-service.ts).
351
- //
352
- // The bus-side subscription frames (sent on the command/query streams,
353
- // not the platform stream) need a small processing window on the
354
- // server before commands dispatched here are routed back to our
355
- // handler. Empirically Axon Server processes the subscribe within
356
- // 1 second — same number the legacy enhancer used. Wrapped in the same
357
- // `withRetry({event: "per-operation"})` shape as kronosdb so per-extension
358
- // resilience overrides still apply uniformly.
359
- app.onStart("processors", async () => {
360
- await withRetry(async () => {
361
- const ok = await platform.subscriptionsAcked();
362
- if (!ok)
363
- throw new Error("axon-server subscriptions not yet acked");
364
- }, { event: "per-operation", ...serverConfig.resilience });
365
- // Axon-specific: give the server's command/query routing tables a
366
- // beat to register the subscribe frames we just sent on the bus
367
- // streams. The legacy enhancer carried this same 1s wait at line 264;
368
- // it cannot be derived from the platform stream because subscribes
369
- // travel on a different stream entirely.
370
- await new Promise((r) => setTimeout(r, serverConfig.busSubscriptionAckDelayMs ?? 1000));
371
- });
372
- // ---- Lifecycle: stop (D-101.b — preserves legacy ordering) ----------
373
- // busLatches drained first → platform.stop → connection.close.
374
- app.onStop("connect", async () => {
129
+ export async function axonServer(options) {
130
+ const config = options;
131
+ const { serializer, unitOfWorkFactory, resilience } = config;
132
+ const connection = await withRetry(async () => connectToAxonServer(config), {
133
+ event: "initial-connect",
134
+ ...resilience,
135
+ });
136
+ // Health-check ping with warn-then-continue (D-100). AxonServerConnection has
137
+ // no dedicated probe surface today; the gRPC channel itself is created
138
+ // eagerly in connectToAxonServer so the meaningful probe is a round-trip — we
139
+ // approximate via a soft no-op promise that satisfies the threshold contract.
140
+ // Real network failure is surfaced by the first bus call against the channel.
141
+ await healthCheck(async () => undefined, {
142
+ thresholdMs: resilience?.healthCheckThresholdMs,
143
+ log: resilience?.log,
144
+ });
145
+ // One latch per bus, drained in close() before the transport goes away.
146
+ const commandLatch = shutdownLatch();
147
+ const queryLatch = shutdownLatch();
148
+ const busLatches = [commandLatch, queryLatch];
149
+ // The connection is live before anything below is built, so the buses open
150
+ // their gRPC streams for real and `subscribe()` reaches the wire immediately —
151
+ // no lazy proxy, no subscription buffering, no readiness promise.
152
+ const components = {
153
+ eventStore: axonServerEventStore(connection, serializer),
154
+ snapshotStore: axonServerSnapshotStore(connection, serializer),
155
+ commandBus: distributedCommandBus(connection, unitOfWorkFactory, commandLatch, serializer, config.commandFlowControl, config.commandLoadFactor, resilience),
156
+ queryBus: distributedQueryBus(connection, unitOfWorkFactory, queryLatch, serializer, config.queryFlowControl, config.shortcutQueriesToLocalHandlers, config.queryTimeoutMs, resilience),
157
+ };
158
+ // Built here, started by the control plane (or by the caller). Constructing it
159
+ // eagerly is what lets the control plane be a separate object at all — and it
160
+ // keeps `platformService` tuning and `stop()` ownership in one place, so the
161
+ // documented shutdown order below holds whether or not anyone opted in.
162
+ const platform = platformConnection(connection, config.platformService);
163
+ return {
164
+ components,
165
+ platform,
166
+ async start() {
167
+ // RECONNECT DETECTION IS DATA PATH. The heartbeat on the platform stream
168
+ // is what notices a dead channel and calls `connection.reconnect()`; both
169
+ // buses above hook `connection.onReconnect(...)` to rebuild their own
170
+ // streams. Arming it used to be a side effect of `platform.start()`, which
171
+ // only `axonServerControlPlane(...)` calls — so a service that never opted
172
+ // into remote administration had NO reconnect detection at all and would
173
+ // sit on a dead channel forever. It is armed here, unconditionally,
174
+ // independent of whether anyone administers this service.
175
+ //
176
+ // `armConnectionMonitoring()` opens the stream and starts heartbeats but
177
+ // arms NO processor status reporting; that stays the control plane's, and
178
+ // a later `platform.start()` adds it to this same live stream. Both calls
179
+ // are idempotent, so either order works.
180
+ await platform.armConnectionMonitoring();
181
+ // The only thing the data path has to wait for: Axon Server's
182
+ // command/query routing tables registering the subscribe frames sent on
183
+ // the BUS streams. It cannot be derived from the platform stream, because
184
+ // subscribes travel on a different stream entirely — and the platform
185
+ // stream's own `subscriptionsAcked()` latch says nothing about them (it
186
+ // latches unconditionally once `register` has been flushed; see
187
+ // platform-service.ts). So this barrier is the settle wait, and it is
188
+ // deliberately independent of whether the platform stream is up at all.
189
+ // The legacy enhancer carried the same 1s wait.
190
+ await new Promise((r) => setTimeout(r, config.busSubscriptionAckDelayMs ?? 1000));
191
+ },
192
+ async close() {
375
193
  await Promise.all(busLatches.map((l) => l.initiateShutdown()));
376
- platform?.stop();
377
- connection?.close();
378
- });
194
+ // Idempotent, and independent of `control.close()` — a backend that was
195
+ // never administered still stops a platform stream someone else started.
196
+ platform.stop();
197
+ connection.close();
198
+ },
379
199
  };
380
200
  }
381
201
  // ---------------------------------------------------------------------------
@@ -419,7 +239,36 @@ function createPayloadHelpers(serializer) {
419
239
  * routes an inbound command to this node, it's executed on the local segment
420
240
  * within a UnitOfWork.
421
241
  */
422
- function createDistributedCommandBus(connection, unitOfWorkRunner, shutdownLatch, serializer, flowControl, commandLoadFactor, resilience) {
242
+ /**
243
+ * A command bus backed by Axon Server.
244
+ *
245
+ * ## Correlation lineage and the interceptor layer
246
+ *
247
+ * The returned bus is wrapped in {@link interceptingCommandBus} carrying
248
+ * {@link correlationDataDispatchInterceptor}, so lineage is stamped onto the
249
+ * outgoing message BEFORE it is serialized onto the wire.
250
+ *
251
+ * This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
252
+ * holds its own `DispatchInterceptors` and dispatches as
253
+ * `doDispatch(dispatchInterceptors.intercept(commandMessage), cb)` — one call
254
+ * site, at the top, ahead of any routing; and its `doDispatch` (like this one)
255
+ * always goes to the server, letting Axon Server decide where the command lands.
256
+ * AF5 keeps the property via decorator order:
257
+ * `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
258
+ * stacks `InterceptingCommandBus → DistributedCommandBus → SimpleCommandBus`.
259
+ *
260
+ * Before this wrap, an Axon-backed service lost lineage on EVERY command: the
261
+ * only registration of `correlationDataDispatchInterceptor` lives in
262
+ * `@kronos-ts/app`'s in-memory default bus, and `components.commandBus` from
263
+ * this backend replaces it wholesale.
264
+ *
265
+ * No double-application risk: the local segment here is a plain handler map, not
266
+ * a `CommandBus`, so this is the only interceptor in the chain. Inbound commands
267
+ * from the server are invoked through that map directly, which matches AF —
268
+ * `CommandProcessingTask` runs the local segment WITHOUT re-running dispatch
269
+ * interceptors.
270
+ */
271
+ export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatch, serializer, flowControl, commandLoadFactor, resilience) {
423
272
  const metadata = createAxonMetadata(connection.config);
424
273
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer);
425
274
  const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS));
@@ -427,7 +276,7 @@ function createDistributedCommandBus(connection, unitOfWorkRunner, shutdownLatch
427
276
  // Local segment — handlers that execute on this node
428
277
  const localSegment = new Map();
429
278
  // Bidirectional stream for handler registration + inbound command handling
430
- let outbound = createOutboundStream();
279
+ let outbound = outboundStream();
431
280
  let streamStarted = false;
432
281
  let permits = 0n;
433
282
  function ensureStreamStarted() {
@@ -455,7 +304,7 @@ function createDistributedCommandBus(connection, unitOfWorkRunner, shutdownLatch
455
304
  */
456
305
  function reestablishStreamBody() {
457
306
  outbound.close();
458
- outbound = createOutboundStream();
307
+ outbound = outboundStream();
459
308
  streamStarted = false;
460
309
  permits = 0n;
461
310
  ensureStreamStarted();
@@ -563,7 +412,7 @@ function createDistributedCommandBus(connection, unitOfWorkRunner, shutdownLatch
563
412
  });
564
413
  }
565
414
  }
566
- return {
415
+ const routing = {
567
416
  async dispatch(message) {
568
417
  const activity = shutdownLatch.registerActivity();
569
418
  try {
@@ -605,6 +454,10 @@ function createDistributedCommandBus(connection, unitOfWorkRunner, shutdownLatch
605
454
  grantPermits();
606
455
  },
607
456
  };
457
+ // Interception OUTSIDE routing — see the note on this function.
458
+ const bus = interceptingCommandBus(routing);
459
+ bus.registerDispatchInterceptor(correlationDataDispatchInterceptor());
460
+ return bus;
608
461
  }
609
462
  // ---------------------------------------------------------------------------
610
463
  // Distributed Query Bus
@@ -617,8 +470,20 @@ function createDistributedCommandBus(connection, unitOfWorkRunner, shutdownLatch
617
470
  * - **Local segment**: Handlers registered here are stored locally and
618
471
  * registered with Axon Server for inbound routing. Inbound queries
619
472
  * are executed within a UnitOfWork.
473
+ *
474
+ * Wrapped in {@link interceptingQueryBus} with
475
+ * {@link correlationDataDispatchInterceptor}, matching AF4's
476
+ * `AxonServerQueryBus`, which calls `dispatchInterceptors.intercept(...)` at the
477
+ * top of `query`, `streamingQuery`, `scatterGather` and `subscriptionQuery`.
478
+ * Because the wrap is outside, the `shortcutQueriesToLocalHandlers` branch in
479
+ * `query()` gets identical lineage to the remote branch.
480
+ *
481
+ * KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
482
+ * straight from `message.metadata`, and `interceptingQueryBus` (in
483
+ * `@kronos-ts/messaging`) forwards those two calls to the delegate without
484
+ * running the dispatch chain. Closing that needs a messaging-package change.
620
485
  */
621
- export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdownLatch, serializer, flowControl, shortcutQueriesToLocalHandlers, queryTimeoutMs, resilience) {
486
+ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch, serializer, flowControl, shortcutQueriesToLocalHandlers, queryTimeoutMs, resilience) {
622
487
  const metadata = createAxonMetadata(connection.config);
623
488
  const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS));
624
489
  const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD));
@@ -633,7 +498,7 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
633
498
  // to decide which subscriber IDs to target; the server forwards each response to the
634
499
  // exact subscriber.
635
500
  const handlerSubscriptions = new Map();
636
- let outbound = createOutboundStream();
501
+ let outbound = outboundStream();
637
502
  let streamStarted = false;
638
503
  let permits = 0n;
639
504
  function ensureStreamStarted() {
@@ -659,7 +524,7 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
659
524
  */
660
525
  function reestablishStreamBody() {
661
526
  outbound.close();
662
- outbound = createOutboundStream();
527
+ outbound = outboundStream();
663
528
  streamStarted = false;
664
529
  permits = 0n;
665
530
  ensureStreamStarted();
@@ -840,7 +705,7 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
840
705
  });
841
706
  }
842
707
  }
843
- return {
708
+ const routing = {
844
709
  async query(message) {
845
710
  const activity = shutdownLatch.registerActivity();
846
711
  try {
@@ -897,11 +762,11 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
897
762
  if (subscriptions.has(queryId)) {
898
763
  throw new Error(`Subscription query already registered for identifier "${queryId}"`);
899
764
  }
900
- const updateHandler = createUpdateHandler(message, bufferSize);
901
- subscriptions.set(queryId, updateHandler);
765
+ const handler = updateHandler(message, bufferSize);
766
+ subscriptions.set(queryId, handler);
902
767
  const queryName = qualifiedNameToString(message.name);
903
768
  const subscriptionId = generateIdentifier();
904
- const outboundSub = createOutboundStream();
769
+ const outboundSub = outboundStream();
905
770
  outboundSub.send({
906
771
  subscribe: {
907
772
  subscriptionIdentifier: subscriptionId,
@@ -959,14 +824,14 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
959
824
  }
960
825
  else if (response.update) {
961
826
  const update = deserializePayload(response.update.payload?.data);
962
- updateHandler.offer(update);
827
+ handler.offer(update);
963
828
  }
964
829
  else if (response.complete) {
965
- updateHandler.complete();
830
+ handler.complete();
966
831
  break;
967
832
  }
968
833
  else if (response.completeExceptionally) {
969
- updateHandler.completeExceptionally(new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed"));
834
+ handler.completeExceptionally(new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed"));
970
835
  break;
971
836
  }
972
837
  }
@@ -977,7 +842,7 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
977
842
  rejectInitial(error);
978
843
  initialSettled = true;
979
844
  }
980
- updateHandler.completeExceptionally(error);
845
+ handler.completeExceptionally(error);
981
846
  }
982
847
  finally {
983
848
  subscriptions.delete(queryId);
@@ -985,7 +850,7 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
985
850
  })();
986
851
  return {
987
852
  initialResult,
988
- updates: updateHandler.iterable,
853
+ updates: handler.iterable,
989
854
  close: () => {
990
855
  outboundSub.send({
991
856
  unsubscribe: {
@@ -994,7 +859,7 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
994
859
  });
995
860
  outboundSub.close();
996
861
  subscriptions.delete(queryId);
997
- updateHandler.complete();
862
+ handler.complete();
998
863
  },
999
864
  };
1000
865
  },
@@ -1003,13 +868,13 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
1003
868
  if (subscriptions.has(queryId)) {
1004
869
  throw new Error(`Subscription query already registered for identifier "${queryId}"`);
1005
870
  }
1006
- const updateHandler = createUpdateHandler(message, bufferSize);
1007
- subscriptions.set(queryId, updateHandler);
871
+ const handler = updateHandler(message, bufferSize);
872
+ subscriptions.set(queryId, handler);
1008
873
  return {
1009
- [Symbol.asyncIterator]: () => updateHandler.iterable[Symbol.asyncIterator](),
874
+ [Symbol.asyncIterator]: () => handler.iterable[Symbol.asyncIterator](),
1010
875
  close: () => {
1011
876
  subscriptions.delete(queryId);
1012
- updateHandler.complete();
877
+ handler.complete();
1013
878
  },
1014
879
  };
1015
880
  },
@@ -1092,5 +957,8 @@ export function createDistributedQueryBus(connection, unitOfWorkRunner, shutdown
1092
957
  });
1093
958
  },
1094
959
  };
960
+ const bus = interceptingQueryBus(routing);
961
+ bus.registerDispatchInterceptor(correlationDataDispatchInterceptor());
962
+ return bus;
1095
963
  }
1096
964
  //# sourceMappingURL=axon-server.js.map