Four variants of the same sync library (IndexedDB, NeDB, SQLite WASM, sql.js) plus a paste-bin demo app for testing multi-browser sync via shared folders. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
190 lines
7.1 KiB
Markdown
190 lines
7.1 KiB
Markdown
# IndexSyncFile
|
|
|
|
A local-first key-value and document store for the browser that syncs across multiple browser windows via a user-selected folder on disk.
|
|
|
|
**No server. No backend. No cloud.** Just browsers and a shared folder.
|
|
|
|
## The experiment
|
|
|
|
Can multiple browser tabs (or separate browsers entirely) stay in sync using nothing but a shared folder on the local file system?
|
|
|
|
This repo explores that question by building the same sync library four times, each with a different in-browser storage engine. The sync layer and public API are identical. Only the local cache differs.
|
|
|
|
The goal is to compare trade-offs: bundle size, startup speed, query power, persistence behavior, and `file://` compatibility.
|
|
|
|
## How it works
|
|
|
|
```
|
|
Browser A Shared Folder Browser B
|
|
--------- ------------- ---------
|
|
kv.set("x", 1)
|
|
|
|
|
+---> writes to local
|
|
| cache (fast)
|
|
|
|
|
+---> writes event file ---> /events/1234_abc.json
|
|
<--- sync() reads new events
|
|
|
|
|
+---> applies to
|
|
local cache
|
|
```
|
|
|
|
1. Each browser keeps a fast local cache for reads/writes (IndexedDB, SQLite WASM, NeDB, or sql.js)
|
|
2. Every mutation is written as an immutable JSON file in `/events/` inside a user-selected folder
|
|
3. When any browser calls `sync()`, it scans `/events/`, finds files it hasn't seen, and merges them into its local cache
|
|
4. Conflicts are resolved by last-write-wins (configurable)
|
|
|
|
### Why events?
|
|
|
|
The event log is what makes multi-browser sync possible. Each mutation is a separate file, so concurrent writes from different browsers never overwrite each other. Both sides' changes are captured and merged on the next `sync()`.
|
|
|
|
Without events, if two browsers both wrote a single database file, the last save would silently destroy the other browser's changes.
|
|
|
|
## Four variants
|
|
|
|
Each variant uses a different local cache engine. The API and sync behavior are identical.
|
|
|
|
| Variant | Local cache | Persistence | `file://` works? | Dependencies |
|
|
|---------|------------|-------------|-------------------|--------------|
|
|
| [`indexeddb/`](./indexeddb/) | IndexedDB | Browser-managed | Yes | Zero |
|
|
| [`nedb/`](./nedb/) | NeDB in-memory | NDJSON snapshots to `/data/` | Yes | `@seald-io/nedb` |
|
|
| [`sqlite/`](./sqlite/) | SQLite WASM | OPFS or in-memory | No (needs HTTP + headers) | `@sqlite.org/sqlite-wasm` |
|
|
| [`sql-js/`](./sql-js/) | sql.js (asm.js) | SQLite binary to `/data/store.sqlite` | Yes | `sql.js` |
|
|
|
|
### Which one should I use?
|
|
|
|
- **Just want it to work** — use `indexeddb/`. Zero deps, works from `file://`, instant startup.
|
|
- **Need MongoDB-style queries** (`$gt`, `$in`, `$regex`) — use `nedb/`.
|
|
- **Need real SQL + proper numeric range queries** — use `sql-js/`. Works from `file://`, no special headers.
|
|
- **Need OPFS-backed persistence + SQL** — use `sqlite/`. Requires HTTP server and may need COOP/COEP headers.
|
|
|
|
## Quick start (same for all variants)
|
|
|
|
```ts
|
|
import { FolderSyncDB } from 'index-sync-file'; // pick your variant
|
|
|
|
const db = await FolderSyncDB.open({
|
|
autoSyncIntervalMs: 5000,
|
|
});
|
|
|
|
// User picks a folder (one-time, persisted across sessions)
|
|
await db.selectFolder();
|
|
|
|
// Key-value
|
|
await db.kv.set('theme', 'dark');
|
|
const theme = await db.kv.get('theme'); // 'dark'
|
|
|
|
// Document collections with indexes
|
|
const todos = db.collection({
|
|
name: 'todos',
|
|
indexes: [{ name: 'byStatus', fields: ['status'] }],
|
|
});
|
|
|
|
await todos.put({ id: '1', title: 'Ship it', status: 'active' });
|
|
await todos.put({ id: '2', title: 'Test it', status: 'done' });
|
|
|
|
const active = await todos.findByIndex('byStatus', 'active');
|
|
// [{ id: '1', title: 'Ship it', status: 'active' }]
|
|
|
|
// Listen for changes (including from other browsers)
|
|
db.on('change', (e) => console.log('changed:', e));
|
|
db.on('conflict', (e) => console.log('conflict resolved:', e));
|
|
```
|
|
|
|
## Folder layout on disk
|
|
|
|
```
|
|
your-selected-folder/
|
|
events/
|
|
1710000000000_a1b2c3d4e5f6.json <-- immutable event files
|
|
1710000001000_f6e5d4c3b2a1.json
|
|
...
|
|
data/ <-- nedb and sql-js variants
|
|
kv.db <-- nedb: NDJSON snapshot
|
|
docs.db
|
|
store.sqlite <-- sql-js: full SQLite database file
|
|
```
|
|
|
|
## Demo app: Paste
|
|
|
|
The [`paste/`](./paste/) folder contains a working paste-bin demo app built with all four variants. Each compiles to a single self-contained HTML file that you can open directly from disk (except the SQLite WASM variant, which needs HTTP).
|
|
|
|
See the [paste README](./paste/README.md) for details.
|
|
|
|
## Browser requirements
|
|
|
|
Requires the **File System Access API** (`showDirectoryPicker`). Supported in:
|
|
- Chrome 86+
|
|
- Edge 86+
|
|
- Brave
|
|
- Opera 72+
|
|
|
|
Firefox and Safari do not support `showDirectoryPicker`. The library throws a clear error if the API is unavailable.
|
|
|
|
## Building
|
|
|
|
Each variant is a self-contained npm package:
|
|
|
|
```bash
|
|
cd indexeddb && npm install && npm run build
|
|
cd nedb && npm install && npm run build
|
|
cd sqlite && npm install && npm run build
|
|
cd sql-js && npm install && npm run build
|
|
```
|
|
|
|
The paste demo uses Bun:
|
|
|
|
```bash
|
|
cd paste && bun run build.ts
|
|
```
|
|
|
|
## API reference
|
|
|
|
See the README in each variant's folder for variant-specific details.
|
|
|
|
### `FolderSyncDB`
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `FolderSyncDB.open(options?)` | Create and initialize the database |
|
|
| `selectFolder()` | Prompt user to pick a sync folder |
|
|
| `hasFolderAccess()` | Check if folder permission is granted |
|
|
| `requestFolderAccess()` | Re-request permission (needs user gesture) |
|
|
| `sync()` | Manually trigger a sync with the folder |
|
|
| `close()` | Stop auto-sync, flush writes, release resources |
|
|
| `kv` | Key-value store (see below) |
|
|
| `collection(options)` | Create/get a document collection (see below) |
|
|
| `on(event, handler)` | Listen for events, returns unsubscribe function |
|
|
|
|
### `kv` (Key-Value Store)
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `get(key)` | Get value by key |
|
|
| `set(key, value)` | Set a key-value pair |
|
|
| `delete(key)` | Delete a key |
|
|
| `has(key)` | Check if key exists |
|
|
| `keys()` | Get all keys |
|
|
| `entries()` | Get all [key, value] pairs |
|
|
|
|
### `collection(options)` (Document Collections)
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `get(id)` | Get document by ID |
|
|
| `put(doc)` | Insert or update a document (must have `id` field) |
|
|
| `delete(id)` | Delete a document (tombstone) |
|
|
| `all()` | Get all documents |
|
|
| `findByIndex(indexName, value)` | Exact-match query on an index |
|
|
| `queryByIndex(indexName, range)` | Range query (`{ gte, lte, gt, lt }`) |
|
|
|
|
### Events
|
|
|
|
| Event | Payload | When |
|
|
|-------|---------|------|
|
|
| `sync:start` | -- | Sync begins |
|
|
| `sync:end` | `{ imported }` or `{ error }` | Sync completes |
|
|
| `change` | `{ type, store, key, id, data }` | Any data mutation (local or synced) |
|
|
| `conflict` | `{ filename, event }` | A conflict was detected and resolved |
|
|
| `folder:lost-permission` | error? | Folder access was lost |
|