Multi-device sync
selfstore syncs without a sync server. The durable home (a Drive file, a WebDAV file, a disk file on a shared mount) doubles as the meeting point: every device pushes its encrypted backup there and folds the others’ changes in. The merge runs on-device; the storage stays dumb, cheap and yours.
It is already on
Once you connect a home with store.connectDrive /
connectFile / connectWebdav, the simple store converges on its own: on
open, on tab focus, on network return, on a slow interval, and it flushes on
tab hide. You rarely call sync yourself; when you want to (a pull-to-refresh
button), it is one call:
await store.sync(); // converge now, on a user gesture
How the merge thinks
Every record carries a Hybrid Logical Clock stamp: physical time when clocks agree, logical ordering when they lie (and device clocks lie). Merges are deterministic: two replicas that see the same inputs converge to the same bytes, in any order. This is fuzz-tested with seeded randomness: two-way merges are symmetric and idempotent, and the set strategies are order-independent across replicas within their contracts. The full story of the clock, the tombstones and the properties the fuzz suite pins down lives in How sync works.
Per collection, you pick the semantics when you open the store:
const store = await selfstore('my-app', {
sync: {
ids: { events: 'ref' }, // per-collection id field
strategies: { accounts: 'lww-map' }, // per-collection strategy
fallback: 'lww-set', // everything else
},
});
| Strategy | Semantics |
|---|---|
lww-set |
Records keyed by id; later write per id wins; deletes tombstoned. The default. |
lww-map |
Field-by-field: concurrent edits to different fields both survive; the same field goes to the later clock. |
grow-set |
Append-only union; entries immutable per id; never conflicts. Ledgers, logs. |
lww-register |
One value as a whole (settings blobs). |
manual |
Do not resolve concurrent same-id edits; surface them. |
The string-id rule, again: a record whose id field is missing or not a string never syncs. The simple store throws at
put(); map a different field withsync.idsabove.
Conflicts are journaled, not hidden
Last-writer-wins drops the losing side of a true concurrent edit; pretending
otherwise is marketing. selfstore refuses to make it silent: every converge
that changed something is journaled on store.state.journal, and same-record
conflicts carry both values, so your UI can show “your phone’s version was
replaced, here it is” and offer a restore.
Honest limits
- Whole-state sync. Every converge downloads, merges and re-uploads the full backup. Fine at MB scale; wasteful for large datasets on metered connections. There is no delta sync today.
- One person’s devices. For several people, use peers. For live collaborative editing, embed a CRDT document (Yjs, Automerge) as a binary file in the snapshot; selfstore carries and syncs it happily.
- Binary files merge by id union. Files carry no clocks: use content-addressed ids, and tie a file’s lifetime to a record so record tombstones carry the deletion.
- Tombstones grow unless compacted. Convergence remembers deletions.
Driving
createLocalStoredirectly, opt into pruning withtombstoneHorizonMs(set it well above the longest a device stays offline).