@gns-foundation/hive-worker 0.1.10 → 0.5.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/src/mobydb.ts ADDED
@@ -0,0 +1,558 @@
1
+ // ============================================================
2
+ // Phase 4: MobyDB Embedded Storage
3
+ // File: packages/hive-worker/src/mobydb.ts
4
+ //
5
+ // Local storage engine for the Hive worker.
6
+ // Phase 4a: SQLite (better-sqlite3)
7
+ // Phase 4b: RocksDB (Rust sidecar)
8
+ //
9
+ // Every compute job (inference, tile, etc.) writes a record
10
+ // locally. Records are sealed into epochs with Merkle roots.
11
+ // ============================================================
12
+
13
+ import Database from 'better-sqlite3';
14
+ import * as crypto from 'crypto';
15
+ import * as path from 'path';
16
+ import * as fs from 'fs';
17
+
18
+ // ─── Config ─────────────────────────────────
19
+ const HIVE_DIR = process.env.HIVE_DIR || path.join(process.env.HOME || '/tmp', '.hive');
20
+ const DB_PATH = path.join(HIVE_DIR, 'mobydb', 'records.db');
21
+ const EPOCH_DURATION_MS = 3600_000; // 1 hour
22
+ const EPOCH_ZERO = new Date('2026-01-01T00:00:00Z').getTime();
23
+
24
+ // ─── Types ──────────────────────────────────
25
+
26
+ export type CollectionType =
27
+ | 'Breadcrumb'
28
+ | 'Telemetry'
29
+ | 'Event'
30
+ | 'Territory'
31
+ | 'Relationship'
32
+ | 'Aggregate'
33
+ | 'Imagery'
34
+ | 'Inference'
35
+ | 'Tile'
36
+ | 'ComputeJob';
37
+
38
+ export interface MobyDBAddress {
39
+ h3_cell: string;
40
+ epoch: number;
41
+ public_key: string;
42
+ }
43
+
44
+ export interface MobyDBRecord {
45
+ id: string;
46
+ address: MobyDBAddress;
47
+ collection_type: CollectionType;
48
+ payload_type: string;
49
+ data: Record<string, any>;
50
+ signature?: string;
51
+ trust_tier: string;
52
+ written_at_ms: number;
53
+ record_hash: string;
54
+ sealed: boolean;
55
+ epoch_merkle_root?: string;
56
+ }
57
+
58
+ export interface EpochSeal {
59
+ epoch: number;
60
+ h3_cell: string;
61
+ worker_pk: string;
62
+ record_count: number;
63
+ merkle_root: string;
64
+ prev_epoch_hash: string;
65
+ sealed_at_ms: number;
66
+ signature?: string;
67
+ }
68
+
69
+ // ─── Hash Helpers ───────────────────────────
70
+
71
+ function sha256(input: string): string {
72
+ return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
73
+ }
74
+
75
+ function computeRecordHash(record: Omit<MobyDBRecord, 'record_hash' | 'sealed' | 'epoch_merkle_root'>): string {
76
+ const canonical = JSON.stringify({
77
+ address: record.address,
78
+ collection_type: record.collection_type,
79
+ payload_type: record.payload_type,
80
+ data: record.data,
81
+ written_at_ms: record.written_at_ms,
82
+ });
83
+ return sha256(canonical);
84
+ }
85
+
86
+ function computeMerkleRoot(hashes: string[]): string {
87
+ if (hashes.length === 0) return sha256('empty');
88
+ if (hashes.length === 1) return hashes[0];
89
+
90
+ const next: string[] = [];
91
+ for (let i = 0; i < hashes.length; i += 2) {
92
+ if (i + 1 < hashes.length) {
93
+ next.push(sha256(hashes[i] + hashes[i + 1]));
94
+ } else {
95
+ next.push(hashes[i]); // odd leaf promoted
96
+ }
97
+ }
98
+ return computeMerkleRoot(next);
99
+ }
100
+
101
+ // ─── Current Epoch ──────────────────────────
102
+
103
+ export function currentEpoch(): number {
104
+ return Math.floor((Date.now() - EPOCH_ZERO) / EPOCH_DURATION_MS);
105
+ }
106
+
107
+ // ─── MobyDB Class ───────────────────────────
108
+
109
+ export class MobyDB {
110
+ private db: Database.Database;
111
+ private workerPk: string;
112
+ private h3Cell: string;
113
+
114
+ constructor(workerPk: string, h3Cell: string) {
115
+ this.workerPk = workerPk;
116
+ this.h3Cell = h3Cell;
117
+
118
+ // Ensure directory exists
119
+ const dir = path.dirname(DB_PATH);
120
+ if (!fs.existsSync(dir)) {
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ }
123
+
124
+ // Open SQLite
125
+ this.db = new Database(DB_PATH);
126
+ this.db.pragma('journal_mode = WAL');
127
+ this.db.pragma('synchronous = NORMAL');
128
+
129
+ this.initSchema();
130
+ console.log(`🐋 MobyDB initialized: ${DB_PATH}`);
131
+ console.log(` Worker: ${workerPk.substring(0, 16)}...`);
132
+ console.log(` Cell: ${h3Cell}`);
133
+ }
134
+
135
+ // ─── Schema ─────────────────────────────
136
+
137
+ private initSchema(): void {
138
+ this.db.exec(`
139
+ CREATE TABLE IF NOT EXISTS records (
140
+ id TEXT PRIMARY KEY,
141
+ h3_cell TEXT NOT NULL,
142
+ epoch INTEGER NOT NULL,
143
+ public_key TEXT NOT NULL,
144
+ collection_type TEXT NOT NULL,
145
+ payload_type TEXT NOT NULL,
146
+ data TEXT NOT NULL,
147
+ signature TEXT,
148
+ trust_tier TEXT DEFAULT 'Observed',
149
+ written_at_ms INTEGER NOT NULL,
150
+ record_hash TEXT NOT NULL,
151
+ sealed INTEGER DEFAULT 0,
152
+ epoch_merkle_root TEXT
153
+ );
154
+
155
+ CREATE INDEX IF NOT EXISTS idx_records_address
156
+ ON records (h3_cell, epoch, public_key);
157
+ CREATE INDEX IF NOT EXISTS idx_records_collection
158
+ ON records (collection_type, epoch DESC);
159
+ CREATE INDEX IF NOT EXISTS idx_records_epoch
160
+ ON records (epoch, sealed);
161
+
162
+ CREATE TABLE IF NOT EXISTS epoch_seals (
163
+ epoch INTEGER NOT NULL,
164
+ h3_cell TEXT NOT NULL,
165
+ worker_pk TEXT NOT NULL,
166
+ record_count INTEGER NOT NULL,
167
+ merkle_root TEXT NOT NULL,
168
+ prev_epoch_hash TEXT NOT NULL,
169
+ sealed_at_ms INTEGER NOT NULL,
170
+ signature TEXT,
171
+ PRIMARY KEY (epoch, h3_cell, worker_pk)
172
+ );
173
+
174
+ CREATE TABLE IF NOT EXISTS sync_queue (
175
+ id TEXT PRIMARY KEY,
176
+ record_id TEXT NOT NULL,
177
+ target TEXT NOT NULL DEFAULT 'central',
178
+ status TEXT DEFAULT 'pending',
179
+ attempts INTEGER DEFAULT 0,
180
+ created_at INTEGER NOT NULL,
181
+ synced_at INTEGER
182
+ );
183
+ `);
184
+ }
185
+
186
+ // ─── Write ──────────────────────────────
187
+
188
+ /**
189
+ * Write a record to local MobyDB.
190
+ * Returns the record with hash computed.
191
+ * Typical latency: <0.1ms (SQLite WAL mode).
192
+ */
193
+ write(
194
+ collectionType: CollectionType,
195
+ payloadType: string,
196
+ data: Record<string, any>,
197
+ options?: {
198
+ h3Cell?: string;
199
+ epoch?: number;
200
+ publicKey?: string;
201
+ trustTier?: string;
202
+ signature?: string;
203
+ },
204
+ ): MobyDBRecord {
205
+ const epoch = options?.epoch ?? currentEpoch();
206
+ const id = crypto.randomUUID();
207
+ const now = Date.now();
208
+
209
+ const record: Omit<MobyDBRecord, 'record_hash' | 'sealed' | 'epoch_merkle_root'> = {
210
+ id,
211
+ address: {
212
+ h3_cell: options?.h3Cell ?? this.h3Cell,
213
+ epoch,
214
+ public_key: options?.publicKey ?? this.workerPk,
215
+ },
216
+ collection_type: collectionType,
217
+ payload_type: payloadType,
218
+ data,
219
+ signature: options?.signature,
220
+ trust_tier: options?.trustTier ?? 'Observed',
221
+ written_at_ms: now,
222
+ };
223
+
224
+ const recordHash = computeRecordHash(record);
225
+
226
+ const stmt = this.db.prepare(`
227
+ INSERT INTO records (id, h3_cell, epoch, public_key, collection_type, payload_type, data, signature, trust_tier, written_at_ms, record_hash)
228
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
229
+ `);
230
+
231
+ stmt.run(
232
+ id,
233
+ record.address.h3_cell,
234
+ epoch,
235
+ record.address.public_key,
236
+ collectionType,
237
+ payloadType,
238
+ JSON.stringify(data),
239
+ record.signature || null,
240
+ record.trust_tier,
241
+ now,
242
+ recordHash,
243
+ );
244
+
245
+ // Queue for central sync
246
+ this.queueSync(id);
247
+
248
+ return { ...record, record_hash: recordHash, sealed: false };
249
+ }
250
+
251
+ // ─── Convenience Writers ────────────────
252
+
253
+ /** Write an inference record after AI compute */
254
+ writeInference(data: {
255
+ model: string;
256
+ provider: string;
257
+ tokens_in: number;
258
+ tokens_out: number;
259
+ latency_ms: number;
260
+ prompt_hash: string;
261
+ response_hash: string;
262
+ requester_pk: string;
263
+ job_id?: string;
264
+ }): MobyDBRecord {
265
+ return this.write('Inference', 'hive/inference', data);
266
+ }
267
+
268
+ /** Write a tile record after rendering/proxying */
269
+ writeTile(data: {
270
+ format: string;
271
+ zoom: number;
272
+ style: string;
273
+ tile_hash: string;
274
+ tile_bytes: number;
275
+ render_ms: number;
276
+ source: string;
277
+ cached: boolean;
278
+ parent_job?: string;
279
+ }): MobyDBRecord {
280
+ return this.write('Tile', data.format === 'mvt' ? 'tile/vector' : 'tile/raster', data);
281
+ }
282
+
283
+ /** Write a compound job record linking multiple steps */
284
+ writeComputeJob(data: {
285
+ job_id: string;
286
+ requester_pk: string;
287
+ steps: Array<{ id: string; type: string; record_hash: string }>;
288
+ total_gns: number;
289
+ stellar_tx?: string;
290
+ latency_ms: number;
291
+ }): MobyDBRecord {
292
+ return this.write('ComputeJob', 'compute/job', data);
293
+ }
294
+
295
+ // ─── Query ──────────────────────────────
296
+
297
+ /** Query records near an H3 cell in an epoch range */
298
+ query(options: {
299
+ h3Cell?: string;
300
+ epochStart?: number;
301
+ epochEnd?: number;
302
+ collectionType?: CollectionType;
303
+ limit?: number;
304
+ }): MobyDBRecord[] {
305
+ let sql = 'SELECT * FROM records WHERE 1=1';
306
+ const params: any[] = [];
307
+
308
+ if (options.h3Cell) {
309
+ sql += ' AND h3_cell = ?';
310
+ params.push(options.h3Cell);
311
+ }
312
+ if (options.epochStart !== undefined) {
313
+ sql += ' AND epoch >= ?';
314
+ params.push(options.epochStart);
315
+ }
316
+ if (options.epochEnd !== undefined) {
317
+ sql += ' AND epoch <= ?';
318
+ params.push(options.epochEnd);
319
+ }
320
+ if (options.collectionType) {
321
+ sql += ' AND collection_type = ?';
322
+ params.push(options.collectionType);
323
+ }
324
+ sql += ' ORDER BY written_at_ms DESC LIMIT ?';
325
+ params.push(options.limit || 100);
326
+
327
+ const rows = this.db.prepare(sql).all(...params) as any[];
328
+ return rows.map(r => ({
329
+ ...r,
330
+ address: { h3_cell: r.h3_cell, epoch: r.epoch, public_key: r.public_key },
331
+ data: JSON.parse(r.data),
332
+ sealed: !!r.sealed,
333
+ }));
334
+ }
335
+
336
+ /** Get a single record by ID */
337
+ get(id: string): MobyDBRecord | null {
338
+ const r = this.db.prepare('SELECT * FROM records WHERE id = ?').get(id) as any;
339
+ if (!r) return null;
340
+ return {
341
+ ...r,
342
+ address: { h3_cell: r.h3_cell, epoch: r.epoch, public_key: r.public_key },
343
+ data: JSON.parse(r.data),
344
+ sealed: !!r.sealed,
345
+ };
346
+ }
347
+
348
+ /** Count records by collection type */
349
+ stats(): Record<string, number> {
350
+ const rows = this.db.prepare(
351
+ 'SELECT collection_type, count(*) as cnt FROM records GROUP BY collection_type'
352
+ ).all() as any[];
353
+ const result: Record<string, number> = {};
354
+ rows.forEach(r => result[r.collection_type] = r.cnt);
355
+ return result;
356
+ }
357
+
358
+ // ─── Epoch Sealing ──────────────────────
359
+
360
+ /**
361
+ * Seal an epoch: compute Merkle root of all records,
362
+ * mark them as sealed, store the epoch seal.
363
+ */
364
+ sealEpoch(epoch: number): EpochSeal | null {
365
+ // Get all unsealed records for this epoch
366
+ const records = this.db.prepare(
367
+ 'SELECT record_hash FROM records WHERE epoch = ? AND sealed = 0 ORDER BY written_at_ms ASC'
368
+ ).all(epoch) as any[];
369
+
370
+ if (records.length === 0) return null;
371
+
372
+ const hashes = records.map(r => r.record_hash);
373
+ const merkleRoot = computeMerkleRoot(hashes);
374
+
375
+ // Get previous epoch seal for chaining
376
+ const prevSeal = this.db.prepare(
377
+ 'SELECT merkle_root FROM epoch_seals WHERE epoch = ? AND h3_cell = ? AND worker_pk = ?'
378
+ ).get(epoch - 1, this.h3Cell, this.workerPk) as any;
379
+ const prevEpochHash = prevSeal?.merkle_root || sha256('genesis');
380
+
381
+ const seal: EpochSeal = {
382
+ epoch,
383
+ h3_cell: this.h3Cell,
384
+ worker_pk: this.workerPk,
385
+ record_count: records.length,
386
+ merkle_root: merkleRoot,
387
+ prev_epoch_hash: prevEpochHash,
388
+ sealed_at_ms: Date.now(),
389
+ };
390
+
391
+ // Transaction: mark records sealed + insert seal
392
+ const transaction = this.db.transaction(() => {
393
+ this.db.prepare(
394
+ 'UPDATE records SET sealed = 1, epoch_merkle_root = ? WHERE epoch = ? AND sealed = 0'
395
+ ).run(merkleRoot, epoch);
396
+
397
+ this.db.prepare(`
398
+ INSERT OR REPLACE INTO epoch_seals (epoch, h3_cell, worker_pk, record_count, merkle_root, prev_epoch_hash, sealed_at_ms, signature)
399
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
400
+ `).run(seal.epoch, seal.h3_cell, seal.worker_pk, seal.record_count, seal.merkle_root, seal.prev_epoch_hash, seal.sealed_at_ms, seal.signature || null);
401
+ });
402
+ transaction();
403
+
404
+ console.log(`🔒 Epoch ${epoch} sealed: ${records.length} records, root=${merkleRoot.substring(0, 16)}...`);
405
+ return seal;
406
+ }
407
+
408
+ /**
409
+ * Auto-seal previous epochs that have unsealed records.
410
+ * Call this periodically (e.g. every minute).
411
+ */
412
+ autoSeal(): EpochSeal[] {
413
+ const current = currentEpoch();
414
+ const unsealed = this.db.prepare(
415
+ 'SELECT DISTINCT epoch FROM records WHERE sealed = 0 AND epoch < ? ORDER BY epoch ASC'
416
+ ).all(current) as any[];
417
+
418
+ const seals: EpochSeal[] = [];
419
+ for (const row of unsealed) {
420
+ const seal = this.sealEpoch(row.epoch);
421
+ if (seal) seals.push(seal);
422
+ }
423
+ return seals;
424
+ }
425
+
426
+ // ─── Proof Generation ───────────────────
427
+
428
+ /**
429
+ * Generate a Merkle proof for a specific record.
430
+ * Proves this record was included in the epoch's Merkle tree.
431
+ */
432
+ generateProof(recordId: string): {
433
+ record_hash: string;
434
+ epoch: number;
435
+ merkle_root: string;
436
+ proof_path: string[];
437
+ verify_url: string;
438
+ } | null {
439
+ const record = this.get(recordId);
440
+ if (!record || !record.sealed) return null;
441
+
442
+ // Get all record hashes in the same epoch (in order)
443
+ const rows = this.db.prepare(
444
+ 'SELECT record_hash FROM records WHERE epoch = ? AND h3_cell = ? ORDER BY written_at_ms ASC'
445
+ ).all(record.address.epoch, record.address.h3_cell) as any[];
446
+
447
+ const hashes = rows.map(r => r.record_hash);
448
+ const proofPath = generateMerkleProofPath(hashes, record.record_hash);
449
+
450
+ return {
451
+ record_hash: record.record_hash,
452
+ epoch: record.address.epoch,
453
+ merkle_root: record.epoch_merkle_root || '',
454
+ proof_path: proofPath,
455
+ verify_url: `https://mobydb.com/proof/${record.address.h3_cell}/${record.address.epoch}/${record.address.public_key}`,
456
+ };
457
+ }
458
+
459
+ // ─── Sync Queue ─────────────────────────
460
+
461
+ private queueSync(recordId: string): void {
462
+ this.db.prepare(`
463
+ INSERT OR IGNORE INTO sync_queue (id, record_id, target, created_at)
464
+ VALUES (?, ?, 'central', ?)
465
+ `).run(crypto.randomUUID(), recordId, Date.now());
466
+ }
467
+
468
+ /** Get records pending sync to central server */
469
+ getPendingSync(limit = 50): string[] {
470
+ const rows = this.db.prepare(
471
+ "SELECT record_id FROM sync_queue WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?"
472
+ ).all(limit) as any[];
473
+ return rows.map(r => r.record_id);
474
+ }
475
+
476
+ /** Mark records as synced */
477
+ markSynced(recordIds: string[]): void {
478
+ const stmt = this.db.prepare(
479
+ "UPDATE sync_queue SET status = 'synced', synced_at = ? WHERE record_id = ?"
480
+ );
481
+ const now = Date.now();
482
+ const transaction = this.db.transaction(() => {
483
+ for (const id of recordIds) {
484
+ stmt.run(now, id);
485
+ }
486
+ });
487
+ transaction();
488
+ }
489
+
490
+ // ─── Lifecycle ──────────────────────────
491
+
492
+ /** Close the database */
493
+ close(): void {
494
+ this.db.close();
495
+ console.log('🐋 MobyDB closed');
496
+ }
497
+
498
+ /** Get the database file path */
499
+ get path(): string {
500
+ return DB_PATH;
501
+ }
502
+
503
+ /** Get total record count */
504
+ get recordCount(): number {
505
+ const r = this.db.prepare('SELECT count(*) as cnt FROM records').get() as any;
506
+ return r.cnt;
507
+ }
508
+ }
509
+
510
+ // ─── Merkle Proof Path ──────────────────────
511
+
512
+ function generateMerkleProofPath(hashes: string[], targetHash: string): string[] {
513
+ if (hashes.length <= 1) return [];
514
+
515
+ const path: string[] = [];
516
+ let level = [...hashes];
517
+ let targetIdx = level.indexOf(targetHash);
518
+
519
+ while (level.length > 1) {
520
+ const nextLevel: string[] = [];
521
+ for (let i = 0; i < level.length; i += 2) {
522
+ if (i + 1 < level.length) {
523
+ // If target is at i, sibling is i+1 (right)
524
+ // If target is at i+1, sibling is i (left)
525
+ if (i === targetIdx) {
526
+ path.push('R:' + level[i + 1]);
527
+ targetIdx = Math.floor(i / 2);
528
+ } else if (i + 1 === targetIdx) {
529
+ path.push('L:' + level[i]);
530
+ targetIdx = Math.floor(i / 2);
531
+ }
532
+ nextLevel.push(sha256(level[i] + level[i + 1]));
533
+ } else {
534
+ if (i === targetIdx) {
535
+ targetIdx = Math.floor(i / 2);
536
+ }
537
+ nextLevel.push(level[i]);
538
+ }
539
+ }
540
+ level = nextLevel;
541
+ }
542
+
543
+ return path;
544
+ }
545
+
546
+ // ─── Singleton ──────────────────────────────
547
+
548
+ let _instance: MobyDB | null = null;
549
+
550
+ export function initMobyDB(workerPk: string, h3Cell: string): MobyDB {
551
+ if (_instance) return _instance;
552
+ _instance = new MobyDB(workerPk, h3Cell);
553
+ return _instance;
554
+ }
555
+
556
+ export function getMobyDB(): MobyDB | null {
557
+ return _instance;
558
+ }