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>
IndexSyncFile — NeDB Variant
Local-first key-value and document store using NeDB (@seald-io/nedb, the maintained fork) as the local cache, syncing with a user-selected folder via the File System Access API.
Why NeDB
- MongoDB-style queries —
$gt,$lt,$in,$nin,$regex,$exists,$elemMatch, and more - Native indexing —
ensureIndexhandles index creation and maintenance automatically - Dot-notation queries — query nested fields like
data.user.namenaturally - Promise-based API —
@seald-io/nedbprovides native*Asyncmethods, no callback wrappers needed
Architecture
Write: app ---> NeDB in-memory (immediate) ---> /events/timestamp_hash.json
---> /data/*.db (debounced NDJSON snapshot)
Sync: /events/*.json ---> sort by timestamp ---> skip applied ---> apply to NeDB
---> persist to /data/*.db
Startup: load /data/*.db (instant) ---> sync only NEW events from /events/
Two persistence layers
| Layer | Purpose | Format |
|---|---|---|
/events/ |
Sync mechanism — immutable event files enable multi-browser sync without data loss | One JSON file per mutation |
/data/ |
Fast reload — NDJSON snapshots so startup doesn't replay the entire event history | One NDJSON file per datastore |
The event log is essential for sync. The data files are a startup optimization — without them, every page load would replay all events from scratch.
Data files (NDJSON)
your-folder/data/
kv.db # all key-value records
docs.db # all collection documents
applied.db # which event files have been processed
meta.db # client ID and other metadata
Each file is newline-delimited JSON (one record per line). Human-readable, diffable, and written with a 250ms debounce to avoid excessive disk writes during batch operations.
NeDB datastores (4 in-memory)
| Datastore | _id |
Purpose |
|---|---|---|
kvDB |
KV key | Key-value records |
docsDB |
"store\0id" |
All collection documents (with _store, _docId, _deleted, data fields) |
appliedDB |
event filename | Tracks processed events |
metaDB |
meta key | Client ID, etc. |
Document data is stored in a data field to avoid field name conflicts. NeDB indexes are created on data.<fieldName> using dot notation.
Install and build
npm install
npm run build
Dependency: @seald-io/nedb ^4.1.0 — actively maintained fork of NeDB with native async/await support. Zero npm vulnerabilities.
Usage
import { FolderSyncDB } from './dist/index.js';
const db = await FolderSyncDB.open({
dbName: 'MyApp',
autoSyncIntervalMs: 5000,
});
await db.selectFolder();
// KV
await db.kv.set('user', { name: 'Alice', role: 'admin' });
// Collections with indexes
const logs = db.collection({
name: 'logs',
indexes: [
{ name: 'byLevel', fields: ['level'] },
{ name: 'byTimestamp', fields: ['ts'] },
],
});
await logs.put({ id: 'log1', level: 'error', ts: Date.now(), msg: 'disk full' });
await logs.put({ id: 'log2', level: 'info', ts: Date.now(), msg: 'started' });
// Exact match (uses NeDB index)
const errors = await logs.findByIndex('byLevel', 'error');
// Range query (uses NeDB $gte/$lte operators)
const recent = await logs.queryByIndex('byTimestamp', {
gte: Date.now() - 3600000,
});
// Multi-field indexes work too
const items = db.collection({
name: 'items',
indexes: [{ name: 'byTypeAndStatus', fields: ['type', 'status'] }],
});
const match = await items.findByIndex('byTypeAndStatus', ['widget', 'active']);
Variant-specific notes
- NeDB runs in-memory only in the browser (no Node.js
fsaccess) - Persistence comes from two sources: the event log (for sync) and the NDJSON snapshots (for fast reload)
- Directory handle is stored in a tiny IDB sidecar (NeDB can't store DOM objects)
- Range queries are only supported on single-field indexes (NeDB limitation)
- Consumer's bundler must handle NeDB's CommonJS-to-ESM conversion and the
browserfield in NeDB'spackage.json getAllData()is used for synchronous zero-copy dumps (no async overhead for persistence writes)