Skip to content

Commit

Permalink
Creating and storing Cursor objects
Browse files Browse the repository at this point in the history
For now it just stores the elemId referenced by the cursor in the
backend, updates the frontend-backend protocol to handle cursors, and
instantiates Cursor objects in the frontend.

Does not actually perform the conversion from elemId to index yet;
that's next.
  • Loading branch information
ept committed Feb 2, 2021
1 parent 170c069 commit 08fd595
Show file tree
Hide file tree
Showing 11 changed files with 179 additions and 19 deletions.
19 changes: 13 additions & 6 deletions backend/columnar.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const COLUMN_TYPE = {

const VALUE_TYPE = {
NULL: 0, FALSE: 1, TRUE: 2, LEB128_UINT: 3, LEB128_INT: 4, IEEE754: 5,
UTF8: 6, BYTES: 7, COUNTER: 8, TIMESTAMP: 9, MIN_UNKNOWN: 10, MAX_UNKNOWN: 15
UTF8: 6, BYTES: 7, COUNTER: 8, TIMESTAMP: 9, CURSOR: 10,
MIN_UNKNOWN: 11, MAX_UNKNOWN: 15
}

// make* actions must be at even-numbered indexes in this list
Expand All @@ -50,7 +51,7 @@ const COMMON_COLUMNS = {
valLen: 5 << 3 | COLUMN_TYPE.VALUE_LEN,
valRaw: 5 << 3 | COLUMN_TYPE.VALUE_RAW,
refActor: 6 << 3 | COLUMN_TYPE.ACTOR_ID,
refCtr: 6 << 3 | COLUMN_TYPE.INT_DELTA
refCtr: 6 << 3 | COLUMN_TYPE.INT_RLE
}

const CHANGE_COLUMNS = Object.assign({
Expand Down Expand Up @@ -239,16 +240,18 @@ function encodeValue(op, columns) {
columns.valLen.appendValue(VALUE_TYPE.FALSE)
} else if (op.value === true) {
columns.valLen.appendValue(VALUE_TYPE.TRUE)
} else if (typeof op.value === 'string') {
const numBytes = columns.valRaw.appendRawString(op.value)
columns.valLen.appendValue(numBytes << 4 | VALUE_TYPE.UTF8)
} else if (ArrayBuffer.isView(op.value)) {
const numBytes = columns.valRaw.appendRawBytes(new Uint8Array(op.value.buffer))
columns.valLen.appendValue(numBytes << 4 | VALUE_TYPE.BYTES)
} else if (op.datatype === 'counter' && typeof op.value === 'number') {
encodeInteger(op.value, VALUE_TYPE.COUNTER, columns)
} else if (op.datatype === 'timestamp' && typeof op.value === 'number') {
encodeInteger(op.value, VALUE_TYPE.TIMESTAMP, columns)
} else if (op.datatype === 'cursor') {
columns.valLen.appendValue(VALUE_TYPE.CURSOR) // cursor's elemId is stored in the ref columns
} else if (typeof op.value === 'string') {
const numBytes = columns.valRaw.appendRawString(op.value)
columns.valLen.appendValue(numBytes << 4 | VALUE_TYPE.UTF8)
} else if (typeof op.datatype === 'number' && op.datatype >= VALUE_TYPE.MIN_UNKNOWN &&
op.datatype <= VALUE_TYPE.MAX_UNKNOWN && op.value instanceof Uint8Array) {
const numBytes = columns.valRaw.appendRawBytes(op.value)
Expand Down Expand Up @@ -310,6 +313,8 @@ function decodeValue(sizeTag, bytes) {
return {value: new Decoder(bytes).readInt53(), datatype: 'counter'}
} else if (sizeTag % 16 === VALUE_TYPE.TIMESTAMP) {
return {value: new Decoder(bytes).readInt53(), datatype: 'timestamp'}
} else if (sizeTag === VALUE_TYPE.CURSOR) {
return {value: '', datatype: 'cursor'}
} else {
return {value: bytes, datatype: sizeTag % 16}
}
Expand Down Expand Up @@ -1075,7 +1080,9 @@ function addPatchProperty(objects, property) {
for (let succId of op.succ) counter.succ[succId] = true

} else if (op.succ.length === 0) { // Ignore any ops that have been overwritten
if (op.actionName.startsWith('make')) {
if (op.actionName === 'set' && op.value.datatype === 'cursor') {
values[op.opId] = {elemId: op.ref, datatype: 'cursor'}
} else if (op.actionName.startsWith('make')) {
values[op.opId] = objects[op.opId]
} else if (op.actionName === 'set') {
values[op.opId] = op.value
Expand Down
10 changes: 7 additions & 3 deletions backend/op_set.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,13 @@ function setPatchProps(opSet, objectId, key, patch) {
ops[opId] = true

if (op.get('action') === 'set') {
patch.props[key][opId] = {value: op.get('value')}
if (op.get('datatype')) {
patch.props[key][opId].datatype = op.get('datatype')
if (op.get('datatype') === 'cursor') {
patch.props[key][opId] = {elemId: op.get('ref'), datatype: 'cursor'}
} else {
patch.props[key][opId] = {value: op.get('value')}
if (op.get('datatype')) {
patch.props[key][opId].datatype = op.get('datatype')
}
}
} else if (isChildOp(op)) {
if (!patch.props[key][opId]) {
Expand Down
3 changes: 3 additions & 0 deletions frontend/apply_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { OPTIONS, OBJECT_ID, CONFLICTS, ELEM_IDS } = require('./constants')
const { Text, instantiateText } = require('./text')
const { Table, instantiateTable } = require('./table')
const { Counter } = require('./counter')
const { Cursor } = require('./cursor')

/**
* Reconstructs the value from the patch object `patch`.
Expand All @@ -20,6 +21,8 @@ function getValue(patch, object, updated) {
return new Date(patch.value)
} else if (patch.datatype === 'counter') {
return new Counter(patch.value)
} else if (patch.datatype === 'cursor') {
return new Cursor('', patch.index, patch.elemId)
} else if (patch.datatype !== undefined) {
throw new TypeError(`Unknown datatype: ${patch.datatype}`)
} else {
Expand Down
28 changes: 21 additions & 7 deletions frontend/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { interpretPatch } = require('./apply_patch')
const { Text } = require('./text')
const { Table } = require('./table')
const { Counter, getWriteableCounter } = require('./counter')
const { Cursor } = require('./cursor')
const { isObject, copyObject } = require('../src/common')
const uuid = require('../src/uuid')

Expand Down Expand Up @@ -50,9 +51,11 @@ class Context {
return {value: value.getTime(), datatype: 'timestamp'}

} else if (value instanceof Counter) {
// Counter object
return {value: value.value, datatype: 'counter'}

} else if (value instanceof Cursor) {
return {elemId: value.elemId, datatype: 'cursor'}

} else {
// Nested object (map, list, text, or table)
const objectId = value[OBJECT_ID]
Expand Down Expand Up @@ -175,6 +178,9 @@ class Context {
if (object[key] instanceof Counter) {
return getWriteableCounter(object[key].value, this, path, objectId, key)

} else if (object[key] instanceof Cursor) {
return object[key].getWriteable(context, path)

} else if (isObject(object[key])) {
const childId = object[key][OBJECT_ID]
const subpath = path.concat([{key, objectId: childId}])
Expand Down Expand Up @@ -264,16 +270,24 @@ class Context {
throw new RangeError('The key of a map entry must not be an empty string')
}

if (isObject(value) && !(value instanceof Date) && !(value instanceof Counter)) {
// Nested object (map, list, text, or table)
return this.createNestedObjects(objectId, key, value, insert, pred, elemId)
} else {
// Date or counter object, or primitive value (number, string, boolean, or null)
if (!isObject(value) || value instanceof Date || value instanceof Counter || value instanceof Cursor) {
// Date/counter/cursor object, or primitive value (number, string, boolean, or null)
const description = this.getValueDescription(value)
const op = elemId ? {action: 'set', obj: objectId, elemId, insert, pred}
: {action: 'set', obj: objectId, key, insert, pred}
this.addOp(Object.assign(op, description))

if (description.datatype === 'cursor') {
op.ref = description.elemId
op.datatype = 'cursor'
} else {
op.value = description.value
if (description.datatype) op.datatype = description.datatype
}
this.addOp(op)
return description
} else {
// Nested object (map, list, text, or table)
return this.createNestedObjects(objectId, key, value, insert, pred, elemId)
}
}

Expand Down
40 changes: 40 additions & 0 deletions frontend/cursor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const { OBJECT_ID, ELEM_IDS } = require('./constants')
const { isObject } = require('../src/common')

/**
* A cursor references a particular element in a list, or a character in an
* Automerge.Text object. As list elements get inserted/deleted ahead of the
* cursor position, the index of the cursor is automatically recomputed.
*/
class Cursor {
constructor(object, index, elemId = undefined) {
if (Array.isArray(object) && object[ELEM_IDS] && typeof index === 'number') {
this.objectId = object[OBJECT_ID]
this.elemId = object[ELEM_IDS][index]
this.index = index
} else if (isObject(object) && object.getElemId && typeof index === 'number') {
this.objectId = object[OBJECT_ID]
this.elemId = object.getElemId(index)
this.index = index
} else if (typeof object == 'string' && /*typeof index === 'number' &&*/ typeof elemId === 'string') {
this.objectId = object
this.elemId = elemId
this.index = index
} else {
throw new TypeError('Construct a cursor using a list/text object and index')
}
}

/**
* Called when a cursor is accessed within a change callback. `context` is the
* proxy context that keeps track of any mutations.
*/
getWriteable(context, path) {
const instance = new Cursor(this.objectId, this.index, this.elemId)
instance.context = context
instance.path = path
return instance
}
}

module.exports = { Cursor }
3 changes: 2 additions & 1 deletion frontend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const { Context } = require('./context')
const { Text } = require('./text')
const { Table } = require('./table')
const { Counter } = require('./counter')
const { Cursor } = require('./cursor')

/**
* Actor IDs must consist only of hexadecimal digits so that they can be encoded
Expand Down Expand Up @@ -370,5 +371,5 @@ module.exports = {
init, from, change, emptyChange, applyPatch,
getObjectId, getObjectById, getActorId, setActorId, getConflicts, getLastLocalChange,
getBackendState, getElementIds,
Text, Table, Counter
Text, Table, Counter, Cursor
}
3 changes: 2 additions & 1 deletion frontend/proxies.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { OBJECT_ID, CHANGE, STATE } = require('./constants')
const { OBJECT_ID, CHANGE, STATE, ELEM_IDS } = require('./constants')
const { Counter } = require('./counter')
const { Text } = require('./text')
const { Table } = require('./table')
Expand Down Expand Up @@ -148,6 +148,7 @@ const ListHandler = {
if (key === Symbol.iterator) return context.getObject(objectId)[Symbol.iterator]
if (key === OBJECT_ID) return objectId
if (key === CHANGE) return context
if (key === ELEM_IDS) return context.getObject(objectId)[ELEM_IDS]
if (key === 'length') return context.getObject(objectId).length
if (typeof key === 'string' && /^[0-9]+$/.test(key)) {
return context.getObjectField(path, objectId, parseListIndex(key))
Expand Down
5 changes: 5 additions & 0 deletions frontend/text.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const { OBJECT_ID } = require('./constants')
const { isObject } = require('../src/common')
const { Cursor } = require('./cursor')

class Text {
constructor (text) {
Expand Down Expand Up @@ -35,6 +36,10 @@ class Text {
return this.elems[index].elemId
}

getCursorAt (index) {
return new Cursor(this, index)
}

/**
* Iterates over the text elements character by character, including any
* inline objects.
Expand Down
3 changes: 2 additions & 1 deletion src/automerge.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ module.exports = {
}

for (let name of ['getObjectId', 'getObjectById', 'getActorId',
'setActorId', 'getConflicts', 'getLastLocalChange', 'Text', 'Table', 'Counter']) {
'setActorId', 'getConflicts', 'getLastLocalChange',
'Text', 'Table', 'Counter', 'Cursor']) {
module.exports[name] = Frontend[name]
}
42 changes: 42 additions & 0 deletions test/backend_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,27 @@ describe('Automerge.Backend', () => {
}}}}
})
})

it('should support Cursor objects', () => {
const actor = uuid()
const change = {actor, seq: 1, startOp: 1, time: 0, deps: [], ops: [
{action: 'makeList', obj: '_root', key: 'list', pred: []},
{action: 'set', obj: `1@${actor}`, elemId: '_head', insert: true, value: 'fish', pred: []},
{action: 'set', obj: '_root', key: 'cursor', ref: `2@${actor}`, datatype: 'cursor', pred: []}
]}
const [s1, patch] = Backend.applyChanges(Backend.init(), [encodeChange(change)])
assert.deepStrictEqual(patch, {
clock: {[actor]: 1}, deps: [hash(change)], maxOp: 3,
diffs: {objectId: '_root', type: 'map', props: {
list: {[`1@${actor}`]: {
objectId: `1@${actor}`, type: 'list',
edits: [{action: 'insert', index: 0, elemId: `2@${actor}`}],
props: {0: {[`2@${actor}`]: {value: 'fish'}}}
}},
cursor: {[`3@${actor}`]: {elemId: `2@${actor}`, datatype: 'cursor'}}
}}
})
})
})

describe('applyLocalChange()', () => {
Expand Down Expand Up @@ -630,5 +651,26 @@ describe('Automerge.Backend', () => {
}}}}
})
})

it('should include Cursor objects', () => {
const actor = uuid()
const change = {actor, seq: 1, startOp: 1, time: 0, deps: [], ops: [
{action: 'makeList', obj: '_root', key: 'list', pred: []},
{action: 'set', obj: `1@${actor}`, elemId: '_head', insert: true, value: 'fish', pred: []},
{action: 'set', obj: '_root', key: 'cursor', ref: `2@${actor}`, datatype: 'cursor', pred: []}
]}
const [s1, patch] = Backend.applyChanges(Backend.init(), [encodeChange(change)])
assert.deepStrictEqual(patch, {
clock: {[actor]: 1}, deps: [hash(change)], maxOp: 3,
diffs: {objectId: '_root', type: 'map', props: {
list: {[`1@${actor}`]: {
objectId: `1@${actor}`, type: 'list',
edits: [{action: 'insert', index: 0, elemId: `2@${actor}`}],
props: {0: {[`2@${actor}`]: {value: 'fish'}}}
}},
cursor: {[`3@${actor}`]: {elemId: `2@${actor}`, datatype: 'cursor'}}
}}
})
})
})
})
42 changes: 42 additions & 0 deletions test/cursor_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const assert = require('assert')
const Automerge = process.env.TEST_DIST === '1' ? require('../dist/automerge') : require('../src/automerge')

describe('Automerge.Cursor', () => {
it('should allow a cursor on a list element', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.list = [1,2,3]
doc.cursor = new Automerge.Cursor(doc.list, 2)
assert.ok(doc.cursor instanceof Automerge.Cursor)
assert.strictEqual(doc.cursor.elemId, `4@${Automerge.getActorId(doc)}`)
})
assert.ok(s1.cursor instanceof Automerge.Cursor)
assert.strictEqual(s1.cursor.elemId, `4@${Automerge.getActorId(s1)}`)

let s2 = Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s1))
assert.ok(s2.cursor instanceof Automerge.Cursor)
assert.strictEqual(s2.cursor.elemId, `4@${Automerge.getActorId(s1)}`)

let s3 = Automerge.load(Automerge.save(s1))
assert.ok(s3.cursor instanceof Automerge.Cursor)
assert.strictEqual(s3.cursor.elemId, `4@${Automerge.getActorId(s1)}`)
})

it('should allow a cursor on a text character', () => {
let s1 = Automerge.change(Automerge.init(), doc => {
doc.text = new Automerge.Text(['a', 'b', 'c'])
doc.cursor = doc.text.getCursorAt(2)
assert.ok(doc.cursor instanceof Automerge.Cursor)
assert.strictEqual(doc.cursor.elemId, `4@${Automerge.getActorId(doc)}`)
})
assert.ok(s1.cursor instanceof Automerge.Cursor)
assert.strictEqual(s1.cursor.elemId, `4@${Automerge.getActorId(s1)}`)

let s2 = Automerge.applyChanges(Automerge.init(), Automerge.getAllChanges(s1))
assert.ok(s2.cursor instanceof Automerge.Cursor)
assert.strictEqual(s2.cursor.elemId, `4@${Automerge.getActorId(s1)}`)

let s3 = Automerge.load(Automerge.save(s1))
assert.ok(s3.cursor instanceof Automerge.Cursor)
assert.strictEqual(s3.cursor.elemId, `4@${Automerge.getActorId(s1)}`)
})
})

0 comments on commit 08fd595

Please sign in to comment.