Syncing a state of mind-meld between a user's devices, through any number of backends.
A backend is a map of two functions and a name:
{:pull (fn [state cursor at]) ; the states of the other devices
:push (fn [state cursor at]) ; writes the merged state
:name "Nextcloud"}
A pull returns a map of the :states to merge, the next :cursor and a :report. It has :partial? when the states are changes since the cursor rather than whole states. A push returns the next :cursor and a :report, and a :state when it learned more while writing. Both take the instant of the sync and never read a clock. The function backend makes a backend of any store of dk.simongray.like-minded.store.
The options of the state say what the state is. Give the same ones to a backend, to sync!, to reconcile and to the functions of dk.simongray.like-minded.document:
A sync needs a :schema, or all three functions of a :crdt.
Syncing a state of mind-meld between a user's devices, through any
number of backends.
A backend is a map of two functions and a name:
{:pull (fn [state cursor at]) ; the states of the other devices
:push (fn [state cursor at]) ; writes the merged state
:name "Nextcloud"}
A pull returns a map of the :states to merge, the next :cursor and a
:report. It has :partial? when the states are changes since the cursor
rather than whole states. A push returns the next :cursor and a
:report, and a :state when it learned more while writing. Both take the
instant of the sync and never read a clock. The function backend makes
a backend of any store of dk.simongray.like-minded.store.
The options of the state say what the state is. Give the same ones to a
backend, to sync!, to reconcile and to the functions of
dk.simongray.like-minded.document:
- :schema, the schema of a state of mind-meld, whose functions merge it
- :crdt, the :merge, :compact and :problem functions of another CRDT,
which go over those of a :schema
- :version, the version of the state's format, document/version by
default
- :kind, what state the documents hold, e.g. "my-app/notes", so that a
document of another kind is left out
- :codec, how a document is encoded, document/edn-codec by default
A sync needs a :schema, or all three functions of a :crdt.Every diagnostic that a sync can put in a report, by id, with its level and what it means.
A diagnostic is a map with an :id, a :level and a :message, and other keys give details, such as the :name of a document. The ids are API, since apps match on them. An app that has a catalogue of its own can add this one to it.
Every diagnostic that a sync can put in a report, by id, with its level and what it means. A diagnostic is a map with an :id, a :level and a :message, and other keys give details, such as the :name of a document. The ids are API, since apps match on them. An app that has a catalogue of its own can add this one to it.
The documents of a sync: their names, and the envelope around the state of a device, encoded and decoded with the options of the state.
An envelope is a map like this:
{:version 1 ; of the state's format
:device "8f1c9e2a-…" ; the device that wrote it
:written-at #inst "2026-09-26T…" ; by the clock of that device
:app "my-app" ; the app that wrote it, optional
:kind "my-app/notes" ; the kind of state, optional
:state {…}} ; the whole state of that device
The options of the state are those of dk.simongray.like-minded.
The documents of a sync: their names, and the envelope around the state
of a device, encoded and decoded with the options of the state.
An envelope is a map like this:
{:version 1 ; of the state's format
:device "8f1c9e2a-…" ; the device that wrote it
:written-at #inst "2026-09-26T…" ; by the clock of that device
:app "my-app" ; the app that wrote it, optional
:kind "my-app/notes" ; the kind of state, optional
:state {…}} ; the whole state of that device
The options of the state are those of dk.simongray.like-minded.Document stores, which keep named documents with versions, e.g. the sync documents of a user's devices, or anything else an app saves.
A store is a map of functions over named documents with versions:
{:fetch (fn [name opts]) ; {:body "…" :version "…"}, or nil
:put (fn [name body opts]) ; {:version "…"}, or {:conflict? true}
:list (fn []) ; [{:name "…" :version "…"}], optional
:delete (fn [name]) ; optional
:name "Nextcloud"} ; for the UI and the report
A body is text or bytes, a byte array on the JVM and a Uint8Array in JavaScript. A fetch gives it as text, or as bytes with :as :bytes in its opts, whichever was put. A put is a conflict when its opts have an :if-version other than the store's, where nil means that the document must not exist yet. A store that can't be written, e.g. over the files of an input, has no :put.
On the JVM, each function returns its value and throws when it fails. In ClojureScript, each can return a value or a promise. A store closes over its credentials, so they're never in the map, and you wrap a store by updating a key.
Document stores, which keep named documents with versions, e.g. the
sync documents of a user's devices, or anything else an app saves.
A store is a map of functions over named documents with versions:
{:fetch (fn [name opts]) ; {:body "…" :version "…"}, or nil
:put (fn [name body opts]) ; {:version "…"}, or {:conflict? true}
:list (fn []) ; [{:name "…" :version "…"}], optional
:delete (fn [name]) ; optional
:name "Nextcloud"} ; for the UI and the report
A body is text or bytes, a byte array on the JVM and a Uint8Array in
JavaScript. A fetch gives it as text, or as bytes with :as :bytes in its
opts, whichever was put. A put is a conflict when its opts have an
:if-version other than the store's, where nil means that the document
must not exist yet. A store that can't be written, e.g. over the files
of an input, has no :put.
On the JVM, each function returns its value and throws when it fails. In
ClojureScript, each can return a value or a promise. A store closes over
its credentials, so they're never in the map, and you wrap a store by
updating a key.Any document store, with its documents encrypted at rest.
A server or a synced folder then holds only ciphertext, and nothing else changes. Names and versions pass through, and bodies are sealed on the way in and opened on the way out.
A body is sealed with AES-256-GCM under a key derived from the passphrase by PBKDF2 with HMAC-SHA256. Each document is a small EDN envelope with the salt, IV and iteration count that open it, so there's no key file to lose:
{:encrypted 1 :cipher "AES-256-GCM" :kdf "PBKDF2-HMAC-SHA256"
:iterations 600000 :salt "…" :iv "…" :data "…"}
A body that isn't an envelope comes back as it is, so you can turn on encryption over a folder that already holds plain documents. A passphrase that doesn't open a document makes that call of the store fail with ::locked.
Since WebCrypto is asynchronous, the functions here and those of the store return promises in ClojureScript. Each platform opens the envelopes of the other.
Any document store, with its documents encrypted at rest.
A server or a synced folder then holds only ciphertext, and nothing else
changes. Names and versions pass through, and bodies are sealed on the
way in and opened on the way out.
A body is sealed with AES-256-GCM under a key derived from the passphrase
by PBKDF2 with HMAC-SHA256. Each document is a small EDN envelope with
the salt, IV and iteration count that open it, so there's no key file to
lose:
{:encrypted 1 :cipher "AES-256-GCM" :kdf "PBKDF2-HMAC-SHA256"
:iterations 600000 :salt "…" :iv "…" :data "…"}
A body that isn't an envelope comes back as it is, so you can turn on
encryption over a folder that already holds plain documents. A
passphrase that doesn't open a document makes that call of the store
fail with ::locked.
Since WebCrypto is asynchronous, the functions here and those of the
store return promises in ClojureScript. Each platform opens the
envelopes of the other.A folder as a document store, e.g. one that a sync client mirrors.
The folder can be a Git working tree, or a folder of iCloud Drive, Dropbox, Google Drive, OneDrive, Seafile, Syncthing or Nextcloud. Each device writes only its own file, so the sync client has no conflicts to resolve. The folder is one of these:
A body is kept as it was put, text or bytes, and a fetch with {:as :bytes} reads it as bytes.
In a browser that can't pick a folder, the user can still sync through one by hand: save the device's sync document into the folder, and load the folder's files with a file input. A browser's permission for a picked folder can lapse between visits, so check it before a sync, and renew it from a click.
A file is written whole and replaces the old one, so a sync client never sees half of one. The version of a file is its modification time and size, which tells two writes apart as finely as the file system keeps time. In a browser, the failures that the user can fix have a :type in their ex-data:
A folder as a document store, e.g. one that a sync client mirrors.
The folder can be a Git working tree, or a folder of iCloud Drive,
Dropbox, Google Drive, OneDrive, Seafile, Syncthing or Nextcloud. Each
device writes only its own file, so the sync client has no conflicts to
resolve. The folder is one of these:
- a path, on the JVM and in Node
- a FileSystemDirectoryHandle in a browser, e.g. a folder that the
user picked with showDirectoryPicker, or the site's own folder of
the origin private file system, from navigator.storage.getDirectory(),
for what an app keeps on the device
- the files of a file input or a drop in a browser, which are read only
A body is kept as it was put, text or bytes, and a fetch with
{:as :bytes} reads it as bytes.
In a browser that can't pick a folder, the user can still sync
through one by hand: save the device's sync document into the folder,
and load the folder's files with a file input. A browser's permission
for a picked folder can lapse between visits, so check it before a
sync, and renew it from a click.
A file is written whole and replaces the old one, so a sync client never
sees half of one. The version of a file is its modification time and
size, which tells two writes apart as finely as the file system keeps
time. In a browser, the failures that the user can fix have a :type
in their ex-data:
- ::not-allowed, a permission to renew
- ::gone, a folder that was moved, deleted or unplugged
- ::read-only, a folder that the browser can't write toA document store in the browser's IndexedDB, for what an app keeps on the device, e.g. its state and a search index.
The documents of a store are in a database of its own, by name. A body is kept as it was put, text or bytes, and a version counts the writes of a document. A put with :if-version checks the version and writes in one transaction, and a listing reads the versions without the bodies.
A browser may clear the storage of a site when the device runs out of room, unless the site has navigator.storage.persist(), so keep there what can be made again. The store is in ClojureScript only, and each of its functions gives a promise. A put that the browser has no room for fails with ::full.
The store follows the Indexed Database API 3.0 of the W3C, https://www.w3.org/TR/IndexedDB/
A document store in the browser's IndexedDB, for what an app keeps on the device, e.g. its state and a search index. The documents of a store are in a database of its own, by name. A body is kept as it was put, text or bytes, and a version counts the writes of a document. A put with :if-version checks the version and writes in one transaction, and a listing reads the versions without the bodies. A browser may clear the storage of a site when the device runs out of room, unless the site has navigator.storage.persist(), so keep there what can be made again. The store is in ClojureScript only, and each of its functions gives a promise. A put that the browser has no room for fails with ::full. The store follows the Indexed Database API 3.0 of the W3C, https://www.w3.org/TR/IndexedDB/
A WebDAV collection (RFC 4918) as a document store, e.g. on Nextcloud, ownCloud, Seafile, Synology, Box, Fastmail or a Hetzner storage box.
It uses GET, PUT, DELETE and a PROPFIND at depth one, with the ETag as the version. If-Match makes a put conditional, and If-None-Match: * asks for a document that isn't there yet. HEAD asks for the ETag when a server answers a put without one, and MKCOL makes the collection when it isn't there.
In a browser, the server must let the page through CORS and expose the ETag.
A WebDAV collection (RFC 4918) as a document store, e.g. on Nextcloud, ownCloud, Seafile, Synology, Box, Fastmail or a Hetzner storage box. It uses GET, PUT, DELETE and a PROPFIND at depth one, with the ETag as the version. If-Match makes a put conditional, and If-None-Match: * asks for a document that isn't there yet. HEAD asks for the ETag when a server answers a put without one, and MKCOL makes the collection when it isn't there. In a browser, the server must let the page through CORS and expose the ETag.
Any document store served over WebDAV (RFC 4918), for a Clojure backend whose browser clients sync through their own origin.
The server answers these methods, with ETags and preconditions:
A client of a store without :put, :list or :delete leaves the same keys out of its WebDAV store.
Any document store served over WebDAV (RFC 4918), for a Clojure backend whose browser clients sync through their own origin. The server answers these methods, with ETags and preconditions: - GET and HEAD, for a document - PUT, for a document, when the store has :put - DELETE, for a document, when the store has :delete - PROPFIND at depth zero or one, with a listing of the collection when the store has :list - OPTIONS, so that a client can ask which methods there are - MKCOL, with a 405, since the collection is already there A client of a store without :put, :list or :delete leaves the same keys out of its WebDAV store.
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 |