@kronos-ts/rabbitmq 0.1.0 → 0.2.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.
@@ -0,0 +1,137 @@
1
+ import type { QueryBus, QueryMessage, SubscriptionQueryResult } from "@kronos-ts/messaging"
2
+ import { qualifiedNameToString } from "@kronos-ts/common"
3
+ import { runInNewUoW } from "@kronos-ts/messaging"
4
+ import type { RabbitMqResolvedConfig } from "./rabbitmq.js"
5
+
6
+ export interface RabbitMqQueryEnvelope {
7
+ readonly kind: "query"
8
+ readonly requestId: string
9
+ readonly message: QueryMessage
10
+ readonly timeoutMs: number
11
+ }
12
+
13
+ export interface RabbitMqQueryReplyEnvelope {
14
+ readonly requestId: string
15
+ readonly ok: boolean
16
+ readonly result?: unknown
17
+ readonly error?: {
18
+ readonly name?: string
19
+ readonly message: string
20
+ readonly stack?: string
21
+ }
22
+ }
23
+
24
+ export interface RabbitMqQueryTransport {
25
+ dispatch(envelope: RabbitMqQueryEnvelope): Promise<RabbitMqQueryReplyEnvelope>
26
+ subscribe(
27
+ queryName: string,
28
+ handler: (envelope: RabbitMqQueryEnvelope) => Promise<RabbitMqQueryReplyEnvelope>,
29
+ ): void | Promise<void>
30
+ }
31
+
32
+ export interface RabbitMqQueryBusOptions {
33
+ readonly localSegment: QueryBus
34
+ readonly transport: RabbitMqQueryTransport
35
+ readonly config: RabbitMqResolvedConfig
36
+ }
37
+
38
+ /**
39
+ * Distributed query bus decorator.
40
+ *
41
+ * Direct request/reply queries (`query` + `subscribe`) route over RabbitMQ.
42
+ * Subscription queries (`subscriptionQuery`, `subscribeToUpdates`, `emitUpdate`,
43
+ * `completeSubscription*`) remain process-local and delegate to the local
44
+ * segment — distributing subscription-query update streams over RabbitMQ is
45
+ * intentionally out of scope for this version (see rabbitmq-extension-plan.md).
46
+ */
47
+ export function createRabbitMqQueryBus(options: RabbitMqQueryBusOptions): QueryBus {
48
+ const localHandlers = new Set<string>()
49
+ const { localSegment, transport, config } = options
50
+
51
+ return {
52
+ async query(message: QueryMessage): Promise<unknown> {
53
+ const queryName = qualifiedNameToString(message.name)
54
+ const preferLocal = config.queries.preferLocalHandlers && !config.queries.alwaysUseDistributedBus
55
+ if (preferLocal && localHandlers.has(queryName)) {
56
+ return localSegment.query(message)
57
+ }
58
+
59
+ const envelope: RabbitMqQueryEnvelope = {
60
+ kind: "query",
61
+ requestId: message.identifier,
62
+ message,
63
+ timeoutMs: config.queries.defaultTimeoutMs,
64
+ }
65
+ const reply = await transport.dispatch(envelope)
66
+ if (!reply.ok) throw deserializeRemoteError(reply.error)
67
+ return reply.result
68
+ },
69
+
70
+ subscribe(queryName: string, handler: (message: QueryMessage) => Promise<unknown>): void {
71
+ localHandlers.add(queryName)
72
+ localSegment.subscribe(queryName, handler)
73
+ void transport.subscribe(queryName, async (envelope) => {
74
+ try {
75
+ // AF5 parity: an inbound distributed query is handled in its own
76
+ // fresh UnitOfWork. Correlation/causation lineage rides on the query
77
+ // message metadata, which crosses the wire intact.
78
+ const result = await runInNewUoW(envelope.message.metadata, () =>
79
+ handler(envelope.message),
80
+ )
81
+ return { requestId: envelope.requestId, ok: true, result }
82
+ } catch (error) {
83
+ return { requestId: envelope.requestId, ok: false, error: serializeError(error) }
84
+ }
85
+ })
86
+ },
87
+
88
+ // Subscription queries stay process-local — see the doc comment above.
89
+ subscriptionQuery(message: QueryMessage, bufferSize?: number): SubscriptionQueryResult {
90
+ return localSegment.subscriptionQuery(message, bufferSize)
91
+ },
92
+
93
+ subscribeToUpdates(
94
+ message: QueryMessage,
95
+ bufferSize?: number,
96
+ ): AsyncIterable<unknown> & { close(): void } {
97
+ return localSegment.subscribeToUpdates(message, bufferSize)
98
+ },
99
+
100
+ emitUpdate(
101
+ queryName: string,
102
+ filter: (queryPayload: unknown) => boolean,
103
+ update: unknown,
104
+ ): Promise<void> {
105
+ return localSegment.emitUpdate(queryName, filter, update)
106
+ },
107
+
108
+ completeSubscription(
109
+ queryName: string,
110
+ filter?: (queryPayload: unknown) => boolean,
111
+ ): Promise<void> {
112
+ return localSegment.completeSubscription(queryName, filter)
113
+ },
114
+
115
+ completeSubscriptionExceptionally(
116
+ queryName: string,
117
+ error: Error,
118
+ filter?: (queryPayload: unknown) => boolean,
119
+ ): Promise<void> {
120
+ return localSegment.completeSubscriptionExceptionally(queryName, error, filter)
121
+ },
122
+ }
123
+ }
124
+
125
+ function serializeError(error: unknown): RabbitMqQueryReplyEnvelope["error"] {
126
+ if (error instanceof Error) {
127
+ return { name: error.name, message: error.message, stack: error.stack }
128
+ }
129
+ return { message: String(error) }
130
+ }
131
+
132
+ function deserializeRemoteError(error: RabbitMqQueryReplyEnvelope["error"]): Error {
133
+ const result = new Error(error?.message ?? "Remote query handling failed")
134
+ result.name = error?.name ?? "RemoteQueryHandlingError"
135
+ if (error?.stack) result.stack = error.stack
136
+ return result
137
+ }
package/src/rabbitmq.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { App, KronosIdentity } from "@kronos-ts/app"
2
2
  import { createRabbitMqTopologyNames, type RabbitMqTopologyConfig } from "./topology.js"
3
3
  import { createRabbitMqCommandBus } from "./command-bus.js"
4
+ import { createRabbitMqQueryBus } from "./query-bus.js"
4
5
  import { AmqpRabbitMqCommandTransport } from "./amqp-command-transport.js"
6
+ import { AmqpRabbitMqQueryTransport } from "./amqp-query-transport.js"
7
+ import { createAmqpConnection } from "./connection.js"
5
8
 
6
9
  export interface RabbitMqCommandDispatchConfig {
7
10
  /** Prefer local handlers when registered; otherwise route through RabbitMQ. Default: true. */
@@ -12,6 +15,15 @@ export interface RabbitMqCommandDispatchConfig {
12
15
  readonly defaultTimeoutMs?: number
13
16
  }
14
17
 
18
+ export interface RabbitMqQueryDispatchConfig {
19
+ /** Prefer local handlers when registered; otherwise route through RabbitMQ. Default: true. */
20
+ readonly preferLocalHandlers?: boolean
21
+ /** Force all query dispatch through RabbitMQ, even when a local handler exists. */
22
+ readonly alwaysUseDistributedBus?: boolean
23
+ /** Default request/reply timeout for query dispatch. Default: 30000. */
24
+ readonly defaultTimeoutMs?: number
25
+ }
26
+
15
27
  export interface RabbitMqRetryConfig {
16
28
  /** Dead-letter failed command messages instead of silently dropping them. Default: true. */
17
29
  readonly deadLetter?: boolean
@@ -23,6 +35,7 @@ export interface RabbitMqExtensionConfig {
23
35
  readonly url: string
24
36
  readonly topology?: RabbitMqTopologyConfig
25
37
  readonly commands?: RabbitMqCommandDispatchConfig
38
+ readonly queries?: RabbitMqQueryDispatchConfig
26
39
  readonly retry?: RabbitMqRetryConfig
27
40
  }
28
41
 
@@ -31,6 +44,7 @@ export interface RabbitMqResolvedConfig {
31
44
  readonly url: string
32
45
  readonly topology: ReturnType<typeof createRabbitMqTopologyNames>
33
46
  readonly commands: Required<RabbitMqCommandDispatchConfig>
47
+ readonly queries: Required<RabbitMqQueryDispatchConfig>
34
48
  readonly retry: Required<RabbitMqRetryConfig>
35
49
  }
36
50
 
@@ -44,6 +58,11 @@ export function resolveRabbitMqConfig(app: App, config: RabbitMqExtensionConfig)
44
58
  alwaysUseDistributedBus: config.commands?.alwaysUseDistributedBus ?? false,
45
59
  defaultTimeoutMs: config.commands?.defaultTimeoutMs ?? 30_000,
46
60
  },
61
+ queries: {
62
+ preferLocalHandlers: config.queries?.preferLocalHandlers ?? true,
63
+ alwaysUseDistributedBus: config.queries?.alwaysUseDistributedBus ?? false,
64
+ defaultTimeoutMs: config.queries?.defaultTimeoutMs ?? 30_000,
65
+ },
47
66
  retry: {
48
67
  deadLetter: config.retry?.deadLetter ?? true,
49
68
  deadLetterExchange: config.retry?.deadLetterExchange ?? `${config.topology?.prefix ?? "kronos"}.dlx`,
@@ -54,14 +73,17 @@ export function resolveRabbitMqConfig(app: App, config: RabbitMqExtensionConfig)
54
73
  /**
55
74
  * RabbitMQ distributed messaging extension.
56
75
  *
57
- * Command transport implementation is intentionally staged. The first committed
58
- * surface resolves app-level identity/topology and reserves the extension entry
59
- * point; the next step wires the command bus decorator around this config.
76
+ * Wraps the command and query buses with RabbitMQ-backed request/reply
77
+ * transports. Both transports share a single broker connection, each taking
78
+ * its own channel. Subscription queries remain process-local distributing
79
+ * their update streams is out of scope for this version.
60
80
  */
61
81
  export function rabbitMq(config: RabbitMqExtensionConfig): (app: App) => void {
62
82
  return (app) => {
63
83
  const resolved = resolveRabbitMqConfig(app, config)
64
- const commandTransport = new AmqpRabbitMqCommandTransport(resolved)
84
+ const connection = createAmqpConnection(resolved.url)
85
+ const commandTransport = new AmqpRabbitMqCommandTransport(resolved, connection)
86
+ const queryTransport = new AmqpRabbitMqQueryTransport(resolved, connection)
65
87
 
66
88
  app.decorate("commandBus", (localSegment) =>
67
89
  createRabbitMqCommandBus({
@@ -71,7 +93,18 @@ export function rabbitMq(config: RabbitMqExtensionConfig): (app: App) => void {
71
93
  }),
72
94
  )
73
95
 
96
+ app.decorate("queryBus", (localSegment) =>
97
+ createRabbitMqQueryBus({
98
+ localSegment,
99
+ transport: queryTransport,
100
+ config: resolved,
101
+ }),
102
+ )
103
+
74
104
  app.onStart("connect", () => commandTransport.connect())
105
+ app.onStart("connect", () => queryTransport.connect())
75
106
  app.onStop("connect", () => commandTransport.close())
107
+ app.onStop("connect", () => queryTransport.close())
108
+ app.onStop("connect", () => connection.close())
76
109
  }
77
110
  }
package/src/topology.ts CHANGED
@@ -4,14 +4,19 @@ import type { KronosIdentity } from "@kronos-ts/app"
4
4
  export interface RabbitMqTopologyConfig {
5
5
  readonly prefix?: string
6
6
  readonly commandsExchange?: string
7
+ readonly queriesExchange?: string
7
8
  readonly durableQueues?: boolean
8
9
  }
9
10
 
10
11
  export interface RabbitMqTopologyNames {
11
12
  readonly commandsExchange: string
13
+ readonly queriesExchange: string
12
14
  commandRoutingKey(commandName: QualifiedName | string): string
13
15
  commandQueue(commandName: QualifiedName | string): string
14
- replyQueue(): string
16
+ queryRoutingKey(queryName: QualifiedName | string): string
17
+ queryQueue(queryName: QualifiedName | string): string
18
+ commandReplyQueue(): string
19
+ queryReplyQueue(): string
15
20
  }
16
21
 
17
22
  export function createRabbitMqTopologyNames(
@@ -22,18 +27,29 @@ export function createRabbitMqTopologyNames(
22
27
  const service = sanitizeSegment(identity.serviceName)
23
28
  const instance = sanitizeSegment(identity.instanceId)
24
29
  const commandsExchange = config.commandsExchange ?? `${prefix}.commands`
30
+ const queriesExchange = config.queriesExchange ?? `${prefix}.queries`
25
31
 
26
32
  return {
27
33
  commandsExchange,
34
+ queriesExchange,
28
35
  commandRoutingKey(commandName) {
29
36
  return messageName(commandName)
30
37
  },
31
38
  commandQueue(commandName) {
32
39
  return `${prefix}.commands.${service}.${sanitizeMessageName(messageName(commandName))}`
33
40
  },
34
- replyQueue() {
41
+ queryRoutingKey(queryName) {
42
+ return messageName(queryName)
43
+ },
44
+ queryQueue(queryName) {
45
+ return `${prefix}.queries.${service}.${sanitizeMessageName(messageName(queryName))}`
46
+ },
47
+ commandReplyQueue() {
35
48
  return `${prefix}.replies.${service}.${instance}`
36
49
  },
50
+ queryReplyQueue() {
51
+ return `${prefix}.query-replies.${service}.${instance}`
52
+ },
37
53
  }
38
54
  }
39
55