@ruvector/edge-net 0.1.6 → 0.2.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/ledger.js ADDED
@@ -0,0 +1,663 @@
1
+ /**
2
+ * @ruvector/edge-net Persistent Ledger with CRDT
3
+ *
4
+ * Conflict-free Replicated Data Type for distributed credit tracking
5
+ * Features:
6
+ * - G-Counter for earned credits
7
+ * - PN-Counter for balance
8
+ * - LWW-Register for metadata
9
+ * - File-based persistence
10
+ * - Network synchronization
11
+ *
12
+ * @module @ruvector/edge-net/ledger
13
+ */
14
+
15
+ import { EventEmitter } from 'events';
16
+ import { randomBytes, createHash } from 'crypto';
17
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { homedir } from 'os';
20
+
21
+ // ============================================
22
+ // CRDT PRIMITIVES
23
+ // ============================================
24
+
25
+ /**
26
+ * G-Counter (Grow-only Counter)
27
+ * Can only increment, never decrement
28
+ */
29
+ export class GCounter {
30
+ constructor(nodeId) {
31
+ this.nodeId = nodeId;
32
+ this.counters = new Map(); // nodeId -> count
33
+ }
34
+
35
+ increment(amount = 1) {
36
+ const current = this.counters.get(this.nodeId) || 0;
37
+ this.counters.set(this.nodeId, current + amount);
38
+ }
39
+
40
+ value() {
41
+ let total = 0;
42
+ for (const count of this.counters.values()) {
43
+ total += count;
44
+ }
45
+ return total;
46
+ }
47
+
48
+ merge(other) {
49
+ for (const [nodeId, count] of other.counters) {
50
+ const current = this.counters.get(nodeId) || 0;
51
+ this.counters.set(nodeId, Math.max(current, count));
52
+ }
53
+ }
54
+
55
+ toJSON() {
56
+ return {
57
+ nodeId: this.nodeId,
58
+ counters: Object.fromEntries(this.counters),
59
+ };
60
+ }
61
+
62
+ static fromJSON(json) {
63
+ const counter = new GCounter(json.nodeId);
64
+ counter.counters = new Map(Object.entries(json.counters));
65
+ return counter;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * PN-Counter (Positive-Negative Counter)
71
+ * Can increment and decrement
72
+ */
73
+ export class PNCounter {
74
+ constructor(nodeId) {
75
+ this.nodeId = nodeId;
76
+ this.positive = new GCounter(nodeId);
77
+ this.negative = new GCounter(nodeId);
78
+ }
79
+
80
+ increment(amount = 1) {
81
+ this.positive.increment(amount);
82
+ }
83
+
84
+ decrement(amount = 1) {
85
+ this.negative.increment(amount);
86
+ }
87
+
88
+ value() {
89
+ return this.positive.value() - this.negative.value();
90
+ }
91
+
92
+ merge(other) {
93
+ this.positive.merge(other.positive);
94
+ this.negative.merge(other.negative);
95
+ }
96
+
97
+ toJSON() {
98
+ return {
99
+ nodeId: this.nodeId,
100
+ positive: this.positive.toJSON(),
101
+ negative: this.negative.toJSON(),
102
+ };
103
+ }
104
+
105
+ static fromJSON(json) {
106
+ const counter = new PNCounter(json.nodeId);
107
+ counter.positive = GCounter.fromJSON(json.positive);
108
+ counter.negative = GCounter.fromJSON(json.negative);
109
+ return counter;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * LWW-Register (Last-Writer-Wins Register)
115
+ * Stores a single value with timestamp
116
+ */
117
+ export class LWWRegister {
118
+ constructor(nodeId, value = null) {
119
+ this.nodeId = nodeId;
120
+ this.value = value;
121
+ this.timestamp = Date.now();
122
+ }
123
+
124
+ set(value) {
125
+ this.value = value;
126
+ this.timestamp = Date.now();
127
+ }
128
+
129
+ get() {
130
+ return this.value;
131
+ }
132
+
133
+ merge(other) {
134
+ if (other.timestamp > this.timestamp) {
135
+ this.value = other.value;
136
+ this.timestamp = other.timestamp;
137
+ }
138
+ }
139
+
140
+ toJSON() {
141
+ return {
142
+ nodeId: this.nodeId,
143
+ value: this.value,
144
+ timestamp: this.timestamp,
145
+ };
146
+ }
147
+
148
+ static fromJSON(json) {
149
+ const register = new LWWRegister(json.nodeId);
150
+ register.value = json.value;
151
+ register.timestamp = json.timestamp;
152
+ return register;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * LWW-Map (Last-Writer-Wins Map)
158
+ * Map with LWW semantics per key
159
+ */
160
+ export class LWWMap {
161
+ constructor(nodeId) {
162
+ this.nodeId = nodeId;
163
+ this.entries = new Map(); // key -> { value, timestamp }
164
+ }
165
+
166
+ set(key, value) {
167
+ this.entries.set(key, {
168
+ value,
169
+ timestamp: Date.now(),
170
+ });
171
+ }
172
+
173
+ get(key) {
174
+ const entry = this.entries.get(key);
175
+ return entry ? entry.value : undefined;
176
+ }
177
+
178
+ delete(key) {
179
+ this.entries.set(key, {
180
+ value: null,
181
+ timestamp: Date.now(),
182
+ deleted: true,
183
+ });
184
+ }
185
+
186
+ has(key) {
187
+ const entry = this.entries.get(key);
188
+ return entry && !entry.deleted;
189
+ }
190
+
191
+ keys() {
192
+ return Array.from(this.entries.keys()).filter(k => !this.entries.get(k).deleted);
193
+ }
194
+
195
+ values() {
196
+ return this.keys().map(k => this.entries.get(k).value);
197
+ }
198
+
199
+ merge(other) {
200
+ for (const [key, entry] of other.entries) {
201
+ const current = this.entries.get(key);
202
+ if (!current || entry.timestamp > current.timestamp) {
203
+ this.entries.set(key, { ...entry });
204
+ }
205
+ }
206
+ }
207
+
208
+ toJSON() {
209
+ return {
210
+ nodeId: this.nodeId,
211
+ entries: Object.fromEntries(this.entries),
212
+ };
213
+ }
214
+
215
+ static fromJSON(json) {
216
+ const map = new LWWMap(json.nodeId);
217
+ map.entries = new Map(Object.entries(json.entries));
218
+ return map;
219
+ }
220
+ }
221
+
222
+ // ============================================
223
+ // PERSISTENT LEDGER
224
+ // ============================================
225
+
226
+ /**
227
+ * Distributed Ledger with CRDT and persistence
228
+ */
229
+ export class Ledger extends EventEmitter {
230
+ constructor(options = {}) {
231
+ super();
232
+ this.nodeId = options.nodeId || `node-${randomBytes(8).toString('hex')}`;
233
+
234
+ // Storage path
235
+ this.dataDir = options.dataDir ||
236
+ join(homedir(), '.ruvector', 'edge-net', 'ledger');
237
+
238
+ // CRDT state
239
+ this.earned = new GCounter(this.nodeId);
240
+ this.spent = new GCounter(this.nodeId);
241
+ this.metadata = new LWWMap(this.nodeId);
242
+ this.transactions = [];
243
+
244
+ // Configuration
245
+ this.autosaveInterval = options.autosaveInterval || 30000; // 30 seconds
246
+ this.maxTransactions = options.maxTransactions || 10000;
247
+
248
+ // Sync
249
+ this.lastSync = 0;
250
+ this.syncPeers = new Set();
251
+
252
+ // Initialize
253
+ this.initialized = false;
254
+ }
255
+
256
+ /**
257
+ * Initialize ledger and load from disk
258
+ */
259
+ async initialize() {
260
+ // Create data directory
261
+ if (!existsSync(this.dataDir)) {
262
+ mkdirSync(this.dataDir, { recursive: true });
263
+ }
264
+
265
+ // Load existing state
266
+ await this.load();
267
+
268
+ // Start autosave
269
+ this.autosaveTimer = setInterval(() => {
270
+ this.save().catch(err => console.error('[Ledger] Autosave error:', err));
271
+ }, this.autosaveInterval);
272
+
273
+ this.initialized = true;
274
+ this.emit('ready', { nodeId: this.nodeId });
275
+
276
+ return this;
277
+ }
278
+
279
+ /**
280
+ * Credit (earn) amount
281
+ */
282
+ credit(amount, memo = '') {
283
+ if (amount <= 0) throw new Error('Amount must be positive');
284
+
285
+ this.earned.increment(amount);
286
+
287
+ const tx = {
288
+ id: `tx-${randomBytes(8).toString('hex')}`,
289
+ type: 'credit',
290
+ amount,
291
+ memo,
292
+ timestamp: Date.now(),
293
+ nodeId: this.nodeId,
294
+ };
295
+
296
+ this.transactions.push(tx);
297
+ this.pruneTransactions();
298
+
299
+ this.emit('credit', { amount, balance: this.balance(), tx });
300
+
301
+ return tx;
302
+ }
303
+
304
+ /**
305
+ * Debit (spend) amount
306
+ */
307
+ debit(amount, memo = '') {
308
+ if (amount <= 0) throw new Error('Amount must be positive');
309
+ if (amount > this.balance()) throw new Error('Insufficient balance');
310
+
311
+ this.spent.increment(amount);
312
+
313
+ const tx = {
314
+ id: `tx-${randomBytes(8).toString('hex')}`,
315
+ type: 'debit',
316
+ amount,
317
+ memo,
318
+ timestamp: Date.now(),
319
+ nodeId: this.nodeId,
320
+ };
321
+
322
+ this.transactions.push(tx);
323
+ this.pruneTransactions();
324
+
325
+ this.emit('debit', { amount, balance: this.balance(), tx });
326
+
327
+ return tx;
328
+ }
329
+
330
+ /**
331
+ * Get current balance
332
+ */
333
+ balance() {
334
+ return this.earned.value() - this.spent.value();
335
+ }
336
+
337
+ /**
338
+ * Get total earned
339
+ */
340
+ totalEarned() {
341
+ return this.earned.value();
342
+ }
343
+
344
+ /**
345
+ * Get total spent
346
+ */
347
+ totalSpent() {
348
+ return this.spent.value();
349
+ }
350
+
351
+ /**
352
+ * Set metadata
353
+ */
354
+ setMetadata(key, value) {
355
+ this.metadata.set(key, value);
356
+ this.emit('metadata', { key, value });
357
+ }
358
+
359
+ /**
360
+ * Get metadata
361
+ */
362
+ getMetadata(key) {
363
+ return this.metadata.get(key);
364
+ }
365
+
366
+ /**
367
+ * Get recent transactions
368
+ */
369
+ getTransactions(limit = 50) {
370
+ return this.transactions.slice(-limit);
371
+ }
372
+
373
+ /**
374
+ * Prune old transactions
375
+ */
376
+ pruneTransactions() {
377
+ if (this.transactions.length > this.maxTransactions) {
378
+ this.transactions = this.transactions.slice(-this.maxTransactions);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Merge with another ledger state (CRDT merge)
384
+ */
385
+ merge(other) {
386
+ // Merge counters
387
+ if (other.earned) {
388
+ this.earned.merge(
389
+ other.earned instanceof GCounter
390
+ ? other.earned
391
+ : GCounter.fromJSON(other.earned)
392
+ );
393
+ }
394
+
395
+ if (other.spent) {
396
+ this.spent.merge(
397
+ other.spent instanceof GCounter
398
+ ? other.spent
399
+ : GCounter.fromJSON(other.spent)
400
+ );
401
+ }
402
+
403
+ if (other.metadata) {
404
+ this.metadata.merge(
405
+ other.metadata instanceof LWWMap
406
+ ? other.metadata
407
+ : LWWMap.fromJSON(other.metadata)
408
+ );
409
+ }
410
+
411
+ // Merge transactions (deduplicate by id)
412
+ if (other.transactions) {
413
+ const existingIds = new Set(this.transactions.map(t => t.id));
414
+ for (const tx of other.transactions) {
415
+ if (!existingIds.has(tx.id)) {
416
+ this.transactions.push(tx);
417
+ }
418
+ }
419
+ // Sort by timestamp and prune
420
+ this.transactions.sort((a, b) => a.timestamp - b.timestamp);
421
+ this.pruneTransactions();
422
+ }
423
+
424
+ this.lastSync = Date.now();
425
+ this.emit('merged', { balance: this.balance() });
426
+ }
427
+
428
+ /**
429
+ * Export state for synchronization
430
+ */
431
+ export() {
432
+ return {
433
+ nodeId: this.nodeId,
434
+ timestamp: Date.now(),
435
+ earned: this.earned.toJSON(),
436
+ spent: this.spent.toJSON(),
437
+ metadata: this.metadata.toJSON(),
438
+ transactions: this.transactions,
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Save to disk
444
+ */
445
+ async save() {
446
+ const filePath = join(this.dataDir, 'ledger.json');
447
+ const data = this.export();
448
+
449
+ writeFileSync(filePath, JSON.stringify(data, null, 2));
450
+ this.emit('saved', { path: filePath });
451
+ }
452
+
453
+ /**
454
+ * Load from disk
455
+ */
456
+ async load() {
457
+ const filePath = join(this.dataDir, 'ledger.json');
458
+
459
+ if (!existsSync(filePath)) {
460
+ return;
461
+ }
462
+
463
+ try {
464
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
465
+
466
+ this.earned = GCounter.fromJSON(data.earned);
467
+ this.spent = GCounter.fromJSON(data.spent);
468
+ this.metadata = LWWMap.fromJSON(data.metadata);
469
+ this.transactions = data.transactions || [];
470
+
471
+ this.emit('loaded', { balance: this.balance() });
472
+ } catch (error) {
473
+ console.error('[Ledger] Load error:', error.message);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Get ledger summary
479
+ */
480
+ getSummary() {
481
+ return {
482
+ nodeId: this.nodeId,
483
+ balance: this.balance(),
484
+ earned: this.totalEarned(),
485
+ spent: this.totalSpent(),
486
+ transactions: this.transactions.length,
487
+ lastSync: this.lastSync,
488
+ initialized: this.initialized,
489
+ };
490
+ }
491
+
492
+ /**
493
+ * Shutdown ledger
494
+ */
495
+ async shutdown() {
496
+ if (this.autosaveTimer) {
497
+ clearInterval(this.autosaveTimer);
498
+ }
499
+
500
+ await this.save();
501
+ this.initialized = false;
502
+ this.emit('shutdown');
503
+ }
504
+ }
505
+
506
+ // ============================================
507
+ // SYNC CLIENT
508
+ // ============================================
509
+
510
+ /**
511
+ * Ledger sync client for relay communication
512
+ */
513
+ export class LedgerSyncClient extends EventEmitter {
514
+ constructor(options = {}) {
515
+ super();
516
+ this.ledger = options.ledger;
517
+ this.relayUrl = options.relayUrl || 'ws://localhost:8080';
518
+ this.ws = null;
519
+ this.connected = false;
520
+ this.syncInterval = options.syncInterval || 60000; // 1 minute
521
+ }
522
+
523
+ /**
524
+ * Connect to relay for syncing
525
+ */
526
+ async connect() {
527
+ return new Promise(async (resolve, reject) => {
528
+ try {
529
+ let WebSocket;
530
+ if (typeof globalThis.WebSocket !== 'undefined') {
531
+ WebSocket = globalThis.WebSocket;
532
+ } else {
533
+ const ws = await import('ws');
534
+ WebSocket = ws.default || ws.WebSocket;
535
+ }
536
+
537
+ this.ws = new WebSocket(this.relayUrl);
538
+
539
+ const timeout = setTimeout(() => {
540
+ reject(new Error('Connection timeout'));
541
+ }, 10000);
542
+
543
+ this.ws.onopen = () => {
544
+ clearTimeout(timeout);
545
+ this.connected = true;
546
+
547
+ // Register for ledger sync
548
+ this.send({
549
+ type: 'register',
550
+ nodeId: this.ledger.nodeId,
551
+ capabilities: ['ledger_sync'],
552
+ });
553
+
554
+ this.emit('connected');
555
+ resolve(true);
556
+ };
557
+
558
+ this.ws.onmessage = (event) => {
559
+ this.handleMessage(JSON.parse(event.data));
560
+ };
561
+
562
+ this.ws.onclose = () => {
563
+ this.connected = false;
564
+ this.emit('disconnected');
565
+ };
566
+
567
+ this.ws.onerror = (error) => {
568
+ clearTimeout(timeout);
569
+ reject(error);
570
+ };
571
+
572
+ } catch (error) {
573
+ reject(error);
574
+ }
575
+ });
576
+ }
577
+
578
+ /**
579
+ * Handle incoming message
580
+ */
581
+ handleMessage(message) {
582
+ switch (message.type) {
583
+ case 'welcome':
584
+ this.startSyncLoop();
585
+ break;
586
+
587
+ case 'ledger_state':
588
+ this.handleLedgerState(message);
589
+ break;
590
+
591
+ case 'ledger_update':
592
+ this.ledger.merge(message.state);
593
+ break;
594
+
595
+ default:
596
+ this.emit('message', message);
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Handle ledger state from relay
602
+ */
603
+ handleLedgerState(message) {
604
+ if (message.state) {
605
+ this.ledger.merge(message.state);
606
+ }
607
+ this.emit('synced', { balance: this.ledger.balance() });
608
+ }
609
+
610
+ /**
611
+ * Start periodic sync
612
+ */
613
+ startSyncLoop() {
614
+ // Initial sync
615
+ this.sync();
616
+
617
+ // Periodic sync
618
+ this.syncTimer = setInterval(() => {
619
+ this.sync();
620
+ }, this.syncInterval);
621
+ }
622
+
623
+ /**
624
+ * Sync with relay
625
+ */
626
+ sync() {
627
+ if (!this.connected) return;
628
+
629
+ this.send({
630
+ type: 'ledger_sync',
631
+ state: this.ledger.export(),
632
+ });
633
+ }
634
+
635
+ /**
636
+ * Send message
637
+ */
638
+ send(message) {
639
+ if (this.connected && this.ws?.readyState === 1) {
640
+ this.ws.send(JSON.stringify(message));
641
+ return true;
642
+ }
643
+ return false;
644
+ }
645
+
646
+ /**
647
+ * Close connection
648
+ */
649
+ close() {
650
+ if (this.syncTimer) {
651
+ clearInterval(this.syncTimer);
652
+ }
653
+ if (this.ws) {
654
+ this.ws.close();
655
+ }
656
+ }
657
+ }
658
+
659
+ // ============================================
660
+ // EXPORTS
661
+ // ============================================
662
+
663
+ export default Ledger;