My mind to your mind. My thoughts to your thoughts.
This is a Clojure and ClojureScript library for keeping an app's data the same on all of a user's devices without a server to merge it, e.g. the notes of a notes app, the queue of a podcast app, or the settings of any app. Each device keeps the data as a plain map and can change it offline, and any two of those maps merge without conflict, in any order. It's a CRDT, a conflict-free replicated data type, for local-first apps.
NOTE: It's the companion of like-minded: like-minded gets the states from each device to the others, and mind-meld merges them without conflict.
The same code runs on the JVM, in Node and in the browser, with the same results on each.
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/mind-meld {:mvn/version "0.1.0"}
For changes that aren't released yet, use the SHA of the latest commit on
master instead:
dk.simongray/mind-meld
{:git/url "https://github.com/simongray/mind-meld"
:git/sha "…"}
For ClojureScript, shadow-cljs only reads Git dependencies from
deps.edn, so also set :deps true in your shadow-cljs.edn.
A state is a map with the id of its device, and a schema names its
collections. Each device writes its changes with crdt/write and the
time each was made:
(require '[dk.simongray.mind-meld :as crdt])
(def schema
{:notes {:depth 1 :compact? true}})
(def phone
(crdt/write {:device "phone"}
[:notes "milk"]
(constantly {:text "Buy milk"})
#inst "2026-10-09T09:00:00Z"))
(def laptop
(crdt/write {:device "laptop"}
[:notes "bread"]
(constantly {:text "Buy bread"})
#inst "2026-10-09T10:00:00Z"))
The :depth of the notes is 1, since one key, the note's id, leads to
each of them. With :compact? true, removed notes can be dropped for
good once every device has seen the removal, as described
below. To catch a mistake in a schema, e.g. in
a test, crdt/schema-problem describes an unknown option or a missing
:depth.
To sync, a device merges the states of the others into its own:
(crdt/merge schema phone laptop)
;; => {:device "phone"
;; :clock #inst "2026-10-09T10:00:00.000-00:00"
;; :notes {"milk" {:text "Buy milk"
;; :updated-at #inst "2026-10-09T09:00:00.000-00:00"
;; :device "phone"}
;; "bread" {:text "Buy bread"
;; :updated-at #inst "2026-10-09T10:00:00.000-00:00"
;; :device "laptop"}}}
The merge gives the same result in any order and any number of times, so
the devices can sync through anything that stores a file. Where two
devices changed the same entry, the later change wins. A removal is a
change too: the entry stays as a tombstone,
marked :removed?, so that an older copy of it can't bring it back. Say
the laptop has synced and removes the milk note:
(crdt/write (crdt/merge schema laptop phone)
[:notes "milk"]
#(assoc % :removed? true)
#inst "2026-10-09T11:00:00Z")
If the phone, still offline, edits the note at 10:30, the note stays
removed on both devices once they sync, since the removal at 11:00 is
later. Use crdt/live? to tell whether an entry is there and isn't a
tombstone.
Other functions cover imports and the rest of a sync:
crdt/at-once, a series of writes gets one stamp, e.g. those of
an import. Without it, writes given the same time are stamped a
millisecond apart, since a write is always stamped later than the
state's clock.crdt/as-device, you can write what another device or a service
did, at the time it happened there.crdt/changes-since gives what changed since an earlier state.crdt/problem describes what in it would break the merge.An ordered collection, e.g. a queue, is a map of ids to entries with a
:rank, a string that sorts in the order of the list. A move goes in a
second collection that holds only the new ranks, so a move never brings
back an item that another device removed:
(require '[dk.simongray.mind-meld.list :as crdt.list])
(def schema
{:queue {:depth 1 :ranked? true}
:queue-moves {:depth 1 :ranked? true}})
(defn entries
"The queue of `state` with its moves."
[state]
(crdt.list/with-moves (:queue state) (:queue-moves state)))
(defn queue
"The ids in the queue of `state`, in order."
[state]
(mapv key (crdt.list/ranked (entries state))))
(defn move
"The `state` with `id` moved to `index` of its queue at `at`."
[state id index at]
(crdt/write state
[:queue-moves id]
(constantly {:rank (crdt.list/rank-at (entries state) id index)})
at))
(def phone
(crdt.list/append {:device "phone"} [:queue] {} ["a" "b"]
#inst "2026-10-09T09:00:00Z"))
(def laptop
(crdt.list/append {:device "laptop"} [:queue] {} ["c" "d"]
#inst "2026-10-09T09:05:00Z"))
(def both
(crdt/merge schema phone laptop))
(queue both)
;; => ["c" "d" "a" "b"]
(queue (move both "d" 0 #inst "2026-10-09T10:00:00Z"))
;; => ["d" "c" "a" "b"]
The ids that a device appends together share a block of ranks of its own,
so the two devices' items don't mix. With :ranked? true,
crdt.list/problem checks the ranks of a collection, as crdt/problem
checks the rest.
The ranks come from dk.simongray.mind-meld.rank, which you can also use
on its own. There's always room for another rank between any two, and the
ranks stay short at the ends of a list, however long it grows.
Tombstones pile up, so crdt/compact drops those of the collections with
:compact? true once every device has merged them and they're older than
the :keep-for option you pass it, 30 days by default. The state holds a
registry of the devices, which records what each has merged:
crdt/identify, e.g. with its
name and platform.crdt/acknowledge.crdt/lagging, and retire one for good with
crdt/retire..cljc, with no
dependencies but Clojure.clojure -X:test # the tests on the JVM
npm install # once, for the Node tests
clojure -M:cljs compile test # the tests in Node
The mind-meld 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 |