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>
259 lines
9.2 KiB
TypeScript
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;
|
|
}
|