yakmesh 1.1.0 → 1.3.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,618 @@
1
+ /**
2
+ * YAKMESH™ PULSE - Precision Universal Latency Sync Engine
3
+ *
4
+ * The heartbeat of the mesh network that provides:
5
+ * - Distributed liveness detection with temporal proofs
6
+ * - Mesh health monitoring and partition detection
7
+ * - Leader election using cryptographic timing
8
+ * - Consensus-ready heartbeat chains
9
+ *
10
+ * Key Innovation: "Heartbeats that prove themselves"
11
+ * - Each heartbeat contains a temporal hash chain
12
+ * - Nodes can verify liveness through cryptographic proofs
13
+ * - Partition detection through heartbeat gap analysis
14
+ *
15
+ * @module mesh/pulse-sync
16
+ * @license MIT
17
+ * @copyright 2026 YAKMESH Contributors
18
+ * @trademark PULSE™ is a trademark of YAKMESH
19
+ */
20
+
21
+ import { randomBytes, createHash } from 'crypto';
22
+ import { sha3_256 } from '@noble/hashes/sha3.js';
23
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
24
+
25
+ const PULSE_CONFIG = {
26
+ // Heartbeat timing
27
+ heartbeatIntervalMs: 1000, // 1 second between heartbeats
28
+ missedBeatsThreshold: 5, // Node considered dead after 5 missed beats
29
+ suspectThreshold: 2, // Node suspected after 2 missed beats
30
+
31
+ // Health monitoring
32
+ healthWindowSize: 60, // Track last 60 heartbeats
33
+ healthyThreshold: 0.95, // 95% heartbeat success = healthy
34
+ degradedThreshold: 0.8, // 80% = degraded
35
+
36
+ // Partition detection
37
+ partitionDetectionWindow: 10000, // 10s window
38
+ minPartitionNodes: 2, // Minimum nodes to declare partition
39
+
40
+ // Leader election
41
+ leaderElectionTimeout: 5000, // 5s to elect leader
42
+ termDurationMs: 30000, // Leader term duration
43
+
44
+ // Chain settings
45
+ maxChainLength: 1000, // Max heartbeats to keep
46
+ chainPruneInterval: 60000, // Prune chain every minute
47
+ };
48
+
49
+ /**
50
+ * A single heartbeat with cryptographic proof
51
+ */
52
+ class Heartbeat {
53
+ constructor(options) {
54
+ this.nodeId = options.nodeId;
55
+ this.sequence = options.sequence || 0;
56
+ this.timestamp = options.timestamp || Date.now();
57
+ this.prevHash = options.prevHash || '0'.repeat(64);
58
+ this.nonce = options.nonce || bytesToHex(randomBytes(8));
59
+ this.meshState = options.meshState || {};
60
+ this.hash = this._computeHash();
61
+ }
62
+
63
+ _computeHash() {
64
+ const data = [
65
+ this.nodeId,
66
+ this.sequence.toString(),
67
+ this.timestamp.toString(),
68
+ this.prevHash,
69
+ this.nonce,
70
+ JSON.stringify(this.meshState),
71
+ ].join(':');
72
+
73
+ return bytesToHex(sha3_256(utf8ToBytes(data)));
74
+ }
75
+
76
+ verify() {
77
+ return this.hash === this._computeHash();
78
+ }
79
+
80
+ /**
81
+ * Verify this heartbeat chains from previous
82
+ */
83
+ chainsFrom(prevHeartbeat) {
84
+ if (!prevHeartbeat) return this.sequence === 0;
85
+ return (
86
+ this.prevHash === prevHeartbeat.hash &&
87
+ this.sequence === prevHeartbeat.sequence + 1 &&
88
+ this.timestamp > prevHeartbeat.timestamp
89
+ );
90
+ }
91
+
92
+ serialize() {
93
+ return {
94
+ nodeId: this.nodeId,
95
+ sequence: this.sequence,
96
+ timestamp: this.timestamp,
97
+ prevHash: this.prevHash,
98
+ nonce: this.nonce,
99
+ meshState: this.meshState,
100
+ hash: this.hash,
101
+ };
102
+ }
103
+
104
+ static deserialize(obj) {
105
+ const hb = new Heartbeat({
106
+ nodeId: obj.nodeId,
107
+ sequence: obj.sequence,
108
+ timestamp: obj.timestamp,
109
+ prevHash: obj.prevHash,
110
+ nonce: obj.nonce,
111
+ meshState: obj.meshState,
112
+ });
113
+
114
+ if (hb.hash !== obj.hash) {
115
+ throw new Error('Heartbeat hash verification failed');
116
+ }
117
+
118
+ return hb;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Heartbeat chain for a single node
124
+ */
125
+ class HeartbeatChain {
126
+ constructor(nodeId) {
127
+ this.nodeId = nodeId;
128
+ this.chain = [];
129
+ this.lastReceived = 0;
130
+ this.status = 'unknown'; // unknown, alive, suspect, dead
131
+ this.missedBeats = 0;
132
+ this.metrics = {
133
+ totalReceived: 0,
134
+ totalMissed: 0,
135
+ avgLatency: 0,
136
+ lastLatency: 0,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Add heartbeat to chain with validation
142
+ */
143
+ addHeartbeat(heartbeat, receivedAt = Date.now()) {
144
+ if (heartbeat.nodeId !== this.nodeId) {
145
+ return { success: false, reason: 'Node ID mismatch' };
146
+ }
147
+
148
+ if (!heartbeat.verify()) {
149
+ return { success: false, reason: 'Hash verification failed' };
150
+ }
151
+
152
+ // Verify chaining
153
+ const lastHb = this.chain[this.chain.length - 1];
154
+ if (lastHb && !heartbeat.chainsFrom(lastHb)) {
155
+ // Could be a gap - check sequence
156
+ if (heartbeat.sequence <= lastHb.sequence) {
157
+ return { success: false, reason: 'Duplicate or old heartbeat' };
158
+ }
159
+ // Gap detected
160
+ const gap = heartbeat.sequence - lastHb.sequence - 1;
161
+ this.metrics.totalMissed += gap;
162
+ }
163
+
164
+ // Calculate latency
165
+ const latency = receivedAt - heartbeat.timestamp;
166
+ this.metrics.lastLatency = latency;
167
+ this.metrics.avgLatency = this.metrics.totalReceived === 0
168
+ ? latency
169
+ : (this.metrics.avgLatency * 0.9 + latency * 0.1);
170
+
171
+ this.chain.push(heartbeat);
172
+ this.lastReceived = receivedAt;
173
+ this.missedBeats = 0;
174
+ this.status = 'alive';
175
+ this.metrics.totalReceived++;
176
+
177
+ // Prune old heartbeats
178
+ while (this.chain.length > PULSE_CONFIG.maxChainLength) {
179
+ this.chain.shift();
180
+ }
181
+
182
+ return { success: true, latency, sequence: heartbeat.sequence };
183
+ }
184
+
185
+ /**
186
+ * Check if node has missed heartbeats
187
+ */
188
+ checkLiveness(currentTime = Date.now()) {
189
+ if (this.lastReceived === 0) {
190
+ return { status: 'unknown', missedBeats: 0 };
191
+ }
192
+
193
+ const elapsed = currentTime - this.lastReceived;
194
+ const expectedBeats = Math.floor(elapsed / PULSE_CONFIG.heartbeatIntervalMs);
195
+
196
+ if (expectedBeats > this.missedBeats) {
197
+ this.missedBeats = expectedBeats;
198
+ this.metrics.totalMissed += expectedBeats - this.missedBeats;
199
+ }
200
+
201
+ if (this.missedBeats >= PULSE_CONFIG.missedBeatsThreshold) {
202
+ this.status = 'dead';
203
+ } else if (this.missedBeats >= PULSE_CONFIG.suspectThreshold) {
204
+ this.status = 'suspect';
205
+ } else {
206
+ this.status = 'alive';
207
+ }
208
+
209
+ return {
210
+ status: this.status,
211
+ missedBeats: this.missedBeats,
212
+ lastReceived: this.lastReceived,
213
+ elapsed,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Get health score (0-1)
219
+ */
220
+ getHealthScore() {
221
+ const total = this.metrics.totalReceived + this.metrics.totalMissed;
222
+ if (total === 0) return 0;
223
+ return this.metrics.totalReceived / total;
224
+ }
225
+
226
+ /**
227
+ * Get the latest N heartbeats as proof of liveness
228
+ */
229
+ getLivenessProof(count = 5) {
230
+ const recent = this.chain.slice(-count);
231
+ return {
232
+ nodeId: this.nodeId,
233
+ status: this.status,
234
+ heartbeats: recent.map(h => h.serialize()),
235
+ healthScore: this.getHealthScore(),
236
+ metrics: { ...this.metrics },
237
+ };
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Mesh health monitor - tracks overall network health
243
+ */
244
+ class MeshHealthMonitor {
245
+ constructor() {
246
+ this.nodes = new Map(); // nodeId -> HeartbeatChain
247
+ this.partitions = [];
248
+ this.healthHistory = [];
249
+ this.alerts = [];
250
+ }
251
+
252
+ /**
253
+ * Process incoming heartbeat
254
+ */
255
+ processHeartbeat(heartbeat, receivedAt = Date.now()) {
256
+ if (!this.nodes.has(heartbeat.nodeId)) {
257
+ this.nodes.set(heartbeat.nodeId, new HeartbeatChain(heartbeat.nodeId));
258
+ }
259
+
260
+ const chain = this.nodes.get(heartbeat.nodeId);
261
+ return chain.addHeartbeat(heartbeat, receivedAt);
262
+ }
263
+
264
+ /**
265
+ * Run liveness check on all nodes
266
+ */
267
+ runLivenessCheck(currentTime = Date.now()) {
268
+ const results = {
269
+ alive: [],
270
+ suspect: [],
271
+ dead: [],
272
+ unknown: [],
273
+ };
274
+
275
+ for (const [nodeId, chain] of this.nodes) {
276
+ const check = chain.checkLiveness(currentTime);
277
+ results[check.status].push({
278
+ nodeId,
279
+ ...check,
280
+ healthScore: chain.getHealthScore(),
281
+ });
282
+ }
283
+
284
+ // Check for partition
285
+ this._detectPartition(results, currentTime);
286
+
287
+ return results;
288
+ }
289
+
290
+ /**
291
+ * Detect network partition
292
+ */
293
+ _detectPartition(livenessResults, currentTime) {
294
+ const deadCount = livenessResults.dead.length;
295
+ const totalCount = this.nodes.size;
296
+
297
+ if (deadCount >= PULSE_CONFIG.minPartitionNodes &&
298
+ deadCount > totalCount * 0.3) {
299
+ // Possible partition - check if dead nodes have similar last-seen times
300
+ const deadNodes = livenessResults.dead;
301
+ const lastSeenTimes = deadNodes.map(n => n.lastReceived);
302
+ const avgLastSeen = lastSeenTimes.reduce((a, b) => a + b, 0) / deadNodes.length;
303
+ const variance = lastSeenTimes.reduce((s, t) => s + Math.pow(t - avgLastSeen, 2), 0) / deadNodes.length;
304
+
305
+ // Low variance = simultaneous failure = likely partition
306
+ if (Math.sqrt(variance) < PULSE_CONFIG.partitionDetectionWindow) {
307
+ const partition = {
308
+ detectedAt: currentTime,
309
+ affectedNodes: deadNodes.map(n => n.nodeId),
310
+ lastSeenRange: [Math.min(...lastSeenTimes), Math.max(...lastSeenTimes)],
311
+ confidence: 1 - (Math.sqrt(variance) / PULSE_CONFIG.partitionDetectionWindow),
312
+ };
313
+ this.partitions.push(partition);
314
+ this.alerts.push({
315
+ type: 'PARTITION_DETECTED',
316
+ severity: 'critical',
317
+ ...partition,
318
+ });
319
+ }
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Get overall mesh health
325
+ */
326
+ getMeshHealth() {
327
+ const liveness = this.runLivenessCheck();
328
+ const totalNodes = this.nodes.size;
329
+
330
+ if (totalNodes === 0) {
331
+ return { status: 'unknown', score: 0, details: liveness };
332
+ }
333
+
334
+ const aliveRatio = liveness.alive.length / totalNodes;
335
+ const avgHealthScore = Array.from(this.nodes.values())
336
+ .reduce((sum, chain) => sum + chain.getHealthScore(), 0) / totalNodes;
337
+
338
+ let status;
339
+ if (aliveRatio >= PULSE_CONFIG.healthyThreshold && avgHealthScore >= PULSE_CONFIG.healthyThreshold) {
340
+ status = 'healthy';
341
+ } else if (aliveRatio >= PULSE_CONFIG.degradedThreshold) {
342
+ status = 'degraded';
343
+ } else {
344
+ status = 'critical';
345
+ }
346
+
347
+ return {
348
+ status,
349
+ score: Math.round((aliveRatio * 0.5 + avgHealthScore * 0.5) * 100),
350
+ details: liveness,
351
+ partitions: this.partitions,
352
+ alerts: this.alerts.slice(-10),
353
+ };
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Leader election using heartbeat timing
359
+ */
360
+ class PulseLeaderElection {
361
+ constructor(options = {}) {
362
+ this.nodeId = options.nodeId;
363
+ this.currentTerm = 0;
364
+ this.currentLeader = null;
365
+ this.leaderSince = null;
366
+ this.votes = new Map();
367
+ this.state = 'follower'; // follower, candidate, leader
368
+ this.heartbeatChains = new Map();
369
+ }
370
+
371
+ /**
372
+ * Start leader election
373
+ */
374
+ startElection() {
375
+ this.currentTerm++;
376
+ this.state = 'candidate';
377
+ this.votes.clear();
378
+ this.votes.set(this.nodeId, this.currentTerm); // Vote for self
379
+
380
+ return {
381
+ type: 'VOTE_REQUEST',
382
+ term: this.currentTerm,
383
+ candidateId: this.nodeId,
384
+ lastHeartbeatSeq: this._getLastHeartbeatSeq(),
385
+ timestamp: Date.now(),
386
+ };
387
+ }
388
+
389
+ /**
390
+ * Handle vote request
391
+ */
392
+ handleVoteRequest(request) {
393
+ // Only vote if candidate has higher term or same term with better heartbeat history
394
+ if (request.term < this.currentTerm) {
395
+ return { voteGranted: false, term: this.currentTerm };
396
+ }
397
+
398
+ if (request.term > this.currentTerm) {
399
+ this.currentTerm = request.term;
400
+ this.state = 'follower';
401
+ this.currentLeader = null;
402
+ }
403
+
404
+ // Check if we haven't voted this term
405
+ const existingVote = this.votes.get(this.nodeId);
406
+ if (existingVote && existingVote === this.currentTerm) {
407
+ return { voteGranted: false, term: this.currentTerm };
408
+ }
409
+
410
+ // Vote for candidate with better heartbeat history
411
+ const mySeq = this._getLastHeartbeatSeq();
412
+ if (request.lastHeartbeatSeq >= mySeq) {
413
+ this.votes.set(this.nodeId, this.currentTerm);
414
+ return { voteGranted: true, term: this.currentTerm, voterId: this.nodeId };
415
+ }
416
+
417
+ return { voteGranted: false, term: this.currentTerm };
418
+ }
419
+
420
+ /**
421
+ * Handle vote response
422
+ */
423
+ handleVoteResponse(response, totalNodes) {
424
+ if (response.term > this.currentTerm) {
425
+ this.currentTerm = response.term;
426
+ this.state = 'follower';
427
+ return { elected: false };
428
+ }
429
+
430
+ if (response.voteGranted && response.term === this.currentTerm) {
431
+ this.votes.set(response.voterId, response.term);
432
+ }
433
+
434
+ // Check if we have majority
435
+ const voteCount = Array.from(this.votes.values())
436
+ .filter(t => t === this.currentTerm).length;
437
+
438
+ if (voteCount > totalNodes / 2) {
439
+ this.state = 'leader';
440
+ this.currentLeader = this.nodeId;
441
+ this.leaderSince = Date.now();
442
+ return { elected: true, term: this.currentTerm };
443
+ }
444
+
445
+ return { elected: false, votes: voteCount, needed: Math.floor(totalNodes / 2) + 1 };
446
+ }
447
+
448
+ /**
449
+ * Receive leader heartbeat (prevents election)
450
+ */
451
+ acknowledgeLeader(leaderId, term) {
452
+ if (term >= this.currentTerm) {
453
+ this.currentTerm = term;
454
+ this.currentLeader = leaderId;
455
+ this.state = 'follower';
456
+ return true;
457
+ }
458
+ return false;
459
+ }
460
+
461
+ _getLastHeartbeatSeq() {
462
+ const myChain = this.heartbeatChains.get(this.nodeId);
463
+ if (!myChain || myChain.chain.length === 0) return 0;
464
+ return myChain.chain[myChain.chain.length - 1].sequence;
465
+ }
466
+
467
+ getState() {
468
+ return {
469
+ nodeId: this.nodeId,
470
+ state: this.state,
471
+ term: this.currentTerm,
472
+ leader: this.currentLeader,
473
+ leaderSince: this.leaderSince,
474
+ votes: Object.fromEntries(this.votes),
475
+ };
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Main PULSE sync engine
481
+ */
482
+ class PulseSync {
483
+ constructor(options = {}) {
484
+ this.nodeId = options.nodeId || bytesToHex(randomBytes(16));
485
+ this.sequence = 0;
486
+ this.lastHeartbeat = null;
487
+ this.healthMonitor = new MeshHealthMonitor();
488
+ this.election = new PulseLeaderElection({ nodeId: this.nodeId });
489
+ this.election.heartbeatChains = this.healthMonitor.nodes;
490
+
491
+ this.stats = {
492
+ heartbeatsSent: 0,
493
+ heartbeatsReceived: 0,
494
+ electionsStarted: 0,
495
+ termsAsLeader: 0,
496
+ };
497
+
498
+ // Callbacks
499
+ this.onHeartbeat = options.onHeartbeat || (() => {});
500
+ this.onLeaderChange = options.onLeaderChange || (() => {});
501
+ this.onPartitionDetected = options.onPartitionDetected || (() => {});
502
+ }
503
+
504
+ /**
505
+ * Generate next heartbeat
506
+ */
507
+ createHeartbeat(meshState = {}) {
508
+ const prevHash = this.lastHeartbeat ? this.lastHeartbeat.hash : '0'.repeat(64);
509
+
510
+ const heartbeat = new Heartbeat({
511
+ nodeId: this.nodeId,
512
+ sequence: this.sequence++,
513
+ prevHash,
514
+ meshState: {
515
+ ...meshState,
516
+ isLeader: this.election.state === 'leader',
517
+ term: this.election.currentTerm,
518
+ },
519
+ });
520
+
521
+ this.lastHeartbeat = heartbeat;
522
+ this.stats.heartbeatsSent++;
523
+
524
+ // Also process our own heartbeat for monitoring
525
+ this.healthMonitor.processHeartbeat(heartbeat);
526
+
527
+ return heartbeat.serialize();
528
+ }
529
+
530
+ /**
531
+ * Process received heartbeat
532
+ */
533
+ receiveHeartbeat(heartbeatData, receivedAt = Date.now()) {
534
+ try {
535
+ const heartbeat = Heartbeat.deserialize(heartbeatData);
536
+ const result = this.healthMonitor.processHeartbeat(heartbeat, receivedAt);
537
+
538
+ if (result.success) {
539
+ this.stats.heartbeatsReceived++;
540
+
541
+ // Check if this is from the leader
542
+ if (heartbeat.meshState.isLeader) {
543
+ this.election.acknowledgeLeader(heartbeat.nodeId, heartbeat.meshState.term);
544
+ }
545
+ }
546
+
547
+ return result;
548
+ } catch (err) {
549
+ return { success: false, reason: err.message };
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Start leader election
555
+ */
556
+ startElection() {
557
+ this.stats.electionsStarted++;
558
+ return this.election.startElection();
559
+ }
560
+
561
+ /**
562
+ * Handle vote request
563
+ */
564
+ handleVoteRequest(request) {
565
+ return this.election.handleVoteRequest(request);
566
+ }
567
+
568
+ /**
569
+ * Handle vote response
570
+ */
571
+ handleVoteResponse(response) {
572
+ const totalNodes = this.healthMonitor.nodes.size;
573
+ const result = this.election.handleVoteResponse(response, totalNodes);
574
+
575
+ if (result.elected) {
576
+ this.stats.termsAsLeader++;
577
+ this.onLeaderChange({ leader: this.nodeId, term: result.term });
578
+ }
579
+
580
+ return result;
581
+ }
582
+
583
+ /**
584
+ * Get mesh health summary
585
+ */
586
+ getHealth() {
587
+ return this.healthMonitor.getMeshHealth();
588
+ }
589
+
590
+ /**
591
+ * Get liveness proof for this node
592
+ */
593
+ getLivenessProof(count = 5) {
594
+ const chain = this.healthMonitor.nodes.get(this.nodeId);
595
+ if (!chain) return null;
596
+ return chain.getLivenessProof(count);
597
+ }
598
+
599
+ /**
600
+ * Get full stats
601
+ */
602
+ getStats() {
603
+ return {
604
+ ...this.stats,
605
+ election: this.election.getState(),
606
+ health: this.healthMonitor.getMeshHealth(),
607
+ };
608
+ }
609
+ }
610
+
611
+ export {
612
+ PULSE_CONFIG,
613
+ Heartbeat,
614
+ HeartbeatChain,
615
+ MeshHealthMonitor,
616
+ PulseLeaderElection,
617
+ PulseSync,
618
+ };