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.1.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 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. Run one step at a time, since a step that overlaps
another can write an older state after a newer one. 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.
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. 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 |