@kronos-ts/axon-server 0.3.2 → 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 +3 -15
- package/dist/axon-server-event-store.d.ts.map +1 -1
- package/dist/axon-server-event-store.js +85 -44
- 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 +92 -208
- package/dist/axon-server.d.ts.map +1 -1
- package/dist/axon-server.js +221 -323
- 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 +134 -4
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +135 -10
- package/dist/connection.js.map +1 -1
- package/dist/context-view.d.ts +30 -0
- package/dist/context-view.d.ts.map +1 -0
- package/dist/context-view.js +19 -0
- package/dist/context-view.js.map +1 -0
- package/dist/control-plane.d.ts +18 -17
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +8 -6
- package/dist/control-plane.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.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/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/command.js.map +1 -1
- package/dist/generated/common.d.ts +20 -20
- package/dist/generated/common.d.ts.map +1 -1
- package/dist/generated/common.js.map +1 -1
- package/dist/generated/control.d.ts +36 -40
- package/dist/generated/control.d.ts.map +1 -1
- package/dist/generated/control.js.map +1 -1
- package/dist/generated/dcb.d.ts +88 -94
- package/dist/generated/dcb.d.ts.map +1 -1
- package/dist/generated/dcb.js.map +1 -1
- package/dist/generated/event.d.ts +81 -84
- package/dist/generated/event.d.ts.map +1 -1
- package/dist/generated/event.js.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/google/protobuf/empty.js.map +1 -1
- package/dist/generated/query.d.ts +40 -40
- package/dist/generated/query.d.ts.map +1 -1
- package/dist/generated/query.js.map +1 -1
- package/dist/index.d.ts +4 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -5
- 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/metadata-conversion.d.ts +1 -1
- package/dist/metadata-conversion.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/dist/shutdown-latch.js.map +1 -1
- package/package.json +5 -9
- package/src/axon-server-event-store.ts +97 -57
- package/src/axon-server-snapshotting-event-store.ts +191 -0
- package/src/axon-server.ts +365 -524
- package/src/connection-manager.ts +1 -1
- package/src/connection.ts +274 -20
- package/src/context-view.ts +46 -0
- package/src/control-plane.ts +19 -19
- 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 +12 -18
- package/src/message-size.ts +1 -1
- package/src/metadata-conversion.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 -88
- package/dist/axon-server-snapshot-store.js.map +0 -1
- package/src/axon-server-snapshot-store.ts +0 -118
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection resilience — PACKAGE-PRIVATE.
|
|
3
|
+
*
|
|
4
|
+
* Exponential backoff with full jitter, per-event attempt caps, terminal-error
|
|
5
|
+
* short-circuit, and a warn-then-continue health probe. Nothing here is
|
|
6
|
+
* exported from this package's barrel.
|
|
7
|
+
*
|
|
8
|
+
* It used to live in `@kronos-ts/core`, and it does not belong there. Core is
|
|
9
|
+
* only what cannot be a helper, and this is a helper by every test: it touches
|
|
10
|
+
* no core shape, no message, no unit of work — it is `setTimeout` and a loop
|
|
11
|
+
* over a function anybody could have written. Its only consumers were the
|
|
12
|
+
* transports and the pool, core's own `src/` never called it, and a helper
|
|
13
|
+
* lives with its users. Same reasoning that moved each persistence package's
|
|
14
|
+
* transaction glue out of core, applied one folder up.
|
|
15
|
+
*
|
|
16
|
+
* Each consuming package keeps its own copy, carrying only what it uses, so
|
|
17
|
+
* each can also state its own retry policy without negotiating with the others.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULTS = {
|
|
20
|
+
initialDelayMs: 100,
|
|
21
|
+
maxDelayMs: 30_000,
|
|
22
|
+
maxAttempts: 10,
|
|
23
|
+
multiplier: 2,
|
|
24
|
+
healthCheckThresholdMs: 5_000,
|
|
25
|
+
};
|
|
26
|
+
const ATTEMPTS_BY_EVENT = {
|
|
27
|
+
"initial-connect": 10,
|
|
28
|
+
"reconnect": 30,
|
|
29
|
+
"per-operation": 5,
|
|
30
|
+
"health-check": 3,
|
|
31
|
+
};
|
|
32
|
+
function resolveConfig(event, partial) {
|
|
33
|
+
return {
|
|
34
|
+
...DEFAULTS,
|
|
35
|
+
maxAttempts: ATTEMPTS_BY_EVENT[event],
|
|
36
|
+
...partial,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function fullJitterDelay(attempt, cfg) {
|
|
40
|
+
const exponential = Math.min(cfg.initialDelayMs * Math.pow(cfg.multiplier, attempt), cfg.maxDelayMs);
|
|
41
|
+
return Math.random() * exponential;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run `fn` with exponential-backoff full-jitter retries.
|
|
45
|
+
*
|
|
46
|
+
* Resolves with the first successful result, or rejects with the last error
|
|
47
|
+
* after `maxAttempts` exhausted. If `isRetryable(err)` returns false on any
|
|
48
|
+
* attempt, the error is rethrown immediately (terminal error short-circuit).
|
|
49
|
+
*
|
|
50
|
+
* D-101: opts may carry caller-supplied resilience overrides — this is the
|
|
51
|
+
* per-extension knob path (`kronosDbConnection({ resilience: { ... } })`).
|
|
52
|
+
*/
|
|
53
|
+
export async function withRetry(fn, opts) {
|
|
54
|
+
const cfg = resolveConfig(opts.event, opts);
|
|
55
|
+
let lastErr;
|
|
56
|
+
for (let attempt = 0; attempt < cfg.maxAttempts; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
return await fn();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
lastErr = err;
|
|
62
|
+
if (cfg.isRetryable && !cfg.isRetryable(err))
|
|
63
|
+
throw err;
|
|
64
|
+
if (attempt === cfg.maxAttempts - 1)
|
|
65
|
+
break;
|
|
66
|
+
const delay = fullJitterDelay(attempt, cfg);
|
|
67
|
+
cfg.log?.(`[kronos:resilience] ${opts.event} attempt ${attempt + 1}/${cfg.maxAttempts} ` +
|
|
68
|
+
`failed; retrying in ${Math.round(delay)}ms — ${err?.message ?? String(err)}`);
|
|
69
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw lastErr;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Run a health-check probe with warn-then-continue semantics (D-100).
|
|
76
|
+
*
|
|
77
|
+
* Resolves WITHOUT throwing on success OR after `thresholdMs` elapses with
|
|
78
|
+
* the probe still pending — production loops should NEVER stall on a flaky
|
|
79
|
+
* health probe. Failures (rejection or threshold breach) emit a single warn
|
|
80
|
+
* via `log` and otherwise resolve normally.
|
|
81
|
+
*/
|
|
82
|
+
export async function healthCheck(fn, opts = {}) {
|
|
83
|
+
const threshold = opts.thresholdMs ?? DEFAULTS.healthCheckThresholdMs;
|
|
84
|
+
const log = opts.log ?? ((m) => console.warn(m));
|
|
85
|
+
let timer;
|
|
86
|
+
try {
|
|
87
|
+
await Promise.race([
|
|
88
|
+
fn(),
|
|
89
|
+
new Promise((_, reject) => {
|
|
90
|
+
timer = setTimeout(() => reject(new Error("health-check threshold exceeded")), threshold);
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
log(`[kronos:resilience] health-check soft-failed (${err?.message ?? String(err)}); continuing`);
|
|
96
|
+
// warn-then-continue: do NOT rethrow.
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
if (timer)
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=resilience.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resilience.js","sourceRoot":"","sources":["../src/resilience.ts"],"names":[],"mappings":"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"}
|
package/dist/shutdown-latch.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* This is the TypeScript equivalent of AF5's ShutdownLatch pattern.
|
|
12
12
|
*/
|
|
13
|
-
export
|
|
13
|
+
export type ShutdownLatch = {
|
|
14
14
|
/**
|
|
15
15
|
* Register an in-flight activity. Throws if shutdown is in progress.
|
|
16
16
|
* Call `end()` on the returned handle when the activity completes.
|
|
@@ -26,11 +26,11 @@ export interface ShutdownLatch {
|
|
|
26
26
|
readonly shuttingDown: boolean;
|
|
27
27
|
/** Number of currently in-flight activities. */
|
|
28
28
|
readonly activeCount: number;
|
|
29
|
-
}
|
|
30
|
-
export
|
|
29
|
+
};
|
|
30
|
+
export type ActivityHandle = {
|
|
31
31
|
/** Mark this activity as complete. */
|
|
32
32
|
end(): void;
|
|
33
|
-
}
|
|
33
|
+
};
|
|
34
34
|
export declare class ShutdownInProgressError extends Error {
|
|
35
35
|
constructor(message?: string);
|
|
36
36
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shutdown-latch.d.ts","sourceRoot":"","sources":["../src/shutdown-latch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,
|
|
1
|
+
{"version":3,"file":"shutdown-latch.d.ts","sourceRoot":"","sources":["../src/shutdown-latch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B;;;OAGG;IACH,gBAAgB,IAAI,cAAc,CAAA;IAElC;;;;OAIG;IACH,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IAEjC,2CAA2C;IAC3C,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAA;IAE9B,gDAAgD;IAChD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,sCAAsC;IACtC,GAAG,IAAI,IAAI,CAAA;CACZ,CAAA;AAED,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,YAAY,OAAO,GAAE,MAA+B,EAGnD;CACF;AAED,wBAAgB,aAAa,IAAI,aAAa,CAmD7C"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shutdown-latch.js","sourceRoot":"","sources":["../src/shutdown-latch.ts"],"names":[],"mappings":"AAsCA,MAAM,OAAO,uBAAwB,SAAQ,KAAK;IAChD,YAAY,
|
|
1
|
+
{"version":3,"file":"shutdown-latch.js","sourceRoot":"","sources":["../src/shutdown-latch.ts"],"names":[],"mappings":"AAsCA,MAAM,OAAO,uBAAwB,SAAQ,KAAK;IAChD,YAAY,OAAO,GAAW,sBAAsB;QAClD,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAA;IACvC,CAAC;CACF;AAED,MAAM,UAAU,aAAa;IAC3B,IAAI,WAAW,GAAG,CAAC,CAAA;IACnB,IAAI,YAAY,GAAG,KAAK,CAAA;IACxB,IAAI,YAAY,GAAwB,IAAI,CAAA;IAE5C,SAAS,YAAY;QACnB,IAAI,YAAY,IAAI,WAAW,KAAK,CAAC,IAAI,YAAY,EAAE,CAAC;YACtD,YAAY,EAAE,CAAA;YACd,YAAY,GAAG,IAAI,CAAA;QACrB,CAAC;IACH,CAAC;IAED,OAAO;QACL,gBAAgB;YACd,IAAI,YAAY,EAAE,CAAC;gBACjB,MAAM,IAAI,uBAAuB,EAAE,CAAA;YACrC,CAAC;YAED,WAAW,EAAE,CAAA;YACb,IAAI,KAAK,GAAG,KAAK,CAAA;YAEjB,OAAO;gBACL,GAAG;oBACD,IAAI,KAAK;wBAAE,OAAM;oBACjB,KAAK,GAAG,IAAI,CAAA;oBACZ,WAAW,EAAE,CAAA;oBACb,YAAY,EAAE,CAAA;gBAChB,CAAC;aACF,CAAA;QACH,CAAC;QAED,gBAAgB;YACd,YAAY,GAAG,IAAI,CAAA;YAEnB,IAAI,WAAW,KAAK,CAAC,EAAE,CAAC;gBACtB,OAAO,OAAO,CAAC,OAAO,EAAE,CAAA;YAC1B,CAAC;YAED,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;gBAC7B,YAAY,GAAG,OAAO,CAAA;YACxB,CAAC,CAAC,CAAA;QACJ,CAAC;QAED,IAAI,YAAY;YACd,OAAO,YAAY,CAAA;QACrB,CAAC;QAED,IAAI,WAAW;YACb,OAAO,WAAW,CAAA;QACpB,CAAC;KACF,CAAA;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kronos-ts/axon-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Axon Server extension for Kronos — event store and command bus over Axon Server.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"author": "Theo Emanuelsson",
|
|
8
|
-
"homepage": "https://github.com/KronosDB/kronos-ts/tree/main/packages/
|
|
8
|
+
"homepage": "https://github.com/KronosDB/kronos-ts/tree/main/packages/axon-server#readme",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/KronosDB/kronos-ts.git",
|
|
12
|
-
"directory": "packages/
|
|
12
|
+
"directory": "packages/axon-server"
|
|
13
13
|
},
|
|
14
14
|
"bugs": {
|
|
15
15
|
"url": "https://github.com/KronosDB/kronos-ts/issues"
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"!src/**/*.bench.ts"
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
|
-
"generate-proto": "grpc_tools_node_protoc --ts_proto_out=./src/generated --ts_proto_opt=outputServices=nice-grpc,outputServices=generic-definitions,useExactTypes=false,esModuleInterop=true --proto_path=./proto ./proto/*.proto",
|
|
36
|
+
"generate-proto": "grpc_tools_node_protoc --ts_proto_out=./src/generated --ts_proto_opt=outputServices=nice-grpc,outputServices=generic-definitions,useExactTypes=false,esModuleInterop=true --proto_path=./proto ./proto/*.proto && bun ../../scripts/interface-to-type/convert.ts ./src/generated",
|
|
37
37
|
"build": "tsc -p tsconfig.json",
|
|
38
38
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
39
39
|
},
|
|
@@ -49,11 +49,7 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@kronos-ts/
|
|
53
|
-
"@kronos-ts/app": "0.6.2",
|
|
54
|
-
"@kronos-ts/eventsourcing": "0.4.2",
|
|
55
|
-
"@kronos-ts/messaging": "0.11.0",
|
|
56
|
-
"@kronos-ts/modelling": "0.3.2",
|
|
52
|
+
"@kronos-ts/core": "0.3.0",
|
|
57
53
|
"@bufbuild/protobuf": "^2.3",
|
|
58
54
|
"@grpc/grpc-js": "^1.12",
|
|
59
55
|
"nice-grpc": "^2.1",
|
|
@@ -3,15 +3,15 @@ import {
|
|
|
3
3
|
qualifiedNameFromString,
|
|
4
4
|
type Tag,
|
|
5
5
|
type Serializer,
|
|
6
|
-
} from "@kronos-ts/
|
|
6
|
+
} from "@kronos-ts/core"
|
|
7
7
|
import type {
|
|
8
8
|
EventCriteria,
|
|
9
9
|
EventMessage,
|
|
10
10
|
MessageStream,
|
|
11
11
|
SequencedEvent,
|
|
12
12
|
StreamingCondition,
|
|
13
|
-
} from "@kronos-ts/
|
|
14
|
-
import { messageStream } from "@kronos-ts/
|
|
13
|
+
} from "@kronos-ts/core"
|
|
14
|
+
import { messageStream, compileQuery } from "@kronos-ts/core"
|
|
15
15
|
import type {
|
|
16
16
|
EventStore,
|
|
17
17
|
SourcingResult,
|
|
@@ -19,11 +19,12 @@ import type {
|
|
|
19
19
|
AppendCondition,
|
|
20
20
|
ConsistencyMarker,
|
|
21
21
|
AppendTransaction,
|
|
22
|
-
} from "@kronos-ts/
|
|
23
|
-
import type { TrackingToken } from "@kronos-ts/
|
|
24
|
-
import { globalSequenceToken, FIRST_TOKEN } from "@kronos-ts/
|
|
25
|
-
import { markerAt, noMarker } from "@kronos-ts/
|
|
26
|
-
import type {
|
|
22
|
+
} from "@kronos-ts/core"
|
|
23
|
+
import type { TrackingToken } from "@kronos-ts/core"
|
|
24
|
+
import { globalSequenceToken, FIRST_TOKEN } from "@kronos-ts/core"
|
|
25
|
+
import { markerAt, noMarker } from "@kronos-ts/core"
|
|
26
|
+
import type { AxonServerStoreSource } from "./connection.js"
|
|
27
|
+
import { contextView } from "./context-view.js"
|
|
27
28
|
import type {
|
|
28
29
|
Criterion,
|
|
29
30
|
TagsAndNamesCriterion,
|
|
@@ -32,7 +33,6 @@ import type {
|
|
|
32
33
|
Event as ProtoEvent,
|
|
33
34
|
SourceEventsResponse,
|
|
34
35
|
} from "./generated/dcb.js"
|
|
35
|
-
import { Metadata } from "nice-grpc"
|
|
36
36
|
|
|
37
37
|
// ---------------------------------------------------------------------------
|
|
38
38
|
// Criteria conversion — framework EventCriteria → proto Criterion[]
|
|
@@ -57,24 +57,26 @@ function tagFromProto(tag: ProtoTag): Tag {
|
|
|
57
57
|
function criteriaToCriterions(criteria: EventCriteria): Criterion[] {
|
|
58
58
|
switch (criteria.kind) {
|
|
59
59
|
case "tags":
|
|
60
|
-
return [
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
tagsAndNames: {
|
|
63
|
+
name: [],
|
|
64
|
+
tag: criteria.tags.map(tagToProto),
|
|
65
|
+
},
|
|
64
66
|
},
|
|
65
|
-
|
|
67
|
+
]
|
|
66
68
|
|
|
67
69
|
case "type-restricted":
|
|
68
70
|
// Inner must be tags or any-tag
|
|
69
|
-
const innerTags = criteria.inner.kind === "tags"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
const innerTags = criteria.inner.kind === "tags" ? criteria.inner.tags.map(tagToProto) : []
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
tagsAndNames: {
|
|
75
|
+
name: [...criteria.types],
|
|
76
|
+
tag: innerTags,
|
|
77
|
+
},
|
|
76
78
|
},
|
|
77
|
-
|
|
79
|
+
]
|
|
78
80
|
|
|
79
81
|
case "either":
|
|
80
82
|
// Flatten all sub-criteria into a list of criterions (OR semantics)
|
|
@@ -111,9 +113,14 @@ function createEventConverters(serializer: Serializer) {
|
|
|
111
113
|
},
|
|
112
114
|
|
|
113
115
|
eventFromProto(protoEvent: ProtoEvent, tags: ProtoTag[]): EventMessage {
|
|
114
|
-
const payload =
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
const payload =
|
|
117
|
+
protoEvent.payload.length > 0
|
|
118
|
+
? serializer.deserialize({
|
|
119
|
+
data: protoEvent.payload,
|
|
120
|
+
type: protoEvent.name,
|
|
121
|
+
revision: protoEvent.version,
|
|
122
|
+
})
|
|
123
|
+
: {}
|
|
117
124
|
|
|
118
125
|
const metadata: Record<string, unknown> = {}
|
|
119
126
|
for (const [k, v] of Object.entries(protoEvent.metadata)) {
|
|
@@ -139,27 +146,37 @@ function createEventConverters(serializer: Serializer) {
|
|
|
139
146
|
// ---------------------------------------------------------------------------
|
|
140
147
|
|
|
141
148
|
/**
|
|
142
|
-
*
|
|
149
|
+
* An EventStore over Axon Server's DCB event store, in one context.
|
|
143
150
|
*
|
|
144
151
|
* This bridges the framework's EventStore interface to the gRPC
|
|
145
152
|
* DcbEventStore service, handling conversion between framework types
|
|
146
153
|
* and proto messages.
|
|
147
154
|
*
|
|
155
|
+
* `context` is Axon Server's tenancy boundary and is a per-call header, so
|
|
156
|
+
* several contexts share the one channel `conn` holds — see `contextView`. The
|
|
157
|
+
* serializer is the connection's: one codec for everything this client puts on
|
|
158
|
+
* the wire.
|
|
159
|
+
*
|
|
148
160
|
* The {@link open} method returns a persistent {@link MessageStream} backed by
|
|
149
161
|
* a single gRPC Stream RPC call that stays open indefinitely, aligned with
|
|
150
162
|
* Java's infinite {@code ResultStream}.
|
|
151
163
|
*/
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
}
|
|
154
176
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (connection.config.token) {
|
|
159
|
-
axonMetadata.set("AxonIQ-Access-Token", connection.config.token)
|
|
160
|
-
}
|
|
161
|
-
return axonMetadata
|
|
162
|
-
}
|
|
177
|
+
export function axonServerEventStore(conn: AxonServerStoreSource, context: string): EventStore {
|
|
178
|
+
const { connection, serializer, metadata: createAxonMetadata } = contextView(conn, context)
|
|
179
|
+
const { eventToProto, eventFromProto } = createEventConverters(serializer)
|
|
163
180
|
|
|
164
181
|
// Push-based subscriber registry (EventBus.subscribe contract). Axon Server's
|
|
165
182
|
// own distribution is the server-side Stream RPC (see open()); these in-process
|
|
@@ -177,31 +194,50 @@ export function axonServerEventStore(connection: AxonServerConnection, serialize
|
|
|
177
194
|
|
|
178
195
|
return {
|
|
179
196
|
async source(condition: SourcingCondition): Promise<SourcingResult> {
|
|
180
|
-
const criterions = criteriaToCriterions(condition.
|
|
197
|
+
const criterions = criteriaToCriterions(compileQuery(condition.query))
|
|
198
|
+
const start = condition.start ?? 0n
|
|
181
199
|
|
|
182
200
|
const request = {
|
|
183
|
-
fromSequence:
|
|
201
|
+
fromSequence: start,
|
|
184
202
|
criterion: criterions,
|
|
185
203
|
}
|
|
186
204
|
|
|
187
205
|
const events: EventMessage[] = []
|
|
188
206
|
let marker: ConsistencyMarker = noMarker()
|
|
189
207
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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)
|
|
200
223
|
}
|
|
201
224
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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() }
|
|
205
241
|
}
|
|
206
242
|
|
|
207
243
|
return { events, marker }
|
|
@@ -213,10 +249,12 @@ export function axonServerEventStore(connection: AxonServerConnection, serialize
|
|
|
213
249
|
): Promise<AppendTransaction> {
|
|
214
250
|
const taggedEvents = newEvents.map(eventToProto)
|
|
215
251
|
const request = {
|
|
216
|
-
condition: condition
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
252
|
+
condition: condition
|
|
253
|
+
? {
|
|
254
|
+
consistencyMarker: condition.marker.position,
|
|
255
|
+
criterion: criteriaToCriterions(compileQuery(condition.query)),
|
|
256
|
+
}
|
|
257
|
+
: undefined,
|
|
220
258
|
event: taggedEvents,
|
|
221
259
|
}
|
|
222
260
|
|
|
@@ -229,7 +267,9 @@ export function axonServerEventStore(connection: AxonServerConnection, serialize
|
|
|
229
267
|
async function* requestStream() {
|
|
230
268
|
yield request
|
|
231
269
|
}
|
|
232
|
-
const response = await connection.eventStore.append(requestStream(), {
|
|
270
|
+
const response = await connection.eventStore.append(requestStream(), {
|
|
271
|
+
metadata: createAxonMetadata(),
|
|
272
|
+
})
|
|
233
273
|
responseMarker = response.consistencyMarker
|
|
234
274
|
await notifySubscribers(newEvents)
|
|
235
275
|
},
|
|
@@ -252,7 +292,7 @@ export function axonServerEventStore(connection: AxonServerConnection, serialize
|
|
|
252
292
|
},
|
|
253
293
|
|
|
254
294
|
open(condition: StreamingCondition): MessageStream<SequencedEvent> {
|
|
255
|
-
const criterions = condition.
|
|
295
|
+
const criterions = condition.query ? criteriaToCriterions(compileQuery(condition.query)) : []
|
|
256
296
|
|
|
257
297
|
const request = {
|
|
258
298
|
fromSequence: condition.position,
|
|
@@ -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
|
+
}
|