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.
package/lib/index.ts ADDED
@@ -0,0 +1,448 @@
1
+ import {
2
+ createInMemoryRaftNetwork,
3
+ type InMemoryRaftNetwork
4
+ } from './raft/in-memory-network.ts';
5
+ import { createRaftNode, type RaftNode } from './raft/node.ts';
6
+ import { createTlsRaftTransport, type TlsPeer } from './raft/tls-transport.ts';
7
+ import {
8
+ createNotLeaderError,
9
+ createQuorumUnavailableError,
10
+ isNotLeaderError,
11
+ isQuorumUnavailableError,
12
+ type ManagedRaftTransport,
13
+ type RaftMetrics,
14
+ type RaftLogEntry,
15
+ type RaftRole,
16
+ type RaftTransport
17
+ } from './raft/types.ts';
18
+ import { createFileRaftStorage } from './storage/file-raft-storage.ts';
19
+
20
+ export type SetCommand = {
21
+ type: 'set';
22
+ key: string;
23
+ value: unknown;
24
+ };
25
+
26
+ export type ClusterPeer = {
27
+ nodeId: string;
28
+ host?: string;
29
+ port?: number;
30
+ };
31
+
32
+ export type ReconfigureCommand = {
33
+ type: 'reconfigure';
34
+ peers: ClusterPeer[];
35
+ };
36
+
37
+ export type DiskeyvalCommand = SetCommand | ReconfigureCommand;
38
+
39
+ export type DiskeyvalOptions = {
40
+ nodeId: string;
41
+ peers: string[] | TlsPeer[];
42
+ transport?: RaftTransport<DiskeyvalCommand>;
43
+ host?: string;
44
+ port?: number;
45
+ tls?: {
46
+ cert: string;
47
+ key: string;
48
+ ca: string;
49
+ servername?: string;
50
+ };
51
+ rpcTimeoutMs?: number;
52
+ persistence?: {
53
+ dir: string;
54
+ compactEvery?: number;
55
+ };
56
+ electionTimeoutMs?: number;
57
+ heartbeatMs?: number;
58
+ proposalTimeoutMs?: number;
59
+ };
60
+
61
+ type DiskeyvalEvents = {
62
+ change: {
63
+ key: string;
64
+ value: unknown;
65
+ };
66
+ leader: {
67
+ nodeId: string | null;
68
+ };
69
+ };
70
+
71
+ type EventKey = keyof DiskeyvalEvents;
72
+
73
+ type EventHandler<K extends EventKey> = (payload: DiskeyvalEvents[K]) => void;
74
+
75
+ function createEventBus() {
76
+ const handlers = new Map<EventKey, Set<(payload: unknown) => void>>();
77
+
78
+ const on = <K extends EventKey>(event: K, handler: EventHandler<K>): void => {
79
+ const existing = handlers.get(event);
80
+ if (existing) {
81
+ existing.add(handler as (payload: unknown) => void);
82
+ return;
83
+ }
84
+ handlers.set(event, new Set([(handler as (payload: unknown) => void)]));
85
+ };
86
+
87
+ const off = <K extends EventKey>(event: K, handler: EventHandler<K>): void => {
88
+ const existing = handlers.get(event);
89
+ if (!existing) {
90
+ return;
91
+ }
92
+ existing.delete(handler as (payload: unknown) => void);
93
+ if (existing.size === 0) {
94
+ handlers.delete(event);
95
+ }
96
+ };
97
+
98
+ const emit = <K extends EventKey>(event: K, payload: DiskeyvalEvents[K]): void => {
99
+ const existing = handlers.get(event);
100
+ if (!existing) {
101
+ return;
102
+ }
103
+ for (const handler of existing) {
104
+ handler(payload);
105
+ }
106
+ };
107
+
108
+ return {
109
+ on,
110
+ off,
111
+ emit
112
+ };
113
+ }
114
+
115
+ export type DiskeyvalNode = {
116
+ nodeId: string;
117
+ state: Record<string, unknown>;
118
+ start: () => Promise<void>;
119
+ end: () => Promise<void>;
120
+ set: (key: string, value: unknown) => Promise<void>;
121
+ get: <T = unknown>(key: string) => Promise<T | undefined>;
122
+ reconfigure: (peers: ClusterPeer[]) => Promise<void>;
123
+ getPeers: () => ClusterPeer[];
124
+ isLeader: () => boolean;
125
+ leaderId: () => string | null;
126
+ getRaftNode: () => RaftNode<DiskeyvalCommand>;
127
+ getMetrics: () => RaftMetrics;
128
+ forceElection: () => Promise<void>;
129
+ on: <K extends EventKey>(event: K, handler: EventHandler<K>) => void;
130
+ off: <K extends EventKey>(event: K, handler: EventHandler<K>) => void;
131
+ };
132
+
133
+ type DiskeyvalContext = {
134
+ nodeId: string;
135
+ selfPeer: ClusterPeer;
136
+ state: Record<string, unknown>;
137
+ raft: RaftNode<DiskeyvalCommand>;
138
+ transport: ManagedRaftTransport<DiskeyvalCommand>;
139
+ events: ReturnType<typeof createEventBus>;
140
+ peersById: Map<string, ClusterPeer>;
141
+ };
142
+
143
+ function extractPeerRecords(peers: DiskeyvalOptions['peers']): ClusterPeer[] {
144
+ if (peers.length === 0) {
145
+ return [];
146
+ }
147
+
148
+ if (typeof peers[0] === 'string') {
149
+ return (peers as string[]).map((nodeId) => ({ nodeId }));
150
+ }
151
+
152
+ return (peers as TlsPeer[]).map((peer) => ({
153
+ nodeId: peer.nodeId,
154
+ host: peer.host,
155
+ port: peer.port
156
+ }));
157
+ }
158
+
159
+ function extractPeerIds(peers: ClusterPeer[]): string[] {
160
+ return peers.map((peer) => peer.nodeId);
161
+ }
162
+
163
+ function normalizePeers(selfNodeId: string, incoming: ClusterPeer[]): ClusterPeer[] {
164
+ const deduped = new Map<string, ClusterPeer>();
165
+ for (const peer of incoming) {
166
+ if (!peer.nodeId || peer.nodeId === selfNodeId) {
167
+ continue;
168
+ }
169
+ deduped.set(peer.nodeId, {
170
+ nodeId: peer.nodeId,
171
+ host: peer.host,
172
+ port: peer.port
173
+ });
174
+ }
175
+
176
+ return Array.from(deduped.values()).sort((a, b) => a.nodeId.localeCompare(b.nodeId));
177
+ }
178
+
179
+ function normalizeMembers(incoming: ClusterPeer[]): ClusterPeer[] {
180
+ const deduped = new Map<string, ClusterPeer>();
181
+ for (const peer of incoming) {
182
+ if (!peer.nodeId) {
183
+ continue;
184
+ }
185
+ deduped.set(peer.nodeId, {
186
+ nodeId: peer.nodeId,
187
+ host: peer.host,
188
+ port: peer.port
189
+ });
190
+ }
191
+
192
+ return Array.from(deduped.values()).sort((a, b) => a.nodeId.localeCompare(b.nodeId));
193
+ }
194
+
195
+ function resolveTransport(options: DiskeyvalOptions): ManagedRaftTransport<DiskeyvalCommand> {
196
+ if (options.transport) {
197
+ return options.transport as ManagedRaftTransport<DiskeyvalCommand>;
198
+ }
199
+
200
+ if (!options.host || !options.port || !options.tls) {
201
+ throw new Error('Either provide transport, or provide host/port/tls for built-in TLS transport');
202
+ }
203
+
204
+ const peers = options.peers as TlsPeer[];
205
+ return createTlsRaftTransport<DiskeyvalCommand>({
206
+ nodeId: options.nodeId,
207
+ host: options.host,
208
+ port: options.port,
209
+ peers,
210
+ tls: options.tls,
211
+ rpcTimeoutMs: options.rpcTimeoutMs
212
+ });
213
+ }
214
+
215
+ function createRpcEndpoint(context: DiskeyvalContext) {
216
+ return {
217
+ onRequestVote: context.raft.onRequestVote,
218
+ onAppendEntries: context.raft.onAppendEntries
219
+ };
220
+ }
221
+
222
+ function applyMembership(context: DiskeyvalContext, peers: ClusterPeer[]): void {
223
+ const normalized = normalizePeers(context.nodeId, peers);
224
+
225
+ const nextIds = new Set(normalized.map((peer) => peer.nodeId));
226
+ for (const existingId of context.peersById.keys()) {
227
+ if (nextIds.has(existingId)) {
228
+ continue;
229
+ }
230
+ context.peersById.delete(existingId);
231
+ if (typeof context.transport.removePeer === 'function') {
232
+ context.transport.removePeer(existingId);
233
+ }
234
+ }
235
+
236
+ for (const peer of normalized) {
237
+ context.peersById.set(peer.nodeId, peer);
238
+ if (
239
+ typeof context.transport.upsertPeer === 'function' &&
240
+ typeof peer.host === 'string' &&
241
+ typeof peer.port === 'number'
242
+ ) {
243
+ context.transport.upsertPeer({
244
+ nodeId: peer.nodeId,
245
+ host: peer.host,
246
+ port: peer.port
247
+ });
248
+ }
249
+ }
250
+
251
+ context.raft.replacePeers(normalized.map((peer) => peer.nodeId));
252
+ }
253
+
254
+ export function diskeyval(options: DiskeyvalOptions): DiskeyvalNode {
255
+ const events = createEventBus();
256
+ const state: Record<string, unknown> = {};
257
+ let started = false;
258
+ const transport = resolveTransport(options);
259
+ const initialPeers = normalizePeers(options.nodeId, extractPeerRecords(options.peers));
260
+ const peersById = new Map(initialPeers.map((peer) => [peer.nodeId, peer]));
261
+
262
+ const raft = createRaftNode<DiskeyvalCommand>({
263
+ nodeId: options.nodeId,
264
+ peerIds: extractPeerIds(initialPeers),
265
+ transport,
266
+ electionTimeoutMs: options.electionTimeoutMs,
267
+ heartbeatMs: options.heartbeatMs,
268
+ proposalTimeoutMs: options.proposalTimeoutMs,
269
+ storage:
270
+ options.persistence !== undefined
271
+ ? createFileRaftStorage<DiskeyvalCommand>({
272
+ dir: options.persistence.dir,
273
+ nodeId: options.nodeId,
274
+ compactEvery: options.persistence.compactEvery
275
+ })
276
+ : undefined,
277
+ applyCommand: (entry) => {
278
+ if (entry.command.type === 'set') {
279
+ state[entry.command.key] = entry.command.value;
280
+ if (started) {
281
+ events.emit('change', { key: entry.command.key, value: entry.command.value });
282
+ }
283
+ return;
284
+ }
285
+
286
+ if (entry.command.type === 'reconfigure') {
287
+ applyMembership(context, entry.command.peers);
288
+ }
289
+ }
290
+ });
291
+
292
+ const context: DiskeyvalContext = {
293
+ nodeId: options.nodeId,
294
+ selfPeer: {
295
+ nodeId: options.nodeId,
296
+ host: options.host,
297
+ port: options.port
298
+ },
299
+ state,
300
+ raft,
301
+ transport,
302
+ events,
303
+ peersById
304
+ };
305
+
306
+ if (typeof context.transport.register === 'function') {
307
+ context.transport.register(context.nodeId, createRpcEndpoint(context));
308
+ }
309
+
310
+ for (const peer of initialPeers) {
311
+ if (
312
+ typeof context.transport.upsertPeer === 'function' &&
313
+ typeof peer.host === 'string' &&
314
+ typeof peer.port === 'number'
315
+ ) {
316
+ context.transport.upsertPeer({
317
+ nodeId: peer.nodeId,
318
+ host: peer.host,
319
+ port: peer.port
320
+ });
321
+ }
322
+ }
323
+
324
+ context.raft.on('leader', (leaderNodeId: string | null) => {
325
+ context.events.emit('leader', { nodeId: leaderNodeId });
326
+ });
327
+
328
+ const start = async (): Promise<void> => {
329
+ if (typeof context.transport.start === 'function') {
330
+ await context.transport.start(createRpcEndpoint(context));
331
+ }
332
+ await context.raft.start();
333
+ started = true;
334
+ };
335
+
336
+ const end = async (): Promise<void> => {
337
+ await context.raft.stop();
338
+ if (typeof context.transport.unregister === 'function') {
339
+ context.transport.unregister(context.nodeId);
340
+ }
341
+ if (typeof context.transport.stop === 'function') {
342
+ await context.transport.stop();
343
+ }
344
+ };
345
+
346
+ const set = async (key: string, value: unknown): Promise<void> => {
347
+ await context.raft.propose({
348
+ type: 'set',
349
+ key,
350
+ value
351
+ });
352
+ };
353
+
354
+ const get = async <T = unknown>(key: string): Promise<T | undefined> => {
355
+ if (!context.raft.isLeader()) {
356
+ throw createNotLeaderError('Reads must go to the leader', context.raft.leaderId());
357
+ }
358
+
359
+ await context.raft.readBarrier();
360
+ return context.state[key] as T | undefined;
361
+ };
362
+
363
+ const reconfigure = async (peers: ClusterPeer[]): Promise<void> => {
364
+ const normalized = normalizeMembers([...peers, context.selfPeer]);
365
+
366
+ await context.raft.propose({
367
+ type: 'reconfigure',
368
+ peers: normalized
369
+ });
370
+ };
371
+
372
+ const getPeers = (): ClusterPeer[] => {
373
+ return Array.from(context.peersById.values()).map((peer) => ({ ...peer }));
374
+ };
375
+
376
+ return {
377
+ nodeId: context.nodeId,
378
+ state: context.state,
379
+ start,
380
+ end,
381
+ set,
382
+ get,
383
+ reconfigure,
384
+ getPeers,
385
+ isLeader: context.raft.isLeader,
386
+ leaderId: context.raft.leaderId,
387
+ getRaftNode: () => context.raft,
388
+ getMetrics: context.raft.getMetrics,
389
+ forceElection: context.raft.startElection,
390
+ on: context.events.on,
391
+ off: context.events.off
392
+ };
393
+ }
394
+
395
+ export function createInMemoryDiskeyvalCluster(
396
+ nodeIds: string[],
397
+ options?: {
398
+ electionTimeoutMs?: number;
399
+ heartbeatMs?: number;
400
+ proposalTimeoutMs?: number;
401
+ }
402
+ ): {
403
+ network: InMemoryRaftNetwork<DiskeyvalCommand>;
404
+ nodes: Record<string, DiskeyvalNode>;
405
+ start: () => Promise<void>;
406
+ stop: () => Promise<void>;
407
+ } {
408
+ const network = createInMemoryRaftNetwork<DiskeyvalCommand>();
409
+ const nodes: Record<string, DiskeyvalNode> = {};
410
+
411
+ for (const nodeId of nodeIds) {
412
+ const peers = nodeIds
413
+ .filter((candidateId) => candidateId !== nodeId)
414
+ .map((candidateId) => ({ nodeId: candidateId }));
415
+
416
+ nodes[nodeId] = diskeyval({
417
+ nodeId,
418
+ peers,
419
+ transport: network,
420
+ electionTimeoutMs: options?.electionTimeoutMs,
421
+ heartbeatMs: options?.heartbeatMs,
422
+ proposalTimeoutMs: options?.proposalTimeoutMs
423
+ });
424
+ }
425
+
426
+ return {
427
+ network,
428
+ nodes,
429
+ start: async () => {
430
+ await Promise.all(Object.values(nodes).map(async (node) => node.start()));
431
+ },
432
+ stop: async () => {
433
+ await Promise.all(Object.values(nodes).map(async (node) => node.end()));
434
+ }
435
+ };
436
+ }
437
+
438
+ export { createInMemoryRaftNetwork, createRaftNode, createTlsRaftTransport };
439
+ export { createFileRaftStorage };
440
+ export {
441
+ createNotLeaderError,
442
+ createQuorumUnavailableError,
443
+ isNotLeaderError,
444
+ isQuorumUnavailableError
445
+ };
446
+ export type { RaftLogEntry, RaftRole, TlsPeer };
447
+
448
+ export default diskeyval;
@@ -0,0 +1,96 @@
1
+ import type {
2
+ AppendEntriesRequest,
3
+ AppendEntriesResponse,
4
+ ManagedRaftTransport,
5
+ RaftRpcEndpoint,
6
+ RequestVoteRequest,
7
+ RequestVoteResponse
8
+ } from './types.ts';
9
+
10
+ function directedEdge(fromNodeId: string, toNodeId: string): string {
11
+ return `${fromNodeId}->${toNodeId}`;
12
+ }
13
+
14
+ export type InMemoryRaftNetwork<T = unknown> = ManagedRaftTransport<T> & {
15
+ register: (nodeId: string, endpoint: RaftRpcEndpoint<T>) => void;
16
+ unregister: (nodeId: string) => void;
17
+ partition: (nodeA: string, nodeB: string) => void;
18
+ heal: (nodeA: string, nodeB: string) => void;
19
+ healAll: () => void;
20
+ };
21
+
22
+ export function createInMemoryRaftNetwork<T = unknown>(options?: {
23
+ latencyMs?: number;
24
+ }): InMemoryRaftNetwork<T> {
25
+ const endpoints = new Map<string, RaftRpcEndpoint<T>>();
26
+ const blockedEdges = new Set<string>();
27
+ const latencyMs = options?.latencyMs ?? 0;
28
+
29
+ const waitLatency = async (): Promise<void> => {
30
+ if (latencyMs <= 0) {
31
+ return;
32
+ }
33
+ await new Promise<void>((resolve) => setTimeout(resolve, latencyMs));
34
+ };
35
+
36
+ const assertReachable = (fromNodeId: string, toNodeId: string): void => {
37
+ if (!blockedEdges.has(directedEdge(fromNodeId, toNodeId))) {
38
+ return;
39
+ }
40
+ throw new Error(`Network partition between ${fromNodeId} and ${toNodeId}`);
41
+ };
42
+
43
+ const requestVote = async (
44
+ fromNodeId: string,
45
+ toNodeId: string,
46
+ request: RequestVoteRequest
47
+ ): Promise<RequestVoteResponse> => {
48
+ await waitLatency();
49
+ assertReachable(fromNodeId, toNodeId);
50
+
51
+ const endpoint = endpoints.get(toNodeId);
52
+ if (!endpoint) {
53
+ throw new Error(`Unknown node: ${toNodeId}`);
54
+ }
55
+
56
+ return endpoint.onRequestVote(fromNodeId, request);
57
+ };
58
+
59
+ const appendEntries = async (
60
+ fromNodeId: string,
61
+ toNodeId: string,
62
+ request: AppendEntriesRequest<T>
63
+ ): Promise<AppendEntriesResponse> => {
64
+ await waitLatency();
65
+ assertReachable(fromNodeId, toNodeId);
66
+
67
+ const endpoint = endpoints.get(toNodeId);
68
+ if (!endpoint) {
69
+ throw new Error(`Unknown node: ${toNodeId}`);
70
+ }
71
+
72
+ return endpoint.onAppendEntries(fromNodeId, request);
73
+ };
74
+
75
+ return {
76
+ requestVote,
77
+ appendEntries,
78
+ register: (nodeId: string, endpoint: RaftRpcEndpoint<T>): void => {
79
+ endpoints.set(nodeId, endpoint);
80
+ },
81
+ unregister: (nodeId: string): void => {
82
+ endpoints.delete(nodeId);
83
+ },
84
+ partition: (nodeA: string, nodeB: string): void => {
85
+ blockedEdges.add(directedEdge(nodeA, nodeB));
86
+ blockedEdges.add(directedEdge(nodeB, nodeA));
87
+ },
88
+ heal: (nodeA: string, nodeB: string): void => {
89
+ blockedEdges.delete(directedEdge(nodeA, nodeB));
90
+ blockedEdges.delete(directedEdge(nodeB, nodeA));
91
+ },
92
+ healAll: (): void => {
93
+ blockedEdges.clear();
94
+ }
95
+ };
96
+ }
@@ -0,0 +1,24 @@
1
+ export { createInMemoryRaftNetwork, type InMemoryRaftNetwork } from './in-memory-network.ts';
2
+ export { createRaftNode, type RaftNode } from './node.ts';
3
+ export { createTlsRaftTransport, type TlsPeer, type TlsRaftTransportOptions } from './tls-transport.ts';
4
+ export {
5
+ createNotLeaderError,
6
+ createQuorumUnavailableError,
7
+ isNotLeaderError,
8
+ isQuorumUnavailableError,
9
+ type ManagedRaftTransport,
10
+ type NotLeaderError,
11
+ type QuorumUnavailableError,
12
+ type RaftMetrics,
13
+ type AppendEntriesRequest,
14
+ type AppendEntriesResponse,
15
+ type RaftLogEntry,
16
+ type RaftNodeOptions,
17
+ type RaftPersistentState,
18
+ type RaftRole,
19
+ type RaftRpcEndpoint,
20
+ type RaftStorage,
21
+ type RaftTransport,
22
+ type RequestVoteRequest,
23
+ type RequestVoteResponse
24
+ } from './types.ts';