import type { OpenOptions, StoreOptions, CollectionApi, KVApi, SyncDBEventName, SyncDBEventHandler, } from './types.js'; import { generateClientId } from './utils.js'; import { Emitter } from './emitter.js'; import { IDBStore } from './idb-store.js'; import { FolderStore } from './folder-store.js'; import { NostrTransport } from './nostr-transport.js'; import { SyncEngine } from './sync-engine.js'; import { KVStore } from './kv-store.js'; import { Collection } from './collection.js'; const META_CLIENT_ID = 'clientId'; const META_DIR_HANDLE = 'dirHandle'; const META_NOSTR_SK = 'nostrSecretKey'; const META_NOSTR_ROOM = 'nostrRoom'; const DEFAULT_RELAYS = [ 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', ]; export class FolderSyncDB { private idb!: IDBStore; private folderStore!: FolderStore; private nostrTransport!: NostrTransport; private syncEngine!: SyncEngine; private emitter!: Emitter; private autoSyncTimer: ReturnType | null = null; private collections = new Map>(); kv!: KVApi; private constructor() {} static async open(options?: OpenOptions): Promise { const db = new FolderSyncDB(); await db.init(options ?? {}); return db; } private async init(opts: OpenOptions): Promise { const dbName = opts.dbName ?? 'FolderSyncDB'; this.emitter = new Emitter(); this.idb = await IDBStore.open(dbName); this.folderStore = new FolderStore(); // ── Nostr transport ─────────────────────────────────────── const relays = opts.relays ?? DEFAULT_RELAYS; this.nostrTransport = new NostrTransport(relays); // Load or generate keypair (persisted in IDB) let skHex = await this.idb.getMeta(META_NOSTR_SK); if (skHex) { this.nostrTransport.setKeypair(hexToBytes(skHex)); } else { const sk = this.nostrTransport.generateKeypair(); await this.idb.setMeta(META_NOSTR_SK, bytesToHex(sk)); } // ── Client ID ───────────────────────────────────────────── let clientId = opts.clientId; if (!clientId) { clientId = await this.idb.getMeta(META_CLIENT_ID); if (!clientId) { clientId = generateClientId(); await this.idb.setMeta(META_CLIENT_ID, clientId); } } else { await this.idb.setMeta(META_CLIENT_ID, clientId); } // ── Sync engine ─────────────────────────────────────────── this.syncEngine = new SyncEngine( this.idb, this.folderStore, this.nostrTransport, this.emitter, clientId, opts.conflictResolver, ); this.kv = new KVStore(this.idb, this.syncEngine); // ── Restore folder handle ───────────────────────────────── await this.tryRestoreHandle(); // ── Restore Nostr room ──────────────────────────────────── const savedRoom = opts.roomKey ?? await this.idb.getMeta(META_NOSTR_ROOM); if (savedRoom) { await this.joinRoom(savedRoom); } // ── Real-time push: new Nostr event → immediate sync ────── this.nostrTransport.onNewEvent(() => { this.sync().catch(() => {}); }); // ── Auto-sync ───────────────────────────────────────────── if (opts.autoSyncIntervalMs && opts.autoSyncIntervalMs > 0) { this.autoSyncTimer = setInterval(() => { this.sync().catch(() => {}); }, opts.autoSyncIntervalMs); } } // ── Folder management ────────────────────────────────────── async selectFolder(): Promise { const handle = await this.folderStore.selectFolder(); await this.idb.setMeta(META_DIR_HANDLE, handle); await this.sync(); } async hasFolderAccess(): Promise { return this.folderStore.hasPermission(); } async requestFolderAccess(): Promise { const granted = await this.folderStore.requestPermission(); if (granted) await this.sync(); return granted; } // ── Nostr room management ────────────────────────────────── /** * Join a Nostr room. The relay stores all events as NIP-78 kind 30078, * so on join we get the full history — no inventory exchange needed. */ async joinRoom(roomKey: string): Promise { await this.nostrTransport.joinRoom(roomKey); await this.idb.setMeta(META_NOSTR_ROOM, roomKey); this.emitter.emit('nostr:connected', { roomKey }); await this.sync(); // Re-publish any local events the relay doesn't have yet this.republishMissing().catch((err) => console.warn('[nostr] republish failed:', err), ); } leaveRoom(): void { this.nostrTransport.leaveRoom(); this.emitter.emit('nostr:disconnected'); } isConnected(): boolean { return this.nostrTransport.isConnected; } get currentRoom(): string | null { return this.nostrTransport.currentRoom; } // ── Sync ─────────────────────────────────────────────────── async sync(): Promise { return this.syncEngine.sync(); } /** * Re-publish all local events that the relay doesn't have. * Reads raw event files from the folder and publishes to Nostr. * Call this manually or it runs automatically on joinRoom. */ async republishMissing(): Promise { if (!this.nostrTransport.isConnected) return; if (!this.folderStore.hasHandle) { console.log('[nostr] no folder connected — cannot re-publish missing events'); return; } const hasAccess = await this.folderStore.hasPermission(); if (!hasAccess) return; const ourApplied = await this.idb.getAppliedSet(); const relayKnown = new Set(await this.nostrTransport.scanEventFilenames()); // Find events we have locally but relay doesn't const missing: string[] = []; for (const filename of ourApplied) { if (!relayKnown.has(filename)) missing.push(filename); } if (missing.length === 0) { console.log('[nostr] relay is up to date with local events'); return; } console.log(`[nostr] re-publishing ${missing.length} events relay is missing...`); let published = 0; for (const filename of missing) { try { const event = await this.folderStore.readEventFile(filename); if (event) { await this.nostrTransport.writeEvent(filename, event); published++; } } catch { // skip unreadable events } } console.log(`[nostr] re-published ${published}/${missing.length} events`); } // ── Collections ──────────────────────────────────────────── collection(options: StoreOptions): CollectionApi { const cached = this.collections.get(options.name); if (cached) return cached as Collection; const col = new Collection(options, this.idb, this.syncEngine); this.collections.set(options.name, col); return col; } // ── Events ───────────────────────────────────────────────── on(event: SyncDBEventName, handler: SyncDBEventHandler): () => void { return this.emitter.on(event, handler); } // ── Lifecycle ────────────────────────────────────────────── async close(): Promise { if (this.autoSyncTimer !== null) { clearInterval(this.autoSyncTimer); this.autoSyncTimer = null; } this.nostrTransport.close(); this.emitter.removeAll(); this.collections.clear(); this.idb.close(); } // ── Internals ────────────────────────────────────────────── private async tryRestoreHandle(): Promise { try { const handle = await this.idb.getMeta(META_DIR_HANDLE); if (!handle || typeof handle.queryPermission !== 'function') return; this.folderStore.setHandle(handle); } catch {} } } // ── Hex helpers ────────────────────────────────────────────── function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); } function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); } return bytes; }