Description
At the moment Automerge only provides an API for an in-memory data structure, and leaves all I/O (persistence on disk and network communication) as an "exercise for the reader". The sync protocol attempts to provide an API for network sync between two nodes (without assuming any particular transport protocol), but experience has shown that users find the sync protocol difficult to understand, and easy to misuse (e.g. knowing when to reset the sync state is quite subtle; sync with more than one peer is also error-prone).
I would like the propose a new API concept for Automerge, which we might call "repository" (or "database"?). It should have the following properties:
- A repository is not tied to any particular storage or networking technology, but it should provide interfaces that make it easy to plug in storage (e.g. embedded DB like IndexedDB or SQLite, remote DB like Postgres or MongoDB, or local filesystem) and network transport (e.g. WebSocket, WebRTC, IPFS/libp2p, plain HTTP) libraries. These should be able to interoperate across platforms, so you can have e.g. an iOS app (using local filesystem storage) syncing its state with a server written in Python (which stores its state in Postgres). We should provide some integrations for commonly used protocols, but also allow customisation.
- The basic data model of storage should read and write byte arrays corresponding to either changes or compressed documents. The repository should automatically determine when to take a log of changes and compact it into a document. The repository may also choose to create and maintain indexes for faster reads.
- The basic model of networking is either a one-to-one connection-oriented link between two nodes running the sync protocol (e.g. WebSocket, WebRTC), or a best-effort multicast protocol (e.g. BroadcastChannel for tabs within the same browser, a pub-sub messaging system, or a gossip protocol). Multiple protocols may be used at the same time (e.g. gossip/pub-sub for low-latency updates, and one-to-one sync to catch up on updates that a node missed it was offline).
- A repository is a collection of many Automerge documents, and all the storage and networking adapters should be multi-document out of the box. The collection of docs in a repo might be too big to load into memory. A repository should be able to efficiently sync the entire collection without loading it into memory: only reading/writing the necessary docs and changes in the storage layer, and sending them over the network in the form they are stored, without instantiating all the in-memory CRDT data structures for those documents.
- The application should be able to choose which documents in the collection to load into memory, and when to free them again. Even if we rely on APIs such as WeakRefs to free Wasm memory when it is no longer needed, it probably makes sense for the application to explicitly signal when to load and free a document: in JS, loading would be async (since e.g. IndexedDB APIs are async), and so any function that might need to load a document would also have to be async. If the app specifies when to load and free a document, then the API for accessing a loaded document can be sync.
- There should be an API where applications can plug in their access control policy, so that for example, they sync the entire repository contents when talking to another device belonging to the same user, but only sync specifically shared documents when talking to another user. The policy also determines from which remote nodes changes should be accepted, and from which they are ignored.
- The repository should also provide a way of communicating ephemeral state, such as user presence (who is currently online), cursor positions, and position updates during drag & drop (notifying users every time a dragged object is moved by a pixel, many times per second). This is information that does not need to be persisted in the CRDT, but it does need to be sent to users who are currently online, and since we're already managing the network communication in the repository, it makes sense to also include the channel for ephemeral updates here.
- The repository also provides a good place to register observers (callback functions that are invoked when a document changes). The current observable API is strange because you register the observer on a document, which is an immutable object, and it doesn't really make sense to observe something that is immutable. It makes much more sense for the callback to be registered on the repository.
Some thoughts on what the repository API might look like:
- When the app starts up, create a
repository
object and register the storage library you want to use. When any sort of network link is established, you also register it with the repository; when it disconnects, it automatically unregisters itself from the repository. The repository object is typically a singleton that exists for the lifetime of the app process. - To create a document, instead of calling
Automerge.init()
you would callrepository.create()
. The new document would automatically be given a unique docId. - To load an existing document, instead of calling
Automerge.load()
and passing in a byte array, you would callawait repository.load(docId)
, which loads the document with the given docId from the registered storage library. - Instead of calling
Automerge.change(doc, callback)
to make a change, callrepository.change(docId, callback)
, which automatically writes the new change to persistent storage and sends it via any network links that are registered on the repository. The callback can be identical to the current Automerge API. - A loaded document is an immutable object, just like in the current API. Updates to a document result in a new document object that structure-shares any unchanged parts with the previous object. This allows the document to play nicely with React. The current state of a loaded document is available through e.g.
repository.get(docId)
. - Instead of handling incoming changes from another user in application code and calling
Automerge.applyChanges()
to update the document, the repository automatically receives incoming changes via its registered network links. The application should register an observer, e.g. usingrepository.observeChanges(callback)
, to re-render the UI whenever a document changes. - We remove the current sync protocol API, and fold its functionality into the repository and its network interface instead. We might keep the current
getChanges
/applyChanges
API for potential advanced use cases that are not satisfied by the repository API, but the expectation would be that most app developers use the repository API.
Need to do some further thinking on what the APIs for storage and networking interfaces should look like.
One inspiration for this work is @localfirst/state, but I envisage the proposed repository API having a deeper integration between storage and sync protocol than is possible with the current Automerge API, in order to make sync of large document sets as efficient as possible.
Feedback on this high-level outline very welcome. If it seems broadly sensible, we can start designing the APIs in more detail.
Activity