@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.
- 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 +73 -45
- 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 +154 -0
- package/dist/axon-server-snapshotting-event-store.js.map +1 -0
- package/dist/axon-server.d.ts +46 -65
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +490 -317
- package/dist/axon-server.js.map +1 -1
- package/dist/bounded-read.d.ts +19 -0
- package/dist/bounded-read.d.ts.map +1 -0
- package/dist/bounded-read.js +39 -0
- package/dist/bounded-read.js.map +1 -0
- package/dist/connection-manager.d.ts +2 -2
- package/dist/connection-manager.d.ts.map +1 -1
- package/dist/connection.d.ts +36 -21
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +84 -45
- 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 +14 -24
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +6 -37
- package/dist/control-plane.js.map +1 -1
- package/dist/event-processor-info.d.ts +7 -25
- package/dist/event-processor-info.d.ts.map +1 -1
- package/dist/event-processor-info.js +16 -20
- package/dist/event-processor-info.js.map +1 -1
- package/dist/flow-controlled-sender.d.ts +2 -2
- package/dist/flow-controlled-sender.d.ts.map +1 -1
- package/dist/flow-controlled-sender.js +37 -17
- package/dist/flow-controlled-sender.js.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 +2 -2
- 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 +7 -11
- package/dist/outbound-stream.d.ts.map +1 -1
- package/dist/outbound-stream.js +65 -16
- package/dist/outbound-stream.js.map +1 -1
- package/dist/platform-service.d.ts +14 -5
- package/dist/platform-service.d.ts.map +1 -1
- package/dist/platform-service.js +70 -10
- package/dist/platform-service.js.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 +5 -4
- package/dist/shutdown-latch.d.ts.map +1 -1
- package/dist/shutdown-latch.js +20 -1
- package/dist/shutdown-latch.js.map +1 -1
- package/dist/stream-recovery.d.ts +9 -0
- package/dist/stream-recovery.d.ts.map +1 -0
- package/dist/stream-recovery.js +70 -0
- package/dist/stream-recovery.js.map +1 -0
- package/package.json +3 -3
- package/src/axon-server-event-store.ts +77 -47
- package/src/axon-server-snapshotting-event-store.ts +194 -0
- package/src/axon-server.ts +472 -374
- package/src/bounded-read.ts +43 -0
- package/src/connection-manager.ts +1 -1
- package/src/connection.ts +93 -59
- package/src/context-view.ts +1 -1
- package/src/control-plane.ts +22 -66
- package/src/event-processor-info.ts +23 -44
- package/src/flow-controlled-sender.ts +34 -15
- 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 -2
- package/src/message-size.ts +1 -1
- package/src/outbound-stream.ts +58 -28
- package/src/platform-service.ts +77 -13
- package/src/resilience.ts +149 -0
- package/src/shutdown-latch.ts +16 -3
- package/src/stream-recovery.ts +72 -0
- 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
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
SequencedEvent,
|
|
12
12
|
StreamingCondition,
|
|
13
13
|
} from "@kronos-ts/core"
|
|
14
|
-
import {
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
const request = {
|
|
187
|
+
fromSequence: start,
|
|
188
|
+
criterion: criterions,
|
|
189
|
+
}
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
const events: EventMessage[] = []
|
|
192
|
+
let marker: ConsistencyMarker = noMarker()
|
|
193
193
|
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
374
|
+
cancel.abort()
|
|
336
375
|
},
|
|
337
|
-
}
|
|
376
|
+
}
|
|
338
377
|
},
|
|
339
378
|
|
|
340
379
|
async getHeadPosition(): Promise<bigint> {
|
|
341
|
-
const response = await connection.
|
|
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.
|
|
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
|
+
}
|