LocalHtmlDataTest/nostr/src/folder-sync-db.ts
Jason Tudisco 7077191c0c NIP-78 encrypted transport, instant UI, re-sync recovery
Nostr transport:
- Switch from kind 1 to NIP-78 kind 30078 (application-specific data)
- Relays store events persistently — no inventory protocol needed
- AES-256-GCM encryption via Web Crypto API (room key = shared secret)
- PBKDF2 key derivation (100k iterations) from room key
- Chunking for large events (images >48KB) with per-chunk encryption
- Auto re-publish missing local events on room join
- Manual "Re-sync" button for recovery of failed publishes

Performance (all variants):
- Emit 'change' immediately after local store write (IDB/NeDB/SQLite)
- Folder and Nostr writes run fire-and-forget in background
- Fast event hashing: fingerprint metadata only, skip full payload SHA-256
- Saving spinner in paste UI while write completes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:11:29 -06:00

259 lines
9.2 KiB
TypeScript

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<typeof setInterval> | null = null;
private collections = new Map<string, Collection<any>>();
kv!: KVApi;
private constructor() {}
static async open(options?: OpenOptions): Promise<FolderSyncDB> {
const db = new FolderSyncDB();
await db.init(options ?? {});
return db;
}
private async init(opts: OpenOptions): Promise<void> {
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<string>(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<string>(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<string>(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<void> {
const handle = await this.folderStore.selectFolder();
await this.idb.setMeta(META_DIR_HANDLE, handle);
await this.sync();
}
async hasFolderAccess(): Promise<boolean> {
return this.folderStore.hasPermission();
}
async requestFolderAccess(): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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<T extends { id: string }>(options: StoreOptions<T>): CollectionApi<T> {
const cached = this.collections.get(options.name);
if (cached) return cached as Collection<T>;
const col = new Collection<T>(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<void> {
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<void> {
try {
const handle = await this.idb.getMeta<FileSystemDirectoryHandle>(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;
}