@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
@@ -10,7 +10,7 @@ import { connectToAxonServer } from "./connection.js"
10
10
  * Aligned with Java's `AxonServerConnectionManager`.
11
11
  *
12
12
  * ```typescript
13
- * const manager = createConnectionManager({
13
+ * const manager = connectionManager({
14
14
  * componentName: "my-app",
15
15
  * host: "axon-server",
16
16
  * port: 8124,
@@ -51,7 +51,7 @@ export interface AxonServerConnectionManager {
51
51
  * The base config (host, port, SSL, etc.) is shared across all contexts.
52
52
  * Only the `context` field varies per connection.
53
53
  */
54
- export function createConnectionManager(
54
+ export function connectionManager(
55
55
  baseConfig: Omit<AxonServerConnectionConfig, "context">,
56
56
  ): AxonServerConnectionManager {
57
57
  const connections = new Map<string, AxonServerConnection>()
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The Axon Server PLATFORM CONTROL PLANE — remote administration, extracted
3
+ * out of the backend.
4
+ *
5
+ * This is not persistence and it is not transport. It is the pair of duties
6
+ * Axon Server's admin surface needs from a client:
7
+ *
8
+ * 1. inbound — Axon Server pushes processor instructions (pause-processor,
9
+ * start-processor, release-segment, split-segment, merge-segment) which
10
+ * have to be routed to the live processor of that name;
11
+ * 2. outbound — the client periodically reports each processor's status so
12
+ * the Axon Dashboard can render it.
13
+ *
14
+ * It lived inside `axonServer().start()` only because it shares the gRPC
15
+ * connection with the data path. It is now separate and OPT-IN: a service that
16
+ * nobody administers remotely simply never builds one, and `axonServer()`
17
+ * matches `postgres()` / `rabbitmq()` — components plus an argument-less
18
+ * `start()` readiness barrier.
19
+ *
20
+ * ```ts
21
+ * const axon = await axonServer({ ... })
22
+ * const app = kronos({ components: { ...axon.components }, modules })
23
+ * await axon.start() // data path only
24
+ *
25
+ * // opt in to remote administration
26
+ * const control = await axonServerControlPlane(axon.platform, app.processors.values())
27
+ * // …
28
+ * await app.stop(); await control.close(); await axon.close()
29
+ * ```
30
+ *
31
+ * ORDERING INVARIANT (the reason this is an async factory rather than a
32
+ * constructor plus a separate `start()`): the instruction handler and the
33
+ * status supplier MUST both be registered BEFORE `platform.start()` sends the
34
+ * `register` frame. An instruction that arrives in the gap between `start()`
35
+ * and `onInstruction(...)` is dropped on the floor, and a status request that
36
+ * arrives before `registerProcessorStatusSupplier(...)` finds no supplier and
37
+ * reports the client as having no processors at all. Register, register, then
38
+ * start — all three inside one function, so there is no call order for a
39
+ * caller to get wrong.
40
+ */
41
+ import type { PlatformConnection } from "./platform-service.js"
42
+ import type { ProcessorStatus, SegmentStatus } from "./event-processor-info.js"
43
+
44
+ /**
45
+ * A processor Axon Server is allowed to observe and control.
46
+ *
47
+ * The container version reached for `app.processors()` and cast the result to
48
+ * `any` before poking at `start` / `stop` / `releaseSegment` / … — this is that
49
+ * cast, written down. Both `TrackingEventProcessor` and
50
+ * `StreamingEventProcessor` satisfy it structurally; anything else that can
51
+ * name itself and answer some of these calls does too. Every member past the
52
+ * name is optional because Axon Server asks for things a given processor kind
53
+ * may not implement (a subscribing processor has no segments), and the
54
+ * instruction handler simply skips what is absent.
55
+ */
56
+ export interface ManagedEventProcessor {
57
+ readonly name: string
58
+ readonly running?: boolean
59
+ readonly replaying?: boolean
60
+ readonly position?: bigint
61
+ start?(): Promise<void> | void
62
+ stop?(): void
63
+ supportsReset?(): boolean
64
+ processingStatus?(): ReadonlyMap<
65
+ number,
66
+ {
67
+ readonly position?: bigint
68
+ readonly caughtUp?: boolean
69
+ readonly replaying?: boolean
70
+ readonly error?: Error
71
+ }
72
+ >
73
+ releaseSegment?(segmentId: number): Promise<unknown> | unknown
74
+ splitSegment?(segmentId: number): Promise<unknown> | unknown
75
+ mergeSegment?(segmentId: number): Promise<unknown> | unknown
76
+ }
77
+
78
+ /**
79
+ * A running control plane. The platform stream is live; instructions are being
80
+ * routed and status is being reported until `close()`.
81
+ */
82
+ export interface AxonServerControlPlane {
83
+ /**
84
+ * The processors this control plane addresses, keyed by name — the snapshot
85
+ * taken at construction. Exposed for introspection and tests.
86
+ */
87
+ readonly processors: ReadonlyMap<string, ManagedEventProcessor>
88
+ /** Stop the platform stream. Idempotent, and safe to call before `axon.close()`. */
89
+ close(): Promise<void>
90
+ }
91
+
92
+ /** Map the managed processors into the status shape the platform stream reports. */
93
+ function processorStatuses(
94
+ processors: Iterable<ManagedEventProcessor>,
95
+ ): ProcessorStatus[] {
96
+ return Array.from(processors, (proc) => {
97
+ const isStreamingProcessor = proc.supportsReset?.() !== false
98
+ const perSegment = proc.processingStatus?.()
99
+ const segments: SegmentStatus[] = perSegment
100
+ ? Array.from(perSegment.entries()).map(([segmentId, status]) => ({
101
+ segmentId,
102
+ caughtUp: status.caughtUp ?? false,
103
+ replaying: status.replaying ?? false,
104
+ onePartOf: 1,
105
+ tokenPosition: status.position ?? 0n,
106
+ errorState: status.error?.message ?? "",
107
+ }))
108
+ : [
109
+ {
110
+ segmentId: 0,
111
+ caughtUp: true,
112
+ replaying: proc.replaying ?? false,
113
+ onePartOf: 1,
114
+ tokenPosition: proc.position ?? 0n,
115
+ errorState: "",
116
+ },
117
+ ]
118
+ return {
119
+ name: proc.name,
120
+ running: proc.running ?? false,
121
+ mode: isStreamingProcessor ? "Tracking" : "Subscribing",
122
+ isStreamingProcessor,
123
+ activeThreads: proc.running ? 1 : 0,
124
+ availableThreads: 0,
125
+ error: false,
126
+ tokenStoreIdentifier: "",
127
+ segments,
128
+ }
129
+ })
130
+ }
131
+
132
+ /**
133
+ * Wire remote administration onto an Axon Server platform stream and start it.
134
+ *
135
+ * `platform` is `axonServer(...).platform` — the backend builds the platform
136
+ * connection (it owns the gRPC connection and the `platformService` tuning) but
137
+ * never starts it, because starting it is exactly what this function is for.
138
+ *
139
+ * `processors` are the LIVE processor instances, which only exist after
140
+ * `kronos` has built them — that is why this cannot be folded back into the
141
+ * backend factory. Pass `app.processors.values()`.
142
+ *
143
+ * Caveat on that call: `kronos` types `processors` as
144
+ * `ReadonlyMap<string, unknown>`, so `.values()` needs a cast today —
145
+ * `app.processors.values() as Iterable<ManagedEventProcessor>`. Narrowing that
146
+ * map in `@kronos-ts/app` would remove the cast; this package cannot.
147
+ *
148
+ * The collection is SNAPSHOTTED into a name-keyed map here, once. That is
149
+ * deliberate: a one-shot iterator (`Map.values()` is one) would otherwise yield
150
+ * nothing on the second status report. The processor OBJECTS are live, so each
151
+ * report still reads current `running` / `position` / segment state off them —
152
+ * only the membership of the set is fixed at construction.
153
+ */
154
+ export async function axonServerControlPlane(
155
+ platform: PlatformConnection,
156
+ processors: Iterable<ManagedEventProcessor> = [],
157
+ ): Promise<AxonServerControlPlane> {
158
+ // Name-keyed view so server-initiated instructions route to the right one.
159
+ const byName = new Map<string, ManagedEventProcessor>()
160
+ for (const proc of processors) byName.set(proc.name, proc)
161
+
162
+ platform.onInstruction(async (instruction) => {
163
+ switch (instruction.kind) {
164
+ case "pause-processor":
165
+ byName.get(instruction.processorName)?.stop?.()
166
+ break
167
+ case "start-processor":
168
+ await byName.get(instruction.processorName)?.start?.()
169
+ break
170
+ case "release-segment":
171
+ await byName.get(instruction.processorName)?.releaseSegment?.(instruction.segmentId)
172
+ break
173
+ case "split-segment":
174
+ await byName.get(instruction.processorName)?.splitSegment?.(instruction.segmentId)
175
+ break
176
+ case "merge-segment":
177
+ await byName.get(instruction.processorName)?.mergeSegment?.(instruction.segmentId)
178
+ break
179
+ }
180
+ })
181
+
182
+ platform.registerProcessorStatusSupplier(() => processorStatuses(byName.values()))
183
+
184
+ // ORDERING: strictly AFTER both registrations — see the file-level JSDoc.
185
+ // An instruction arriving in the gap would be dropped; an early status
186
+ // request would find no supplier.
187
+ await platform.start()
188
+
189
+ return {
190
+ processors: byName,
191
+ async close() {
192
+ platform.stop()
193
+ },
194
+ }
195
+ }
@@ -27,7 +27,7 @@ export interface FlowControlledSender<T> {
27
27
  * @param send Function called to actually send an update downstream.
28
28
  * @param maxBufferSize Maximum number of updates to buffer. Default: 256.
29
29
  */
30
- export function createFlowControlledSender<T>(
30
+ export function flowControlledSender<T>(
31
31
  send: (value: T) => void,
32
32
  onComplete?: () => void,
33
33
  onError?: (error: Error) => void,
package/src/index.ts CHANGED
@@ -6,28 +6,38 @@ export {
6
6
 
7
7
  export {
8
8
  type AxonServerConnectionManager,
9
- createConnectionManager,
9
+ connectionManager,
10
10
  } from "./connection-manager.js"
11
11
 
12
12
  export {
13
- createAxonServerEventStore,
13
+ axonServerEventStore,
14
14
  } from "./axon-server-event-store.js"
15
15
 
16
16
  export {
17
- createAxonServerSnapshotStore,
17
+ axonServerSnapshotStore,
18
18
  } from "./axon-server-snapshot-store.js"
19
19
 
20
20
  export {
21
21
  axonServer,
22
- type AxonServerExtensionConfig,
22
+ distributedCommandBus,
23
+ distributedQueryBus,
24
+ type AxonServerConfig,
25
+ type AxonServerBackend,
26
+ type AxonServerComponents,
23
27
  type FlowControlConfig,
24
28
  type ProcessingInstructions,
25
29
  } from "./axon-server.js"
26
30
 
31
+ export {
32
+ axonServerControlPlane,
33
+ type AxonServerControlPlane,
34
+ type ManagedEventProcessor,
35
+ } from "./control-plane.js"
36
+
27
37
  export {
28
38
  type MessageSizeConfig,
29
39
  MessageSizeExceededError,
30
- createMessageSizeValidator,
40
+ messageSizeValidator,
31
41
  } from "./message-size.js"
32
42
 
33
43
  export {
@@ -42,19 +52,19 @@ export {
42
52
  type PlatformInstruction,
43
53
  type InstructionHandler,
44
54
  type PlatformServiceOptions,
45
- createPlatformConnection,
55
+ platformConnection,
46
56
  } from "./platform-service.js"
47
57
 
48
58
  export {
49
59
  type FlowControlledSender,
50
- createFlowControlledSender,
60
+ flowControlledSender,
51
61
  } from "./flow-controlled-sender.js"
52
62
 
53
63
  export {
54
64
  type ShutdownLatch,
55
65
  type ActivityHandle,
56
66
  ShutdownInProgressError,
57
- createShutdownLatch,
67
+ shutdownLatch,
58
68
  } from "./shutdown-latch.js"
59
69
 
60
70
  export {
@@ -73,3 +83,4 @@ export {
73
83
  mapErrorCode,
74
84
  isTransientError,
75
85
  } from "./errors.js"
86
+ export type { AxonServerOptions } from "./axon-server.js"
@@ -39,7 +39,7 @@ export class MessageSizeExceededError extends Error {
39
39
  * - `validate(data)` — throws if over limit, warns if over 75%
40
40
  * - `estimateSize(payload)` — quick byte size estimate
41
41
  */
42
- export function createMessageSizeValidator(config?: MessageSizeConfig) {
42
+ export function messageSizeValidator(config?: MessageSizeConfig) {
43
43
  const maxSize = config?.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE
44
44
  const threshold = config?.warningThreshold ?? WARNING_THRESHOLD
45
45
  const warningSize = Math.floor(maxSize * threshold)
@@ -12,7 +12,7 @@ export interface OutboundStream<T> {
12
12
  close(): void
13
13
  }
14
14
 
15
- export function createOutboundStream<T>(): OutboundStream<T> {
15
+ export function outboundStream<T>(): OutboundStream<T> {
16
16
  let resolve: ((value: IteratorResult<T>) => void) | null = null
17
17
  const queue: T[] = []
18
18
  let closed = false
@@ -1,5 +1,5 @@
1
1
  import type { AxonServerConnection } from "./connection.js"
2
- import { createOutboundStream } from "./outbound-stream.js"
2
+ import { outboundStream } from "./outbound-stream.js"
3
3
  import type { PlatformInboundInstruction } from "./generated/control.js"
4
4
  import { Metadata } from "nice-grpc"
5
5
  import type { ProcessorStatusSupplier } from "./event-processor-info.js"
@@ -36,9 +36,40 @@ export type InstructionHandler = (instruction: PlatformInstruction) => void | Pr
36
36
  * - Receives server-initiated instructions (pause, resume, split, merge segments)
37
37
  */
38
38
  export interface PlatformConnection {
39
- /** Start the platform stream (register with Axon Server, begin heartbeats). */
39
+ /**
40
+ * DATA PATH. Open the platform stream, register this client, and arm the
41
+ * heartbeat that calls `connection.reconnect()` when the server stops
42
+ * answering.
43
+ *
44
+ * This is split out of {@link start} deliberately. Reconnect detection is a
45
+ * property of the CONNECTION, and the command/query buses hook
46
+ * `connection.onReconnect(...)` to re-establish their own streams — so a
47
+ * service that never opts into remote administration still needs it. When it
48
+ * lived only inside `start()` (which only the control plane calls), such a
49
+ * service had no heartbeat-driven reconnect detection on its data path at all
50
+ * and would sit on a dead channel indefinitely.
51
+ *
52
+ * Arms NOTHING control-plane-specific: no processor status reporting. Safe to
53
+ * call repeatedly; a stream that is already up is left alone.
54
+ */
55
+ armConnectionMonitoring(): Promise<void>
56
+ /**
57
+ * CONTROL PLANE. Everything {@link armConnectionMonitoring} does, plus
58
+ * periodic processor status reporting.
59
+ *
60
+ * Idempotent in both halves: if the data path already opened the stream, this
61
+ * only adds status reporting; if it did not, this opens the stream too.
62
+ */
40
63
  start(): Promise<void>
41
- /** Stop the platform stream and heartbeats. */
64
+ /**
65
+ * Stop the platform stream, heartbeats and status reporting.
66
+ *
67
+ * Note that this tears down the SHARED stream — including the data path's
68
+ * reconnect detection. `axonServerControlPlane(...).close()` calls it, which
69
+ * is correct in the documented shutdown order (`app.stop()` → `control.close()`
70
+ * → `axon.close()`) but means closing a control plane on a still-running
71
+ * service disarms reconnect detection with it.
72
+ */
42
73
  stop(): void
43
74
  /** Register a handler for server-initiated instructions. */
44
75
  onInstruction(handler: InstructionHandler): void
@@ -98,7 +129,7 @@ export interface PlatformServiceOptions {
98
129
  * 2. Sends periodic heartbeats to verify connectivity
99
130
  * 3. Receives instructions from Axon Server (split, merge, pause, resume)
100
131
  */
101
- export function createPlatformConnection(
132
+ export function platformConnection(
102
133
  connection: AxonServerConnection,
103
134
  options?: PlatformServiceOptions,
104
135
  ): PlatformConnection {
@@ -109,12 +140,27 @@ export function createPlatformConnection(
109
140
 
110
141
  const instructionHandlers: InstructionHandler[] = []
111
142
  const processorStatusSuppliers: ProcessorStatusSupplier[] = []
143
+ /**
144
+ * Instructions that arrived before anything registered a handler.
145
+ *
146
+ * The control plane registers its handler before calling `start()`, but the
147
+ * DATA path now opens the same stream via `armConnectionMonitoring()` — which
148
+ * runs before any control plane exists. That leaves a window in which Axon
149
+ * Server can push an instruction at a client with nothing to route it to.
150
+ * Buffering makes the window harmless instead of merely forbidden: the first
151
+ * `onInstruction` registration drains this queue in arrival order. Mirrors the
152
+ * kronosdb platform connection, which has had this since its backend started
153
+ * the stream for `subscriptionsAcked()`.
154
+ */
155
+ const pendingInstructions: PlatformInstruction[] = []
112
156
  let isConnected = false
113
157
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null
114
158
  let heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null
115
159
  let processorStatusTimer: ReturnType<typeof setInterval> | null = null
160
+ /** Guards against arming the status-report timer twice — see `startProcessorStatusReporting`. */
161
+ let processorStatusArmed = false
116
162
  let lastHeartbeatResponse = Date.now()
117
- let outbound: ReturnType<typeof createOutboundStream<PlatformInboundInstruction>> | null = null
163
+ let outbound: ReturnType<typeof outboundStream<PlatformInboundInstruction>> | null = null
118
164
  /**
119
165
  * Latches once Axon Server sends its first inbound message after
120
166
  * registration — the earliest observable signal that the platform stream
@@ -140,6 +186,11 @@ export function createPlatformConnection(
140
186
  // Parse instruction type
141
187
  const instruction = parseInstruction(message)
142
188
  if (instruction) {
189
+ if (instructionHandlers.length === 0) {
190
+ // Nothing to route to yet — hold it for the first registration
191
+ // rather than dropping it.
192
+ pendingInstructions.push(instruction)
193
+ }
143
194
  for (const handler of instructionHandlers) {
144
195
  try {
145
196
  await handler(instruction)
@@ -240,6 +291,14 @@ export function createPlatformConnection(
240
291
  }
241
292
 
242
293
  function startProcessorStatusReporting() {
294
+ // Idempotency guard. The control plane may call `start()` after the data
295
+ // path already brought the stream up, and `start()` is itself documented as
296
+ // safe to call twice. Without this flag a second call inside the initial
297
+ // delay window would leave TWO pending timeouts, each of which installs an
298
+ // interval, and only the last would be tracked in `processorStatusTimer` —
299
+ // the first would leak past `stop()`.
300
+ if (processorStatusArmed) return
301
+ processorStatusArmed = true
243
302
  if (processorStatusTimer) clearInterval(processorStatusTimer)
244
303
 
245
304
  // Initial delay before first report
@@ -273,57 +332,87 @@ export function createPlatformConnection(
273
332
  }
274
333
  }
275
334
 
276
- return {
277
- async start() {
278
- if (isConnected) return
279
-
280
- // Re-arm the ack latch so a stop/start cycle correctly re-waits.
281
- acked = false
282
- outbound = createOutboundStream<PlatformInboundInstruction>()
335
+ /**
336
+ * Open the platform stream and arm the heartbeat. Shared by
337
+ * `armConnectionMonitoring()` (data path) and `start()` (control plane);
338
+ * whichever runs first opens it, the other finds it up and returns.
339
+ */
340
+ function openPlatformStream() {
341
+ if (isConnected) return
342
+
343
+ // A heartbeat timeout clears `isConnected` WITHOUT going through `stop()`,
344
+ // so a later call can land here with the dropped stream's outbound still
345
+ // open. Close it before replacing the reference, or it leaks. This is more
346
+ // reachable now that the data path arms the heartbeat: previously only a
347
+ // control plane could get here after a timeout.
348
+ if (outbound) {
349
+ outbound.close()
350
+ outbound = null
351
+ }
283
352
 
284
- // Register with Axon Server
285
- outbound.send({
286
- register: {
287
- clientId: connection.config.clientId,
288
- componentName: connection.config.componentName,
289
- version: "1.0.0",
290
- tags: {},
291
- },
292
- instructionId: "",
293
- })
353
+ // Re-arm the ack latch so a stop/start cycle correctly re-waits.
354
+ acked = false
355
+ outbound = outboundStream<PlatformInboundInstruction>()
356
+
357
+ // Register with Axon Server
358
+ outbound.send({
359
+ register: {
360
+ clientId: connection.config.clientId,
361
+ componentName: connection.config.componentName,
362
+ version: "1.0.0",
363
+ tags: {},
364
+ },
365
+ instructionId: "",
366
+ })
367
+
368
+ // Open bidirectional stream
369
+ const inbound = connection.platform.openStream(outbound.iterable, {
370
+ metadata: grpcMetadata,
371
+ })
372
+
373
+ isConnected = true
374
+ // DATA PATH: the heartbeat is what calls connection.reconnect() on
375
+ // timeout, which is what the command/query buses hang their stream
376
+ // re-establishment off. It belongs to every service, administered or not.
377
+ startHeartbeat()
378
+ processInboundInstructions(inbound)
379
+
380
+ // Axon Server's PlatformService does NOT proactively emit an inbound
381
+ // frame in response to `register` — the stream is held open silently
382
+ // until either (a) the server pushes a topology / instruction event,
383
+ // or (b) one of our heartbeat pings round-trips back. That means the
384
+ // first-inbound-frame ack signal used by kronosdb (Plan 09-03 / D-102)
385
+ // doesn't fire deterministically here, and the processors-stage
386
+ // `withRetry({event: "per-operation"})` poll would otherwise hang.
387
+ //
388
+ // Axon-specific ack derivation: latch `acked = true` immediately once
389
+ // the outbound `register` frame has been flushed to the gRPC layer.
390
+ // Bus subscriptions (sent on the command/query streams, NOT the
391
+ // platform stream) are an orthogonal concern handled by the
392
+ // command/query bus reconnect path — the legacy 1-second sleep that
393
+ // we replaced was always covering register processing, not bus-side
394
+ // routability. Structurally this is the Axon Server equivalent of
395
+ // D-102: drop the magic-number wait, use the earliest deterministic
396
+ // observable signal that fits the underlying protocol.
397
+ acked = true
398
+ }
294
399
 
295
- // Open bidirectional stream
296
- const inbound = connection.platform.openStream(outbound.iterable, {
297
- metadata: grpcMetadata,
298
- })
400
+ return {
401
+ async armConnectionMonitoring() {
402
+ openPlatformStream()
403
+ },
299
404
 
300
- isConnected = true
301
- startHeartbeat()
405
+ async start() {
406
+ openPlatformStream()
302
407
  startProcessorStatusReporting()
303
- processInboundInstructions(inbound)
304
-
305
- // Axon Server's PlatformService does NOT proactively emit an inbound
306
- // frame in response to `register` — the stream is held open silently
307
- // until either (a) the server pushes a topology / instruction event,
308
- // or (b) one of our heartbeat pings round-trips back. That means the
309
- // first-inbound-frame ack signal used by kronosdb (Plan 09-03 / D-102)
310
- // doesn't fire deterministically here, and the processors-stage
311
- // `withRetry({event: "per-operation"})` poll would otherwise hang.
312
- //
313
- // Axon-specific ack derivation: latch `acked = true` immediately once
314
- // the outbound `register` frame has been flushed to the gRPC layer.
315
- // Bus subscriptions (sent on the command/query streams, NOT the
316
- // platform stream) are an orthogonal concern handled by the
317
- // command/query bus reconnect path — the legacy 1-second sleep that
318
- // we replaced was always covering register processing, not bus-side
319
- // routability. Structurally this is the Axon Server equivalent of
320
- // D-102: drop the magic-number wait, use the earliest deterministic
321
- // observable signal that fits the underlying protocol.
322
- acked = true
323
408
  },
324
409
 
325
410
  stop() {
326
411
  isConnected = false
412
+ // A stopped stream's un-routed backlog is stale — do not replay it if a
413
+ // handler registers later.
414
+ pendingInstructions.length = 0
415
+ processorStatusArmed = false
327
416
  if (heartbeatTimer) {
328
417
  clearInterval(heartbeatTimer)
329
418
  heartbeatTimer = null
@@ -343,7 +432,22 @@ export function createPlatformConnection(
343
432
  },
344
433
 
345
434
  onInstruction(handler) {
435
+ const isFirst = instructionHandlers.length === 0
346
436
  instructionHandlers.push(handler)
437
+
438
+ // Drain anything that arrived before a handler existed, in arrival order.
439
+ if (isFirst && pendingInstructions.length > 0) {
440
+ const backlog = pendingInstructions.splice(0, pendingInstructions.length)
441
+ void (async () => {
442
+ for (const instruction of backlog) {
443
+ try {
444
+ await handler(instruction)
445
+ } catch (err) {
446
+ console.error("Platform instruction handler error:", err)
447
+ }
448
+ }
449
+ })()
450
+ }
347
451
  },
348
452
 
349
453
  registerProcessorStatusSupplier(supplier) {
@@ -43,7 +43,7 @@ export class ShutdownInProgressError extends Error {
43
43
  }
44
44
  }
45
45
 
46
- export function createShutdownLatch(): ShutdownLatch {
46
+ export function shutdownLatch(): ShutdownLatch {
47
47
  let activeCount = 0
48
48
  let shuttingDown = false
49
49
  let drainResolve: (() => void) | null = null