This page has the contracts and the formats of like-minded. The README shows how to sync, and the docstrings have the details of each function. Providers lists where the common WebDAV servers and sync clients keep their folders.
A store is a map of functions over named documents, each with a version token:
{:fetch (fn [name opts]) ; {:body "…" :version "…"}, or nil
:put (fn [name body opts]) ; {:version "…"}, or {:conflict? true}, optional
:list (fn []) ; [{:name "…" :version "…"} …], optional
:delete (fn [name]) ; optional
:name "Nextcloud"} ; for the UI and the report
opts has an :if-version that isn't the
current version of the document, and an :if-version of nil means that
the document mustn't exist yet. The check and the write happen as one
step, so of two puts with the same :if-version, only one succeeds. The
folder store guarantees this within one process, and in a browser
across the tabs of a site.{:conflict? true} rather than throwing, since
it's a normal outcome when the devices share one document. A failure of
the network or of a service throws, and the step reports it.{:as :bytes} in its opts, whichever of the two was put, so a store
converts it when needed.:name mustn't contain them.:list can't hold a document per device, so it holds
one shared document.:put is read only, and a backend over it only pulls.To test a store against this contract, use store/check!. It writes a
probe document, and for a store without :list, it checks that a put
with a stale version and a put with an :if-version of nil are both
refused, as one shared document needs. A store without :delete keeps
the probe document.
A backend is a map of two functions and a name, which sync! calls:
{:pull (fn [state cursor at]) ; {:states […] :cursor cursor' :report […] :partial? false}
:push (fn [state cursor at]) ; {:cursor cursor' :state state' :report […]}
:name "my-service"}
:partial? true, the states are only the changes since the
cursor. They merge like whole states, but they aren't acknowledged in
mind-meld's registry of devices, since they don't show all that a
device has merged.:state
and :report are optional, and it returns a :state when it learned
more while it wrote.at is the instant of the step, so the functions never read a
clock.sync! need different names, since the cursors are
kept by name, and sync! throws on a duplicate.Any store becomes a backend through backend. A service that merges on
its own server is a backend directly, which translates in both
directions: a push sends the changes since the last push as the service's
own actions, and a pull turns what the service reports into a partial
state. The backend of podcast-clj for the gpodder API,
sync.gpodder,
is one.
The options of backend:
| Option | Meaning | Default |
|---|---|---|
:strategy | :per-device or :shared | :per-device if the store has :list, else :shared |
:direction | :both, :pull (read only) or :push (write only) | :both if the store has :put, else :pull |
:app | The name of the app, written into each document | none |
:name | The name of the backend | the name of the store |
:document | The name of the shared document | state and the extension of the codec, e.g. state.edn |
:extra-documents-fn | A function of the state that gives documents to write beside those of the devices, as a map of name to body, e.g. a list for other apps to read | none |
A backend, sync!, reconcile and the functions of document also take
the options of the state:
| Option | Meaning | Default |
|---|---|---|
:schema | The schema of a state of mind-meld | none |
:crdt | The :merge, :compact and :problem functions of the state, which go over those of the :schema | none |
:version | The version of the state's format | document/version, which is 1 |
:kind | What state the documents hold, e.g. "my-notes" | none |
:codec | How a document is encoded: a map of the :extension of its name, and the functions that :encode its map as the body and :decode it | document/edn-codec |
A sync needs a :schema, or all three functions of a :crdt. With
either strategy, a retired device writes nothing, and the report has
:sync/retired-device. An extra document is written when it changes, and
its name can't end in the extension of the codec, since it would be read
as a device's document.
Each device writes only its own document, named after its device id,
e.g. phone.edn. A step of sync! with this strategy does the
following:
If the document of this device changed since the last step, another
installation, or another tab of the same page, is using the same device
id, and the report has :sync/shared-device. If the document is gone from
the store, the next push writes it again.
All devices write one document, state.edn by default. A step of sync!
with this strategy does the following:
:if-version set to the version in the cursor.:sync/conflict.If the document is gone from the store, the next push writes it again. A document in a newer format, or of another kind, is left alone, since this device can't read all of it and a put would lose data.
A step of sync! returns this, in ClojureScript as a promise:
{:state merged ; the local state with the pulled states merged
:cursors {"Nextcloud" {…} …} ; by backend name
:at #inst "…" ; the instant of the step
:failures 0 ; the steps in a row in which a backend failed
:report [...]} ; the diagnostics of the step
Besides the options of the state, sync! takes :at, the instant of the
step, which is the current time by default, the :cursors and
:failures of the last step, and :compact, which is false to keep
every tombstone, or the options of the compact function of the CRDT.
:sync/backend-failed, with
its :backend and the :reason.sync! doesn't push to it, and its
cursor stays the same. If a push fails, the backend keeps the cursor of
its pull.:problem of the CRDT finds a problem in the merged state, e.g.
a value that came in through an assoc, the step writes nothing and
reports :sync/malformed-state, since the other devices would leave
out the whole document.:state doesn't include what the user did during the step, so give
the step to finish with the current state. It merges them with the
merge of the CRDT, then compacts at the step's :at, since the merge
brings back the tombstones that the step dropped.Each diagnostic in the report is a map with an :id, a :level and a
:message, and diagnostics/catalogue describes every id.
To do the I/O yourself, e.g. in a background task with a time budget,
fetch the documents whose names end in the extension of the codec, merge
them with reconcile, and put the document that document/for-device
gives.
A document is an EDN map, unless the options give another codec:
{:version 1 ; the version of the state's format
:device "phone" ; the id of the device that wrote it
:written-at #inst "2026-10-10T…" ; when, by the clock of that device
:app "my-app" ; the app that wrote it, optional
:kind "my-notes" ; the kind of state, optional
:state {…}} ; the whole state of that device
:sync/newer-format.:kind leaves out a document of another kind, and
reports :sync/other-kind. A document without a kind is read by any
reader, and a reader without one reads any document. So apps that sync
the same kind of state can share a folder, and apps with kinds of their
own can share one without merging each other's states.:problem of the CRDT
finds a problem in, is left out with :sync/unreadable-document, since
the merge would spread the problem to every device.:sync/clock-skew names its device. The merge
spreads the later time to every device, so the stamps no longer say
when things happened, but nothing is lost, and the states still agree.
Leaving the document out wouldn't help, since its device couldn't sync,
and its own state would keep the later stamps once its clock was fixed.The store of store.encrypted wraps each body in this EDN map:
{:encrypted 1 :cipher "AES-256-GCM" :kdf "PBKDF2-HMAC-SHA256"
:iterations 600000 :salt "…" :iv "…" :data "…"}
:salt, :iv and :data are base64.javax.crypto on the JVM and WebCrypto in ClojureScript, with
the iterations and the salt of the envelope.max-iterations, ten times the default.
A body that asks for more, or whose salt, IV or data isn't base64, isn't
an envelope, and is reported as a document that doesn't read.:except
of the store are written in the clear, e.g. an extra document for other
apps to read.:dk.simongray.like-minded.store.encrypted/locked.| Store | Namespace | Platform | Version token |
|---|---|---|---|
| A map in an atom | store/memory | all | a count of the writes |
| A folder | store.folder | all | the modification time, as exact as the file system keeps it, and the size |
| A WebDAV collection | store.webdav | all | the ETag |
| An IndexedDB database | store.indexeddb | a browser | a count of the writes |
| Any store, encrypted | store.encrypted | all | the version of the wrapped store |
The folder store takes a path on the JVM and in Node. In a browser, it
takes a FileSystemDirectoryHandle, or the files of a file input or a
drop:
showDirectoryPicker or from the origin private
file system. A write goes through createWritable, whose changes reach
the file only when the stream closes (File System, section 2.3.2).:put or
:delete, so a backend over it only pulls. Of a folder loaded with
<input webkitdirectory>, only the files at its top count, and the
folder's name is the store's name.folder/query-permission! and
folder/request-permission! give :granted, :prompt or :denied. A
path, a folder of the origin private file system and the files of an
input give :granted, since there's nothing to ask for. The browser
asks the user only during a click.:type in their ex-data:
::folder/not-allowed for a permission to renew, ::folder/gone for a
folder that was moved, deleted or unplugged, and ::folder/read-only
for a folder that the browser can't write to.The WebDAV store takes its :credentials as a map of a :username and a
:password, or of a :token, or as a function that returns the current
map, so that you can refresh a token that expires. OAuth flows are up to
you, and nothing that a sync returns or reports contains the credentials.
https://user:pass@host/, and its name is the host alone.On the JVM, store.webdav.server/handler serves any store over WebDAV as
a Ring handler:
:list, a PROPFIND that lists the collection gets
:put or no :delete, a PUT or a DELETE gets 405,
and OPTIONS doesn't offer it. A 405 names the allowed methods in
Allow.(dissoc (webdav/store url) :list), and the backend then uses one
shared document.:max-bytes of the handler, and a
larger body gets 413.If-Match: * matches the document if it exists, and gets 412
if it doesn't.. and ..,
get 400.The handler checks no credentials, so mount it behind your login. On a site with many users, give each a handler over the store of their own documents.
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 |