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)
})
})
Thank you @adorsk, this looks interesting. Pinging @pvh @geoffreylitt @orionz who recently used JSON Patch and Automerge in Cambria, in case it is relevant.
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?
And a test for
getChangesAsJsonPatches
...