diskeyval 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.
@@ -0,0 +1,280 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import diskeyval, { type DiskeyvalNode } from '../../lib/index.ts';
7
+ import { isNotLeaderError, isQuorumUnavailableError } from '../../lib/index.ts';
8
+ import { hasOpenSsl } from '../support/certs.ts';
9
+ import { createTestCertificates } from '../support/certs.ts';
10
+ import { getFreePorts } from '../support/ports.ts';
11
+ import { createTlsTestCluster } from '../support/tls-cluster.ts';
12
+ import { waitForCondition } from '../support/helpers.ts';
13
+
14
+ async function forceLeader(node: { forceElection: () => Promise<void>; isLeader: () => boolean }) {
15
+ await node.forceElection();
16
+ await waitForCondition(() => node.isLeader(), {
17
+ timeoutMs: 1_500,
18
+ intervalMs: 20,
19
+ message: 'Expected forced node to become leader'
20
+ });
21
+ }
22
+
23
+ test('mTLS cluster elects leader and replicates writes', { skip: !hasOpenSsl() }, async () => {
24
+ const cluster = await createTlsTestCluster(['node-1', 'node-2', 'node-3']);
25
+
26
+ try {
27
+ await cluster.start();
28
+ await forceLeader(cluster.nodes['node-1']);
29
+
30
+ await cluster.nodes['node-1'].set('e2e-key', 'e2e-value');
31
+
32
+ await waitForCondition(
33
+ () =>
34
+ cluster.nodes['node-1'].state['e2e-key'] === 'e2e-value' &&
35
+ cluster.nodes['node-2'].state['e2e-key'] === 'e2e-value' &&
36
+ cluster.nodes['node-3'].state['e2e-key'] === 'e2e-value',
37
+ {
38
+ timeoutMs: 3_000,
39
+ intervalMs: 25,
40
+ message: 'mTLS cluster did not converge on committed value'
41
+ }
42
+ );
43
+ } finally {
44
+ await cluster.stop();
45
+ cluster.cleanup();
46
+ }
47
+ });
48
+
49
+ test('mTLS cluster rejects writes when quorum is unavailable', { skip: !hasOpenSsl() }, async () => {
50
+ const cluster = await createTlsTestCluster(['node-1', 'node-2', 'node-3']);
51
+
52
+ try {
53
+ await cluster.start();
54
+ await forceLeader(cluster.nodes['node-1']);
55
+
56
+ await cluster.nodes['node-2'].end();
57
+ await cluster.nodes['node-3'].end();
58
+
59
+ await assert.rejects(
60
+ () => cluster.nodes['node-1'].set('blocked-write', true),
61
+ (error: unknown) => isQuorumUnavailableError(error)
62
+ );
63
+ } finally {
64
+ await cluster.stop();
65
+ cluster.cleanup();
66
+ }
67
+ });
68
+
69
+ test('mTLS cluster performs failover when leader stops', { skip: !hasOpenSsl() }, async () => {
70
+ const cluster = await createTlsTestCluster(['node-1', 'node-2', 'node-3']);
71
+
72
+ try {
73
+ await cluster.start();
74
+ await forceLeader(cluster.nodes['node-1']);
75
+
76
+ await cluster.nodes['node-1'].end();
77
+
78
+ await waitForCondition(
79
+ () => cluster.nodes['node-2'].isLeader() || cluster.nodes['node-3'].isLeader(),
80
+ {
81
+ timeoutMs: 4_000,
82
+ intervalMs: 25,
83
+ message: 'No new leader elected after stopping previous leader'
84
+ }
85
+ );
86
+
87
+ const newLeader = cluster.nodes['node-2'].isLeader() ? cluster.nodes['node-2'] : cluster.nodes['node-3'];
88
+ await newLeader.set('post-failover', 'ok');
89
+
90
+ await waitForCondition(
91
+ () => cluster.nodes['node-2'].state['post-failover'] === 'ok' || cluster.nodes['node-3'].state['post-failover'] === 'ok',
92
+ {
93
+ timeoutMs: 3_000,
94
+ intervalMs: 25,
95
+ message: 'New leader did not replicate post-failover write'
96
+ }
97
+ );
98
+ } finally {
99
+ await cluster.stop();
100
+ cluster.cleanup();
101
+ }
102
+ });
103
+
104
+ test('follower mTLS read returns not-leader error with leader hint', { skip: !hasOpenSsl() }, async () => {
105
+ const cluster = await createTlsTestCluster(['node-1', 'node-2', 'node-3']);
106
+
107
+ try {
108
+ await cluster.start();
109
+ await forceLeader(cluster.nodes['node-1']);
110
+ await waitForCondition(() => cluster.nodes['node-2'].leaderId() === 'node-1', {
111
+ timeoutMs: 1_500,
112
+ intervalMs: 20,
113
+ message: 'Follower did not observe leader identity'
114
+ });
115
+
116
+ await assert.rejects(
117
+ () => cluster.nodes['node-2'].get('missing'),
118
+ (error: unknown) => {
119
+ assert.ok(isNotLeaderError(error));
120
+ assert.equal(error.leaderId, 'node-1');
121
+ return true;
122
+ }
123
+ );
124
+ } finally {
125
+ await cluster.stop();
126
+ cluster.cleanup();
127
+ }
128
+ });
129
+
130
+ test('mTLS cluster restores committed state after full restart', { skip: !hasOpenSsl() }, async () => {
131
+ const persistenceDir = await mkdtemp(join(tmpdir(), 'diskeyval-e2e-persist-'));
132
+
133
+ const firstRun = await createTlsTestCluster(['node-1', 'node-2', 'node-3'], {
134
+ persistenceDir
135
+ });
136
+ let committedWriterId: 'node-1' | 'node-2' | 'node-3' = 'node-1';
137
+
138
+ const getLeader = (nodes: typeof firstRun.nodes) => {
139
+ if (nodes['node-1'].isLeader()) {
140
+ return nodes['node-1'];
141
+ }
142
+ if (nodes['node-2'].isLeader()) {
143
+ return nodes['node-2'];
144
+ }
145
+ if (nodes['node-3'].isLeader()) {
146
+ return nodes['node-3'];
147
+ }
148
+ return null;
149
+ };
150
+
151
+ try {
152
+ await firstRun.start();
153
+ await waitForCondition(() => getLeader(firstRun.nodes) !== null, {
154
+ timeoutMs: 3_000,
155
+ intervalMs: 20,
156
+ message: 'Expected a leader before initial persistent write'
157
+ });
158
+
159
+ const initialLeader = getLeader(firstRun.nodes);
160
+ assert.ok(initialLeader);
161
+ await initialLeader.set('restart-key', 'restart-value');
162
+ committedWriterId = initialLeader.nodeId as 'node-1' | 'node-2' | 'node-3';
163
+ } finally {
164
+ await firstRun.stop();
165
+ firstRun.cleanup();
166
+ }
167
+
168
+ const secondRun = await createTlsTestCluster(['node-1', 'node-2', 'node-3'], {
169
+ persistenceDir
170
+ });
171
+
172
+ try {
173
+ await secondRun.start();
174
+ await waitForCondition(
175
+ () => secondRun.nodes[committedWriterId].state['restart-key'] === 'restart-value',
176
+ {
177
+ timeoutMs: 8_000,
178
+ intervalMs: 25,
179
+ message: 'Expected persisted state to be restored on restart for committed writer'
180
+ }
181
+ );
182
+
183
+ await forceLeader(secondRun.nodes[committedWriterId]);
184
+ const recovered = await secondRun.nodes[committedWriterId].get('restart-key');
185
+ assert.equal(recovered, 'restart-value');
186
+ } finally {
187
+ await secondRun.stop();
188
+ secondRun.cleanup();
189
+ await rm(persistenceDir, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ test('mTLS cluster can dynamically add a new node and replicate state', { skip: !hasOpenSsl() }, async () => {
194
+ const nodeIds = ['node-1', 'node-2', 'node-3', 'node-4'];
195
+ const certs = createTestCertificates(nodeIds);
196
+ const ports = await getFreePorts(nodeIds.length);
197
+ const endpoints = Object.fromEntries(
198
+ nodeIds.map((nodeId, index) => [
199
+ nodeId,
200
+ {
201
+ nodeId,
202
+ host: '127.0.0.1',
203
+ port: ports[index]
204
+ }
205
+ ])
206
+ ) as Record<string, { nodeId: string; host: string; port: number }>;
207
+
208
+ const mkNode = (nodeId: string, peers: string[]): DiskeyvalNode =>
209
+ diskeyval({
210
+ nodeId,
211
+ host: endpoints[nodeId].host,
212
+ port: endpoints[nodeId].port,
213
+ peers: peers.map((peerId) => ({
214
+ nodeId: endpoints[peerId].nodeId,
215
+ host: endpoints[peerId].host,
216
+ port: endpoints[peerId].port
217
+ })),
218
+ tls: {
219
+ cert: certs.nodes[nodeId].certPem,
220
+ key: certs.nodes[nodeId].keyPem,
221
+ ca: certs.caPem
222
+ },
223
+ electionTimeoutMs: 200,
224
+ heartbeatMs: 50,
225
+ proposalTimeoutMs: 2_000,
226
+ rpcTimeoutMs: 2_000
227
+ });
228
+
229
+ const node1 = mkNode('node-1', ['node-2', 'node-3']);
230
+ const node2 = mkNode('node-2', ['node-1', 'node-3']);
231
+ const node3 = mkNode('node-3', ['node-1', 'node-2']);
232
+ const node4 = mkNode('node-4', ['node-2']);
233
+
234
+ try {
235
+ await node1.start();
236
+ await node2.start();
237
+ await node3.start();
238
+
239
+ await forceLeader(node1);
240
+ await node1.set('before-join', 'present');
241
+
242
+ await node4.start();
243
+
244
+ await node1.reconfigure([
245
+ endpoints['node-2'],
246
+ endpoints['node-3'],
247
+ endpoints['node-4']
248
+ ]);
249
+
250
+ await waitForCondition(
251
+ () => node4.state['before-join'] === 'present',
252
+ {
253
+ timeoutMs: 5_000,
254
+ intervalMs: 30,
255
+ message: 'new node did not catch up existing committed state after reconfigure'
256
+ }
257
+ );
258
+
259
+ await node1.set('after-join', 'replicated');
260
+ await waitForCondition(
261
+ () =>
262
+ node2.state['after-join'] === 'replicated' &&
263
+ node3.state['after-join'] === 'replicated' &&
264
+ node4.state['after-join'] === 'replicated',
265
+ {
266
+ timeoutMs: 5_000,
267
+ intervalMs: 30,
268
+ message: 'new cluster membership did not replicate subsequent writes'
269
+ }
270
+ );
271
+ } finally {
272
+ await Promise.all([
273
+ node1.end().catch(() => undefined),
274
+ node2.end().catch(() => undefined),
275
+ node3.end().catch(() => undefined),
276
+ node4.end().catch(() => undefined)
277
+ ]);
278
+ certs.cleanup();
279
+ }
280
+ });
@@ -0,0 +1,105 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { isQuorumUnavailableError } from '../../lib/index.ts';
4
+ import { createTestCluster, forceLeader, waitForCondition } from '../support/helpers.ts';
5
+
6
+ test('[sim] 3-node cluster elects leader and commits value', async () => {
7
+ const cluster = createTestCluster();
8
+ await cluster.start();
9
+ try {
10
+ await forceLeader(cluster.nodes['node-1']);
11
+ await cluster.nodes['node-1'].set('sim-key', 'sim-value');
12
+
13
+ await waitForCondition(
14
+ () =>
15
+ cluster.nodes['node-1'].state['sim-key'] === 'sim-value' &&
16
+ cluster.nodes['node-2'].state['sim-key'] === 'sim-value' &&
17
+ cluster.nodes['node-3'].state['sim-key'] === 'sim-value',
18
+ { timeoutMs: 1_000, message: 'Cluster did not converge' }
19
+ );
20
+ } finally {
21
+ await cluster.stop();
22
+ }
23
+ });
24
+
25
+ test('[sim] minority partition cannot commit', async () => {
26
+ const cluster = createTestCluster();
27
+ await cluster.start();
28
+ try {
29
+ await forceLeader(cluster.nodes['node-1']);
30
+ cluster.network.partition('node-1', 'node-2');
31
+ cluster.network.partition('node-1', 'node-3');
32
+
33
+ await assert.rejects(
34
+ () => cluster.nodes['node-1'].set('blocked', 1),
35
+ (error: unknown) => isQuorumUnavailableError(error)
36
+ );
37
+ } finally {
38
+ await cluster.stop();
39
+ }
40
+ });
41
+
42
+ test('[sim] majority partition continues committing', async () => {
43
+ const cluster = createTestCluster();
44
+ await cluster.start();
45
+ try {
46
+ await forceLeader(cluster.nodes['node-1']);
47
+
48
+ cluster.network.partition('node-1', 'node-3');
49
+ cluster.network.partition('node-2', 'node-3');
50
+
51
+ await cluster.nodes['node-1'].set('majority', true);
52
+ await waitForCondition(
53
+ () => cluster.nodes['node-2'].state.majority === true,
54
+ { timeoutMs: 500, message: 'Majority peer did not commit write' }
55
+ );
56
+
57
+ assert.equal(cluster.nodes['node-3'].state.majority, undefined);
58
+ } finally {
59
+ await cluster.stop();
60
+ }
61
+ });
62
+
63
+ test('[sim] partition heal converges logs', async () => {
64
+ const cluster = createTestCluster();
65
+ await cluster.start();
66
+ try {
67
+ await forceLeader(cluster.nodes['node-1']);
68
+
69
+ cluster.network.partition('node-1', 'node-3');
70
+ cluster.network.partition('node-2', 'node-3');
71
+ await cluster.nodes['node-1'].set('heal-key', 'during-partition');
72
+
73
+ cluster.network.healAll();
74
+
75
+ await waitForCondition(
76
+ () => cluster.nodes['node-3'].state['heal-key'] === 'during-partition',
77
+ { timeoutMs: 1_200, message: 'Lagging node did not catch up after heal' }
78
+ );
79
+ } finally {
80
+ await cluster.stop();
81
+ }
82
+ });
83
+
84
+ test('[sim] dynamic reconfigure can add a new node and replicate state', async () => {
85
+ const cluster = createTestCluster(['node-1', 'node-2', 'node-3', 'node-4']);
86
+ await cluster.start();
87
+ try {
88
+ await forceLeader(cluster.nodes['node-1']);
89
+
90
+ await cluster.nodes['node-1'].reconfigure([
91
+ { nodeId: 'node-2' },
92
+ { nodeId: 'node-3' },
93
+ { nodeId: 'node-4' }
94
+ ]);
95
+
96
+ await cluster.nodes['node-1'].set('new-member-key', 'replicated');
97
+
98
+ await waitForCondition(
99
+ () => cluster.nodes['node-4'].state['new-member-key'] === 'replicated',
100
+ { timeoutMs: 1_500, message: 'new member did not catch up after reconfigure' }
101
+ );
102
+ } finally {
103
+ await cluster.stop();
104
+ }
105
+ });
@@ -0,0 +1,97 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createTestCluster, forceLeader, waitForCondition } from '../support/helpers.ts';
4
+
5
+ test('high write throughput burst converges on all replicas', async () => {
6
+ const cluster = createTestCluster(['node-1', 'node-2', 'node-3', 'node-4', 'node-5']);
7
+ await cluster.start();
8
+
9
+ try {
10
+ await forceLeader(cluster.nodes['node-1']);
11
+
12
+ const writeCount = 120;
13
+ for (let index = 0; index < writeCount; index += 1) {
14
+ await cluster.nodes['node-1'].set(`k-${index}`, index);
15
+ }
16
+
17
+ await waitForCondition(
18
+ () => cluster.nodes['node-4'].state['k-119'] === 119,
19
+ {
20
+ timeoutMs: 2_000,
21
+ intervalMs: 15,
22
+ message: 'Tail write did not replicate in throughput burst'
23
+ }
24
+ );
25
+
26
+ assert.equal(cluster.nodes['node-1'].state['k-0'], 0);
27
+ assert.equal(cluster.nodes['node-2'].state['k-50'], 50);
28
+ assert.equal(cluster.nodes['node-3'].state['k-119'], 119);
29
+ } finally {
30
+ await cluster.stop();
31
+ }
32
+ });
33
+
34
+ test('cluster recovers from repeated partition/heal cycles', async () => {
35
+ const cluster = createTestCluster();
36
+ await cluster.start();
37
+
38
+ try {
39
+ await forceLeader(cluster.nodes['node-1']);
40
+
41
+ for (let cycle = 0; cycle < 8; cycle += 1) {
42
+ cluster.network.partition('node-1', 'node-3');
43
+ cluster.network.partition('node-2', 'node-3');
44
+ await cluster.nodes['node-1'].set(`cycle-${cycle}`, cycle);
45
+ cluster.network.healAll();
46
+
47
+ await waitForCondition(
48
+ () => cluster.nodes['node-3'].state[`cycle-${cycle}`] === cycle,
49
+ {
50
+ timeoutMs: 1_500,
51
+ intervalMs: 20,
52
+ message: `Lagging follower did not catch up after cycle ${cycle}`
53
+ }
54
+ );
55
+ }
56
+ } finally {
57
+ await cluster.stop();
58
+ }
59
+ });
60
+
61
+ test('randomized partition churn preserves convergence safety', async () => {
62
+ const cluster = createTestCluster();
63
+ await cluster.start();
64
+
65
+ try {
66
+ await forceLeader(cluster.nodes['node-1']);
67
+
68
+ for (let index = 0; index < 50; index += 1) {
69
+ if (index % 7 === 0) {
70
+ cluster.network.partition('node-1', 'node-3');
71
+ cluster.network.partition('node-2', 'node-3');
72
+ }
73
+ if (index % 7 === 3) {
74
+ cluster.network.healAll();
75
+ }
76
+
77
+ await cluster.nodes['node-1'].set(`safe-${index}`, index);
78
+ }
79
+
80
+ cluster.network.healAll();
81
+
82
+ await waitForCondition(
83
+ () => cluster.nodes['node-3'].state['safe-49'] === 49,
84
+ {
85
+ timeoutMs: 2_000,
86
+ intervalMs: 20,
87
+ message: 'Cluster failed to converge after randomized churn'
88
+ }
89
+ );
90
+
91
+ assert.equal(cluster.nodes['node-1'].state['safe-49'], 49);
92
+ assert.equal(cluster.nodes['node-2'].state['safe-49'], 49);
93
+ assert.equal(cluster.nodes['node-3'].state['safe-49'], 49);
94
+ } finally {
95
+ await cluster.stop();
96
+ }
97
+ });
@@ -0,0 +1,126 @@
1
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { execFileSync, spawnSync } from 'node:child_process';
5
+
6
+ function runOpenSsl(args: string[], cwd: string): void {
7
+ execFileSync('openssl', args, {
8
+ cwd,
9
+ stdio: 'pipe'
10
+ });
11
+ }
12
+
13
+ export function hasOpenSsl(): boolean {
14
+ const result = spawnSync('openssl', ['version'], { stdio: 'pipe' });
15
+ return result.status === 0;
16
+ }
17
+
18
+ export type NodeCertificateBundle = {
19
+ nodeId: string;
20
+ certPem: string;
21
+ keyPem: string;
22
+ };
23
+
24
+ export type TestCertificates = {
25
+ caPem: string;
26
+ nodes: Record<string, NodeCertificateBundle>;
27
+ cleanup: () => void;
28
+ };
29
+
30
+ export function createTestCertificates(nodeIds: string[]): TestCertificates {
31
+ const workingDir = mkdtempSync(join(tmpdir(), 'diskeyval-certs-'));
32
+
33
+ const caKeyPath = join(workingDir, 'ca.key.pem');
34
+ const caCertPath = join(workingDir, 'ca.cert.pem');
35
+
36
+ runOpenSsl(['genrsa', '-out', caKeyPath, '2048'], workingDir);
37
+ runOpenSsl(
38
+ [
39
+ 'req',
40
+ '-x509',
41
+ '-new',
42
+ '-nodes',
43
+ '-key',
44
+ caKeyPath,
45
+ '-sha256',
46
+ '-days',
47
+ '1',
48
+ '-out',
49
+ caCertPath,
50
+ '-subj',
51
+ '/CN=diskeyval-test-ca'
52
+ ],
53
+ workingDir
54
+ );
55
+
56
+ const caPem = readFileSync(caCertPath, 'utf8');
57
+ const nodes: Record<string, NodeCertificateBundle> = {};
58
+
59
+ for (const nodeId of nodeIds) {
60
+ const keyPath = join(workingDir, `${nodeId}.key.pem`);
61
+ const csrPath = join(workingDir, `${nodeId}.csr.pem`);
62
+ const certPath = join(workingDir, `${nodeId}.cert.pem`);
63
+ const configPath = join(workingDir, `${nodeId}.cnf`);
64
+
65
+ writeFileSync(
66
+ configPath,
67
+ [
68
+ '[req]',
69
+ 'prompt = no',
70
+ 'distinguished_name = dn',
71
+ 'req_extensions = req_ext',
72
+ '',
73
+ '[dn]',
74
+ `CN = ${nodeId}`,
75
+ '',
76
+ '[req_ext]',
77
+ `subjectAltName = DNS:${nodeId},URI:spiffe://diskeyval/${nodeId}`,
78
+ 'extendedKeyUsage = serverAuth,clientAuth',
79
+ 'keyUsage = digitalSignature,keyEncipherment'
80
+ ].join('\n')
81
+ );
82
+
83
+ runOpenSsl(['genrsa', '-out', keyPath, '2048'], workingDir);
84
+ runOpenSsl(
85
+ ['req', '-new', '-key', keyPath, '-out', csrPath, '-config', configPath],
86
+ workingDir
87
+ );
88
+ runOpenSsl(
89
+ [
90
+ 'x509',
91
+ '-req',
92
+ '-in',
93
+ csrPath,
94
+ '-CA',
95
+ caCertPath,
96
+ '-CAkey',
97
+ caKeyPath,
98
+ '-CAcreateserial',
99
+ '-out',
100
+ certPath,
101
+ '-days',
102
+ '1',
103
+ '-sha256',
104
+ '-extfile',
105
+ configPath,
106
+ '-extensions',
107
+ 'req_ext'
108
+ ],
109
+ workingDir
110
+ );
111
+
112
+ nodes[nodeId] = {
113
+ nodeId,
114
+ certPem: readFileSync(certPath, 'utf8'),
115
+ keyPem: readFileSync(keyPath, 'utf8')
116
+ };
117
+ }
118
+
119
+ return {
120
+ caPem,
121
+ nodes,
122
+ cleanup: () => {
123
+ rmSync(workingDir, { recursive: true, force: true });
124
+ }
125
+ };
126
+ }
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createInMemoryDiskeyvalCluster, type DiskeyvalNode } from '../../lib/index.ts';
3
+
4
+ export function sleep(ms: number): Promise<void> {
5
+ return new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+
8
+ export async function waitForCondition(
9
+ condition: () => boolean,
10
+ options?: { timeoutMs?: number; intervalMs?: number; message?: string }
11
+ ): Promise<void> {
12
+ const timeoutMs = options?.timeoutMs ?? 1_000;
13
+ const intervalMs = options?.intervalMs ?? 10;
14
+ const start = Date.now();
15
+
16
+ while (Date.now() - start <= timeoutMs) {
17
+ if (condition()) {
18
+ return;
19
+ }
20
+ await sleep(intervalMs);
21
+ }
22
+
23
+ assert.fail(options?.message ?? `Condition not met within ${timeoutMs}ms`);
24
+ }
25
+
26
+ export async function forceLeader(node: DiskeyvalNode): Promise<void> {
27
+ await node.forceElection();
28
+ await waitForCondition(() => node.isLeader(), {
29
+ timeoutMs: 500,
30
+ message: `Node ${node.nodeId} did not become leader`
31
+ });
32
+ }
33
+
34
+ export function createTestCluster(nodeIds: string[] = ['node-1', 'node-2', 'node-3']) {
35
+ return createInMemoryDiskeyvalCluster(nodeIds, {
36
+ electionTimeoutMs: 10_000,
37
+ heartbeatMs: 20,
38
+ proposalTimeoutMs: 200
39
+ });
40
+ }
41
+
42
+ export function createTestClusterWithOptions(
43
+ nodeIds: string[],
44
+ options: {
45
+ electionTimeoutMs?: number;
46
+ heartbeatMs?: number;
47
+ proposalTimeoutMs?: number;
48
+ }
49
+ ) {
50
+ return createInMemoryDiskeyvalCluster(nodeIds, options);
51
+ }
@@ -0,0 +1,33 @@
1
+ import { createServer } from 'node:net';
2
+
3
+ export async function getFreePort(): Promise<number> {
4
+ return new Promise((resolve, reject) => {
5
+ const server = createServer();
6
+
7
+ server.once('error', reject);
8
+ server.listen(0, '127.0.0.1', () => {
9
+ const address = server.address();
10
+ if (!address || typeof address === 'string') {
11
+ server.close(() => reject(new Error('Could not allocate free port')));
12
+ return;
13
+ }
14
+
15
+ const port = address.port;
16
+ server.close((closeError) => {
17
+ if (closeError) {
18
+ reject(closeError);
19
+ return;
20
+ }
21
+ resolve(port);
22
+ });
23
+ });
24
+ });
25
+ }
26
+
27
+ export async function getFreePorts(count: number): Promise<number[]> {
28
+ const ports: number[] = [];
29
+ for (let index = 0; index < count; index += 1) {
30
+ ports.push(await getFreePort());
31
+ }
32
+ return ports;
33
+ }