Great minds think alike.
This is a Clojure and ClojureScript library for local-first sync. It keeps an app's data the same on all of a user's devices through storage they already have, e.g. a folder that iCloud, Dropbox or Syncthing syncs, a WebDAV server such as Nextcloud, or the browser's own storage. There's no sync server to run.
NOTE: It's the companion of mind-meld: mind-meld merges the states of two devices without conflict, and like-minded gets the states from each device to the others.
sync!, and next-sync says when to call it again.The same code runs on the JVM, in Node and in the browser.
This library was spun out of podcast-clj, a library for making podcast software. There, it syncs a listener's subscriptions, queue and play progress between their devices. Like podcast-clj, it was developed with assistance from frontier LLMs.
It requires Clojure 1.11+ and Java 11+. For the latest release, add it
from Clojars to the
:deps in your deps.edn:
dk.simongray/like-minded {:mvn/version "0.2.0"}
For changes that aren't released yet, use the SHA of the latest commit on
master instead:
dk.simongray/like-minded
{:git/url "https://github.com/simongray/like-minded"
:git/sha "…"}
For ClojureScript, shadow-cljs only reads Git dependencies from
deps.edn, so also set :deps true in your shadow-cljs.edn.
The state of each device is a state of mind-meld, and the options of the state say what it is. Give them to the backend and to each sync:
(require '[dk.simongray.like-minded :as like-minded]
'[dk.simongray.like-minded.store.folder :as folder]
'[dk.simongray.mind-meld :as crdt])
(def opts
{:schema {:notes {:depth 1 :compact? true}} ; the mind-meld schema
:kind "my-notes"}) ; what the documents hold
(def dropbox
(like-minded/backend (folder/store "/Users/me/Dropbox/my-notes") opts))
(def phone
(crdt/write {:device "phone"}
[:notes "milk"]
(constantly {:text "Buy milk"})
#inst "2026-10-10T09:00:00Z"))
(def step
(like-minded/sync! phone [dropbox] opts))
The :device of a state names its document, so make it once for each
installation and keep it, e.g. with (str (random-uuid)). It has to be
a plain file name, and sync! throws without one.
The step writes phone.edn into the folder. A laptop that syncs through
the same folder writes laptop.edn, and merges in the phone's notes:
(def laptop
(crdt/write {:device "laptop"}
[:notes "bread"]
(constantly {:text "Buy bread"})
#inst "2026-10-10T09:05:00Z"))
(-> (like-minded/sync! laptop [dropbox] opts) :state :notes keys)
;; => ("bread" "milk")
A step returns the merged :state, a :report of what it found, its
:at, and the :cursors of the backends and the :failures in a row,
which the next step of the device takes:
(like-minded/sync! (:state step) [dropbox]
(merge opts (select-keys step [:cursors :failures])))
In ClojureScript, sync! returns a promise. The user may have changed
the state during the step, so give the step to finish with the state as
it is by then, which merges the two and compacts them as the step did:
(-> (like-minded/finish phone step opts) :notes keys)
;; => ("milk")
Then call next-sync for when to sync again.
Call sync! when the app starts, when it goes to the background, after a
series of changes, and when a file watcher or a service says that
something changed. A step that overlaps another can write an older state
after a newer one, so a step that begins while another step of the
device is running does nothing, and its report has
:sync/overlapping-step. That holds within a process, and in a browser
across the tabs of a page too. A file watcher also sees the writes of
the step itself, so let such a call wait until the step ends.
For when to sync again, give next-sync the last step, with its :at
and :failures, and whether the state has :changed? since:
(like-minded/next-sync {:at #inst "2026-10-10T09:00:00Z" :changed? true}
#inst "2026-10-10T09:00:03Z")
;; => #inst "2026-10-10T09:00:10.000-00:00"
The waits of default-cadence are short after a change, long when
nothing changed, and grow with each failure. Give waits of your own as
the third argument.
For control over the I/O, e.g. in a background task with a time budget,
reconcile merges the documents that you fetch yourself, as the
reference describes.
To turn sync off on a device, call leave!, which takes what sync!
takes. It retires the device and writes that through each backend once
more. The other devices read it at their next step, stop waiting for the
device before they compact, and delete its document. A retired device
can't come back, so to sync it again later, give its state a new
:device.
A store is a map of functions over named documents with versions, and
dk.simongray.like-minded.store describes the contract:
| Store | Namespace | Platform |
|---|---|---|
| A map in an atom | store/memory | all |
| A folder | store.folder | the JVM, Node, and a browser through showDirectoryPicker or the files of an input |
| A WebDAV collection | store.webdav | all |
| An IndexedDB database | store.indexeddb | a browser |
| Any store, encrypted | store.encrypted | all |
To try a store through its contract, e.g. from a button in the settings,
call store/check!, which reports what works. It's also the test for a
store of your own. On the JVM, store.webdav.server/handler serves any
store over WebDAV as a Ring handler, for a server whose browser clients
sync through their own origin.
The encrypting store seals each document with AES-256-GCM under a key from a passphrase, and keeps the salt and the IV in the document, so there's no key file to lose:
(require '[dk.simongray.like-minded.store.encrypted :as encrypted])
(encrypted/store (folder/store "/Users/me/Dropbox/my-notes")
"correct horse battery staple")
The passphrase can also be a function that asks the user for it.
The reference has the version token of each store and what it takes, and Providers lists where the common WebDAV servers and sync clients keep their folders.
:schema, the schema of the state in mind-meld, whose functions merge
it:crdt, the :merge, :compact and :problem functions of a state
with rules of its own, which go over those of the schema:version, the version of the state's format, which changes only when
the meaning of a key does. A device leaves out a document of a newer
version.:kind, what state the documents hold, so that two apps that sync
through the same folder don't merge each other's states:codec, how a document is encoded, EDN by defaultA backend also takes the :app to write into each document, an
:extra-documents-fn that gives documents to write beside the state,
e.g. for other apps to read, and the :strategy and :direction that
it otherwise takes from the store. The
reference has the defaults of each.
A service that merges on its own server is a backend directly, a map of
a :pull and a :push:
{:pull (fn [state cursor at]) ; the states of the other devices
:push (fn [state cursor at]) ; writes the merged state
:name "my-service"}
A pull that gives only the changes since its cursor says so with
:partial?, and they merge like any other state. A :leave that writes
the state of a device that leaves is optional. The
reference has the whole
contract.
.cljc, and depends on
mind-meld, wary-fetch for the
requests of WebDAV, and qname-hiccup
for its listings.clojure -X:test # the tests on the JVM
npm install # once, for the Node tests
clojure -M:cljs compile test # the tests in Node
clojure -M:cljs compile browser-test # for a browser, from target/browser-test
Besides the tests of each store, generative tests have devices change a state at random and sync in random orders through a store, with either strategy. They check that the devices end in the same state, and that compaction then leaves no tombstone and changes nothing that a user sees.
The like-minded project is licensed under the MIT licence.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |