@kronos-ts/axon-server 0.1.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 (106) hide show
  1. package/dist/axon-server-event-store.d.ts +16 -0
  2. package/dist/axon-server-event-store.d.ts.map +1 -0
  3. package/dist/axon-server-event-store.js +282 -0
  4. package/dist/axon-server-event-store.js.map +1 -0
  5. package/dist/axon-server-snapshot-store.d.ts +12 -0
  6. package/dist/axon-server-snapshot-store.d.ts.map +1 -0
  7. package/dist/axon-server-snapshot-store.js +88 -0
  8. package/dist/axon-server-snapshot-store.js.map +1 -0
  9. package/dist/axon-server.d.ts +115 -0
  10. package/dist/axon-server.d.ts.map +1 -0
  11. package/dist/axon-server.js +986 -0
  12. package/dist/axon-server.js.map +1 -0
  13. package/dist/connection-manager.d.ts +49 -0
  14. package/dist/connection-manager.d.ts.map +1 -0
  15. package/dist/connection-manager.js +37 -0
  16. package/dist/connection-manager.js.map +1 -0
  17. package/dist/connection.d.ts +129 -0
  18. package/dist/connection.d.ts.map +1 -0
  19. package/dist/connection.js +130 -0
  20. package/dist/connection.js.map +1 -0
  21. package/dist/errors.d.ts +96 -0
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/errors.js +189 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/event-processor-info.d.ts +35 -0
  26. package/dist/event-processor-info.d.ts.map +1 -0
  27. package/dist/event-processor-info.js +28 -0
  28. package/dist/event-processor-info.js.map +1 -0
  29. package/dist/flow-controlled-sender.d.ts +30 -0
  30. package/dist/flow-controlled-sender.d.ts.map +1 -0
  31. package/dist/flow-controlled-sender.js +60 -0
  32. package/dist/flow-controlled-sender.js.map +1 -0
  33. package/dist/generated/command.d.ts +158 -0
  34. package/dist/generated/command.d.ts.map +1 -0
  35. package/dist/generated/command.js +970 -0
  36. package/dist/generated/command.js.map +1 -0
  37. package/dist/generated/common.d.ts +130 -0
  38. package/dist/generated/common.d.ts.map +1 -0
  39. package/dist/generated/common.js +908 -0
  40. package/dist/generated/common.js.map +1 -0
  41. package/dist/generated/control.d.ts +293 -0
  42. package/dist/generated/control.d.ts.map +1 -0
  43. package/dist/generated/control.js +1938 -0
  44. package/dist/generated/control.js.map +1 -0
  45. package/dist/generated/dcb.d.ts +650 -0
  46. package/dist/generated/dcb.d.ts.map +1 -0
  47. package/dist/generated/dcb.js +2943 -0
  48. package/dist/generated/dcb.js.map +1 -0
  49. package/dist/generated/event.d.ts +667 -0
  50. package/dist/generated/event.d.ts.map +1 -0
  51. package/dist/generated/event.js +3185 -0
  52. package/dist/generated/event.js.map +1 -0
  53. package/dist/generated/google/protobuf/empty.d.ts +30 -0
  54. package/dist/generated/google/protobuf/empty.d.ts.map +1 -0
  55. package/dist/generated/google/protobuf/empty.js +46 -0
  56. package/dist/generated/google/protobuf/empty.js.map +1 -0
  57. package/dist/generated/query.d.ts +300 -0
  58. package/dist/generated/query.d.ts.map +1 -0
  59. package/dist/generated/query.js +2183 -0
  60. package/dist/generated/query.js.map +1 -0
  61. package/dist/index.d.ts +12 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +12 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/message-size.d.ts +38 -0
  66. package/dist/message-size.d.ts.map +1 -0
  67. package/dist/message-size.js +57 -0
  68. package/dist/message-size.js.map +1 -0
  69. package/dist/metadata-conversion.d.ts +11 -0
  70. package/dist/metadata-conversion.d.ts.map +1 -0
  71. package/dist/metadata-conversion.js +51 -0
  72. package/dist/metadata-conversion.js.map +1 -0
  73. package/dist/outbound-stream.d.ts +15 -0
  74. package/dist/outbound-stream.d.ts.map +1 -0
  75. package/dist/outbound-stream.js +39 -0
  76. package/dist/outbound-stream.js.map +1 -0
  77. package/dist/platform-service.d.ts +119 -0
  78. package/dist/platform-service.d.ts.map +1 -0
  79. package/dist/platform-service.js +250 -0
  80. package/dist/platform-service.js.map +1 -0
  81. package/dist/shutdown-latch.d.ts +38 -0
  82. package/dist/shutdown-latch.d.ts.map +1 -0
  83. package/dist/shutdown-latch.js +51 -0
  84. package/dist/shutdown-latch.js.map +1 -0
  85. package/package.json +69 -0
  86. package/src/axon-server-event-store.ts +358 -0
  87. package/src/axon-server-snapshot-store.ts +118 -0
  88. package/src/axon-server.ts +1202 -0
  89. package/src/connection-manager.ts +88 -0
  90. package/src/connection.ts +272 -0
  91. package/src/errors.ts +223 -0
  92. package/src/event-processor-info.ts +62 -0
  93. package/src/flow-controlled-sender.ts +91 -0
  94. package/src/generated/command.ts +1231 -0
  95. package/src/generated/common.ts +1097 -0
  96. package/src/generated/control.ts +2419 -0
  97. package/src/generated/dcb.ts +3826 -0
  98. package/src/generated/event.ts +4076 -0
  99. package/src/generated/google/protobuf/empty.ts +84 -0
  100. package/src/generated/query.ts +2723 -0
  101. package/src/index.ts +75 -0
  102. package/src/message-size.ts +75 -0
  103. package/src/metadata-conversion.ts +46 -0
  104. package/src/outbound-stream.ts +52 -0
  105. package/src/platform-service.ts +361 -0
  106. package/src/shutdown-latch.ts +97 -0
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ export {
2
+ type AxonServerConnectionConfig,
3
+ type AxonServerConnection,
4
+ connectToAxonServer,
5
+ } from "./connection.js"
6
+
7
+ export {
8
+ type AxonServerConnectionManager,
9
+ createConnectionManager,
10
+ } from "./connection-manager.js"
11
+
12
+ export {
13
+ createAxonServerEventStore,
14
+ } from "./axon-server-event-store.js"
15
+
16
+ export {
17
+ createAxonServerSnapshotStore,
18
+ } from "./axon-server-snapshot-store.js"
19
+
20
+ export {
21
+ axonServer,
22
+ type AxonServerExtensionConfig,
23
+ type FlowControlConfig,
24
+ type ProcessingInstructions,
25
+ } from "./axon-server.js"
26
+
27
+ export {
28
+ type MessageSizeConfig,
29
+ MessageSizeExceededError,
30
+ createMessageSizeValidator,
31
+ } from "./message-size.js"
32
+
33
+ export {
34
+ type ProcessorStatus,
35
+ type SegmentStatus,
36
+ type ProcessorStatusSupplier,
37
+ toEventProcessorInfo,
38
+ } from "./event-processor-info.js"
39
+
40
+ export {
41
+ type PlatformConnection,
42
+ type PlatformInstruction,
43
+ type InstructionHandler,
44
+ type PlatformServiceOptions,
45
+ createPlatformConnection,
46
+ } from "./platform-service.js"
47
+
48
+ export {
49
+ type FlowControlledSender,
50
+ createFlowControlledSender,
51
+ } from "./flow-controlled-sender.js"
52
+
53
+ export {
54
+ type ShutdownLatch,
55
+ type ActivityHandle,
56
+ ShutdownInProgressError,
57
+ createShutdownLatch,
58
+ } from "./shutdown-latch.js"
59
+
60
+ export {
61
+ AxonServerErrorCode,
62
+ type AxonServerErrorCodeValue,
63
+ AxonServerError,
64
+ NoHandlerForCommandError,
65
+ NoHandlerForQueryError,
66
+ CommandExecutionError,
67
+ QueryExecutionError,
68
+ CommandDispatchError,
69
+ QueryDispatchError,
70
+ ConcurrencyError,
71
+ ConnectionFailedError,
72
+ AuthenticationError,
73
+ mapErrorCode,
74
+ isTransientError,
75
+ } from "./errors.js"
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Message size validation for Axon Server gRPC communication.
3
+ *
4
+ * Axon Server has a maximum inbound message size (default: 4MB).
5
+ * This module pre-checks message sizes before sending and warns
6
+ * when approaching the limit.
7
+ */
8
+
9
+ /** Default max message size in bytes (4MB — Axon Server default). */
10
+ const DEFAULT_MAX_MESSAGE_SIZE = 4 * 1024 * 1024
11
+
12
+ /** Warning threshold as a fraction of max size. */
13
+ const WARNING_THRESHOLD = 0.75
14
+
15
+ export interface MessageSizeConfig {
16
+ /** Maximum message size in bytes. Default: 4MB */
17
+ maxMessageSize?: number
18
+ /** Warning threshold as a fraction (0-1). Default: 0.75 */
19
+ warningThreshold?: number
20
+ }
21
+
22
+ export class MessageSizeExceededError extends Error {
23
+ readonly actualSize: number
24
+ readonly maxSize: number
25
+
26
+ constructor(actualSize: number, maxSize: number) {
27
+ super(
28
+ `Message size ${actualSize} bytes exceeds maximum ${maxSize} bytes`,
29
+ )
30
+ this.name = "MessageSizeExceededError"
31
+ this.actualSize = actualSize
32
+ this.maxSize = maxSize
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Creates a message size validator.
38
+ *
39
+ * - `validate(data)` — throws if over limit, warns if over 75%
40
+ * - `estimateSize(payload)` — quick byte size estimate
41
+ */
42
+ export function createMessageSizeValidator(config?: MessageSizeConfig) {
43
+ const maxSize = config?.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE
44
+ const threshold = config?.warningThreshold ?? WARNING_THRESHOLD
45
+ const warningSize = Math.floor(maxSize * threshold)
46
+
47
+ return {
48
+ /**
49
+ * Validate a serialized message size.
50
+ * Throws if over the limit, logs a warning if over the threshold.
51
+ */
52
+ validate(data: Uint8Array, context?: string): void {
53
+ const size = data.byteLength
54
+ if (size > maxSize) {
55
+ throw new MessageSizeExceededError(size, maxSize)
56
+ }
57
+ if (size > warningSize) {
58
+ console.warn(
59
+ `Message size warning${context ? ` (${context})` : ""}: ` +
60
+ `${size} bytes is ${Math.round((size / maxSize) * 100)}% of max ${maxSize} bytes`,
61
+ )
62
+ }
63
+ },
64
+
65
+ /**
66
+ * Estimate the serialized size of a payload (quick JSON length check).
67
+ */
68
+ estimateSize(payload: unknown): number {
69
+ return new TextEncoder().encode(JSON.stringify(payload)).byteLength
70
+ },
71
+
72
+ /** The configured maximum size in bytes. */
73
+ maxSize,
74
+ }
75
+ }
@@ -0,0 +1,46 @@
1
+ import type { Metadata } from "@kronos-ts/common"
2
+ import type { MetaDataValue } from "./generated/common.js"
3
+
4
+ /**
5
+ * Convert framework metadata to Axon Server proto metadata format.
6
+ */
7
+ export function metadataToProto(metadata: Metadata): Record<string, MetaDataValue> {
8
+ const result: Record<string, MetaDataValue> = {}
9
+ for (const [key, value] of Object.entries(metadata)) {
10
+ if (typeof value === "string") {
11
+ result[key] = { textValue: value }
12
+ } else if (typeof value === "number") {
13
+ if (Number.isInteger(value)) {
14
+ result[key] = { numberValue: BigInt(value) }
15
+ } else {
16
+ result[key] = { doubleValue: value }
17
+ }
18
+ } else if (typeof value === "boolean") {
19
+ result[key] = { booleanValue: value }
20
+ } else if (typeof value === "bigint") {
21
+ result[key] = { numberValue: value }
22
+ } else if (value !== undefined && value !== null) {
23
+ result[key] = { textValue: JSON.stringify(value) }
24
+ }
25
+ }
26
+ return result
27
+ }
28
+
29
+ /**
30
+ * Convert Axon Server proto metadata format to framework metadata.
31
+ */
32
+ export function metadataFromProto(protoMeta: Record<string, MetaDataValue>): Metadata {
33
+ const result: Record<string, unknown> = {}
34
+ for (const [key, value] of Object.entries(protoMeta)) {
35
+ if (value.textValue !== undefined) {
36
+ result[key] = value.textValue
37
+ } else if (value.numberValue !== undefined) {
38
+ result[key] = Number(value.numberValue)
39
+ } else if (value.booleanValue !== undefined) {
40
+ result[key] = value.booleanValue
41
+ } else if (value.doubleValue !== undefined) {
42
+ result[key] = value.doubleValue
43
+ }
44
+ }
45
+ return result
46
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A queue-backed async iterable for feeding outbound messages to a
3
+ * bidirectional gRPC stream. Buffers messages when the stream isn't
4
+ * consuming, and resolves promises when the stream is waiting.
5
+ */
6
+ export interface OutboundStream<T> {
7
+ /** Send a message into the stream. */
8
+ send(message: T): void
9
+ /** The async iterable to pass to the gRPC client. */
10
+ readonly iterable: AsyncIterable<T>
11
+ /** Close the stream. */
12
+ close(): void
13
+ }
14
+
15
+ export function createOutboundStream<T>(): OutboundStream<T> {
16
+ let resolve: ((value: IteratorResult<T>) => void) | null = null
17
+ const queue: T[] = []
18
+ let closed = false
19
+
20
+ return {
21
+ send(message: T) {
22
+ if (resolve) {
23
+ const r = resolve
24
+ resolve = null
25
+ r({ value: message, done: false })
26
+ } else {
27
+ queue.push(message)
28
+ }
29
+ },
30
+
31
+ iterable: {
32
+ [Symbol.asyncIterator]() {
33
+ return {
34
+ next(): Promise<IteratorResult<T>> {
35
+ const queued = queue.shift()
36
+ if (queued) return Promise.resolve({ value: queued, done: false })
37
+ if (closed) return Promise.resolve({ value: undefined as any, done: true })
38
+ return new Promise((r) => { resolve = r })
39
+ },
40
+ }
41
+ },
42
+ },
43
+
44
+ close() {
45
+ closed = true
46
+ if (resolve) {
47
+ resolve({ value: undefined as any, done: true })
48
+ resolve = null
49
+ }
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,361 @@
1
+ import type { AxonServerConnection } from "./connection.js"
2
+ import { createOutboundStream } from "./outbound-stream.js"
3
+ import type { PlatformInboundInstruction } from "./generated/control.js"
4
+ import { Metadata } from "nice-grpc"
5
+ import type { ProcessorStatusSupplier } from "./event-processor-info.js"
6
+ import { toEventProcessorInfo } from "./event-processor-info.js"
7
+
8
+ /**
9
+ * Server-initiated instructions received via the platform stream.
10
+ * Axon Server sends these to control processor behavior.
11
+ */
12
+ export type PlatformInstruction =
13
+ | { kind: "pause-processor"; processorName: string }
14
+ | { kind: "start-processor"; processorName: string }
15
+ | { kind: "release-segment"; processorName: string; segmentId: number }
16
+ | { kind: "split-segment"; processorName: string; segmentId: number }
17
+ | { kind: "merge-segment"; processorName: string; segmentId: number }
18
+ | { kind: "command-handler-added"; componentName: string; commandName: string }
19
+ | { kind: "command-handler-removed"; componentName: string; commandName: string }
20
+ | { kind: "query-handler-added"; componentName: string; queryName: string }
21
+ | { kind: "query-handler-removed"; componentName: string; queryName: string }
22
+ | { kind: "reconnect-request" }
23
+
24
+ /**
25
+ * Callback for handling platform instructions from Axon Server.
26
+ */
27
+ export type InstructionHandler = (instruction: PlatformInstruction) => void | Promise<void>
28
+
29
+ /**
30
+ * Manages the PlatformService connection to Axon Server.
31
+ *
32
+ * Responsibilities:
33
+ * - Node discovery via `getPlatformServer()`
34
+ * - Bidirectional stream for topology management (`openStream()`)
35
+ * - Heartbeat protocol to detect dead connections
36
+ * - Receives server-initiated instructions (pause, resume, split, merge segments)
37
+ */
38
+ export interface PlatformConnection {
39
+ /** Start the platform stream (register with Axon Server, begin heartbeats). */
40
+ start(): Promise<void>
41
+ /** Stop the platform stream and heartbeats. */
42
+ stop(): void
43
+ /** Register a handler for server-initiated instructions. */
44
+ onInstruction(handler: InstructionHandler): void
45
+ /**
46
+ * Register a supplier that provides event processor status.
47
+ * Status is reported to Axon Server periodically.
48
+ */
49
+ registerProcessorStatusSupplier(supplier: ProcessorStatusSupplier): void
50
+ /** Whether the platform stream is active. */
51
+ readonly connected: boolean
52
+ /**
53
+ * Resolves with `true` once Axon Server has acknowledged this client's
54
+ * registration on the platform stream (we use the first server-originated
55
+ * inbound message — typically a heartbeat reply or platform instruction —
56
+ * as the ack signal). Resolves with `false` if the platform is not yet
57
+ * started.
58
+ *
59
+ * This mirrors the kronosdb implementation (Plan 09-03 / D-102): the
60
+ * native `axonServer` extension's `onStart('processors', ...)` hook polls
61
+ * this method via `withRetry` so the application waits exactly long enough
62
+ * for handler subscriptions to be routable, no longer or shorter — the
63
+ * Axon equivalent of dropping the legacy 1-second sleep.
64
+ *
65
+ * Implementation note: while Axon Server's outbound stream maintains a
66
+ * `pendingSubscriptions` map (per RESEARCH.md), the `register` frame is
67
+ * sent on the *platform* stream (separate from the bus streams) and Axon
68
+ * Server replies with a heartbeat tick / topology message at the
69
+ * configured interval. We treat the first inbound platform-stream frame
70
+ * as the ack signal — same observable derivation as kronosdb to keep the
71
+ * two extensions structurally symmetric.
72
+ */
73
+ subscriptionsAcked(): Promise<boolean>
74
+ }
75
+
76
+ export interface PlatformServiceOptions {
77
+ /** Heartbeat interval in ms. Default: 10000 */
78
+ heartbeatIntervalMs?: number
79
+ /** Heartbeat timeout in ms. If no response within this window, reconnect. Default: 7500 */
80
+ heartbeatTimeoutMs?: number
81
+ /**
82
+ * Interval for reporting event processor status to Axon Server in ms.
83
+ * Aligned with Java's `processorsNotificationRate`. Default: 500.
84
+ */
85
+ processorsNotificationRateMs?: number
86
+ /**
87
+ * Delay before first processor status report in ms.
88
+ * Aligned with Java's `processorsNotificationInitialDelay`. Default: 5000.
89
+ */
90
+ processorsNotificationInitialDelayMs?: number
91
+ }
92
+
93
+ /**
94
+ * Creates a PlatformService connection to Axon Server.
95
+ *
96
+ * The platform stream is the control plane — it:
97
+ * 1. Registers this client with Axon Server
98
+ * 2. Sends periodic heartbeats to verify connectivity
99
+ * 3. Receives instructions from Axon Server (split, merge, pause, resume)
100
+ */
101
+ export function createPlatformConnection(
102
+ connection: AxonServerConnection,
103
+ options?: PlatformServiceOptions,
104
+ ): PlatformConnection {
105
+ const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 10000
106
+ const heartbeatTimeoutMs = options?.heartbeatTimeoutMs ?? 7500
107
+ const processorsNotificationRateMs = options?.processorsNotificationRateMs ?? 500
108
+ const processorsNotificationInitialDelayMs = options?.processorsNotificationInitialDelayMs ?? 5000
109
+
110
+ const instructionHandlers: InstructionHandler[] = []
111
+ const processorStatusSuppliers: ProcessorStatusSupplier[] = []
112
+ let isConnected = false
113
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null
114
+ let heartbeatTimeoutTimer: ReturnType<typeof setTimeout> | null = null
115
+ let processorStatusTimer: ReturnType<typeof setInterval> | null = null
116
+ let lastHeartbeatResponse = Date.now()
117
+ let outbound: ReturnType<typeof createOutboundStream<PlatformInboundInstruction>> | null = null
118
+ /**
119
+ * Latches once Axon Server sends its first inbound message after
120
+ * registration — the earliest observable signal that the platform stream
121
+ * is fully wired (mirror of kronosdb Plan 09-03 / D-102, replaces the
122
+ * legacy 1-second sleep). Reset on every `start()` so a stop/start cycle
123
+ * re-arms the latch correctly.
124
+ */
125
+ let acked = false
126
+
127
+ const grpcMetadata = new Metadata()
128
+ grpcMetadata.set("AxonIQ-Context", connection.config.context)
129
+ if (connection.config.token) {
130
+ grpcMetadata.set("AxonIQ-Access-Token", connection.config.token)
131
+ }
132
+
133
+ async function processInboundInstructions(inbound: AsyncIterable<any>) {
134
+ try {
135
+ for await (const message of inbound) {
136
+ // First inbound message after start() = the platform has accepted our
137
+ // registration and is talking back. Latch the ack flag (mirror of
138
+ // kronosdb Plan 09-03 / D-102 — replaces the legacy 1s sleep).
139
+ acked = true
140
+ // Parse instruction type
141
+ const instruction = parseInstruction(message)
142
+ if (instruction) {
143
+ for (const handler of instructionHandlers) {
144
+ try {
145
+ await handler(instruction)
146
+ } catch (err) {
147
+ console.error("Platform instruction handler error:", err)
148
+ }
149
+ }
150
+ }
151
+
152
+ // Handle heartbeat response — track last response time for timeout detection
153
+ if (message.heartbeat) {
154
+ lastHeartbeatResponse = Date.now()
155
+ }
156
+ }
157
+ } catch (err) {
158
+ if (isConnected) {
159
+ console.error("Platform stream error:", err)
160
+ isConnected = false
161
+ }
162
+ }
163
+ }
164
+
165
+ function parseInstruction(message: any): PlatformInstruction | null {
166
+ if (message.requestReconnect) {
167
+ return { kind: "reconnect-request" }
168
+ }
169
+
170
+ // Processor control instructions
171
+ const ctrl = message.eventProcessorControl
172
+ if (ctrl) {
173
+ const processorName = ctrl.processorName ?? ""
174
+ if (ctrl.pauseEventProcessor) {
175
+ return { kind: "pause-processor", processorName }
176
+ }
177
+ if (ctrl.startEventProcessor) {
178
+ return { kind: "start-processor", processorName }
179
+ }
180
+ if (ctrl.releaseSegment !== undefined) {
181
+ return { kind: "release-segment", processorName, segmentId: ctrl.releaseSegment.segmentId ?? 0 }
182
+ }
183
+ if (ctrl.splitEventProcessor !== undefined) {
184
+ return { kind: "split-segment", processorName, segmentId: ctrl.splitEventProcessor.segmentId ?? 0 }
185
+ }
186
+ if (ctrl.mergeEventProcessor !== undefined) {
187
+ return { kind: "merge-segment", processorName, segmentId: ctrl.mergeEventProcessor.segmentId ?? 0 }
188
+ }
189
+ }
190
+
191
+ // Topology change instructions
192
+ const topo = message.topologyChange
193
+ if (topo) {
194
+ const componentName = topo.componentName ?? ""
195
+ if (topo.commandHandlerAdded) {
196
+ return { kind: "command-handler-added", componentName, commandName: topo.commandHandlerAdded.commandName ?? "" }
197
+ }
198
+ if (topo.commandHandlerRemoved) {
199
+ return { kind: "command-handler-removed", componentName, commandName: topo.commandHandlerRemoved.commandName ?? "" }
200
+ }
201
+ if (topo.queryHandlerAdded) {
202
+ return { kind: "query-handler-added", componentName, queryName: topo.queryHandlerAdded.queryName ?? "" }
203
+ }
204
+ if (topo.queryHandlerRemoved) {
205
+ return { kind: "query-handler-removed", componentName, queryName: topo.queryHandlerRemoved.queryName ?? "" }
206
+ }
207
+ }
208
+
209
+ return null
210
+ }
211
+
212
+ function startHeartbeat() {
213
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
214
+ lastHeartbeatResponse = Date.now()
215
+
216
+ heartbeatTimer = setInterval(() => {
217
+ if (!isConnected || !outbound) return
218
+
219
+ // Check if last heartbeat response was too long ago
220
+ const timeSinceLastResponse = Date.now() - lastHeartbeatResponse
221
+ if (timeSinceLastResponse > heartbeatTimeoutMs) {
222
+ console.warn(
223
+ `Platform heartbeat timeout: no response in ${timeSinceLastResponse}ms ` +
224
+ `(threshold: ${heartbeatTimeoutMs}ms). Marking connection as lost.`,
225
+ )
226
+ isConnected = false
227
+ connection.reconnect().catch((err) => {
228
+ console.error("Failed to reconnect after heartbeat timeout:", err)
229
+ })
230
+ return
231
+ }
232
+
233
+ outbound.send({
234
+ heartbeat: {
235
+ clientId: connection.config.clientId,
236
+ },
237
+ instructionId: "",
238
+ })
239
+ }, heartbeatIntervalMs)
240
+ }
241
+
242
+ function startProcessorStatusReporting() {
243
+ if (processorStatusTimer) clearInterval(processorStatusTimer)
244
+
245
+ // Initial delay before first report
246
+ setTimeout(() => {
247
+ if (!isConnected) return
248
+ reportProcessorStatus()
249
+
250
+ // Then report at the configured rate
251
+ processorStatusTimer = setInterval(() => {
252
+ if (!isConnected || !outbound) return
253
+ reportProcessorStatus()
254
+ }, processorsNotificationRateMs)
255
+ }, processorsNotificationInitialDelayMs)
256
+ }
257
+
258
+ function reportProcessorStatus() {
259
+ if (!outbound || processorStatusSuppliers.length === 0) return
260
+
261
+ for (const supplier of processorStatusSuppliers) {
262
+ try {
263
+ const statuses = supplier()
264
+ for (const status of statuses) {
265
+ outbound.send({
266
+ eventProcessorInfo: toEventProcessorInfo(status),
267
+ instructionId: "",
268
+ })
269
+ }
270
+ } catch (err) {
271
+ console.warn("Failed to report processor status:", err)
272
+ }
273
+ }
274
+ }
275
+
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>()
283
+
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
+ })
294
+
295
+ // Open bidirectional stream
296
+ const inbound = connection.platform.openStream(outbound.iterable, {
297
+ metadata: grpcMetadata,
298
+ })
299
+
300
+ isConnected = true
301
+ startHeartbeat()
302
+ 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
+ },
324
+
325
+ stop() {
326
+ isConnected = false
327
+ if (heartbeatTimer) {
328
+ clearInterval(heartbeatTimer)
329
+ heartbeatTimer = null
330
+ }
331
+ if (heartbeatTimeoutTimer) {
332
+ clearTimeout(heartbeatTimeoutTimer)
333
+ heartbeatTimeoutTimer = null
334
+ }
335
+ if (processorStatusTimer) {
336
+ clearInterval(processorStatusTimer)
337
+ processorStatusTimer = null
338
+ }
339
+ if (outbound) {
340
+ outbound.close()
341
+ outbound = null
342
+ }
343
+ },
344
+
345
+ onInstruction(handler) {
346
+ instructionHandlers.push(handler)
347
+ },
348
+
349
+ registerProcessorStatusSupplier(supplier) {
350
+ processorStatusSuppliers.push(supplier)
351
+ },
352
+
353
+ get connected() {
354
+ return isConnected
355
+ },
356
+
357
+ async subscriptionsAcked(): Promise<boolean> {
358
+ return isConnected && acked
359
+ },
360
+ }
361
+ }