Compare commits

...

7 commits

39 changed files with 1513 additions and 1154 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ perf.*
/Cargo.lock /Cargo.lock
build/ build/
.vim/* .vim/*
/target

View file

@ -1,11 +1,11 @@
// Properties of the document root object // Properties of the document root object
//const OPTIONS = Symbol('_options') // object containing options passed to init() //const OPTIONS = Symbol('_options') // object containing options passed to init()
//const CACHE = Symbol('_cache') // map from objectId to immutable object //const CACHE = Symbol('_cache') // map from objectId to immutable object
//export const STATE = Symbol.for('_am_state') // object containing metadata about current state (e.g. sequence numbers)
export const STATE = Symbol.for('_am_meta') // object containing metadata about current state (e.g. sequence numbers) export const STATE = Symbol.for('_am_meta') // object containing metadata about current state (e.g. sequence numbers)
export const HEADS = Symbol.for('_am_heads') // object containing metadata about current state (e.g. sequence numbers) export const HEADS = Symbol.for('_am_heads') // object containing metadata about current state (e.g. sequence numbers)
export const TRACE = Symbol.for('_am_trace') // object containing metadata about current state (e.g. sequence numbers) export const TRACE = Symbol.for('_am_trace') // object containing metadata about current state (e.g. sequence numbers)
export const OBJECT_ID = Symbol.for('_am_objectId') // object containing metadata about current state (e.g. sequence numbers) export const OBJECT_ID = Symbol.for('_am_objectId') // object containing metadata about current state (e.g. sequence numbers)
export const IS_PROXY = Symbol.for('_am_isProxy') // object containing metadata about current state (e.g. sequence numbers)
export const READ_ONLY = Symbol.for('_am_readOnly') // object containing metadata about current state (e.g. sequence numbers) export const READ_ONLY = Symbol.for('_am_readOnly') // object containing metadata about current state (e.g. sequence numbers)
export const FROZEN = Symbol.for('_am_frozen') // object containing metadata about current state (e.g. sequence numbers) export const FROZEN = Symbol.for('_am_frozen') // object containing metadata about current state (e.g. sequence numbers)

View file

@ -2,11 +2,11 @@
/** @hidden **/ /** @hidden **/
export {/** @hidden */ uuid} from './uuid' export {/** @hidden */ uuid} from './uuid'
import {rootProxy, listProxy, textProxy, mapProxy} from "./proxies" import {rootProxy, listProxy, mapProxy} from "./proxies"
import {STATE, HEADS, TRACE, OBJECT_ID, READ_ONLY, FROZEN} from "./constants" import {STATE, HEADS, TRACE, IS_PROXY, OBJECT_ID, READ_ONLY, FROZEN} from "./constants"
import {AutomergeValue, Text, Counter} from "./types" import {AutomergeValue, Text, Counter} from "./types"
export {AutomergeValue, Text, Counter, Int, Uint, Float64, ScalarValue} from "./types" export {AutomergeValue, Counter, Int, Uint, Float64, ScalarValue} from "./types"
import {type API, type Patch} from "@automerge/automerge-wasm"; import {type API, type Patch} from "@automerge/automerge-wasm";
export { type Patch, PutPatch, DelPatch, SplicePatch, IncPatch, SyncMessage, } from "@automerge/automerge-wasm" export { type Patch, PutPatch, DelPatch, SplicePatch, IncPatch, SyncMessage, } from "@automerge/automerge-wasm"
@ -116,15 +116,6 @@ function _trace<T>(doc: Doc<T>): string | undefined {
return Reflect.get(doc, TRACE) as string return Reflect.get(doc, TRACE) as string
} }
function _set_heads<T>(doc: Doc<T>, heads: Heads) {
_state(doc).heads = heads
}
function _clear_heads<T>(doc: Doc<T>) {
Reflect.set(doc, HEADS, undefined)
Reflect.set(doc, TRACE, undefined)
}
function _obj<T>(doc: Doc<T>): ObjID | null { function _obj<T>(doc: Doc<T>): ObjID | null {
if (!(typeof doc === 'object') || doc === null) { if (!(typeof doc === 'object') || doc === null) {
return null return null
@ -161,7 +152,6 @@ export function init<T>(_opts?: ActorId | InitOptions<T>): Doc<T> {
handle.enablePatches(true) handle.enablePatches(true)
handle.enableFreeze(!!opts.freeze) handle.enableFreeze(!!opts.freeze)
handle.registerDatatype("counter", (n) => new Counter(n)) handle.registerDatatype("counter", (n) => new Counter(n))
handle.registerDatatype("text", (n) => new Text(n))
const doc = handle.materialize("/", undefined, {handle, heads: undefined, freeze, patchCallback}) as Doc<T> const doc = handle.materialize("/", undefined, {handle, heads: undefined, freeze, patchCallback}) as Doc<T>
return doc return doc
} }
@ -403,7 +393,6 @@ export function load<T>(data: Uint8Array, _opts?: ActorId | InitOptions<T>): Doc
handle.enablePatches(true) handle.enablePatches(true)
handle.enableFreeze(!!opts.freeze) handle.enableFreeze(!!opts.freeze)
handle.registerDatatype("counter", (n) => new Counter(n)) handle.registerDatatype("counter", (n) => new Counter(n))
handle.registerDatatype("text", (n) => new Text(n))
const doc: any = handle.materialize("/", undefined, {handle, heads: undefined, patchCallback}) as Doc<T> const doc: any = handle.materialize("/", undefined, {handle, heads: undefined, patchCallback}) as Doc<T>
return doc return doc
} }
@ -513,7 +502,7 @@ function conflictAt(context: Automerge, objectId: ObjID, prop: Prop): Conflicts
result[fullVal[1]] = listProxy(context, fullVal[1], [prop], true) result[fullVal[1]] = listProxy(context, fullVal[1], [prop], true)
break; break;
case "text": case "text":
result[fullVal[1]] = textProxy(context, fullVal[1], [prop], true) result[fullVal[1]] = context.text(fullVal[1])
break; break;
//case "table": //case "table":
//case "cursor": //case "cursor":
@ -611,8 +600,17 @@ export function getLastLocalChange<T>(doc: Doc<T>): Change | undefined {
* This is useful to determine if something is actually an automerge document, * This is useful to determine if something is actually an automerge document,
* if `doc` is not an automerge document this will return null. * if `doc` is not an automerge document this will return null.
*/ */
export function getObjectId(doc: any): ObjID | null { export function getObjectId(doc: any, prop?: Prop): ObjID | null {
if (prop) {
const state = _state(doc, false)
const objectId = _obj(doc)
if (!state || !objectId) {
throw new RangeError("invalid object for splice")
}
return state.handle.get(objectId, prop) as ObjID
} else {
return _obj(doc) return _obj(doc)
}
} }
/** /**
@ -810,6 +808,25 @@ export function getMissingDeps<T>(doc: Doc<T>, heads: Heads): Heads {
return state.handle.getMissingDeps(heads) return state.handle.getMissingDeps(heads)
} }
export function splice<T>(doc: Doc<T>, prop: Prop, index: number, del: number, newText?: string) {
if (!Reflect.get(doc, IS_PROXY)) {
throw new RangeError("object cannot be modified outside of a change block")
}
const state = _state(doc, false)
const objectId = _obj(doc)
if (!objectId) {
throw new RangeError("invalid object for splice")
}
const value = state.handle.getWithType(objectId, prop)
if (value === null) {
throw new RangeError("Cannot splice, not a valid value");
} else if (value[0] === 'text') {
return state.handle.splice(value[1], index, del, newText)
} else {
throw new RangeError(`Cannot splice, value is of type '${value[0]}', must be 'text'`);
}
}
/** /**
* Get the hashes of the heads of this document * Get the hashes of the heads of this document
*/ */

View file

@ -4,7 +4,7 @@ import { Prop } from "@automerge/automerge-wasm"
import { AutomergeValue, ScalarValue, MapValue, ListValue, TextValue } from "./types" import { AutomergeValue, ScalarValue, MapValue, ListValue, TextValue } from "./types"
import { Counter, getWriteableCounter } from "./counter" import { Counter, getWriteableCounter } from "./counter"
import { Text } from "./text" import { Text } from "./text"
import { STATE, HEADS, TRACE, FROZEN, OBJECT_ID, READ_ONLY, COUNTER, INT, UINT, F64, TEXT } from "./constants" import { STATE, HEADS, TRACE, FROZEN, IS_PROXY, OBJECT_ID, READ_ONLY, COUNTER, INT, UINT, F64, TEXT } from "./constants"
function parseListIndex(key) { function parseListIndex(key) {
if (typeof key === 'string' && /^[0-9]+$/.test(key)) key = parseInt(key, 10) if (typeof key === 'string' && /^[0-9]+$/.test(key)) key = parseInt(key, 10)
@ -30,7 +30,8 @@ function valueAt(target, prop: Prop) : AutomergeValue | undefined {
case undefined: return; case undefined: return;
case "map": return mapProxy(context, val, [ ... path, prop ], readonly, heads); case "map": return mapProxy(context, val, [ ... path, prop ], readonly, heads);
case "list": return listProxy(context, val, [ ... path, prop ], readonly, heads); case "list": return listProxy(context, val, [ ... path, prop ], readonly, heads);
case "text": return textProxy(context, val, [ ... path, prop ], readonly, heads); //case "text": return textProxy(context, val, [ ... path, prop ], readonly, heads);
case "text": return context.text(val, heads);
//case "table": //case "table":
//case "cursor": //case "cursor":
case "str": return val; case "str": return val;
@ -66,8 +67,8 @@ function import_value(value) {
return [ value.value, "f64" ] return [ value.value, "f64" ]
} else if (value[COUNTER]) { } else if (value[COUNTER]) {
return [ value.value, "counter" ] return [ value.value, "counter" ]
} else if (value[TEXT]) { //} else if (value[TEXT]) {
return [ value, "text" ] // return [ value, "text" ]
} else if (value instanceof Date) { } else if (value instanceof Date) {
return [ value.getTime(), "timestamp" ] return [ value.getTime(), "timestamp" ]
} else if (value instanceof Uint8Array) { } else if (value instanceof Uint8Array) {
@ -92,7 +93,7 @@ function import_value(value) {
} }
break; break;
case 'string': case 'string':
return [ value ] return [ value, "text" ]
break; break;
default: default:
throw new RangeError(`Unsupported type of value: ${typeof value}`) throw new RangeError(`Unsupported type of value: ${typeof value}`)
@ -104,11 +105,12 @@ const MapHandler = {
const { context, objectId, readonly, frozen, heads, cache } = target const { context, objectId, readonly, frozen, heads, cache } = target
if (key === Symbol.toStringTag) { return target[Symbol.toStringTag] } if (key === Symbol.toStringTag) { return target[Symbol.toStringTag] }
if (key === OBJECT_ID) return objectId if (key === OBJECT_ID) return objectId
if (key === IS_PROXY) return true
if (key === READ_ONLY) return readonly if (key === READ_ONLY) return readonly
if (key === FROZEN) return frozen if (key === FROZEN) return frozen
if (key === HEADS) return heads if (key === HEADS) return heads
if (key === TRACE) return target.trace if (key === TRACE) return target.trace
if (key === STATE) return context; if (key === STATE) return { handle: context };
if (!cache[key]) { if (!cache[key]) {
cache[key] = valueAt(target, key) cache[key] = valueAt(target, key)
} }
@ -150,11 +152,14 @@ const MapHandler = {
break break
} }
case "text": { case "text": {
const text = context.putObject(objectId, key, "", "text") context.putObject(objectId, key, value, "text")
/*
const text = context.putObject(objectId, key, value, "text")
const proxyText = textProxy(context, text, [ ... path, key ], readonly ); const proxyText = textProxy(context, text, [ ... path, key ], readonly );
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
proxyText[i] = value.get(i) proxyText[i] = value.get(i)
} }
*/
break break
} }
case "map": { case "map": {
@ -212,11 +217,12 @@ const ListHandler = {
if (index === Symbol.hasInstance) { return (instance) => { return Array.isArray(instance) } } if (index === Symbol.hasInstance) { return (instance) => { return Array.isArray(instance) } }
if (index === Symbol.toStringTag) { return target[Symbol.toStringTag] } if (index === Symbol.toStringTag) { return target[Symbol.toStringTag] }
if (index === OBJECT_ID) return objectId if (index === OBJECT_ID) return objectId
if (index === IS_PROXY) return true
if (index === READ_ONLY) return readonly if (index === READ_ONLY) return readonly
if (index === FROZEN) return frozen if (index === FROZEN) return frozen
if (index === HEADS) return heads if (index === HEADS) return heads
if (index === TRACE) return target.trace if (index === TRACE) return target.trace
if (index === STATE) return context; if (index === STATE) return { handle: context };
if (index === 'length') return context.length(objectId, heads); if (index === 'length') return context.length(objectId, heads);
if (typeof index === 'number') { if (typeof index === 'number') {
return valueAt(target, index) return valueAt(target, index)
@ -268,12 +274,12 @@ const ListHandler = {
case "text": { case "text": {
let text let text
if (index >= context.length(objectId)) { if (index >= context.length(objectId)) {
text = context.insertObject(objectId, index, "", "text") text = context.insertObject(objectId, index, value, "text")
} else { } else {
text = context.putObject(objectId, index, "", "text") text = context.putObject(objectId, index, value, "text")
} }
const proxyText = textProxy(context, text, [ ... path, index ], readonly); //const proxyText = textProxy(context, text, [ ... path, index ], readonly);
proxyText.splice(0,0,...value) //proxyText.splice(0,0,...value)
break; break;
} }
case "map": { case "map": {
@ -350,11 +356,12 @@ const TextHandler = Object.assign({}, ListHandler, {
if (index === Symbol.toStringTag) { return target[Symbol.toStringTag] } if (index === Symbol.toStringTag) { return target[Symbol.toStringTag] }
if (index === Symbol.hasInstance) { return (instance) => { return Array.isArray(instance) } } if (index === Symbol.hasInstance) { return (instance) => { return Array.isArray(instance) } }
if (index === OBJECT_ID) return objectId if (index === OBJECT_ID) return objectId
if (index === IS_PROXY) return true
if (index === READ_ONLY) return readonly if (index === READ_ONLY) return readonly
if (index === FROZEN) return frozen if (index === FROZEN) return frozen
if (index === HEADS) return heads if (index === HEADS) return heads
if (index === TRACE) return target.trace if (index === TRACE) return target.trace
if (index === STATE) return context; if (index === STATE) return { handle: context };
if (index === 'length') return context.length(objectId, heads); if (index === 'length') return context.length(objectId, heads);
if (typeof index === 'number') { if (typeof index === 'number') {
return valueAt(target, index) return valueAt(target, index)
@ -377,11 +384,13 @@ export function listProxy(context: Automerge, objectId: ObjID, path?: Prop[], re
return new Proxy(target, ListHandler) return new Proxy(target, ListHandler)
} }
/*
export function textProxy(context: Automerge, objectId: ObjID, path?: Prop[], readonly?: boolean, heads?: Heads) : TextValue { export function textProxy(context: Automerge, objectId: ObjID, path?: Prop[], readonly?: boolean, heads?: Heads) : TextValue {
const target = [] const target = []
Object.assign(target, {context, objectId, path, readonly: !!readonly, frozen: false, heads, cache: {}}) Object.assign(target, {context, objectId, path, readonly: !!readonly, frozen: false, heads, cache: {}})
return new Proxy(target, TextHandler) return new Proxy(target, TextHandler)
} }
*/
export function rootProxy<T>(context: Automerge, readonly?: boolean) : T { export function rootProxy<T>(context: Automerge, readonly?: boolean) : T {
/* eslint-disable-next-line */ /* eslint-disable-next-line */
@ -406,8 +415,12 @@ function listMethods(target) {
start = parseListIndex(start || 0) start = parseListIndex(start || 0)
end = parseListIndex(end || length) end = parseListIndex(end || length)
for (let i = start; i < Math.min(end, length); i++) { for (let i = start; i < Math.min(end, length); i++) {
if (datatype === "text" || datatype === "list" || datatype === "map") {
context.putObject(objectId, i, value, datatype)
} else {
context.put(objectId, i, value, datatype) context.put(objectId, i, value, datatype)
} }
}
return this return this
}, },
@ -482,9 +495,7 @@ function listMethods(target) {
break; break;
} }
case "text": { case "text": {
const text = context.insertObject(objectId, index, "", "text") context.insertObject(objectId, index, value)
const proxyText = textProxy(context, text, [ ... path, index ], readonly);
proxyText.splice(0,0,...value)
break; break;
} }
case "map": { case "map": {

View file

@ -1,6 +1,7 @@
import * as assert from 'assert' import * as assert from 'assert'
import {Counter} from 'automerge' import {Counter} from 'automerge'
import * as Automerge from '../src' import * as Automerge from '../src'
import * as WASM from "@automerge/automerge-wasm"
describe('Automerge', () => { describe('Automerge', () => {
describe('basics', () => { describe('basics', () => {
@ -198,10 +199,9 @@ describe('Automerge', () => {
}) })
it('handle text', () => { it('handle text', () => {
let doc1 = Automerge.init() let doc1 = Automerge.init()
let tmp = new Automerge.Text("hello")
let doc2 = Automerge.change(doc1, (d) => { let doc2 = Automerge.change(doc1, (d) => {
d.list = new Automerge.Text("hello") d.list = "hello"
d.list.insertAt(2,"Z") Automerge.splice(d, "list", 2, 0, "Z")
}) })
let changes = Automerge.getChanges(doc1, doc2) let changes = Automerge.getChanges(doc1, doc2)
let docB1 = Automerge.init() let docB1 = Automerge.init()
@ -209,6 +209,15 @@ describe('Automerge', () => {
assert.deepEqual(docB2, doc2); assert.deepEqual(docB2, doc2);
}) })
it('handle non-text strings', () => {
let doc1 = WASM.create();
doc1.put("_root", "text", "hello world");
let doc2 = Automerge.load(doc1.save())
assert.throws(() => {
Automerge.change(doc2, (d) => { Automerge.splice(d, "text", 1, 0, "Z") })
}, /Cannot splice/)
})
it('have many list methods', () => { it('have many list methods', () => {
let doc1 = Automerge.from({ list: [1,2,3] }) let doc1 = Automerge.from({ list: [1,2,3] })
assert.deepEqual(doc1, { list: [1,2,3] }); assert.deepEqual(doc1, { list: [1,2,3] });
@ -240,7 +249,7 @@ describe('Automerge', () => {
}) })
it('lists and text have indexof', () => { it('lists and text have indexof', () => {
let doc = Automerge.from({ list: [0,1,2,3,4,5,6], text: new Automerge.Text("hello world") }) let doc = Automerge.from({ list: [0,1,2,3,4,5,6], text: "hello world" })
console.log(doc.list.indexOf(5)) console.log(doc.list.indexOf(5))
console.log(doc.text.indexOf("world")) console.log(doc.text.indexOf("world"))
}) })
@ -313,7 +322,7 @@ describe('Automerge', () => {
"date": new Date(), "date": new Date(),
"counter": new Automerge.Counter(), "counter": new Automerge.Counter(),
"bytes": new Uint8Array(10), "bytes": new Uint8Array(10),
"text": new Automerge.Text(), "text": "",
"list": [], "list": [],
"map": {} "map": {}
}) })
@ -332,7 +341,7 @@ describe('Automerge', () => {
}) })
it("should return non-null for map, list, text, and objects", () => { it("should return non-null for map, list, text, and objects", () => {
assert.notEqual(Automerge.getObjectId(s1.text), null) assert.equal(Automerge.getObjectId(s1.text), null)
assert.notEqual(Automerge.getObjectId(s1.list), null) assert.notEqual(Automerge.getObjectId(s1.list), null)
assert.notEqual(Automerge.getObjectId(s1.map), null) assert.notEqual(Automerge.getObjectId(s1.map), null)
}) })

View file

@ -4,7 +4,7 @@ import { assertEqualsOneOf } from './helpers'
import { decodeChange } from './legacy/columnar' import { decodeChange } from './legacy/columnar'
const UUID_PATTERN = /^[0-9a-f]{32}$/ const UUID_PATTERN = /^[0-9a-f]{32}$/
const OPID_PATTERN = /^[0-9]+@[0-9a-f]{32}$/ const OPID_PATTERN = /^[0-9]+@([0-9a-f][0-9a-f])*$/
// CORE FEATURES // CORE FEATURES
// //
@ -75,7 +75,7 @@ describe('Automerge', () => {
describe('sequential use', () => { describe('sequential use', () => {
let s1, s2 let s1, s2
beforeEach(() => { beforeEach(() => {
s1 = Automerge.init() s1 = Automerge.init("aabbcc")
}) })
it('should not mutate objects', () => { it('should not mutate objects', () => {
@ -93,7 +93,11 @@ describe('Automerge', () => {
assert.deepStrictEqual(change, { assert.deepStrictEqual(change, {
actor: change.actor, deps: [], seq: 1, startOp: 1, actor: change.actor, deps: [], seq: 1, startOp: 1,
hash: change.hash, message: '', time: change.time, hash: change.hash, message: '', time: change.time,
ops: [{obj: '_root', key: 'foo', action: 'set', insert: false, value: 'bar', pred: []}] ops: [
{obj: '_root', key: 'foo', action: 'makeText', insert: false, 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' }]
}) })
}) })
@ -287,11 +291,12 @@ describe('Automerge', () => {
}, doc => { }, doc => {
doc.birds = ['Goldfinch'] doc.birds = ['Goldfinch']
}) })
assert.strictEqual(callbacks.length, 2) assert.strictEqual(callbacks.length, 1)
assert.deepStrictEqual(callbacks[0].patch, { action: "put", path: ["birds"], value: [], conflict: false}) assert.deepStrictEqual(callbacks[0].patch[0], { action: "put", path: ["birds"], value: [] })
assert.deepStrictEqual(callbacks[1].patch, { action: "splice", path: ["birds",0], values: ["Goldfinch"] }) 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, s1) assert.strictEqual(callbacks[0].before, s1)
assert.strictEqual(callbacks[1].after, s2) assert.strictEqual(callbacks[0].after, s2)
}) })
it('should call a patchCallback set up on document initialisation', () => { it('should call a patchCallback set up on document initialisation', () => {
@ -302,8 +307,11 @@ describe('Automerge', () => {
const s2 = Automerge.change(s1, doc => doc.bird = 'Goldfinch') const s2 = Automerge.change(s1, doc => doc.bird = 'Goldfinch')
const actor = Automerge.getActorId(s1) const actor = Automerge.getActorId(s1)
assert.strictEqual(callbacks.length, 1) assert.strictEqual(callbacks.length, 1)
assert.deepStrictEqual(callbacks[0].patch, { assert.deepStrictEqual(callbacks[0].patch[0], {
action: "put", path: ["bird"], value: "Goldfinch", conflict: false action: "put", path: ["bird"], value: ""
})
assert.deepStrictEqual(callbacks[0].patch[1], {
action: "splice", path: ["bird", 0], value: "Goldfinch"
}) })
assert.strictEqual(callbacks[0].before, s1) assert.strictEqual(callbacks[0].before, s1)
assert.strictEqual(callbacks[0].after, s2) assert.strictEqual(callbacks[0].after, s2)
@ -868,20 +876,20 @@ describe('Automerge', () => {
s1 = Automerge.change(s1, doc => doc.birds = ['finch']) s1 = Automerge.change(s1, doc => doc.birds = ['finch'])
s2 = Automerge.merge(s2, s1) s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.birds[0] = 'greenfinch') s1 = Automerge.change(s1, doc => doc.birds[0] = 'greenfinch')
s2 = Automerge.change(s2, doc => doc.birds[0] = 'goldfinch') s2 = Automerge.change(s2, doc => doc.birds[0] = 'goldfinch_')
s3 = Automerge.merge(s1, s2) s3 = Automerge.merge(s1, s2)
if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) { if (Automerge.getActorId(s1) > Automerge.getActorId(s2)) {
assert.deepStrictEqual(s3.birds, ['greenfinch']) assert.deepStrictEqual(s3.birds, ['greenfinch'])
} else { } else {
assert.deepStrictEqual(s3.birds, ['goldfinch']) assert.deepStrictEqual(s3.birds, ['goldfinch_'])
} }
assert.deepStrictEqual(Automerge.getConflicts(s3.birds, 0), { assert.deepStrictEqual(Automerge.getConflicts(s3.birds, 0), {
[`3@${Automerge.getActorId(s1)}`]: 'greenfinch', [`8@${Automerge.getActorId(s1)}`]: 'greenfinch',
[`3@${Automerge.getActorId(s2)}`]: 'goldfinch' [`8@${Automerge.getActorId(s2)}`]: 'goldfinch_'
}) })
}) })
it.skip('should handle assignment conflicts of different types', () => { it('should handle assignment conflicts of different types', () => {
s1 = Automerge.change(s1, doc => doc.field = 'string') s1 = Automerge.change(s1, doc => doc.field = 'string')
s2 = Automerge.change(s2, doc => doc.field = ['list']) s2 = Automerge.change(s2, doc => doc.field = ['list'])
s3 = Automerge.change(s3, doc => doc.field = {thing: 'map'}) s3 = Automerge.change(s3, doc => doc.field = {thing: 'map'})
@ -906,8 +914,7 @@ describe('Automerge', () => {
}) })
}) })
// FIXME - difficult bug here - patches arrive for conflicted subobject it('should handle changes within a conflicting list element', () => {
it.skip('should handle changes within a conflicting list element', () => {
s1 = Automerge.change(s1, doc => doc.list = ['hello']) s1 = Automerge.change(s1, doc => doc.list = ['hello'])
s2 = Automerge.merge(s2, s1) s2 = Automerge.merge(s2, s1)
s1 = Automerge.change(s1, doc => doc.list[0] = {map1: true}) s1 = Automerge.change(s1, doc => doc.list[0] = {map1: true})
@ -921,8 +928,8 @@ describe('Automerge', () => {
assert.deepStrictEqual(s3.list, [{map2: true, key: 2}]) assert.deepStrictEqual(s3.list, [{map2: true, key: 2}])
} }
assert.deepStrictEqual(Automerge.getConflicts(s3.list, 0), { assert.deepStrictEqual(Automerge.getConflicts(s3.list, 0), {
[`3@${Automerge.getActorId(s1)}`]: {map1: true, key: 1}, [`8@${Automerge.getActorId(s1)}`]: {map1: true, key: 1},
[`3@${Automerge.getActorId(s2)}`]: {map2: true, key: 2} [`8@${Automerge.getActorId(s2)}`]: {map2: true, key: 2}
}) })
}) })
@ -1154,7 +1161,8 @@ describe('Automerge', () => {
hash: changes12[0].hash, actor: '01234567', seq: 1, startOp: 1, hash: changes12[0].hash, actor: '01234567', seq: 1, startOp: 1,
time: changes12[0].time, message: '', deps: [], ops: [ time: changes12[0].time, message: '', deps: [], ops: [
{obj: '_root', action: 'makeList', key: 'list', insert: false, pred: []}, {obj: '_root', action: 'makeList', key: 'list', insert: false, pred: []},
{obj: listId, action: 'set', elemId: '_head', insert: true, value: 'a', 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 s3 = Automerge.change(s2, doc => doc.list.deleteAt(0))
@ -1163,9 +1171,10 @@ describe('Automerge', () => {
const changes45 = Automerge.getAllChanges(s5).map(decodeChange) const changes45 = Automerge.getAllChanges(s5).map(decodeChange)
assert.deepStrictEqual(s5, {list: ['b']}) assert.deepStrictEqual(s5, {list: ['b']})
assert.deepStrictEqual(changes45[2], { assert.deepStrictEqual(changes45[2], {
hash: changes45[2].hash, actor: '01234567', seq: 3, startOp: 4, hash: changes45[2].hash, actor: '01234567', seq: 3, startOp: 5,
time: changes45[2].time, message: '', deps: [changes45[1].hash], ops: [ time: changes45[2].time, message: '', deps: [changes45[1].hash], ops: [
{obj: listId, action: 'set', elemId: '_head', insert: true, value: 'b', pred: []} {obj: listId, action: 'makeText', elemId: '_head', insert: true, pred: []},
{obj: "5@01234567", action: 'set', elemId: '_head', insert: true, value: 'b', pred: []}
] ]
}) })
}) })
@ -1305,8 +1314,8 @@ describe('Automerge', () => {
// TEXT // TEXT
it('should handle updates to a text object', () => { it('should handle updates to a text object', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.text = new Automerge.Text('ab')) let s1 = Automerge.change(Automerge.init(), doc => doc.text = 'ab')
let s2 = Automerge.change(s1, doc => doc.text.set(0, 'A')) let s2 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 1, "A"))
let [s3] = Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s2)) let [s3] = Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s2))
assert.deepStrictEqual([...s3.text], ['A', 'b']) assert.deepStrictEqual([...s3.text], ['A', 'b'])
}) })
@ -1352,11 +1361,12 @@ describe('Automerge', () => {
callbacks.push({patch, before, after}) callbacks.push({patch, before, after})
} }
}) })
assert.strictEqual(callbacks.length, 2) assert.strictEqual(callbacks.length, 1)
assert.deepStrictEqual(callbacks[0].patch, { action: 'put', path: ["birds"], value: [], conflict: false }) assert.deepStrictEqual(callbacks[0].patch[0], { action: 'put', path: ["birds"], value: [] })
assert.deepStrictEqual(callbacks[1].patch, { action: 'splice', path: ["birds",0], values: ["Goldfinch"] }) 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].before, before)
assert.strictEqual(callbacks[1].after, after) assert.strictEqual(callbacks[0].after, after)
}) })
it('should merge multiple applied changes into one patch', () => { it('should merge multiple applied changes into one patch', () => {
@ -1364,23 +1374,24 @@ describe('Automerge', () => {
const s2 = Automerge.change(s1, doc => doc.birds.push('Chaffinch')) const s2 = Automerge.change(s1, doc => doc.birds.push('Chaffinch'))
const patches = [], actor = Automerge.getActorId(s2) const patches = [], actor = Automerge.getActorId(s2)
Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s2), Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s2),
{patchCallback: p => patches.push(p)}) {patchCallback: p => patches.push(... p)})
assert.deepStrictEqual(patches, [ assert.deepStrictEqual(patches, [
{ action: 'put', conflict: false, path: [ 'birds' ], value: [] }, { action: 'put', path: [ 'birds' ], value: [] },
{ action: "splice", path: [ "birds", 0 ], values: [ "Goldfinch", "Chaffinch" ] } { action: "insert", path: [ "birds", 0 ], values: [ "" ] },
{ action: "splice", path: [ "birds", 0, 0 ], value: "Goldfinch" },
{ action: "insert", path: [ "birds", 1 ], values: [ "" ] },
{ action: "splice", path: [ "birds", 1, 0 ], value: "Chaffinch" }
]) ])
}) })
it('should call a patchCallback registered on doc initialisation', () => { it('should call a patchCallback registered on doc initialisation', () => {
const s1 = Automerge.change(Automerge.init(), doc => doc.bird = 'Goldfinch') const s1 = Automerge.change(Automerge.init(), doc => doc.bird = 'Goldfinch')
const patches = [], actor = Automerge.getActorId(s1) const patches = [], actor = Automerge.getActorId(s1)
const before = Automerge.init({patchCallback: p => patches.push(p)}) const before = Automerge.init({patchCallback: p => patches.push(... p)})
Automerge.applyChanges(before, Automerge.getAllChanges(s1)) Automerge.applyChanges(before, Automerge.getAllChanges(s1))
assert.deepStrictEqual(patches, [{ assert.deepStrictEqual(patches, [
action: "put", { action: "put", path: [ "bird" ], value: "" },
conflict: false, { action: "splice", path: [ "bird", 0 ], value: "Goldfinch" }
path: [ "bird" ],
value: "Goldfinch" }
]) ])
}) })
}) })

View file

@ -527,6 +527,7 @@ describe('Data sync protocol', () => {
assert.deepStrictEqual(getHeads(n2), [n1hash2, n2hash2].sort()) assert.deepStrictEqual(getHeads(n2), [n1hash2, n2hash2].sort())
}) })
// FIXME - this has a periodic failure
it('should sync two nodes with connection reset', () => { it('should sync two nodes with connection reset', () => {
s1 = decodeSyncState(encodeSyncState(s1)) s1 = decodeSyncState(encodeSyncState(s1))
s2 = decodeSyncState(encodeSyncState(s2)) s2 = decodeSyncState(encodeSyncState(s2))

View file

@ -197,502 +197,102 @@ function applyDeltaDocToAutomergeText(delta, doc) {
describe('Automerge.Text', () => { describe('Automerge.Text', () => {
let s1, s2 let s1, s2
beforeEach(() => { beforeEach(() => {
s1 = Automerge.change(Automerge.init(), doc => doc.text = new Automerge.Text()) s1 = Automerge.change(Automerge.init(), doc => doc.text = "")
s2 = Automerge.merge(Automerge.init(), s1) s2 = Automerge.merge(Automerge.init(), s1)
}) })
it('should support insertion', () => { it('should support insertion', () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, 'a')) s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, "a"))
assert.strictEqual(s1.text.length, 1) assert.strictEqual(s1.text.length, 1)
assert.strictEqual(s1.text.get(0), 'a') assert.strictEqual(s1.text[0], 'a')
assert.strictEqual(s1.text.toString(), 'a') assert.strictEqual(s1.text, 'a')
//assert.strictEqual(s1.text.getElemId(0), `2@${Automerge.getActorId(s1)}`) //assert.strictEqual(s1.text.getElemId(0), `2@${Automerge.getActorId(s1)}`)
}) })
it('should support deletion', () => { it('should support deletion', () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, 'a', 'b', 'c')) s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, "abc"))
s1 = Automerge.change(s1, doc => doc.text.deleteAt(1, 1)) s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 1, 1))
assert.strictEqual(s1.text.length, 2) assert.strictEqual(s1.text.length, 2)
assert.strictEqual(s1.text.get(0), 'a') assert.strictEqual(s1.text[0], 'a')
assert.strictEqual(s1.text.get(1), 'c') assert.strictEqual(s1.text[1], 'c')
assert.strictEqual(s1.text.toString(), 'ac') assert.strictEqual(s1.text, 'ac')
}) })
it("should support implicit and explicit deletion", () => { it("should support implicit and explicit deletion", () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, "a", "b", "c")) s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, "abc"))
s1 = Automerge.change(s1, doc => doc.text.deleteAt(1)) s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 1, 1))
s1 = Automerge.change(s1, doc => doc.text.deleteAt(1, 0)) s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 1, 0))
assert.strictEqual(s1.text.length, 2) assert.strictEqual(s1.text.length, 2)
assert.strictEqual(s1.text.get(0), "a") assert.strictEqual(s1.text[0], "a")
assert.strictEqual(s1.text.get(1), "c") assert.strictEqual(s1.text[1], "c")
assert.strictEqual(s1.text.toString(), "ac") assert.strictEqual(s1.text, "ac")
}) })
it('should handle concurrent insertion', () => { it('should handle concurrent insertion', () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, 'a', 'b', 'c')) s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, "abc"))
s2 = Automerge.change(s2, doc => doc.text.insertAt(0, 'x', 'y', 'z')) s2 = Automerge.change(s2, doc => Automerge.splice(doc, "text", 0, 0, "xyz"))
s1 = Automerge.merge(s1, s2) s1 = Automerge.merge(s1, s2)
assert.strictEqual(s1.text.length, 6) assert.strictEqual(s1.text.length, 6)
assertEqualsOneOf(s1.text.toString(), 'abcxyz', 'xyzabc') assertEqualsOneOf(s1.text, 'abcxyz', 'xyzabc')
assertEqualsOneOf(s1.text.join(''), 'abcxyz', 'xyzabc')
}) })
it('should handle text and other ops in the same change', () => { it('should handle text and other ops in the same change', () => {
s1 = Automerge.change(s1, doc => { s1 = Automerge.change(s1, doc => {
doc.foo = 'bar' doc.foo = 'bar'
doc.text.insertAt(0, 'a') Automerge.splice(doc, "text", 0, 0, 'a')
}) })
assert.strictEqual(s1.foo, 'bar') assert.strictEqual(s1.foo, 'bar')
assert.strictEqual(s1.text.toString(), 'a') assert.strictEqual(s1.text, 'a')
assert.strictEqual(s1.text.join(''), 'a') assert.strictEqual(s1.text, 'a')
}) })
it('should serialize to JSON as a simple string', () => { it('should serialize to JSON as a simple string', () => {
s1 = Automerge.change(s1, doc => doc.text.insertAt(0, 'a', '"', 'b')) s1 = Automerge.change(s1, doc => Automerge.splice(doc, "text", 0, 0, 'a"b'))
assert.strictEqual(JSON.stringify(s1), '{"text":"a\\"b"}') assert.strictEqual(JSON.stringify(s1), '{"text":"a\\"b"}')
}) })
it('should allow modification before an object is assigned to a document', () => {
s1 = Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text()
text.insertAt(0, 'a', 'b', 'c', 'd')
text.deleteAt(2)
doc.text = text
assert.strictEqual(doc.text.toString(), 'abd')
assert.strictEqual(doc.text.join(''), 'abd')
})
assert.strictEqual(s1.text.toString(), 'abd')
assert.strictEqual(s1.text.join(''), 'abd')
})
it('should allow modification after an object is assigned to a document', () => { it('should allow modification after an object is assigned to a document', () => {
s1 = Automerge.change(Automerge.init(), doc => { s1 = Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text() doc.text = ""
doc.text = text Automerge.splice(doc ,"text", 0, 0, 'abcd')
doc.text.insertAt(0, 'a', 'b', 'c', 'd') Automerge.splice(doc ,"text", 2, 1)
doc.text.deleteAt(2) assert.strictEqual(doc.text, 'abd')
assert.strictEqual(doc.text.toString(), 'abd')
assert.strictEqual(doc.text.join(''), 'abd')
}) })
assert.strictEqual(s1.text.join(''), 'abd') assert.strictEqual(s1.text, 'abd')
}) })
it('should not allow modification outside of a change callback', () => { it('should not allow modification outside of a change callback', () => {
assert.throws(() => s1.text.insertAt(0, 'a'), /object cannot be modified outside of a change block/) assert.throws(() => Automerge.splice(s1 ,"text", 0, 0, 'a'), /object cannot be modified outside of a change block/)
}) })
describe('with initial value', () => { describe('with initial value', () => {
it('should accept a string as initial value', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.text = new Automerge.Text('init'))
assert.strictEqual(s1.text.length, 4)
assert.strictEqual(s1.text.get(0), 'i')
assert.strictEqual(s1.text.get(1), 'n')
assert.strictEqual(s1.text.get(2), 'i')
assert.strictEqual(s1.text.get(3), 't')
assert.strictEqual(s1.text.toString(), 'init')
})
it('should accept an array as initial value', () => {
let s1 = Automerge.change(Automerge.init(), doc => doc.text = new Automerge.Text(['i', 'n', 'i', 't']))
assert.strictEqual(s1.text.length, 4)
assert.strictEqual(s1.text.get(0), 'i')
assert.strictEqual(s1.text.get(1), 'n')
assert.strictEqual(s1.text.get(2), 'i')
assert.strictEqual(s1.text.get(3), 't')
assert.strictEqual(s1.text.toString(), 'init')
})
it('should initialize text in Automerge.from()', () => { it('should initialize text in Automerge.from()', () => {
let s1 = Automerge.from({text: new Automerge.Text('init')}) let s1 = Automerge.from({text: 'init'})
assert.strictEqual(s1.text.length, 4) assert.strictEqual(s1.text.length, 4)
assert.strictEqual(s1.text.get(0), 'i') assert.strictEqual(s1.text[0], 'i')
assert.strictEqual(s1.text.get(1), 'n') assert.strictEqual(s1.text[1], 'n')
assert.strictEqual(s1.text.get(2), 'i') assert.strictEqual(s1.text[2], 'i')
assert.strictEqual(s1.text.get(3), 't') assert.strictEqual(s1.text[3], 't')
assert.strictEqual(s1.text.toString(), 'init') assert.strictEqual(s1.text, 'init')
}) })
it('should encode the initial value as a change', () => { it('should encode the initial value as a change', () => {
const s1 = Automerge.from({text: new Automerge.Text('init')}) const s1 = Automerge.from({text: 'init'})
const changes = Automerge.getAllChanges(s1) const changes = Automerge.getAllChanges(s1)
assert.strictEqual(changes.length, 1) assert.strictEqual(changes.length, 1)
const [s2] = Automerge.applyChanges(Automerge.init(), changes) const [s2] = Automerge.applyChanges(Automerge.init(), changes)
assert.strictEqual(s2.text instanceof Automerge.Text, true) assert.strictEqual(s2.text, 'init')
assert.strictEqual(s2.text.toString(), 'init') assert.strictEqual(s2.text, 'init')
assert.strictEqual(s2.text.join(''), 'init')
}) })
it('should allow immediate access to the value', () => {
Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text('init')
assert.strictEqual(text.length, 4)
assert.strictEqual(text.get(0), 'i')
assert.strictEqual(text.toString(), 'init')
doc.text = text
assert.strictEqual(doc.text.length, 4)
assert.strictEqual(doc.text.get(0), 'i')
assert.strictEqual(doc.text.toString(), 'init')
})
})
it('should allow pre-assignment modification of the initial value', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text('init')
text.deleteAt(3)
assert.strictEqual(text.join(''), 'ini')
doc.text = text
assert.strictEqual(doc.text.join(''), 'ini')
assert.strictEqual(doc.text.toString(), 'ini')
})
assert.strictEqual(s1.text.toString(), 'ini')
assert.strictEqual(s1.text.join(''), 'ini')
})
it('should allow post-assignment modification of the initial value', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
const text = new Automerge.Text('init')
doc.text = text
doc.text.deleteAt(0)
doc.text.insertAt(0, 'I')
assert.strictEqual(doc.text.join(''), 'Init')
assert.strictEqual(doc.text.toString(), 'Init')
})
assert.strictEqual(s1.text.join(''), 'Init')
assert.strictEqual(s1.text.toString(), 'Init')
})
})
describe('non-textual control characters', () => {
let s1
beforeEach(() => {
s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text()
doc.text.insertAt(0, 'a')
doc.text.insertAt(1, { attribute: 'bold' })
})
})
it('should allow fetching non-textual characters', () => {
assert.deepEqual(s1.text.get(1), { attribute: 'bold' })
//assert.strictEqual(s1.text.getElemId(1), `3@${Automerge.getActorId(s1)}`)
})
it('should include control characters in string length', () => {
assert.strictEqual(s1.text.length, 2)
assert.strictEqual(s1.text.get(0), 'a')
})
it('should replace control characters from toString()', () => {
assert.strictEqual(s1.text.toString(), 'a\uFFFC')
})
it('should allow control characters to be updated', () => {
const s2 = Automerge.change(s1, doc => doc.text.get(1).attribute = 'italic')
const s3 = Automerge.load(Automerge.save(s2))
assert.strictEqual(s1.text.get(1).attribute, 'bold')
assert.strictEqual(s2.text.get(1).attribute, 'italic')
assert.strictEqual(s3.text.get(1).attribute, 'italic')
})
describe('spans interface to Text', () => {
it('should return a simple string as a single span', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('hello world')
})
assert.deepEqual(s1.text.toSpans(), ['hello world'])
})
it('should return an empty string as an empty array', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text()
})
assert.deepEqual(s1.text.toSpans(), [])
})
it('should split a span at a control character', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('hello world')
doc.text.insertAt(5, { attributes: { bold: true } })
})
assert.deepEqual(s1.text.toSpans(),
['hello', { attributes: { bold: true } }, ' world'])
})
it('should allow consecutive control characters', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('hello world')
doc.text.insertAt(5, { attributes: { bold: true } })
doc.text.insertAt(6, { attributes: { italic: true } })
})
assert.deepEqual(s1.text.toSpans(),
['hello',
{ attributes: { bold: true } },
{ attributes: { italic: true } },
' world'
])
})
it('should allow non-consecutive control characters', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('hello world')
doc.text.insertAt(5, { attributes: { bold: true } })
doc.text.insertAt(12, { attributes: { italic: true } })
})
assert.deepEqual(s1.text.toSpans(),
['hello',
{ attributes: { bold: true } },
' world',
{ attributes: { italic: true } }
])
})
it('should be convertable into a Quill delta', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Gandalf the Grey')
doc.text.insertAt(0, { attributes: { bold: true } })
doc.text.insertAt(7 + 1, { attributes: { bold: null } })
doc.text.insertAt(12 + 2, { attributes: { color: '#cccccc' } })
})
let deltaDoc = automergeTextToDeltaDoc(s1.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [
{ insert: 'Gandalf', attributes: { bold: true } },
{ insert: ' the ' },
{ insert: 'Grey', attributes: { color: '#cccccc' } }
]
assert.deepEqual(deltaDoc, expectedDoc)
})
it('should support embeds', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('')
doc.text.insertAt(0, { attributes: { link: 'https://quilljs.com' } })
doc.text.insertAt(1, {
image: 'https://quilljs.com/assets/images/icon.png'
})
doc.text.insertAt(2, { attributes: { link: null } })
})
let deltaDoc = automergeTextToDeltaDoc(s1.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [{
// An image link
insert: {
image: 'https://quilljs.com/assets/images/icon.png'
},
attributes: {
link: 'https://quilljs.com'
}
}]
assert.deepEqual(deltaDoc, expectedDoc)
})
it('should handle concurrent overlapping spans', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Gandalf the Grey')
})
let s2 = Automerge.merge(Automerge.init(), s1)
let s3 = Automerge.change(s1, doc => {
doc.text.insertAt(8, { attributes: { bold: true } })
doc.text.insertAt(16 + 1, { attributes: { bold: null } })
})
let s4 = Automerge.change(s2, doc => {
doc.text.insertAt(0, { attributes: { bold: true } })
doc.text.insertAt(11 + 1, { attributes: { bold: null } })
})
let merged = Automerge.merge(s3, s4)
let deltaDoc = automergeTextToDeltaDoc(merged.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [
{ insert: 'Gandalf the Grey', attributes: { bold: true } },
]
assert.deepEqual(deltaDoc, expectedDoc)
})
it('should handle debolding spans', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Gandalf the Grey')
})
let s2 = Automerge.merge(Automerge.init(), s1)
let s3 = Automerge.change(s1, doc => {
doc.text.insertAt(0, { attributes: { bold: true } })
doc.text.insertAt(16 + 1, { attributes: { bold: null } })
})
let s4 = Automerge.change(s2, doc => {
doc.text.insertAt(8, { attributes: { bold: null } })
doc.text.insertAt(11 + 1, { attributes: { bold: true } })
})
let merged = Automerge.merge(s3, s4)
let deltaDoc = automergeTextToDeltaDoc(merged.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [
{ insert: 'Gandalf ', attributes: { bold: true } },
{ insert: 'the' },
{ insert: ' Grey', attributes: { bold: true } },
]
assert.deepEqual(deltaDoc, expectedDoc)
})
// xxx: how would this work for colors?
it('should handle destyling across destyled spans', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Gandalf the Grey')
})
let s2 = Automerge.merge(Automerge.init(), s1)
let s3 = Automerge.change(s1, doc => {
doc.text.insertAt(0, { attributes: { bold: true } })
doc.text.insertAt(16 + 1, { attributes: { bold: null } })
})
let s4 = Automerge.change(s2, doc => {
doc.text.insertAt(8, { attributes: { bold: null } })
doc.text.insertAt(11 + 1, { attributes: { bold: true } })
})
let merged = Automerge.merge(s3, s4)
let final = Automerge.change(merged, doc => {
doc.text.insertAt(3 + 1, { attributes: { bold: null } })
doc.text.insertAt(doc.text.length, { attributes: { bold: true } })
})
let deltaDoc = automergeTextToDeltaDoc(final.text)
// From https://quilljs.com/docs/delta/
let expectedDoc = [
{ insert: 'Gan', attributes: { bold: true } },
{ insert: 'dalf the Grey' },
]
assert.deepEqual(deltaDoc, expectedDoc)
})
it('should apply an insert', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Hello world')
})
const delta = [
{ retain: 6 },
{ insert: 'reader' },
{ delete: 5 }
]
let s2 = Automerge.change(s1, doc => {
applyDeltaDocToAutomergeText(delta, doc)
})
//assert.strictEqual(s2.text.join(''), 'Hello reader')
assert.strictEqual(s2.text.toString(), 'Hello reader')
})
it('should apply an insert with control characters', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Hello world')
})
const delta = [
{ retain: 6 },
{ insert: 'reader', attributes: { bold: true } },
{ delete: 5 },
{ insert: '!' }
]
let s2 = Automerge.change(s1, doc => {
applyDeltaDocToAutomergeText(delta, doc)
})
assert.strictEqual(s2.text.toString(), 'Hello \uFFFCreader\uFFFC!')
assert.deepEqual(s2.text.toSpans(), [
"Hello ",
{ attributes: { bold: true } },
"reader",
{ attributes: { bold: null } },
"!"
])
})
it('should account for control characters in retain/delete lengths', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('Hello world')
doc.text.insertAt(4, { attributes: { color: '#ccc' } })
doc.text.insertAt(10, { attributes: { color: '#f00' } })
})
const delta = [
{ retain: 6 },
{ insert: 'reader', attributes: { bold: true } },
{ delete: 5 },
{ insert: '!' }
]
let s2 = Automerge.change(s1, doc => {
applyDeltaDocToAutomergeText(delta, doc)
})
assert.strictEqual(s2.text.toString(), 'Hell\uFFFCo \uFFFCreader\uFFFC\uFFFC!')
assert.deepEqual(s2.text.toSpans(), [
"Hell",
{ attributes: { color: '#ccc'} },
"o ",
{ attributes: { bold: true } },
"reader",
{ attributes: { bold: null } },
{ attributes: { color: '#f00'} },
"!"
])
})
it('should support embeds', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text('')
})
let deltaDoc = [{
// An image link
insert: {
image: 'https://quilljs.com/assets/images/icon.png'
},
attributes: {
link: 'https://quilljs.com'
}
}]
let s2 = Automerge.change(s1, doc => {
applyDeltaDocToAutomergeText(deltaDoc, doc)
})
assert.deepEqual(s2.text.toSpans(), [
{ attributes: { link: 'https://quilljs.com' } },
{ image: 'https://quilljs.com/assets/images/icon.png'},
{ attributes: { link: null } },
])
})
})
}) })
it('should support unicode when creating text', () => { it('should support unicode when creating text', () => {
s1 = Automerge.from({ s1 = Automerge.from({
text: new Automerge.Text('🐦') text: '🐦'
}) })
assert.strictEqual(s1.text.get(0), '🐦') // TODO utf16 indexing
assert.strictEqual(s1.text, '🐦')
}) })
}) })

View file

@ -1,3 +1,10 @@
automerge automerge
automerge.h automerge.h
automerge.o automerge.o
*.cmake
CMakeFiles
Makefile
DartConfiguration.tcl
config.h
CMakeCache.txt
Cargo

View file

@ -34,6 +34,7 @@ serde_bytes = "0.11.5"
hex = "^0.4.3" hex = "^0.4.3"
regex = "^1.5" regex = "^1.5"
itertools = "^0.10.3" itertools = "^0.10.3"
ropey = "1.5.0"
[dependencies.wasm-bindgen] [dependencies.wasm-bindgen]
version = "^0.2.83" version = "^0.2.83"

View file

@ -154,7 +154,7 @@ Lists are index addressable sets of values. These values can be any scalar or o
### Text ### Text
Text is a specialized list type intended for modifying a text document. The primary way to interact with a text document is via the `splice()` method. Spliced strings will be indexable by character (important to note for platforms that index by graphmeme cluster). Non text can be inserted into a text document and will be represented with the unicode object replacement character. Text is a specialized list type intended for modifying a text document. The primary way to interact with a text document is via the `splice()` method. Spliced strings will be indexable by character (important to note for platforms that index by graphmeme cluster).
```javascript ```javascript
let doc = create("aaaaaa") let doc = create("aaaaaa")
@ -162,12 +162,6 @@ Text is a specialized list type intended for modifying a text document. The pri
doc.splice(notes, 6, 5, "everyone") doc.splice(notes, 6, 5, "everyone")
doc.text(notes) // returns "Hello everyone" doc.text(notes) // returns "Hello everyone"
let obj = doc.insertObject(notes, 6, { hi: "there" })
doc.text(notes) // returns "Hello \ufffceveryone"
doc.getWithType(notes, 6) // returns ["map", obj]
doc.get(obj, "hi") // returns "there"
``` ```
### Tables ### Tables

View file

@ -34,7 +34,7 @@
"target": "rimraf ./$TARGET && yarn compile && yarn bindgen && yarn opt", "target": "rimraf ./$TARGET && yarn compile && yarn bindgen && yarn opt",
"compile": "cargo build --target wasm32-unknown-unknown --profile $PROFILE", "compile": "cargo build --target wasm32-unknown-unknown --profile $PROFILE",
"bindgen": "wasm-bindgen --no-typescript --weak-refs --target $TARGET --out-dir $TARGET ../target/wasm32-unknown-unknown/$TARGET_DIR/automerge_wasm.wasm", "bindgen": "wasm-bindgen --no-typescript --weak-refs --target $TARGET --out-dir $TARGET ../target/wasm32-unknown-unknown/$TARGET_DIR/automerge_wasm.wasm",
"opt": "wasm-opt -O4 $TARGET/automerge_wasm_bg.wasm -o $TARGET/automerge_wasm_bg.wasm", "opt": "wasm-opt -Oz $TARGET/automerge_wasm_bg.wasm -o $TARGET/automerge_wasm_bg.wasm",
"test": "ts-mocha -p tsconfig.json --type-check --bail --full-trace test/*.ts" "test": "ts-mocha -p tsconfig.json --type-check --bail --full-trace test/*.ts"
}, },
"devDependencies": { "devDependencies": {

View file

@ -2,8 +2,9 @@ use crate::value::Datatype;
use crate::Automerge; use crate::Automerge;
use automerge as am; use automerge as am;
use automerge::transaction::Transactable; use automerge::transaction::Transactable;
use automerge::ROOT;
use automerge::{Change, ChangeHash, ObjType, Prop}; use automerge::{Change, ChangeHash, ObjType, Prop};
use js_sys::{Array, Function, Object, Reflect, Symbol, Uint8Array}; use js_sys::{Array, Function, JsString, Object, Reflect, Symbol, Uint8Array};
use std::collections::{BTreeSet, HashSet}; use std::collections::{BTreeSet, HashSet};
use std::fmt::Display; use std::fmt::Display;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
@ -348,13 +349,8 @@ pub(crate) fn to_objtype(
.map(|(key, val)| (key.as_string().unwrap().into(), val)) .map(|(key, val)| (key.as_string().unwrap().into(), val))
.collect(); .collect();
Some((ObjType::Map, map)) Some((ObjType::Map, map))
} else if let Some(text) = value.as_string() { } else if value.as_string().is_some() {
let text = text Some((ObjType::Text, vec![(0.into(), value.clone())]))
.chars()
.enumerate()
.map(|(i, ch)| (i.into(), ch.to_string().into()))
.collect();
Some((ObjType::Text, text))
} else { } else {
None None
} }
@ -377,22 +373,32 @@ impl Automerge {
heads: Option<&Vec<ChangeHash>>, heads: Option<&Vec<ChangeHash>>,
meta: &JsValue, meta: &JsValue,
) -> Result<JsValue, JsValue> { ) -> Result<JsValue, JsValue> {
let result = if datatype.is_sequence() { let result = match datatype {
self.wrap_object( Datatype::Text => {
if let Some(heads) = heads {
self.doc.text_at(obj, heads)?.into()
} else {
self.doc.text(obj)?.into()
}
}
Datatype::List => self
.wrap_object(
self.export_list(obj, heads, meta)?, self.export_list(obj, heads, meta)?,
datatype, datatype,
&obj.to_string().into(), &obj.to_string().into(),
meta, meta,
)? )?
} else { .into(),
self.wrap_object( _ => self
.wrap_object(
self.export_map(obj, heads, meta)?, self.export_map(obj, heads, meta)?,
datatype, datatype,
&obj.to_string().into(), &obj.to_string().into(),
meta, meta,
)? )?
.into(),
}; };
Ok(result.into()) Ok(result)
} }
pub(crate) fn export_map( pub(crate) fn export_map(
@ -539,7 +545,7 @@ impl Automerge {
} else { } else {
value value
}; };
if matches!(datatype, Datatype::Map | Datatype::List | Datatype::Text) { if matches!(datatype, Datatype::Map | Datatype::List) {
set_hidden_value(&value, &Symbol::for_(RAW_OBJECT_SYMBOL), id)?; set_hidden_value(&value, &Symbol::for_(RAW_OBJECT_SYMBOL), id)?;
} }
set_hidden_value(&value, &Symbol::for_(DATATYPE_SYMBOL), datatype)?; set_hidden_value(&value, &Symbol::for_(DATATYPE_SYMBOL), datatype)?;
@ -555,12 +561,23 @@ impl Automerge {
array: &Object, array: &Object,
patch: &Patch, patch: &Patch,
meta: &JsValue, meta: &JsValue,
exposed: &mut HashSet<ObjId>,
) -> Result<Object, JsValue> { ) -> Result<Object, JsValue> {
let result = Array::from(array); // shallow copy let result = Array::from(array); // shallow copy
match patch { match patch {
Patch::PutSeq { index, value, .. } => { Patch::PutSeq {
index,
value,
expose,
..
} => {
if *expose && value.0.is_object() {
exposed.insert(value.1.clone());
Reflect::set(&result, &(*index as f64).into(), &JsValue::null())?;
} else {
let sub_val = self.maybe_wrap_object(alloc(&value.0), &value.1, meta)?; let sub_val = self.maybe_wrap_object(alloc(&value.0), &value.1, meta)?;
Reflect::set(&result, &(*index as f64).into(), &sub_val)?; Reflect::set(&result, &(*index as f64).into(), &sub_val)?;
}
Ok(result.into()) Ok(result.into())
} }
Patch::DeleteSeq { index, .. } => self.sub_splice(result, *index, 1, vec![], meta), Patch::DeleteSeq { index, .. } => self.sub_splice(result, *index, 1, vec![], meta),
@ -584,6 +601,7 @@ impl Automerge {
} }
Patch::DeleteMap { .. } => Err(to_js_err("cannot delete from a seq")), Patch::DeleteMap { .. } => Err(to_js_err("cannot delete from a seq")),
Patch::PutMap { .. } => Err(to_js_err("cannot set key in seq")), Patch::PutMap { .. } => Err(to_js_err("cannot set key in seq")),
Patch::SpliceText { .. } => Err(to_js_err("cannot splice text in seq")),
} }
} }
@ -592,12 +610,20 @@ impl Automerge {
map: &Object, map: &Object,
patch: &Patch, patch: &Patch,
meta: &JsValue, meta: &JsValue,
exposed: &mut HashSet<ObjId>,
) -> Result<Object, JsValue> { ) -> Result<Object, JsValue> {
let result = Object::assign(&Object::new(), map); // shallow copy let result = Object::assign(&Object::new(), map); // shallow copy
match patch { match patch {
Patch::PutMap { key, value, .. } => { Patch::PutMap {
key, value, expose, ..
} => {
if *expose && value.0.is_object() {
exposed.insert(value.1.clone());
Reflect::set(&result, &key.into(), &JsValue::null())?;
} else {
let sub_val = self.maybe_wrap_object(alloc(&value.0), &value.1, meta)?; let sub_val = self.maybe_wrap_object(alloc(&value.0), &value.1, meta)?;
Reflect::set(&result, &key.into(), &sub_val)?; Reflect::set(&result, &key.into(), &sub_val)?;
}
Ok(result) Ok(result)
} }
Patch::DeleteMap { key, .. } => { Patch::DeleteMap { key, .. } => {
@ -622,6 +648,7 @@ impl Automerge {
} }
} }
Patch::Insert { .. } => Err(to_js_err("cannot insert into map")), Patch::Insert { .. } => Err(to_js_err("cannot insert into map")),
Patch::SpliceText { .. } => Err(to_js_err("cannot Splice into map")),
Patch::DeleteSeq { .. } => Err(to_js_err("cannot splice a map")), Patch::DeleteSeq { .. } => Err(to_js_err("cannot splice a map")),
Patch::PutSeq { .. } => Err(to_js_err("cannot array index a map")), Patch::PutSeq { .. } => Err(to_js_err("cannot array index a map")),
} }
@ -633,12 +660,23 @@ impl Automerge {
patch: &Patch, patch: &Patch,
depth: usize, depth: usize,
meta: &JsValue, meta: &JsValue,
exposed: &mut HashSet<ObjId>,
) -> Result<Object, JsValue> { ) -> Result<Object, JsValue> {
let (inner, datatype, id) = self.unwrap_object(&obj)?; let (inner, datatype, id) = self.unwrap_object(&obj)?;
let prop = patch.path().get(depth).map(|p| prop_to_js(&p.1)); let prop = patch.path().get(depth).map(|p| prop_to_js(&p.1));
let result = if let Some(prop) = prop { let result = if let Some(prop) = prop {
if let Ok(sub_obj) = Reflect::get(&inner, &prop)?.dyn_into::<Object>() { let subval = Reflect::get(&inner, &prop)?;
let new_value = self.apply_patch(sub_obj, patch, depth + 1, meta)?; if subval.is_string() && patch.path().len() - 1 == depth {
if let Ok(s) = subval.dyn_into::<JsString>() {
let new_value = self.apply_patch_to_text(&s, patch)?;
let result = shallow_copy(&inner);
Reflect::set(&result, &prop, &new_value)?;
Ok(result)
} else {
panic!("string is not JsString")
}
} else if let Ok(sub_obj) = Reflect::get(&inner, &prop)?.dyn_into::<Object>() {
let new_value = self.apply_patch(sub_obj, patch, depth + 1, meta, exposed)?;
let result = shallow_copy(&inner); let result = shallow_copy(&inner);
Reflect::set(&result, &prop, &new_value)?; Reflect::set(&result, &prop, &new_value)?;
Ok(result) Ok(result)
@ -648,14 +686,54 @@ impl Automerge {
return Ok(obj); return Ok(obj);
} }
} else if Array::is_array(&inner) { } else if Array::is_array(&inner) {
self.apply_patch_to_array(&inner, patch, meta) if id.as_string() == Some(patch.obj().to_string()) {
self.apply_patch_to_array(&inner, patch, meta, exposed)
} else { } else {
self.apply_patch_to_map(&inner, patch, meta) Ok(Array::from(&inner).into())
}
} else if id.as_string() == Some(patch.obj().to_string()) {
self.apply_patch_to_map(&inner, patch, meta, exposed)
} else {
Ok(Object::assign(&Object::new(), &inner))
}?; }?;
self.wrap_object(result, datatype, &id, meta) self.wrap_object(result, datatype, &id, meta)
} }
fn apply_patch_to_text(&self, string: &JsString, patch: &Patch) -> Result<JsValue, JsValue> {
match patch {
Patch::DeleteSeq { index, .. } => {
let index = *index as u32;
let length = string.length();
let before = string.slice(0, index);
let after = string.slice(index + 1, length);
Ok(before.concat(&after).into())
}
Patch::Insert { index, values, .. } => {
let index = *index as u32;
let length = string.length();
let before = string.slice(0, index);
let after = string.slice(index, length);
let to_insert: String = values
.iter()
.map(|v| v.0.to_str().unwrap_or("\u{fffc}"))
.collect();
Ok(before.concat(&to_insert.into()).concat(&after).into())
}
Patch::SpliceText { index, value, .. } => {
let index = *index as u32;
let length = string.length();
let before = string.slice(0, index);
let after = string.slice(index, length);
Ok(before
.concat(&value.to_string().into())
.concat(&after)
.into())
}
_ => Ok(string.into()),
}
}
fn sub_splice<'a, I: IntoIterator<Item = &'a (Value<'a>, ObjId)>>( fn sub_splice<'a, I: IntoIterator<Item = &'a (Value<'a>, ObjId)>>(
&self, &self,
o: Array, o: Array,
@ -674,6 +752,153 @@ impl Automerge {
Reflect::apply(&method, &o, &args)?; Reflect::apply(&method, &o, &args)?;
Ok(o.into()) Ok(o.into())
} }
pub(crate) fn import(&self, id: JsValue) -> Result<ObjId, JsValue> {
if let Some(s) = id.as_string() {
if let Some(post) = s.strip_prefix('/') {
let mut obj = ROOT;
let mut is_map = true;
let parts = post.split('/');
for prop in parts {
if prop.is_empty() {
break;
}
let val = if is_map {
self.doc.get(obj, prop)?
} else {
self.doc.get(obj, am::Prop::Seq(prop.parse().unwrap()))?
};
match val {
Some((am::Value::Object(ObjType::Map), id)) => {
is_map = true;
obj = id;
}
Some((am::Value::Object(ObjType::Table), id)) => {
is_map = true;
obj = id;
}
Some((am::Value::Object(_), id)) => {
is_map = false;
obj = id;
}
None => return Err(to_js_err(format!("invalid path '{}'", s))),
_ => return Err(to_js_err(format!("path '{}' is not an object", s))),
};
}
Ok(obj)
} else {
Ok(self.doc.import(&s)?)
}
} else {
Err(to_js_err("invalid objid"))
}
}
pub(crate) fn import_prop(&self, prop: JsValue) -> Result<Prop, JsValue> {
if let Some(s) = prop.as_string() {
Ok(s.into())
} else if let Some(n) = prop.as_f64() {
Ok((n as usize).into())
} else {
Err(to_js_err(format!("invalid prop {:?}", prop)))
}
}
pub(crate) fn import_scalar(
&self,
value: &JsValue,
datatype: &Option<String>,
) -> Option<am::ScalarValue> {
match datatype.as_deref() {
Some("boolean") => value.as_bool().map(am::ScalarValue::Boolean),
Some("int") => value.as_f64().map(|v| am::ScalarValue::Int(v as i64)),
Some("uint") => value.as_f64().map(|v| am::ScalarValue::Uint(v as u64)),
// TODO: sym?
Some("str") => value.as_string().map(|v| am::ScalarValue::Str(v.into())),
Some("f64") => value.as_f64().map(am::ScalarValue::F64),
Some("bytes") => Some(am::ScalarValue::Bytes(
value.clone().dyn_into::<Uint8Array>().unwrap().to_vec(),
)),
Some("counter") => value.as_f64().map(|v| am::ScalarValue::counter(v as i64)),
Some("timestamp") => {
if let Some(v) = value.as_f64() {
Some(am::ScalarValue::Timestamp(v as i64))
} else if let Ok(d) = value.clone().dyn_into::<js_sys::Date>() {
Some(am::ScalarValue::Timestamp(d.get_time() as i64))
} else {
None
}
}
Some("null") => Some(am::ScalarValue::Null),
Some(_) => None,
None => {
if value.is_null() {
Some(am::ScalarValue::Null)
} else if let Some(b) = value.as_bool() {
Some(am::ScalarValue::Boolean(b))
} else if let Some(s) = value.as_string() {
Some(am::ScalarValue::Str(s.into()))
} else if let Some(n) = value.as_f64() {
if (n.round() - n).abs() < f64::EPSILON {
Some(am::ScalarValue::Int(n as i64))
} else {
Some(am::ScalarValue::F64(n))
}
} else if let Ok(d) = value.clone().dyn_into::<js_sys::Date>() {
Some(am::ScalarValue::Timestamp(d.get_time() as i64))
} else if let Ok(o) = &value.clone().dyn_into::<Uint8Array>() {
Some(am::ScalarValue::Bytes(o.to_vec()))
} else {
None
}
}
}
}
pub(crate) fn import_value(
&self,
value: &JsValue,
datatype: Option<String>,
) -> Result<(Value<'static>, Vec<(Prop, JsValue)>), JsValue> {
match self.import_scalar(value, &datatype) {
Some(val) => Ok((val.into(), vec![])),
None => {
if let Some((o, subvals)) = to_objtype(value, &None) {
Ok((o.into(), subvals))
} else {
web_sys::console::log_2(&"Invalid value".into(), value);
Err(to_js_err("invalid value"))
}
}
}
}
pub(crate) fn finalize_exposed(
&self,
object: &JsValue,
exposed: HashSet<ObjId>,
meta: &JsValue,
) -> Result<(), JsValue> {
for obj in exposed {
let mut pointer = object.clone();
let obj_type = self.doc.object_type(&obj).unwrap();
let path: Vec<_> = self
.doc
.path_to_object(&obj)?
.iter()
.map(|p| prop_to_js(&p.1))
.collect();
let value = self.export_object(&obj, obj_type.into(), None, meta)?;
for (i, prop) in path.iter().enumerate() {
if i + 1 < path.len() {
pointer = Reflect::get(&pointer, prop)?;
} else {
Reflect::set(&pointer, prop, &value)?;
}
}
}
Ok(())
}
} }
pub(crate) fn alloc(value: &Value<'_>) -> (Datatype, JsValue) { pub(crate) fn alloc(value: &Value<'_>) -> (Datatype, JsValue) {
@ -682,7 +907,7 @@ pub(crate) fn alloc(value: &Value<'_>) -> (Datatype, JsValue) {
ObjType::Map => (Datatype::Map, Object::new().into()), ObjType::Map => (Datatype::Map, Object::new().into()),
ObjType::Table => (Datatype::Table, Object::new().into()), ObjType::Table => (Datatype::Table, Object::new().into()),
ObjType::List => (Datatype::List, Array::new().into()), ObjType::List => (Datatype::List, Array::new().into()),
ObjType::Text => (Datatype::Text, Array::new().into()), ObjType::Text => (Datatype::Text, "".into()),
}, },
am::Value::Scalar(s) => match s.as_ref() { am::Value::Scalar(s) => match s.as_ref() {
am::ScalarValue::Bytes(v) => (Datatype::Bytes, Uint8Array::from(v.as_slice()).into()), am::ScalarValue::Bytes(v) => (Datatype::Bytes, Uint8Array::from(v.as_slice()).into()),

View file

@ -29,10 +29,11 @@
use am::transaction::CommitOptions; use am::transaction::CommitOptions;
use am::transaction::{Observed, Transactable, UnObserved}; use am::transaction::{Observed, Transactable, UnObserved};
use automerge as am; use automerge as am;
use automerge::{Change, ObjId, ObjType, Prop, Value, ROOT}; use automerge::{Change, ObjId, Prop, Value, ROOT};
use js_sys::{Array, Function, Object, Uint8Array}; use js_sys::{Array, Function, Object, Uint8Array};
use serde::ser::Serialize; use serde::ser::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet;
use std::convert::TryInto; use std::convert::TryInto;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
@ -191,8 +192,13 @@ impl Automerge {
vals.push(value); vals.push(value);
} }
} }
self.doc if !vals.is_empty() {
.splice(&obj, start, delete_count, vals.into_iter())?; self.doc.splice(&obj, start, delete_count, vals)?;
} else {
for _ in 0..delete_count {
self.doc.delete(&obj, start)?;
}
}
} }
Ok(()) Ok(())
} }
@ -210,11 +216,16 @@ impl Automerge {
#[wasm_bindgen(js_name = pushObject)] #[wasm_bindgen(js_name = pushObject)]
pub fn push_object(&mut self, obj: JsValue, value: JsValue) -> Result<Option<String>, JsValue> { pub fn push_object(&mut self, obj: JsValue, value: JsValue) -> Result<Option<String>, JsValue> {
let obj = self.import(obj)?; let obj = self.import(obj)?;
let (value, subvals) = let (objtype, subvals) =
to_objtype(&value, &None).ok_or_else(|| to_js_err("expected object"))?; to_objtype(&value, &None).ok_or_else(|| to_js_err("expected object"))?;
let index = self.doc.length(&obj); let index = self.doc.length(&obj);
let opid = self.doc.insert_object(&obj, index, value)?; let opid = self.doc.insert_object(&obj, index, objtype)?;
if objtype == am::ObjType::Text {
self.doc
.splice_text(&opid, 0, 0, &value.as_string().unwrap_or_default())?;
} else {
self.subset(&opid, subvals)?; self.subset(&opid, subvals)?;
}
Ok(opid.to_string().into()) Ok(opid.to_string().into())
} }
@ -243,10 +254,15 @@ impl Automerge {
) -> Result<Option<String>, JsValue> { ) -> Result<Option<String>, JsValue> {
let obj = self.import(obj)?; let obj = self.import(obj)?;
let index = index as f64; let index = index as f64;
let (value, subvals) = let (objtype, subvals) =
to_objtype(&value, &None).ok_or_else(|| to_js_err("expected object"))?; to_objtype(&value, &None).ok_or_else(|| to_js_err("expected object"))?;
let opid = self.doc.insert_object(&obj, index as usize, value)?; let opid = self.doc.insert_object(&obj, index as usize, objtype)?;
if objtype == am::ObjType::Text {
self.doc
.splice_text(&opid, 0, 0, &value.as_string().unwrap_or_default())?;
} else {
self.subset(&opid, subvals)?; self.subset(&opid, subvals)?;
}
Ok(opid.to_string().into()) Ok(opid.to_string().into())
} }
@ -275,10 +291,15 @@ impl Automerge {
) -> Result<JsValue, JsValue> { ) -> Result<JsValue, JsValue> {
let obj = self.import(obj)?; let obj = self.import(obj)?;
let prop = self.import_prop(prop)?; let prop = self.import_prop(prop)?;
let (value, subvals) = let (objtype, subvals) =
to_objtype(&value, &None).ok_or_else(|| to_js_err("expected object"))?; to_objtype(&value, &None).ok_or_else(|| to_js_err("expected object"))?;
let opid = self.doc.put_object(&obj, prop, value)?; let opid = self.doc.put_object(&obj, prop, objtype)?;
if objtype == am::ObjType::Text {
self.doc
.splice_text(&opid, 0, 0, &value.as_string().unwrap_or_default())?;
} else {
self.subset(&opid, subvals)?; self.subset(&opid, subvals)?;
}
Ok(opid.to_string().into()) Ok(opid.to_string().into())
} }
@ -477,16 +498,26 @@ impl Automerge {
object = self.wrap_object(object, datatype, &id, &meta)?; object = self.wrap_object(object, datatype, &id, &meta)?;
} }
for p in patches { let mut exposed = HashSet::default();
if let Some(c) = &callback {
let before = object.clone(); let before = object.clone();
object = self.apply_patch(object, &p, 0, &meta)?;
c.call3(&JsValue::undefined(), &p.try_into()?, &before, &object)?; for p in &patches {
} else { object = self.apply_patch(object, p, 0, &meta, &mut exposed)?;
object = self.apply_patch(object, &p, 0, &meta)?; }
if let Some(c) = &callback {
if !patches.is_empty() {
let patches: Array = patches
.into_iter()
.map(|p| JsValue::try_from(p))
.collect::<Result<_, _>>()?;
c.call3(&JsValue::undefined(), &patches.into(), &before, &object)?;
} }
} }
self.finalize_exposed(&object, exposed, &meta)?;
Ok(object.into()) Ok(object.into())
} }
@ -659,121 +690,6 @@ impl Automerge {
let _patches = self.doc.observer().take_patches(); // throw away patches let _patches = self.doc.observer().take_patches(); // throw away patches
self.export_object(&obj, obj_type.into(), heads.as_ref(), &meta) self.export_object(&obj, obj_type.into(), heads.as_ref(), &meta)
} }
fn import(&self, id: JsValue) -> Result<ObjId, JsValue> {
if let Some(s) = id.as_string() {
if let Some(post) = s.strip_prefix('/') {
let mut obj = ROOT;
let mut is_map = true;
let parts = post.split('/');
for prop in parts {
if prop.is_empty() {
break;
}
let val = if is_map {
self.doc.get(obj, prop)?
} else {
self.doc.get(obj, am::Prop::Seq(prop.parse().unwrap()))?
};
match val {
Some((am::Value::Object(ObjType::Map), id)) => {
is_map = true;
obj = id;
}
Some((am::Value::Object(ObjType::Table), id)) => {
is_map = true;
obj = id;
}
Some((am::Value::Object(_), id)) => {
is_map = false;
obj = id;
}
None => return Err(to_js_err(format!("invalid path '{}'", s))),
_ => return Err(to_js_err(format!("path '{}' is not an object", s))),
};
}
Ok(obj)
} else {
Ok(self.doc.import(&s)?)
}
} else {
Err(to_js_err("invalid objid"))
}
}
fn import_prop(&self, prop: JsValue) -> Result<Prop, JsValue> {
if let Some(s) = prop.as_string() {
Ok(s.into())
} else if let Some(n) = prop.as_f64() {
Ok((n as usize).into())
} else {
Err(to_js_err(format!("invalid prop {:?}", prop)))
}
}
fn import_scalar(&self, value: &JsValue, datatype: &Option<String>) -> Option<am::ScalarValue> {
match datatype.as_deref() {
Some("boolean") => value.as_bool().map(am::ScalarValue::Boolean),
Some("int") => value.as_f64().map(|v| am::ScalarValue::Int(v as i64)),
Some("uint") => value.as_f64().map(|v| am::ScalarValue::Uint(v as u64)),
Some("str") => value.as_string().map(|v| am::ScalarValue::Str(v.into())),
Some("f64") => value.as_f64().map(am::ScalarValue::F64),
Some("bytes") => Some(am::ScalarValue::Bytes(
value.clone().dyn_into::<Uint8Array>().unwrap().to_vec(),
)),
Some("counter") => value.as_f64().map(|v| am::ScalarValue::counter(v as i64)),
Some("timestamp") => {
if let Some(v) = value.as_f64() {
Some(am::ScalarValue::Timestamp(v as i64))
} else if let Ok(d) = value.clone().dyn_into::<js_sys::Date>() {
Some(am::ScalarValue::Timestamp(d.get_time() as i64))
} else {
None
}
}
Some("null") => Some(am::ScalarValue::Null),
Some(_) => None,
None => {
if value.is_null() {
Some(am::ScalarValue::Null)
} else if let Some(b) = value.as_bool() {
Some(am::ScalarValue::Boolean(b))
} else if let Some(s) = value.as_string() {
Some(am::ScalarValue::Str(s.into()))
} else if let Some(n) = value.as_f64() {
if (n.round() - n).abs() < f64::EPSILON {
Some(am::ScalarValue::Int(n as i64))
} else {
Some(am::ScalarValue::F64(n))
}
} else if let Ok(d) = value.clone().dyn_into::<js_sys::Date>() {
Some(am::ScalarValue::Timestamp(d.get_time() as i64))
} else if let Ok(o) = &value.clone().dyn_into::<Uint8Array>() {
Some(am::ScalarValue::Bytes(o.to_vec()))
} else {
None
}
}
}
}
fn import_value(
&self,
value: &JsValue,
datatype: Option<String>,
) -> Result<(Value<'static>, Vec<(Prop, JsValue)>), JsValue> {
match self.import_scalar(value, &datatype) {
Some(val) => Ok((val.into(), vec![])),
None => {
if let Some((o, subvals)) = to_objtype(value, &datatype) {
Ok((o.into(), subvals))
} else {
web_sys::console::log_2(&"Invalid value".into(), value);
Err(to_js_err("invalid value"))
}
}
}
}
} }
#[wasm_bindgen(js_name = create)] #[wasm_bindgen(js_name = create)]

View file

@ -1,8 +1,9 @@
#![allow(dead_code)] #![allow(dead_code)]
use crate::interop::{alloc, js_set}; use crate::interop::{alloc, js_set};
use automerge::{ObjId, OpObserver, Parents, Prop, SequenceTree, Value}; use automerge::{Automerge, ObjId, OpObserver, Prop, SequenceTree, Value};
use js_sys::{Array, Object}; use js_sys::{Array, Object};
use ropey::Rope;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@ -32,14 +33,14 @@ pub(crate) enum Patch {
path: Vec<(ObjId, Prop)>, path: Vec<(ObjId, Prop)>,
key: String, key: String,
value: (Value<'static>, ObjId), value: (Value<'static>, ObjId),
conflict: bool, expose: bool,
}, },
PutSeq { PutSeq {
obj: ObjId, obj: ObjId,
path: Vec<(ObjId, Prop)>, path: Vec<(ObjId, Prop)>,
index: usize, index: usize,
value: (Value<'static>, ObjId), value: (Value<'static>, ObjId),
conflict: bool, expose: bool,
}, },
Insert { Insert {
obj: ObjId, obj: ObjId,
@ -47,6 +48,12 @@ pub(crate) enum Patch {
index: usize, index: usize,
values: SequenceTree<(Value<'static>, ObjId)>, values: SequenceTree<(Value<'static>, ObjId)>,
}, },
SpliceText {
obj: ObjId,
path: Vec<(ObjId, Prop)>,
index: usize,
value: Rope,
},
Increment { Increment {
obj: ObjId, obj: ObjId,
path: Vec<(ObjId, Prop)>, path: Vec<(ObjId, Prop)>,
@ -69,7 +76,7 @@ pub(crate) enum Patch {
impl OpObserver for Observer { impl OpObserver for Observer {
fn insert( fn insert(
&mut self, &mut self,
mut parents: Parents<'_>, doc: &Automerge,
obj: ObjId, obj: ObjId,
index: usize, index: usize,
tagged_value: (Value<'_>, ObjId), tagged_value: (Value<'_>, ObjId),
@ -90,7 +97,7 @@ impl OpObserver for Observer {
return; return;
} }
} }
let path = parents.path(); if let Some(path) = doc.parents(&obj).ok().and_then(|mut p| p.visible_path()) {
let mut values = SequenceTree::new(); let mut values = SequenceTree::new();
values.push(value); values.push(value);
let patch = Patch::Insert { let patch = Patch::Insert {
@ -102,16 +109,44 @@ impl OpObserver for Observer {
self.patches.push(patch); self.patches.push(patch);
} }
} }
}
fn delete(&mut self, mut parents: Parents<'_>, obj: ObjId, prop: Prop) { fn splice_text(&mut self, doc: &Automerge, obj: ObjId, index: usize, value: &str) {
if self.enabled { if self.enabled {
if let Some(Patch::Insert { if let Some(Patch::SpliceText {
obj: tail_obj,
index: tail_index,
value: prev_value,
..
}) = self.patches.last_mut()
{
let range = *tail_index..=*tail_index + prev_value.len_chars();
if tail_obj == &obj && range.contains(&index) {
prev_value.insert(index - *tail_index, value);
return;
}
}
if let Some(path) = doc.parents(&obj).ok().and_then(|mut p| p.visible_path()) {
let patch = Patch::SpliceText {
path,
obj,
index,
value: Rope::from_str(value),
};
self.patches.push(patch);
}
}
}
fn delete(&mut self, doc: &Automerge, obj: ObjId, prop: Prop) {
if self.enabled {
match self.patches.last_mut() {
Some(Patch::Insert {
obj: tail_obj, obj: tail_obj,
index: tail_index, index: tail_index,
values, values,
.. ..
}) = self.patches.last_mut() }) => {
{
if let Prop::Seq(index) = prop { if let Prop::Seq(index) = prop {
let range = *tail_index..*tail_index + values.len(); let range = *tail_index..*tail_index + values.len();
if tail_obj == &obj && range.contains(&index) { if tail_obj == &obj && range.contains(&index) {
@ -120,7 +155,25 @@ impl OpObserver for Observer {
} }
} }
} }
let path = parents.path(); Some(Patch::SpliceText {
obj: tail_obj,
index: tail_index,
value,
..
}) => {
if let Prop::Seq(index) = prop {
let range = *tail_index..*tail_index + value.len_chars();
if tail_obj == &obj && range.contains(&index) {
let start = index - *tail_index;
let end = start + 1;
value.remove(start..end);
return;
}
}
}
_ => {}
}
if let Some(path) = doc.parents(&obj).ok().and_then(|mut p| p.visible_path()) {
let patch = match prop { let patch = match prop {
Prop::Map(key) => Patch::DeleteMap { path, obj, key }, Prop::Map(key) => Patch::DeleteMap { path, obj, key },
Prop::Seq(index) => Patch::DeleteSeq { Prop::Seq(index) => Patch::DeleteSeq {
@ -133,17 +186,19 @@ impl OpObserver for Observer {
self.patches.push(patch) self.patches.push(patch)
} }
} }
}
fn put( fn put(
&mut self, &mut self,
mut parents: Parents<'_>, doc: &Automerge,
obj: ObjId, obj: ObjId,
prop: Prop, prop: Prop,
tagged_value: (Value<'_>, ObjId), tagged_value: (Value<'_>, ObjId),
conflict: bool, _conflict: bool,
) { ) {
if self.enabled { if self.enabled {
let path = parents.path(); let expose = false;
if let Some(path) = doc.parents(&obj).ok().and_then(|mut p| p.visible_path()) {
let value = (tagged_value.0.to_owned(), tagged_value.1); let value = (tagged_value.0.to_owned(), tagged_value.1);
let patch = match prop { let patch = match prop {
Prop::Map(key) => Patch::PutMap { Prop::Map(key) => Patch::PutMap {
@ -151,29 +206,59 @@ impl OpObserver for Observer {
obj, obj,
key, key,
value, value,
conflict, expose,
}, },
Prop::Seq(index) => Patch::PutSeq { Prop::Seq(index) => Patch::PutSeq {
path, path,
obj, obj,
index, index,
value, value,
conflict, expose,
}, },
}; };
self.patches.push(patch); self.patches.push(patch);
} }
} }
}
fn increment( fn expose(
&mut self, &mut self,
mut parents: Parents<'_>, doc: &Automerge,
obj: ObjId, obj: ObjId,
prop: Prop, prop: Prop,
tagged_value: (i64, ObjId), tagged_value: (Value<'_>, ObjId),
_conflict: bool,
) { ) {
if self.enabled { if self.enabled {
let path = parents.path(); let expose = true;
if let Some(path) = doc.parents(&obj).ok().and_then(|mut p| p.visible_path()) {
let value = (tagged_value.0.to_owned(), tagged_value.1);
let patch = match prop {
Prop::Map(key) => Patch::PutMap {
path,
obj,
key,
value,
expose,
},
Prop::Seq(index) => Patch::PutSeq {
path,
obj,
index,
value,
expose,
},
};
self.patches.push(patch);
}
}
}
fn flag_conflict(&mut self, _doc: &Automerge, _obj: ObjId, _prop: Prop) {}
fn increment(&mut self, doc: &Automerge, obj: ObjId, prop: Prop, tagged_value: (i64, ObjId)) {
if self.enabled {
if let Some(path) = doc.parents(&obj).ok().and_then(|mut p| p.visible_path()) {
let value = tagged_value.0; let value = tagged_value.0;
self.patches.push(Patch::Increment { self.patches.push(Patch::Increment {
path, path,
@ -183,6 +268,7 @@ impl OpObserver for Observer {
}) })
} }
} }
}
fn merge(&mut self, other: &Self) { fn merge(&mut self, other: &Self) {
self.patches.extend_from_slice(other.patches.as_slice()) self.patches.extend_from_slice(other.patches.as_slice())
@ -219,6 +305,7 @@ impl Patch {
Self::PutSeq { path, .. } => path.as_slice(), Self::PutSeq { path, .. } => path.as_slice(),
Self::Increment { path, .. } => path.as_slice(), Self::Increment { path, .. } => path.as_slice(),
Self::Insert { path, .. } => path.as_slice(), Self::Insert { path, .. } => path.as_slice(),
Self::SpliceText { path, .. } => path.as_slice(),
Self::DeleteMap { path, .. } => path.as_slice(), Self::DeleteMap { path, .. } => path.as_slice(),
Self::DeleteSeq { path, .. } => path.as_slice(), Self::DeleteSeq { path, .. } => path.as_slice(),
} }
@ -230,6 +317,7 @@ impl Patch {
Self::PutSeq { obj, .. } => obj, Self::PutSeq { obj, .. } => obj,
Self::Increment { obj, .. } => obj, Self::Increment { obj, .. } => obj,
Self::Insert { obj, .. } => obj, Self::Insert { obj, .. } => obj,
Self::SpliceText { obj, .. } => obj,
Self::DeleteMap { obj, .. } => obj, Self::DeleteMap { obj, .. } => obj,
Self::DeleteSeq { obj, .. } => obj, Self::DeleteSeq { obj, .. } => obj,
} }
@ -243,11 +331,7 @@ impl TryFrom<Patch> for JsValue {
let result = Object::new(); let result = Object::new();
match p { match p {
Patch::PutMap { Patch::PutMap {
path, path, key, value, ..
key,
value,
conflict,
..
} => { } => {
js_set(&result, "action", "put")?; js_set(&result, "action", "put")?;
js_set( js_set(
@ -256,15 +340,10 @@ impl TryFrom<Patch> for JsValue {
export_path(path.as_slice(), &Prop::Map(key)), export_path(path.as_slice(), &Prop::Map(key)),
)?; )?;
js_set(&result, "value", alloc(&value.0).1)?; js_set(&result, "value", alloc(&value.0).1)?;
js_set(&result, "conflict", &JsValue::from_bool(conflict))?;
Ok(result.into()) Ok(result.into())
} }
Patch::PutSeq { Patch::PutSeq {
path, path, index, value, ..
index,
value,
conflict,
..
} => { } => {
js_set(&result, "action", "put")?; js_set(&result, "action", "put")?;
js_set( js_set(
@ -273,7 +352,6 @@ impl TryFrom<Patch> for JsValue {
export_path(path.as_slice(), &Prop::Seq(index)), export_path(path.as_slice(), &Prop::Seq(index)),
)?; )?;
js_set(&result, "value", alloc(&value.0).1)?; js_set(&result, "value", alloc(&value.0).1)?;
js_set(&result, "conflict", &JsValue::from_bool(conflict))?;
Ok(result.into()) Ok(result.into())
} }
Patch::Insert { Patch::Insert {
@ -282,7 +360,7 @@ impl TryFrom<Patch> for JsValue {
values, values,
.. ..
} => { } => {
js_set(&result, "action", "splice")?; js_set(&result, "action", "insert")?;
js_set( js_set(
&result, &result,
"path", "path",
@ -295,6 +373,18 @@ impl TryFrom<Patch> for JsValue {
)?; )?;
Ok(result.into()) Ok(result.into())
} }
Patch::SpliceText {
path, index, value, ..
} => {
js_set(&result, "action", "splice")?;
js_set(
&result,
"path",
export_path(path.as_slice(), &Prop::Seq(index)),
)?;
js_set(&result, "value", value.to_string())?;
Ok(result.into())
}
Patch::Increment { Patch::Increment {
path, prop, value, .. path, prop, value, ..
} => { } => {

View file

@ -21,10 +21,6 @@ pub(crate) enum Datatype {
} }
impl Datatype { impl Datatype {
pub(crate) fn is_sequence(&self) -> bool {
matches!(self, Self::List | Self::Text)
}
pub(crate) fn is_scalar(&self) -> bool { pub(crate) fn is_scalar(&self) -> bool {
!matches!(self, Self::Map | Self::Table | Self::List | Self::Text) !matches!(self, Self::Map | Self::Table | Self::List | Self::Text)
} }

View file

@ -104,8 +104,8 @@ describe('Automerge', () => {
doc1.putObject("/", "list", "abc"); doc1.putObject("/", "list", "abc");
const patches = doc1.popPatches() const patches = doc1.popPatches()
assert.deepEqual( patches, [ assert.deepEqual( patches, [
{ action: 'put', conflict: false, path: [ 'list' ], value: [] }, { action: 'put', path: [ 'list' ], value: "" },
{ action: 'splice', path: [ 'list', 0 ], values: [ 'a', 'b', 'c' ] }]) { action: 'splice', path: [ 'list', 0 ], value: 'abc' }])
}) })
it('it should allow registering type wrappers', () => { it('it should allow registering type wrappers', () => {
@ -140,29 +140,26 @@ describe('Automerge', () => {
let mat = doc1.materialize("/") let mat = doc1.materialize("/")
assert.deepEqual( mat, { notes: "hello world".split("") } ) assert.deepEqual( mat, { notes: "hello world" } )
const doc2 = create() const doc2 = create()
let apply : any = doc2.materialize("/") let apply : any = doc2.materialize("/")
doc2.enablePatches(true) doc2.enablePatches(true)
doc2.registerDatatype("text", (n: Value[]) => new String(n.join("")))
apply = doc2.applyPatches(apply) apply = doc2.applyPatches(apply)
doc2.merge(doc1); doc2.merge(doc1);
apply = doc2.applyPatches(apply) apply = doc2.applyPatches(apply)
assert.deepEqual(_obj(apply), "_root") assert.deepEqual(_obj(apply), "_root")
assert.deepEqual(_obj(apply['notes']), "1@aaaa") assert.deepEqual( apply, { notes: "hello world" } )
assert.deepEqual( apply, { notes: new String("hello world") } )
doc2.splice("/notes", 6, 5, "everyone"); doc2.splice("/notes", 6, 5, "everyone");
apply = doc2.applyPatches(apply) apply = doc2.applyPatches(apply)
assert.deepEqual( apply, { notes: new String("hello everyone") } ) assert.deepEqual( apply, { notes: "hello everyone" } )
mat = doc2.materialize("/") mat = doc2.materialize("/")
assert.deepEqual(_obj(mat), "_root") assert.deepEqual(_obj(mat), "_root")
// @ts-ignore // @ts-ignore
assert.deepEqual(_obj(mat.notes), "1@aaaa") assert.deepEqual( mat, { notes: "hello everyone" } )
assert.deepEqual( mat, { notes: new String("hello everyone") } )
}) })
it('should set the OBJECT_ID property on lists, maps, and text objects and not on scalars', () => { it('should set the OBJECT_ID property on lists, maps, and text objects and not on scalars', () => {
@ -189,8 +186,8 @@ describe('Automerge', () => {
assert.equal(_obj(applied.bytes), null) assert.equal(_obj(applied.bytes), null)
assert.equal(_obj(applied.counter), null) assert.equal(_obj(applied.counter), null)
assert.equal(_obj(applied.date), null) assert.equal(_obj(applied.date), null)
assert.equal(_obj(applied.text), null)
assert.notEqual(_obj(applied.text), null)
assert.notEqual(_obj(applied.list), null) assert.notEqual(_obj(applied.list), null)
assert.notEqual(_obj(applied.map), null) assert.notEqual(_obj(applied.map), null)
}) })

View file

@ -118,12 +118,6 @@ describe('Automerge', () => {
doc.splice(notes, 6, 5, "everyone") doc.splice(notes, 6, 5, "everyone")
assert.deepEqual(doc.text(notes), "Hello everyone") assert.deepEqual(doc.text(notes), "Hello everyone")
const obj = doc.insertObject(notes, 6, { hi: "there" })
assert.deepEqual(doc.text(notes), "Hello \ufffceveryone")
assert.deepEqual(doc.get(notes, 6), obj)
assert.deepEqual(doc.get(obj, "hi"), "there")
}) })
it('Querying Data (1)', () => { it('Querying Data (1)', () => {
const doc1 = create("aabbcc") const doc1 = create("aabbcc")

View file

@ -220,8 +220,8 @@ describe('Automerge', () => {
const text = doc.putObject(root, "text", ""); const text = doc.putObject(root, "text", "");
doc.splice(text, 0, 0, "hello ") doc.splice(text, 0, 0, "hello ")
doc.splice(text, 6, 0, ["w", "o", "r", "l", "d"]) doc.splice(text, 6, 0, "world")
doc.splice(text, 11, 0, ["!", "?"]) doc.splice(text, 11, 0, "!?")
assert.deepEqual(doc.getWithType(text, 0), ["str", "h"]) assert.deepEqual(doc.getWithType(text, 0), ["str", "h"])
assert.deepEqual(doc.getWithType(text, 1), ["str", "e"]) assert.deepEqual(doc.getWithType(text, 1), ["str", "e"])
assert.deepEqual(doc.getWithType(text, 9), ["str", "l"]) assert.deepEqual(doc.getWithType(text, 9), ["str", "l"])
@ -230,13 +230,12 @@ describe('Automerge', () => {
assert.deepEqual(doc.getWithType(text, 12), ["str", "?"]) assert.deepEqual(doc.getWithType(text, 12), ["str", "?"])
}) })
it('should be able to insert objects into text', () => { it('should NOT be able to insert objects into text', () => {
const doc = create() const doc = create()
const text = doc.putObject("/", "text", "Hello world"); const text = doc.putObject("/", "text", "Hello world");
const obj = doc.insertObject(text, 6, { hello: "world" }); assert.throws(() => {
assert.deepEqual(doc.text(text), "Hello \ufffcworld"); doc.insertObject(text, 6, { hello: "world" });
assert.deepEqual(doc.getWithType(text, 6), ["map", obj]); })
assert.deepEqual(doc.getWithType(obj, "hello"), ["str", "world"]);
}) })
it('should be able save all or incrementally', () => { it('should be able save all or incrementally', () => {
@ -369,7 +368,6 @@ describe('Automerge', () => {
it('recursive sets are possible', () => { it('recursive sets are possible', () => {
const doc = create("aaaa") const doc = create("aaaa")
doc.registerDatatype("text", (n: Value[]) => new String(n.join("")))
const l1 = doc.putObject("_root", "list", [{ foo: "bar" }, [1, 2, 3]]) const l1 = doc.putObject("_root", "list", [{ foo: "bar" }, [1, 2, 3]])
const l2 = doc.insertObject(l1, 0, { zip: ["a", "b"] }) const l2 = doc.insertObject(l1, 0, { zip: ["a", "b"] })
doc.putObject("_root", "info1", "hello world") // 'text' object doc.putObject("_root", "info1", "hello world") // 'text' object
@ -377,13 +375,13 @@ describe('Automerge', () => {
const l4 = doc.putObject("_root", "info3", "hello world") const l4 = doc.putObject("_root", "info3", "hello world")
assert.deepEqual(doc.materialize(), { assert.deepEqual(doc.materialize(), {
"list": [{ zip: ["a", "b"] }, { foo: "bar" }, [1, 2, 3]], "list": [{ zip: ["a", "b"] }, { foo: "bar" }, [1, 2, 3]],
"info1": new String("hello world"), "info1": "hello world",
"info2": "hello world", "info2": "hello world",
"info3": new String("hello world"), "info3": "hello world",
}) })
assert.deepEqual(doc.materialize(l2), { zip: ["a", "b"] }) assert.deepEqual(doc.materialize(l2), { zip: ["a", "b"] })
assert.deepEqual(doc.materialize(l1), [{ zip: ["a", "b"] }, { foo: "bar" }, [1, 2, 3]]) assert.deepEqual(doc.materialize(l1), [{ zip: ["a", "b"] }, { foo: "bar" }, [1, 2, 3]])
assert.deepEqual(doc.materialize(l4), new String("hello world")) assert.deepEqual(doc.materialize(l4), "hello world")
}) })
it('only returns an object id when objects are created', () => { it('only returns an object id when objects are created', () => {
@ -472,7 +470,7 @@ describe('Automerge', () => {
doc2.enablePatches(true) doc2.enablePatches(true)
doc2.loadIncremental(doc1.saveIncremental()) doc2.loadIncremental(doc1.saveIncremental())
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'put', path: ['hello'], value: 'world', conflict: false } { action: 'put', path: ['hello'], value: 'world' }
]) ])
}) })
@ -482,9 +480,9 @@ describe('Automerge', () => {
doc2.enablePatches(true) doc2.enablePatches(true)
doc2.loadIncremental(doc1.saveIncremental()) doc2.loadIncremental(doc1.saveIncremental())
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'put', path: [ 'birds' ], value: {}, conflict: false }, { action: 'put', path: [ 'birds' ], value: {} },
{ action: 'put', path: [ 'birds', 'friday' ], value: {}, conflict: false }, { action: 'put', path: [ 'birds', 'friday' ], value: {} },
{ action: 'put', path: [ 'birds', 'friday', 'robins' ], value: 3, conflict: false}, { action: 'put', path: [ 'birds', 'friday', 'robins' ], value: 3},
]) ])
}) })
@ -496,7 +494,7 @@ describe('Automerge', () => {
doc1.delete('_root', 'favouriteBird') doc1.delete('_root', 'favouriteBird')
doc2.loadIncremental(doc1.saveIncremental()) doc2.loadIncremental(doc1.saveIncremental())
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'put', path: [ 'favouriteBird' ], value: 'Robin', conflict: false }, { action: 'put', path: [ 'favouriteBird' ], value: 'Robin' },
{ action: 'del', path: [ 'favouriteBird' ] } { action: 'del', path: [ 'favouriteBird' ] }
]) ])
}) })
@ -507,8 +505,8 @@ describe('Automerge', () => {
doc2.enablePatches(true) doc2.enablePatches(true)
doc2.loadIncremental(doc1.saveIncremental()) doc2.loadIncremental(doc1.saveIncremental())
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'put', path: [ 'birds' ], value: [], conflict: false }, { action: 'put', path: [ 'birds' ], value: [] },
{ action: 'splice', path: [ 'birds', 0 ], values: ['Goldfinch', 'Chaffinch'] }, { action: 'insert', path: [ 'birds', 0 ], values: ['Goldfinch', 'Chaffinch'] },
]) ])
}) })
@ -520,9 +518,9 @@ describe('Automerge', () => {
doc2.enablePatches(true) doc2.enablePatches(true)
doc2.loadIncremental(doc1.saveIncremental()) doc2.loadIncremental(doc1.saveIncremental())
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'splice', path: [ 'birds', 0 ], values: [{}] }, { action: 'insert', path: [ 'birds', 0 ], values: [{}] },
{ action: 'put', path: [ 'birds', 0, 'species' ], value: 'Goldfinch', conflict: false }, { action: 'put', path: [ 'birds', 0, 'species' ], value: 'Goldfinch' },
{ action: 'put', path: [ 'birds', 0, 'count', ], value: 3, conflict: false } { action: 'put', path: [ 'birds', 0, 'count', ], value: 3 }
]) ])
}) })
@ -538,7 +536,7 @@ describe('Automerge', () => {
assert.deepEqual(doc1.getWithType('1@aaaa', 1), ['str', 'Greenfinch']) assert.deepEqual(doc1.getWithType('1@aaaa', 1), ['str', 'Greenfinch'])
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'del', path: ['birds', 0] }, { action: 'del', path: ['birds', 0] },
{ action: 'splice', path: ['birds', 1], values: ['Greenfinch'] } { action: 'insert', path: ['birds', 1], values: ['Greenfinch'] }
]) ])
}) })
@ -561,10 +559,10 @@ describe('Automerge', () => {
assert.deepEqual([0, 1, 2, 3].map(i => (doc3.getWithType('1@aaaa', i) || [])[1]), ['a', 'b', 'c', 'd']) assert.deepEqual([0, 1, 2, 3].map(i => (doc3.getWithType('1@aaaa', i) || [])[1]), ['a', 'b', 'c', 'd'])
assert.deepEqual([0, 1, 2, 3].map(i => (doc4.getWithType('1@aaaa', i) || [])[1]), ['a', 'b', 'c', 'd']) assert.deepEqual([0, 1, 2, 3].map(i => (doc4.getWithType('1@aaaa', i) || [])[1]), ['a', 'b', 'c', 'd'])
assert.deepEqual(doc3.popPatches(), [ assert.deepEqual(doc3.popPatches(), [
{ action: 'splice', path: ['values', 0], values:['a','b','c','d'] }, { action: 'insert', path: ['values', 0], values:['a','b','c','d'] },
]) ])
assert.deepEqual(doc4.popPatches(), [ assert.deepEqual(doc4.popPatches(), [
{ action: 'splice', path: ['values',0], values:['a','b','c','d'] }, { action: 'insert', path: ['values',0], values:['a','b','c','d'] },
]) ])
}) })
@ -587,10 +585,10 @@ describe('Automerge', () => {
assert.deepEqual([0, 1, 2, 3, 4, 5].map(i => (doc3.getWithType('1@aaaa', i) || [])[1]), ['a', 'b', 'c', 'd', 'e', 'f']) assert.deepEqual([0, 1, 2, 3, 4, 5].map(i => (doc3.getWithType('1@aaaa', i) || [])[1]), ['a', 'b', 'c', 'd', 'e', 'f'])
assert.deepEqual([0, 1, 2, 3, 4, 5].map(i => (doc4.getWithType('1@aaaa', i) || [])[1]), ['a', 'b', 'c', 'd', 'e', 'f']) assert.deepEqual([0, 1, 2, 3, 4, 5].map(i => (doc4.getWithType('1@aaaa', i) || [])[1]), ['a', 'b', 'c', 'd', 'e', 'f'])
assert.deepEqual(doc3.popPatches(), [ assert.deepEqual(doc3.popPatches(), [
{ action: 'splice', path: ['values', 2], values: ['c','d','e','f'] }, { action: 'insert', path: ['values', 2], values: ['c','d','e','f'] },
]) ])
assert.deepEqual(doc4.popPatches(), [ assert.deepEqual(doc4.popPatches(), [
{ action: 'splice', path: ['values', 2], values: ['c','d','e','f'] }, { action: 'insert', path: ['values', 2], values: ['c','d','e','f'] },
]) ])
}) })
@ -608,12 +606,11 @@ describe('Automerge', () => {
assert.deepEqual(doc4.getWithType('_root', 'bird'), ['str', 'Goldfinch']) assert.deepEqual(doc4.getWithType('_root', 'bird'), ['str', 'Goldfinch'])
assert.deepEqual(doc4.getAll('_root', 'bird'), [['str', 'Greenfinch', '1@aaaa'], ['str', 'Goldfinch', '1@bbbb']]) assert.deepEqual(doc4.getAll('_root', 'bird'), [['str', 'Greenfinch', '1@aaaa'], ['str', 'Goldfinch', '1@bbbb']])
assert.deepEqual(doc3.popPatches(), [ assert.deepEqual(doc3.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Greenfinch', conflict: false }, { action: 'put', path: ['bird'], value: 'Greenfinch' },
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: true }, { action: 'put', path: ['bird'], value: 'Goldfinch' },
]) ])
assert.deepEqual(doc4.popPatches(), [ assert.deepEqual(doc4.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: false }, { action: 'put', path: ['bird'], value: 'Goldfinch' },
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: true },
]) ])
}) })
@ -642,17 +639,13 @@ describe('Automerge', () => {
['str', 'Greenfinch', '1@aaaa'], ['str', 'Chaffinch', '1@bbbb'], ['str', 'Goldfinch', '1@cccc'] ['str', 'Greenfinch', '1@aaaa'], ['str', 'Chaffinch', '1@bbbb'], ['str', 'Goldfinch', '1@cccc']
]) ])
assert.deepEqual(doc1.popPatches(), [ assert.deepEqual(doc1.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Chaffinch', conflict: true }, { action: 'put', path: ['bird'], value: 'Chaffinch' },
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: true } { action: 'put', path: ['bird'], value: 'Goldfinch' }
]) ])
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: true }, { action: 'put', path: ['bird'], value: 'Goldfinch' },
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: true }
])
assert.deepEqual(doc3.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: true },
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: true }
]) ])
assert.deepEqual(doc3.popPatches(), [ ])
}) })
it('should allow a conflict to be resolved', () => { it('should allow a conflict to be resolved', () => {
@ -667,9 +660,9 @@ describe('Automerge', () => {
doc3.loadIncremental(doc1.saveIncremental()) doc3.loadIncremental(doc1.saveIncremental())
assert.deepEqual(doc3.getAll('_root', 'bird'), [['str', 'Goldfinch', '2@aaaa']]) assert.deepEqual(doc3.getAll('_root', 'bird'), [['str', 'Goldfinch', '2@aaaa']])
assert.deepEqual(doc3.popPatches(), [ assert.deepEqual(doc3.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Greenfinch', conflict: false }, { action: 'put', path: ['bird'], value: 'Greenfinch' },
{ action: 'put', path: ['bird'], value: 'Chaffinch', conflict: true }, { action: 'put', path: ['bird'], value: 'Chaffinch' },
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: false } { action: 'put', path: ['bird'], value: 'Goldfinch' }
]) ])
}) })
@ -689,10 +682,10 @@ describe('Automerge', () => {
assert.deepEqual(doc2.getWithType('_root', 'bird'), ['str', 'Goldfinch']) assert.deepEqual(doc2.getWithType('_root', 'bird'), ['str', 'Goldfinch'])
assert.deepEqual(doc2.getAll('_root', 'bird'), [['str', 'Goldfinch', '2@aaaa']]) assert.deepEqual(doc2.getAll('_root', 'bird'), [['str', 'Goldfinch', '2@aaaa']])
assert.deepEqual(doc1.popPatches(), [ assert.deepEqual(doc1.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: false } { action: 'put', path: ['bird'], value: 'Goldfinch' }
]) ])
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Goldfinch', conflict: false } { action: 'put', path: ['bird'], value: 'Goldfinch' }
]) ])
}) })
@ -715,12 +708,11 @@ describe('Automerge', () => {
assert.deepEqual(doc4.getWithType('1@aaaa', 0), ['str', 'Redwing']) assert.deepEqual(doc4.getWithType('1@aaaa', 0), ['str', 'Redwing'])
assert.deepEqual(doc4.getAll('1@aaaa', 0), [['str', 'Song Thrush', '4@aaaa'], ['str', 'Redwing', '4@bbbb']]) assert.deepEqual(doc4.getAll('1@aaaa', 0), [['str', 'Song Thrush', '4@aaaa'], ['str', 'Redwing', '4@bbbb']])
assert.deepEqual(doc3.popPatches(), [ assert.deepEqual(doc3.popPatches(), [
{ action: 'put', path: ['birds',0], value: 'Song Thrush', conflict: false }, { action: 'put', path: ['birds',0], value: 'Song Thrush' },
{ action: 'put', path: ['birds',0], value: 'Redwing', conflict: true } { action: 'put', path: ['birds',0], value: 'Redwing' }
]) ])
assert.deepEqual(doc4.popPatches(), [ assert.deepEqual(doc4.popPatches(), [
{ action: 'put', path: ['birds',0], value: 'Redwing', conflict: false }, { action: 'put', path: ['birds',0], value: 'Redwing' },
{ action: 'put', path: ['birds',0], value: 'Redwing', conflict: true }
]) ])
}) })
@ -746,15 +738,14 @@ describe('Automerge', () => {
assert.deepEqual(doc4.getAll('1@aaaa', 2), [['str', 'Song Thrush', '6@aaaa'], ['str', 'Redwing', '6@bbbb']]) assert.deepEqual(doc4.getAll('1@aaaa', 2), [['str', 'Song Thrush', '6@aaaa'], ['str', 'Redwing', '6@bbbb']])
assert.deepEqual(doc3.popPatches(), [ assert.deepEqual(doc3.popPatches(), [
{ action: 'del', path: ['birds',0], }, { action: 'del', path: ['birds',0], },
{ action: 'put', path: ['birds',1], value: 'Song Thrush', conflict: false }, { action: 'put', path: ['birds',1], value: 'Song Thrush' },
{ action: 'splice', path: ['birds',0], values: ['Ring-necked parakeet'] }, { action: 'insert', path: ['birds',0], values: ['Ring-necked parakeet'] },
{ action: 'put', path: ['birds',2], value: 'Redwing', conflict: true } { action: 'put', path: ['birds',2], value: 'Redwing' }
]) ])
assert.deepEqual(doc4.popPatches(), [ assert.deepEqual(doc4.popPatches(), [
{ action: 'put', path: ['birds',0], value: 'Ring-necked parakeet', conflict: false }, { action: 'put', path: ['birds',0], value: 'Ring-necked parakeet' },
{ action: 'put', path: ['birds',2], value: 'Redwing', conflict: false }, { action: 'put', path: ['birds',2], value: 'Redwing' },
{ action: 'put', path: ['birds',0], value: 'Ring-necked parakeet', conflict: false }, { action: 'put', path: ['birds',0], value: 'Ring-necked parakeet' },
{ action: 'put', path: ['birds',2], value: 'Redwing', conflict: true }
]) ])
}) })
@ -770,14 +761,14 @@ describe('Automerge', () => {
doc3.loadIncremental(change2) doc3.loadIncremental(change2)
assert.deepEqual(doc3.getAll('_root', 'bird'), [['str', 'Robin', '1@aaaa'], ['str', 'Wren', '1@bbbb']]) assert.deepEqual(doc3.getAll('_root', 'bird'), [['str', 'Robin', '1@aaaa'], ['str', 'Wren', '1@bbbb']])
assert.deepEqual(doc3.popPatches(), [ assert.deepEqual(doc3.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Robin', conflict: false }, { action: 'put', path: ['bird'], value: 'Robin' },
{ action: 'put', path: ['bird'], value: 'Wren', conflict: true } { action: 'put', path: ['bird'], value: 'Wren' }
]) ])
doc3.loadIncremental(change3) doc3.loadIncremental(change3)
assert.deepEqual(doc3.getWithType('_root', 'bird'), ['str', 'Robin']) assert.deepEqual(doc3.getWithType('_root', 'bird'), ['str', 'Robin'])
assert.deepEqual(doc3.getAll('_root', 'bird'), [['str', 'Robin', '1@aaaa']]) assert.deepEqual(doc3.getAll('_root', 'bird'), [['str', 'Robin', '1@aaaa']])
assert.deepEqual(doc3.popPatches(), [ assert.deepEqual(doc3.popPatches(), [
{ action: 'put', path: ['bird'], value: 'Robin', conflict: false } { action: 'put', path: ['bird'], value: 'Robin' }
]) ])
}) })
@ -792,14 +783,11 @@ describe('Automerge', () => {
doc2.loadIncremental(change1) doc2.loadIncremental(change1)
assert.deepEqual(doc1.getAll('_root', 'birds'), [['list', '1@aaaa'], ['map', '1@bbbb']]) assert.deepEqual(doc1.getAll('_root', 'birds'), [['list', '1@aaaa'], ['map', '1@bbbb']])
assert.deepEqual(doc1.popPatches(), [ assert.deepEqual(doc1.popPatches(), [
{ action: 'put', path: ['birds'], value: {}, conflict: true }, { action: 'put', path: ['birds'], value: {} },
{ action: 'put', path: ['birds', 'Sparrowhawk'], value: 1, conflict: false } { action: 'put', path: ['birds', 'Sparrowhawk'], value: 1 }
]) ])
assert.deepEqual(doc2.getAll('_root', 'birds'), [['list', '1@aaaa'], ['map', '1@bbbb']]) assert.deepEqual(doc2.getAll('_root', 'birds'), [['list', '1@aaaa'], ['map', '1@bbbb']])
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [])
{ action: 'put', path: ['birds'], value: {}, conflict: true },
{ action: 'splice', path: ['birds',0], values: ['Parakeet'] }
])
}) })
it('should support date objects', () => { it('should support date objects', () => {
@ -809,7 +797,7 @@ describe('Automerge', () => {
doc2.loadIncremental(doc1.saveIncremental()) doc2.loadIncremental(doc1.saveIncremental())
assert.deepEqual(doc2.getWithType('_root', 'createdAt'), ['timestamp', now]) assert.deepEqual(doc2.getWithType('_root', 'createdAt'), ['timestamp', now])
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'put', path: ['createdAt'], value: now, conflict: false } { action: 'put', path: ['createdAt'], value: now }
]) ])
}) })
@ -823,11 +811,11 @@ describe('Automerge', () => {
doc1.putObject('_root', 'list', []) doc1.putObject('_root', 'list', [])
assert.deepEqual(doc1.popPatches(), [ assert.deepEqual(doc1.popPatches(), [
{ action: 'put', path: ['key1'], value: 1, conflict: false }, { action: 'put', path: ['key1'], value: 1 },
{ action: 'put', path: ['key1'], value: 2, conflict: false }, { action: 'put', path: ['key1'], value: 2 },
{ action: 'put', path: ['key2'], value: 3, conflict: false }, { action: 'put', path: ['key2'], value: 3 },
{ action: 'put', path: ['map'], value: {}, conflict: false }, { action: 'put', path: ['map'], value: {} },
{ action: 'put', path: ['list'], value: [], conflict: false }, { action: 'put', path: ['list'], value: [] },
]) ])
}) })
@ -842,8 +830,8 @@ describe('Automerge', () => {
doc1.insertObject(list, 2, []) doc1.insertObject(list, 2, [])
assert.deepEqual(doc1.popPatches(), [ assert.deepEqual(doc1.popPatches(), [
{ action: 'put', path: ['list'], value: [], conflict: false }, { action: 'put', path: ['list'], value: [] },
{ action: 'splice', path: ['list', 0], values: [2,1,[],{},3] }, { action: 'insert', path: ['list', 0], values: [2,1,[],{},3] },
]) ])
}) })
@ -856,8 +844,8 @@ describe('Automerge', () => {
doc1.pushObject(list, []) doc1.pushObject(list, [])
assert.deepEqual(doc1.popPatches(), [ assert.deepEqual(doc1.popPatches(), [
{ action: 'put', path: ['list'], value: [], conflict: false }, { action: 'put', path: ['list'], value: [] },
{ action: 'splice', path: ['list',0], values: [1,{},[]] }, { action: 'insert', path: ['list',0], values: [1,{},[]] },
]) ])
}) })
@ -869,8 +857,8 @@ describe('Automerge', () => {
doc1.splice(list, 1, 2) doc1.splice(list, 1, 2)
assert.deepEqual(doc1.popPatches(), [ assert.deepEqual(doc1.popPatches(), [
{ action: 'put', path: ['list'], value: [], conflict: false }, { action: 'put', path: ['list'], value: [] },
{ action: 'splice', path: ['list',0], values: [1,4] }, { action: 'insert', path: ['list',0], values: [1,4] },
]) ])
}) })
@ -881,7 +869,7 @@ describe('Automerge', () => {
doc1.increment('_root', 'counter', 4) doc1.increment('_root', 'counter', 4)
assert.deepEqual(doc1.popPatches(), [ assert.deepEqual(doc1.popPatches(), [
{ action: 'put', path: ['counter'], value: 2, conflict: false }, { action: 'put', path: ['counter'], value: 2 },
{ action: 'inc', path: ['counter'], value: 4 }, { action: 'inc', path: ['counter'], value: 4 },
]) ])
}) })
@ -895,8 +883,8 @@ describe('Automerge', () => {
doc1.delete('_root', 'key1') doc1.delete('_root', 'key1')
doc1.delete('_root', 'key2') doc1.delete('_root', 'key2')
assert.deepEqual(doc1.popPatches(), [ assert.deepEqual(doc1.popPatches(), [
{ action: 'put', path: ['key1'], value: 1, conflict: false }, { action: 'put', path: ['key1'], value: 1 },
{ action: 'put', path: ['key2'], value: 2, conflict: false }, { action: 'put', path: ['key2'], value: 2 },
{ action: 'del', path: ['key1'], }, { action: 'del', path: ['key1'], },
{ action: 'del', path: ['key2'], }, { action: 'del', path: ['key2'], },
]) ])
@ -911,7 +899,7 @@ describe('Automerge', () => {
doc2.loadIncremental(doc1.saveIncremental()) doc2.loadIncremental(doc1.saveIncremental())
assert.deepEqual(doc2.getWithType('_root', 'starlings'), ['counter', 3]) assert.deepEqual(doc2.getWithType('_root', 'starlings'), ['counter', 3])
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'put', path: ['starlings'], value: 2, conflict: false }, { action: 'put', path: ['starlings'], value: 2 },
{ action: 'inc', path: ['starlings'], value: 1 } { action: 'inc', path: ['starlings'], value: 1 }
]) ])
}) })
@ -929,8 +917,8 @@ describe('Automerge', () => {
doc2.loadIncremental(doc1.saveIncremental()) doc2.loadIncremental(doc1.saveIncremental())
assert.deepEqual(doc2.popPatches(), [ assert.deepEqual(doc2.popPatches(), [
{ action: 'put', path: ['list'], value: [], conflict: false }, { action: 'put', path: ['list'], value: [] },
{ action: 'splice', path: ['list',0], values: [1] }, { action: 'insert', path: ['list',0], values: [1] },
{ action: 'inc', path: ['list',0], value: 2 }, { action: 'inc', path: ['list',0], value: 2 },
{ action: 'inc', path: ['list',0], value: -5 }, { action: 'inc', path: ['list',0], value: -5 },
]) ])

View file

@ -66,6 +66,17 @@ fn get_changes(doc: &Automerge, patches: Vec<Patch>) {
doc.path_to_object(&obj) doc.path_to_object(&obj)
) )
} }
Patch::Splice {
obj, index, value, ..
} => {
println!(
"splice '{:?}' at {:?} in obj {:?}, object path {:?}",
value,
index,
obj,
doc.path_to_object(&obj)
)
}
Patch::Increment { Patch::Increment {
obj, prop, value, .. obj, prop, value, ..
} => { } => {
@ -83,6 +94,12 @@ fn get_changes(doc: &Automerge, patches: Vec<Patch>) {
obj, obj,
doc.path_to_object(&obj) doc.path_to_object(&obj)
), ),
Patch::Expose { obj, prop, .. } => println!(
"expose {:?} in obj {:?}, object path {:?}",
prop,
obj,
doc.path_to_object(&obj)
),
} }
} }
} }

View file

@ -469,6 +469,44 @@ impl<Obs: Observation> Transactable for AutoCommitWithObs<Obs> {
) )
} }
fn splice_text<O: AsRef<ExId>>(
&mut self,
obj: O,
pos: usize,
del: usize,
text: &str,
) -> Result<(), AutomergeError> {
self.ensure_transaction_open();
let (current, tx) = self.transaction.as_mut().unwrap();
tx.splice_text(
&mut self.doc,
current.observer(),
obj.as_ref(),
pos,
del,
text,
)
}
fn splice_text_utf16<O: AsRef<ExId>>(
&mut self,
obj: O,
pos: usize,
del: usize,
text: &str,
) -> Result<(), AutomergeError> {
self.ensure_transaction_open();
let (current, tx) = self.transaction.as_mut().unwrap();
tx.splice_text_utf16(
&mut self.doc,
current.observer(),
obj.as_ref(),
pos,
del,
text,
)
}
fn text<O: AsRef<ExId>>(&self, obj: O) -> Result<String, AutomergeError> { fn text<O: AsRef<ExId>>(&self, obj: O) -> Result<String, AutomergeError> {
self.doc.text(obj) self.doc.text(obj)
} }

View file

@ -321,9 +321,7 @@ impl Automerge {
&self, &self,
obj: O, obj: O,
) -> Result<Vec<(ExId, Prop)>, AutomergeError> { ) -> Result<Vec<(ExId, Prop)>, AutomergeError> {
let mut path = self.parents(obj.as_ref().clone())?.collect::<Vec<_>>(); Ok(self.parents(obj.as_ref().clone())?.path())
path.reverse();
Ok(path)
} }
/// Get the keys of the object `obj`. /// Get the keys of the object `obj`.
@ -512,11 +510,7 @@ impl Automerge {
let query = self.ops.search(&obj, query::ListVals::new()); let query = self.ops.search(&obj, query::ListVals::new());
let mut buffer = String::new(); let mut buffer = String::new();
for q in &query.ops { for q in &query.ops {
if let OpType::Put(ScalarValue::Str(s)) = &q.action { buffer.push_str(q.to_str());
buffer.push_str(s);
} else {
buffer.push('\u{fffc}');
}
} }
Ok(buffer) Ok(buffer)
} }
@ -805,11 +799,11 @@ impl Automerge {
self.update_history(change, ops.len()); self.update_history(change, ops.len());
if let Some(observer) = observer { if let Some(observer) = observer {
for (obj, op) in ops { for (obj, op) in ops {
self.ops.insert_op_with_observer(&obj, op, *observer); self.insert_op_with_observer(&obj, op, *observer);
} }
} else { } else {
for (obj, op) in ops { for (obj, op) in ops {
self.ops.insert_op(&obj, op); self.insert_op(&obj, op);
} }
} }
} }
@ -1241,6 +1235,98 @@ impl Automerge {
objects.map(|os| os.iter().filter_map(|o| self.exid_to_obj(o).ok()).collect()); objects.map(|os| os.iter().filter_map(|o| self.exid_to_obj(o).ok()).collect());
self.ops.visualise(objects) self.ops.visualise(objects)
} }
pub(crate) fn insert_op(&mut self, obj: &ObjId, op: Op) -> Op {
let q = self.ops.search(obj, query::SeekOp::new(&op));
let succ = q.succ;
let pos = q.pos;
self.ops.add_succ(obj, succ.iter().copied(), &op);
if !op.is_delete() {
self.ops.insert(pos, obj, op.clone());
}
op
}
pub(crate) fn insert_op_with_observer<Obs: OpObserver>(
&mut self,
obj: &ObjId,
op: Op,
observer: &mut Obs,
) -> Op {
let q = self.ops.search(obj, query::SeekOpWithPatch::new(&op));
let obj_type = self.ops.object_type(obj);
let query::SeekOpWithPatch {
pos,
succ,
seen,
seen16,
values,
had_value_before,
..
} = q;
let ex_obj = self.ops.id_to_exid(obj.0);
let key = match op.key {
Key::Map(index) => self.ops.m.props[index].clone().into(),
Key::Seq(_) => seen.into(),
};
if op.insert {
if obj_type == Some(ObjType::Text) {
observer.splice_text(self, ex_obj, seen, seen16, op.to_str());
} else {
let value = (op.value(), self.ops.id_to_exid(op.id));
observer.insert(self, ex_obj, seen, value);
}
} else if op.is_delete() {
if let Some(winner) = &values.last() {
let value = (winner.value(), self.ops.id_to_exid(winner.id));
let conflict = values.len() > 1;
observer.expose(self, ex_obj, key, value, conflict);
} else if had_value_before {
observer.delete(self, ex_obj, key);
}
} else if let Some(value) = op.get_increment_value() {
// only observe this increment if the counter is visible, i.e. the counter's
// create op is in the values
//if values.iter().any(|value| op.pred.contains(&value.id)) {
if values
.last()
.map(|value| op.pred.contains(&value.id))
.unwrap_or_default()
{
// we have observed the value
observer.increment(self, ex_obj, key, (value, self.ops.id_to_exid(op.id)));
}
} else {
let just_conflict = values
.last()
.map(|value| self.ops.m.lamport_cmp(op.id, value.id) != Ordering::Greater)
.unwrap_or(false);
let value = (op.value(), self.ops.id_to_exid(op.id));
if op.is_list_op() && !had_value_before {
observer.insert(self, ex_obj, seen, value);
} else if just_conflict {
observer.flag_conflict(self, ex_obj, key);
} else {
let conflict = !values.is_empty();
observer.put(self, ex_obj, key, value, conflict);
}
}
self.ops.add_succ(obj, succ.iter().copied(), &op);
if !op.is_delete() {
self.ops.insert(pos, obj, op.clone());
}
op
}
} }
impl Default for Automerge { impl Default for Automerge {

View file

@ -1318,21 +1318,21 @@ fn compute_list_indexes_correctly_when_list_element_is_split_across_tree_nodes()
fn get_parent_objects() { fn get_parent_objects() {
let mut doc = AutoCommit::new(); let mut doc = AutoCommit::new();
let map = doc.put_object(ROOT, "a", ObjType::Map).unwrap(); let map = doc.put_object(ROOT, "a", ObjType::Map).unwrap();
let list = doc.insert_object(&map, 0, ObjType::List).unwrap(); let list = doc.put_object(&map, "b", ObjType::List).unwrap();
doc.insert(&list, 0, 2).unwrap(); doc.insert(&list, 0, 2).unwrap();
let text = doc.put_object(&list, 0, ObjType::Text).unwrap(); let text = doc.put_object(&list, 0, ObjType::Text).unwrap();
assert_eq!( assert_eq!(
doc.parents(&map).unwrap().next(), doc.parents(&map).unwrap().next(),
Some((ROOT, Prop::Map("a".into()))) Some((ROOT, Prop::Map("a".into()), true))
); );
assert_eq!( assert_eq!(
doc.parents(&list).unwrap().next(), doc.parents(&list).unwrap().next(),
Some((map, Prop::Seq(0))) Some((map, Prop::Map("b".into()), true))
); );
assert_eq!( assert_eq!(
doc.parents(&text).unwrap().next(), doc.parents(&text).unwrap().next(),
Some((list, Prop::Seq(0))) Some((list, Prop::Seq(0), true))
); );
} }
@ -1340,7 +1340,7 @@ fn get_parent_objects() {
fn get_path_to_object() { fn get_path_to_object() {
let mut doc = AutoCommit::new(); let mut doc = AutoCommit::new();
let map = doc.put_object(ROOT, "a", ObjType::Map).unwrap(); let map = doc.put_object(ROOT, "a", ObjType::Map).unwrap();
let list = doc.insert_object(&map, 0, ObjType::List).unwrap(); let list = doc.put_object(&map, "b", ObjType::List).unwrap();
doc.insert(&list, 0, 2).unwrap(); doc.insert(&list, 0, 2).unwrap();
let text = doc.put_object(&list, 0, ObjType::Text).unwrap(); let text = doc.put_object(&list, 0, ObjType::Text).unwrap();
@ -1350,13 +1350,16 @@ fn get_path_to_object() {
); );
assert_eq!( assert_eq!(
doc.path_to_object(&list).unwrap(), doc.path_to_object(&list).unwrap(),
vec![(ROOT, Prop::Map("a".into())), (map.clone(), Prop::Seq(0)),] vec![
(ROOT, Prop::Map("a".into())),
(map.clone(), Prop::Map("b".into())),
]
); );
assert_eq!( assert_eq!(
doc.path_to_object(&text).unwrap(), doc.path_to_object(&text).unwrap(),
vec![ vec![
(ROOT, Prop::Map("a".into())), (ROOT, Prop::Map("a".into())),
(map, Prop::Seq(0)), (map, Prop::Map("b".into())),
(list, Prop::Seq(0)), (list, Prop::Seq(0)),
] ]
); );
@ -1366,14 +1369,14 @@ fn get_path_to_object() {
fn parents_iterator() { fn parents_iterator() {
let mut doc = AutoCommit::new(); let mut doc = AutoCommit::new();
let map = doc.put_object(ROOT, "a", ObjType::Map).unwrap(); let map = doc.put_object(ROOT, "a", ObjType::Map).unwrap();
let list = doc.insert_object(&map, 0, ObjType::List).unwrap(); let list = doc.put_object(&map, "b", ObjType::List).unwrap();
doc.insert(&list, 0, 2).unwrap(); doc.insert(&list, 0, 2).unwrap();
let text = doc.put_object(&list, 0, ObjType::Text).unwrap(); let text = doc.put_object(&list, 0, ObjType::Text).unwrap();
let mut parents = doc.parents(text).unwrap(); let mut parents = doc.parents(text).unwrap();
assert_eq!(parents.next(), Some((list, Prop::Seq(0)))); assert_eq!(parents.next(), Some((list, Prop::Seq(0), true)));
assert_eq!(parents.next(), Some((map, Prop::Seq(0)))); assert_eq!(parents.next(), Some((map, Prop::Map("b".into()), true)));
assert_eq!(parents.next(), Some((ROOT, Prop::Map("a".into())))); assert_eq!(parents.next(), Some((ROOT, Prop::Map("a".into()), true)));
assert_eq!(parents.next(), None); assert_eq!(parents.next(), None);
} }
@ -1383,27 +1386,28 @@ fn can_insert_a_grapheme_into_text() {
let mut tx = doc.transaction(); let mut tx = doc.transaction();
let text = tx.put_object(ROOT, "text", ObjType::Text).unwrap(); let text = tx.put_object(ROOT, "text", ObjType::Text).unwrap();
let polar_bear = "🐻‍❄️"; let polar_bear = "🐻‍❄️";
tx.insert(&text, 0, polar_bear).unwrap(); tx.splice_text(&text, 0, 0, polar_bear).unwrap();
tx.commit(); tx.commit();
let s = doc.text(&text).unwrap(); let s = doc.text(&text).unwrap();
assert_eq!(s, polar_bear); assert_eq!(s, polar_bear);
let len = doc.length(&text); let len = doc.length(&text);
assert_eq!(len, 1); // just one grapheme assert_eq!(len, 4); // 4 utf8 chars
} }
#[test] #[test]
fn can_insert_long_string_into_text() { fn long_strings_spliced_into_text_get_segmented_by_utf8_chars() {
let mut doc = Automerge::new(); let mut doc = Automerge::new();
let mut tx = doc.transaction(); let mut tx = doc.transaction();
let text = tx.put_object(ROOT, "text", ObjType::Text).unwrap(); let text = tx.put_object(ROOT, "text", ObjType::Text).unwrap();
let polar_bear = "🐻‍❄️"; let polar_bear = "🐻‍❄️";
let polar_bear_army = polar_bear.repeat(100); let polar_bear_army = polar_bear.repeat(100);
tx.insert(&text, 0, &polar_bear_army).unwrap(); tx.splice_text(&text, 0, 0, &polar_bear_army).unwrap();
tx.commit(); tx.commit();
let s = doc.text(&text).unwrap(); let s = doc.text(&text).unwrap();
assert_eq!(s, polar_bear_army); assert_eq!(s, polar_bear_army);
let len = doc.length(&text); let len = doc.length(&text);
assert_eq!(len, 1); // many graphemes assert_eq!(len, polar_bear.chars().count() * 100);
assert_eq!(len, 400);
} }
#[test] #[test]

View file

@ -1,7 +1,7 @@
use crate::storage::load::Error as LoadError; use crate::storage::load::Error as LoadError;
use crate::types::{ActorId, ScalarValue}; use crate::types::{ActorId, ScalarValue};
use crate::value::DataType; use crate::value::DataType;
use crate::ChangeHash; use crate::{ChangeHash, ObjType};
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -45,6 +45,14 @@ pub enum AutomergeError {
NonChangeCompressed, NonChangeCompressed,
#[error("id was not an object id")] #[error("id was not an object id")]
NotAnObject, NotAnObject,
#[error("invalid op for object of type `{0}`")]
InvalidOp(ObjType),
}
impl PartialEq for AutomergeError {
fn eq(&self, other: &Self) -> bool {
std::mem::discriminant(self) == std::mem::discriminant(other)
}
} }
#[cfg(feature = "wasm")] #[cfg(feature = "wasm")]

View file

@ -1,5 +1,5 @@
use crate::exid::ExId; use crate::exid::ExId;
use crate::Parents; use crate::Automerge;
use crate::Prop; use crate::Prop;
use crate::Value; use crate::Value;
@ -7,22 +7,24 @@ use crate::Value;
pub trait OpObserver: Default + Clone { pub trait OpObserver: Default + Clone {
/// A new value has been inserted into the given object. /// A new value has been inserted into the given object.
/// ///
/// - `parents`: A parents iterator that can be used to collect path information /// - `doc`: a handle to the doc after the op has been inserted, can be used to query information
/// - `objid`: the object that has been inserted into. /// - `objid`: the object that has been inserted into.
/// - `index`: the index the new value has been inserted at. /// - `index`: the index the new value has been inserted at.
/// - `tagged_value`: the value that has been inserted and the id of the operation that did the /// - `tagged_value`: the value that has been inserted and the id of the operation that did the
/// insert. /// insert.
fn insert( fn insert(
&mut self, &mut self,
parents: Parents<'_>, doc: &Automerge,
objid: ExId, objid: ExId,
index: usize, index: usize,
tagged_value: (Value<'_>, ExId), tagged_value: (Value<'_>, ExId),
); );
fn splice_text(&mut self, doc: &Automerge, objid: ExId, index: usize, index_utf16: usize, value: &str);
/// A new value has been put into the given object. /// A new value has been put into the given object.
/// ///
/// - `parents`: A parents iterator that can be used to collect path information /// - `doc`: a handle to the doc after the op has been inserted, can be used to query information
/// - `objid`: the object that has been put into. /// - `objid`: the object that has been put into.
/// - `prop`: the prop that the value as been put at. /// - `prop`: the prop that the value as been put at.
/// - `tagged_value`: the value that has been put into the object and the id of the operation /// - `tagged_value`: the value that has been put into the object and the id of the operation
@ -30,34 +32,54 @@ pub trait OpObserver: Default + Clone {
/// - `conflict`: whether this put conflicts with other operations. /// - `conflict`: whether this put conflicts with other operations.
fn put( fn put(
&mut self, &mut self,
parents: Parents<'_>, doc: &Automerge,
objid: ExId, objid: ExId,
prop: Prop, prop: Prop,
tagged_value: (Value<'_>, ExId), tagged_value: (Value<'_>, ExId),
conflict: bool, conflict: bool,
); );
/// When a delete op exposes a previously conflicted value
/// Similar to a put op - except for maps, lists and text, edits
/// may already exist and need to be queried
///
/// - `doc`: a handle to the doc after the op has been inserted, can be used to query information
/// - `objid`: the object that has been put into.
/// - `prop`: the prop that the value as been put at.
/// - `tagged_value`: the value that has been put into the object and the id of the operation
/// that did the put.
/// - `conflict`: whether this put conflicts with other operations.
fn expose(
&mut self,
doc: &Automerge,
objid: ExId,
prop: Prop,
tagged_value: (Value<'_>, ExId),
conflict: bool,
);
/// Flag a new conflict on a value without changing it
///
/// - `doc`: a handle to the doc after the op has been inserted, can be used to query information
/// - `objid`: the object that has been put into.
/// - `prop`: the prop that the value as been put at.
fn flag_conflict(&mut self, doc: &Automerge, objid: ExId, prop: Prop);
/// A counter has been incremented. /// A counter has been incremented.
/// ///
/// - `parents`: A parents iterator that can be used to collect path information /// - `doc`: a handle to the doc after the op has been inserted, can be used to query information
/// - `objid`: the object that contains the counter. /// - `objid`: the object that contains the counter.
/// - `prop`: they prop that the chounter is at. /// - `prop`: they prop that the chounter is at.
/// - `tagged_value`: the amount the counter has been incremented by, and the the id of the /// - `tagged_value`: the amount the counter has been incremented by, and the the id of the
/// increment operation. /// increment operation.
fn increment( fn increment(&mut self, doc: &Automerge, objid: ExId, prop: Prop, tagged_value: (i64, ExId));
&mut self,
parents: Parents<'_>,
objid: ExId,
prop: Prop,
tagged_value: (i64, ExId),
);
/// A value has beeen deleted. /// A value has beeen deleted.
/// ///
/// - `parents`: A parents iterator that can be used to collect path information /// - `doc`: a handle to the doc after the op has been inserted, can be used to query information
/// - `objid`: the object that has been deleted in. /// - `objid`: the object that has been deleted in.
/// - `prop`: the prop of the value that has been deleted. /// - `prop`: the prop of the value that has been deleted.
fn delete(&mut self, parents: Parents<'_>, objid: ExId, prop: Prop); fn delete(&mut self, doc: &Automerge, objid: ExId, prop: Prop);
/// Branch of a new op_observer later to be merged /// Branch of a new op_observer later to be merged
/// ///
@ -79,16 +101,18 @@ pub trait OpObserver: Default + Clone {
impl OpObserver for () { impl OpObserver for () {
fn insert( fn insert(
&mut self, &mut self,
_parents: Parents<'_>, _doc: &Automerge,
_objid: ExId, _objid: ExId,
_index: usize, _index: usize,
_tagged_value: (Value<'_>, ExId), _tagged_value: (Value<'_>, ExId),
) { ) {
} }
fn splice_text(&mut self, _doc: &Automerge, _objid: ExId, _index: usize, _index_utf16: usize, _value: &str) {}
fn put( fn put(
&mut self, &mut self,
_parents: Parents<'_>, _doc: &Automerge,
_objid: ExId, _objid: ExId,
_prop: Prop, _prop: Prop,
_tagged_value: (Value<'_>, ExId), _tagged_value: (Value<'_>, ExId),
@ -96,16 +120,28 @@ impl OpObserver for () {
) { ) {
} }
fn expose(
&mut self,
_doc: &Automerge,
_objid: ExId,
_prop: Prop,
_tagged_value: (Value<'_>, ExId),
_conflict: bool,
) {
}
fn flag_conflict(&mut self, _doc: &Automerge, _objid: ExId, _prop: Prop) {}
fn increment( fn increment(
&mut self, &mut self,
_parents: Parents<'_>, _doc: &Automerge,
_objid: ExId, _objid: ExId,
_prop: Prop, _prop: Prop,
_tagged_value: (i64, ExId), _tagged_value: (i64, ExId),
) { ) {
} }
fn delete(&mut self, _parents: Parents<'_>, _objid: ExId, _prop: Prop) {} fn delete(&mut self, _doc: &Automerge, _objid: ExId, _prop: Prop) {}
fn merge(&mut self, _other: &Self) {} fn merge(&mut self, _other: &Self) {}
} }
@ -125,59 +161,87 @@ impl VecOpObserver {
} }
impl OpObserver for VecOpObserver { impl OpObserver for VecOpObserver {
fn insert( fn insert(&mut self, doc: &Automerge, obj: ExId, index: usize, (value, id): (Value<'_>, ExId)) {
&mut self, if let Ok(mut p) = doc.parents(&obj) {
mut parents: Parents<'_>,
obj: ExId,
index: usize,
(value, id): (Value<'_>, ExId),
) {
let path = parents.path();
self.patches.push(Patch::Insert { self.patches.push(Patch::Insert {
obj, obj,
path, path: p.path(),
index, index,
value: (value.into_owned(), id), value: (value.into_owned(), id),
}); });
} }
}
fn splice_text(&mut self, doc: &Automerge, obj: ExId, index: usize, _index_utf16: usize, value: &str) {
if let Ok(mut p) = doc.parents(&obj) {
self.patches.push(Patch::Splice {
obj,
path: p.path(),
index,
value: value.to_string(),
})
}
}
fn put( fn put(
&mut self, &mut self,
mut parents: Parents<'_>, doc: &Automerge,
obj: ExId, obj: ExId,
prop: Prop, prop: Prop,
(value, id): (Value<'_>, ExId), (value, id): (Value<'_>, ExId),
conflict: bool, conflict: bool,
) { ) {
let path = parents.path(); if let Ok(mut p) = doc.parents(&obj) {
self.patches.push(Patch::Put { self.patches.push(Patch::Put {
obj, obj,
path, path: p.path(),
prop, prop,
value: (value.into_owned(), id), value: (value.into_owned(), id),
conflict, conflict,
}); });
} }
}
fn increment( fn expose(
&mut self, &mut self,
mut parents: Parents<'_>, doc: &Automerge,
obj: ExId, obj: ExId,
prop: Prop, prop: Prop,
tagged_value: (i64, ExId), (value, id): (Value<'_>, ExId),
conflict: bool,
) { ) {
let path = parents.path(); if let Ok(mut p) = doc.parents(&obj) {
self.patches.push(Patch::Expose {
obj,
path: p.path(),
prop,
value: (value.into_owned(), id),
conflict,
});
}
}
fn flag_conflict(&mut self, mut _doc: &Automerge, _obj: ExId, _prop: Prop) {}
fn increment(&mut self, doc: &Automerge, obj: ExId, prop: Prop, tagged_value: (i64, ExId)) {
if let Ok(mut p) = doc.parents(&obj) {
self.patches.push(Patch::Increment { self.patches.push(Patch::Increment {
obj, obj,
path, path: p.path(),
prop, prop,
value: tagged_value, value: tagged_value,
}); });
} }
}
fn delete(&mut self, mut parents: Parents<'_>, obj: ExId, prop: Prop) { fn delete(&mut self, doc: &Automerge, obj: ExId, prop: Prop) {
let path = parents.path(); if let Ok(mut p) = doc.parents(&obj) {
self.patches.push(Patch::Delete { obj, path, prop }) self.patches.push(Patch::Delete {
obj,
path: p.path(),
prop,
})
}
} }
fn merge(&mut self, other: &Self) { fn merge(&mut self, other: &Self) {
@ -201,7 +265,20 @@ pub enum Patch {
/// Whether this put conflicts with another. /// Whether this put conflicts with another.
conflict: bool, conflict: bool,
}, },
/// Inserting a new element into a list/text /// Exposing (via delete) an old but conflicted value with a prop in a map, or a list element
Expose {
/// path to the object
path: Vec<(ExId, Prop)>,
/// The object that was put into.
obj: ExId,
/// The prop that the new value was put at.
prop: Prop,
/// The value that was put, and the id of the operation that put it there.
value: (Value<'static>, ExId),
/// Whether this put conflicts with another.
conflict: bool,
},
/// Inserting a new element into a list
Insert { Insert {
/// path to the object /// path to the object
path: Vec<(ExId, Prop)>, path: Vec<(ExId, Prop)>,
@ -212,6 +289,17 @@ pub enum Patch {
/// The value that was inserted, and the id of the operation that inserted it there. /// The value that was inserted, and the id of the operation that inserted it there.
value: (Value<'static>, ExId), value: (Value<'static>, ExId),
}, },
/// Splicing a text object
Splice {
/// path to the object
path: Vec<(ExId, Prop)>,
/// The object that was inserted into.
obj: ExId,
/// The index that the new value was inserted at.
index: usize,
/// The value that was spliced
value: String,
},
/// Incrementing a counter. /// Incrementing a counter.
Increment { Increment {
/// path to the object /// path to the object

View file

@ -3,7 +3,7 @@ use crate::exid::ExId;
use crate::indexed_cache::IndexedCache; use crate::indexed_cache::IndexedCache;
use crate::op_tree::{self, OpTree}; use crate::op_tree::{self, OpTree};
use crate::parents::Parents; use crate::parents::Parents;
use crate::query::{self, OpIdSearch, TreeQuery}; use crate::query::{self, OpIdVisSearch, TreeQuery};
use crate::types::{self, ActorId, Key, ObjId, Op, OpId, OpIds, OpType, Prop}; use crate::types::{self, ActorId, Key, ObjId, Op, OpId, OpIds, OpType, Prop};
use crate::{ObjType, OpObserver}; use crate::{ObjType, OpObserver};
use fxhash::FxBuildHasher; use fxhash::FxBuildHasher;
@ -73,10 +73,12 @@ impl OpSetInternal {
Parents { obj, ops: self } Parents { obj, ops: self }
} }
pub(crate) fn parent_object(&self, obj: &ObjId) -> Option<(ObjId, Key)> { pub(crate) fn parent_object(&self, obj: &ObjId) -> Option<(ObjId, Key, bool)> {
let parent = self.trees.get(obj)?.parent?; let parent = self.trees.get(obj)?.parent?;
let key = self.search(&parent, OpIdSearch::new(obj.0)).key().unwrap(); let query = self.search(&parent, OpIdVisSearch::new(obj.0));
Some((parent, key)) let key = query.key().unwrap();
let visible = query.visible;
Some((parent, key, visible))
} }
pub(crate) fn export_key(&self, obj: ObjId, key: Key) -> Prop { pub(crate) fn export_key(&self, obj: ObjId, key: Key) -> Prop {
@ -169,7 +171,7 @@ impl OpSetInternal {
} }
} }
pub(crate) fn replace<F>(&mut self, obj: &ObjId, index: usize, f: F) pub(crate) fn change_vis<F>(&mut self, obj: &ObjId, index: usize, f: F)
where where
F: Fn(&mut Op), F: Fn(&mut Op),
{ {
@ -231,6 +233,7 @@ impl OpSetInternal {
} }
} }
/*
pub(crate) fn insert_op(&mut self, obj: &ObjId, op: Op) -> Op { pub(crate) fn insert_op(&mut self, obj: &ObjId, op: Op) -> Op {
let q = self.search(obj, query::SeekOp::new(&op)); let q = self.search(obj, query::SeekOp::new(&op));
@ -252,6 +255,7 @@ impl OpSetInternal {
observer: &mut Obs, observer: &mut Obs,
) -> Op { ) -> Op {
let q = self.search(obj, query::SeekOpWithPatch::new(&op)); let q = self.search(obj, query::SeekOpWithPatch::new(&op));
let obj_type = self.object_type(obj);
let query::SeekOpWithPatch { let query::SeekOpWithPatch {
pos, pos,
@ -271,13 +275,17 @@ impl OpSetInternal {
}; };
if op.insert { if op.insert {
if obj_type == Some(ObjType::Text) {
observer.splice_text(parents, ex_obj, seen, op.to_str());
} else {
let value = (op.value(), self.id_to_exid(op.id)); let value = (op.value(), self.id_to_exid(op.id));
observer.insert(parents, ex_obj, seen, value); observer.insert(parents, ex_obj, seen, value);
}
} else if op.is_delete() { } else if op.is_delete() {
if let Some(winner) = &values.last() { if let Some(winner) = &values.last() {
let value = (winner.value(), self.id_to_exid(winner.id)); let value = (winner.value(), self.id_to_exid(winner.id));
let conflict = values.len() > 1; let conflict = values.len() > 1;
observer.put(parents, ex_obj, key, value, conflict); observer.expose(parents, ex_obj, key, value, conflict);
} else if had_value_before { } else if had_value_before {
observer.delete(parents, ex_obj, key); observer.delete(parents, ex_obj, key);
} }
@ -294,18 +302,15 @@ impl OpSetInternal {
observer.increment(parents, ex_obj, key, (value, self.id_to_exid(op.id))); observer.increment(parents, ex_obj, key, (value, self.id_to_exid(op.id)));
} }
} else { } else {
let winner = if let Some(last_value) = values.last() { let just_conflict = values
if self.m.lamport_cmp(op.id, last_value.id) == Ordering::Greater { .last()
&op .map(|value| self.m.lamport_cmp(op.id, value.id) != Ordering::Greater)
} else { .unwrap_or(false);
last_value let value = (op.value(), self.id_to_exid(op.id));
}
} else {
&op
};
let value = (winner.value(), self.id_to_exid(winner.id));
if op.is_list_op() && !had_value_before { if op.is_list_op() && !had_value_before {
observer.insert(parents, ex_obj, seen, value); observer.insert(parents, ex_obj, seen, value);
} else if just_conflict {
observer.flag_conflict(parents, ex_obj, key);
} else { } else {
let conflict = !values.is_empty(); let conflict = !values.is_empty();
observer.put(parents, ex_obj, key, value, conflict); observer.put(parents, ex_obj, key, value, conflict);
@ -320,6 +325,7 @@ impl OpSetInternal {
op op
} }
*/
pub(crate) fn object_type(&self, id: &ObjId) -> Option<ObjType> { pub(crate) fn object_type(&self, id: &ObjId) -> Option<ObjType> {
self.trees.get(id).map(|tree| tree.objtype) self.trees.get(id).map(|tree| tree.objtype)

View file

@ -7,7 +7,7 @@ use crate::{
op_tree::OpTreeInternal, op_tree::OpTreeInternal,
storage::load::{DocObserver, LoadedObject}, storage::load::{DocObserver, LoadedObject},
types::{ObjId, Op}, types::{ObjId, Op},
OpObserver, Automerge, OpObserver,
}; };
/// An opset builder which creates an optree for each object as it finishes loading, inserting the /// An opset builder which creates an optree for each object as it finishes loading, inserting the
@ -78,10 +78,10 @@ impl<'a, O: OpObserver> DocObserver for ObservedOpSetBuilder<'a, O> {
} }
fn finish(self, _metadata: super::OpSetMetadata) -> Self::Output { fn finish(self, _metadata: super::OpSetMetadata) -> Self::Output {
let mut opset = OpSet::new(); let mut opset = Automerge::new();
for (obj, op) in self.ops { for (obj, op) in self.ops {
opset.insert_op_with_observer(&obj, op, self.observer); opset.insert_op_with_observer(&obj, op, self.observer);
} }
opset opset.ops
} }
} }

View file

@ -8,7 +8,7 @@ use std::{
pub(crate) use crate::op_set::OpSetMetadata; pub(crate) use crate::op_set::OpSetMetadata;
use crate::{ use crate::{
clock::Clock, clock::Clock,
query::{self, Index, QueryResult, ReplaceArgs, TreeQuery}, query::{self, ChangeVisibility, Index, QueryResult, TreeQuery},
}; };
use crate::{ use crate::{
types::{ObjId, Op, OpId}, types::{ObjId, Op, OpId},
@ -618,24 +618,20 @@ impl OpTreeNode {
/// Update the operation at the given index using the provided function. /// Update the operation at the given index using the provided function.
/// ///
/// This handles updating the indices after the update. /// This handles updating the indices after the update.
pub(crate) fn update<F>(&mut self, index: usize, f: F) -> ReplaceArgs pub(crate) fn update<F>(&mut self, index: usize, f: F) -> ChangeVisibility
where where
F: FnOnce(&mut Op), F: FnOnce(&mut Op),
{ {
if self.is_leaf() { if self.is_leaf() {
let new_element = self.elements.get_mut(index).unwrap(); let new_element = self.elements.get_mut(index).unwrap();
let old_id = new_element.id; let old_vis = new_element.visible();
let old_visible = new_element.visible();
f(new_element); f(new_element);
let replace_args = ReplaceArgs { self.index.change_vis(ChangeVisibility {
old_id, old_vis,
new_id: new_element.id, new_vis: new_element.visible(),
old_visible, key: new_element.elemid_or_key(),
new_visible: new_element.visible(), utf16_len: new_element.utf16_len(),
new_key: new_element.elemid_or_key(), })
};
self.index.replace(&replace_args);
replace_args
} else { } else {
let mut cumulative_len = 0; let mut cumulative_len = 0;
let len = self.len(); let len = self.len();
@ -646,23 +642,18 @@ impl OpTreeNode {
} }
Ordering::Equal => { Ordering::Equal => {
let new_element = self.elements.get_mut(child_index).unwrap(); let new_element = self.elements.get_mut(child_index).unwrap();
let old_id = new_element.id; let old_vis = new_element.visible();
let old_visible = new_element.visible();
f(new_element); f(new_element);
let replace_args = ReplaceArgs { return self.index.change_vis(ChangeVisibility {
old_id, old_vis,
new_id: new_element.id, new_vis: new_element.visible(),
old_visible, key: new_element.elemid_or_key(),
new_visible: new_element.visible(), utf16_len: new_element.utf16_len(),
new_key: new_element.elemid_or_key(), });
};
self.index.replace(&replace_args);
return replace_args;
} }
Ordering::Greater => { Ordering::Greater => {
let replace_args = child.update(index - cumulative_len, f); let vis_args = child.update(index - cumulative_len, f);
self.index.replace(&replace_args); return self.index.change_vis(vis_args);
return replace_args;
} }
} }
} }

View file

@ -10,23 +10,36 @@ pub struct Parents<'a> {
impl<'a> Parents<'a> { impl<'a> Parents<'a> {
pub fn path(&mut self) -> Vec<(ExId, Prop)> { pub fn path(&mut self) -> Vec<(ExId, Prop)> {
let mut path = self.collect::<Vec<_>>(); let mut path = self.map(|(id, prop, _)| (id, prop)).collect::<Vec<_>>();
path.reverse(); path.reverse();
path path
} }
pub fn visible_path(&mut self) -> Option<Vec<(ExId, Prop)>> {
let mut path = Vec::new();
for (id, prop, vis) in self {
if !vis {
return None;
}
path.push((id, prop))
}
path.reverse();
Some(path)
}
} }
impl<'a> Iterator for Parents<'a> { impl<'a> Iterator for Parents<'a> {
type Item = (ExId, Prop); type Item = (ExId, Prop, bool);
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
if self.obj.is_root() { if self.obj.is_root() {
None None
} else if let Some((obj, key)) = self.ops.parent_object(&self.obj) { } else if let Some((obj, key, visible)) = self.ops.parent_object(&self.obj) {
self.obj = obj; self.obj = obj;
Some(( Some((
self.ops.id_to_exid(self.obj.0), self.ops.id_to_exid(self.obj.0),
self.ops.export_key(self.obj, key), self.ops.export_key(self.obj, key),
visible,
)) ))
} else { } else {
None None

View file

@ -20,6 +20,7 @@ mod map_range_at;
mod nth; mod nth;
mod nth_at; mod nth_at;
mod opid; mod opid;
mod opid_vis;
mod prop; mod prop;
mod prop_at; mod prop_at;
mod seek_op; mod seek_op;
@ -40,6 +41,7 @@ pub(crate) use map_range_at::MapRangeAt;
pub(crate) use nth::Nth; pub(crate) use nth::Nth;
pub(crate) use nth_at::NthAt; pub(crate) use nth_at::NthAt;
pub(crate) use opid::OpIdSearch; pub(crate) use opid::OpIdSearch;
pub(crate) use opid_vis::OpIdVisSearch;
pub(crate) use prop::Prop; pub(crate) use prop::Prop;
pub(crate) use prop_at::PropAt; pub(crate) use prop_at::PropAt;
pub(crate) use seek_op::SeekOp; pub(crate) use seek_op::SeekOp;
@ -47,12 +49,11 @@ pub(crate) use seek_op_with_patch::SeekOpWithPatch;
// use a struct for the args for clarity as they are passed up the update chain in the optree // use a struct for the args for clarity as they are passed up the update chain in the optree
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ReplaceArgs { pub(crate) struct ChangeVisibility {
pub(crate) old_id: OpId, pub(crate) old_vis: bool,
pub(crate) new_id: OpId, pub(crate) new_vis: bool,
pub(crate) old_visible: bool, pub(crate) key: Key,
pub(crate) new_visible: bool, pub(crate) utf16_len: usize,
pub(crate) new_key: Key,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@ -100,6 +101,7 @@ pub(crate) enum QueryResult {
pub(crate) struct Index { pub(crate) struct Index {
/// The map of visible keys to the number of visible operations for that key. /// The map of visible keys to the number of visible operations for that key.
pub(crate) visible: HashMap<Key, usize, FxBuildHasher>, pub(crate) visible: HashMap<Key, usize, FxBuildHasher>,
pub(crate) visible16: usize,
/// Set of opids found in this node and below. /// Set of opids found in this node and below.
pub(crate) ops: HashSet<OpId, FxBuildHasher>, pub(crate) ops: HashSet<OpId, FxBuildHasher>,
} }
@ -108,6 +110,7 @@ impl Index {
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
Index { Index {
visible: Default::default(), visible: Default::default(),
visible16: 0,
ops: Default::default(), ops: Default::default(),
} }
} }
@ -121,40 +124,47 @@ impl Index {
self.visible.contains_key(seen) self.visible.contains_key(seen)
} }
pub(crate) fn replace( pub(crate) fn change_vis(&mut self, change_vis: ChangeVisibility) -> ChangeVisibility {
&mut self, let ChangeVisibility {
ReplaceArgs { old_vis,
old_id, new_vis,
new_id, key,
old_visible, utf16_len,
new_visible, } = &change_vis;
new_key, match (old_vis, new_vis) {
}: &ReplaceArgs, (true, false) => match self.visible.get(key).copied() {
) {
if old_id != new_id {
self.ops.remove(old_id);
self.ops.insert(*new_id);
}
match (new_visible, old_visible, new_key) {
(false, true, key) => match self.visible.get(key).copied() {
Some(n) if n == 1 => { Some(n) if n == 1 => {
self.visible.remove(key); self.visible.remove(key);
self.visible16 -= *utf16_len;
} }
Some(n) => { Some(n) => {
self.visible.insert(*key, n - 1); self.visible.insert(*key, n - 1);
} }
None => panic!("remove overun in index"), None => panic!("remove overun in index"),
}, },
(true, false, key) => *self.visible.entry(*key).or_default() += 1, (false, true) => {
if let Some(n) = self.visible.get(key) {
self.visible.insert(*key, n + 1);
} else {
self.visible.insert(*key, 1);
self.visible16 += *utf16_len;
}
}
_ => {} _ => {}
} }
change_vis
} }
pub(crate) fn insert(&mut self, op: &Op) { pub(crate) fn insert(&mut self, op: &Op) {
self.ops.insert(op.id); self.ops.insert(op.id);
if op.visible() { if op.visible() {
*self.visible.entry(op.elemid_or_key()).or_default() += 1; let key = op.elemid_or_key();
if let Some(n) = self.visible.get(&key) {
self.visible.insert(key, n + 1);
} else {
self.visible.insert(key, 1);
self.visible16 += op.utf16_len();
}
} }
} }
@ -165,6 +175,7 @@ impl Index {
match self.visible.get(&key).copied() { match self.visible.get(&key).copied() {
Some(n) if n == 1 => { Some(n) if n == 1 => {
self.visible.remove(&key); self.visible.remove(&key);
self.visible16 -= op.utf16_len();
} }
Some(n) => { Some(n) => {
self.visible.insert(key, n - 1); self.visible.insert(key, n - 1);
@ -178,9 +189,12 @@ impl Index {
for id in &other.ops { for id in &other.ops {
self.ops.insert(*id); self.ops.insert(*id);
} }
for (elem, n) in other.visible.iter() { for (elem, other_len) in other.visible.iter() {
*self.visible.entry(*elem).or_default() += n; self.visible.entry(*elem)
.and_modify(|len| *len += *other_len)
.or_insert(*other_len);
} }
self.visible16 += other.visible16;
} }
} }

View file

@ -1,6 +1,6 @@
use crate::op_tree::OpTreeNode; use crate::op_tree::OpTreeNode;
use crate::query::{QueryResult, TreeQuery}; use crate::query::{QueryResult, TreeQuery};
use crate::types::{ElemId, Key, Op, OpId}; use crate::types::{Key, Op, OpId};
/// Search for an OpId in a tree. /// Search for an OpId in a tree.
/// Returns the index of the operation in the tree. /// Returns the index of the operation in the tree.
@ -30,10 +30,6 @@ impl OpIdSearch {
None None
} }
} }
pub(crate) fn key(&self) -> &Option<Key> {
&self.key
}
} }
impl<'a> TreeQuery<'a> for OpIdSearch { impl<'a> TreeQuery<'a> for OpIdSearch {
@ -49,11 +45,6 @@ impl<'a> TreeQuery<'a> for OpIdSearch {
fn query_element(&mut self, element: &Op) -> QueryResult { fn query_element(&mut self, element: &Op) -> QueryResult {
if element.id == self.target { if element.id == self.target {
self.found = true; self.found = true;
if element.insert {
self.key = Some(Key::Seq(ElemId(element.id)));
} else {
self.key = Some(element.key);
}
QueryResult::Finish QueryResult::Finish
} else { } else {
self.pos += 1; self.pos += 1;

View file

@ -0,0 +1,62 @@
use crate::op_tree::OpTreeNode;
use crate::query::{QueryResult, TreeQuery};
use crate::types::{Key, Op, OpId};
/// Search for an OpId in a tree.
/// Returns the index of the operation in the tree.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct OpIdVisSearch {
target: OpId,
found: bool,
pub(crate) visible: bool,
key: Option<Key>,
}
impl OpIdVisSearch {
pub(crate) fn new(target: OpId) -> Self {
OpIdVisSearch {
target,
found: false,
visible: true,
key: None,
}
}
pub(crate) fn key(&self) -> &Option<Key> {
&self.key
}
}
impl<'a> TreeQuery<'a> for OpIdVisSearch {
fn query_node(&mut self, child: &OpTreeNode) -> QueryResult {
if child.index.ops.contains(&self.target) {
QueryResult::Descend
} else {
QueryResult::Next
}
}
fn query_element(&mut self, element: &Op) -> QueryResult {
if element.id == self.target {
self.found = true;
self.key = Some(element.elemid_or_key());
if element.visible() {
QueryResult::Next
} else {
self.visible = false;
QueryResult::Finish
}
} else if self.found {
if self.key != Some(element.elemid_or_key()) {
QueryResult::Finish
} else if element.visible() {
self.visible = false;
QueryResult::Finish
} else {
QueryResult::Next
}
} else {
QueryResult::Next
}
}
}

View file

@ -11,6 +11,7 @@ pub(crate) struct SeekOpWithPatch<'a> {
pub(crate) succ: Vec<usize>, pub(crate) succ: Vec<usize>,
found: bool, found: bool,
pub(crate) seen: usize, pub(crate) seen: usize,
pub(crate) seen16: usize,
last_seen: Option<Key>, last_seen: Option<Key>,
pub(crate) values: Vec<&'a Op>, pub(crate) values: Vec<&'a Op>,
pub(crate) had_value_before: bool, pub(crate) had_value_before: bool,
@ -26,6 +27,7 @@ impl<'a> SeekOpWithPatch<'a> {
pos: 0, pos: 0,
found: false, found: false,
seen: 0, seen: 0,
seen16: 0,
last_seen: None, last_seen: None,
values: vec![], values: vec![],
had_value_before: false, had_value_before: false,
@ -58,6 +60,7 @@ impl<'a> SeekOpWithPatch<'a> {
} }
if e.visible() && self.last_seen.is_none() { if e.visible() && self.last_seen.is_none() {
self.seen += 1; self.seen += 1;
self.seen16 += e.utf16_len();
self.last_seen = Some(e.elemid_or_key()) self.last_seen = Some(e.elemid_or_key())
} }
} }
@ -113,6 +116,7 @@ impl<'a> TreeQuery<'a> for SeekOpWithPatch<'a> {
} }
} }
self.seen += num_vis; self.seen += num_vis;
self.seen += child.index.visible16;
// FIXME: this is also wrong: `last_seen` needs to be the elemId of the // FIXME: this is also wrong: `last_seen` needs to be the elemId of the
// last *visible* list element in this subtree, but I think this returns // last *visible* list element in this subtree, but I think this returns

View file

@ -99,7 +99,7 @@ impl TransactionInner {
for (obj, _prop, op) in self.operations.into_iter().rev() { for (obj, _prop, op) in self.operations.into_iter().rev() {
for pred_id in &op.pred { for pred_id in &op.pred {
if let Some(p) = doc.ops.search(&obj, OpIdSearch::new(*pred_id)).index() { if let Some(p) = doc.ops.search(&obj, OpIdSearch::new(*pred_id)).index() {
doc.ops.replace(&obj, p, |o| o.remove_succ(&op)); doc.ops.change_vis(&obj, p, |o| o.remove_succ(&op));
} }
} }
if let Some(pos) = doc.ops.search(&obj, OpIdSearch::new(op.id)).index() { if let Some(pos) = doc.ops.search(&obj, OpIdSearch::new(op.id)).index() {
@ -140,6 +140,15 @@ impl TransactionInner {
let obj = doc.exid_to_obj(ex_obj)?; let obj = doc.exid_to_obj(ex_obj)?;
let value = value.into(); let value = value.into();
let prop = prop.into(); let prop = prop.into();
let obj_type = doc
.ops
.object_type(&obj)
.ok_or(AutomergeError::NotAnObject)?;
match (&prop, obj_type) {
(Prop::Map(_), ObjType::Map) => Ok(()),
(Prop::Seq(_), ObjType::List) => Ok(()),
_ => Err(AutomergeError::InvalidOp(obj_type)),
}?;
self.local_op(doc, op_observer, obj, prop, value.into())?; self.local_op(doc, op_observer, obj, prop, value.into())?;
Ok(()) Ok(())
} }
@ -167,6 +176,15 @@ impl TransactionInner {
) -> Result<ExId, AutomergeError> { ) -> Result<ExId, AutomergeError> {
let obj = doc.exid_to_obj(ex_obj)?; let obj = doc.exid_to_obj(ex_obj)?;
let prop = prop.into(); let prop = prop.into();
let obj_type = doc
.ops
.object_type(&obj)
.ok_or(AutomergeError::NotAnObject)?;
match (&prop, obj_type) {
(Prop::Map(_), ObjType::Map) => Ok(()),
(Prop::Seq(_), ObjType::List) => Ok(()),
_ => Err(AutomergeError::InvalidOp(obj_type)),
}?;
let id = self let id = self
.local_op(doc, op_observer, obj, prop, value.into())? .local_op(doc, op_observer, obj, prop, value.into())?
.unwrap(); .unwrap();
@ -207,6 +225,13 @@ impl TransactionInner {
value: V, value: V,
) -> Result<(), AutomergeError> { ) -> Result<(), AutomergeError> {
let obj = doc.exid_to_obj(ex_obj)?; let obj = doc.exid_to_obj(ex_obj)?;
let obj_type = doc
.ops
.object_type(&obj)
.ok_or(AutomergeError::NotAnObject)?;
if obj_type != ObjType::List {
return Err(AutomergeError::InvalidOp(obj_type));
}
let value = value.into(); let value = value.into();
tracing::trace!(obj=?obj, value=?value, "inserting value"); tracing::trace!(obj=?obj, value=?value, "inserting value");
self.do_insert(doc, op_observer, obj, index, value.into())?; self.do_insert(doc, op_observer, obj, index, value.into())?;
@ -222,6 +247,13 @@ impl TransactionInner {
value: ObjType, value: ObjType,
) -> Result<ExId, AutomergeError> { ) -> Result<ExId, AutomergeError> {
let obj = doc.exid_to_obj(ex_obj)?; let obj = doc.exid_to_obj(ex_obj)?;
let obj_type = doc
.ops
.object_type(&obj)
.ok_or(AutomergeError::NotAnObject)?;
if obj_type != ObjType::List {
return Err(AutomergeError::InvalidOp(obj_type));
}
let id = self.do_insert(doc, op_observer, obj, index, value.into())?; let id = self.do_insert(doc, op_observer, obj, index, value.into())?;
let id = doc.id_to_exid(id); let id = doc.id_to_exid(id);
Ok(id) Ok(id)
@ -391,13 +423,93 @@ impl TransactionInner {
pub(crate) fn splice<Obs: OpObserver>( pub(crate) fn splice<Obs: OpObserver>(
&mut self, &mut self,
doc: &mut Automerge, doc: &mut Automerge,
mut op_observer: Option<&mut Obs>, op_observer: Option<&mut Obs>,
ex_obj: &ExId, ex_obj: &ExId,
mut pos: usize, pos: usize,
del: usize, del: usize,
vals: impl IntoIterator<Item = ScalarValue>, vals: impl IntoIterator<Item = ScalarValue>,
) -> Result<(), AutomergeError> { ) -> Result<(), AutomergeError> {
let obj = doc.exid_to_obj(ex_obj)?; let obj = doc.exid_to_obj(ex_obj)?;
let obj_type = doc
.ops
.object_type(&obj)
.ok_or(AutomergeError::NotAnObject)?;
if obj_type != ObjType::List {
return Err(AutomergeError::InvalidOp(obj_type));
}
self.inner_splice(doc, op_observer, obj, pos, del, vals)
}
/// Splice string into a text object
pub(crate) fn splice_text<Obs: OpObserver>(
&mut self,
doc: &mut Automerge,
op_observer: Option<&mut Obs>,
ex_obj: &ExId,
pos: usize,
del: usize,
text: &str,
) -> Result<(), AutomergeError> {
let obj = doc.exid_to_obj(ex_obj)?;
let obj_type = doc
.ops
.object_type(&obj)
.ok_or(AutomergeError::NotAnObject)?;
if obj_type != ObjType::Text {
return Err(AutomergeError::InvalidOp(obj_type));
}
let vals = text.chars().map(ScalarValue::from);
self.inner_splice(doc, op_observer, obj, pos, del, vals)
}
/// Splice string into a text object
pub(crate) fn splice_text_utf16<Obs: OpObserver>(
&mut self,
doc: &mut Automerge,
mut op_observer: Option<&mut Obs>,
ex_obj: &ExId,
mut pos: usize,
del: usize,
text: &str,
) -> Result<(), AutomergeError> {
let obj = doc.exid_to_obj(ex_obj)?;
let obj_type = doc
.ops
.object_type(&obj)
.ok_or(AutomergeError::NotAnObject)?;
if obj_type != ObjType::Text {
return Err(AutomergeError::InvalidOp(obj_type));
}
let vals = text.chars().map(ScalarValue::from);
for _ in 0..del {
// This unwrap and rewrap of the option is necessary to appeas the borrow checker :(
if let Some(obs) = op_observer.as_mut() {
self.local_op(doc, Some(*obs), obj, pos.into(), OpType::Delete)?;
} else {
self.local_op::<Obs>(doc, None, obj, pos.into(), OpType::Delete)?;
}
}
for v in vals {
// As above this unwrap and rewrap of the option is necessary to appeas the borrow checker :(
if let Some(obs) = op_observer.as_mut() {
self.do_insert(doc, Some(*obs), obj, pos, v.clone().into())?;
} else {
self.do_insert::<Obs>(doc, None, obj, pos, v.clone().into())?;
}
pos += 1;
}
Ok(())
}
pub(crate) fn inner_splice<Obs: OpObserver>(
&mut self,
doc: &mut Automerge,
mut op_observer: Option<&mut Obs>,
obj: ObjId,
mut pos: usize,
del: usize,
vals: impl IntoIterator<Item = ScalarValue>,
) -> Result<(), AutomergeError> {
for _ in 0..del { for _ in 0..del {
// This unwrap and rewrap of the option is necessary to appeas the borrow checker :( // This unwrap and rewrap of the option is necessary to appeas the borrow checker :(
if let Some(obs) = op_observer.as_mut() { if let Some(obs) = op_observer.as_mut() {
@ -429,25 +541,32 @@ impl TransactionInner {
// TODO - id_to_exid should be a noop if not used - change type to Into<ExId>? // TODO - id_to_exid should be a noop if not used - change type to Into<ExId>?
if let Some(op_observer) = op_observer { if let Some(op_observer) = op_observer {
let ex_obj = doc.ops.id_to_exid(obj.0); let ex_obj = doc.ops.id_to_exid(obj.0);
let parents = doc.ops.parents(obj);
if op.insert { if op.insert {
let obj_type = doc.ops.object_type(&obj);
match (obj_type, prop.clone()) {
(Some(ObjType::List), Prop::Seq(index)) => {
let value = (op.value(), doc.ops.id_to_exid(op.id)); let value = (op.value(), doc.ops.id_to_exid(op.id));
match prop { op_observer.insert(doc, ex_obj, index, value)
Prop::Map(_) => panic!("insert into a map"), }
Prop::Seq(index) => op_observer.insert(parents, ex_obj, index, value), (Some(ObjType::Text), Prop::Seq(index)) => {
// FIXME
op_observer.splice_text(doc, ex_obj, index, 0, op.to_str())
}
// this should be a warning - not a panic
_ => panic!("insert into a map"),
} }
} else if op.is_delete() { } else if op.is_delete() {
op_observer.delete(parents, ex_obj, prop.clone()); op_observer.delete(doc, ex_obj, prop.clone());
} else if let Some(value) = op.get_increment_value() { } else if let Some(value) = op.get_increment_value() {
op_observer.increment( op_observer.increment(
parents, doc,
ex_obj, ex_obj,
prop.clone(), prop.clone(),
(value, doc.ops.id_to_exid(op.id)), (value, doc.ops.id_to_exid(op.id)),
); );
} else { } else {
let value = (op.value(), doc.ops.id_to_exid(op.id)); let value = (op.value(), doc.ops.id_to_exid(op.id));
op_observer.put(parents, ex_obj, prop.clone(), value, false); op_observer.put(doc, ex_obj, prop.clone(), value, false);
} }
} }
self.operations.push((obj, prop, op)); self.operations.push((obj, prop, op));

View file

@ -171,6 +171,26 @@ impl<'a, Obs: observation::Observation> Transactable for Transaction<'a, Obs> {
self.do_tx(|tx, doc, obs| tx.splice(doc, obs, obj.as_ref(), pos, del, vals)) self.do_tx(|tx, doc, obs| tx.splice(doc, obs, obj.as_ref(), pos, del, vals))
} }
fn splice_text<O: AsRef<ExId>>(
&mut self,
obj: O,
pos: usize,
del: usize,
text: &str,
) -> Result<(), AutomergeError> {
self.do_tx(|tx, doc, obs| tx.splice_text(doc, obs, obj.as_ref(), pos, del, text))
}
fn splice_text_utf16<O: AsRef<ExId>>(
&mut self,
obj: O,
pos: usize,
del: usize,
text: &str,
) -> Result<(), AutomergeError> {
self.do_tx(|tx, doc, obs| tx.splice_text_utf16(doc, obs, obj.as_ref(), pos, del, text))
}
fn keys<O: AsRef<ExId>>(&self, obj: O) -> Keys<'_, '_> { fn keys<O: AsRef<ExId>>(&self, obj: O) -> Keys<'_, '_> {
self.doc.keys(obj) self.doc.keys(obj)
} }

View file

@ -91,10 +91,16 @@ pub trait Transactable {
pos: usize, pos: usize,
del: usize, del: usize,
text: &str, text: &str,
) -> Result<(), AutomergeError> { ) -> Result<(), AutomergeError>;
let vals = text.chars().map(|c| c.into());
self.splice(obj, pos, del, vals) /// Like [`Self::splice`] but for text.
} fn splice_text_utf16<O: AsRef<ExId>>(
&mut self,
obj: O,
pos: usize,
del: usize,
text: &str,
) -> Result<(), AutomergeError>;
/// Get the keys of the given object, it should be a map. /// Get the keys of the given object, it should be a map.
fn keys<O: AsRef<ExId>>(&self, obj: O) -> Keys<'_, '_>; fn keys<O: AsRef<ExId>>(&self, obj: O) -> Keys<'_, '_>;
@ -193,9 +199,7 @@ pub trait Transactable {
fn parents<O: AsRef<ExId>>(&self, obj: O) -> Result<Parents<'_>, AutomergeError>; fn parents<O: AsRef<ExId>>(&self, obj: O) -> Result<Parents<'_>, AutomergeError>;
fn path_to_object<O: AsRef<ExId>>(&self, obj: O) -> Result<Vec<(ExId, Prop)>, AutomergeError> { fn path_to_object<O: AsRef<ExId>>(&self, obj: O) -> Result<Vec<(ExId, Prop)>, AutomergeError> {
let mut path = self.parents(obj.as_ref().clone())?.collect::<Vec<_>>(); Ok(self.parents(obj.as_ref().clone())?.path())
path.reverse();
Ok(path)
} }
/// The heads this transaction will be based on /// The heads this transaction will be based on

View file

@ -491,6 +491,22 @@ impl Op {
} }
} }
pub(crate) fn utf16_len(&self) -> usize {
if let OpType::Put(ScalarValue::Str(s)) = &self.action {
s.encode_utf16().count()
} else {
1 // "\u{fffc}".to_owned().encode_utf16().count()
}
}
pub(crate) fn to_str(&self) -> &str {
if let OpType::Put(ScalarValue::Str(s)) = &self.action {
s
} else {
"\u{fffc}"
}
}
pub(crate) fn visible(&self) -> bool { pub(crate) fn visible(&self) -> bool {
if self.is_inc() { if self.is_inc() {
false false

View file

@ -1123,8 +1123,7 @@ fn test_merging_test_conflicts_then_saving_and_loading() {
let mut doc1 = new_doc_with_actor(actor1); let mut doc1 = new_doc_with_actor(actor1);
let text = doc1.put_object(ROOT, "text", ObjType::Text).unwrap(); let text = doc1.put_object(ROOT, "text", ObjType::Text).unwrap();
doc1.splice(&text, 0, 0, "hello".chars().map(|c| c.to_string().into())) doc1.splice_text(&text, 0, 0, "hello").unwrap();
.unwrap();
let mut doc2 = AutoCommit::load(&doc1.save()).unwrap(); let mut doc2 = AutoCommit::load(&doc1.save()).unwrap();
doc2.set_actor(actor2); doc2.set_actor(actor2);
@ -1133,11 +1132,10 @@ fn test_merging_test_conflicts_then_saving_and_loading() {
"text" => { list![{"h"}, {"e"}, {"l"}, {"l"}, {"o"}]}, "text" => { list![{"h"}, {"e"}, {"l"}, {"l"}, {"o"}]},
}}; }};
doc2.splice(&text, 4, 1, Vec::new()).unwrap(); doc2.splice_text(&text, 4, 1, "").unwrap();
doc2.splice(&text, 4, 0, vec!["!".into()]).unwrap(); doc2.splice_text(&text, 4, 0, "!").unwrap();
doc2.splice(&text, 5, 0, vec![" ".into()]).unwrap(); doc2.splice_text(&text, 5, 0, " ").unwrap();
doc2.splice(&text, 6, 0, "world".chars().map(|c| c.into())) doc2.splice_text(&text, 6, 0, "world").unwrap();
.unwrap();
assert_doc!( assert_doc!(
doc2.document(), doc2.document(),
@ -1373,3 +1371,29 @@ fn simple_bad_saveload() {
let bytes = doc.save(); let bytes = doc.save();
Automerge::load(&bytes).unwrap(); Automerge::load(&bytes).unwrap();
} }
#[test]
fn ops_on_wrong_objets() -> Result<(), AutomergeError> {
let mut doc = AutoCommit::new();
let list = doc.put_object(&automerge::ROOT, "list", ObjType::List)?;
doc.insert(&list, 0, "a")?;
doc.insert(&list, 1, "b")?;
let e1 = doc.put(&list, "a", "AAA");
assert_eq!(e1, Err(AutomergeError::InvalidOp(ObjType::List)));
let e2 = doc.splice_text(&list, 0, 0, "hello world");
assert_eq!(e2, Err(AutomergeError::InvalidOp(ObjType::List)));
let map = doc.put_object(&automerge::ROOT, "map", ObjType::Map)?;
doc.put(&map, "a", "AAA")?;
doc.put(&map, "b", "BBB")?;
let e3 = doc.insert(&map, 0, "b");
assert_eq!(e3, Err(AutomergeError::InvalidOp(ObjType::Map)));
let e4 = doc.splice_text(&map, 0, 0, "hello world");
assert_eq!(e4, Err(AutomergeError::InvalidOp(ObjType::Map)));
let text = doc.put_object(&automerge::ROOT, "text", ObjType::Text)?;
doc.splice_text(&text, 0, 0, "hello world")?;
let e5 = doc.put(&text, "a", "AAA");
assert_eq!(e5, Err(AutomergeError::InvalidOp(ObjType::Text)));
let e6 = doc.insert(&text, 0, "b");
assert_eq!(e6, Err(AutomergeError::InvalidOp(ObjType::Text)));
Ok(())
}

View file

@ -1,12 +1,9 @@
// Apply the paper editing trace to an Automerge.Text object, one char at a time // Apply the paper editing trace to an Automerge.Text object, one char at a time
const { edits, finalText } = require('./editing-trace') const { edits, finalText } = require('./editing-trace')
const Automerge = require('../automerge-js') const Automerge = require('../../javascript')
const wasm_api = require('../automerge-wasm')
Automerge.use(wasm_api)
const start = new Date() const start = new Date()
let state = Automerge.from({text: new Automerge.Text()}) let state = Automerge.from({text: ""})
state = Automerge.change(state, doc => { state = Automerge.change(state, doc => {
for (let i = 0; i < edits.length; i++) { for (let i = 0; i < edits.length; i++) {
@ -14,14 +11,13 @@ state = Automerge.change(state, doc => {
console.log(`Processed ${i} edits in ${new Date() - start} ms`) console.log(`Processed ${i} edits in ${new Date() - start} ms`)
} }
let edit = edits[i] let edit = edits[i]
if (edit[1] > 0) doc.text.deleteAt(edit[0], edit[1]) Automerge.splice(doc, 'text', ... edit)
if (edit.length > 2) doc.text.insertAt(edit[0], ...edit.slice(2))
} }
}) })
let _ = Automerge.save(state) let _ = Automerge.save(state)
console.log(`Done in ${new Date() - start} ms`) console.log(`Done in ${new Date() - start} ms`)
if (state.text.join('') !== finalText) { if (state.text !== finalText) {
throw new RangeError('ERROR: final text did not match expectation') throw new RangeError('ERROR: final text did not match expectation')
} }