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,84 @@
1
+ import { diskeyval, type DiskeyvalNode } from '../../lib/index.ts';
2
+ import { createTestCertificates, hasOpenSsl } from './certs.ts';
3
+ import { getFreePorts } from './ports.ts';
4
+
5
+ type TlsTestCluster = {
6
+ nodes: Record<string, DiskeyvalNode>;
7
+ start: () => Promise<void>;
8
+ stop: () => Promise<void>;
9
+ cleanup: () => void;
10
+ };
11
+
12
+ export async function createTlsTestCluster(
13
+ nodeIds: string[],
14
+ options?: {
15
+ persistenceDir?: string;
16
+ }
17
+ ): Promise<TlsTestCluster> {
18
+ if (!hasOpenSsl()) {
19
+ throw new Error('openssl is required for TLS e2e tests');
20
+ }
21
+
22
+ const certs = createTestCertificates(nodeIds);
23
+ const ports = await getFreePorts(nodeIds.length);
24
+
25
+ const endpoints = nodeIds.map((nodeId, index) => ({
26
+ nodeId,
27
+ host: '127.0.0.1',
28
+ port: ports[index]
29
+ }));
30
+
31
+ const nodes: Record<string, DiskeyvalNode> = {};
32
+
33
+ for (const endpoint of endpoints) {
34
+ const peers = endpoints
35
+ .filter((candidate) => candidate.nodeId !== endpoint.nodeId)
36
+ .map((candidate) => ({
37
+ nodeId: candidate.nodeId,
38
+ host: candidate.host,
39
+ port: candidate.port
40
+ }));
41
+
42
+ nodes[endpoint.nodeId] = diskeyval({
43
+ nodeId: endpoint.nodeId,
44
+ host: endpoint.host,
45
+ port: endpoint.port,
46
+ peers,
47
+ tls: {
48
+ cert: certs.nodes[endpoint.nodeId].certPem,
49
+ key: certs.nodes[endpoint.nodeId].keyPem,
50
+ ca: certs.caPem
51
+ },
52
+ electionTimeoutMs: 200,
53
+ heartbeatMs: 50,
54
+ proposalTimeoutMs: 1_000,
55
+ rpcTimeoutMs: 1_000,
56
+ persistence:
57
+ options?.persistenceDir !== undefined
58
+ ? {
59
+ dir: options.persistenceDir,
60
+ compactEvery: 16
61
+ }
62
+ : undefined
63
+ });
64
+ }
65
+
66
+ return {
67
+ nodes,
68
+ start: async () => {
69
+ for (const nodeId of nodeIds) {
70
+ await nodes[nodeId].start();
71
+ }
72
+ },
73
+ stop: async () => {
74
+ await Promise.all(
75
+ nodeIds.map(async (nodeId) => {
76
+ await nodes[nodeId].end();
77
+ })
78
+ );
79
+ },
80
+ cleanup: () => {
81
+ certs.cleanup();
82
+ }
83
+ };
84
+ }
@@ -0,0 +1,193 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { diskeyval, type DiskeyvalCommand } from '../../lib/index.ts';
4
+ import type {
5
+ AppendEntriesRequest,
6
+ AppendEntriesResponse,
7
+ ManagedRaftTransport,
8
+ RaftRpcEndpoint,
9
+ RequestVoteRequest,
10
+ RequestVoteResponse
11
+ } from '../../lib/raft/types.ts';
12
+
13
+ function createAlwaysAckTransport(): ManagedRaftTransport<DiskeyvalCommand> & {
14
+ starts: number;
15
+ stops: number;
16
+ registered: string[];
17
+ unregistered: string[];
18
+ upserts: string[];
19
+ removes: string[];
20
+ } {
21
+ const state = {
22
+ starts: 0,
23
+ stops: 0,
24
+ registered: [] as string[],
25
+ unregistered: [] as string[],
26
+ upserts: [] as string[],
27
+ removes: [] as string[]
28
+ };
29
+
30
+ let endpoint: RaftRpcEndpoint<DiskeyvalCommand> | null = null;
31
+
32
+ const requestVote = async (
33
+ _from: string,
34
+ _to: string,
35
+ request: RequestVoteRequest
36
+ ): Promise<RequestVoteResponse> => ({
37
+ term: request.term,
38
+ voteGranted: true
39
+ });
40
+
41
+ const appendEntries = async (
42
+ _from: string,
43
+ _to: string,
44
+ request: AppendEntriesRequest<DiskeyvalCommand>
45
+ ): Promise<AppendEntriesResponse> => ({
46
+ term: request.term,
47
+ success: true,
48
+ matchIndex: request.prevLogIndex + request.entries.length
49
+ });
50
+
51
+ const transport: ManagedRaftTransport<DiskeyvalCommand> & {
52
+ starts: number;
53
+ stops: number;
54
+ registered: string[];
55
+ unregistered: string[];
56
+ upserts: string[];
57
+ removes: string[];
58
+ } = {
59
+ get starts() {
60
+ return state.starts;
61
+ },
62
+ get stops() {
63
+ return state.stops;
64
+ },
65
+ get registered() {
66
+ return state.registered;
67
+ },
68
+ get unregistered() {
69
+ return state.unregistered;
70
+ },
71
+ get upserts() {
72
+ return state.upserts;
73
+ },
74
+ get removes() {
75
+ return state.removes;
76
+ },
77
+ requestVote,
78
+ appendEntries,
79
+ start: async (nextEndpoint) => {
80
+ endpoint = nextEndpoint;
81
+ state.starts += 1;
82
+ },
83
+ stop: async () => {
84
+ endpoint = null;
85
+ state.stops += 1;
86
+ },
87
+ register: (nodeId) => {
88
+ state.registered.push(nodeId);
89
+ },
90
+ unregister: (nodeId) => {
91
+ state.unregistered.push(nodeId);
92
+ },
93
+ upsertPeer: (peer) => {
94
+ state.upserts.push(peer.nodeId);
95
+ },
96
+ removePeer: (nodeId) => {
97
+ state.removes.push(nodeId);
98
+ }
99
+ };
100
+
101
+ return transport;
102
+ }
103
+
104
+ test('[unit/diskeyval] throws when built-in transport is selected without host/port/tls', () => {
105
+ assert.throws(
106
+ () =>
107
+ diskeyval({
108
+ nodeId: 'node-missing-transport',
109
+ peers: []
110
+ }),
111
+ /Either provide transport, or provide host\/port\/tls/i
112
+ );
113
+ });
114
+
115
+ test('[unit/diskeyval] event bus, lifecycle hooks, and membership upsert/remove are exercised', async () => {
116
+ const transport = createAlwaysAckTransport();
117
+
118
+ const node = diskeyval({
119
+ nodeId: 'node-1',
120
+ peers: [
121
+ { nodeId: 'node-2', host: '127.0.0.1', port: 5002 },
122
+ { nodeId: 'node-3', host: '127.0.0.1', port: 5003 }
123
+ ],
124
+ transport,
125
+ electionTimeoutMs: 10_000,
126
+ heartbeatMs: 20,
127
+ proposalTimeoutMs: 300
128
+ });
129
+
130
+ let changeEvents = 0;
131
+ const onChange = (): void => {
132
+ changeEvents += 1;
133
+ };
134
+
135
+ // Exercise "off" branch for missing event registration.
136
+ node.off('change', onChange);
137
+ node.on('change', onChange);
138
+ // Exercise "on existing" branch.
139
+ node.on('change', onChange);
140
+
141
+ await node.start();
142
+ await node.forceElection();
143
+ await node.set('cover', true);
144
+
145
+ node.off('change', onChange);
146
+ // Exercise "off" branch after handlers set is deleted.
147
+ node.off('change', onChange);
148
+
149
+ assert.equal(changeEvents, 1);
150
+ assert.ok(transport.starts >= 1);
151
+ assert.deepEqual(transport.registered, ['node-1']);
152
+ assert.ok(transport.upserts.includes('node-2'));
153
+ assert.ok(transport.upserts.includes('node-3'));
154
+
155
+ await node.reconfigure([
156
+ { nodeId: 'node-3', host: '127.0.0.1', port: 5003 },
157
+ { nodeId: 'node-4', host: '127.0.0.1', port: 5004 },
158
+ { nodeId: '', host: '127.0.0.1', port: 5999 }
159
+ ]);
160
+
161
+ assert.ok(transport.removes.includes('node-2'));
162
+ assert.ok(transport.upserts.includes('node-4'));
163
+
164
+ const peers = node.getPeers();
165
+ peers[0].nodeId = 'mutated';
166
+ assert.notEqual(node.getPeers()[0].nodeId, 'mutated');
167
+
168
+ await node.end();
169
+ assert.deepEqual(transport.unregistered, ['node-1']);
170
+ assert.ok(transport.stops >= 1);
171
+ });
172
+
173
+ test('[unit/diskeyval] accepts string peer ids and exposes normalized peers', async () => {
174
+ const transport = createAlwaysAckTransport();
175
+ const node = diskeyval({
176
+ nodeId: 'node-1',
177
+ peers: ['node-2', 'node-3', 'node-2'],
178
+ transport,
179
+ electionTimeoutMs: 10_000
180
+ });
181
+
182
+ try {
183
+ await node.start();
184
+ const peers = node.getPeers();
185
+ assert.deepEqual(
186
+ peers.map((peer) => peer.nodeId).sort(),
187
+ ['node-2', 'node-3']
188
+ );
189
+ assert.equal(peers.every((peer) => peer.host === undefined && peer.port === undefined), true);
190
+ } finally {
191
+ await node.end();
192
+ }
193
+ });
@@ -0,0 +1,111 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createInMemoryRaftNetwork } from '../../lib/raft/in-memory-network.ts';
4
+ import type { RaftRpcEndpoint } from '../../lib/raft/types.ts';
5
+
6
+ type Command = { value: string };
7
+
8
+ function endpoint(): RaftRpcEndpoint<Command> {
9
+ return {
10
+ onRequestVote: async (_from, request) => ({
11
+ term: request.term,
12
+ voteGranted: true
13
+ }),
14
+ onAppendEntries: async (_from, request) => ({
15
+ term: request.term,
16
+ success: true,
17
+ matchIndex: request.prevLogIndex + request.entries.length
18
+ })
19
+ };
20
+ }
21
+
22
+ test('[unit/in-memory-network] supports latency and basic rpc', async () => {
23
+ const network = createInMemoryRaftNetwork<Command>({ latencyMs: 1 });
24
+ network.register('node-2', endpoint());
25
+
26
+ const vote = await network.requestVote('node-1', 'node-2', {
27
+ term: 1,
28
+ candidateId: 'node-1',
29
+ lastLogIndex: 0,
30
+ lastLogTerm: 0
31
+ });
32
+ assert.equal(vote.voteGranted, true);
33
+
34
+ const append = await network.appendEntries('node-1', 'node-2', {
35
+ term: 1,
36
+ leaderId: 'node-1',
37
+ prevLogIndex: 0,
38
+ prevLogTerm: 0,
39
+ entries: [],
40
+ leaderCommit: 0
41
+ });
42
+ assert.equal(append.success, true);
43
+ });
44
+
45
+ test('[unit/in-memory-network] unknown nodes and heal path are covered', async () => {
46
+ const network = createInMemoryRaftNetwork<Command>();
47
+
48
+ await assert.rejects(
49
+ () =>
50
+ network.requestVote('node-1', 'missing', {
51
+ term: 1,
52
+ candidateId: 'node-1',
53
+ lastLogIndex: 0,
54
+ lastLogTerm: 0
55
+ }),
56
+ /Unknown node: missing/i
57
+ );
58
+
59
+ network.register('node-2', endpoint());
60
+ network.partition('node-1', 'node-2');
61
+ await assert.rejects(
62
+ () =>
63
+ network.appendEntries('node-1', 'node-2', {
64
+ term: 1,
65
+ leaderId: 'node-1',
66
+ prevLogIndex: 0,
67
+ prevLogTerm: 0,
68
+ entries: [],
69
+ leaderCommit: 0
70
+ }),
71
+ /Network partition/i
72
+ );
73
+
74
+ network.heal('node-1', 'node-2');
75
+ const healed = await network.appendEntries('node-1', 'node-2', {
76
+ term: 1,
77
+ leaderId: 'node-1',
78
+ prevLogIndex: 0,
79
+ prevLogTerm: 0,
80
+ entries: [],
81
+ leaderCommit: 0
82
+ });
83
+ assert.equal(healed.success, true);
84
+
85
+ network.partition('node-1', 'node-2');
86
+ network.healAll();
87
+ const healedAll = await network.appendEntries('node-1', 'node-2', {
88
+ term: 1,
89
+ leaderId: 'node-1',
90
+ prevLogIndex: 0,
91
+ prevLogTerm: 0,
92
+ entries: [],
93
+ leaderCommit: 0
94
+ });
95
+ assert.equal(healedAll.success, true);
96
+
97
+ network.unregister('node-2');
98
+ await assert.rejects(
99
+ () =>
100
+ network.appendEntries('node-1', 'node-2', {
101
+ term: 1,
102
+ leaderId: 'node-1',
103
+ prevLogIndex: 0,
104
+ prevLogTerm: 0,
105
+ entries: [],
106
+ leaderCommit: 0
107
+ }),
108
+ /Unknown node: node-2/i
109
+ );
110
+ });
111
+