Help with writing a binding to Automerge

I’m trying to write a simple binding to the CRDT library automerge and I’m having some trouble figuring out how to type the change function nicely. I’ve looked around for some other ReScript libraries that use Proxy objects like this, and haven’t found anything, but here is what I have so far:

Playground

module Automerge = {
  module Document = {
    type t<'a> = {..} as 'a
  }

  @module("automerge")
  external make: 'a => Document.t<'a> = "init"

  @module("automerge")
  external change: (Document.t<'a>, @uncurry ('a => unit)) => Document.t<'a> =
    "change"
}
type person = {@set "name": string, @set "phone": string}

let raw = {"name": "Scott", "phone": "000000000"}
let doc1 = raw->Automerge.make // Automerge.Document.t<{ "name": string; "phone": string }>
let doc2 = doc1->Automerge.change(
  %raw(`
function (draft) { // Trying to get this to be a mutable object
  draft["name"] = "Scott Trinh"
  draft["phone"] = "111111111"
}`),
)

let check = doc2["name"] == "Scott Trinh" // true

—I’m also very interested in general feedback on what I have so far in my external binding here.

I don’t think ReScript is suitable for Immer or Autometgecs mutable api. In the case of immer I would drop it’s use in a ReScript project in favour of functional immutable updates. Does something similar exist for automerge?

No, Automerge uses Proxies (like immer…) to create a list of change operations which is what gets saved in the CRDT. There isn’t an immutable interface that uses diffing or something like that. Inlining the JS as change handlers isn’t terrible but it is unfortunate. Is there a way to trick the compiler into treating a local object like a JS object to allow mutability?

I wonder if there is a way to use external to make a function call interface to the mutation calls. Like a kind of macro. Pseudo-code example:

external set: (Automerge.Document.t<'a>, string, 'value) => "$1[$2] = $3"

Or maybe just actually having a function that does this might be the solution?

Hi. Did you consider using records with mutable fields? Here is what I came up with:

module Automerge = {
  module Document = {
    type t<'a>
  }

  external get: Document.t<'a> => 'a = "%identity"

  @module("automerge")
  external make: 'a => Document.t<'a> = "init"

  @module("automerge")
  external change: (Document.t<'a>, @uncurry ('a => unit)) => Document.t<'a> =
    "change"
}
type person = {mutable name: string, mutable phone: string}

let raw = {name: "Scott", phone: "000000000"}
let doc1 = raw->Automerge.make // Automerge.Document.t<{ "name": string; "phone": string }>
let doc2 = doc1->Automerge.change(draft => {
  draft.name = "Scott Trinh"
  draft.phone = "111111111"
})

let check = (doc2->Automerge.get).name == "Scott Trinh" // true

I’m not quite happy about the external get: Document.t<'a> => 'a = "%identity" hack though. If automerge has a function for extracting regular unwrapped object from a document, maybe it’s better to use that instead of %identity. Also, would be good if get would return an immutable object — strip mutable from the type somehow — but I’m not sure how to do that.

1 Like

Hi @scotttrinh, yes a decorator @set_index is available for that:

module Document = {
  type t<'a> = {..} as 'a
  @set_index external setString: (t<'a>, string, string) => unit = ""
}

And you can change you callback to:

let doc2 = doc1->Automerge.change(draft => {
  draft->Automerge.Document.setString("name", "Scott Trinh")
  draft->Automerge.Document.setString("phone", "111111111")
})
1 Like

Oh, this is actually quite good! I’m fine with the hack here, although, as you say it’d be good if it was mutable only within the change callback. I’ll play around with this and see if I can come up with something there to avoid the external record being mutable.

1 Like

Following on here, is there anyway to restrict the value type to be based on the value of the original record/object type at that key? Like in pseudo-code: (t<'a>, 'key, 'a['key]) => unit?

Not that I’m aware of with @set_index.

@rpominov’s solution with the mutable fields looks great, but if you wanted to avoid the mutable fields, then maybe you can use @set_index to enable mutations, e.g.:

module Person = {
  type t = {name: string, phone: string}
  %%private(@set_index external set: (t, string, 'a) => unit = "")
  let setName = (person: t, name: string) => set(person, "name", name)
  let setPhone = (person: t, phone: string) => set(person, "phone", phone)
}

And your callback becomes:

let doc2 = doc1->Automerge.change(draft => {
  draft->Person.setName("Scott Trinh")
  draft->Person.setPhone("111111111")
})
1 Like

Looks like there is a @set/@get decorator that does exactly this! :tada: Having to define the getter and setter in the binding is an unfortunate amount of Java-esque boilerplate, but I’m happy with the output and the level of type safety here!

Here is where I ended up:

Playground

module Automerge = {
  module Document = {
    type t<'a>
  }

  @module("automerge")
  external make: 'a => Document.t<'a> = "init"

  @module("automerge")
  external change: (Document.t<'a>, @uncurry ('a => unit)) => Document.t<'a> =
    "change"

  external get: Document.t<'a> => 'a = "%identity"
}

module Person = {
  type t = {name: string, age: int}
  @set external setName: (t, string) => unit = "name"
  @get external getName: unit => string = "name"

  @set external setAge: (t, int) => unit = "age"
  @get external getAge: unit => int = "age"
}

let raw: Person.t = {name: "Scott", age: 38}
let doc1 = raw->Automerge.make // Automerge.Document.t<{ "name": string; "phone": string }>
let doc2 = doc1->Automerge.change(draft => {
  draft->Person.setName("Scott Trinh")
  draft->Person.setAge(39)
})

let check = Automerge.get(doc2).name == "Scott Trinh" // true

Edit: Had a stray bit of old person type defined :see_no_evil:

1 Like

Are you sure this is working? It seems to be pulling a string out of thin air?

2 Likes

You can look at the JS output in the Playground link I posted there. Seems to be doing the correct thing. Also seems to match the documentation that I linked to which looks like this:

type textarea
@set external setName: (textarea, string) => unit = "name"
@get external getName: textarea => string = "name"

I think the “string out of thin air” is actually relative to the type passed as the first argument in the getter/setter, so it’s not quite as weird as it seems. It definitely seems to be prone to error (you can pass any string at all in the = "notARealKey") but this is a binding, so it’s going to be a bit unsafe anyway.

I just checked. The getters are definitely not working as intended. Try this in the playground:

let test = () => {
  let name = Person.getName()
}

I would recommend just getting rid of the getters. The Person.t type is a record anyway, you can access its fields directly.

1 Like

Ahh, good catch, there was a typo: The type signature should be t => string not unit => string. Updated Playground

But anyway, good point that the getters are redundant on the record type! Here’s the updated code without the extra getters defined:
Playground

module Automerge = {
  module Document = {
    type t<'a>
  }

  @module("automerge")
  external make: 'a => Document.t<'a> = "init"

  @module("automerge")
  external change: (Document.t<'a>, @uncurry ('a => unit)) => Document.t<'a> =
    "change"

  external get: Document.t<'a> => 'a = "%identity"
}

module Person = {
  type t = {name: string, age: int}

  @set external setName: (t, string) => unit = "name"
  @set external setAge: (t, int) => unit = "age"
}

let raw: Person.t = {name: "Scott", age: 38}
let doc1 = raw->Automerge.make // Automerge.Document.t<{ "name": string; "phone": string }>
let doc2 = doc1->Automerge.change(draft => {
  draft->Person.setName("Scott Trinh")
  draft->Person.setAge(39)
})

let check = Automerge.get(doc2).name == "Scott Trinh" // true
1 Like

The setters aren’t necessary either if you use mutable record fields:

type t = {mutable name: string, mutable age: int}

let x = {name: "bob", age: 10}
x.age = 11
2 Likes

Yeah, that’s a good point, too. I want to discourage the mutability of these fields outside of the Automerge change callback, but this doesn’t really do anything to enforce that. I wonder if there is some way to make a functor or something that defines the mutable interface on the base type and uses that type in the change callback instead. I’ll give that some thought, but feel free to suggest something! I’m still prettty new to ReScript.