@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.
- package/dist/amqp-command-transport.d.ts +8 -9
- package/dist/amqp-command-transport.d.ts.map +1 -1
- package/dist/amqp-command-transport.js +9 -11
- package/dist/amqp-command-transport.js.map +1 -1
- package/dist/amqp-query-transport.d.ts +38 -0
- package/dist/amqp-query-transport.d.ts.map +1 -0
- package/dist/amqp-query-transport.js +174 -0
- package/dist/amqp-query-transport.js.map +1 -0
- package/dist/connection.d.ts +26 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +28 -0
- package/dist/connection.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/query-bus.d.ts +38 -0
- package/dist/query-bus.d.ts.map +1 -0
- package/dist/query-bus.js +80 -0
- package/dist/query-bus.js.map +1 -0
- package/dist/rabbitmq.d.ts +14 -3
- package/dist/rabbitmq.d.ts.map +1 -1
- package/dist/rabbitmq.js +23 -4
- package/dist/rabbitmq.js.map +1 -1
- package/dist/topology.d.ts +6 -1
- package/dist/topology.d.ts.map +1 -1
- package/dist/topology.js +12 -1
- package/dist/topology.js.map +1 -1
- package/package.json +2 -2
- package/src/amqp-command-transport.ts +10 -23
- package/src/amqp-query-transport.ts +215 -0
- package/src/connection.ts +50 -0
- package/src/index.ts +16 -0
- package/src/query-bus.ts +137 -0
- package/src/rabbitmq.ts +37 -4
- package/src/topology.ts +18 -2
package/src/query-bus.ts
ADDED
|
@@ -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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|