automerge / automerge-classic

A JSON-like data structure (a CRDT) that can be modified concurrently by different users, and merged again automatically.
http://automerge.org/
MIT License
14.75k stars 467 forks source link

Experimental observable API #308

Closed ept closed 3 years ago

ept commented 3 years ago

There have been requests for an observer-style API, where an application's callback function gets called whenever a certain part of an Automerge document changes. For example, this is useful when integrating a text editing widget with Automerge, where the widget state needs to be updated whenever the Automerge document changes (especially as a result of remote changes arriving from the network).

This PR is based on #288, allowing hooking into patch application, but based on the performance branch rather than main. Moreover, I've extended the API with a new class Automerge.Observable, which allows observing individual objects as well as the entire document. The callback is given a description of the change in the Automerge patch format (including, for example, a description of which elements to insert or delete in a list or text object), as well as the before and after state of the object, and a flag indicating whether the change is the result of a local call to Automerge.change or whether it came form Automerge.applyChanges (typically a remote change).

I made Observable a separate class because an Automerge document is immutable, and so it doesn't make much sense to register observers on it directly. On the other hand, the Observable class is mutable, allowing observers to be added.

Basic usage example:

let observable = new Automerge.Observable()
let doc = Automerge.from({text: new Automerge.Text()}, {observable})
observable.observe(doc.text, (diff, before, after, local, changes) => {
  // diff.edits is an array of insert or delete actions
  // diff.props contains any updated values in this object
  // before is the state of the object before this update
  // after is the state of the object after this update
  // local is true if this callback is due to a local change
  // changes is an array of binary changes that were applied
})

// Each of these calls the callback above (if the text is changed)
doc = Automerge.change(doc, doc => doc.text.insertAt(0, 'a', 'b', 'c'))
doc = Automerge.applyChanges(doc, changesFromRemoteUser)

You can pass an observable option to Automerge.init(), Automerge.from(), or Automerge.load(). See the tests for some more examples of how to use this API.

Observable is based on a slightly lower-level API (which allows observing only an entire document, not individual objects within it) called patchCallback:

let doc = Automerge.init({patchCallback: (patch, before, after, local, changes) => {
  // as before
}})
doc = Automerge.change(doc, doc => { /* ... */ })
pvh commented 3 years ago

This is tremendously exciting! I'll try and build a little something on it ASAP.

echarles commented 3 years ago

Thx @ept. This works great for me. What about providing also the change to the callback on top of the existing (diff, before, after, local)?

ept commented 3 years ago

@echarles Added the binary changes as a fifth callback argument. (It's an array of changes since you can call Automerge.applyChanges() with a whole bunch of changes in one go, and you will receive one observer callback for the whole batch.)

ept commented 3 years ago

I have merged this since it seems to be useful, and doesn't require any coordination with the Rust implementation. However, I marked the API as experimental, and noted in the changelog that this means the API may change in incompatible ways without a major version bump. This gives us the freedom to revise the API if we think of a better one, even once it is baked into a 1.0 release.

echarles commented 3 years ago

Thx @ept This is really useful and needed for our case. Happy to see this experimental API evolve/change based on users' feedbacks.