Search selfstore
v1.8.21

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.

Two devices make concurrent edits, both push and pull an encrypted backup to the same dumb home, and each folds the other in on-device so they converge to identical bytes. Device A rename account The home encrypted ZIP, dumb Device B add transaction push / pull push / pull on-device merge Every device converges to the same bytes HLC orders writes; concurrent edits to different fields both survive
The home never merges anything. Each device pulls the others' encrypted backup and runs the same deterministic merge locally, so they all land on identical state.

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 with sync.ids above.

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 createLocalStore directly, opt into pruning with tombstoneHorizonMs (set it well above the longest a device stays offline).