automerge/javascript/test/legacy_tests.ts
Alex Good 1e33c9d9e0 Use Automerge::load instead of load_incremental if empty
Problem: when running the sync protocol for a new document the API
requires that the user create an empty document and then call
`receive_sync_message` on that document. This results in the OpObserver
for the new document being called with every single op in the document
history. For documents with a large history this can be extremely time
consuming, but the OpObserver doesn't need to know about all the hidden
states.

Solution: Modify `Automerge::load_with` and
`Automerge::apply_changes_with` to check if the document is empty before
applying changes. If the document _is_ empty then we don't call the
observer for every change, but instead use
`automerge::observe_current_state` to notify the observer of the new
state once all the changes have been applied.
2023-02-03 10:01:12 +00:00

1874 lines
68 KiB
TypeScript

import * as assert from "assert"
import { unstable as Automerge } from "../src"
import { assertEqualsOneOf } from "./helpers"
import { decodeChange } from "./legacy/columnar"
const UUID_PATTERN = /^[0-9a-f]{32}$/
const OPID_PATTERN = /^[0-9]+@([0-9a-f][0-9a-f])*$/
// CORE FEATURES
//
// TODO - Cursors
// TODO - Tables
// TODO - on-pass load() & reconstruct change from opset
// TODO - micro-patches (needed for fully hydrated object in js)
// TODO - valueAt(heads) / GC
//
// AUTOMERGE UNSUPPORTED
//
// TODO - patchCallback
describe("Automerge", () => {
describe("initialization ", () => {
it("should initially be an empty map", () => {
const doc = Automerge.init()
assert.deepStrictEqual(doc, {})
})
it("should allow instantiating from an existing object", () => {
const initialState = { birds: { wrens: 3, magpies: 4 } }
const doc = Automerge.from(initialState)
assert.deepStrictEqual(doc, initialState)
})
it("should allow merging of an object initialized with `from`", () => {
let doc1 = Automerge.from({ cards: [] })
let doc2 = Automerge.merge(Automerge.init(), doc1)
assert.deepStrictEqual(doc2, { cards: [] })
})
it("should allow passing an actorId when instantiating from an existing object", () => {
const actorId = "1234"
let doc = Automerge.from({ foo: 1 }, actorId)
assert.strictEqual(Automerge.getActorId(doc), "1234")
})
it("accepts an empty object as initial state", () => {
const doc = Automerge.from({})
assert.deepStrictEqual(doc, {})
})
it("accepts an array as initial state, but converts it to an object", () => {
// @ts-ignore
const doc = Automerge.from(["a", "b", "c"])
assert.deepStrictEqual(doc, { "0": "a", "1": "b", "2": "c" })
})
it("accepts strings as initial values, but treats them as an array of characters", () => {
// @ts-ignore
const doc = Automerge.from("abc")
assert.deepStrictEqual(doc, { "0": "a", "1": "b", "2": "c" })
})
it("ignores numbers provided as initial values", () => {
// @ts-ignore
const doc = Automerge.from(123)
assert.deepStrictEqual(doc, {})
})
it("ignores booleans provided as initial values", () => {
// @ts-ignore
const doc1 = Automerge.from(false)
assert.deepStrictEqual(doc1, {})
// @ts-ignore
const doc2 = Automerge.from(true)
assert.deepStrictEqual(doc2, {})
})
})
describe("sequential use", () => {
let s1: Automerge.Doc<any>, s2: Automerge.Doc<any>
beforeEach(() => {
s1 = Automerge.init("aabbcc")
})
it("should not mutate objects", () => {
s2 = Automerge.change(s1, doc => (doc.foo = "bar"))
assert.strictEqual(s1.foo, undefined)
assert.strictEqual(s2.foo, "bar")
})
it("changes should be retrievable", () => {
const change1 = Automerge.getLastLocalChange(s1)
s2 = Automerge.change(s1, doc => (doc.foo = "bar"))
const change2 = Automerge.getLastLocalChange(s2)
assert.strictEqual(change1, undefined)
const change = Automerge.decodeChange(change2!)
assert.deepStrictEqual(change, {
actor: change.actor,
deps: [],
seq: 1,
startOp: 1,
hash: change.hash,
message: null,
time: change.time,
ops: [
{ obj: "_root", key: "foo", action: "makeText", pred: [] },
{
action: "set",
elemId: "_head",
insert: true,
obj: "1@aabbcc",
pred: [],
value: "b",
},
{
action: "set",
elemId: "2@aabbcc",
insert: true,
obj: "1@aabbcc",
pred: [],
value: "a",
},
{
action: "set",
elemId: "3@aabbcc",
insert: true,
obj: "1@aabbcc",
pred: [],
value: "r",
},
],
})
})
it("should not register any conflicts on repeated assignment", () => {
assert.strictEqual(Automerge.getConflicts(s1, "foo"), undefined)
s1 = Automerge.change(s1, "change", doc => (doc.foo = "one"))
assert.strictEqual(Automerge.getConflicts(s1, "foo"), undefined)
s1 = Automerge.change(s1, "change", doc => (doc.foo = "two"))
assert.strictEqual(Automerge.getConflicts(s1, "foo"), undefined)
})
describe("changes", () => {
it("should group several changes", () => {
s2 = Automerge.change(s1, "change message", doc => {
doc.first = "one"
assert.strictEqual(doc.first, "one")
doc.second = "two"
assert.deepStrictEqual(doc, {
first: "one",
second: "two",
})
})
assert.deepStrictEqual(s1, {})
assert.deepStrictEqual(s2, { first: "one", second: "two" })
})
it("should freeze objects if desired", () => {
s1 = Automerge.init({ freeze: true })
s2 = Automerge.change(s1, doc => (doc.foo = "bar"))
try {
// @ts-ignore
s2.foo = "lemon"
} catch (e) {}
assert.strictEqual(s2.foo, "bar")
let deleted = false
try {
// @ts-ignore
deleted = delete s2.foo
} catch (e) {}
assert.strictEqual(s2.foo, "bar")
assert.strictEqual(deleted, false)
Automerge.change(s2, () => {
try {
// @ts-ignore
s2.foo = "lemon"
} catch (e) {}
assert.strictEqual(s2.foo, "bar")
})
assert.throws(() => {
Object.assign(s2, { x: 4 })
})
assert.strictEqual(s2.x, undefined)
})
it("should allow repeated reading and writing of values", () => {
s2 = Automerge.change(s1, "change message", doc => {
doc.value = "a"
assert.strictEqual(doc.value, "a")
doc.value = "b"
doc.value = "c"
assert.strictEqual(doc.value, "c")
})
assert.deepStrictEqual(s1, {})
assert.deepStrictEqual(s2, { value: "c" })
})
it("should not record conflicts when writing the same field several times within one change", () => {
s1 = Automerge.change(s1, "change message", doc => {
doc.value = "a"
doc.value = "b"
doc.value = "c"
})
assert.strictEqual(s1.value, "c")
assert.strictEqual(Automerge.getConflicts(s1, "value"), undefined)
})
it("should return the unchanged state object if nothing changed", () => {
s2 = Automerge.change(s1, () => {})
assert.strictEqual(s2, s1)
})
it("should ignore field updates that write the existing value", () => {
s1 = Automerge.change(s1, doc => (doc.field = 123))
s2 = Automerge.change(s1, doc => (doc.field = 123))
assert.strictEqual(s2, s1)
})
it("should not ignore field updates that resolve a conflict", () => {
s2 = Automerge.merge(Automerge.init(), s1)
s1 = Automerge.change(s1, doc => (doc.field = 123))
s2 = Automerge.change(s2, doc => (doc.field = 321))
s1 = Automerge.merge(s1, s2)
assert.strictEqual(
Object.keys(Automerge.getConflicts(s1, "field")!).length,
2
)
const resolved = Automerge.change(s1, doc => (doc.field = s1.field))
assert.notStrictEqual(resolved, s1)
assert.deepStrictEqual(resolved, { field: s1.field })
assert.strictEqual(Automerge.getConflicts(resolved, "field"), undefined)
})
it("should ignore list element updates that write the existing value", () => {
s1 = Automerge.change(s1, doc => (doc.list = [123]))
s2 = Automerge.change(s1, doc => (doc.list[0] = 123))
assert.strictEqual(s2, s1)
})
it("should not ignore list element updates that resolve a conflict", () => {
s1 = Automerge.change(s1, doc => (doc.list = [1]))
s2 = Automerge.merge(Automerge.init(), s1)
s1 = Automerge.change(s1, doc => (doc.list[0] = 123))
s2 = Automerge.change(s2, doc => (doc.list[0] = 321))
s1 = Automerge.merge(s1, s2)
assert.deepStrictEqual(Automerge.getConflicts(s1.list, 0), {
[`3@${Automerge.getActorId(s1)}`]: 123,
[`3@${Automerge.getActorId(s2)}`]: 321,
})
const resolved = Automerge.change(s1, doc => (doc.list[0] = s1.list[0]))
assert.deepStrictEqual(resolved, s1)
assert.notStrictEqual(resolved, s1)
assert.strictEqual(Automerge.getConflicts(resolved.list, 0), undefined)
})
it("should sanity-check arguments", () => {
s1 = Automerge.change(s1, doc => (doc.nested = {}))
assert.throws(() => {
// @ts-ignore
Automerge.change({}, doc => (doc.foo = "bar"))
}, /must be the document root/)
assert.throws(() => {
// @ts-ignore
Automerge.change(s1.nested, doc => (doc.foo = "bar"))
}, /must be the document root/)
})
it("should not allow nested change blocks", () => {
assert.throws(() => {
Automerge.change(s1, doc1 => {
Automerge.change(doc1, doc2 => {
// @ts-ignore
doc2.foo = "bar"
})
})
}, /Calls to Automerge.change cannot be nested/)
assert.throws(() => {
s1 = Automerge.change(s1, doc1 => {
s2 = Automerge.change(s1, doc2 => (doc2.two = 2))
doc1.one = 1
})
}, /Attempting to change an outdated document/)
})
it("should not allow the same base document to be used for multiple changes", () => {
assert.throws(() => {
Automerge.change(s1, doc => (doc.one = 1))
Automerge.change(s1, doc => (doc.two = 2))
}, /Attempting to change an outdated document/)
})
it("should allow a document to be cloned", () => {
s1 = Automerge.change(s1, doc => (doc.zero = 0))
s2 = Automerge.clone(s1)
s1 = Automerge.change(s1, doc => (doc.one = 1))
s2 = Automerge.change(s2, doc => (doc.two = 2))
assert.deepStrictEqual(s1, { zero: 0, one: 1 })
assert.deepStrictEqual(s2, { zero: 0, two: 2 })
Automerge.free(s1)
Automerge.free(s2)
})
it("should work with Object.assign merges", () => {
s1 = Automerge.change(s1, doc1 => {
doc1.stuff = { foo: "bar", baz: "blur" }
})
s1 = Automerge.change(s1, doc1 => {
doc1.stuff = Object.assign({}, doc1.stuff, { baz: "updated!" })
})
assert.deepStrictEqual(s1, { stuff: { foo: "bar", baz: "updated!" } })
})
it("should support Date objects in maps", () => {
const now = new Date()
s1 = Automerge.change(s1, doc => (doc.now = now))
let changes = Automerge.getAllChanges(s1)
;[s2] = Automerge.applyChanges(Automerge.init(), changes)
assert.strictEqual(s2.now instanceof Date, true)
assert.strictEqual(s2.now.getTime(), now.getTime())
})
it("should support Date objects in lists", () => {
const now = new Date()
s1 = Automerge.change(s1, doc => (doc.list = [now]))
let changes = Automerge.getAllChanges(s1)
;[s2] = Automerge.applyChanges(Automerge.init(), changes)
assert.strictEqual(s2.list[0] instanceof Date, true)
assert.strictEqual(s2.list[0].getTime(), now.getTime())
})
it("should call patchCallback if supplied", () => {
const callbacks: Array<{
patches: Array<Automerge.Patch>
before: Automerge.Doc<any>
after: Automerge.Doc<any>
}> = []
const s2 = Automerge.change(
s1,
{
patchCallback: (patches, before, after) =>
callbacks.push({ patches, before, after }),
},
doc => {
doc.birds = ["Goldfinch"]
}
)
assert.strictEqual(callbacks.length, 1)
assert.deepStrictEqual(callbacks[0].patches[0], {
action: "put",
path: ["birds"],
value: [],
})
assert.deepStrictEqual(callbacks[0].patches[1], {
action: "insert",
path: ["birds", 0],
values: [""],
})
assert.deepStrictEqual(callbacks[0].patches[2], {
action: "splice",
path: ["birds", 0, 0],
value: "Goldfinch",
})
assert.strictEqual(callbacks[0].before, s1)
assert.strictEqual(callbacks[0].after, s2)
})
it("should call a patchCallback set up on document initialisation", () => {
const callbacks: Array<{
patches: Array<Automerge.Patch>
before: Automerge.Doc<any>
after: Automerge.Doc<any>
}> = []
s1 = Automerge.init({
patchCallback: (patches, before, after) =>
callbacks.push({ patches, before, after }),
})
const s2 = Automerge.change(s1, doc => (doc.bird = "Goldfinch"))
assert.strictEqual(callbacks.length, 1)
assert.deepStrictEqual(callbacks[0].patches[0], {
action: "put",
path: ["bird"],
value: "",
})
assert.deepStrictEqual(callbacks[0].patches[1], {
action: "splice",
path: ["bird", 0],
value: "Goldfinch",
})
assert.strictEqual(callbacks[0].before, s1)
assert.strictEqual(callbacks[0].after, s2)
})
})
describe("emptyChange()", () => {
it("should append an empty change to the history", () => {
s1 = Automerge.change(s1, "first change", doc => (doc.field = 123))
s2 = Automerge.emptyChange(s1, "empty change")
assert.notStrictEqual(s2, s1)
assert.deepStrictEqual(s2, s1)
assert.deepStrictEqual(
Automerge.getHistory(s2).map(state => state.change.message),
["first change", "empty change"]
)
})
it("should reference dependencies", () => {
s1 = Automerge.change(s1, doc => (doc.field = 123))
s2 = Automerge.merge(Automerge.init(), s1)
s2 = Automerge.change(s2, doc => (doc.other = "hello"))
s1 = Automerge.emptyChange(Automerge.merge(s1, s2))
const history = Automerge.getHistory(s1)
const emptyChange = history[2].change
assert.deepStrictEqual(
emptyChange.deps,
[history[0].change.hash, history[1].change.hash].sort()
)
assert.deepStrictEqual(emptyChange.ops, [])
})
})
describe("root object", () => {
it("should handle single-property assignment", () => {
s1 = Automerge.change(s1, "set bar", doc => (doc.foo = "bar"))
s1 = Automerge.change(s1, "set zap", doc => (doc.zip = "zap"))
assert.strictEqual(s1.foo, "bar")
assert.strictEqual(s1.zip, "zap")
assert.deepStrictEqual(s1, { foo: "bar", zip: "zap" })
})
it("should allow floating-point values", () => {
s1 = Automerge.change(s1, doc => (doc.number = 1589032171.1))
assert.strictEqual(s1.number, 1589032171.1)
})
it("should handle multi-property assignment", () => {
s1 = Automerge.change(s1, "multi-assign", doc => {
Object.assign(doc, { foo: "bar", answer: 42 })
})
assert.strictEqual(s1.foo, "bar")
assert.strictEqual(s1.answer, 42)
assert.deepStrictEqual(s1, { foo: "bar", answer: 42 })
})
it("should handle root property deletion", () => {
s1 = Automerge.change(s1, "set foo", doc => {
doc.foo = "bar"
doc.something = null
})
s1 = Automerge.change(s1, "del foo", doc => {
delete doc.foo
})
assert.strictEqual(s1.foo, undefined)
assert.strictEqual(s1.something, null)
assert.deepStrictEqual(s1, { something: null })
})
it("should follow JS delete behavior", () => {
s1 = Automerge.change(s1, "set foo", doc => {
doc.foo = "bar"
})
let deleted: any
s1 = Automerge.change(s1, "del foo", doc => {
deleted = delete doc.foo
})
assert.strictEqual(deleted, true)
let deleted2: any
assert.doesNotThrow(() => {
s1 = Automerge.change(s1, "del baz", doc => {
deleted2 = delete doc.baz
})
})
assert.strictEqual(deleted2, true)
})
it("should allow the type of a property to be changed", () => {
s1 = Automerge.change(s1, "set number", doc => (doc.prop = 123))
assert.strictEqual(s1.prop, 123)
s1 = Automerge.change(s1, "set string", doc => (doc.prop = "123"))
assert.strictEqual(s1.prop, "123")
s1 = Automerge.change(s1, "set null", doc => (doc.prop = null))
assert.strictEqual(s1.prop, null)
s1 = Automerge.change(s1, "set bool", doc => (doc.prop = true))
assert.strictEqual(s1.prop, true)
})
it("should require property names to be valid", () => {
assert.throws(() => {
Automerge.change(s1, "foo", doc => (doc[""] = "x"))
}, /must not be an empty string/)
})
it("should not allow assignment of unsupported datatypes", () => {
Automerge.change(s1, doc => {
assert.throws(() => {
doc.foo = undefined
}, /Unsupported type of value: undefined/)
assert.throws(() => {
doc.foo = { prop: undefined }
}, /Unsupported type of value: undefined/)
assert.throws(() => {
doc.foo = () => {}
}, /Unsupported type of value: function/)
assert.throws(() => {
doc.foo = Symbol("foo")
}, /Unsupported type of value: symbol/)
})
})
})
describe("nested maps", () => {
it("should assign an objectId to nested maps", () => {
s1 = Automerge.change(s1, doc => {
doc.nested = {}
})
Automerge.getObjectId(s1.nested)
assert.strictEqual(
OPID_PATTERN.test(Automerge.getObjectId(s1.nested)!),
true
)
assert.notEqual(Automerge.getObjectId(s1.nested), "_root")
})
it("should handle assignment of a nested property", () => {
s1 = Automerge.change(s1, "first change", doc => {
doc.nested = {}
doc.nested.foo = "bar"
})
s1 = Automerge.change(s1, "second change", doc => {
doc.nested.one = 1
})
assert.deepStrictEqual(s1, { nested: { foo: "bar", one: 1 } })
assert.deepStrictEqual(s1.nested, { foo: "bar", one: 1 })
assert.strictEqual(s1.nested.foo, "bar")
assert.strictEqual(s1.nested.one, 1)
})
it("should handle assignment of an object literal", () => {
s1 = Automerge.change(s1, doc => {
doc.textStyle = { bold: false, fontSize: 12 }
})
assert.deepStrictEqual(s1, {
textStyle: { bold: false, fontSize: 12 },
})
assert.deepStrictEqual(s1.textStyle, { bold: false, fontSize: 12 })
assert.strictEqual(s1.textStyle.bold, false)
assert.strictEqual(s1.textStyle.fontSize, 12)
})
it("should handle assignment of multiple nested properties", () => {
s1 = Automerge.change(s1, doc => {
doc.textStyle = { bold: false, fontSize: 12 }
Object.assign(doc.textStyle, { typeface: "Optima", fontSize: 14 })
})
assert.strictEqual(s1.textStyle.typeface, "Optima")
assert.strictEqual(s1.textStyle.bold, false)
assert.strictEqual(s1.textStyle.fontSize, 14)
assert.deepStrictEqual(s1.textStyle, {
typeface: "Optima",
bold: false,
fontSize: 14,
})
})
it("should handle arbitrary-depth nesting", () => {
s1 = Automerge.change(s1, doc => {
doc.a = { b: { c: { d: { e: { f: { g: "h" } } } } } }
})
s1 = Automerge.change(s1, doc => {
doc.a.b.c.d.e.f.i = "j"
})
assert.deepStrictEqual(s1, {
a: { b: { c: { d: { e: { f: { g: "h", i: "j" } } } } } },
})
assert.strictEqual(s1.a.b.c.d.e.f.g, "h")
assert.strictEqual(s1.a.b.c.d.e.f.i, "j")
})
it("should allow an old object to be replaced with a new one", () => {
s1 = Automerge.change(s1, "change 1", doc => {
doc.myPet = { species: "dog", legs: 4, breed: "dachshund" }
})
let s2 = Automerge.change(s1, "change 2", doc => {
doc.myPet = {
species: "koi",
variety: "紅白",
colors: { red: true, white: true, black: false },
}
})
assert.deepStrictEqual(s1.myPet, {
species: "dog",
legs: 4,
breed: "dachshund",
})
assert.strictEqual(s1.myPet.breed, "dachshund")
assert.deepStrictEqual(s2.myPet, {
species: "koi",
variety: "紅白",
colors: { red: true, white: true, black: false },
})
// @ts-ignore
assert.strictEqual(s2.myPet.breed, undefined)
assert.strictEqual(s2.myPet.variety, "紅白")
})
it("should allow fields to be changed between primitive and nested map", () => {
s1 = Automerge.change(s1, doc => (doc.color = "#ff7f00"))
assert.strictEqual(s1.color, "#ff7f00")
s1 = Automerge.change(
s1,
doc => (doc.color = { red: 255, green: 127, blue: 0 })
)
assert.deepStrictEqual(s1.color, { red: 255, green: 127, blue: 0 })
s1 = Automerge.change(s1, doc => (doc.color = "#ff7f00"))
assert.strictEqual(s1.color, "#ff7f00")
})
it("should not allow several references to the same map object", () => {
s1 = Automerge.change(s1, doc => (doc.object = {}))
assert.throws(() => {
Automerge.change(s1, doc => {
doc.x = doc.object
})
}, /Cannot create a reference to an existing document object/)
assert.throws(() => {
Automerge.change(s1, doc => {
doc.x = s1.object
})
}, /Cannot create a reference to an existing document object/)
assert.throws(() => {
Automerge.change(s1, doc => {
doc.x = {}
doc.y = doc.x
})
}, /Cannot create a reference to an existing document object/)
})
it("should not allow object-copying idioms", () => {
s1 = Automerge.change(s1, doc => {
doc.items = [
{ id: "id1", name: "one" },
{ id: "id2", name: "two" },
]
})
// People who have previously worked with immutable state in JavaScript may be tempted
// to use idioms like this, which don't work well with Automerge -- see e.g.
// https://github.com/automerge/automerge/issues/260
assert.throws(() => {
Automerge.change(s1, doc => {
doc.items = [...doc.items, { id: "id3", name: "three" }]
})
}, /Cannot create a reference to an existing document object/)
})
it("should handle deletion of properties within a map", () => {
s1 = Automerge.change(s1, "set style", doc => {
doc.textStyle = { typeface: "Optima", bold: false, fontSize: 12 }
})
s1 = Automerge.change(s1, "non-bold", doc => delete doc.textStyle.bold)
assert.strictEqual(s1.textStyle.bold, undefined)
assert.deepStrictEqual(s1.textStyle, {
typeface: "Optima",
fontSize: 12,
})
})
it("should handle deletion of references to a map", () => {
s1 = Automerge.change(s1, "make rich text doc", doc => {
Object.assign(doc, {
title: "Hello",
textStyle: { typeface: "Optima", fontSize: 12 },
})
})
s1 = Automerge.change(s1, doc => delete doc.textStyle)
assert.strictEqual(s1.textStyle, undefined)
assert.deepStrictEqual(s1, { title: "Hello" })
})
it("should validate field names", () => {
s1 = Automerge.change(s1, doc => (doc.nested = {}))
assert.throws(() => {
Automerge.change(s1, doc => (doc.nested[""] = "x"))
}, /must not be an empty string/)
assert.throws(() => {
Automerge.change(s1, doc => (doc.nested = { "": "x" }))
}, /must not be an empty string/)
})
})
describe("lists", () => {
it("should allow elements to be inserted", () => {
s1 = Automerge.change(s1, doc => (doc.noodles = []))
s1 = Automerge.change(s1, doc =>
doc.noodles.insertAt(0, "udon", "soba")
)
s1 = Automerge.change(s1, doc => doc.noodles.insertAt(1, "ramen"))
assert.deepStrictEqual(s1, { noodles: ["udon", "ramen", "soba"] })
assert.deepStrictEqual(s1.noodles, ["udon", "ramen", "soba"])
assert.strictEqual(s1.noodles[0], "udon")
assert.strictEqual(s1.noodles[1], "ramen")
assert.strictEqual(s1.noodles[2], "soba")
assert.strictEqual(s1.noodles.length, 3)
})
it("should handle assignment of a list literal", () => {
s1 = Automerge.change(
s1,
doc => (doc.noodles = ["udon", "ramen", "soba"])
)
assert.deepStrictEqual(s1, { noodles: ["udon", "ramen", "soba"] })
assert.deepStrictEqual(s1.noodles, ["udon", "ramen", "soba"])
assert.strictEqual(s1.noodles[0], "udon")
assert.strictEqual(s1.noodles[1], "ramen")
assert.strictEqual(s1.noodles[2], "soba")
assert.strictEqual(s1.noodles[3], undefined)
assert.strictEqual(s1.noodles.length, 3)
})
it("should only allow numeric indexes", () => {
s1 = Automerge.change(
s1,
doc => (doc.noodles = ["udon", "ramen", "soba"])
)
s1 = Automerge.change(s1, doc => (doc.noodles[1] = "Ramen!"))
assert.strictEqual(s1.noodles[1], "Ramen!")
s1 = Automerge.change(s1, doc => (doc.noodles["1"] = "RAMEN!!!"))
assert.strictEqual(s1.noodles[1], "RAMEN!!!")
assert.throws(() => {
Automerge.change(s1, doc => (doc.noodles.favourite = "udon"))
}, /list index must be a number/)
assert.throws(() => {
Automerge.change(s1, doc => (doc.noodles[""] = "udon"))
}, /list index must be a number/)
assert.throws(() => {
Automerge.change(s1, doc => (doc.noodles["1e6"] = "udon"))
}, /list index must be a number/)
})
it("should handle deletion of list elements", () => {
s1 = Automerge.change(
s1,
doc => (doc.noodles = ["udon", "ramen", "soba"])
)
s1 = Automerge.change(s1, doc => delete doc.noodles[1])
assert.deepStrictEqual(s1.noodles, ["udon", "soba"])
s1 = Automerge.change(s1, doc => doc.noodles.deleteAt(1))
assert.deepStrictEqual(s1.noodles, ["udon"])
assert.strictEqual(s1.noodles[0], "udon")
assert.strictEqual(s1.noodles[1], undefined)
assert.strictEqual(s1.noodles[2], undefined)
assert.strictEqual(s1.noodles.length, 1)
})
it("should handle assignment of individual list indexes", () => {
s1 = Automerge.change(
s1,
doc => (doc.japaneseFood = ["udon", "ramen", "soba"])
)
s1 = Automerge.change(s1, doc => (doc.japaneseFood[1] = "sushi"))
assert.deepStrictEqual(s1.japaneseFood, ["udon", "sushi", "soba"])
assert.strictEqual(s1.japaneseFood[0], "udon")
assert.strictEqual(s1.japaneseFood[1], "sushi")
assert.strictEqual(s1.japaneseFood[2], "soba")
assert.strictEqual(s1.japaneseFood[3], undefined)
assert.strictEqual(s1.japaneseFood.length, 3)
})
it("concurrent edits insert in reverse actorid order if counters equal", () => {
s1 = Automerge.init("aaaa")
s2 = Automerge.init("bbbb")
s1 = Automerge.change(s1, doc => (doc.list = []))
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.list.splice(0, 0, "2@aaaa"))
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, "2@bbbb"))
s2 = Automerge.merge(s2, s1)
assert.deepStrictEqual(Automerge.toJS(s2).list, ["2@bbbb", "2@aaaa"])
})
it("concurrent edits insert in reverse counter order if different", () => {
s1 = Automerge.init("aaaa")
s2 = Automerge.init("bbbb")
s1 = Automerge.change(s1, doc => (doc.list = []))
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.list.splice(0, 0, "2@aaaa"))
s2 = Automerge.change(s2, doc => (doc.foo = "2@bbbb"))
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, "3@bbbb"))
s2 = Automerge.merge(s2, s1)
assert.deepStrictEqual(s2.list, ["3@bbbb", "2@aaaa"])
})
it("should treat out-by-one assignment as insertion", () => {
s1 = Automerge.change(s1, doc => (doc.japaneseFood = ["udon"]))
s1 = Automerge.change(s1, doc => (doc.japaneseFood[1] = "sushi"))
assert.deepStrictEqual(s1.japaneseFood, ["udon", "sushi"])
assert.strictEqual(s1.japaneseFood[0], "udon")
assert.strictEqual(s1.japaneseFood[1], "sushi")
assert.strictEqual(s1.japaneseFood[2], undefined)
assert.strictEqual(s1.japaneseFood.length, 2)
})
it("should not allow out-of-range assignment", () => {
s1 = Automerge.change(s1, doc => (doc.japaneseFood = ["udon"]))
assert.throws(() => {
Automerge.change(s1, doc => (doc.japaneseFood[4] = "ramen"))
}, /is out of bounds/)
})
it("should allow bulk assignment of multiple list indexes", () => {
s1 = Automerge.change(
s1,
doc => (doc.noodles = ["udon", "ramen", "soba"])
)
s1 = Automerge.change(s1, doc =>
Object.assign(doc.noodles, { 0: "うどん", 2: "そば" })
)
assert.deepStrictEqual(s1.noodles, ["うどん", "ramen", "そば"])
assert.strictEqual(s1.noodles[0], "うどん")
assert.strictEqual(s1.noodles[1], "ramen")
assert.strictEqual(s1.noodles[2], "そば")
assert.strictEqual(s1.noodles.length, 3)
})
it("should handle nested objects", () => {
s1 = Automerge.change(
s1,
doc =>
(doc.noodles = [{ type: "ramen", dishes: ["tonkotsu", "shoyu"] }])
)
s1 = Automerge.change(s1, doc =>
doc.noodles.push({ type: "udon", dishes: ["tempura udon"] })
)
s1 = Automerge.change(s1, doc => doc.noodles[0].dishes.push("miso"))
assert.deepStrictEqual(s1, {
noodles: [
{ type: "ramen", dishes: ["tonkotsu", "shoyu", "miso"] },
{ type: "udon", dishes: ["tempura udon"] },
],
})
assert.deepStrictEqual(s1.noodles[0], {
type: "ramen",
dishes: ["tonkotsu", "shoyu", "miso"],
})
assert.deepStrictEqual(s1.noodles[1], {
type: "udon",
dishes: ["tempura udon"],
})
})
it("should handle nested lists", () => {
s1 = Automerge.change(
s1,
doc => (doc.noodleMatrix = [["ramen", "tonkotsu", "shoyu"]])
)
s1 = Automerge.change(s1, doc =>
doc.noodleMatrix.push(["udon", "tempura udon"])
)
s1 = Automerge.change(s1, doc => doc.noodleMatrix[0].push("miso"))
assert.deepStrictEqual(s1.noodleMatrix, [
["ramen", "tonkotsu", "shoyu", "miso"],
["udon", "tempura udon"],
])
assert.deepStrictEqual(s1.noodleMatrix[0], [
"ramen",
"tonkotsu",
"shoyu",
"miso",
])
assert.deepStrictEqual(s1.noodleMatrix[1], ["udon", "tempura udon"])
})
it("should handle deep nesting", () => {
s1 = Automerge.change(
s1,
doc =>
(doc.nesting = {
maps: { m1: { m2: { foo: "bar", baz: {} }, m2a: {} } },
lists: [
[1, 2, 3],
[[3, 4, 5, [6]], 7],
],
mapsinlists: [{ foo: "bar" }, [{ bar: "baz" }]],
listsinmaps: { foo: [1, 2, 3], bar: [[{ baz: "123" }]] },
})
)
s1 = Automerge.change(s1, doc => {
doc.nesting.maps.m1a = "123"
doc.nesting.maps.m1.m2.baz.xxx = "123"
delete doc.nesting.maps.m1.m2a
doc.nesting.lists.shift()
doc.nesting.lists[0][0].pop()
doc.nesting.lists[0][0].push(100)
doc.nesting.mapsinlists[0].foo = "baz"
doc.nesting.mapsinlists[1][0].foo = "bar"
delete doc.nesting.mapsinlists[1]
doc.nesting.listsinmaps.foo.push(4)
doc.nesting.listsinmaps.bar[0][0].baz = "456"
delete doc.nesting.listsinmaps.bar
})
assert.deepStrictEqual(s1, {
nesting: {
maps: {
m1: { m2: { foo: "bar", baz: { xxx: "123" } } },
m1a: "123",
},
lists: [[[3, 4, 5, 100], 7]],
mapsinlists: [{ foo: "baz" }],
listsinmaps: { foo: [1, 2, 3, 4] },
},
})
})
it("should handle replacement of the entire list", () => {
s1 = Automerge.change(
s1,
doc => (doc.noodles = ["udon", "soba", "ramen"])
)
s1 = Automerge.change(
s1,
doc => (doc.japaneseNoodles = doc.noodles.slice())
)
s1 = Automerge.change(s1, doc => (doc.noodles = ["wonton", "pho"]))
assert.deepStrictEqual(s1, {
noodles: ["wonton", "pho"],
japaneseNoodles: ["udon", "soba", "ramen"],
})
assert.deepStrictEqual(s1.noodles, ["wonton", "pho"])
assert.strictEqual(s1.noodles[0], "wonton")
assert.strictEqual(s1.noodles[1], "pho")
assert.strictEqual(s1.noodles[2], undefined)
assert.strictEqual(s1.noodles.length, 2)
})
it("should allow assignment to change the type of a list element", () => {
s1 = Automerge.change(
s1,
doc => (doc.noodles = ["udon", "soba", "ramen"])
)
assert.deepStrictEqual(s1.noodles, ["udon", "soba", "ramen"])
s1 = Automerge.change(
s1,
doc => (doc.noodles[1] = { type: "soba", options: ["hot", "cold"] })
)
assert.deepStrictEqual(s1.noodles, [
"udon",
{ type: "soba", options: ["hot", "cold"] },
"ramen",
])
s1 = Automerge.change(
s1,
doc => (doc.noodles[1] = ["hot soba", "cold soba"])
)
assert.deepStrictEqual(s1.noodles, [
"udon",
["hot soba", "cold soba"],
"ramen",
])
s1 = Automerge.change(s1, doc => (doc.noodles[1] = "soba is the best"))
assert.deepStrictEqual(s1.noodles, [
"udon",
"soba is the best",
"ramen",
])
})
it("should allow list creation and assignment in the same change callback", () => {
s1 = Automerge.change(Automerge.init(), doc => {
doc.letters = ["a", "b", "c"]
doc.letters[1] = "d"
})
assert.strictEqual(s1.letters[1], "d")
})
it("should allow adding and removing list elements in the same change callback", () => {
let s1 = Automerge.change(
Automerge.init<{ noodles: Array<string> }>(),
// @ts-ignore
doc => (doc.noodles = [])
)
s1 = Automerge.change(s1, doc => {
doc.noodles.push("udon")
// @ts-ignore
doc.noodles.deleteAt(0)
})
assert.deepStrictEqual(s1, { noodles: [] })
// do the add-remove cycle twice, test for #151 (https://github.com/automerge/automerge/issues/151)
s1 = Automerge.change(s1, doc => {
// @ts-ignore
doc.noodles.push("soba")
// @ts-ignore
doc.noodles.deleteAt(0)
})
assert.deepStrictEqual(s1, { noodles: [] })
})
it("should handle arbitrary-depth nesting", () => {
s1 = Automerge.change(
s1,
doc => (doc.maze = [[[[[[[["noodles", ["here"]]]]]]]]])
)
s1 = Automerge.change(s1, doc =>
doc.maze[0][0][0][0][0][0][0][1].unshift("found")
)
assert.deepStrictEqual(s1.maze, [
[[[[[[["noodles", ["found", "here"]]]]]]]],
])
assert.deepStrictEqual(s1.maze[0][0][0][0][0][0][0][1][1], "here")
s2 = Automerge.load(Automerge.save(s1))
assert.deepStrictEqual(s1, s2)
})
it("should not allow several references to the same list object", () => {
s1 = Automerge.change(s1, doc => (doc.list = []))
assert.throws(() => {
Automerge.change(s1, doc => {
doc.x = doc.list
})
}, /Cannot create a reference to an existing document object/)
assert.throws(() => {
Automerge.change(s1, doc => {
doc.x = s1.list
})
}, /Cannot create a reference to an existing document object/)
assert.throws(() => {
Automerge.change(s1, doc => {
doc.x = []
doc.y = doc.x
})
}, /Cannot create a reference to an existing document object/)
})
})
describe("counters", () => {
// counter
it("should allow deleting counters from maps", () => {
const s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc.birds = { wrens: new Automerge.Counter(1) })
)
const s2 = Automerge.change(s1, doc => doc.birds.wrens.increment(2))
const s3 = Automerge.change(s2, doc => delete doc.birds.wrens)
assert.deepStrictEqual(s2, {
birds: { wrens: new Automerge.Counter(3) },
})
assert.deepStrictEqual(s3, { birds: {} })
})
// counter
/*
it('should not allow deleting counters from lists', () => {
const s1 = Automerge.change(Automerge.init(), doc => doc.recordings = [new Automerge.Counter(1)])
const s2 = Automerge.change(s1, doc => doc.recordings[0].increment(2))
assert.deepStrictEqual(s2, {recordings: [new Automerge.Counter(3)]})
assert.throws(() => { Automerge.change(s2, doc => doc.recordings.deleteAt(0)) }, /Unsupported operation/)
})
*/
})
})
describe("concurrent use", () => {
let s1: Automerge.Doc<any>,
s2: Automerge.Doc<any>,
s3: Automerge.Doc<any>,
s4: Automerge.Doc<any>
beforeEach(() => {
s1 = Automerge.init<any>()
s2 = Automerge.init<any>()
s3 = Automerge.init<any>()
s4 = Automerge.init<any>()
})
it("should merge concurrent updates of different properties", () => {
s1 = Automerge.change(s1, doc => (doc.foo = "bar"))
s2 = Automerge.change(s2, doc => (doc.hello = "world"))
s3 = Automerge.merge(s1, s2)
assert.strictEqual(s3.foo, "bar")
assert.strictEqual(s3.hello, "world")
assert.deepStrictEqual(s3, { foo: "bar", hello: "world" })
assert.strictEqual(Automerge.getConflicts(s3, "foo"), undefined)
assert.strictEqual(Automerge.getConflicts(s3, "hello"), undefined)
s4 = Automerge.load(Automerge.save(s3))
assert.deepEqual(s3, s4)
})
it("should add concurrent increments of the same property", () => {
s1 = Automerge.change(s1, doc => (doc.counter = new Automerge.Counter()))
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.counter.increment())
s2 = Automerge.change(s2, doc => doc.counter.increment(2))
s3 = Automerge.merge(s1, s2)
assert.strictEqual(s1.counter.value, 1)
assert.strictEqual(s2.counter.value, 2)
assert.strictEqual(s3.counter.value, 3)
assert.strictEqual(Automerge.getConflicts(s3, "counter"), undefined)
s4 = Automerge.load(Automerge.save(s3))
assert.deepEqual(s3, s4)
})
it("should add increments only to the values they precede", () => {
s1 = Automerge.change(s1, doc => (doc.counter = new Automerge.Counter(0)))
s1 = Automerge.change(s1, doc => doc.counter.increment())
s2 = Automerge.change(
s2,
doc => (doc.counter = new Automerge.Counter(100))
)
s2 = Automerge.change(s2, doc => doc.counter.increment(3))
s3 = Automerge.merge(s1, s2)
if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) {
assert.deepStrictEqual(s3, { counter: new Automerge.Counter(1) })
} else {
assert.deepStrictEqual(s3, { counter: new Automerge.Counter(103) })
}
assert.deepStrictEqual(Automerge.getConflicts(s3, "counter"), {
[`1@${Automerge.getActorId(s1)}`]: new Automerge.Counter(1),
[`1@${Automerge.getActorId(s2)}`]: new Automerge.Counter(103),
})
s4 = Automerge.load(Automerge.save(s3))
assert.deepEqual(s3, s4)
})
it("should detect concurrent updates of the same field", () => {
s1 = Automerge.change(s1, doc => (doc.field = "one"))
s2 = Automerge.change(s2, doc => (doc.field = "two"))
s3 = Automerge.merge(s1, s2)
if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) {
assert.deepStrictEqual(s3, { field: "one" })
} else {
assert.deepStrictEqual(s3, { field: "two" })
}
assert.deepStrictEqual(Automerge.getConflicts(s3, "field"), {
[`1@${Automerge.getActorId(s1)}`]: "one",
[`1@${Automerge.getActorId(s2)}`]: "two",
})
})
it("should detect concurrent updates of the same list element", () => {
s1 = Automerge.change(s1, doc => (doc.birds = ["finch"]))
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => (doc.birds[0] = "greenfinch"))
s2 = Automerge.change(s2, doc => (doc.birds[0] = "goldfinch_"))
s3 = Automerge.merge(s1, s2)
if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) {
assert.deepStrictEqual(s3.birds, ["greenfinch"])
} else {
assert.deepStrictEqual(s3.birds, ["goldfinch_"])
}
assert.deepStrictEqual(Automerge.getConflicts(s3.birds, 0), {
[`8@${Automerge.getActorId(s1)}`]: "greenfinch",
[`8@${Automerge.getActorId(s2)}`]: "goldfinch_",
})
})
it("should handle assignment conflicts of different types", () => {
s1 = Automerge.change(s1, doc => (doc.field = "string"))
s2 = Automerge.change(s2, doc => (doc.field = ["list"]))
s3 = Automerge.change(s3, doc => (doc.field = { thing: "map" }))
s1 = Automerge.merge(Automerge.merge(s1, s2), s3)
assertEqualsOneOf(s1.field, "string", ["list"], { thing: "map" })
assert.deepStrictEqual(Automerge.getConflicts(s1, "field"), {
[`1@${Automerge.getActorId(s1)}`]: "string",
[`1@${Automerge.getActorId(s2)}`]: ["list"],
[`1@${Automerge.getActorId(s3)}`]: { thing: "map" },
})
})
it("should handle changes within a conflicting map field", () => {
s1 = Automerge.change(s1, doc => (doc.field = "string"))
s2 = Automerge.change(s2, doc => (doc.field = {}))
s2 = Automerge.change(s2, doc => (doc.field.innerKey = 42))
s3 = Automerge.merge(s1, s2)
assertEqualsOneOf(s3.field, "string", { innerKey: 42 })
assert.deepStrictEqual(Automerge.getConflicts(s3, "field"), {
[`1@${Automerge.getActorId(s1)}`]: "string",
[`1@${Automerge.getActorId(s2)}`]: { innerKey: 42 },
})
})
it("should handle changes within a conflicting list element", () => {
s1 = Automerge.change(s1, doc => (doc.list = ["hello"]))
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => (doc.list[0] = { map1: true }))
s1 = Automerge.change(s1, doc => (doc.list[0].key = 1))
s2 = Automerge.change(s2, doc => (doc.list[0] = { map2: true }))
s2 = Automerge.change(s2, doc => (doc.list[0].key = 2))
s3 = Automerge.merge(s1, s2)
if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) {
assert.deepStrictEqual(s3.list, [{ map1: true, key: 1 }])
} else {
assert.deepStrictEqual(s3.list, [{ map2: true, key: 2 }])
}
assert.deepStrictEqual(Automerge.getConflicts<any>(s3.list, 0), {
[`8@${Automerge.getActorId(s1)}`]: { map1: true, key: 1 },
[`8@${Automerge.getActorId(s2)}`]: { map2: true, key: 2 },
})
})
it("should not merge concurrently assigned nested maps", () => {
s1 = Automerge.change(s1, doc => (doc.config = { background: "blue" }))
s2 = Automerge.change(s2, doc => (doc.config = { logo_url: "logo.png" }))
s3 = Automerge.merge(s1, s2)
assertEqualsOneOf(
s3.config,
{ background: "blue" },
{ logo_url: "logo.png" }
)
assert.deepStrictEqual(Automerge.getConflicts(s3, "config"), {
[`1@${Automerge.getActorId(s1)}`]: { background: "blue" },
[`1@${Automerge.getActorId(s2)}`]: { logo_url: "logo.png" },
})
})
it("should clear conflicts after assigning a new value", () => {
s1 = Automerge.change(s1, doc => (doc.field = "one"))
s2 = Automerge.change(s2, doc => (doc.field = "two"))
s3 = Automerge.merge(s1, s2)
s3 = Automerge.change(s3, doc => (doc.field = "three"))
assert.deepStrictEqual(s3, { field: "three" })
assert.strictEqual(Automerge.getConflicts(s3, "field"), undefined)
s2 = Automerge.merge(s2, s3)
assert.deepStrictEqual(s2, { field: "three" })
assert.strictEqual(Automerge.getConflicts(s2, "field"), undefined)
})
it("should handle concurrent insertions at different list positions", () => {
s1 = Automerge.change(s1, doc => (doc.list = ["one", "three"]))
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.list.splice(1, 0, "two"))
s2 = Automerge.change(s2, doc => doc.list.push("four"))
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s3, { list: ["one", "two", "three", "four"] })
assert.strictEqual(Automerge.getConflicts(s3, "list"), undefined)
})
it("should handle concurrent insertions at the same list position", () => {
s1 = Automerge.change(s1, doc => (doc.birds = ["parakeet"]))
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds.push("starling"))
s2 = Automerge.change(s2, doc => doc.birds.push("chaffinch"))
s3 = Automerge.merge(s1, s2)
assertEqualsOneOf(
s3.birds,
["parakeet", "starling", "chaffinch"],
["parakeet", "chaffinch", "starling"]
)
s2 = Automerge.merge(s2, s3)
assert.deepStrictEqual(s2, s3)
})
it("should handle concurrent assignment and deletion of a map entry", () => {
// Add-wins semantics
s1 = Automerge.change(s1, doc => (doc.bestBird = "robin"))
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => delete doc.bestBird)
s2 = Automerge.change(s2, doc => (doc.bestBird = "magpie"))
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s1, {})
assert.deepStrictEqual(s2, { bestBird: "magpie" })
assert.deepStrictEqual(s3, { bestBird: "magpie" })
assert.strictEqual(Automerge.getConflicts(s3, "bestBird"), undefined)
})
it("should handle concurrent assignment and deletion of a list element", () => {
// Concurrent assignment ressurects a deleted list element. Perhaps a little
// surprising, but consistent with add-wins semantics of maps (see test above)
s1 = Automerge.change(
s1,
doc => (doc.birds = ["blackbird", "thrush", "goldfinch"])
)
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => (doc.birds[1] = "starling"))
s2 = Automerge.change(s2, doc => doc.birds.splice(1, 1))
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s1.birds, ["blackbird", "starling", "goldfinch"])
assert.deepStrictEqual(s2.birds, ["blackbird", "goldfinch"])
assert.deepStrictEqual(s3.birds, ["blackbird", "starling", "goldfinch"])
s4 = Automerge.load(Automerge.save(s3))
assert.deepStrictEqual(s3, s4)
})
it("should handle insertion after a deleted list element", () => {
s1 = Automerge.change(
s1,
doc => (doc.birds = ["blackbird", "thrush", "goldfinch"])
)
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds.splice(1, 2))
s2 = Automerge.change(s2, doc => doc.birds.splice(2, 0, "starling"))
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s3, { birds: ["blackbird", "starling"] })
assert.deepStrictEqual(Automerge.merge(s2, s3), {
birds: ["blackbird", "starling"],
})
})
it("should handle concurrent deletion of the same element", () => {
s1 = Automerge.change(
s1,
doc => (doc.birds = ["albatross", "buzzard", "cormorant"])
)
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds.deleteAt(1)) // buzzard
s2 = Automerge.change(s2, doc => doc.birds.deleteAt(1)) // buzzard
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s3.birds, ["albatross", "cormorant"])
})
it("should handle concurrent deletion of different elements", () => {
s1 = Automerge.change(
s1,
doc => (doc.birds = ["albatross", "buzzard", "cormorant"])
)
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds.deleteAt(0)) // albatross
s2 = Automerge.change(s2, doc => doc.birds.deleteAt(1)) // buzzard
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s3.birds, ["cormorant"])
})
it("should handle concurrent updates at different levels of the tree", () => {
// A delete higher up in the tree overrides an update in a subtree
s1 = Automerge.change(
s1,
doc =>
(doc.animals = {
birds: { pink: "flamingo", black: "starling" },
mammals: ["badger"],
})
)
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => (doc.animals.birds.brown = "sparrow"))
s2 = Automerge.change(s2, doc => delete doc.animals.birds)
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s1.animals, {
birds: {
pink: "flamingo",
brown: "sparrow",
black: "starling",
},
mammals: ["badger"],
})
assert.deepStrictEqual(s2.animals, { mammals: ["badger"] })
assert.deepStrictEqual(s3.animals, { mammals: ["badger"] })
})
it("should handle updates of concurrently deleted objects", () => {
s1 = Automerge.change(
s1,
doc => (doc.birds = { blackbird: { feathers: "black" } })
)
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => delete doc.birds.blackbird)
s2 = Automerge.change(s2, doc => (doc.birds.blackbird.beak = "orange"))
s3 = Automerge.merge(s1, s2)
assert.deepStrictEqual(s1, { birds: {} })
})
it("should not interleave sequence insertions at the same position", () => {
s1 = Automerge.change(s1, doc => (doc.wisdom = []))
s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc =>
doc.wisdom.push("to", "be", "is", "to", "do")
)
s2 = Automerge.change(s2, doc =>
doc.wisdom.push("to", "do", "is", "to", "be")
)
s3 = Automerge.merge(s1, s2)
assertEqualsOneOf(
s3.wisdom,
["to", "be", "is", "to", "do", "to", "do", "is", "to", "be"],
["to", "do", "is", "to", "be", "to", "be", "is", "to", "do"]
)
// In case you're wondering: http://quoteinvestigator.com/2013/09/16/do-be-do/
})
describe("multiple insertions at the same list position", () => {
it("should handle insertion by greater actor ID", () => {
s1 = Automerge.init("aaaa")
s2 = Automerge.init("bbbb")
s1 = Automerge.change(s1, doc => (doc.list = ["two"]))
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, "one"))
assert.deepStrictEqual(s2.list, ["one", "two"])
})
it("should handle insertion by lesser actor ID", () => {
s1 = Automerge.init("bbbb")
s2 = Automerge.init("aaaa")
s1 = Automerge.change(s1, doc => (doc.list = ["two"]))
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, "one"))
assert.deepStrictEqual(s2.list, ["one", "two"])
})
it("should handle insertion regardless of actor ID", () => {
s1 = Automerge.change(s1, doc => (doc.list = ["two"]))
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.splice(0, 0, "one"))
assert.deepStrictEqual(s2.list, ["one", "two"])
})
it("should make insertion order consistent with causality", () => {
s1 = Automerge.change(s1, doc => (doc.list = ["four"]))
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.unshift("three"))
s1 = Automerge.merge(s1, s2)
s1 = Automerge.change(s1, doc => doc.list.unshift("two"))
s2 = Automerge.merge(s2, s1)
s2 = Automerge.change(s2, doc => doc.list.unshift("one"))
assert.deepStrictEqual(s2.list, ["one", "two", "three", "four"])
})
})
})
describe("saving and loading", () => {
it("should save and restore an empty document", () => {
let s = Automerge.load(Automerge.save(Automerge.init()))
assert.deepStrictEqual(s, {})
})
it("should generate a new random actor ID", () => {
let s1 = Automerge.init()
let s2 = Automerge.load(Automerge.save(s1))
assert.strictEqual(
UUID_PATTERN.test(Automerge.getActorId(s1).toString()),
true
)
assert.strictEqual(
UUID_PATTERN.test(Automerge.getActorId(s2).toString()),
true
)
assert.notEqual(Automerge.getActorId(s1), Automerge.getActorId(s2))
})
it("should allow a custom actor ID to be set", () => {
let s = Automerge.load(Automerge.save(Automerge.init()), "333333")
assert.strictEqual(Automerge.getActorId(s), "333333")
})
it("should reconstitute complex datatypes", () => {
let s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc.todos = [{ title: "water plants", done: false }])
)
let s2 = Automerge.load(Automerge.save(s1))
assert.deepStrictEqual(s2, {
todos: [{ title: "water plants", done: false }],
})
})
it("should save and load maps with @ symbols in the keys", () => {
let s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc["123@4567"] = "hello")
)
let s2 = Automerge.load(Automerge.save(s1))
assert.deepStrictEqual(s2, { "123@4567": "hello" })
})
it("should reconstitute conflicts", () => {
let s1 = Automerge.change(
Automerge.init<any>("111111"),
doc => (doc.x = 3)
)
let s2 = Automerge.change(
Automerge.init<any>("222222"),
doc => (doc.x = 5)
)
s1 = Automerge.merge(s1, s2)
let s3 = Automerge.load<any>(Automerge.save(s1))
assert.strictEqual(s1.x, 5)
assert.strictEqual(s3.x, 5)
assert.deepStrictEqual(Automerge.getConflicts(s1, "x"), {
"1@111111": 3,
"1@222222": 5,
})
assert.deepStrictEqual(Automerge.getConflicts(s3, "x"), {
"1@111111": 3,
"1@222222": 5,
})
})
it("should reconstitute element ID counters", () => {
const s1 = Automerge.init<any>("01234567")
const s2 = Automerge.change(s1, doc => (doc.list = ["a"]))
const listId = Automerge.getObjectId(s2.list)
const changes12 = Automerge.getAllChanges(s2).map(Automerge.decodeChange)
assert.deepStrictEqual(changes12, [
{
hash: changes12[0].hash,
actor: "01234567",
seq: 1,
startOp: 1,
time: changes12[0].time,
message: null,
deps: [],
ops: [
{ obj: "_root", action: "makeList", key: "list", pred: [] },
{
obj: listId,
action: "makeText",
elemId: "_head",
insert: true,
pred: [],
},
{
obj: "2@01234567",
action: "set",
elemId: "_head",
insert: true,
value: "a",
pred: [],
},
],
},
])
const s3 = Automerge.change(s2, doc => doc.list.deleteAt(0))
const s4 = Automerge.load<any>(Automerge.save(s3), "01234567")
const s5 = Automerge.change(s4, doc => doc.list.push("b"))
const changes45 = Automerge.getAllChanges(s5).map(Automerge.decodeChange)
assert.deepStrictEqual(s5, { list: ["b"] })
assert.deepStrictEqual(changes45[2], {
hash: changes45[2].hash,
actor: "01234567",
seq: 3,
startOp: 5,
time: changes45[2].time,
message: null,
deps: [changes45[1].hash],
ops: [
{
obj: listId,
action: "makeText",
elemId: "_head",
insert: true,
pred: [],
},
{
obj: "5@01234567",
action: "set",
elemId: "_head",
insert: true,
value: "b",
pred: [],
},
],
})
})
it("should allow a reloaded list to be mutated", () => {
let doc = Automerge.change(Automerge.init<any>(), doc => (doc.foo = []))
doc = Automerge.load(Automerge.save(doc))
doc = Automerge.change(doc, "add", doc => doc.foo.push(1))
doc = Automerge.load(Automerge.save(doc))
assert.deepStrictEqual(doc.foo, [1])
})
it("should reload a document containing deflated columns", () => {
// In this test, the keyCtr column is long enough for deflate compression to kick in, but the
// keyStr column is short. Thus, the deflate bit gets set for keyCtr but not for keyStr.
// When checking whether the columns appear in ascending order, we must ignore the deflate bit.
let doc = Automerge.change(Automerge.init<any>(), doc => {
doc.list = []
for (let i = 0; i < 200; i++)
doc.list.insertAt(Math.floor(Math.random() * i), "a")
})
Automerge.load<any>(Automerge.save(doc))
let expected: Array<string> = []
for (let i = 0; i < 200; i++) expected.push("a")
assert.deepStrictEqual(doc, { list: expected })
})
it.skip("should call patchCallback if supplied to load", () => {
const s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc.birds = ["Goldfinch"])
)
const s2 = Automerge.change(s1, doc => doc.birds.push("Chaffinch"))
const callbacks: Array<any> = [],
actor = Automerge.getActorId(s1)
const reloaded = Automerge.load<any>(Automerge.save(s2), {
patchCallback(patch, before, after) {
callbacks.push({ patch, before, after })
},
})
assert.strictEqual(callbacks.length, 1)
assert.deepStrictEqual(callbacks[0].patch, {
maxOp: 3,
deps: [decodeChange(Automerge.getAllChanges(s2)[1]).hash],
clock: { [actor]: 2 },
pendingChanges: 0,
diffs: {
objectId: "_root",
type: "map",
props: {
birds: {
[`1@${actor}`]: {
objectId: `1@${actor}`,
type: "list",
edits: [
{
action: "multi-insert",
index: 0,
elemId: `2@${actor}`,
values: ["Goldfinch", "Chaffinch"],
},
],
},
},
},
},
})
assert.deepStrictEqual(callbacks[0].before, {})
assert.strictEqual(callbacks[0].after, reloaded)
assert.strictEqual(callbacks[0].local, false)
})
})
describe("history API", () => {
it("should return an empty history for an empty document", () => {
assert.deepStrictEqual(Automerge.getHistory(Automerge.init()), [])
})
it("should make past document states accessible", () => {
let s = Automerge.init<any>()
s = Automerge.change(s, doc => (doc.config = { background: "blue" }))
s = Automerge.change(s, doc => (doc.birds = ["mallard"]))
s = Automerge.change(s, doc => doc.birds.unshift("oystercatcher"))
assert.deepStrictEqual(
Automerge.getHistory(s).map(state => state.snapshot),
[
{ config: { background: "blue" } },
{ config: { background: "blue" }, birds: ["mallard"] },
{
config: { background: "blue" },
birds: ["oystercatcher", "mallard"],
},
]
)
})
it("should make change messages accessible", () => {
let s = Automerge.init<any>()
s = Automerge.change(s, "Empty Bookshelf", doc => (doc.books = []))
s = Automerge.change(s, "Add Orwell", doc =>
doc.books.push("Nineteen Eighty-Four")
)
s = Automerge.change(s, "Add Huxley", doc =>
doc.books.push("Brave New World")
)
assert.deepStrictEqual(s.books, [
"Nineteen Eighty-Four",
"Brave New World",
])
assert.deepStrictEqual(
Automerge.getHistory(s).map(state => state.change.message),
["Empty Bookshelf", "Add Orwell", "Add Huxley"]
)
})
})
describe("changes API", () => {
it("should return an empty list on an empty document", () => {
let changes = Automerge.getAllChanges(Automerge.init())
assert.deepStrictEqual(changes, [])
})
it("should return an empty list when nothing changed", () => {
let s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc.birds = ["Chaffinch"])
)
assert.deepStrictEqual(Automerge.getChanges(s1, s1), [])
})
it("should do nothing when applying an empty list of changes", () => {
let s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc.birds = ["Chaffinch"])
)
assert.deepStrictEqual(Automerge.applyChanges(s1, [])[0], s1)
})
it("should return all changes when compared to an empty document", () => {
let s1 = Automerge.change(
Automerge.init<any>(),
"Add Chaffinch",
doc => (doc.birds = ["Chaffinch"])
)
let s2 = Automerge.change(s1, "Add Bullfinch", doc =>
doc.birds.push("Bullfinch")
)
let changes = Automerge.getChanges(Automerge.init(), s2)
assert.strictEqual(changes.length, 2)
})
it("should allow a document copy to be reconstructed from scratch", () => {
let s1 = Automerge.change(
Automerge.init<any>(),
"Add Chaffinch",
doc => (doc.birds = ["Chaffinch"])
)
let s2 = Automerge.change(s1, "Add Bullfinch", doc =>
doc.birds.push("Bullfinch")
)
let changes = Automerge.getAllChanges(s2)
let [s3] = Automerge.applyChanges(Automerge.init<any>(), changes)
assert.deepStrictEqual(s3.birds, ["Chaffinch", "Bullfinch"])
})
it("should return changes since the last given version", () => {
let s1 = Automerge.change(
Automerge.init<any>(),
"Add Chaffinch",
doc => (doc.birds = ["Chaffinch"])
)
let changes1 = Automerge.getAllChanges(s1)
let s2 = Automerge.change(s1, "Add Bullfinch", doc =>
doc.birds.push("Bullfinch")
)
let changes2 = Automerge.getChanges(s1, s2)
assert.strictEqual(changes1.length, 1) // Add Chaffinch
assert.strictEqual(changes2.length, 1) // Add Bullfinch
})
it("should incrementally apply changes since the last given version", () => {
let s1 = Automerge.change(
Automerge.init<any>(),
"Add Chaffinch",
doc => (doc.birds = ["Chaffinch"])
)
let changes1 = Automerge.getAllChanges(s1)
let s2 = Automerge.change(s1, "Add Bullfinch", doc =>
doc.birds.push("Bullfinch")
)
let changes2 = Automerge.getChanges(s1, s2)
let [s3] = Automerge.applyChanges(Automerge.init<any>(), changes1)
let [s4] = Automerge.applyChanges(s3, changes2)
assert.deepStrictEqual(s3.birds, ["Chaffinch"])
assert.deepStrictEqual(s4.birds, ["Chaffinch", "Bullfinch"])
})
it("should handle updates to a list element", () => {
let s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc.birds = ["Chaffinch", "Bullfinch"])
)
let s2 = Automerge.change(s1, doc => (doc.birds[0] = "Goldfinch"))
let [s3] = Automerge.applyChanges(
Automerge.init<any>(),
Automerge.getAllChanges(s2)
)
assert.deepStrictEqual(s3.birds, ["Goldfinch", "Bullfinch"])
assert.strictEqual(Automerge.getConflicts(s3.birds, 0), undefined)
})
// TEXT
it("should handle updates to a text object", () => {
let s1 = Automerge.change(Automerge.init<any>(), doc => (doc.text = "ab"))
let s2 = Automerge.change(s1, doc =>
Automerge.splice(doc, "text", 0, 1, "A")
)
let [s3] = Automerge.applyChanges(
Automerge.init<any>(),
Automerge.getAllChanges(s2)
)
assert.deepStrictEqual([...s3.text], ["A", "b"])
})
/*
it.skip('should report missing dependencies', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.birds = ['Chaffinch'])
let s2 = Automerge.merge(Automerge.init(), s1)
s2 = Automerge.change(s2, doc => doc.birds.push('Bullfinch'))
let changes = Automerge.getAllChanges(s2)
let [s3, patch] = Automerge.applyChanges(Automerge.init(), [changes[1]])
assert.deepStrictEqual(s3, {})
assert.deepStrictEqual(Automerge.Backend.getMissingDeps(Automerge.Frontend.getBackendState(s3)),
decodeChange(changes[1]).deps)
assert.strictEqual(patch.pendingChanges, 1)
;[s3, patch] = Automerge.applyChanges(s3, [changes[0]])
assert.deepStrictEqual(s3.birds, ['Chaffinch', 'Bullfinch'])
assert.deepStrictEqual(Automerge.Backend.getMissingDeps(Automerge.Frontend.getBackendState(s3)), [])
assert.strictEqual(patch.pendingChanges, 0)
})
*/
it("should report missing dependencies with out-of-order applyChanges", () => {
let s0 = Automerge.init<any>()
let s1 = Automerge.change(s0, doc => (doc.test = ["a"]))
let changes01 = Automerge.getAllChanges(s1)
let s2 = Automerge.change(s1, doc => (doc.test = ["b"]))
let changes12 = Automerge.getChanges(s1, s2)
let s3 = Automerge.change(s2, doc => (doc.test = ["c"]))
let changes23 = Automerge.getChanges(s2, s3)
let s4 = Automerge.init()
let [s5] = Automerge.applyChanges(s4, changes23)
let [s6] = Automerge.applyChanges(s5, changes12)
assert.deepStrictEqual(Automerge.getMissingDeps(s6, []), [
decodeChange(changes01[0]).hash,
])
})
it("should call patchCallback if supplied when applying changes", () => {
const s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc.birds = ["Goldfinch"])
)
const callbacks: Array<any> = []
const before = Automerge.init()
const [after] = Automerge.applyChanges(
before,
Automerge.getAllChanges(s1),
{
patchCallback(patch, before, after) {
callbacks.push({ patch, before, after })
},
}
)
assert.strictEqual(callbacks.length, 1)
assert.deepStrictEqual(callbacks[0].patch[0], {
action: "put",
path: ["birds"],
value: [],
})
assert.deepStrictEqual(callbacks[0].patch[1], {
action: "insert",
path: ["birds", 0],
values: [""],
})
assert.deepStrictEqual(callbacks[0].patch[2], {
action: "splice",
path: ["birds", 0, 0],
value: "Goldfinch",
})
assert.strictEqual(callbacks[0].before, before)
assert.strictEqual(callbacks[0].after, after)
})
it("should merge multiple applied changes into one patch", () => {
const s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc.birds = ["Goldfinch"])
)
const s2 = Automerge.change(s1, doc => doc.birds.push("Chaffinch"))
const patches: Array<any> = []
Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s2), {
patchCallback: p => patches.push(...p),
})
assert.deepStrictEqual(patches, [
{ action: "put", path: ["birds"], value: [] },
{ action: "insert", path: ["birds", 0], values: ["", ""] },
{ action: "splice", path: ["birds", 0, 0], value: "Goldfinch" },
{ action: "splice", path: ["birds", 1, 0], value: "Chaffinch" },
])
})
it("should call a patchCallback registered on doc initialisation", () => {
const s1 = Automerge.change(
Automerge.init<any>(),
doc => (doc.bird = "Goldfinch")
)
const patches: Array<any> = []
const before = Automerge.init({
patchCallback: p => patches.push(...p),
})
Automerge.applyChanges(before, Automerge.getAllChanges(s1))
assert.deepStrictEqual(patches, [
{ action: "put", path: ["bird"], value: "" },
{ action: "splice", path: ["bird", 0], value: "Goldfinch" },
])
})
})
})