@kronos-ts/axon-server 0.4.0 → 0.6.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 +0 -16
  2. package/dist/axon-server-event-store.d.ts.map +1 -1
  3. package/dist/axon-server-event-store.js +73 -45
  4. package/dist/axon-server-event-store.js.map +1 -1
  5. package/dist/axon-server-snapshotting-event-store.d.ts +30 -0
  6. package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -0
  7. package/dist/axon-server-snapshotting-event-store.js +154 -0
  8. package/dist/axon-server-snapshotting-event-store.js.map +1 -0
  9. package/dist/axon-server.d.ts +46 -65
  10. package/dist/axon-server.d.ts.map +1 -1
  11. package/dist/axon-server.js +490 -317
  12. package/dist/axon-server.js.map +1 -1
  13. package/dist/bounded-read.d.ts +19 -0
  14. package/dist/bounded-read.d.ts.map +1 -0
  15. package/dist/bounded-read.js +39 -0
  16. package/dist/bounded-read.js.map +1 -0
  17. package/dist/connection-manager.d.ts +2 -2
  18. package/dist/connection-manager.d.ts.map +1 -1
  19. package/dist/connection.d.ts +36 -21
  20. package/dist/connection.d.ts.map +1 -1
  21. package/dist/connection.js +84 -45
  22. package/dist/connection.js.map +1 -1
  23. package/dist/context-view.d.ts +2 -2
  24. package/dist/context-view.d.ts.map +1 -1
  25. package/dist/control-plane.d.ts +14 -24
  26. package/dist/control-plane.d.ts.map +1 -1
  27. package/dist/control-plane.js +6 -37
  28. package/dist/control-plane.js.map +1 -1
  29. package/dist/event-processor-info.d.ts +7 -25
  30. package/dist/event-processor-info.d.ts.map +1 -1
  31. package/dist/event-processor-info.js +16 -20
  32. package/dist/event-processor-info.js.map +1 -1
  33. package/dist/flow-controlled-sender.d.ts +2 -2
  34. package/dist/flow-controlled-sender.d.ts.map +1 -1
  35. package/dist/flow-controlled-sender.js +37 -17
  36. package/dist/flow-controlled-sender.js.map +1 -1
  37. package/dist/generated/command.d.ts +20 -20
  38. package/dist/generated/command.d.ts.map +1 -1
  39. package/dist/generated/common.d.ts +20 -20
  40. package/dist/generated/common.d.ts.map +1 -1
  41. package/dist/generated/control.d.ts +36 -40
  42. package/dist/generated/control.d.ts.map +1 -1
  43. package/dist/generated/dcb.d.ts +88 -94
  44. package/dist/generated/dcb.d.ts.map +1 -1
  45. package/dist/generated/event.d.ts +81 -84
  46. package/dist/generated/event.d.ts.map +1 -1
  47. package/dist/generated/google/protobuf/empty.d.ts +3 -4
  48. package/dist/generated/google/protobuf/empty.d.ts.map +1 -1
  49. package/dist/generated/query.d.ts +40 -40
  50. package/dist/generated/query.d.ts.map +1 -1
  51. package/dist/index.d.ts +2 -2
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/message-size.d.ts +2 -2
  56. package/dist/message-size.d.ts.map +1 -1
  57. package/dist/outbound-stream.d.ts +7 -11
  58. package/dist/outbound-stream.d.ts.map +1 -1
  59. package/dist/outbound-stream.js +65 -16
  60. package/dist/outbound-stream.js.map +1 -1
  61. package/dist/platform-service.d.ts +14 -5
  62. package/dist/platform-service.d.ts.map +1 -1
  63. package/dist/platform-service.js +70 -10
  64. package/dist/platform-service.js.map +1 -1
  65. package/dist/resilience.d.ts +62 -0
  66. package/dist/resilience.d.ts.map +1 -0
  67. package/dist/resilience.js +103 -0
  68. package/dist/resilience.js.map +1 -0
  69. package/dist/shutdown-latch.d.ts +5 -4
  70. package/dist/shutdown-latch.d.ts.map +1 -1
  71. package/dist/shutdown-latch.js +20 -1
  72. package/dist/shutdown-latch.js.map +1 -1
  73. package/dist/stream-recovery.d.ts +9 -0
  74. package/dist/stream-recovery.d.ts.map +1 -0
  75. package/dist/stream-recovery.js +70 -0
  76. package/dist/stream-recovery.js.map +1 -0
  77. package/package.json +3 -3
  78. package/src/axon-server-event-store.ts +77 -47
  79. package/src/axon-server-snapshotting-event-store.ts +194 -0
  80. package/src/axon-server.ts +472 -374
  81. package/src/bounded-read.ts +43 -0
  82. package/src/connection-manager.ts +1 -1
  83. package/src/connection.ts +93 -59
  84. package/src/context-view.ts +1 -1
  85. package/src/control-plane.ts +22 -66
  86. package/src/event-processor-info.ts +23 -44
  87. package/src/flow-controlled-sender.ts +34 -15
  88. package/src/generated/command.ts +10 -10
  89. package/src/generated/common.ts +10 -10
  90. package/src/generated/control.ts +20 -20
  91. package/src/generated/dcb.ts +47 -47
  92. package/src/generated/event.ts +42 -42
  93. package/src/generated/google/protobuf/empty.ts +2 -2
  94. package/src/generated/query.ts +20 -20
  95. package/src/index.ts +1 -2
  96. package/src/message-size.ts +1 -1
  97. package/src/outbound-stream.ts +58 -28
  98. package/src/platform-service.ts +77 -13
  99. package/src/resilience.ts +149 -0
  100. package/src/shutdown-latch.ts +16 -3
  101. package/src/stream-recovery.ts +72 -0
  102. package/dist/axon-server-snapshot-store.d.ts +0 -12
  103. package/dist/axon-server-snapshot-store.d.ts.map +0 -1
  104. package/dist/axon-server-snapshot-store.js +0 -82
  105. package/dist/axon-server-snapshot-store.js.map +0 -1
  106. package/src/axon-server-snapshot-store.ts +0 -110
@@ -11,7 +11,7 @@ import type {
11
11
  SequencedEvent,
12
12
  StreamingCondition,
13
13
  } from "@kronos-ts/core"
14
- import { messageStream, compileQuery } from "@kronos-ts/core"
14
+ import { compileQuery } from "@kronos-ts/core"
15
15
  import type {
16
16
  EventStore,
17
17
  SourcingResult,
@@ -25,6 +25,7 @@ import { globalSequenceToken, FIRST_TOKEN } from "@kronos-ts/core"
25
25
  import { markerAt, noMarker } from "@kronos-ts/core"
26
26
  import type { AxonServerStoreSource } from "./connection.js"
27
27
  import { contextView } from "./context-view.js"
28
+ import { boundedRead } from "./bounded-read.js"
28
29
  import type {
29
30
  Criterion,
30
31
  TagsAndNamesCriterion,
@@ -161,37 +162,37 @@ function createEventConverters(serializer: Serializer) {
161
162
  * a single gRPC Stream RPC call that stays open indefinitely, aligned with
162
163
  * Java's infinite {@code ResultStream}.
163
164
  */
165
+ /**
166
+ * Axon Server's way of saying "there is nothing at or after that position".
167
+ *
168
+ * Matched on the gRPC status code first — `OUT_OF_RANGE` is 11 — with the
169
+ * server's own message as a second, narrower gate, so an unrelated future
170
+ * OUT_OF_RANGE is not swallowed along with it.
171
+ */
172
+ function isStartPastHead(err: unknown): boolean {
173
+ const code = (err as { code?: unknown }).code
174
+ const details = String((err as { details?: unknown }).details ?? "")
175
+ return code === 11 && details.includes("Start sequence cannot be larger than end sequence")
176
+ }
177
+
164
178
  export function axonServerEventStore(conn: AxonServerStoreSource, context: string): EventStore {
165
179
  const { connection, serializer, metadata: createAxonMetadata } = contextView(conn, context)
166
180
  const { eventToProto, eventFromProto } = createEventConverters(serializer)
167
181
 
168
- // Push-based subscriber registry (EventBus.subscribe contract). Axon Server's
169
- // own distribution is the server-side Stream RPC (see open()); these in-process
170
- // subscribers are notified best-effort on every successful local append.
171
- const subscribers = new Set<(events: ReadonlyArray<EventMessage>) => Promise<void>>()
172
- async function notifySubscribers(events: ReadonlyArray<EventMessage>): Promise<void> {
173
- for (const sub of subscribers) {
174
- try {
175
- await sub(events)
176
- } catch {
177
- /* ignore subscriber errors */
178
- }
179
- }
180
- }
181
-
182
- return {
183
- async source(condition: SourcingCondition): Promise<SourcingResult> {
184
- const criterions = criteriaToCriterions(compileQuery(condition.query))
182
+ async function sourceOnce(condition: SourcingCondition, signal: AbortSignal): Promise<SourcingResult> {
183
+ const criterions = criteriaToCriterions(compileQuery(condition.query))
184
+ const start = condition.start ?? 0n
185
185
 
186
- const request = {
187
- fromSequence: condition.start ?? 0n,
188
- criterion: criterions,
189
- }
186
+ const request = {
187
+ fromSequence: start,
188
+ criterion: criterions,
189
+ }
190
190
 
191
- const events: EventMessage[] = []
192
- let marker: ConsistencyMarker = noMarker()
191
+ const events: EventMessage[] = []
192
+ let marker: ConsistencyMarker = noMarker()
193
193
 
194
- const stream = connection.eventStore.source(request, { metadata: createAxonMetadata() })
194
+ try {
195
+ const stream = connection.eventStore.source(request, { metadata: createAxonMetadata(), signal })
195
196
  for await (const response of stream) {
196
197
  if (response.event) {
197
198
  const taggedEvent = response.event
@@ -207,8 +208,30 @@ export function axonServerEventStore(conn: AxonServerStoreSource, context: strin
207
208
  marker = markerAt(response.consistencyMarker)
208
209
  }
209
210
  }
211
+ } catch (err) {
212
+ // READING PAST THE HEAD IS AN EMPTY ANSWER, NOT AN ERROR — and Axon
213
+ // Server disagrees, so this is where the two vocabularies are
214
+ // reconciled. `Source` fails OUT_OF_RANGE with "Start sequence cannot
215
+ // be larger than end sequence" whenever `fromSequence` is beyond the
216
+ // global head, which is the ORDINARY STEADY STATE of a snapshotted
217
+ // load: an entry written at the head means the very next read resumes
218
+ // at head + 1 and legitimately finds nothing.
219
+ //
220
+ // The marker is `start - 1`, and it is exact rather than conservative:
221
+ // the caller has already accounted for everything up to there (that is
222
+ // what asking to start later MEANS), and nothing can exist after it or
223
+ // the server would not have refused. So an append conditioned on this
224
+ // read is checked against precisely the range that was read.
225
+ if (!isStartPastHead(err)) throw err
226
+ return { events: [], marker: start > 0n ? markerAt(start - 1n) : noMarker() }
227
+ }
228
+
229
+ return { events, marker }
230
+ }
210
231
 
211
- return { events, marker }
232
+ return {
233
+ source(condition: SourcingCondition): Promise<SourcingResult> {
234
+ return boundedRead(connection.config.readTimeoutMs, (signal) => sourceOnce(condition, signal))
212
235
  },
213
236
 
214
237
  async appendEvents(
@@ -239,7 +262,6 @@ export function axonServerEventStore(conn: AxonServerStoreSource, context: strin
239
262
  metadata: createAxonMetadata(),
240
263
  })
241
264
  responseMarker = response.consistencyMarker
242
- await notifySubscribers(newEvents)
243
265
  },
244
266
  async afterCommit() {
245
267
  return markerAt(responseMarker ?? 0n)
@@ -267,7 +289,20 @@ export function axonServerEventStore(conn: AxonServerStoreSource, context: strin
267
289
  criterion: criterions,
268
290
  }
269
291
 
270
- const grpcStream = connection.eventStore.stream(request, { metadata: createAxonMetadata() })
292
+ // `close()` must reach the server. This is a server-streaming call: the
293
+ // client sends nothing after the request, so the only way to end it from
294
+ // this side is to cancel it. Abandoning the async iterator does not do
295
+ // that — the `for await` below stays parked on the call until the next
296
+ // event arrives, which at the head of a quiet stream is never, and every
297
+ // closed stream leaves a live call behind on the server. With enough of
298
+ // them open, Axon Server stops answering later `source` reads (a 2-CPU
299
+ // container gets there after a handful): handlers never reply, and
300
+ // commands die at the server's own timeout.
301
+ const cancel = new AbortController()
302
+ const grpcStream = connection.eventStore.stream(request, {
303
+ metadata: createAxonMetadata(),
304
+ signal: cancel.signal,
305
+ })
271
306
 
272
307
  // Internal buffer for events pulled from the gRPC stream
273
308
  const buffer: SequencedEvent[] = []
@@ -296,7 +331,11 @@ export function axonServerEventStore(conn: AxonServerStoreSource, context: strin
296
331
  completed = true
297
332
  availableCallback?.()
298
333
  } catch (err) {
299
- streamError = err instanceof Error ? err : new Error(String(err))
334
+ // Our own cancellation surfaces here as an AbortError; that is the
335
+ // stream closing as asked, not a failure to report.
336
+ if (!completed) {
337
+ streamError = err instanceof Error ? err : new Error(String(err))
338
+ }
300
339
  completed = true
301
340
  availableCallback?.()
302
341
  }
@@ -304,7 +343,7 @@ export function axonServerEventStore(conn: AxonServerStoreSource, context: strin
304
343
 
305
344
  startReading()
306
345
 
307
- return messageStream<SequencedEvent>({
346
+ return {
308
347
  next() {
309
348
  return buffer.shift()
310
349
  },
@@ -332,13 +371,15 @@ export function axonServerEventStore(conn: AxonServerStoreSource, context: strin
332
371
  close() {
333
372
  completed = true
334
373
  availableCallback = null
335
- // gRPC stream will be cancelled when the async iterator is abandoned
374
+ cancel.abort()
336
375
  },
337
- })
376
+ }
338
377
  },
339
378
 
340
379
  async getHeadPosition(): Promise<bigint> {
341
- const response = await connection.eventStore.getHead({}, { metadata: createAxonMetadata() })
380
+ const response = await boundedRead(connection.config.readTimeoutMs, (signal) =>
381
+ connection.eventStore.getHead({}, { metadata: createAxonMetadata(), signal }),
382
+ )
342
383
  return response.sequence
343
384
  },
344
385
 
@@ -347,21 +388,10 @@ export function axonServerEventStore(conn: AxonServerStoreSource, context: strin
347
388
  },
348
389
 
349
390
  async latestToken(): Promise<TrackingToken> {
350
- const response = await connection.eventStore.getHead({}, { metadata: createAxonMetadata() })
391
+ const response = await boundedRead(connection.config.readTimeoutMs, (signal) =>
392
+ connection.eventStore.getHead({}, { metadata: createAxonMetadata(), signal }),
393
+ )
351
394
  return globalSequenceToken(response.sequence)
352
395
  },
353
-
354
- // EventBus contract — publish = append without condition, then notify
355
- // in-process subscribers.
356
- async publish(events: ReadonlyArray<EventMessage>): Promise<void> {
357
- await this.append(events)
358
- },
359
-
360
- subscribe(handler: (events: ReadonlyArray<EventMessage>) => Promise<void>): () => void {
361
- subscribers.add(handler)
362
- return () => {
363
- subscribers.delete(handler)
364
- }
365
- },
366
396
  }
367
397
  }
@@ -0,0 +1,194 @@
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
+ SnapshotStoreCapability,
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 { boundedRead } from "./bounded-read.js"
37
+ import type { Snapshot as ProtoSnapshot } from "./generated/dcb.js"
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Conversion — framework Snapshot ↔ proto Snapshot
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const encoder = new TextEncoder()
44
+
45
+ /**
46
+ * The proto message carries `name`, `version`, `timestamp` and a metadata map
47
+ * the narrowed seam has no equivalent for. `name` carries the cache key,
48
+ * `timestamp` is filled at write time for operators, and `version` stays EMPTY
49
+ * — snapshots have no versions, and inventing one to fill a proto field would
50
+ * be exactly the bookkeeping this design removed. The metadata map stays empty
51
+ * too: a cache entry carries no cargo.
52
+ */
53
+ function createSnapshotConverters(serializer: Serializer) {
54
+ return {
55
+ snapshotToProto(name: string, snapshot: Snapshot): ProtoSnapshot {
56
+ const serialized = serializer.serialize(snapshot.state, name, "")
57
+ return {
58
+ name,
59
+ version: "",
60
+ payload: serialized.data,
61
+ timestamp: BigInt(Date.now()),
62
+ metadata: {},
63
+ }
64
+ },
65
+
66
+ snapshotFromProto(name: string, proto: ProtoSnapshot, position: bigint): Snapshot {
67
+ const state =
68
+ proto.payload.length > 0
69
+ ? serializer.deserialize({ data: proto.payload, type: name, revision: "" })
70
+ : {}
71
+
72
+ return { state, position }
73
+ },
74
+ }
75
+ }
76
+
77
+ function encodeKey(key: string): Uint8Array {
78
+ return encoder.encode(key)
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Axon Server snapshot store
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Add the snapshotting capability to an Axon Server-backed log.
87
+ *
88
+ * ```ts
89
+ * const eventStore = axonServerSnapshottingEventStore(
90
+ * axonServerEventStore(axon, "default"),
91
+ * axon,
92
+ * "default",
93
+ * )
94
+ * ```
95
+ *
96
+ * ADDITIVE, NOT COLLAPSING. It returns `E & SnapshotStoreCapability` — the store you
97
+ * passed in, plus the write — so capabilities stack in either order and nothing
98
+ * the inner store carried is laundered on the way through.
99
+ *
100
+ * `context` is a per-call header, so this shares the one channel `conn` holds
101
+ * with every other context — see `contextView`. Payloads go through the
102
+ * connection's serializer.
103
+ *
104
+ * LATEST-ONLY, over a service that is not. `DcbSnapshotStore` keeps a sequence
105
+ * of snapshots per key; the capability keeps one. `add` therefore sets
106
+ * `prune: true`, so writing an entry retires the ones before it, and `getLast`
107
+ * is the only read. That is the narrowing doing its job: the framework programs
108
+ * against the cache it needs, not against everything the backend happens to
109
+ * offer.
110
+ */
111
+ export function axonServerSnapshottingEventStore<E extends EventStore>(
112
+ next: E,
113
+ conn: AxonServerStoreSource,
114
+ context: string,
115
+ ): E & SnapshotStoreCapability {
116
+ const { connection, serializer, metadata: createAxonMetadata } = contextView(conn, context)
117
+ const { snapshotToProto, snapshotFromProto } = createSnapshotConverters(serializer)
118
+
119
+ /** The cached fold filed under `key`, or nothing — the first of the two calls. */
120
+ async function loadSnapshot(key: string): Promise<Snapshot | undefined> {
121
+ try {
122
+ const response = await boundedRead(connection.config.readTimeoutMs, (signal) =>
123
+ connection.snapshotStore.getLast(
124
+ { key: encodeKey(key) },
125
+ { metadata: createAxonMetadata(), signal },
126
+ ),
127
+ )
128
+
129
+ if (!response.snapshot) {
130
+ return undefined
131
+ }
132
+
133
+ return snapshotFromProto(key, response.snapshot, response.sequence)
134
+ } catch (err) {
135
+ // Axon Server throws rather than answering empty when no snapshot
136
+ // exists — and a cache miss is not an error to anybody upstream.
137
+ if (
138
+ String(err).includes("No snapshot found") ||
139
+ String(err).includes("not found")
140
+ ) {
141
+ return undefined
142
+ }
143
+ throw err
144
+ }
145
+ }
146
+
147
+ return {
148
+ ...next,
149
+
150
+ async storeSnapshot(key: string, snapshot: Snapshot): Promise<void> {
151
+ await connection.snapshotStore.add(
152
+ {
153
+ key: encodeKey(key),
154
+ sequence: snapshot.position,
155
+ prune: true,
156
+ snapshot: snapshotToProto(key, snapshot),
157
+ },
158
+ { metadata: createAxonMetadata() },
159
+ )
160
+ },
161
+
162
+ /**
163
+ * THE CLIENT-SIDE FUSION. Two calls, assembled into the one result a fold
164
+ * expects — and the cache is NEVER LOAD-BEARING, so a miss or an outright
165
+ * throw from the snapshot service both fall through to a full read.
166
+ */
167
+ async source(condition: SourcingCondition): Promise<SourcingResult> {
168
+ const key = condition.snapshot
169
+ if (key === undefined) return next.source(condition)
170
+
171
+ // The strategy is CONSUMED here; the store below gets a plain condition.
172
+ const plain = withoutSnapshotKey(condition)
173
+
174
+ let snapshot: Snapshot | undefined
175
+ try {
176
+ snapshot = await loadSnapshot(key.key)
177
+ } catch {
178
+ // A cache you cannot reach is a cache miss. Loads stay correct; they
179
+ // just cost what they always cost.
180
+ return next.source(plain)
181
+ }
182
+ if (snapshot === undefined) return next.source(plain)
183
+
184
+ // Resume AFTER the position the snapshot already folded; a condition that
185
+ // independently asked to start later keeps its own floor.
186
+ const resumeFrom = snapshot.position + 1n
187
+ const start =
188
+ plain.start !== undefined && plain.start > resumeFrom ? plain.start : resumeFrom
189
+
190
+ const result = await next.source({ ...plain, start })
191
+ return { ...result, snapshot }
192
+ },
193
+ } as E & SnapshotStoreCapability
194
+ }