@kronos-ts/axon-server 0.4.0 → 0.5.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/axon-server-event-store.d.ts +0 -16
- package/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +47 -14
- package/dist/axon-server-event-store.js.map +1 -1
- package/dist/axon-server-snapshotting-event-store.d.ts +30 -0
- package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -0
- package/dist/axon-server-snapshotting-event-store.js +153 -0
- package/dist/axon-server-snapshotting-event-store.js.map +1 -0
- package/dist/axon-server.d.ts +33 -60
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +58 -59
- package/dist/axon-server.js.map +1 -1
- package/dist/connection-manager.d.ts +2 -2
- package/dist/connection-manager.d.ts.map +1 -1
- package/dist/connection.d.ts +23 -21
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +6 -5
- package/dist/connection.js.map +1 -1
- package/dist/context-view.d.ts +2 -2
- package/dist/context-view.d.ts.map +1 -1
- package/dist/control-plane.d.ts +5 -5
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/event-processor-info.d.ts +4 -4
- package/dist/event-processor-info.d.ts.map +1 -1
- package/dist/flow-controlled-sender.d.ts +2 -2
- package/dist/flow-controlled-sender.d.ts.map +1 -1
- package/dist/generated/command.d.ts +20 -20
- package/dist/generated/command.d.ts.map +1 -1
- package/dist/generated/common.d.ts +20 -20
- package/dist/generated/common.d.ts.map +1 -1
- package/dist/generated/control.d.ts +36 -40
- package/dist/generated/control.d.ts.map +1 -1
- package/dist/generated/dcb.d.ts +88 -94
- package/dist/generated/dcb.d.ts.map +1 -1
- package/dist/generated/event.d.ts +81 -84
- package/dist/generated/event.d.ts.map +1 -1
- package/dist/generated/google/protobuf/empty.d.ts +3 -4
- package/dist/generated/google/protobuf/empty.d.ts.map +1 -1
- package/dist/generated/query.d.ts +40 -40
- package/dist/generated/query.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/message-size.d.ts +2 -2
- package/dist/message-size.d.ts.map +1 -1
- package/dist/outbound-stream.d.ts +2 -2
- package/dist/outbound-stream.d.ts.map +1 -1
- package/dist/platform-service.d.ts +4 -4
- package/dist/platform-service.d.ts.map +1 -1
- package/dist/resilience.d.ts +62 -0
- package/dist/resilience.d.ts.map +1 -0
- package/dist/resilience.js +103 -0
- package/dist/resilience.js.map +1 -0
- package/dist/shutdown-latch.d.ts +4 -4
- package/dist/shutdown-latch.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/axon-server-event-store.ts +46 -14
- package/src/axon-server-snapshotting-event-store.ts +191 -0
- package/src/axon-server.ts +72 -77
- package/src/connection-manager.ts +1 -1
- package/src/connection.ts +16 -14
- package/src/context-view.ts +1 -1
- package/src/control-plane.ts +3 -3
- package/src/event-processor-info.ts +2 -2
- package/src/flow-controlled-sender.ts +1 -1
- package/src/generated/command.ts +10 -10
- package/src/generated/common.ts +10 -10
- package/src/generated/control.ts +20 -20
- package/src/generated/dcb.ts +47 -47
- package/src/generated/event.ts +42 -42
- package/src/generated/google/protobuf/empty.ts +2 -2
- package/src/generated/query.ts +20 -20
- package/src/index.ts +1 -1
- package/src/message-size.ts +1 -1
- package/src/outbound-stream.ts +1 -1
- package/src/platform-service.ts +2 -2
- package/src/resilience.ts +149 -0
- package/src/shutdown-latch.ts +2 -2
- package/dist/axon-server-snapshot-store.d.ts +0 -12
- package/dist/axon-server-snapshot-store.d.ts.map +0 -1
- package/dist/axon-server-snapshot-store.js +0 -82
- package/dist/axon-server-snapshot-store.js.map +0 -1
- package/src/axon-server-snapshot-store.ts +0 -110
|
@@ -161,6 +161,19 @@ function createEventConverters(serializer: Serializer) {
|
|
|
161
161
|
* a single gRPC Stream RPC call that stays open indefinitely, aligned with
|
|
162
162
|
* Java's infinite {@code ResultStream}.
|
|
163
163
|
*/
|
|
164
|
+
/**
|
|
165
|
+
* Axon Server's way of saying "there is nothing at or after that position".
|
|
166
|
+
*
|
|
167
|
+
* Matched on the gRPC status code first — `OUT_OF_RANGE` is 11 — with the
|
|
168
|
+
* server's own message as a second, narrower gate, so an unrelated future
|
|
169
|
+
* OUT_OF_RANGE is not swallowed along with it.
|
|
170
|
+
*/
|
|
171
|
+
function isStartPastHead(err: unknown): boolean {
|
|
172
|
+
const code = (err as { code?: unknown }).code
|
|
173
|
+
const details = String((err as { details?: unknown }).details ?? "")
|
|
174
|
+
return code === 11 && details.includes("Start sequence cannot be larger than end sequence")
|
|
175
|
+
}
|
|
176
|
+
|
|
164
177
|
export function axonServerEventStore(conn: AxonServerStoreSource, context: string): EventStore {
|
|
165
178
|
const { connection, serializer, metadata: createAxonMetadata } = contextView(conn, context)
|
|
166
179
|
const { eventToProto, eventFromProto } = createEventConverters(serializer)
|
|
@@ -182,30 +195,49 @@ export function axonServerEventStore(conn: AxonServerStoreSource, context: strin
|
|
|
182
195
|
return {
|
|
183
196
|
async source(condition: SourcingCondition): Promise<SourcingResult> {
|
|
184
197
|
const criterions = criteriaToCriterions(compileQuery(condition.query))
|
|
198
|
+
const start = condition.start ?? 0n
|
|
185
199
|
|
|
186
200
|
const request = {
|
|
187
|
-
fromSequence:
|
|
201
|
+
fromSequence: start,
|
|
188
202
|
criterion: criterions,
|
|
189
203
|
}
|
|
190
204
|
|
|
191
205
|
const events: EventMessage[] = []
|
|
192
206
|
let marker: ConsistencyMarker = noMarker()
|
|
193
207
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
208
|
+
try {
|
|
209
|
+
const stream = connection.eventStore.source(request, { metadata: createAxonMetadata() })
|
|
210
|
+
for await (const response of stream) {
|
|
211
|
+
if (response.event) {
|
|
212
|
+
const taggedEvent = response.event
|
|
213
|
+
const protoEvent = taggedEvent.event
|
|
214
|
+
if (protoEvent) {
|
|
215
|
+
// DCB source/stream responses carry no tags — the server indexes
|
|
216
|
+
// them write-side but does not echo them back (SequencedEvent has
|
|
217
|
+
// only sequence + event). Reconstructed events get empty tags.
|
|
218
|
+
events.push(eventFromProto(protoEvent, []))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (response.consistencyMarker !== undefined) {
|
|
222
|
+
marker = markerAt(response.consistencyMarker)
|
|
204
223
|
}
|
|
205
224
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
225
|
+
} catch (err) {
|
|
226
|
+
// READING PAST THE HEAD IS AN EMPTY ANSWER, NOT AN ERROR — and Axon
|
|
227
|
+
// Server disagrees, so this is where the two vocabularies are
|
|
228
|
+
// reconciled. `Source` fails OUT_OF_RANGE with "Start sequence cannot
|
|
229
|
+
// be larger than end sequence" whenever `fromSequence` is beyond the
|
|
230
|
+
// global head, which is the ORDINARY STEADY STATE of a snapshotted
|
|
231
|
+
// load: an entry written at the head means the very next read resumes
|
|
232
|
+
// at head + 1 and legitimately finds nothing.
|
|
233
|
+
//
|
|
234
|
+
// The marker is `start - 1`, and it is exact rather than conservative:
|
|
235
|
+
// the caller has already accounted for everything up to there (that is
|
|
236
|
+
// what asking to start later MEANS), and nothing can exist after it or
|
|
237
|
+
// the server would not have refused. So an append conditioned on this
|
|
238
|
+
// read is checked against precisely the range that was read.
|
|
239
|
+
if (!isStartPastHead(err)) throw err
|
|
240
|
+
return { events: [], marker: start > 0n ? markerAt(start - 1n) : noMarker() }
|
|
209
241
|
}
|
|
210
242
|
|
|
211
243
|
return { events, marker }
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// AXON SERVER SNAPSHOTS — the capability tier, served by `DcbSnapshotStore`.
|
|
3
|
+
//
|
|
4
|
+
// THE PROBE, AND WHAT IT SETTLED. Axon Server's public API defines both a
|
|
5
|
+
// snapshot STORE service (`DcbSnapshotStore`: Add / Delete / List / GetLast)
|
|
6
|
+
// and, on newer API drops, a fused `SnapshottedDcbEventStore.Source` whose
|
|
7
|
+
// stream leads with the snapshot. Only the first of those is actually served:
|
|
8
|
+
//
|
|
9
|
+
// axoniq/axonserver:2025.2.5 DcbSnapshotStore/GetLast → answers
|
|
10
|
+
// SnapshottedDcbEventStore/Source → UNIMPLEMENTED
|
|
11
|
+
// axoniq/axonserver:2026.0.4 same, both ways
|
|
12
|
+
//
|
|
13
|
+
// So there is no server-side fusion to call here — there is nothing on the
|
|
14
|
+
// other end of the wire. The READ is therefore fused CLIENT-SIDE: `GetLast` for
|
|
15
|
+
// the entry, then a source after its position, assembled into the one
|
|
16
|
+
// `SourcingResult` a fold expects. Two round trips, the same answers, correct
|
|
17
|
+
// today.
|
|
18
|
+
//
|
|
19
|
+
// When a server version does serve `SnapshottedDcbEventStore.Source`, the fused
|
|
20
|
+
// call lands INSIDE this function — `source` stops making two calls and starts
|
|
21
|
+
// making one — and no host changes a line, because the capability was never a
|
|
22
|
+
// promise about round trips.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
EventStore,
|
|
27
|
+
Snapshot,
|
|
28
|
+
SnapshotCapability,
|
|
29
|
+
SourcingCondition,
|
|
30
|
+
SourcingResult,
|
|
31
|
+
} from "@kronos-ts/core"
|
|
32
|
+
import type { Serializer } from "@kronos-ts/core"
|
|
33
|
+
import { withoutSnapshotKey } from "@kronos-ts/core"
|
|
34
|
+
import type { AxonServerStoreSource } from "./connection.js"
|
|
35
|
+
import { contextView } from "./context-view.js"
|
|
36
|
+
import type { Snapshot as ProtoSnapshot } from "./generated/dcb.js"
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Conversion — framework Snapshot ↔ proto Snapshot
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const encoder = new TextEncoder()
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The proto message carries `name`, `version`, `timestamp` and a metadata map
|
|
46
|
+
* the narrowed seam has no equivalent for. `name` carries the cache key,
|
|
47
|
+
* `timestamp` is filled at write time for operators, and `version` stays EMPTY
|
|
48
|
+
* — snapshots have no versions, and inventing one to fill a proto field would
|
|
49
|
+
* be exactly the bookkeeping this design removed. The metadata map stays empty
|
|
50
|
+
* too: a cache entry carries no cargo.
|
|
51
|
+
*/
|
|
52
|
+
function createSnapshotConverters(serializer: Serializer) {
|
|
53
|
+
return {
|
|
54
|
+
snapshotToProto(name: string, snapshot: Snapshot): ProtoSnapshot {
|
|
55
|
+
const serialized = serializer.serialize(snapshot.state, name, "")
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
version: "",
|
|
59
|
+
payload: serialized.data,
|
|
60
|
+
timestamp: BigInt(Date.now()),
|
|
61
|
+
metadata: {},
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
snapshotFromProto(name: string, proto: ProtoSnapshot, position: bigint): Snapshot {
|
|
66
|
+
const state =
|
|
67
|
+
proto.payload.length > 0
|
|
68
|
+
? serializer.deserialize({ data: proto.payload, type: name, revision: "" })
|
|
69
|
+
: {}
|
|
70
|
+
|
|
71
|
+
return { state, position }
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function encodeKey(key: string): Uint8Array {
|
|
77
|
+
return encoder.encode(key)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Axon Server snapshot store
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Add the snapshotting capability to an Axon Server-backed log.
|
|
86
|
+
*
|
|
87
|
+
* ```ts
|
|
88
|
+
* const eventStore = axonServerSnapshottingEventStore(
|
|
89
|
+
* axonServerEventStore(axon, "default"),
|
|
90
|
+
* axon,
|
|
91
|
+
* "default",
|
|
92
|
+
* )
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* ADDITIVE, NOT COLLAPSING. It returns `E & SnapshotCapability` — the store you
|
|
96
|
+
* passed in, plus the write — so capabilities stack in either order and nothing
|
|
97
|
+
* the inner store carried is laundered on the way through.
|
|
98
|
+
*
|
|
99
|
+
* `context` is a per-call header, so this shares the one channel `conn` holds
|
|
100
|
+
* with every other context — see `contextView`. Payloads go through the
|
|
101
|
+
* connection's serializer.
|
|
102
|
+
*
|
|
103
|
+
* LATEST-ONLY, over a service that is not. `DcbSnapshotStore` keeps a sequence
|
|
104
|
+
* of snapshots per key; the capability keeps one. `add` therefore sets
|
|
105
|
+
* `prune: true`, so writing an entry retires the ones before it, and `getLast`
|
|
106
|
+
* is the only read. That is the narrowing doing its job: the framework programs
|
|
107
|
+
* against the cache it needs, not against everything the backend happens to
|
|
108
|
+
* offer.
|
|
109
|
+
*/
|
|
110
|
+
export function axonServerSnapshottingEventStore<E extends EventStore>(
|
|
111
|
+
next: E,
|
|
112
|
+
conn: AxonServerStoreSource,
|
|
113
|
+
context: string,
|
|
114
|
+
): E & SnapshotCapability {
|
|
115
|
+
const { connection, serializer, metadata: createAxonMetadata } = contextView(conn, context)
|
|
116
|
+
const { snapshotToProto, snapshotFromProto } = createSnapshotConverters(serializer)
|
|
117
|
+
|
|
118
|
+
/** The cached fold filed under `key`, or nothing — the first of the two calls. */
|
|
119
|
+
async function loadSnapshot(key: string): Promise<Snapshot | undefined> {
|
|
120
|
+
try {
|
|
121
|
+
const response = await connection.snapshotStore.getLast(
|
|
122
|
+
{ key: encodeKey(key) },
|
|
123
|
+
{ metadata: createAxonMetadata() },
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if (!response.snapshot) {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return snapshotFromProto(key, response.snapshot, response.sequence)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// Axon Server throws rather than answering empty when no snapshot
|
|
133
|
+
// exists — and a cache miss is not an error to anybody upstream.
|
|
134
|
+
if (
|
|
135
|
+
String(err).includes("No snapshot found") ||
|
|
136
|
+
String(err).includes("not found")
|
|
137
|
+
) {
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
throw err
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
...next,
|
|
146
|
+
|
|
147
|
+
async storeSnapshot(key: string, snapshot: Snapshot): Promise<void> {
|
|
148
|
+
await connection.snapshotStore.add(
|
|
149
|
+
{
|
|
150
|
+
key: encodeKey(key),
|
|
151
|
+
sequence: snapshot.position,
|
|
152
|
+
prune: true,
|
|
153
|
+
snapshot: snapshotToProto(key, snapshot),
|
|
154
|
+
},
|
|
155
|
+
{ metadata: createAxonMetadata() },
|
|
156
|
+
)
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* THE CLIENT-SIDE FUSION. Two calls, assembled into the one result a fold
|
|
161
|
+
* expects — and the cache is NEVER LOAD-BEARING, so a miss or an outright
|
|
162
|
+
* throw from the snapshot service both fall through to a full read.
|
|
163
|
+
*/
|
|
164
|
+
async source(condition: SourcingCondition): Promise<SourcingResult> {
|
|
165
|
+
const key = condition.snapshot
|
|
166
|
+
if (key === undefined) return next.source(condition)
|
|
167
|
+
|
|
168
|
+
// The strategy is CONSUMED here; the store below gets a plain condition.
|
|
169
|
+
const plain = withoutSnapshotKey(condition)
|
|
170
|
+
|
|
171
|
+
let snapshot: Snapshot | undefined
|
|
172
|
+
try {
|
|
173
|
+
snapshot = await loadSnapshot(key.key)
|
|
174
|
+
} catch {
|
|
175
|
+
// A cache you cannot reach is a cache miss. Loads stay correct; they
|
|
176
|
+
// just cost what they always cost.
|
|
177
|
+
return next.source(plain)
|
|
178
|
+
}
|
|
179
|
+
if (snapshot === undefined) return next.source(plain)
|
|
180
|
+
|
|
181
|
+
// Resume AFTER the position the snapshot already folded; a condition that
|
|
182
|
+
// independently asked to start later keeps its own floor.
|
|
183
|
+
const resumeFrom = snapshot.position + 1n
|
|
184
|
+
const start =
|
|
185
|
+
plain.start !== undefined && plain.start > resumeFrom ? plain.start : resumeFrom
|
|
186
|
+
|
|
187
|
+
const result = await next.source({ ...plain, start })
|
|
188
|
+
return { ...result, snapshot }
|
|
189
|
+
},
|
|
190
|
+
} as E & SnapshotCapability
|
|
191
|
+
}
|
package/src/axon-server.ts
CHANGED
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Axon Server is a SMART HUB: outbound dispatch always goes to the server, and
|
|
5
5
|
* the server decides which node handles it — there is no client-side
|
|
6
|
-
* prefer-
|
|
6
|
+
* prefer-next fork here, which is the whole difference from the dumb-pipe
|
|
7
7
|
* broker in `@kronos-ts/rabbitmq`.
|
|
8
8
|
*
|
|
9
|
-
* Both buses are plain functions over the shared connection and YOUR
|
|
9
|
+
* Both buses are plain functions over the shared connection and YOUR next bus:
|
|
10
10
|
*
|
|
11
11
|
* ```ts
|
|
12
12
|
* const commandBus = interceptingCommandBus(
|
|
13
|
-
* axonServerCommandBus(
|
|
13
|
+
* axonServerCommandBus(localCommandBus(unitOfWork), axon), correlation)
|
|
14
14
|
* const queryBus = interceptingQueryBus(
|
|
15
|
-
* axonServerQueryBus(
|
|
15
|
+
* axonServerQueryBus(localQueryBus(unitOfWork), axon), correlation)
|
|
16
16
|
* ```
|
|
17
17
|
*
|
|
18
18
|
* Axon-specific protocol invariants are preserved byte-for-byte:
|
|
@@ -30,9 +30,8 @@ import {
|
|
|
30
30
|
qualifiedNameFromString,
|
|
31
31
|
generateIdentifier,
|
|
32
32
|
type Serializer,
|
|
33
|
-
withRetry,
|
|
34
|
-
type ResilienceConfig,
|
|
35
33
|
} from "@kronos-ts/core"
|
|
34
|
+
import { withRetry, type ResilienceConfig } from "./resilience.js"
|
|
36
35
|
import type {
|
|
37
36
|
CommandBus,
|
|
38
37
|
CommandMessage,
|
|
@@ -41,12 +40,10 @@ import type {
|
|
|
41
40
|
SubscriptionFilter,
|
|
42
41
|
SubscriptionQueryResult,
|
|
43
42
|
UnitOfWork,
|
|
44
|
-
Unstamped,
|
|
45
43
|
UpdateHandler,
|
|
46
44
|
} from "@kronos-ts/core"
|
|
47
45
|
import {
|
|
48
46
|
applySubscriptionFilter,
|
|
49
|
-
stamped,
|
|
50
47
|
updateHandler,
|
|
51
48
|
runAfterCommitOrImmediately,
|
|
52
49
|
} from "@kronos-ts/core"
|
|
@@ -69,7 +66,7 @@ const DEFAULT_LOAD_FACTOR = 100
|
|
|
69
66
|
/**
|
|
70
67
|
* Flow control configuration for a bus channel.
|
|
71
68
|
*/
|
|
72
|
-
export
|
|
69
|
+
export type FlowControlConfig = {
|
|
73
70
|
/** Initial permits granted to Axon Server. Default: 5000 (aligned with Java). */
|
|
74
71
|
permits?: number
|
|
75
72
|
/** Threshold at which to request more permits. Default: 2500 (aligned with Java). */
|
|
@@ -80,7 +77,7 @@ export interface FlowControlConfig {
|
|
|
80
77
|
* Processing instructions attached to outbound messages.
|
|
81
78
|
* Controls routing, priority, and timeout behavior on Axon Server.
|
|
82
79
|
*/
|
|
83
|
-
export
|
|
80
|
+
export type ProcessingInstructions = {
|
|
84
81
|
/** Routing key for consistent hashing (e.g., aggregate ID). */
|
|
85
82
|
routingKey?: string
|
|
86
83
|
/** Priority (higher = processed first). Default: 0 */
|
|
@@ -91,10 +88,10 @@ export interface ProcessingInstructions {
|
|
|
91
88
|
|
|
92
89
|
/**
|
|
93
90
|
* Tuning for {@link axonServerCommandBus}. Every field has a working default;
|
|
94
|
-
* the two arguments that carry meaning — the connection and your
|
|
91
|
+
* the two arguments that carry meaning — the connection and your next bus —
|
|
95
92
|
* are positional, and this record is the trailing remainder.
|
|
96
93
|
*/
|
|
97
|
-
export
|
|
94
|
+
export type AxonServerCommandBusOptions = {
|
|
98
95
|
/** Axon Server context for this bus's stream. Default: the connection's. */
|
|
99
96
|
context?: string
|
|
100
97
|
/** Flow control for the command stream. */
|
|
@@ -112,7 +109,7 @@ export interface AxonServerCommandBusOptions {
|
|
|
112
109
|
/**
|
|
113
110
|
* Tuning for {@link axonServerQueryBus}. See {@link AxonServerCommandBusOptions}.
|
|
114
111
|
*/
|
|
115
|
-
export
|
|
112
|
+
export type AxonServerQueryBusOptions = {
|
|
116
113
|
/** Axon Server context for this bus's stream. Default: the connection's. */
|
|
117
114
|
context?: string
|
|
118
115
|
/** Flow control for the query stream. */
|
|
@@ -208,33 +205,33 @@ function createPayloadHelpers(serializer: Serializer) {
|
|
|
208
205
|
// ---------------------------------------------------------------------------
|
|
209
206
|
|
|
210
207
|
/**
|
|
211
|
-
* A command bus backed by Axon Server, over YOUR
|
|
208
|
+
* A command bus backed by Axon Server, over YOUR next bus.
|
|
212
209
|
*
|
|
213
210
|
* - **Outbound dispatch**: ALWAYS through Axon Server, via the unary Dispatch
|
|
214
211
|
* RPC. Axon Server routes the command to the appropriate node (which may be
|
|
215
|
-
* this one). There is deliberately no client-side prefer-
|
|
212
|
+
* this one). There is deliberately no client-side prefer-next fork: the hub
|
|
216
213
|
* is the router, and short-circuiting it would silently defeat load factors,
|
|
217
214
|
* priorities and routing keys.
|
|
218
|
-
* - **Inbound**: a command the server routes here is dispatched into `
|
|
215
|
+
* - **Inbound**: a command the server routes here is dispatched into `next` —
|
|
219
216
|
* not into a privately-held handler map. That is what makes the unit-of-work
|
|
220
|
-
* policy you chose for `
|
|
217
|
+
* policy you chose for `next` (say `postgresUnitOfWork(unitOfWork, pg)`)
|
|
221
218
|
* apply to server-routed work exactly as it applies to work this process
|
|
222
219
|
* originated. It is also why this function takes no `unitOfWork` argument:
|
|
223
|
-
* `
|
|
224
|
-
* - **subscribe**: registers the handler on `
|
|
220
|
+
* `next` carries that policy now.
|
|
221
|
+
* - **subscribe**: registers the handler on `next` AND announces the name to
|
|
225
222
|
* Axon Server, so other nodes can route to us.
|
|
226
223
|
*
|
|
227
|
-
* ##
|
|
224
|
+
* ## correlation and the interceptor layer
|
|
228
225
|
*
|
|
229
|
-
* The returned bus stamps no
|
|
226
|
+
* The returned bus stamps no correlation of its own. A host that wants it wraps the
|
|
230
227
|
* OUTERMOST bus:
|
|
231
228
|
*
|
|
232
229
|
* ```ts
|
|
233
|
-
* interceptingCommandBus(axonServerCommandBus(
|
|
230
|
+
* interceptingCommandBus(axonServerCommandBus(next, conn), correlation)
|
|
234
231
|
* ```
|
|
235
232
|
*
|
|
236
233
|
* so whatever a host adds runs BEFORE the message is serialized onto the wire.
|
|
237
|
-
*
|
|
234
|
+
* Correlation itself is usually already on `message.metadata` by then — `ctx.send`
|
|
238
235
|
* stamps the unit of work's correlation data before any bus sees the message.
|
|
239
236
|
*
|
|
240
237
|
* This is precisely how the Java client does it. AF4's `AxonServerCommandBus`
|
|
@@ -242,17 +239,17 @@ function createPayloadHelpers(serializer: Serializer) {
|
|
|
242
239
|
* `doDispatch(dispatchInterceptors.intercept(commandMessage), cb)` — one call
|
|
243
240
|
* site, at the top, ahead of any routing. AF5 keeps the property via decorator
|
|
244
241
|
* order: `DISTRIBUTED_COMMAND_BUS_ORDER = InterceptingCommandBus.DECORATION_ORDER - 50`
|
|
245
|
-
* stacks `InterceptingCommandBus → DistributedCommandBus →
|
|
242
|
+
* stacks `InterceptingCommandBus → DistributedCommandBus → LocalCommandBus`.
|
|
246
243
|
*
|
|
247
|
-
* If `
|
|
248
|
-
* `
|
|
244
|
+
* If `next` is itself an intercepting bus, a server-routed command sees
|
|
245
|
+
* `correlation` twice. That is harmless: both of its fields are `??` seeds, so the
|
|
249
246
|
* second application finds them set and changes nothing.
|
|
250
247
|
*/
|
|
251
|
-
export function axonServerCommandBus(
|
|
248
|
+
export function axonServerCommandBus<U extends UnitOfWork = UnitOfWork>(
|
|
249
|
+
next: CommandBus<U>,
|
|
252
250
|
conn: AxonServerBusSource,
|
|
253
|
-
local: CommandBus,
|
|
254
251
|
options: AxonServerCommandBusOptions = {},
|
|
255
|
-
): CommandBus {
|
|
252
|
+
): CommandBus<U> {
|
|
256
253
|
const {
|
|
257
254
|
connection,
|
|
258
255
|
serializer,
|
|
@@ -268,9 +265,9 @@ export function axonServerCommandBus(
|
|
|
268
265
|
|
|
269
266
|
/**
|
|
270
267
|
* The names this node announced to Axon Server. The handlers themselves live
|
|
271
|
-
* on `
|
|
268
|
+
* on `next`; this set exists so an inbound command for a name we never
|
|
272
269
|
* subscribed still answers NO_HANDLER_FOR_COMMAND rather than whatever
|
|
273
|
-
* `
|
|
270
|
+
* `next.dispatch` happens to throw — and so a reconnect can re-announce.
|
|
274
271
|
*/
|
|
275
272
|
const subscribedNames = new Set<string>()
|
|
276
273
|
|
|
@@ -371,18 +368,18 @@ export function axonServerCommandBus(
|
|
|
371
368
|
}
|
|
372
369
|
|
|
373
370
|
// Through the LOCAL BUS, so the caller's unit-of-work policy runs.
|
|
374
|
-
// AF parity is preserved: `CommandProcessingTask` runs the
|
|
375
|
-
// segment without re-running dispatch interceptors, and a `
|
|
376
|
-
// that happens to carry `
|
|
371
|
+
// AF parity is preserved: `CommandProcessingTask` runs the next
|
|
372
|
+
// segment without re-running dispatch interceptors, and a `next`
|
|
373
|
+
// that happens to carry `correlation` re-applies a pair of `??` seeds
|
|
377
374
|
// that are already set.
|
|
378
|
-
resultPayload = await
|
|
375
|
+
resultPayload = await next.dispatch(commandMessage)
|
|
379
376
|
} catch (err) {
|
|
380
377
|
errorCode = AxonServerErrorCode.COMMAND_EXECUTION_ERROR
|
|
381
378
|
errorMsg = err instanceof Error ? err.message : String(err)
|
|
382
379
|
}
|
|
383
380
|
} else {
|
|
384
381
|
errorCode = AxonServerErrorCode.NO_HANDLER_FOR_COMMAND
|
|
385
|
-
errorMsg = `No
|
|
382
|
+
errorMsg = `No next handler for command "${commandName}"`
|
|
386
383
|
}
|
|
387
384
|
|
|
388
385
|
// Send response back to Axon Server
|
|
@@ -431,14 +428,13 @@ export function axonServerCommandBus(
|
|
|
431
428
|
}
|
|
432
429
|
|
|
433
430
|
return {
|
|
434
|
-
async dispatch(unstamped:
|
|
431
|
+
async dispatch(unstamped: CommandMessage): Promise<unknown> {
|
|
435
432
|
// A transport is not a task: it has no unit of work, so it has no clock.
|
|
436
|
-
// A message that reaches the wire
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
const message = stamped(unstamped, Date.now)
|
|
433
|
+
// A message that reaches the wire with no instant yet gets one from system
|
|
434
|
+
// time here — the envelope crosses a process boundary and must be fully
|
|
435
|
+
// formed. A locally-shortcut message is handed to `next` untouched
|
|
436
|
+
// instead, so the task that handles it supplies the instant.
|
|
437
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
|
|
442
438
|
const activity = shutdownLatch.registerActivity()
|
|
443
439
|
try {
|
|
444
440
|
const commandName = qualifiedNameToString(message.name)
|
|
@@ -471,10 +467,10 @@ export function axonServerCommandBus(
|
|
|
471
467
|
|
|
472
468
|
subscribe(
|
|
473
469
|
commandName: string,
|
|
474
|
-
handler: (message: CommandMessage, uow:
|
|
470
|
+
handler: (message: CommandMessage, uow: U) => Promise<unknown>,
|
|
475
471
|
) {
|
|
476
472
|
subscribedNames.add(commandName)
|
|
477
|
-
|
|
473
|
+
next.subscribe(commandName, handler)
|
|
478
474
|
|
|
479
475
|
ensureStreamStarted()
|
|
480
476
|
// Subscription FIRST
|
|
@@ -490,35 +486,35 @@ export function axonServerCommandBus(
|
|
|
490
486
|
// ---------------------------------------------------------------------------
|
|
491
487
|
|
|
492
488
|
/**
|
|
493
|
-
* A query bus backed by Axon Server, over YOUR
|
|
489
|
+
* A query bus backed by Axon Server, over YOUR next bus.
|
|
494
490
|
*
|
|
495
491
|
* Same architecture as {@link axonServerCommandBus}: outbound dispatch goes
|
|
496
|
-
* through Axon Server, and a query the server routes here runs through `
|
|
492
|
+
* through Axon Server, and a query the server routes here runs through `next`,
|
|
497
493
|
* so your unit-of-work policy applies to server-routed reads too. `subscribe`
|
|
498
|
-
* registers on `
|
|
494
|
+
* registers on `next` and announces the name to the server.
|
|
499
495
|
*
|
|
500
496
|
* The one asymmetry with commands is `shortcutQueriesToLocalHandlers` — Java
|
|
501
497
|
* has it for queries and not for commands, and so do we. When it is on and this
|
|
502
|
-
* node subscribed the name, `query()` goes straight to `
|
|
503
|
-
* unit of work is passed through, so the
|
|
498
|
+
* node subscribed the name, `query()` goes straight to `next` and the caller's
|
|
499
|
+
* unit of work is passed through, so the next branch nests exactly as the
|
|
504
500
|
* in-process bus does.
|
|
505
501
|
*
|
|
506
|
-
*
|
|
502
|
+
* Correlation, if wanted, is `interceptingQueryBus(bus, correlation)` at the host,
|
|
507
503
|
* matching AF4's `AxonServerQueryBus`, which calls
|
|
508
504
|
* `dispatchInterceptors.intercept(...)` at the top of `query`, `streamingQuery`,
|
|
509
505
|
* `scatterGather` and `subscriptionQuery`. Because the wrap is outside, the
|
|
510
|
-
* shortcut branch gets identical
|
|
506
|
+
* shortcut branch gets identical correlation to the remote branch.
|
|
511
507
|
*
|
|
512
508
|
* KNOWN GAP: `subscriptionQuery` / `subscribeToUpdates` build their proto
|
|
513
509
|
* straight from `message.metadata`, and `interceptingQueryBus` (in
|
|
514
510
|
* `@kronos-ts/core`) forwards those two calls to the delegate without
|
|
515
511
|
* running the dispatch chain. Closing that needs a core change.
|
|
516
512
|
*/
|
|
517
|
-
export function axonServerQueryBus(
|
|
513
|
+
export function axonServerQueryBus<U extends UnitOfWork = UnitOfWork>(
|
|
514
|
+
next: QueryBus<U>,
|
|
518
515
|
conn: AxonServerBusSource,
|
|
519
|
-
local: QueryBus,
|
|
520
516
|
options: AxonServerQueryBusOptions = {},
|
|
521
|
-
): QueryBus {
|
|
517
|
+
): QueryBus<U> {
|
|
522
518
|
const {
|
|
523
519
|
connection,
|
|
524
520
|
serializer,
|
|
@@ -533,7 +529,7 @@ export function axonServerQueryBus(
|
|
|
533
529
|
const queryTimeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS
|
|
534
530
|
const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
|
|
535
531
|
|
|
536
|
-
/** Query names announced to Axon Server; the handlers live on `
|
|
532
|
+
/** Query names announced to Axon Server; the handlers live on `next`. */
|
|
537
533
|
const subscribedNames = new Set<string>()
|
|
538
534
|
|
|
539
535
|
// Local subscription store — subscription queries opened by THIS instance.
|
|
@@ -643,14 +639,14 @@ export function axonServerQueryBus(
|
|
|
643
639
|
metadata: metadataFromProto(proto.metaData ?? {}),
|
|
644
640
|
timestamp: Number(proto.timestamp),
|
|
645
641
|
}
|
|
646
|
-
resultPayload = await
|
|
642
|
+
resultPayload = await next.query(queryMessage)
|
|
647
643
|
} catch (err) {
|
|
648
644
|
errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
|
|
649
645
|
errorMsg = err instanceof Error ? err.message : String(err)
|
|
650
646
|
}
|
|
651
647
|
} else {
|
|
652
648
|
errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
|
|
653
|
-
errorMsg = `No
|
|
649
|
+
errorMsg = `No next handler for query "${queryName}"`
|
|
654
650
|
}
|
|
655
651
|
|
|
656
652
|
const responseSerialized =
|
|
@@ -715,16 +711,16 @@ export function axonServerQueryBus(
|
|
|
715
711
|
timestamp: Number(proto.timestamp),
|
|
716
712
|
}
|
|
717
713
|
|
|
718
|
-
// Through the LOCAL BUS: no unit of work is handed in, so `
|
|
714
|
+
// Through the LOCAL BUS: no unit of work is handed in, so `next`
|
|
719
715
|
// opens one under whatever policy the caller gave it.
|
|
720
|
-
resultPayload = await
|
|
716
|
+
resultPayload = await next.query(queryMessage)
|
|
721
717
|
} catch (err) {
|
|
722
718
|
errorCode = AxonServerErrorCode.QUERY_EXECUTION_ERROR
|
|
723
719
|
errorMsg = err instanceof Error ? err.message : String(err)
|
|
724
720
|
}
|
|
725
721
|
} else {
|
|
726
722
|
errorCode = AxonServerErrorCode.NO_HANDLER_FOR_QUERY
|
|
727
|
-
errorMsg = `No
|
|
723
|
+
errorMsg = `No next handler for query "${queryName}"`
|
|
728
724
|
}
|
|
729
725
|
|
|
730
726
|
outbound.send({
|
|
@@ -778,28 +774,27 @@ export function axonServerQueryBus(
|
|
|
778
774
|
}
|
|
779
775
|
}
|
|
780
776
|
|
|
781
|
-
const routing: QueryBus = {
|
|
782
|
-
async query(unstamped:
|
|
777
|
+
const routing: QueryBus<U> = {
|
|
778
|
+
async query(unstamped: QueryMessage, uow?: UnitOfWork): Promise<unknown> {
|
|
783
779
|
const activity = shutdownLatch.registerActivity()
|
|
784
780
|
try {
|
|
785
781
|
const queryName = qualifiedNameToString(unstamped.name)
|
|
786
782
|
|
|
787
783
|
// Local shortcut — handle locally if a handler is co-located. The
|
|
788
|
-
// caller's unit of work is passed straight through, so `
|
|
784
|
+
// caller's unit of work is passed straight through, so `next` makes the
|
|
789
785
|
// nest-or-open decision on the HANDLE exactly as it does for an
|
|
790
786
|
// in-process read: a live unit of work handed in by `ctx.query` is
|
|
791
787
|
// reused so the consulting read shares the caller's transaction.
|
|
792
788
|
if (shortcutQueriesToLocalHandlers && subscribedNames.has(queryName)) {
|
|
793
|
-
return
|
|
789
|
+
return next.query(unstamped, uow)
|
|
794
790
|
}
|
|
795
791
|
|
|
796
792
|
// A transport is not a task: it has no unit of work, so it has no clock.
|
|
797
|
-
// A message that reaches the wire
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
const message = stamped(unstamped, Date.now)
|
|
793
|
+
// A message that reaches the wire with no instant yet gets one from system
|
|
794
|
+
// time here — the envelope crosses a process boundary and must be fully
|
|
795
|
+
// formed. A locally-shortcut message is handed to `next` untouched
|
|
796
|
+
// instead, so the task that handles it supplies the instant.
|
|
797
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
|
|
803
798
|
|
|
804
799
|
const responseStream = connection.queries.query(
|
|
805
800
|
{
|
|
@@ -833,10 +828,10 @@ export function axonServerQueryBus(
|
|
|
833
828
|
|
|
834
829
|
subscribe(
|
|
835
830
|
queryName: string,
|
|
836
|
-
handler: (message: QueryMessage, uow:
|
|
831
|
+
handler: (message: QueryMessage, uow: U) => Promise<unknown>,
|
|
837
832
|
) {
|
|
838
833
|
subscribedNames.add(queryName)
|
|
839
|
-
|
|
834
|
+
next.subscribe(queryName, handler)
|
|
840
835
|
|
|
841
836
|
ensureStreamStarted()
|
|
842
837
|
sendSubscribe(queryName)
|
|
@@ -845,10 +840,10 @@ export function axonServerQueryBus(
|
|
|
845
840
|
},
|
|
846
841
|
|
|
847
842
|
subscriptionQuery(
|
|
848
|
-
unstamped:
|
|
843
|
+
unstamped: QueryMessage,
|
|
849
844
|
bufferSize?: number,
|
|
850
845
|
): SubscriptionQueryResult {
|
|
851
|
-
const message =
|
|
846
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
|
|
852
847
|
const queryId = message.identifier
|
|
853
848
|
if (subscriptions.has(queryId)) {
|
|
854
849
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
@@ -973,10 +968,10 @@ export function axonServerQueryBus(
|
|
|
973
968
|
},
|
|
974
969
|
|
|
975
970
|
subscribeToUpdates(
|
|
976
|
-
unstamped:
|
|
971
|
+
unstamped: QueryMessage,
|
|
977
972
|
bufferSize?: number,
|
|
978
973
|
): AsyncIterable<unknown> & { close(): void } {
|
|
979
|
-
const message =
|
|
974
|
+
const message = { ...unstamped, timestamp: unstamped.timestamp ?? Date.now() }
|
|
980
975
|
const queryId = message.identifier
|
|
981
976
|
if (subscriptions.has(queryId)) {
|
|
982
977
|
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
@@ -22,7 +22,7 @@ import { connectToAxonServer } from "./connection.js"
|
|
|
22
22
|
* await manager.disconnectAll()
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
|
-
export
|
|
25
|
+
export type AxonServerConnectionManager = {
|
|
26
26
|
/**
|
|
27
27
|
* Get or create a connection for the given context.
|
|
28
28
|
* Connections are created lazily and cached.
|