@kronos-ts/axon-server 0.3.1 → 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,74 +1,47 @@
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
- import { qualifiedNameToString, qualifiedNameFromString, generateIdentifier, withRetry, healthCheck, } from "@kronos-ts/common";
55
- import { applySubscriptionFilter, correlationDataDispatchInterceptor, interceptingCommandBus, interceptingQueryBus, updateHandler, runAfterCommitOrImmediately, } from "@kronos-ts/messaging";
56
- import { Metadata } from "nice-grpc";
57
- import { connectToAxonServer } from "./connection.js";
58
- import { axonServerEventStore } from "./axon-server-event-store.js";
59
- import { axonServerSnapshotStore } from "./axon-server-snapshot-store.js";
28
+ import { qualifiedNameToString, qualifiedNameFromString, generateIdentifier, withRetry, } from "@kronos-ts/core";
29
+ import { applySubscriptionFilter, stamped, updateHandler, runAfterCommitOrImmediately, } from "@kronos-ts/core";
30
+ import { contextView } from "./context-view.js";
60
31
  import { metadataToProto, metadataFromProto } from "./metadata-conversion.js";
61
32
  import { outboundStream } from "./outbound-stream.js";
62
33
  import { mapErrorCode, AxonServerErrorCode } from "./errors.js";
63
- import { shutdownLatch } from "./shutdown-latch.js";
64
- import { platformConnection, } from "./platform-service.js";
65
34
  /** Default flow control settings — aligned with Java's 5000 permits. */
66
35
  const DEFAULT_PERMITS = 5000n;
67
36
  const DEFAULT_THRESHOLD = 2500n;
37
+ /** Default query dispatch timeout — aligned with Java's one hour. */
38
+ const DEFAULT_QUERY_TIMEOUT_MS = 3_600_000;
39
+ /** Default command handler load factor — aligned with Java's 100. */
40
+ const DEFAULT_LOAD_FACTOR = 100;
68
41
  // Processing instruction keys — aligned with proto ProcessingKey enum.
69
42
  // CLIENT_SUPPORTS_STREAMING (key=8) is an Axon-Server-specific capability
70
- // advertisement that MUST survive the migration verbatim — see file-level
71
- // JSDoc above and `defaultQueryInstructions` below.
43
+ // advertisement that MUST survive verbatim — see file-level JSDoc above and
44
+ // `defaultQueryInstructions` below.
72
45
  const INSTRUCTION_KEY = {
73
46
  ROUTING_KEY: 0,
74
47
  PRIORITY: 1,
@@ -84,10 +57,16 @@ function toProtoProcessingInstructions(instructions) {
84
57
  result.push({ key: INSTRUCTION_KEY.ROUTING_KEY, value: { textValue: instructions.routingKey } });
85
58
  }
86
59
  if (instructions.priority !== undefined) {
87
- result.push({ key: INSTRUCTION_KEY.PRIORITY, value: { numberValue: BigInt(instructions.priority) } });
60
+ result.push({
61
+ key: INSTRUCTION_KEY.PRIORITY,
62
+ value: { numberValue: BigInt(instructions.priority) },
63
+ });
88
64
  }
89
65
  if (instructions.timeoutMs !== undefined) {
90
- result.push({ key: INSTRUCTION_KEY.TIMEOUT, value: { numberValue: BigInt(instructions.timeoutMs) } });
66
+ result.push({
67
+ key: INSTRUCTION_KEY.TIMEOUT,
68
+ value: { numberValue: BigInt(instructions.timeoutMs) },
69
+ });
91
70
  }
92
71
  return result;
93
72
  }
@@ -104,102 +83,8 @@ function defaultQueryInstructions(timeoutMs) {
104
83
  { key: INSTRUCTION_KEY.CLIENT_SUPPORTS_STREAMING, value: { booleanValue: true } },
105
84
  ];
106
85
  }
107
- /**
108
- * Build the gRPC metadata headers required by Axon Server. AxonIQ-Context
109
- * is mandatory (identifies the tenant/context); AxonIQ-Access-Token is
110
- * optional auth. Both must be attached to every outbound stream/RPC —
111
- * preserved verbatim from the legacy enhancer.
112
- */
113
- function createAxonMetadata(config) {
114
- const metadata = new Metadata();
115
- metadata.set("AxonIQ-Context", config.context);
116
- if (config.token) {
117
- metadata.set("AxonIQ-Access-Token", config.token);
118
- }
119
- return metadata;
120
- }
121
- /**
122
- * Connect to Axon Server and build the components it backs.
123
- *
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`.
128
- */
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() {
193
- await Promise.all(busLatches.map((l) => l.initiateShutdown()));
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
- },
199
- };
200
- }
201
86
  // ---------------------------------------------------------------------------
202
- // Shared payload helpers (moved verbatim from legacy enhancer)
87
+ // Shared payload helpers
203
88
  // ---------------------------------------------------------------------------
204
89
  function createPayloadHelpers(serializer) {
205
90
  return {
@@ -214,68 +99,66 @@ function createPayloadHelpers(serializer) {
214
99
  };
215
100
  }
216
101
  // ---------------------------------------------------------------------------
217
- // Distributed Command Bus
218
- //
219
- // Bus implementation moved verbatim from the legacy enhancer with TWO
220
- // behavioural additions per D-97:
221
- // 1) reestablishStream() body wrapped in withRetry({ event: "reconnect" })
222
- // 2) inbound-stream backoff replaced by the same withRetry path
223
- //
224
- // Axon-specific protocol invariants preserved BYTE-FOR-BYTE:
225
- // - AxonIQ-Context + AxonIQ-Access-Token gRPC metadata headers via
226
- // createAxonMetadata(connection.config)
227
- // - permits-AFTER-subscriptions ordering on reestablishStreamBody (subs
228
- // are sent BEFORE grantPermits() in the reconnect path; the initial
229
- // handshake matches this — see ensureStreamStarted's grantPermits call
230
- // in subscribe()).
102
+ // Axon Server Command Bus
231
103
  // ---------------------------------------------------------------------------
232
104
  /**
233
- * A command bus backed by Axon Server.
105
+ * A command bus backed by Axon Server, over YOUR local bus.
234
106
  *
235
- * - **Outbound dispatch**: Always goes through Axon Server via the unary Dispatch RPC.
236
- * Axon Server routes the command to the appropriate node (which may be this one).
237
- * - **Local segment**: Handlers subscribed via `subscribe()` are registered with
238
- * Axon Server (so other nodes can route to us) and stored locally. When Axon Server
239
- * routes an inbound command to this node, it's executed on the local segment
240
- * within a UnitOfWork.
241
- */
242
- /**
243
- * A command bus backed by Axon Server.
107
+ * - **Outbound dispatch**: ALWAYS through Axon Server, via the unary Dispatch
108
+ * RPC. Axon Server routes the command to the appropriate node (which may be
109
+ * this one). There is deliberately no client-side prefer-local fork: the hub
110
+ * is the router, and short-circuiting it would silently defeat load factors,
111
+ * priorities and routing keys.
112
+ * - **Inbound**: a command the server routes here is dispatched into `local` —
113
+ * not into a privately-held handler map. That is what makes the unit-of-work
114
+ * policy you chose for `local` (say `postgresUnitOfWork(pg, unitOfWork)`)
115
+ * apply to server-routed work exactly as it applies to work this process
116
+ * originated. It is also why this function takes no `unitOfWork` argument:
117
+ * `local` carries that policy now.
118
+ * - **subscribe**: registers the handler on `local` AND announces the name to
119
+ * Axon Server, so other nodes can route to us.
244
120
  *
245
121
  * ## Correlation lineage and the interceptor layer
246
122
  *
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.
123
+ * The returned bus stamps no lineage of its own. A host that wants it wraps the
124
+ * OUTERMOST bus:
125
+ *
126
+ * ```ts
127
+ * interceptingCommandBus(axonServerCommandBus(conn, local), lineage)
128
+ * ```
129
+ *
130
+ * so whatever a host adds runs BEFORE the message is serialized onto the wire.
131
+ * Lineage itself is usually already on `message.metadata` by then — `ctx.send`
132
+ * stamps the unit of work's correlation data before any bus sees the message.
250
133
  *
251
134
  * This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
252
135
  * holds its own `DispatchInterceptors` and dispatches as
253
136
  * `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`
137
+ * site, at the top, ahead of any routing. AF5 keeps the property via decorator
138
+ * order: `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
258
139
  * stacks `InterceptingCommandBus → DistributedCommandBus → SimpleCommandBus`.
259
140
  *
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.
141
+ * If `local` is itself an intercepting bus, a server-routed command sees
142
+ * `lineage` twice. That is harmless: both of its fields are `??` seeds, so the
143
+ * second application finds them set and changes nothing.
270
144
  */
271
- export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatch, serializer, flowControl, commandLoadFactor, resilience) {
272
- const metadata = createAxonMetadata(connection.config);
145
+ export function axonServerCommandBus(conn, local, options = {}) {
146
+ const { connection, serializer, metadata: axonMetadata, } = contextView(conn, options.context ?? conn.connection.config.context);
147
+ const shutdownLatch = conn.shutdown;
148
+ const resilience = options.resilience ?? conn.resilience;
149
+ const metadata = axonMetadata();
273
150
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer);
274
- const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS));
275
- const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD));
276
- // Local segment handlers that execute on this node
277
- const localSegment = new Map();
278
- // Bidirectional stream for handler registration + inbound command handling
151
+ const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS));
152
+ const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD));
153
+ const loadFactor = options.loadFactor ?? DEFAULT_LOAD_FACTOR;
154
+ /**
155
+ * The names this node announced to Axon Server. The handlers themselves live
156
+ * on `local`; this set exists so an inbound command for a name we never
157
+ * subscribed still answers NO_HANDLER_FOR_COMMAND rather than whatever
158
+ * `local.dispatch` happens to throw — and so a reconnect can re-announce.
159
+ */
160
+ const subscribedNames = new Set();
161
+ // Bidirectional stream for handler subscription + inbound command handling
279
162
  let outbound = outboundStream();
280
163
  let streamStarted = false;
281
164
  let permits = 0n;
@@ -294,8 +177,20 @@ export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatc
294
177
  });
295
178
  permits += PERMITS;
296
179
  }
180
+ function sendSubscribe(commandName) {
181
+ outbound.send({
182
+ subscribe: {
183
+ messageId: generateIdentifier(),
184
+ command: commandName,
185
+ componentName: connection.config.componentName,
186
+ clientId: connection.config.clientId,
187
+ loadFactor,
188
+ },
189
+ instructionId: generateIdentifier(),
190
+ });
191
+ }
297
192
  /**
298
- * Re-establish the bidirectional stream and re-subscribe all handlers.
193
+ * Re-establish the bidirectional stream and re-announce all handlers.
299
194
  * Called on stream error or when the connection reconnects.
300
195
  *
301
196
  * ORDER (preserves Axon-specific invariant): subscriptions are
@@ -309,18 +204,8 @@ export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatc
309
204
  permits = 0n;
310
205
  ensureStreamStarted();
311
206
  // Re-subscribe all handlers FIRST
312
- for (const commandName of localSegment.keys()) {
313
- outbound.send({
314
- subscribe: {
315
- messageId: generateIdentifier(),
316
- command: commandName,
317
- componentName: connection.config.componentName,
318
- clientId: connection.config.clientId,
319
- loadFactor: commandLoadFactor ?? 100,
320
- },
321
- instructionId: generateIdentifier(),
322
- });
323
- }
207
+ for (const commandName of subscribedNames)
208
+ sendSubscribe(commandName);
324
209
  // Permits AFTER subscriptions (Axon-specific ordering invariant)
325
210
  grantPermits();
326
211
  }
@@ -336,7 +221,7 @@ export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatc
336
221
  connection.onReconnect(() => {
337
222
  if (!shutdownLatch.shuttingDown && streamStarted) {
338
223
  reestablishStreamWithRetry().catch((err) => {
339
- console.error("Distributed command bus: reconnect retries exhausted", err);
224
+ console.error("Axon Server command bus: reconnect retries exhausted", err);
340
225
  });
341
226
  }
342
227
  });
@@ -348,11 +233,10 @@ export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatc
348
233
  permits--;
349
234
  const proto = message.command;
350
235
  const commandName = proto.name;
351
- const handler = localSegment.get(commandName);
352
236
  let resultPayload;
353
237
  let errorCode = "";
354
238
  let errorMsg = "";
355
- if (handler) {
239
+ if (subscribedNames.has(commandName)) {
356
240
  try {
357
241
  const commandMessage = {
358
242
  kind: "command",
@@ -362,8 +246,12 @@ export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatc
362
246
  metadata: metadataFromProto(proto.metaData),
363
247
  timestamp: Number(proto.timestamp),
364
248
  };
365
- // Execute inbound command within its own UnitOfWork (AF5 parity)
366
- resultPayload = await unitOfWorkRunner(commandMessage.metadata, () => handler(commandMessage));
249
+ // Through the LOCAL BUS, so the caller's unit-of-work policy runs.
250
+ // AF parity is preserved: `CommandProcessingTask` runs the local
251
+ // segment without re-running dispatch interceptors, and a `local`
252
+ // that happens to carry `lineage` re-applies a pair of `??` seeds
253
+ // that are already set.
254
+ resultPayload = await local.dispatch(commandMessage);
367
255
  }
368
256
  catch (err) {
369
257
  errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR;
@@ -381,11 +269,14 @@ export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatc
381
269
  requestIdentifier: proto.messageIdentifier,
382
270
  errorCode,
383
271
  errorMessage: errorCode
384
- ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
385
- : undefined,
386
- payload: resultPayload !== undefined
387
- ? serializePayload("result", resultPayload)
272
+ ? {
273
+ message: errorMsg,
274
+ location: connection.config.componentName,
275
+ details: [],
276
+ errorCode,
277
+ }
388
278
  : undefined,
279
+ payload: resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
389
280
  metaData: {},
390
281
  processingInstructions: [],
391
282
  },
@@ -406,14 +297,21 @@ export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatc
406
297
  return;
407
298
  if (String(err).includes("Connection dropped"))
408
299
  return;
409
- console.error("Distributed command bus: inbound stream error, attempting re-establishment via withRetry", err);
300
+ console.error("Axon Server command bus: inbound stream error, attempting re-establishment via withRetry", err);
410
301
  await reestablishStreamWithRetry().catch((retryErr) => {
411
- console.error("Distributed command bus: reconnect retries exhausted", retryErr);
302
+ console.error("Axon Server command bus: reconnect retries exhausted", retryErr);
412
303
  });
413
304
  }
414
305
  }
415
- const routing = {
416
- async dispatch(message) {
306
+ return {
307
+ async dispatch(unstamped) {
308
+ // A transport is not a task: it has no unit of work, so it has no clock.
309
+ // A message that reaches the wire still {@link Unstamped} is therefore
310
+ // stamped from system time here — the envelope crosses a process boundary
311
+ // and must be fully formed. A locally-shortcut message is handed to
312
+ // `local` unstamped instead, so the task that handles it supplies the
313
+ // instant.
314
+ const message = stamped(unstamped, Date.now);
417
315
  const activity = shutdownLatch.registerActivity();
418
316
  try {
419
317
  const commandName = qualifiedNameToString(message.name);
@@ -437,58 +335,56 @@ export function distributedCommandBus(connection, unitOfWorkRunner, shutdownLatc
437
335
  }
438
336
  },
439
337
  subscribe(commandName, handler) {
440
- localSegment.set(commandName, handler);
338
+ subscribedNames.add(commandName);
339
+ local.subscribe(commandName, handler);
441
340
  ensureStreamStarted();
442
341
  // Subscription FIRST
443
- outbound.send({
444
- subscribe: {
445
- messageId: generateIdentifier(),
446
- command: commandName,
447
- componentName: connection.config.componentName,
448
- clientId: connection.config.clientId,
449
- loadFactor: commandLoadFactor ?? 100,
450
- },
451
- instructionId: generateIdentifier(),
452
- });
342
+ sendSubscribe(commandName);
453
343
  // Permits AFTER subscription (Axon-specific ordering invariant)
454
344
  grantPermits();
455
345
  },
456
346
  };
457
- // Interception OUTSIDE routing — see the note on this function.
458
- const bus = interceptingCommandBus(routing);
459
- bus.registerDispatchInterceptor(correlationDataDispatchInterceptor());
460
- return bus;
461
347
  }
462
348
  // ---------------------------------------------------------------------------
463
- // Distributed Query Bus
349
+ // Axon Server Query Bus
464
350
  // ---------------------------------------------------------------------------
465
351
  /**
466
- * A query bus backed by Axon Server.
352
+ * A query bus backed by Axon Server, over YOUR local bus.
467
353
  *
468
- * Same architecture as the distributed command bus:
469
- * - **Outbound dispatch**: Always through Axon Server.
470
- * - **Local segment**: Handlers registered here are stored locally and
471
- * registered with Axon Server for inbound routing. Inbound queries
472
- * are executed within a UnitOfWork.
354
+ * Same architecture as {@link axonServerCommandBus}: outbound dispatch goes
355
+ * through Axon Server, and a query the server routes here runs through `local`,
356
+ * so your unit-of-work policy applies to server-routed reads too. `subscribe`
357
+ * registers on `local` and announces the name to the server.
473
358
  *
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.
359
+ * The one asymmetry with commands is `shortcutQueriesToLocalHandlers` — Java
360
+ * has it for queries and not for commands, and so do we. When it is on and this
361
+ * node subscribed the name, `query()` goes straight to `local` and the caller's
362
+ * unit of work is passed through, so the local branch nests exactly as the
363
+ * in-process bus does.
364
+ *
365
+ * Lineage, if wanted, is `interceptingQueryBus(bus, lineage)` at the host,
366
+ * matching AF4's `AxonServerQueryBus`, which calls
367
+ * `dispatchInterceptors.intercept(...)` at the top of `query`, `streamingQuery`,
368
+ * `scatterGather` and `subscriptionQuery`. Because the wrap is outside, the
369
+ * shortcut branch gets identical lineage to the remote branch.
480
370
  *
481
371
  * KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
482
372
  * 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.
373
+ * `@kronos-ts/core`) forwards those two calls to the delegate without
374
+ * running the dispatch chain. Closing that needs a core change.
485
375
  */
486
- export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch, serializer, flowControl, shortcutQueriesToLocalHandlers, queryTimeoutMs, resilience) {
487
- const metadata = createAxonMetadata(connection.config);
488
- const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS));
489
- const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD));
376
+ export function axonServerQueryBus(conn, local, options = {}) {
377
+ const { connection, serializer, metadata: axonMetadata, } = contextView(conn, options.context ?? conn.connection.config.context);
378
+ const shutdownLatch = conn.shutdown;
379
+ const resilience = options.resilience ?? conn.resilience;
380
+ const metadata = axonMetadata();
381
+ const PERMITS = BigInt(options.flowControl?.permits ?? Number(DEFAULT_PERMITS));
382
+ const THRESHOLD = BigInt(options.flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD));
383
+ const shortcutQueriesToLocalHandlers = options.shortcutQueriesToLocalHandlers ?? false;
384
+ const queryTimeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS;
490
385
  const { serializePayload, deserializePayload } = createPayloadHelpers(serializer);
491
- const localSegment = new Map();
386
+ /** Query names announced to Axon Server; the handlers live on `local`. */
387
+ const subscribedNames = new Set();
492
388
  // Local subscription store — subscription queries opened by THIS instance.
493
389
  // Inbound updates from the server are offered into these via the Subscription RPC loop.
494
390
  const subscriptions = new Map();
@@ -515,8 +411,20 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
515
411
  });
516
412
  permits += PERMITS;
517
413
  }
414
+ function sendSubscribe(queryName) {
415
+ outbound.send({
416
+ subscribe: {
417
+ messageId: generateIdentifier(),
418
+ query: queryName,
419
+ resultName: "",
420
+ componentName: connection.config.componentName,
421
+ clientId: connection.config.clientId,
422
+ },
423
+ instructionId: generateIdentifier(),
424
+ });
425
+ }
518
426
  /**
519
- * Re-establish the bidirectional stream and re-subscribe all handlers.
427
+ * Re-establish the bidirectional stream and re-announce all handlers.
520
428
  * Called on stream error or when the connection reconnects.
521
429
  *
522
430
  * ORDER (preserves Axon-specific invariant): subscriptions are
@@ -528,18 +436,8 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
528
436
  streamStarted = false;
529
437
  permits = 0n;
530
438
  ensureStreamStarted();
531
- for (const queryName of localSegment.keys()) {
532
- outbound.send({
533
- subscribe: {
534
- messageId: generateIdentifier(),
535
- query: queryName,
536
- resultName: "",
537
- componentName: connection.config.componentName,
538
- clientId: connection.config.clientId,
539
- },
540
- instructionId: generateIdentifier(),
541
- });
542
- }
439
+ for (const queryName of subscribedNames)
440
+ sendSubscribe(queryName);
543
441
  grantQueryPermits();
544
442
  }
545
443
  async function reestablishStreamWithRetry() {
@@ -554,7 +452,7 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
554
452
  connection.onReconnect(() => {
555
453
  if (!shutdownLatch.shuttingDown && streamStarted) {
556
454
  reestablishStreamWithRetry().catch((err) => {
557
- console.error("Distributed query bus: reconnect retries exhausted", err);
455
+ console.error("Axon Server query bus: reconnect retries exhausted", err);
558
456
  });
559
457
  }
560
458
  });
@@ -568,11 +466,10 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
568
466
  const queryName = proto.query;
569
467
  const payload = deserializePayload(proto.payload?.data, proto.payload?.type, proto.payload?.revision);
570
468
  handlerSubscriptions.set(subId, { queryName, payload });
571
- const handler = localSegment.get(queryName);
572
469
  let resultPayload;
573
470
  let errorCode = "";
574
471
  let errorMsg = "";
575
- if (handler) {
472
+ if (subscribedNames.has(queryName)) {
576
473
  try {
577
474
  const queryMessage = {
578
475
  kind: "query",
@@ -582,9 +479,7 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
582
479
  metadata: metadataFromProto(proto.metaData ?? {}),
583
480
  timestamp: Number(proto.timestamp),
584
481
  };
585
- resultPayload = await unitOfWorkRunner(queryMessage.metadata, async () => {
586
- return handler(queryMessage);
587
- });
482
+ resultPayload = await local.query(queryMessage);
588
483
  }
589
484
  catch (err) {
590
485
  errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR;
@@ -595,9 +490,7 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
595
490
  errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY;
596
491
  errorMsg = `No local handler for query "${queryName}"`;
597
492
  }
598
- const responseSerialized = resultPayload !== undefined
599
- ? serializePayload("result", resultPayload)
600
- : undefined;
493
+ const responseSerialized = resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined;
601
494
  outbound.send({
602
495
  subscriptionQueryResponse: {
603
496
  messageIdentifier: generateIdentifier(),
@@ -607,7 +500,12 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
607
500
  requestIdentifier: proto.messageIdentifier,
608
501
  errorCode,
609
502
  errorMessage: errorCode
610
- ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
503
+ ? {
504
+ message: errorMsg,
505
+ location: connection.config.componentName,
506
+ details: [],
507
+ errorCode,
508
+ }
611
509
  : undefined,
612
510
  payload: responseSerialized,
613
511
  metaData: {},
@@ -635,11 +533,10 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
635
533
  permits--;
636
534
  const proto = message.query;
637
535
  const queryName = proto.query;
638
- const handler = localSegment.get(queryName);
639
536
  let resultPayload;
640
537
  let errorCode = "";
641
538
  let errorMsg = "";
642
- if (handler) {
539
+ if (subscribedNames.has(queryName)) {
643
540
  try {
644
541
  const queryMessage = {
645
542
  kind: "query",
@@ -649,9 +546,9 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
649
546
  metadata: metadataFromProto(proto.metaData),
650
547
  timestamp: Number(proto.timestamp),
651
548
  };
652
- resultPayload = await unitOfWorkRunner(queryMessage.metadata, async () => {
653
- return handler(queryMessage);
654
- });
549
+ // Through the LOCAL BUS: no unit of work is handed in, so `local`
550
+ // opens one under whatever policy the caller gave it.
551
+ resultPayload = await local.query(queryMessage);
655
552
  }
656
553
  catch (err) {
657
554
  errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR;
@@ -668,11 +565,14 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
668
565
  requestIdentifier: proto.messageIdentifier,
669
566
  errorCode,
670
567
  errorMessage: errorCode
671
- ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
672
- : undefined,
673
- payload: resultPayload !== undefined
674
- ? serializePayload("result", resultPayload)
568
+ ? {
569
+ message: errorMsg,
570
+ location: connection.config.componentName,
571
+ details: [],
572
+ errorCode,
573
+ }
675
574
  : undefined,
575
+ payload: resultPayload !== undefined ? serializePayload("result", resultPayload) : undefined,
676
576
  metaData: {},
677
577
  processingInstructions: [],
678
578
  },
@@ -699,33 +599,39 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
699
599
  return;
700
600
  if (String(err).includes("Connection dropped"))
701
601
  return;
702
- console.error("Distributed query bus: inbound stream error, attempting re-establishment via withRetry", err);
602
+ console.error("Axon Server query bus: inbound stream error, attempting re-establishment via withRetry", err);
703
603
  await reestablishStreamWithRetry().catch((retryErr) => {
704
- console.error("Distributed query bus: reconnect retries exhausted", retryErr);
604
+ console.error("Axon Server query bus: reconnect retries exhausted", retryErr);
705
605
  });
706
606
  }
707
607
  }
708
608
  const routing = {
709
- async query(message) {
609
+ async query(unstamped, uow) {
710
610
  const activity = shutdownLatch.registerActivity();
711
611
  try {
712
- const queryName = qualifiedNameToString(message.name);
713
- // Local shortcut — handle locally if handler is co-located
714
- if (shortcutQueriesToLocalHandlers) {
715
- const localHandler = localSegment.get(queryName);
716
- if (localHandler) {
717
- return unitOfWorkRunner(message.metadata, async () => {
718
- return localHandler(message);
719
- });
720
- }
612
+ const queryName = qualifiedNameToString(unstamped.name);
613
+ // Local shortcut — handle locally if a handler is co-located. The
614
+ // caller's unit of work is passed straight through, so `local` makes the
615
+ // nest-or-open decision on the HANDLE exactly as it does for an
616
+ // in-process read: a live unit of work handed in by `ctx.query` is
617
+ // reused so the consulting read shares the caller's transaction.
618
+ if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
619
+ return local.query(unstamped, uow);
721
620
  }
621
+ // A transport is not a task: it has no unit of work, so it has no clock.
622
+ // A message that reaches the wire still {@link Unstamped} is therefore
623
+ // stamped from system time here — the envelope crosses a process boundary
624
+ // and must be fully formed. A locally-shortcut message is handed to
625
+ // `local` unstamped instead, so the task that handles it supplies the
626
+ // instant.
627
+ const message = stamped(unstamped, Date.now);
722
628
  const responseStream = connection.queries.query({
723
629
  messageIdentifier: message.identifier,
724
630
  query: queryName,
725
631
  timestamp: BigInt(message.timestamp),
726
632
  payload: serializePayload(queryName, message.payload),
727
633
  metaData: metadataToProto(message.metadata),
728
- processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
634
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs),
729
635
  clientId: connection.config.clientId,
730
636
  componentName: connection.config.componentName,
731
637
  }, { metadata });
@@ -742,22 +648,15 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
742
648
  }
743
649
  },
744
650
  subscribe(queryName, handler) {
745
- localSegment.set(queryName, handler);
651
+ subscribedNames.add(queryName);
652
+ local.subscribe(queryName, handler);
746
653
  ensureStreamStarted();
747
- outbound.send({
748
- subscribe: {
749
- messageId: generateIdentifier(),
750
- query: queryName,
751
- resultName: "",
752
- componentName: connection.config.componentName,
753
- clientId: connection.config.clientId,
754
- },
755
- instructionId: generateIdentifier(),
756
- });
654
+ sendSubscribe(queryName);
757
655
  // Permits AFTER subscription (Axon-specific ordering invariant)
758
656
  grantQueryPermits();
759
657
  },
760
- subscriptionQuery(message, bufferSize) {
658
+ subscriptionQuery(unstamped, bufferSize) {
659
+ const message = stamped(unstamped, Date.now);
761
660
  const queryId = message.identifier;
762
661
  if (subscriptions.has(queryId)) {
763
662
  throw new Error(`Subscription query already registered for identifier "${queryId}"`);
@@ -777,7 +676,7 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
777
676
  timestamp: BigInt(message.timestamp),
778
677
  payload: serializePayload(queryName, message.payload),
779
678
  metaData: metadataToProto(message.metadata),
780
- processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
679
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs),
781
680
  clientId: connection.config.clientId,
782
681
  componentName: connection.config.componentName,
783
682
  },
@@ -793,7 +692,7 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
793
692
  timestamp: BigInt(message.timestamp),
794
693
  payload: serializePayload(queryName, message.payload),
795
694
  metaData: metadataToProto(message.metadata),
796
- processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
695
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs),
797
696
  clientId: connection.config.clientId,
798
697
  componentName: connection.config.componentName,
799
698
  },
@@ -831,7 +730,8 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
831
730
  break;
832
731
  }
833
732
  else if (response.completeExceptionally) {
834
- handler.completeExceptionally(new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed"));
733
+ handler.completeExceptionally(new Error(response.completeExceptionally.errorMessage?.message ??
734
+ "Subscription query failed"));
835
735
  break;
836
736
  }
837
737
  }
@@ -863,7 +763,8 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
863
763
  },
864
764
  };
865
765
  },
866
- subscribeToUpdates(message, bufferSize) {
766
+ subscribeToUpdates(unstamped, bufferSize) {
767
+ const message = stamped(unstamped, Date.now);
867
768
  const queryId = message.identifier;
868
769
  if (subscriptions.has(queryId)) {
869
770
  throw new Error(`Subscription query already registered for identifier "${queryId}"`);
@@ -957,8 +858,6 @@ export function distributedQueryBus(connection, unitOfWorkRunner, shutdownLatch,
957
858
  });
958
859
  },
959
860
  };
960
- const bus = interceptingQueryBus(routing);
961
- bus.registerDispatchInterceptor(correlationDataDispatchInterceptor());
962
- return bus;
861
+ return routing;
963
862
  }
964
863
  //# sourceMappingURL=axon-server.js.map