Add Nostr dual-sync variant (folder + relay)
Fifth library variant that keeps folder sync for offline/local use AND adds Nostr relay sync for cross-device reach via WebSocket. Both transports run simultaneously - writes go to folder AND Nostr, sync imports from both and bridges events between them. Works from file:// since Nostr uses WebSocket (not fetch/WebRTC). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6ebe02ad56
commit
b5528b0ecf
13
nostr/package.json
Normal file
13
nostr/package.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@foldersync/nostr",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Local-first browser KV/document store with dual folder + Nostr relay sync",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"nostr-tools": "^2.10.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
137
nostr/src/collection.ts
Normal file
137
nostr/src/collection.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import type { CollectionApi, IndexDefinition, StoreOptions } from './types.js';
|
||||||
|
import type { IDBStore } from './idb-store.js';
|
||||||
|
import type { SyncEngine } from './sync-engine.js';
|
||||||
|
|
||||||
|
export class Collection<T extends { id: string }> implements CollectionApi<T> {
|
||||||
|
private readonly storeName: string;
|
||||||
|
private readonly indexes: IndexDefinition<T>[];
|
||||||
|
private indexesBuilt = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
options: StoreOptions<T>,
|
||||||
|
private idb: IDBStore,
|
||||||
|
private sync: SyncEngine,
|
||||||
|
) {
|
||||||
|
this.storeName = options.name;
|
||||||
|
this.indexes = options.indexes ?? [];
|
||||||
|
this.sync.registerIndexes(this.storeName, this.indexes as IndexDefinition[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazily rebuild indexes on first access. */
|
||||||
|
private async ensureIndexes(): Promise<void> {
|
||||||
|
if (this.indexesBuilt || this.indexes.length === 0) return;
|
||||||
|
await this.idb.rebuildIndexes(
|
||||||
|
this.storeName,
|
||||||
|
this.indexes as IndexDefinition[],
|
||||||
|
);
|
||||||
|
this.indexesBuilt = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<T | undefined> {
|
||||||
|
const rec = await this.idb.getDoc(this.storeName, id);
|
||||||
|
if (!rec) return undefined;
|
||||||
|
return rec.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(doc: T): Promise<void> {
|
||||||
|
await this.ensureIndexes();
|
||||||
|
const existing = await this.idb.getRawDoc(this.storeName, doc.id);
|
||||||
|
const rev = (existing?.rev ?? 0) + 1;
|
||||||
|
const ts = Date.now();
|
||||||
|
// SyncEngine.persistEvent handles the IDB write + folder write
|
||||||
|
await this.sync.writeDoc(this.storeName, doc.id, doc, ts, rev);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.ensureIndexes();
|
||||||
|
const existing = await this.idb.getRawDoc(this.storeName, id);
|
||||||
|
if (!existing || existing.deleted) return;
|
||||||
|
|
||||||
|
const rev = existing.rev + 1;
|
||||||
|
const ts = Date.now();
|
||||||
|
await this.sync.deleteDocEvent(this.storeName, id, ts, rev);
|
||||||
|
}
|
||||||
|
|
||||||
|
async all(): Promise<T[]> {
|
||||||
|
const docs = await this.idb.getAllDocs(this.storeName);
|
||||||
|
return docs.map((d) => d.data as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIndex(indexName: string, value: IDBValidKey): Promise<T[]> {
|
||||||
|
await this.ensureIndexes();
|
||||||
|
this.assertIndex(indexName);
|
||||||
|
const ids = await this.idb.findByIndex(this.storeName, indexName, value);
|
||||||
|
return this.fetchByIds(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryByIndex(
|
||||||
|
indexName: string,
|
||||||
|
range: {
|
||||||
|
gt?: IDBValidKey;
|
||||||
|
gte?: IDBValidKey;
|
||||||
|
lt?: IDBValidKey;
|
||||||
|
lte?: IDBValidKey;
|
||||||
|
},
|
||||||
|
): Promise<T[]> {
|
||||||
|
await this.ensureIndexes();
|
||||||
|
this.assertIndex(indexName);
|
||||||
|
|
||||||
|
const lower = range.gte ?? range.gt;
|
||||||
|
const upper = range.lte ?? range.lt;
|
||||||
|
const lowerOpen = range.gte === undefined && range.gt !== undefined;
|
||||||
|
const upperOpen = range.lte === undefined && range.lt !== undefined;
|
||||||
|
|
||||||
|
let idbRange: IDBKeyRange;
|
||||||
|
if (lower !== undefined && upper !== undefined) {
|
||||||
|
idbRange = IDBKeyRange.bound(
|
||||||
|
[this.storeName, indexName, lower],
|
||||||
|
[this.storeName, indexName, upper],
|
||||||
|
lowerOpen,
|
||||||
|
upperOpen,
|
||||||
|
);
|
||||||
|
} else if (lower !== undefined) {
|
||||||
|
idbRange = IDBKeyRange.lowerBound(
|
||||||
|
[this.storeName, indexName, lower],
|
||||||
|
lowerOpen,
|
||||||
|
);
|
||||||
|
} else if (upper !== undefined) {
|
||||||
|
idbRange = IDBKeyRange.upperBound(
|
||||||
|
[this.storeName, indexName, upper],
|
||||||
|
upperOpen,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No bounds: return all entries for this index
|
||||||
|
idbRange = IDBKeyRange.bound(
|
||||||
|
[this.storeName, indexName, -Infinity],
|
||||||
|
[this.storeName, indexName, []],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = await this.idb.queryByIndex(
|
||||||
|
this.storeName,
|
||||||
|
indexName,
|
||||||
|
idbRange,
|
||||||
|
);
|
||||||
|
return this.fetchByIds(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private assertIndex(name: string): void {
|
||||||
|
if (!this.indexes.some((i) => i.name === name)) {
|
||||||
|
throw new Error(
|
||||||
|
`Index "${name}" is not defined on collection "${this.storeName}". ` +
|
||||||
|
`Defined indexes: ${this.indexes.map((i) => i.name).join(', ') || '(none)'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchByIds(ids: string[]): Promise<T[]> {
|
||||||
|
const results: T[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const doc = await this.idb.getDoc(this.storeName, id);
|
||||||
|
if (doc) results.push(doc.data as T);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
nostr/src/emitter.ts
Normal file
33
nostr/src/emitter.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { SyncDBEventName, SyncDBEventHandler } from './types.js';
|
||||||
|
|
||||||
|
export class Emitter {
|
||||||
|
private listeners = new Map<SyncDBEventName, Set<SyncDBEventHandler>>();
|
||||||
|
|
||||||
|
on(event: SyncDBEventName, handler: SyncDBEventHandler): () => void {
|
||||||
|
let set = this.listeners.get(event);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.listeners.set(event, set);
|
||||||
|
}
|
||||||
|
set.add(handler);
|
||||||
|
return () => {
|
||||||
|
set!.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: SyncDBEventName, ...args: unknown[]): void {
|
||||||
|
const set = this.listeners.get(event);
|
||||||
|
if (!set) return;
|
||||||
|
for (const fn of set) {
|
||||||
|
try {
|
||||||
|
fn(...args);
|
||||||
|
} catch {
|
||||||
|
// listener errors must not break the library
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAll(): void {
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
122
nostr/src/folder-store.ts
Normal file
122
nostr/src/folder-store.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import type { SyncEvent } from './types.js';
|
||||||
|
import { parseEventFilename } from './utils.js';
|
||||||
|
|
||||||
|
const EVENTS_DIR = 'events';
|
||||||
|
|
||||||
|
export class FolderStore {
|
||||||
|
private dirHandle: FileSystemDirectoryHandle | null = null;
|
||||||
|
|
||||||
|
// ── Handle management ──────────────────────────────────────
|
||||||
|
|
||||||
|
get hasHandle(): boolean {
|
||||||
|
return this.dirHandle !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHandle(): FileSystemDirectoryHandle | null {
|
||||||
|
return this.dirHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHandle(handle: FileSystemDirectoryHandle): void {
|
||||||
|
this.dirHandle = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectFolder(): Promise<FileSystemDirectoryHandle> {
|
||||||
|
if (typeof window.showDirectoryPicker !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
'File System Access API is not supported in this browser. ' +
|
||||||
|
'Use a Chromium-based browser (Chrome, Edge, Brave, etc.).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.dirHandle = await window.showDirectoryPicker({
|
||||||
|
mode: 'readwrite',
|
||||||
|
});
|
||||||
|
// Ensure events directory exists
|
||||||
|
await this.dirHandle.getDirectoryHandle(EVENTS_DIR, { create: true });
|
||||||
|
return this.dirHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permission checks ─────────────────────────────────────
|
||||||
|
|
||||||
|
async hasPermission(): Promise<boolean> {
|
||||||
|
if (!this.dirHandle) return false;
|
||||||
|
try {
|
||||||
|
const perm = await this.dirHandle.queryPermission({ mode: 'readwrite' });
|
||||||
|
return perm === 'granted';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestPermission(): Promise<boolean> {
|
||||||
|
if (!this.dirHandle) return false;
|
||||||
|
try {
|
||||||
|
const perm = await this.dirHandle.requestPermission({
|
||||||
|
mode: 'readwrite',
|
||||||
|
});
|
||||||
|
return perm === 'granted';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write event file ──────────────────────────────────────
|
||||||
|
|
||||||
|
async writeEvent(filename: string, event: SyncEvent): Promise<void> {
|
||||||
|
const eventsDir = await this.getEventsDir();
|
||||||
|
const fileHandle = await eventsDir.getFileHandle(filename, {
|
||||||
|
create: true,
|
||||||
|
});
|
||||||
|
const writable = await fileHandle.createWritable();
|
||||||
|
try {
|
||||||
|
await writable.write(JSON.stringify(event, null, 2));
|
||||||
|
} finally {
|
||||||
|
await writable.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scan events ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async scanEventFilenames(): Promise<string[]> {
|
||||||
|
const eventsDir = await this.getEventsDir();
|
||||||
|
const names: string[] = [];
|
||||||
|
|
||||||
|
for await (const [name, handle] of eventsDir.entries()) {
|
||||||
|
if (handle.kind === 'file' && name.endsWith('.json')) {
|
||||||
|
if (parseEventFilename(name)) {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by (timestamp, full filename) for deterministic ordering
|
||||||
|
names.sort((a, b) => {
|
||||||
|
const pa = parseEventFilename(a)!;
|
||||||
|
const pb = parseEventFilename(b)!;
|
||||||
|
if (pa.ts !== pb.ts) return pa.ts - pb.ts;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readEventFile(filename: string): Promise<SyncEvent | null> {
|
||||||
|
const eventsDir = await this.getEventsDir();
|
||||||
|
try {
|
||||||
|
const fh = await eventsDir.getFileHandle(filename);
|
||||||
|
const file = await fh.getFile();
|
||||||
|
const text = await file.text();
|
||||||
|
return JSON.parse(text) as SyncEvent;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async getEventsDir(): Promise<FileSystemDirectoryHandle> {
|
||||||
|
if (!this.dirHandle) {
|
||||||
|
throw new Error('No folder selected. Call selectFolder() first.');
|
||||||
|
}
|
||||||
|
return this.dirHandle.getDirectoryHandle(EVENTS_DIR, { create: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
210
nostr/src/folder-sync-db.ts
Normal file
210
nostr/src/folder-sync-db.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
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.nostr.band',
|
||||||
|
];
|
||||||
|
|
||||||
|
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>>();
|
||||||
|
|
||||||
|
/** Public KV API — available immediately after open(). */
|
||||||
|
kv!: KVApi;
|
||||||
|
|
||||||
|
// ── Construction (use static open()) ───────────────────────
|
||||||
|
|
||||||
|
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 setup ────────────────────────────────
|
||||||
|
const relays = opts.relays ?? DEFAULT_RELAYS;
|
||||||
|
this.nostrTransport = new NostrTransport(relays);
|
||||||
|
|
||||||
|
// Load or generate Nostr keypair
|
||||||
|
let skHex = await this.idb.getMeta<string>(META_NOSTR_SK);
|
||||||
|
if (skHex) {
|
||||||
|
const sk = hexToBytes(skHex);
|
||||||
|
this.nostrTransport.setKeypair(sk);
|
||||||
|
} 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 (dual transport) ─────────────────────────
|
||||||
|
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 from Nostr triggers 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 (same as indexeddb variant) ──────────
|
||||||
|
|
||||||
|
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 (NEW) ────────────────────────────
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (folder + Nostr) ──────────────────────────────────
|
||||||
|
|
||||||
|
async sync(): Promise<void> {
|
||||||
|
return this.syncEngine.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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) return;
|
||||||
|
if (typeof handle.queryPermission !== 'function') return;
|
||||||
|
this.folderStore.setHandle(handle);
|
||||||
|
} catch {
|
||||||
|
// Handle was not restorable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
88
nostr/src/fs-access.d.ts
vendored
Normal file
88
nostr/src/fs-access.d.ts
vendored
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Type declarations for the File System Access API.
|
||||||
|
* These APIs are available in Chromium-based browsers only.
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FileSystemHandlePermissionDescriptor {
|
||||||
|
mode?: 'read' | 'readwrite';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemDirectoryHandle {
|
||||||
|
readonly kind: 'directory';
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
getDirectoryHandle(
|
||||||
|
name: string,
|
||||||
|
options?: { create?: boolean },
|
||||||
|
): Promise<FileSystemDirectoryHandle>;
|
||||||
|
|
||||||
|
getFileHandle(
|
||||||
|
name: string,
|
||||||
|
options?: { create?: boolean },
|
||||||
|
): Promise<FileSystemFileHandle>;
|
||||||
|
|
||||||
|
entries(): AsyncIterableIterator<
|
||||||
|
[string, FileSystemDirectoryHandle | FileSystemFileHandle]
|
||||||
|
>;
|
||||||
|
|
||||||
|
values(): AsyncIterableIterator<
|
||||||
|
FileSystemDirectoryHandle | FileSystemFileHandle
|
||||||
|
>;
|
||||||
|
|
||||||
|
keys(): AsyncIterableIterator<string>;
|
||||||
|
|
||||||
|
queryPermission(
|
||||||
|
descriptor?: FileSystemHandlePermissionDescriptor,
|
||||||
|
): Promise<PermissionState>;
|
||||||
|
|
||||||
|
requestPermission(
|
||||||
|
descriptor?: FileSystemHandlePermissionDescriptor,
|
||||||
|
): Promise<PermissionState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemFileHandle {
|
||||||
|
readonly kind: 'file';
|
||||||
|
readonly name: string;
|
||||||
|
getFile(): Promise<File>;
|
||||||
|
createWritable(
|
||||||
|
options?: FileSystemCreateWritableOptions,
|
||||||
|
): Promise<FileSystemWritableFileStream>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemCreateWritableOptions {
|
||||||
|
keepExistingData?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemWritableFileStream extends WritableStream {
|
||||||
|
write(data: BufferSource | Blob | string | WriteParams): Promise<void>;
|
||||||
|
seek(position: number): Promise<void>;
|
||||||
|
truncate(size: number): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WriteParams {
|
||||||
|
type: 'write' | 'seek' | 'truncate';
|
||||||
|
data?: BufferSource | Blob | string;
|
||||||
|
position?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShowDirectoryPickerOptions {
|
||||||
|
id?: string;
|
||||||
|
mode?: 'read' | 'readwrite';
|
||||||
|
startIn?:
|
||||||
|
| FileSystemDirectoryHandle
|
||||||
|
| 'desktop'
|
||||||
|
| 'documents'
|
||||||
|
| 'downloads'
|
||||||
|
| 'music'
|
||||||
|
| 'pictures'
|
||||||
|
| 'videos';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
showDirectoryPicker(
|
||||||
|
options?: ShowDirectoryPickerOptions,
|
||||||
|
): Promise<FileSystemDirectoryHandle>;
|
||||||
|
}
|
||||||
308
nostr/src/idb-store.ts
Normal file
308
nostr/src/idb-store.ts
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
import type {
|
||||||
|
KVRecord,
|
||||||
|
DocRecord,
|
||||||
|
IdxEntry,
|
||||||
|
AppliedRecord,
|
||||||
|
MetaRecord,
|
||||||
|
IndexDefinition,
|
||||||
|
} from './types.js';
|
||||||
|
import { extractIndexValue } from './utils.js';
|
||||||
|
|
||||||
|
// ── Store names ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const S_KV = 'kv';
|
||||||
|
const S_DOCS = 'docs';
|
||||||
|
const S_IDX = 'idx';
|
||||||
|
const S_APPLIED = 'applied';
|
||||||
|
const S_META = 'meta';
|
||||||
|
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
// ── Promise wrappers for raw IDB ─────────────────────────────
|
||||||
|
|
||||||
|
function reqP<T>(req: IDBRequest<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function txDone(tx: IDBTransaction): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error ?? new Error('Transaction aborted'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IDBStore ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class IDBStore {
|
||||||
|
private constructor(private db: IDBDatabase) {}
|
||||||
|
|
||||||
|
// ── Open ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static open(name: string): Promise<IDBStore> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(name, DB_VERSION);
|
||||||
|
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result;
|
||||||
|
|
||||||
|
// KV store
|
||||||
|
if (!db.objectStoreNames.contains(S_KV)) {
|
||||||
|
db.createObjectStore(S_KV, { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document store (compound primary key)
|
||||||
|
if (!db.objectStoreNames.contains(S_DOCS)) {
|
||||||
|
const docs = db.createObjectStore(S_DOCS, {
|
||||||
|
keyPath: ['store', 'id'],
|
||||||
|
});
|
||||||
|
docs.createIndex('byStore', 'store', { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary index entries
|
||||||
|
if (!db.objectStoreNames.contains(S_IDX)) {
|
||||||
|
const idx = db.createObjectStore(S_IDX, {
|
||||||
|
keyPath: ['store', 'indexName', 'id'],
|
||||||
|
});
|
||||||
|
idx.createIndex('lookup', ['store', 'indexName', 'value'], {
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
|
idx.createIndex('byDoc', ['store', 'id'], { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied event dedup
|
||||||
|
if (!db.objectStoreNames.contains(S_APPLIED)) {
|
||||||
|
db.createObjectStore(S_APPLIED, { keyPath: 'filename' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
if (!db.objectStoreNames.contains(S_META)) {
|
||||||
|
db.createObjectStore(S_META, { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
req.onsuccess = () => resolve(new IDBStore(req.result));
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KV operations ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async getKV(key: string): Promise<KVRecord | undefined> {
|
||||||
|
const tx = this.db.transaction(S_KV, 'readonly');
|
||||||
|
return reqP(tx.objectStore(S_KV).get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
async putKV(record: KVRecord): Promise<void> {
|
||||||
|
const tx = this.db.transaction(S_KV, 'readwrite');
|
||||||
|
tx.objectStore(S_KV).put(record);
|
||||||
|
return txDone(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteKV(key: string): Promise<void> {
|
||||||
|
const tx = this.db.transaction(S_KV, 'readwrite');
|
||||||
|
tx.objectStore(S_KV).delete(key);
|
||||||
|
return txDone(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllKVKeys(): Promise<string[]> {
|
||||||
|
const tx = this.db.transaction(S_KV, 'readonly');
|
||||||
|
const records: KVRecord[] = await reqP(tx.objectStore(S_KV).getAll());
|
||||||
|
return records.map((r) => r.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllKVEntries(): Promise<KVRecord[]> {
|
||||||
|
const tx = this.db.transaction(S_KV, 'readonly');
|
||||||
|
return reqP(tx.objectStore(S_KV).getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Doc operations ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async getDoc(store: string, id: string): Promise<DocRecord | undefined> {
|
||||||
|
const tx = this.db.transaction(S_DOCS, 'readonly');
|
||||||
|
const rec: DocRecord | undefined = await reqP(
|
||||||
|
tx.objectStore(S_DOCS).get([store, id]),
|
||||||
|
);
|
||||||
|
if (rec && rec.deleted) return undefined;
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRawDoc(store: string, id: string): Promise<DocRecord | undefined> {
|
||||||
|
const tx = this.db.transaction(S_DOCS, 'readonly');
|
||||||
|
return reqP(tx.objectStore(S_DOCS).get([store, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async putDoc(
|
||||||
|
record: DocRecord,
|
||||||
|
indexes: IndexDefinition[],
|
||||||
|
): Promise<void> {
|
||||||
|
const tx = this.db.transaction([S_DOCS, S_IDX], 'readwrite');
|
||||||
|
const docsOS = tx.objectStore(S_DOCS);
|
||||||
|
const idxOS = tx.objectStore(S_IDX);
|
||||||
|
|
||||||
|
// Remove old index entries for this doc (chain off getAll request)
|
||||||
|
const byDocIdx = idxOS.index('byDoc');
|
||||||
|
const getOld = byDocIdx.getAll([record.store, record.id]);
|
||||||
|
|
||||||
|
getOld.onsuccess = () => {
|
||||||
|
const oldEntries: IdxEntry[] = getOld.result;
|
||||||
|
for (const entry of oldEntries) {
|
||||||
|
idxOS.delete([entry.store, entry.indexName, entry.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write document
|
||||||
|
docsOS.put(record);
|
||||||
|
|
||||||
|
// Create new index entries (only for non-deleted docs)
|
||||||
|
if (!record.deleted) {
|
||||||
|
for (const def of indexes) {
|
||||||
|
const val = extractIndexValue(record.data, def.fields as string[]);
|
||||||
|
if (val !== undefined) {
|
||||||
|
idxOS.put({
|
||||||
|
store: record.store,
|
||||||
|
indexName: def.name,
|
||||||
|
id: record.id,
|
||||||
|
value: val,
|
||||||
|
} satisfies IdxEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return txDone(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDoc(
|
||||||
|
store: string,
|
||||||
|
id: string,
|
||||||
|
ts: number,
|
||||||
|
rev: number,
|
||||||
|
indexes: IndexDefinition[],
|
||||||
|
): Promise<void> {
|
||||||
|
return this.putDoc(
|
||||||
|
{ store, id, data: null, ts, rev, deleted: true },
|
||||||
|
indexes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllDocs(store: string): Promise<DocRecord[]> {
|
||||||
|
const tx = this.db.transaction(S_DOCS, 'readonly');
|
||||||
|
const idx = tx.objectStore(S_DOCS).index('byStore');
|
||||||
|
const all: DocRecord[] = await reqP(idx.getAll(store));
|
||||||
|
return all.filter((r) => !r.deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIndex(
|
||||||
|
store: string,
|
||||||
|
indexName: string,
|
||||||
|
value: IDBValidKey,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const tx = this.db.transaction(S_IDX, 'readonly');
|
||||||
|
const idx = tx.objectStore(S_IDX).index('lookup');
|
||||||
|
const entries: IdxEntry[] = await reqP(
|
||||||
|
idx.getAll([store, indexName, value]),
|
||||||
|
);
|
||||||
|
return entries.map((e) => e.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryByIndex(
|
||||||
|
store: string,
|
||||||
|
indexName: string,
|
||||||
|
range: IDBKeyRange,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const tx = this.db.transaction(S_IDX, 'readonly');
|
||||||
|
const idx = tx.objectStore(S_IDX).index('lookup');
|
||||||
|
const entries: IdxEntry[] = await reqP(idx.getAll(range));
|
||||||
|
// Filter to only this store+index (the range may be broader)
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.store === store && e.indexName === indexName)
|
||||||
|
.map((e) => e.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rebuildIndexes(
|
||||||
|
store: string,
|
||||||
|
indexes: IndexDefinition[],
|
||||||
|
): Promise<void> {
|
||||||
|
const docs = await this.getAllDocs(store);
|
||||||
|
const tx = this.db.transaction(S_IDX, 'readwrite');
|
||||||
|
const idxOS = tx.objectStore(S_IDX);
|
||||||
|
|
||||||
|
// Delete all existing index entries for this store
|
||||||
|
const byDocIdx = idxOS.index('byDoc');
|
||||||
|
for (const doc of docs) {
|
||||||
|
const req = byDocIdx.getAll([store, doc.id]);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
for (const entry of req.result as IdxEntry[]) {
|
||||||
|
idxOS.delete([entry.store, entry.indexName, entry.id]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-create all entries
|
||||||
|
for (const doc of docs) {
|
||||||
|
for (const def of indexes) {
|
||||||
|
const val = extractIndexValue(doc.data, def.fields as string[]);
|
||||||
|
if (val !== undefined) {
|
||||||
|
idxOS.put({
|
||||||
|
store,
|
||||||
|
indexName: def.name,
|
||||||
|
id: doc.id,
|
||||||
|
value: val,
|
||||||
|
} satisfies IdxEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return txDone(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Applied-event tracking ─────────────────────────────────
|
||||||
|
|
||||||
|
async isEventApplied(filename: string): Promise<boolean> {
|
||||||
|
const tx = this.db.transaction(S_APPLIED, 'readonly');
|
||||||
|
const rec = await reqP(tx.objectStore(S_APPLIED).get(filename));
|
||||||
|
return rec !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markEventApplied(filename: string): Promise<void> {
|
||||||
|
const tx = this.db.transaction(S_APPLIED, 'readwrite');
|
||||||
|
tx.objectStore(S_APPLIED).put({
|
||||||
|
filename,
|
||||||
|
appliedAt: Date.now(),
|
||||||
|
} satisfies AppliedRecord);
|
||||||
|
return txDone(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAppliedSet(): Promise<Set<string>> {
|
||||||
|
const tx = this.db.transaction(S_APPLIED, 'readonly');
|
||||||
|
const all: AppliedRecord[] = await reqP(
|
||||||
|
tx.objectStore(S_APPLIED).getAll(),
|
||||||
|
);
|
||||||
|
return new Set(all.map((r) => r.filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Meta ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getMeta<T = unknown>(key: string): Promise<T | undefined> {
|
||||||
|
const tx = this.db.transaction(S_META, 'readonly');
|
||||||
|
const rec: MetaRecord | undefined = await reqP(
|
||||||
|
tx.objectStore(S_META).get(key),
|
||||||
|
);
|
||||||
|
return rec?.value as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMeta(key: string, value: unknown): Promise<void> {
|
||||||
|
const tx = this.db.transaction(S_META, 'readwrite');
|
||||||
|
tx.objectStore(S_META).put({ key, value } satisfies MetaRecord);
|
||||||
|
return txDone(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
nostr/src/index.ts
Normal file
11
nostr/src/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export { FolderSyncDB } from './folder-sync-db.js';
|
||||||
|
export type {
|
||||||
|
OpenOptions,
|
||||||
|
StoreOptions,
|
||||||
|
IndexDefinition,
|
||||||
|
SyncEvent,
|
||||||
|
KVApi,
|
||||||
|
CollectionApi,
|
||||||
|
SyncDBEventName,
|
||||||
|
SyncDBEventHandler,
|
||||||
|
} from './types.js';
|
||||||
45
nostr/src/kv-store.ts
Normal file
45
nostr/src/kv-store.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import type { KVApi } from './types.js';
|
||||||
|
import type { IDBStore } from './idb-store.js';
|
||||||
|
import type { SyncEngine } from './sync-engine.js';
|
||||||
|
|
||||||
|
export class KVStore implements KVApi {
|
||||||
|
constructor(
|
||||||
|
private idb: IDBStore,
|
||||||
|
private sync: SyncEngine,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get<T = unknown>(key: string): Promise<T | undefined> {
|
||||||
|
const rec = await this.idb.getKV(key);
|
||||||
|
return rec?.value as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<T = unknown>(key: string, value: T): Promise<void> {
|
||||||
|
const existing = await this.idb.getKV(key);
|
||||||
|
const rev = (existing?.rev ?? 0) + 1;
|
||||||
|
const ts = Date.now();
|
||||||
|
// SyncEngine.persistEvent handles the IDB write + folder write
|
||||||
|
await this.sync.writeKV(key, value, ts, rev);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
const existing = await this.idb.getKV(key);
|
||||||
|
if (!existing) return;
|
||||||
|
const rev = existing.rev + 1;
|
||||||
|
const ts = Date.now();
|
||||||
|
await this.sync.deleteKV(key, ts, rev);
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string): Promise<boolean> {
|
||||||
|
const rec = await this.idb.getKV(key);
|
||||||
|
return rec !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async keys(): Promise<string[]> {
|
||||||
|
return this.idb.getAllKVKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
async entries<T = unknown>(): Promise<Array<[string, T]>> {
|
||||||
|
const records = await this.idb.getAllKVEntries();
|
||||||
|
return records.map((r) => [r.key, r.value as T]);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
nostr/src/nostr-transport.ts
Normal file
179
nostr/src/nostr-transport.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import type { SyncEvent } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nostr relay transport layer.
|
||||||
|
*
|
||||||
|
* Implements the same event I/O interface as FolderStore so the SyncEngine
|
||||||
|
* can treat it as just another transport.
|
||||||
|
*
|
||||||
|
* Uses raw WebSocket + minimal Nostr protocol (NIP-01) to avoid
|
||||||
|
* heavy dependencies. Works from file:// origins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NOSTR_EVENT_KIND = 4078; // custom regular kind (stored by relays)
|
||||||
|
|
||||||
|
// ── Minimal Nostr crypto (secp256k1 via nostr-tools) ─────────
|
||||||
|
|
||||||
|
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
|
||||||
|
import { SimplePool, type SubCloser } from 'nostr-tools/pool';
|
||||||
|
import type { Filter } from 'nostr-tools/filter';
|
||||||
|
|
||||||
|
export class NostrTransport {
|
||||||
|
private relays: string[];
|
||||||
|
private pool: SimplePool;
|
||||||
|
private roomKey: string | null = null;
|
||||||
|
private secretKey: Uint8Array | null = null;
|
||||||
|
|
||||||
|
/** Cached events keyed by our standard filename. */
|
||||||
|
private eventCache = new Map<string, SyncEvent>();
|
||||||
|
|
||||||
|
/** Active relay subscription (closeable). */
|
||||||
|
private sub: SubCloser | null = null;
|
||||||
|
|
||||||
|
/** Callback fired when a new event arrives in real time. */
|
||||||
|
private _onNewEvent?: () => void;
|
||||||
|
|
||||||
|
constructor(relays: string[]) {
|
||||||
|
this.relays = relays;
|
||||||
|
this.pool = new SimplePool();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key management ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the keypair. Called by FolderSyncDB after loading from IDB
|
||||||
|
* (or generating a new one on first run).
|
||||||
|
*/
|
||||||
|
setKeypair(sk: Uint8Array): void {
|
||||||
|
this.secretKey = sk;
|
||||||
|
// Derive pubkey (used internally by finalizeEvent)
|
||||||
|
getPublicKey(sk);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateKeypair(): Uint8Array {
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
this.setKeypair(sk);
|
||||||
|
return sk;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Room management ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async joinRoom(roomKey: string): Promise<void> {
|
||||||
|
if (this.roomKey === roomKey && this.sub) return; // already joined
|
||||||
|
this.leaveRoom();
|
||||||
|
this.roomKey = roomKey;
|
||||||
|
|
||||||
|
// Fetch existing events from relays
|
||||||
|
const fetchFilter: Filter = {
|
||||||
|
kinds: [NOSTR_EVENT_KIND],
|
||||||
|
'#channel': [roomKey],
|
||||||
|
limit: 5000,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const events = await this.pool.querySync(this.relays, fetchFilter as Filter);
|
||||||
|
for (const ev of events) {
|
||||||
|
this.cacheNostrEvent(ev);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// relay might be unreachable — continue with empty cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe for new real-time events
|
||||||
|
const subFilter: Filter = {
|
||||||
|
kinds: [NOSTR_EVENT_KIND],
|
||||||
|
'#channel': [roomKey],
|
||||||
|
since: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
this.sub = this.pool.subscribeMany(this.relays, subFilter, {
|
||||||
|
onevent: (ev) => {
|
||||||
|
const isNew = this.cacheNostrEvent(ev);
|
||||||
|
if (isNew) this._onNewEvent?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveRoom(): void {
|
||||||
|
if (this.sub) {
|
||||||
|
this.sub.close();
|
||||||
|
this.sub = null;
|
||||||
|
}
|
||||||
|
this.roomKey = null;
|
||||||
|
this.eventCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this.roomKey !== null && this.sub !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentRoom(): string | null {
|
||||||
|
return this.roomKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Callback for real-time push ─────────────────────────────
|
||||||
|
|
||||||
|
onNewEvent(cb: () => void): void {
|
||||||
|
this._onNewEvent = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event I/O (same interface as FolderStore) ───────────────
|
||||||
|
|
||||||
|
async writeEvent(filename: string, event: SyncEvent): Promise<void> {
|
||||||
|
if (!this.roomKey || !this.secretKey) return;
|
||||||
|
|
||||||
|
this.eventCache.set(filename, event);
|
||||||
|
|
||||||
|
const nostrEvent = finalizeEvent({
|
||||||
|
kind: NOSTR_EVENT_KIND,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['channel', this.roomKey],
|
||||||
|
['filename', filename],
|
||||||
|
],
|
||||||
|
content: JSON.stringify(event),
|
||||||
|
}, this.secretKey);
|
||||||
|
|
||||||
|
// Publish to all relays, don't wait for all to confirm
|
||||||
|
try {
|
||||||
|
await Promise.any(this.pool.publish(this.relays, nostrEvent as any));
|
||||||
|
} catch {
|
||||||
|
// All relays failed — event is still in local cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async scanEventFilenames(): Promise<string[]> {
|
||||||
|
return Array.from(this.eventCache.keys()).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async readEventFile(filename: string): Promise<SyncEvent | null> {
|
||||||
|
return this.eventCache.get(filename) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.leaveRoom();
|
||||||
|
this.pool.close(this.relays);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Nostr event and cache it. Returns true if the event
|
||||||
|
* was new (not already cached).
|
||||||
|
*/
|
||||||
|
private cacheNostrEvent(nostrEvent: any): boolean {
|
||||||
|
try {
|
||||||
|
const filename = nostrEvent.tags?.find(
|
||||||
|
(t: string[]) => t[0] === 'filename',
|
||||||
|
)?.[1];
|
||||||
|
if (!filename) return false;
|
||||||
|
if (this.eventCache.has(filename)) return false;
|
||||||
|
|
||||||
|
const syncEvent = JSON.parse(nostrEvent.content) as SyncEvent;
|
||||||
|
this.eventCache.set(filename, syncEvent);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
254
nostr/src/sync-engine.ts
Normal file
254
nostr/src/sync-engine.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import type { SyncEvent, IndexDefinition, OpenOptions } from './types.js';
|
||||||
|
import type { IDBStore } from './idb-store.js';
|
||||||
|
import type { FolderStore } from './folder-store.js';
|
||||||
|
import type { NostrTransport } from './nostr-transport.js';
|
||||||
|
import type { Emitter } from './emitter.js';
|
||||||
|
import { canonicalJson, sha256Hex, eventFilename } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-transport sync engine.
|
||||||
|
*
|
||||||
|
* On local write → IDB + folder (if connected) + Nostr (if joined)
|
||||||
|
* On sync() → import from folder + import from Nostr, deduplicate
|
||||||
|
*/
|
||||||
|
export class SyncEngine {
|
||||||
|
private clientId: string;
|
||||||
|
private conflictResolver?: OpenOptions['conflictResolver'];
|
||||||
|
private collectionIndexes = new Map<string, IndexDefinition[]>();
|
||||||
|
private syncing = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private idb: IDBStore,
|
||||||
|
private folder: FolderStore,
|
||||||
|
private nostr: NostrTransport,
|
||||||
|
private emitter: Emitter,
|
||||||
|
clientId: string,
|
||||||
|
conflictResolver?: OpenOptions['conflictResolver'],
|
||||||
|
) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.conflictResolver = conflictResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collection index registration ──────────────────────────
|
||||||
|
|
||||||
|
registerIndexes(store: string, indexes: IndexDefinition[]): void {
|
||||||
|
this.collectionIndexes.set(store, indexes);
|
||||||
|
}
|
||||||
|
|
||||||
|
getIndexes(store: string): IndexDefinition[] {
|
||||||
|
return this.collectionIndexes.get(store) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local writes (write to IDB + folder + Nostr) ───────────
|
||||||
|
|
||||||
|
async writeKV(key: string, value: unknown, ts: number, rev: number): Promise<void> {
|
||||||
|
const event: SyncEvent = {
|
||||||
|
type: 'put', store: 'kv', key, ts,
|
||||||
|
clientId: this.clientId, data: value, rev,
|
||||||
|
};
|
||||||
|
await this.persistEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteKV(key: string, ts: number, rev: number): Promise<void> {
|
||||||
|
const event: SyncEvent = {
|
||||||
|
type: 'delete', store: 'kv', key, ts,
|
||||||
|
clientId: this.clientId, rev,
|
||||||
|
};
|
||||||
|
await this.persistEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeDoc(store: string, id: string, data: unknown, ts: number, rev: number): Promise<void> {
|
||||||
|
const event: SyncEvent = {
|
||||||
|
type: 'put', store, key: id, id, ts,
|
||||||
|
clientId: this.clientId, data, rev,
|
||||||
|
};
|
||||||
|
await this.persistEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDocEvent(store: string, id: string, ts: number, rev: number): Promise<void> {
|
||||||
|
const event: SyncEvent = {
|
||||||
|
type: 'delete', store, key: id, id, ts,
|
||||||
|
clientId: this.clientId, rev,
|
||||||
|
};
|
||||||
|
await this.persistEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core persist: IDB + folder + Nostr ─────────────────────
|
||||||
|
|
||||||
|
private async persistEvent(event: SyncEvent): Promise<void> {
|
||||||
|
const canonical = canonicalJson(event);
|
||||||
|
const hash = await sha256Hex(canonical);
|
||||||
|
const filename = eventFilename(event.ts, hash);
|
||||||
|
|
||||||
|
// 1. Write to IDB (fast path)
|
||||||
|
await this.applyEvent(event);
|
||||||
|
await this.idb.markEventApplied(filename);
|
||||||
|
|
||||||
|
// 2. Write to folder (if connected)
|
||||||
|
if (this.folder.hasHandle && (await this.folder.hasPermission())) {
|
||||||
|
try {
|
||||||
|
await this.folder.writeEvent(filename, event);
|
||||||
|
} catch (err) {
|
||||||
|
this.emitter.emit('folder:lost-permission', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Publish to Nostr (if joined)
|
||||||
|
if (this.nostr.isConnected) {
|
||||||
|
try {
|
||||||
|
await this.nostr.writeEvent(filename, event);
|
||||||
|
} catch {
|
||||||
|
// Nostr publish failed — event is still in IDB + folder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitter.emit('change', {
|
||||||
|
type: event.type, store: event.store,
|
||||||
|
key: event.key, id: event.id, data: event.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync: import from BOTH folder and Nostr ────────────────
|
||||||
|
|
||||||
|
async sync(): Promise<void> {
|
||||||
|
if (this.syncing) return;
|
||||||
|
this.syncing = true;
|
||||||
|
this.emitter.emit('sync:start');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appliedSet = await this.idb.getAppliedSet();
|
||||||
|
let importCount = 0;
|
||||||
|
|
||||||
|
// ── Import from folder ─────────────────────────────────
|
||||||
|
const hasFolder = this.folder.hasHandle && (await this.folder.hasPermission());
|
||||||
|
if (hasFolder) {
|
||||||
|
const folderFiles = await this.folder.scanEventFilenames();
|
||||||
|
for (const name of folderFiles) {
|
||||||
|
if (appliedSet.has(name)) continue;
|
||||||
|
const event = await this.folder.readEventFile(name);
|
||||||
|
if (!event) continue;
|
||||||
|
|
||||||
|
const hadConflict = await this.applyEventWithConflictCheck(event);
|
||||||
|
await this.idb.markEventApplied(name);
|
||||||
|
appliedSet.add(name);
|
||||||
|
importCount++;
|
||||||
|
|
||||||
|
if (hadConflict) this.emitter.emit('conflict', { filename: name, event });
|
||||||
|
this.emitter.emit('change', {
|
||||||
|
type: event.type, store: event.store,
|
||||||
|
key: event.key, id: event.id, data: event.data, source: 'folder',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bridge: push folder event to Nostr so remote devices get it
|
||||||
|
if (this.nostr.isConnected) {
|
||||||
|
this.nostr.writeEvent(name, event).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Import from Nostr ──────────────────────────────────
|
||||||
|
if (this.nostr.isConnected) {
|
||||||
|
const nostrFiles = await this.nostr.scanEventFilenames();
|
||||||
|
for (const name of nostrFiles) {
|
||||||
|
if (appliedSet.has(name)) continue;
|
||||||
|
const event = await this.nostr.readEventFile(name);
|
||||||
|
if (!event) continue;
|
||||||
|
|
||||||
|
const hadConflict = await this.applyEventWithConflictCheck(event);
|
||||||
|
await this.idb.markEventApplied(name);
|
||||||
|
appliedSet.add(name);
|
||||||
|
importCount++;
|
||||||
|
|
||||||
|
if (hadConflict) this.emitter.emit('conflict', { filename: name, event });
|
||||||
|
this.emitter.emit('change', {
|
||||||
|
type: event.type, store: event.store,
|
||||||
|
key: event.key, id: event.id, data: event.data, source: 'nostr',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bridge: persist Nostr event to folder so it survives offline
|
||||||
|
if (hasFolder) {
|
||||||
|
this.folder.writeEvent(name, event).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither transport is connected, nothing to do
|
||||||
|
if (!hasFolder && !this.nostr.isConnected) {
|
||||||
|
// No transport — sync is a no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitter.emit('sync:end', { imported: importCount });
|
||||||
|
} catch (err) {
|
||||||
|
this.emitter.emit('sync:end', { error: err });
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply a single event to IDB ────────────────────────────
|
||||||
|
|
||||||
|
private async applyEvent(event: SyncEvent): Promise<void> {
|
||||||
|
if (event.store === 'kv') {
|
||||||
|
if (event.type === 'put') {
|
||||||
|
await this.idb.putKV({
|
||||||
|
key: event.key, value: event.data,
|
||||||
|
ts: event.ts, rev: event.rev ?? 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.idb.deleteKV(event.key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const indexes = this.getIndexes(event.store);
|
||||||
|
if (event.type === 'put') {
|
||||||
|
await this.idb.putDoc({
|
||||||
|
store: event.store, id: event.id ?? event.key,
|
||||||
|
data: event.data, ts: event.ts, rev: event.rev ?? 0,
|
||||||
|
}, indexes);
|
||||||
|
} else {
|
||||||
|
await this.idb.deleteDoc(
|
||||||
|
event.store, event.id ?? event.key,
|
||||||
|
event.ts, event.rev ?? 0, indexes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyEventWithConflictCheck(event: SyncEvent): Promise<boolean> {
|
||||||
|
let hadConflict = false;
|
||||||
|
|
||||||
|
if (event.store === 'kv') {
|
||||||
|
const existing = await this.idb.getKV(event.key);
|
||||||
|
if (existing) {
|
||||||
|
if (event.ts > existing.ts) {
|
||||||
|
hadConflict = true;
|
||||||
|
} else if (event.ts === existing.ts) {
|
||||||
|
hadConflict = true;
|
||||||
|
if (this.conflictResolver) {
|
||||||
|
const resolved = this.conflictResolver(existing.value, event.data);
|
||||||
|
event = { ...event, data: resolved };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const existing = await this.idb.getRawDoc(event.store, event.id ?? event.key);
|
||||||
|
if (existing && !existing.deleted) {
|
||||||
|
if (event.ts > existing.ts) {
|
||||||
|
hadConflict = true;
|
||||||
|
} else if (event.ts === existing.ts) {
|
||||||
|
hadConflict = true;
|
||||||
|
if (this.conflictResolver) {
|
||||||
|
const resolved = this.conflictResolver(existing.data, event.data);
|
||||||
|
event = { ...event, data: resolved };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.applyEvent(event);
|
||||||
|
return hadConflict;
|
||||||
|
}
|
||||||
|
}
|
||||||
112
nostr/src/types.ts
Normal file
112
nostr/src/types.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// ── Event types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type EventType = 'put' | 'delete';
|
||||||
|
|
||||||
|
export interface SyncEvent {
|
||||||
|
type: EventType;
|
||||||
|
store: string; // "kv" | collection name
|
||||||
|
key: string;
|
||||||
|
id?: string; // present for collection documents
|
||||||
|
ts: number; // millisecond Unix epoch
|
||||||
|
clientId: string;
|
||||||
|
data?: unknown; // absent on deletes
|
||||||
|
rev?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Index / collection options ───────────────────────────────
|
||||||
|
|
||||||
|
export interface IndexDefinition<T = unknown> {
|
||||||
|
name: string;
|
||||||
|
fields: (keyof T & string)[] | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreOptions<T = unknown> {
|
||||||
|
name: string;
|
||||||
|
indexes?: IndexDefinition<T>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenOptions {
|
||||||
|
dbName?: string;
|
||||||
|
autoSyncIntervalMs?: number;
|
||||||
|
clientId?: string;
|
||||||
|
conflictResolver?: (current: unknown, incoming: unknown) => unknown;
|
||||||
|
|
||||||
|
/** Nostr relay WebSocket URLs. Defaults to a set of popular public relays. */
|
||||||
|
relays?: string[];
|
||||||
|
/** If provided, automatically join this room on open. */
|
||||||
|
roomKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IDB record shapes ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface KVRecord {
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
ts: number;
|
||||||
|
rev: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocRecord {
|
||||||
|
store: string;
|
||||||
|
id: string;
|
||||||
|
data: unknown;
|
||||||
|
ts: number;
|
||||||
|
rev: number;
|
||||||
|
deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdxEntry {
|
||||||
|
store: string;
|
||||||
|
indexName: string;
|
||||||
|
id: string;
|
||||||
|
value: IDBValidKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppliedRecord {
|
||||||
|
filename: string;
|
||||||
|
appliedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaRecord {
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event emitter ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SyncDBEventName =
|
||||||
|
| 'sync:start'
|
||||||
|
| 'sync:end'
|
||||||
|
| 'change'
|
||||||
|
| 'conflict'
|
||||||
|
| 'folder:lost-permission'
|
||||||
|
| 'nostr:connected'
|
||||||
|
| 'nostr:disconnected'
|
||||||
|
| 'nostr:event';
|
||||||
|
|
||||||
|
export type SyncDBEventHandler = (...args: unknown[]) => void;
|
||||||
|
|
||||||
|
// ── KV API surface ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface KVApi {
|
||||||
|
get<T = unknown>(key: string): Promise<T | undefined>;
|
||||||
|
set<T = unknown>(key: string, value: T): Promise<void>;
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
has(key: string): Promise<boolean>;
|
||||||
|
keys(): Promise<string[]>;
|
||||||
|
entries<T = unknown>(): Promise<Array<[string, T]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collection API surface ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface CollectionApi<T extends { id: string }> {
|
||||||
|
get(id: string): Promise<T | undefined>;
|
||||||
|
put(doc: T): Promise<void>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
all(): Promise<T[]>;
|
||||||
|
findByIndex(indexName: string, value: IDBValidKey): Promise<T[]>;
|
||||||
|
queryByIndex(
|
||||||
|
indexName: string,
|
||||||
|
range: { gt?: IDBValidKey; gte?: IDBValidKey; lt?: IDBValidKey; lte?: IDBValidKey },
|
||||||
|
): Promise<T[]>;
|
||||||
|
}
|
||||||
94
nostr/src/utils.ts
Normal file
94
nostr/src/utils.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
// ── Canonical JSON ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function sortKeys(obj: unknown): unknown {
|
||||||
|
if (obj === null || obj === undefined || typeof obj !== 'object') return obj;
|
||||||
|
if (Array.isArray(obj)) return obj.map(sortKeys);
|
||||||
|
const sorted: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
|
||||||
|
sorted[key] = sortKeys((obj as Record<string, unknown>)[key]);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalJson(obj: unknown): string {
|
||||||
|
return JSON.stringify(sortKeys(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hashing ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function sha256Hex(data: string): Promise<string> {
|
||||||
|
const buf = await crypto.subtle.digest(
|
||||||
|
'SHA-256',
|
||||||
|
new TextEncoder().encode(data),
|
||||||
|
);
|
||||||
|
return Array.from(new Uint8Array(buf))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event filenames ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const HASH_PREFIX_LEN = 12;
|
||||||
|
|
||||||
|
export function eventFilename(ts: number, fullHash: string): string {
|
||||||
|
return `${ts}_${fullHash.slice(0, HASH_PREFIX_LEN)}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_FILE_RE = /^(\d+)_([0-9a-f]+)\.json$/;
|
||||||
|
|
||||||
|
export function parseEventFilename(
|
||||||
|
name: string,
|
||||||
|
): { ts: number; hash: string } | null {
|
||||||
|
const m = EVENT_FILE_RE.exec(name);
|
||||||
|
if (!m) return null;
|
||||||
|
return { ts: Number(m[1]), hash: m[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client ID ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function generateClientId(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Index value extraction ───────────────────────────────────
|
||||||
|
|
||||||
|
export function extractIndexValue(
|
||||||
|
data: unknown,
|
||||||
|
fields: string[],
|
||||||
|
): IDBValidKey | undefined {
|
||||||
|
if (!data || typeof data !== 'object') return undefined;
|
||||||
|
const rec = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (fields.length === 1) {
|
||||||
|
const v = getNestedValue(rec, fields[0]);
|
||||||
|
if (v === undefined || v === null) return undefined;
|
||||||
|
return toIDBKey(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: IDBValidKey[] = [];
|
||||||
|
for (const f of fields) {
|
||||||
|
const v = getNestedValue(rec, f);
|
||||||
|
if (v === undefined || v === null) return undefined;
|
||||||
|
parts.push(toIDBKey(v));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||||
|
let cur: unknown = obj;
|
||||||
|
for (const seg of path.split('.')) {
|
||||||
|
if (cur === null || cur === undefined || typeof cur !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
cur = (cur as Record<string, unknown>)[seg];
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIDBKey(v: unknown): IDBValidKey {
|
||||||
|
if (typeof v === 'string' || typeof v === 'number') return v;
|
||||||
|
if (v instanceof Date) return v;
|
||||||
|
if (Array.isArray(v)) return v.map(toIDBKey);
|
||||||
|
// Fallback: stringify
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
21
nostr/tsconfig.json
Normal file
21
nostr/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@ -20,7 +20,7 @@
|
|||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { join, basename } from 'path';
|
import { join, basename } from 'path';
|
||||||
|
|
||||||
const variants = ['indexeddb', 'sqlite', 'nedb', 'sql-js'] as const;
|
const variants = ['indexeddb', 'sqlite', 'nedb', 'sql-js', 'nostr'] as const;
|
||||||
|
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
console.log(`Building paste-${variant}...`);
|
console.log(`Building paste-${variant}...`);
|
||||||
|
|||||||
70
paste/nostr/app.ts
Normal file
70
paste/nostr/app.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { FolderSyncDB } from '../../nostr/src/index.ts';
|
||||||
|
import { initPasteApp } from '../shared.ts';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const db = await FolderSyncDB.open({ autoSyncIntervalMs: 3000 });
|
||||||
|
await initPasteApp(db as any, 'nostr');
|
||||||
|
|
||||||
|
// ── Nostr room UI ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const roomInput = document.querySelector('#room-input') as HTMLInputElement;
|
||||||
|
const joinBtn = document.querySelector('#join-room') as HTMLButtonElement;
|
||||||
|
const nostrStatus = document.querySelector('#nostr-status')!;
|
||||||
|
|
||||||
|
function updateNostrUI(connected: boolean, room?: string) {
|
||||||
|
if (connected && room) {
|
||||||
|
nostrStatus.textContent = `Connected: ${room}`;
|
||||||
|
nostrStatus.className = 'status ok';
|
||||||
|
joinBtn.textContent = 'Leave';
|
||||||
|
roomInput.disabled = true;
|
||||||
|
} else {
|
||||||
|
nostrStatus.textContent = 'Not connected';
|
||||||
|
nostrStatus.className = 'status';
|
||||||
|
joinBtn.textContent = 'Join room';
|
||||||
|
roomInput.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
joinBtn.addEventListener('click', async () => {
|
||||||
|
if (db.isConnected()) {
|
||||||
|
db.leaveRoom();
|
||||||
|
updateNostrUI(false);
|
||||||
|
} else {
|
||||||
|
const key = roomInput.value.trim();
|
||||||
|
if (!key) {
|
||||||
|
nostrStatus.textContent = 'Enter a room key';
|
||||||
|
nostrStatus.className = 'status err';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
nostrStatus.textContent = 'Connecting...';
|
||||||
|
nostrStatus.className = 'status';
|
||||||
|
await db.joinRoom(key);
|
||||||
|
updateNostrUI(true, key);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
nostrStatus.textContent = 'Error: ' + (e as Error).message;
|
||||||
|
nostrStatus.className = 'status err';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roomInput.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') joinBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore saved room
|
||||||
|
if (db.isConnected()) {
|
||||||
|
updateNostrUI(true, db.currentRoom ?? undefined);
|
||||||
|
roomInput.value = db.currentRoom ?? '';
|
||||||
|
} else {
|
||||||
|
updateNostrUI(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.on('nostr:connected', (data: any) => {
|
||||||
|
updateNostrUI(true, data?.roomKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
db.on('nostr:disconnected', () => {
|
||||||
|
updateNostrUI(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
paste/nostr/index.html
Normal file
43
paste/nostr/index.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>paste — Nostr + Folder</title>
|
||||||
|
<link rel="stylesheet" href="../styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>paste <span id="variant-label" class="variant">nostr</span></h1>
|
||||||
|
<p class="sub">type text + enter, or paste an image — use #hashtags to add tags</p>
|
||||||
|
<div class="folder-bar">
|
||||||
|
<button id="select-folder" class="folder-btn">Select sync folder</button>
|
||||||
|
<span id="folder-status" class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="folder-bar" style="margin-top:6px">
|
||||||
|
<input type="text" id="room-input" placeholder="room key" autocomplete="off"
|
||||||
|
style="padding:6px 10px;border:1px solid #555;border-radius:6px;background:#1e1e2e;color:#cdd6f4;font-size:0.85rem;width:180px">
|
||||||
|
<button id="join-room" class="folder-btn">Join room</button>
|
||||||
|
<span id="nostr-status" class="status"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="input-area">
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="paste-input"
|
||||||
|
placeholder="type something and press Enter, or Ctrl+V an image"
|
||||||
|
autocomplete="off" spellcheck="false">
|
||||||
|
<button class="clip-btn" id="clip-btn" title="Attach a file">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="file-input" hidden>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
</div>
|
||||||
|
<div id="items" class="items"></div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="./app.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user