LocalHtmlDataTest/README.md
Jason Tudisco 6ebe02ad56 Initial commit: local-first browser sync library experiment
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>
2026-03-17 22:04:08 -06:00

7.1 KiB

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 Browser-managed Yes Zero
nedb/ NeDB in-memory NDJSON snapshots to /data/ Yes @seald-io/nedb
sqlite/ SQLite WASM OPFS or in-memory No (needs HTTP + headers) @sqlite.org/sqlite-wasm
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)

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/ 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 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:

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:

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