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 466 forks source link

Automerge & JSON Patch (RFC-6902) #286

Open adorsk opened 3 years ago

adorsk commented 3 years ago

First of all, thank you to the authors & maintainers of automerge. I'm really glad this project exists, and it's been very helpful to me for a recent project.

I wanted to share some code that I wrote recently, to adapt automerge changes to/from JSON patches. For me this was useful for using automerge in conjunction with mobx-state-tree . Perhaps it will be useful for someone else?

import Automerge from 'automerge'
import { get } from 'lodash'

export function getChangesAsJsonPatches ({oldDoc, newDoc}) {
  const patches = []
  const changes = Automerge.diff(oldDoc, newDoc)
  // We keep a registry of newObjects, in order to combine 'create' and 'set' actions into a single patch.
  // The assumption here is that newObjects are always registered before the changes that reference them.
  const newObjects = {}
  function _resolveValue (change) {
    return change.link ? newObjects[change.value] : change.value
  }
  function _genPathStr (change) {
    return [
      '',
      ...change.path,
      ((change.type === 'map') ? change.key : change.index)
    ].join('/')
  }
  for (const change of changes) {
    if (change.action === 'create') {
      let value
      if (change.type === 'map') {
        value = {}
      } else if (change.type === 'list') {
        value = []
      }
      newObjects[change.obj] = value
      continue
    }
    if (change.action === 'remove') {
      if (change.path === null) {
        if (change.type === 'map') {
          delete newObjects[change.obj][change.key]
        } else if (change.type === 'list') {
          newObjects[change.obj].splice(change.index, 1)
        }
        continue
      }
      if (change.type === 'map') {
        // This line bit is here because Automerge currently creates 'remove' changes for map objects
        // that don't necessarily get added to the change doc (see test below).
        const canIgnore = get(oldDoc, [...change.path, change.key]) === undefined
        if (canIgnore) { continue }
        patches.push({op: 'remove', path: _genPathStr(change)})
        continue
      }
      if (change.type === 'list') {
        patches.push({op: 'remove', path: _genPathStr(change)})
        continue
      }
    }
    if (change.action === 'set') {
      if (change.path === null) {
        newObjects[change.obj][change.key] = _resolveValue(change)
        continue
      }
      patches.push({
        op: change.link ? 'add' : 'replace',
        path: _genPathStr(change),
        value: _resolveValue(change),
      })
      continue
    }
    if (change.action === 'insert') {
      if (change.path === null) {
        newObjects[change.obj].splice(change.index, 0, _resolveValue(change))
        continue
      }
      patches.push({
        op: 'add',
        path: _genPathStr(change),
        value: _resolveValue(change),
      })
      continue
    }
  }
  return patches
}

export function applyJsonPatchToAutomergeDoc ({patch, doc}) {
  const { op, path, value } = patch
  const validOps = new Set(['add', 'replace', 'remove'])
  if (!validOps.has(op)) {
    throw new Error(`Invalid op '${op}'`)
  }
  const pathParts = path.split('/').slice(1)
  let currentParent = doc
  let currentPathPart
  for (let i = 0; i < pathParts.length; i++) {
    currentPathPart = pathParts[i]
    if (Array.isArray(currentParent)) {
      currentPathPart = parseInt(currentPathPart, 10)
    }
    if (i < pathParts.length - 1) {
      currentParent = currentParent[currentPathPart]
    }
  }
  if (Array.isArray(currentParent)) {
    if (op === 'add') {
      currentParent.insertAt(currentPathPart, value)
    } else if (op === 'remove') {
      currentParent.deleteAt(currentPathPart, 1)
    } else if (op === 'replace') {
      currentParent[currentPathPart] = value
    }
  } else { // assume map by default
    if (op === 'add' || op === 'replace') {
      currentParent[currentPathPart] = value
    } else if (op === 'remove') {
      delete currentParent[currentPathPart]
    }
  }
}

And a test for getChangesAsJsonPatches...

import Automerge from 'automerge'
import { applyPatch } from 'fast-json-patch'

import { getChangesAsJsonPatches } from './utils'

describe('utils', () => {
  test('getChangesAsJsonPatches', () => {
    const oldDoc = Automerge.from({
      existingMap: {
        foo: 'bar',
        pie: 'cherry',
      },
      existingList: [0, 1, 2],
    })
    const newDoc = Automerge.change(oldDoc, (doc) => {
      doc.existingMap.foo = 'bar.new'
      delete doc.existingMap.pie

      doc.newMap = {
        subMap: {
          subMapKey: 'valueForSubMapKey',
          doomedKey: 'valueForDoomedKey',
        }
      }
      delete doc.newMap.subMap.doomedKey

      // Note: Automerge outputs a change to delete 'doomedMap', but does not output a change to add 'doomedMap'.
     // This is why we have the 'canIgnore' bit in the code above, to see if 'remove' changes affect
     // parts of the original doc.
      doc.doomedMap = {destiny: 'doom!'}
      delete doc.doomedMap

      doc.existingMap.doomedMap = {destiny: 'existingDoom!'}
      delete doc.existingMap.doomedMap

      doc.existingList[1] = 'existingList[1].new'
      doc.existingList.deleteAt(2)
      doc.existingList.push('existingList.pushed')

      doc.newList = []
      doc.newList.push([['newList[0][0]']])
      doc.newList.push('newList[1].orig')
      doc.newList.insertAt(1, 'newList[1].inserted')
      doc.newList.deleteAt(0)
      doc.newList[1] = 'newList[1].set'

      doc.doomedList = ['doomed!']
      delete doc.doomedList
    })
    const actualPatches = getChangesAsJsonPatches({oldDoc, newDoc})
    const _clone = (obj) => JSON.parse(JSON.stringify(obj))
    const patchedOldDoc = applyPatch(_clone(oldDoc), _clone(actualPatches)).newDocument
    expect(patchedOldDoc).toEqual(newDoc)
  })
})
ept commented 3 years ago

Thank you @adorsk, this looks interesting. Pinging @pvh @geoffreylitt @orionz who recently used JSON Patch and Automerge in Cambria, in case it is relevant.