@furystack/cross-node-bus 1.0.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/CHANGELOG.md +78 -0
- package/README.md +49 -0
- package/esm/cross-node-bus-telemetry.d.ts +74 -0
- package/esm/cross-node-bus-telemetry.d.ts.map +1 -0
- package/esm/cross-node-bus-telemetry.js +28 -0
- package/esm/cross-node-bus-telemetry.js.map +1 -0
- package/esm/cross-node-bus-telemetry.spec.d.ts +2 -0
- package/esm/cross-node-bus-telemetry.spec.d.ts.map +1 -0
- package/esm/cross-node-bus-telemetry.spec.js +115 -0
- package/esm/cross-node-bus-telemetry.spec.js.map +1 -0
- package/esm/cross-node-bus.d.ts +78 -0
- package/esm/cross-node-bus.d.ts.map +1 -0
- package/esm/cross-node-bus.js +18 -0
- package/esm/cross-node-bus.js.map +1 -0
- package/esm/cross-node-bus.spec.d.ts +2 -0
- package/esm/cross-node-bus.spec.d.ts.map +1 -0
- package/esm/cross-node-bus.spec.js +123 -0
- package/esm/cross-node-bus.spec.js.map +1 -0
- package/esm/define-in-process-cross-node-bus.d.ts +26 -0
- package/esm/define-in-process-cross-node-bus.d.ts.map +1 -0
- package/esm/define-in-process-cross-node-bus.js +27 -0
- package/esm/define-in-process-cross-node-bus.js.map +1 -0
- package/esm/errors.d.ts +21 -0
- package/esm/errors.d.ts.map +1 -0
- package/esm/errors.js +30 -0
- package/esm/errors.js.map +1 -0
- package/esm/errors.spec.d.ts +2 -0
- package/esm/errors.spec.d.ts.map +1 -0
- package/esm/errors.spec.js +30 -0
- package/esm/errors.spec.js.map +1 -0
- package/esm/in-process-cross-node-bus.d.ts +58 -0
- package/esm/in-process-cross-node-bus.d.ts.map +1 -0
- package/esm/in-process-cross-node-bus.js +196 -0
- package/esm/in-process-cross-node-bus.js.map +1 -0
- package/esm/in-process-cross-node-bus.spec.d.ts +2 -0
- package/esm/in-process-cross-node-bus.spec.d.ts.map +1 -0
- package/esm/in-process-cross-node-bus.spec.js +737 -0
- package/esm/in-process-cross-node-bus.spec.js.map +1 -0
- package/esm/index.d.ts +8 -0
- package/esm/index.d.ts.map +1 -0
- package/esm/index.js +7 -0
- package/esm/index.js.map +1 -0
- package/esm/memory-broker.d.ts +74 -0
- package/esm/memory-broker.d.ts.map +1 -0
- package/esm/memory-broker.js +156 -0
- package/esm/memory-broker.js.map +1 -0
- package/esm/memory-broker.spec.d.ts +2 -0
- package/esm/memory-broker.spec.d.ts.map +1 -0
- package/esm/memory-broker.spec.js +497 -0
- package/esm/memory-broker.spec.js.map +1 -0
- package/esm/testing/create-in-process-bus-network.d.ts +49 -0
- package/esm/testing/create-in-process-bus-network.d.ts.map +1 -0
- package/esm/testing/create-in-process-bus-network.js +54 -0
- package/esm/testing/create-in-process-bus-network.js.map +1 -0
- package/esm/testing/create-in-process-bus-network.spec.d.ts +2 -0
- package/esm/testing/create-in-process-bus-network.spec.d.ts.map +1 -0
- package/esm/testing/create-in-process-bus-network.spec.js +142 -0
- package/esm/testing/create-in-process-bus-network.spec.js.map +1 -0
- package/esm/testing/index.d.ts +2 -0
- package/esm/testing/index.d.ts.map +1 -0
- package/esm/testing/index.js +2 -0
- package/esm/testing/index.js.map +1 -0
- package/esm/types.d.ts +35 -0
- package/esm/types.d.ts.map +1 -0
- package/esm/types.js +2 -0
- package/esm/types.js.map +1 -0
- package/package.json +56 -0
- package/src/cross-node-bus-telemetry.spec.ts +44 -0
- package/src/cross-node-bus-telemetry.ts +69 -0
- package/src/cross-node-bus.spec.ts +41 -0
- package/src/cross-node-bus.ts +92 -0
- package/src/define-in-process-cross-node-bus.ts +38 -0
- package/src/errors.spec.ts +32 -0
- package/src/errors.ts +38 -0
- package/src/in-process-cross-node-bus.spec.ts +428 -0
- package/src/in-process-cross-node-bus.ts +248 -0
- package/src/index.ts +7 -0
- package/src/memory-broker.spec.ts +282 -0
- package/src/memory-broker.ts +199 -0
- package/src/testing/create-in-process-bus-network.spec.ts +73 -0
- package/src/testing/create-in-process-bus-network.ts +87 -0
- package/src/testing/index.ts +1 -0
- package/src/types.ts +35 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
2
|
+
if (value !== null && value !== void 0) {
|
|
3
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
4
|
+
var dispose, inner;
|
|
5
|
+
if (async) {
|
|
6
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
7
|
+
dispose = value[Symbol.asyncDispose];
|
|
8
|
+
}
|
|
9
|
+
if (dispose === void 0) {
|
|
10
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
11
|
+
dispose = value[Symbol.dispose];
|
|
12
|
+
if (async) inner = dispose;
|
|
13
|
+
}
|
|
14
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
15
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
16
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
17
|
+
}
|
|
18
|
+
else if (async) {
|
|
19
|
+
env.stack.push({ async: true });
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
24
|
+
return function (env) {
|
|
25
|
+
function fail(e) {
|
|
26
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
27
|
+
env.hasError = true;
|
|
28
|
+
}
|
|
29
|
+
var r, s = 0;
|
|
30
|
+
function next() {
|
|
31
|
+
while (r = env.stack.pop()) {
|
|
32
|
+
try {
|
|
33
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
34
|
+
if (r.dispose) {
|
|
35
|
+
var result = r.dispose.call(r.value);
|
|
36
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
37
|
+
}
|
|
38
|
+
else s |= 1;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
fail(e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
45
|
+
if (env.hasError) throw env.error;
|
|
46
|
+
}
|
|
47
|
+
return next();
|
|
48
|
+
};
|
|
49
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
+
var e = new Error(message);
|
|
51
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
+
});
|
|
53
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
54
|
+
import { createInProcessBusNetwork } from './create-in-process-bus-network.js';
|
|
55
|
+
describe('createInProcessBusNetwork', () => {
|
|
56
|
+
it('mints `count` buses sharing one broker', async () => {
|
|
57
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
58
|
+
try {
|
|
59
|
+
const network = __addDisposableResource(env_1, createInProcessBusNetwork({ count: 3 }), false);
|
|
60
|
+
expect(network.buses).toHaveLength(3);
|
|
61
|
+
expect(network.broker).toBeDefined();
|
|
62
|
+
const handlerB = vi.fn();
|
|
63
|
+
const handlerC = vi.fn();
|
|
64
|
+
const _subB = __addDisposableResource(env_1, network.buses[1].subscribe('topic', handlerB), false);
|
|
65
|
+
const _subC = __addDisposableResource(env_1, network.buses[2].subscribe('topic', handlerC), false);
|
|
66
|
+
await network.buses[0].publish('topic', { from: 0 });
|
|
67
|
+
expect(handlerB).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(handlerC).toHaveBeenCalledTimes(1);
|
|
69
|
+
}
|
|
70
|
+
catch (e_1) {
|
|
71
|
+
env_1.error = e_1;
|
|
72
|
+
env_1.hasError = true;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
__disposeResources(env_1);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
it('honors per-bus topicPrefixes for multi-service simulation', async () => {
|
|
79
|
+
const env_2 = { stack: [], error: void 0, hasError: false };
|
|
80
|
+
try {
|
|
81
|
+
const network = __addDisposableResource(env_2, createInProcessBusNetwork({
|
|
82
|
+
count: 2,
|
|
83
|
+
topicPrefixes: ['svc-a/', 'svc-b/'],
|
|
84
|
+
}), false);
|
|
85
|
+
const [busA, busB] = network.buses;
|
|
86
|
+
const onB = vi.fn();
|
|
87
|
+
const eavesdrop = vi.fn();
|
|
88
|
+
const _subB = __addDisposableResource(env_2, busB.subscribe('events', onB), false);
|
|
89
|
+
const _foreign = __addDisposableResource(env_2, busB.subscribeForeign('svc-a/', 'events', eavesdrop), false);
|
|
90
|
+
await busA.publish('events', { from: 'a' });
|
|
91
|
+
expect(onB).not.toHaveBeenCalled();
|
|
92
|
+
expect(eavesdrop).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect((eavesdrop.mock.calls[0]?.[0]).originId).toBe(busA.nodeId);
|
|
94
|
+
}
|
|
95
|
+
catch (e_2) {
|
|
96
|
+
env_2.error = e_2;
|
|
97
|
+
env_2.hasError = true;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
__disposeResources(env_2);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
it('honors per-bus nodeIds', () => {
|
|
104
|
+
const env_3 = { stack: [], error: void 0, hasError: false };
|
|
105
|
+
try {
|
|
106
|
+
const network = __addDisposableResource(env_3, createInProcessBusNetwork({
|
|
107
|
+
count: 2,
|
|
108
|
+
nodeIds: ['alice', 'bob'],
|
|
109
|
+
}), false);
|
|
110
|
+
expect(network.buses[0].nodeId).toBe('alice');
|
|
111
|
+
expect(network.buses[1].nodeId).toBe('bob');
|
|
112
|
+
}
|
|
113
|
+
catch (e_3) {
|
|
114
|
+
env_3.error = e_3;
|
|
115
|
+
env_3.hasError = true;
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
__disposeResources(env_3);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
it('disposes every bus and the broker on teardown', () => {
|
|
122
|
+
const network = createInProcessBusNetwork({ count: 2 });
|
|
123
|
+
network[Symbol.dispose]();
|
|
124
|
+
for (const bus of network.buses) {
|
|
125
|
+
expect(() => bus.subscribe('topic', () => undefined)).toThrow(/disposed/);
|
|
126
|
+
}
|
|
127
|
+
expect(() => network.broker.publish('topic', 'a', null)).toThrow(/disposed/);
|
|
128
|
+
});
|
|
129
|
+
describe('input validation', () => {
|
|
130
|
+
it('rejects non-positive count', () => {
|
|
131
|
+
expect(() => createInProcessBusNetwork({ count: 0 })).toThrow(RangeError);
|
|
132
|
+
expect(() => createInProcessBusNetwork({ count: 1.5 })).toThrow(RangeError);
|
|
133
|
+
});
|
|
134
|
+
it('rejects mismatched topicPrefixes length', () => {
|
|
135
|
+
expect(() => createInProcessBusNetwork({ count: 2, topicPrefixes: ['only-one/'] })).toThrow(RangeError);
|
|
136
|
+
});
|
|
137
|
+
it('rejects mismatched nodeIds length', () => {
|
|
138
|
+
expect(() => createInProcessBusNetwork({ count: 2, nodeIds: ['only-one'] })).toThrow(RangeError);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
//# sourceMappingURL=create-in-process-bus-network.spec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-in-process-bus-network.spec.js","sourceRoot":"","sources":["../../src/testing/create-in-process-bus-network.spec.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AAEjD,OAAO,EAAE,yBAAyB,EAAE,MAAM,oCAAoC,CAAA;AAE9E,QAAQ,CAAC,2BAA2B,EAAE,GAAG,EAAE;IACzC,EAAE,CAAC,wCAAwC,EAAE,KAAK,IAAI,EAAE;;;YACtD,MAAM,OAAO,kCAAG,yBAAyB,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,QAAA,CAAA;YACvD,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YACrC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAA;YAEpC,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;YACxB,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;YACxB,MAAM,KAAK,kCAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,QAAA,CAAA;YAC3D,MAAM,KAAK,kCAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,QAAA,CAAA;YAE3D,MAAM,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAA;YAEpD,MAAM,CAAC,QAAQ,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAA;YACzC,MAAM,CAAC,QAAQ,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAA;;;;;;;;;KAC1C,CAAC,CAAA;IAEF,EAAE,CAAC,2DAA2D,EAAE,KAAK,IAAI,EAAE;;;YACzE,MAAM,OAAO,kCAAG,yBAAyB,CAAC;gBACxC,KAAK,EAAE,CAAC;gBACR,aAAa,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC;aACpC,CAAC,QAAA,CAAA;YACF,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,OAAO,CAAC,KAAK,CAAA;YAClC,MAAM,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;YACnB,MAAM,SAAS,GAAG,EAAE,CAAC,EAAE,EAAE,CAAA;YAEzB,MAAM,KAAK,kCAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,GAAG,CAAC,QAAA,CAAA;YAC3C,MAAM,QAAQ,kCAAG,IAAI,CAAC,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,CAAC,QAAA,CAAA;YAErE,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAA;YAE3C,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAA;YAClC,MAAM,CAAC,SAAS,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAA;YAC1C,MAAM,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAgB,CAAA,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;;;;;;;;;KAChF,CAAC,CAAA;IAEF,EAAE,CAAC,wBAAwB,EAAE,GAAG,EAAE;;;YAChC,MAAM,OAAO,kCAAG,yBAAyB,CAAC;gBACxC,KAAK,EAAE,CAAC;gBACR,OAAO,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC;aAC1B,CAAC,QAAA,CAAA;YACF,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;YAC7C,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;;;;;;;;;KAC5C,CAAC,CAAA;IAEF,EAAE,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACvD,MAAM,OAAO,GAAG,yBAAyB,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAA;QACvD,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAA;QACzB,KAAK,MAAM,GAAG,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;YAChC,MAAM,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;QAC3E,CAAC;QACD,MAAM,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;IAC9E,CAAC,CAAC,CAAA;IAEF,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;QAChC,EAAE,CAAC,4BAA4B,EAAE,GAAG,EAAE;YACpC,MAAM,CAAC,GAAG,EAAE,CAAC,yBAAyB,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;YACzE,MAAM,CAAC,GAAG,EAAE,CAAC,yBAAyB,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;QAC7E,CAAC,CAAC,CAAA;QAEF,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;YACjD,MAAM,CAAC,GAAG,EAAE,CAAC,yBAAyB,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,aAAa,EAAE,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;QACzG,CAAC,CAAC,CAAA;QAEF,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;YAC3C,MAAM,CAAC,GAAG,EAAE,CAAC,yBAAyB,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;QAClG,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,cAAc,oCAAoC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,cAAc,oCAAoC,CAAA"}
|
package/esm/types.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message envelope delivered to subscribers and emitted on the wire by every
|
|
3
|
+
* adapter. Adapters refuse messages whose {@link BusMessage.v} they do not
|
|
4
|
+
* recognise — see the rolling-deploy strategy in
|
|
5
|
+
* `docs/internal/cross-node-bus-spike.md` §12.
|
|
6
|
+
*/
|
|
7
|
+
export type BusMessage = {
|
|
8
|
+
/** Wire-format version. Adapters refuse incompatible versions. */
|
|
9
|
+
readonly v: 1;
|
|
10
|
+
/** `nodeId` of the publisher. */
|
|
11
|
+
readonly originId: string;
|
|
12
|
+
/** ISO-8601 publish timestamp from the publisher's clock (diagnostic only). */
|
|
13
|
+
readonly emittedAt: string;
|
|
14
|
+
/**
|
|
15
|
+
* Adapter-assigned per-topic monotonic id. Optional because non-sequencing
|
|
16
|
+
* adapters do not provide one.
|
|
17
|
+
*/
|
|
18
|
+
readonly seq?: string;
|
|
19
|
+
/** Caller-supplied payload. Must be JSON-serializable. */
|
|
20
|
+
readonly payload: unknown;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Static description of a transport's behavior. Declared by every adapter
|
|
24
|
+
* and asserted by facades at registration time so misconfigured deployments
|
|
25
|
+
* fail loudly rather than serving stale data.
|
|
26
|
+
*/
|
|
27
|
+
export type CrossNodeBusCapabilities = {
|
|
28
|
+
/** Messages survive process restarts. */
|
|
29
|
+
readonly persistent: boolean;
|
|
30
|
+
/** Replay returns retained messages on demand. */
|
|
31
|
+
readonly replay: boolean;
|
|
32
|
+
/** Adapter assigns a server-monotonic {@link BusMessage.seq}. */
|
|
33
|
+
readonly assignsSequence: boolean;
|
|
34
|
+
};
|
|
35
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,kEAAkE;IAClE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAA;IACb,iCAAiC;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,+EAA+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,0DAA0D;IAC1D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAC1B,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,yCAAyC;IACzC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;IAC5B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,iEAAiE;IACjE,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAA;CAClC,CAAA"}
|
package/esm/types.js
ADDED
package/esm/types.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@furystack/cross-node-bus",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Transport-agnostic cross-process publish/subscribe primitive for FuryStack",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc --outDir ./esm"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./esm/index.d.ts",
|
|
12
|
+
"import": "./esm/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./testing": {
|
|
15
|
+
"types": "./esm/testing/index.d.ts",
|
|
16
|
+
"import": "./esm/testing/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"esm",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/furystack/furystack.git"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"FuryStack",
|
|
29
|
+
"pubsub",
|
|
30
|
+
"event-bus",
|
|
31
|
+
"cross-node",
|
|
32
|
+
"in-process"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"author": "Gallay Lajos <gallay.lajos@gmail.com>",
|
|
38
|
+
"license": "GPL-2.0",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/furystack/furystack/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/furystack/furystack",
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@furystack/inject": "^13.0.1",
|
|
45
|
+
"@furystack/utils": "^9.0.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^25.9.1",
|
|
49
|
+
"typescript": "^6.0.3",
|
|
50
|
+
"vitest": "^4.1.7"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22.0.0"
|
|
54
|
+
},
|
|
55
|
+
"sideEffects": false
|
|
56
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createInjector } from '@furystack/inject'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { CrossNodeBusTelemetry, CrossNodeBusTelemetryToken } from './cross-node-bus-telemetry.js'
|
|
4
|
+
|
|
5
|
+
describe('CrossNodeBusTelemetryToken', () => {
|
|
6
|
+
it('resolves a CrossNodeBusTelemetry instance', async () => {
|
|
7
|
+
await using injector = createInjector()
|
|
8
|
+
const telemetry = injector.get(CrossNodeBusTelemetryToken)
|
|
9
|
+
expect(telemetry).toBeInstanceOf(CrossNodeBusTelemetry)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('disposes the telemetry instance with the injector scope', async () => {
|
|
13
|
+
const injector = createInjector()
|
|
14
|
+
const telemetry = injector.get(CrossNodeBusTelemetryToken)
|
|
15
|
+
const handler = vi.fn()
|
|
16
|
+
telemetry.addListener('onCrossNodePublished', handler)
|
|
17
|
+
await injector[Symbol.asyncDispose]()
|
|
18
|
+
|
|
19
|
+
telemetry.emit('onCrossNodePublished', { topic: 'x', originId: 'y', byteLength: 0 })
|
|
20
|
+
expect(handler).not.toHaveBeenCalled()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('shares the singleton instance across child scopes', async () => {
|
|
24
|
+
await using injector = createInjector()
|
|
25
|
+
await using scopeA = injector.createScope({ owner: 'a' })
|
|
26
|
+
await using scopeB = injector.createScope({ owner: 'b' })
|
|
27
|
+
|
|
28
|
+
const telA = scopeA.get(CrossNodeBusTelemetryToken)
|
|
29
|
+
const telB = scopeB.get(CrossNodeBusTelemetryToken)
|
|
30
|
+
|
|
31
|
+
expect(telA).toBe(telB)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('issues independent instances per root injector', () => {
|
|
35
|
+
const a = createInjector()
|
|
36
|
+
const b = createInjector()
|
|
37
|
+
try {
|
|
38
|
+
expect(a.get(CrossNodeBusTelemetryToken)).not.toBe(b.get(CrossNodeBusTelemetryToken))
|
|
39
|
+
} finally {
|
|
40
|
+
void a[Symbol.asyncDispose]()
|
|
41
|
+
void b[Symbol.asyncDispose]()
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineService, type Token } from '@furystack/inject'
|
|
2
|
+
import { EventHub, type ListenerErrorPayload } from '@furystack/utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Phase of the bus pipeline an `onCrossNodeError` event refers to. `serialize`
|
|
6
|
+
* is reserved for future adapters that fail when JSON-encoding payloads;
|
|
7
|
+
* the in-process default never produces it.
|
|
8
|
+
*/
|
|
9
|
+
export type CrossNodeBusErrorPhase = 'publish' | 'subscribe' | 'subscribeForeign' | 'replay' | 'serialize'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Union of telemetry signals emitted by every {@link CrossNodeBus} adapter.
|
|
13
|
+
* Each adapter forwards into the same hub so subscribers can observe the
|
|
14
|
+
* whole bus surface from one place — independent of which transport is
|
|
15
|
+
* bound.
|
|
16
|
+
*/
|
|
17
|
+
export type CrossNodeBusTelemetryEvents = {
|
|
18
|
+
/** Fired after a message has been accepted by the transport. */
|
|
19
|
+
onCrossNodePublished: { topic: string; originId: string; byteLength: number }
|
|
20
|
+
/**
|
|
21
|
+
* Fired once per message arriving at the bus, before local fan-out.
|
|
22
|
+
* `lagMs = Date.now() - Date.parse(message.emittedAt)`; clock skew can
|
|
23
|
+
* produce negative values, which adapters report verbatim.
|
|
24
|
+
*/
|
|
25
|
+
onCrossNodeReceived: { topic: string; originId: string; lagMs: number }
|
|
26
|
+
onCrossNodeError: { topic: string; error: unknown; phase: CrossNodeBusErrorPhase }
|
|
27
|
+
/**
|
|
28
|
+
* Fired when an adapter that owns its replay buffer drops a retained
|
|
29
|
+
* message to honor the configured replay window. Operators alert on the
|
|
30
|
+
* trend so the window can be tuned before reconnecting clients start
|
|
31
|
+
* hitting `ReplayWindowExceededError`.
|
|
32
|
+
*
|
|
33
|
+
* Only adapters that own the buffer emit this signal — today that is
|
|
34
|
+
* {@link InProcessCrossNodeBus} via {@link MemoryBroker}. Network-broker
|
|
35
|
+
* adapters that delegate trimming to the broker (Redis Streams' `MAXLEN`,
|
|
36
|
+
* NATS JetStream's max-bytes) cannot observe individual evictions on the
|
|
37
|
+
* client side; consumers needing that signal should read it from the
|
|
38
|
+
* broker's native metrics (e.g. `redis_streams_length` from the Prom
|
|
39
|
+
* exporter).
|
|
40
|
+
*/
|
|
41
|
+
onCrossNodeWindowEvicted: { topic: string; evictedSeq: string; retainedCount: number }
|
|
42
|
+
onListenerError: ListenerErrorPayload
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Application-facing telemetry surface for the cross-node bus. Subscribers
|
|
47
|
+
* use the standard {@link EventHub} `addListener` / `subscribe` API.
|
|
48
|
+
*/
|
|
49
|
+
export class CrossNodeBusTelemetry extends EventHub<CrossNodeBusTelemetryEvents> {}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* DI token for the shared {@link CrossNodeBusTelemetry} instance.
|
|
53
|
+
*
|
|
54
|
+
* Singleton because the bus token itself is singleton — co-locating both at
|
|
55
|
+
* the root injector keeps every override factory (including transport
|
|
56
|
+
* adapters) free to inject telemetry without lifetime-compatibility
|
|
57
|
+
* gymnastics. Each test still gets isolation by minting its own root
|
|
58
|
+
* injector with `createInjector()`.
|
|
59
|
+
*/
|
|
60
|
+
export const CrossNodeBusTelemetryToken: Token<CrossNodeBusTelemetry, 'singleton'> = defineService({
|
|
61
|
+
name: 'furystack/cross-node-bus/CrossNodeBusTelemetry',
|
|
62
|
+
lifetime: 'singleton',
|
|
63
|
+
factory: ({ onDispose }) => {
|
|
64
|
+
const telemetry = new CrossNodeBusTelemetry()
|
|
65
|
+
// eslint-disable-next-line furystack/prefer-using-wrapper -- delegated to onDispose
|
|
66
|
+
onDispose(() => telemetry[Symbol.dispose]())
|
|
67
|
+
return telemetry
|
|
68
|
+
},
|
|
69
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createInjector } from '@furystack/inject'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { CrossNodeBus } from './cross-node-bus.js'
|
|
4
|
+
import { CrossNodeBusTelemetryToken } from './cross-node-bus-telemetry.js'
|
|
5
|
+
import { defineInProcessCrossNodeBus } from './define-in-process-cross-node-bus.js'
|
|
6
|
+
import { InProcessCrossNodeBus } from './in-process-cross-node-bus.js'
|
|
7
|
+
|
|
8
|
+
describe('CrossNodeBus token', () => {
|
|
9
|
+
it('resolves an InProcessCrossNodeBus by default', async () => {
|
|
10
|
+
await using injector = createInjector()
|
|
11
|
+
const bus = injector.get(CrossNodeBus)
|
|
12
|
+
expect(bus).toBeInstanceOf(InProcessCrossNodeBus)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('disposes the bus when the injector scope tears down', async () => {
|
|
16
|
+
const injector = createInjector()
|
|
17
|
+
const bus = injector.get(CrossNodeBus)
|
|
18
|
+
await injector[Symbol.asyncDispose]()
|
|
19
|
+
expect(() => bus.subscribe('topic', () => undefined)).toThrow(/disposed/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('honors override bindings via defineInProcessCrossNodeBus', async () => {
|
|
23
|
+
await using injector = createInjector()
|
|
24
|
+
injector.bind(CrossNodeBus, defineInProcessCrossNodeBus({ nodeId: 'svc-a-1', topicPrefix: 'svc-a/' }))
|
|
25
|
+
|
|
26
|
+
const bus = injector.get(CrossNodeBus)
|
|
27
|
+
expect(bus.nodeId).toBe('svc-a-1')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('publishes drive the scoped CrossNodeBusTelemetryToken', async () => {
|
|
31
|
+
await using injector = createInjector()
|
|
32
|
+
const telemetry = injector.get(CrossNodeBusTelemetryToken)
|
|
33
|
+
const handler = vi.fn()
|
|
34
|
+
using _sub = telemetry.subscribe('onCrossNodePublished', handler)
|
|
35
|
+
|
|
36
|
+
const bus = injector.get(CrossNodeBus)
|
|
37
|
+
await bus.publish('topic', { hi: 'there' })
|
|
38
|
+
|
|
39
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { defineService, type Token } from '@furystack/inject'
|
|
2
|
+
import { defineInProcessCrossNodeBus } from './define-in-process-cross-node-bus.js'
|
|
3
|
+
import type { BusMessage, CrossNodeBusCapabilities } from './types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transport-agnostic publish/subscribe primitive. Implementations talk to a
|
|
7
|
+
* concrete broker (in-process map, Redis Streams, …); facades layer typed
|
|
8
|
+
* event contracts on top.
|
|
9
|
+
*
|
|
10
|
+
* Self-delivery is on by default — a publisher receives its own messages.
|
|
11
|
+
* Subscribers that need a local-vs-remote distinction either filter on
|
|
12
|
+
* `message.originId === bus.nodeId` or use
|
|
13
|
+
* {@link CrossNodeBus.subscribeRemoteOnly}.
|
|
14
|
+
*/
|
|
15
|
+
export interface CrossNodeBus extends Disposable {
|
|
16
|
+
/** Stable, unique id of this node. Included in every published message. */
|
|
17
|
+
readonly nodeId: string
|
|
18
|
+
|
|
19
|
+
/** Static description of what this adapter can do. */
|
|
20
|
+
readonly capabilities: CrossNodeBusCapabilities
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Publishes `payload` on `topic`. Resolves once the message has been
|
|
24
|
+
* accepted by the underlying transport (not when it has been delivered to
|
|
25
|
+
* all subscribers).
|
|
26
|
+
*/
|
|
27
|
+
publish(topic: string, payload: unknown): Promise<void>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Subscribes to every message published on `topic`, including ones
|
|
31
|
+
* originating from this node.
|
|
32
|
+
*/
|
|
33
|
+
subscribe(topic: string, handler: (message: BusMessage) => void): Disposable
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Convenience for the common "I only care about messages from other nodes"
|
|
37
|
+
* pattern. Equivalent to {@link CrossNodeBus.subscribe} + filter on
|
|
38
|
+
* `message.originId !== bus.nodeId`.
|
|
39
|
+
*/
|
|
40
|
+
subscribeRemoteOnly(topic: string, handler: (message: BusMessage) => void): Disposable
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Subscribe to a topic owned by another `topicPrefix`. Explicit, greppable
|
|
44
|
+
* cross-service eavesdrop. Adapters that lack the underlying capability
|
|
45
|
+
* throw at registration time.
|
|
46
|
+
*/
|
|
47
|
+
subscribeForeign(prefix: string, topic: string, handler: (message: BusMessage) => void): Disposable
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Replay messages on `topic` whose `seq` is greater than `fromSeq`. Throws
|
|
51
|
+
* synchronously when {@link CrossNodeBusCapabilities.replay} is `false` or
|
|
52
|
+
* when `fromSeq` is older than the adapter's retained window — facades
|
|
53
|
+
* fall back to a full snapshot in the latter case.
|
|
54
|
+
*/
|
|
55
|
+
replay(topic: string, fromSeq: string): AsyncIterable<BusMessage>
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compares two adapter-issued seq tokens from the **same topic**. Returns a
|
|
59
|
+
* negative number when `a` precedes `b`, zero when equal, a positive number
|
|
60
|
+
* when `a` follows `b`. Facades use this for dedup and "have we seen newer?"
|
|
61
|
+
* checks without leaking the adapter-specific seq encoding.
|
|
62
|
+
*
|
|
63
|
+
* Behavior across topics, adapters, or for tokens this adapter never issued
|
|
64
|
+
* is undefined.
|
|
65
|
+
*/
|
|
66
|
+
compareSeq(a: string, b: string): number
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns the oldest retained seq for `topic`, or `undefined` when nothing
|
|
70
|
+
* is currently retained. Throws synchronously when
|
|
71
|
+
* {@link CrossNodeBusCapabilities.replay} is `false`. Facades use this to
|
|
72
|
+
* decide whether a delta replay is feasible before calling
|
|
73
|
+
* {@link CrossNodeBus.replay}.
|
|
74
|
+
*/
|
|
75
|
+
oldestSeq(topic: string): string | undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Shared {@link CrossNodeBus} token. Resolves an `InProcessCrossNodeBus` by
|
|
80
|
+
* default — single-node deployments work without configuration. Multi-node
|
|
81
|
+
* deployments override the binding with a transport adapter, e.g.
|
|
82
|
+
* `defineRedisCrossNodeBusAdapter({ … })`.
|
|
83
|
+
*
|
|
84
|
+
* Singleton: a single bus per injector tree is the right semantic for
|
|
85
|
+
* cross-process publish/subscribe. Tests get isolation by minting their own
|
|
86
|
+
* root injector with `createInjector()`.
|
|
87
|
+
*/
|
|
88
|
+
export const CrossNodeBus: Token<CrossNodeBus, 'singleton'> = defineService({
|
|
89
|
+
name: 'furystack/cross-node-bus/CrossNodeBus',
|
|
90
|
+
lifetime: 'singleton',
|
|
91
|
+
factory: defineInProcessCrossNodeBus(),
|
|
92
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ServiceFactory } from '@furystack/inject'
|
|
2
|
+
import { CrossNodeBusTelemetryToken } from './cross-node-bus-telemetry.js'
|
|
3
|
+
import { InProcessCrossNodeBus, type InProcessCrossNodeBusOptions } from './in-process-cross-node-bus.js'
|
|
4
|
+
import type { CrossNodeBus } from './cross-node-bus.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options accepted by {@link defineInProcessCrossNodeBus}. `telemetry` is
|
|
8
|
+
* intentionally absent — the factory always injects
|
|
9
|
+
* {@link CrossNodeBusTelemetryToken} from the surrounding scope.
|
|
10
|
+
*/
|
|
11
|
+
export type DefineInProcessCrossNodeBusOptions = Omit<InProcessCrossNodeBusOptions, 'telemetry'>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns a {@link ServiceFactory} bound to {@link CrossNodeBus}. Use it to
|
|
15
|
+
* override the default factory at boot:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* injector.bind(
|
|
19
|
+
* CrossNodeBus,
|
|
20
|
+
* defineInProcessCrossNodeBus({ topicPrefix: 'svc-a/' }),
|
|
21
|
+
* )
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Mirrors the `defineXxxCrossNodeBusAdapter` shape future transport
|
|
25
|
+
* adapters expose. Wires telemetry and disposal into the surrounding
|
|
26
|
+
* injector scope.
|
|
27
|
+
*/
|
|
28
|
+
export const defineInProcessCrossNodeBus = (
|
|
29
|
+
options: DefineInProcessCrossNodeBusOptions = {},
|
|
30
|
+
): ServiceFactory<CrossNodeBus> => {
|
|
31
|
+
return ({ inject, onDispose }) => {
|
|
32
|
+
const telemetry = inject(CrossNodeBusTelemetryToken)
|
|
33
|
+
const bus = new InProcessCrossNodeBus({ ...options, telemetry })
|
|
34
|
+
// eslint-disable-next-line furystack/prefer-using-wrapper -- delegated to onDispose
|
|
35
|
+
onDispose(() => bus[Symbol.dispose]())
|
|
36
|
+
return bus
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ReplayWindowExceededError, UnsupportedCapabilityError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
describe('errors', () => {
|
|
5
|
+
describe('ReplayWindowExceededError', () => {
|
|
6
|
+
it('captures the topic, fromSeq and oldestRetainedSeq', () => {
|
|
7
|
+
const error = new ReplayWindowExceededError('topic', '5', '7')
|
|
8
|
+
expect(error.name).toBe('ReplayWindowExceededError')
|
|
9
|
+
expect(error.topic).toBe('topic')
|
|
10
|
+
expect(error.fromSeq).toBe('5')
|
|
11
|
+
expect(error.oldestRetainedSeq).toBe('7')
|
|
12
|
+
expect(error.message).toContain('topic')
|
|
13
|
+
expect(error.message).toContain('5')
|
|
14
|
+
expect(error.message).toContain('7')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('reports "none" when no messages are retained', () => {
|
|
18
|
+
const error = new ReplayWindowExceededError('topic', '5', undefined)
|
|
19
|
+
expect(error.oldestRetainedSeq).toBeUndefined()
|
|
20
|
+
expect(error.message).toContain('none')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('UnsupportedCapabilityError', () => {
|
|
25
|
+
it('captures the missing capability name', () => {
|
|
26
|
+
const error = new UnsupportedCapabilityError('replay')
|
|
27
|
+
expect(error.name).toBe('UnsupportedCapabilityError')
|
|
28
|
+
expect(error.capability).toBe('replay')
|
|
29
|
+
expect(error.message).toContain('replay')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
})
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CrossNodeBusCapabilities } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown synchronously by {@link CrossNodeBus.replay} when `fromSeq` falls
|
|
5
|
+
* outside the adapter's retained window. Facades catch this and fall back
|
|
6
|
+
* to a full-snapshot path.
|
|
7
|
+
*/
|
|
8
|
+
export class ReplayWindowExceededError extends Error {
|
|
9
|
+
public readonly topic: string
|
|
10
|
+
public readonly fromSeq: string
|
|
11
|
+
public readonly oldestRetainedSeq: string | undefined
|
|
12
|
+
|
|
13
|
+
constructor(topic: string, fromSeq: string, oldestRetainedSeq: string | undefined) {
|
|
14
|
+
super(
|
|
15
|
+
`Replay window exceeded for topic "${topic}": requested fromSeq=${fromSeq}, oldest retained=${
|
|
16
|
+
oldestRetainedSeq ?? 'none'
|
|
17
|
+
}`,
|
|
18
|
+
)
|
|
19
|
+
this.name = 'ReplayWindowExceededError'
|
|
20
|
+
this.topic = topic
|
|
21
|
+
this.fromSeq = fromSeq
|
|
22
|
+
this.oldestRetainedSeq = oldestRetainedSeq
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Thrown synchronously by an adapter when a caller invokes a method that
|
|
28
|
+
* requires a capability the adapter does not advertise.
|
|
29
|
+
*/
|
|
30
|
+
export class UnsupportedCapabilityError extends Error {
|
|
31
|
+
public readonly capability: keyof CrossNodeBusCapabilities
|
|
32
|
+
|
|
33
|
+
constructor(capability: keyof CrossNodeBusCapabilities) {
|
|
34
|
+
super(`Adapter does not support capability: ${capability}`)
|
|
35
|
+
this.name = 'UnsupportedCapabilityError'
|
|
36
|
+
this.capability = capability
|
|
37
|
+
}
|
|
38
|
+
}
|