Skip to content

Commit 08fd595

Browse files
committed
Creating and storing Cursor objects
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.
1 parent 170c069 commit 08fd595

File tree

11 files changed

+179
-19
lines changed

11 files changed

+179
-19
lines changed

backend/columnar.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ const COLUMN_TYPE = {
2929

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

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

5657
const CHANGE_COLUMNS = Object.assign({
@@ -239,16 +240,18 @@ function encodeValue(op, columns) {
239240
columns.valLen.appendValue(VALUE_TYPE.FALSE)
240241
} else if (op.value === true) {
241242
columns.valLen.appendValue(VALUE_TYPE.TRUE)
242-
} else if (typeof op.value === 'string') {
243-
const numBytes = columns.valRaw.appendRawString(op.value)
244-
columns.valLen.appendValue(numBytes << 4 | VALUE_TYPE.UTF8)
245243
} else if (ArrayBuffer.isView(op.value)) {
246244
const numBytes = columns.valRaw.appendRawBytes(new Uint8Array(op.value.buffer))
247245
columns.valLen.appendValue(numBytes << 4 | VALUE_TYPE.BYTES)
248246
} else if (op.datatype === 'counter' && typeof op.value === 'number') {
249247
encodeInteger(op.value, VALUE_TYPE.COUNTER, columns)
250248
} else if (op.datatype === 'timestamp' && typeof op.value === 'number') {
251249
encodeInteger(op.value, VALUE_TYPE.TIMESTAMP, columns)
250+
} else if (op.datatype === 'cursor') {
251+
columns.valLen.appendValue(VALUE_TYPE.CURSOR) // cursor's elemId is stored in the ref columns
252+
} else if (typeof op.value === 'string') {
253+
const numBytes = columns.valRaw.appendRawString(op.value)
254+
columns.valLen.appendValue(numBytes << 4 | VALUE_TYPE.UTF8)
252255
} else if (typeof op.datatype === 'number' && op.datatype >= VALUE_TYPE.MIN_UNKNOWN &&
253256
op.datatype <= VALUE_TYPE.MAX_UNKNOWN && op.value instanceof Uint8Array) {
254257
const numBytes = columns.valRaw.appendRawBytes(op.value)
@@ -310,6 +313,8 @@ function decodeValue(sizeTag, bytes) {
310313
return {value: new Decoder(bytes).readInt53(), datatype: 'counter'}
311314
} else if (sizeTag % 16 === VALUE_TYPE.TIMESTAMP) {
312315
return {value: new Decoder(bytes).readInt53(), datatype: 'timestamp'}
316+
} else if (sizeTag === VALUE_TYPE.CURSOR) {
317+
return {value: '', datatype: 'cursor'}
313318
} else {
314319
return {value: bytes, datatype: sizeTag % 16}
315320
}
@@ -1075,7 +1080,9 @@ function addPatchProperty(objects, property) {
10751080
for (let succId of op.succ) counter.succ[succId] = true
10761081

10771082
} else if (op.succ.length === 0) { // Ignore any ops that have been overwritten
1078-
if (op.actionName.startsWith('make')) {
1083+
if (op.actionName === 'set' && op.value.datatype === 'cursor') {
1084+
values[op.opId] = {elemId: op.ref, datatype: 'cursor'}
1085+
} else if (op.actionName.startsWith('make')) {
10791086
values[op.opId] = objects[op.opId]
10801087
} else if (op.actionName === 'set') {
10811088
values[op.opId] = op.value

backend/op_set.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,13 @@ function setPatchProps(opSet, objectId, key, patch) {
255255
ops[opId] = true
256256

257257
if (op.get('action') === 'set') {
258-
patch.props[key][opId] = {value: op.get('value')}
259-
if (op.get('datatype')) {
260-
patch.props[key][opId].datatype = op.get('datatype')
258+
if (op.get('datatype') === 'cursor') {
259+
patch.props[key][opId] = {elemId: op.get('ref'), datatype: 'cursor'}
260+
} else {
261+
patch.props[key][opId] = {value: op.get('value')}
262+
if (op.get('datatype')) {
263+
patch.props[key][opId].datatype = op.get('datatype')
264+
}
261265
}
262266
} else if (isChildOp(op)) {
263267
if (!patch.props[key][opId]) {

frontend/apply_patch.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const { OPTIONS, OBJECT_ID, CONFLICTS, ELEM_IDS } = require('./constants')
33
const { Text, instantiateText } = require('./text')
44
const { Table, instantiateTable } = require('./table')
55
const { Counter } = require('./counter')
6+
const { Cursor } = require('./cursor')
67

78
/**
89
* Reconstructs the value from the patch object `patch`.
@@ -20,6 +21,8 @@ function getValue(patch, object, updated) {
2021
return new Date(patch.value)
2122
} else if (patch.datatype === 'counter') {
2223
return new Counter(patch.value)
24+
} else if (patch.datatype === 'cursor') {
25+
return new Cursor('', patch.index, patch.elemId)
2326
} else if (patch.datatype !== undefined) {
2427
throw new TypeError(`Unknown datatype: ${patch.datatype}`)
2528
} else {

frontend/context.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const { interpretPatch } = require('./apply_patch')
33
const { Text } = require('./text')
44
const { Table } = require('./table')
55
const { Counter, getWriteableCounter } = require('./counter')
6+
const { Cursor } = require('./cursor')
67
const { isObject, copyObject } = require('../src/common')
78
const uuid = require('../src/uuid')
89

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

5253
} else if (value instanceof Counter) {
53-
// Counter object
5454
return {value: value.value, datatype: 'counter'}
5555

56+
} else if (value instanceof Cursor) {
57+
return {elemId: value.elemId, datatype: 'cursor'}
58+
5659
} else {
5760
// Nested object (map, list, text, or table)
5861
const objectId = value[OBJECT_ID]
@@ -175,6 +178,9 @@ class Context {
175178
if (object[key] instanceof Counter) {
176179
return getWriteableCounter(object[key].value, this, path, objectId, key)
177180

181+
} else if (object[key] instanceof Cursor) {
182+
return object[key].getWriteable(context, path)
183+
178184
} else if (isObject(object[key])) {
179185
const childId = object[key][OBJECT_ID]
180186
const subpath = path.concat([{key, objectId: childId}])
@@ -264,16 +270,24 @@ class Context {
264270
throw new RangeError('The key of a map entry must not be an empty string')
265271
}
266272

267-
if (isObject(value) && !(value instanceof Date) && !(value instanceof Counter)) {
268-
// Nested object (map, list, text, or table)
269-
return this.createNestedObjects(objectId, key, value, insert, pred, elemId)
270-
} else {
271-
// Date or counter object, or primitive value (number, string, boolean, or null)
273+
if (!isObject(value) || value instanceof Date || value instanceof Counter || value instanceof Cursor) {
274+
// Date/counter/cursor object, or primitive value (number, string, boolean, or null)
272275
const description = this.getValueDescription(value)
273276
const op = elemId ? {action: 'set', obj: objectId, elemId, insert, pred}
274277
: {action: 'set', obj: objectId, key, insert, pred}
275-
this.addOp(Object.assign(op, description))
278+
279+
if (description.datatype === 'cursor') {
280+
op.ref = description.elemId
281+
op.datatype = 'cursor'
282+
} else {
283+
op.value = description.value
284+
if (description.datatype) op.datatype = description.datatype
285+
}
286+
this.addOp(op)
276287
return description
288+
} else {
289+
// Nested object (map, list, text, or table)
290+
return this.createNestedObjects(objectId, key, value, insert, pred, elemId)
277291
}
278292
}
279293

frontend/cursor.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const { OBJECT_ID, ELEM_IDS } = require('./constants')
2+
const { isObject } = require('../src/common')
3+
4+
/**
5+
* A cursor references a particular element in a list, or a character in an
6+
* Automerge.Text object. As list elements get inserted/deleted ahead of the
7+
* cursor position, the index of the cursor is automatically recomputed.
8+
*/
9+
class Cursor {
10+
constructor(object, index, elemId = undefined) {
11+
if (Array.isArray(object) && object[ELEM_IDS] && typeof index === 'number') {
12+
this.objectId = object[OBJECT_ID]
13+
this.elemId = object[ELEM_IDS][index]
14+
this.index = index
15+
} else if (isObject(object) && object.getElemId && typeof index === 'number') {
16+
this.objectId = object[OBJECT_ID]
17+
this.elemId = object.getElemId(index)
18+
this.index = index
19+
} else if (typeof object == 'string' && /*typeof index === 'number' &&*/ typeof elemId === 'string') {
20+
this.objectId = object
21+
this.elemId = elemId
22+
this.index = index
23+
} else {
24+
throw new TypeError('Construct a cursor using a list/text object and index')
25+
}
26+
}
27+
28+
/**
29+
* Called when a cursor is accessed within a change callback. `context` is the
30+
* proxy context that keeps track of any mutations.
31+
*/
32+
getWriteable(context, path) {
33+
const instance = new Cursor(this.objectId, this.index, this.elemId)
34+
instance.context = context
35+
instance.path = path
36+
return instance
37+
}
38+
}
39+
40+
module.exports = { Cursor }

frontend/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { Context } = require('./context')
77
const { Text } = require('./text')
88
const { Table } = require('./table')
99
const { Counter } = require('./counter')
10+
const { Cursor } = require('./cursor')
1011

1112
/**
1213
* Actor IDs must consist only of hexadecimal digits so that they can be encoded
@@ -370,5 +371,5 @@ module.exports = {
370371
init, from, change, emptyChange, applyPatch,
371372
getObjectId, getObjectById, getActorId, setActorId, getConflicts, getLastLocalChange,
372373
getBackendState, getElementIds,
373-
Text, Table, Counter
374+
Text, Table, Counter, Cursor
374375
}

frontend/proxies.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { OBJECT_ID, CHANGE, STATE } = require('./constants')
1+
const { OBJECT_ID, CHANGE, STATE, ELEM_IDS } = require('./constants')
22
const { Counter } = require('./counter')
33
const { Text } = require('./text')
44
const { Table } = require('./table')
@@ -148,6 +148,7 @@ const ListHandler = {
148148
if (key === Symbol.iterator) return context.getObject(objectId)[Symbol.iterator]
149149
if (key === OBJECT_ID) return objectId
150150
if (key === CHANGE) return context
151+
if (key === ELEM_IDS) return context.getObject(objectId)[ELEM_IDS]
151152
if (key === 'length') return context.getObject(objectId).length
152153
if (typeof key === 'string' && /^[0-9]+$/.test(key)) {
153154
return context.getObjectField(path, objectId, parseListIndex(key))

frontend/text.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { OBJECT_ID } = require('./constants')
22
const { isObject } = require('../src/common')
3+
const { Cursor } = require('./cursor')
34

45
class Text {
56
constructor (text) {
@@ -35,6 +36,10 @@ class Text {
3536
return this.elems[index].elemId
3637
}
3738

39+
getCursorAt (index) {
40+
return new Cursor(this, index)
41+
}
42+
3843
/**
3944
* Iterates over the text elements character by character, including any
4045
* inline objects.

src/automerge.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ module.exports = {
132132
}
133133

134134
for (let name of ['getObjectId', 'getObjectById', 'getActorId',
135-
'setActorId', 'getConflicts', 'getLastLocalChange', 'Text', 'Table', 'Counter']) {
135+
'setActorId', 'getConflicts', 'getLastLocalChange',
136+
'Text', 'Table', 'Counter', 'Cursor']) {
136137
module.exports[name] = Frontend[name]
137138
}

test/backend_test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,27 @@ describe('Automerge.Backend', () => {
254254
}}}}
255255
})
256256
})
257+
258+
it('should support Cursor objects', () => {
259+
const actor = uuid()
260+
const change = {actor, seq: 1, startOp: 1, time: 0, deps: [], ops: [
261+
{action: 'makeList', obj: '_root', key: 'list', pred: []},
262+
{action: 'set', obj: `1@${actor}`, elemId: '_head', insert: true, value: 'fish', pred: []},
263+
{action: 'set', obj: '_root', key: 'cursor', ref: `2@${actor}`, datatype: 'cursor', pred: []}
264+
]}
265+
const [s1, patch] = Backend.applyChanges(Backend.init(), [encodeChange(change)])
266+
assert.deepStrictEqual(patch, {
267+
clock: {[actor]: 1}, deps: [hash(change)], maxOp: 3,
268+
diffs: {objectId: '_root', type: 'map', props: {
269+
list: {[`1@${actor}`]: {
270+
objectId: `1@${actor}`, type: 'list',
271+
edits: [{action: 'insert', index: 0, elemId: `2@${actor}`}],
272+
props: {0: {[`2@${actor}`]: {value: 'fish'}}}
273+
}},
274+
cursor: {[`3@${actor}`]: {elemId: `2@${actor}`, datatype: 'cursor'}}
275+
}}
276+
})
277+
})
257278
})
258279

259280
describe('applyLocalChange()', () => {
@@ -630,5 +651,26 @@ describe('Automerge.Backend', () => {
630651
}}}}
631652
})
632653
})
654+
655+
it('should include Cursor objects', () => {
656+
const actor = uuid()
657+
const change = {actor, seq: 1, startOp: 1, time: 0, deps: [], ops: [
658+
{action: 'makeList', obj: '_root', key: 'list', pred: []},
659+
{action: 'set', obj: `1@${actor}`, elemId: '_head', insert: true, value: 'fish', pred: []},
660+
{action: 'set', obj: '_root', key: 'cursor', ref: `2@${actor}`, datatype: 'cursor', pred: []}
661+
]}
662+
const [s1, patch] = Backend.applyChanges(Backend.init(), [encodeChange(change)])
663+
assert.deepStrictEqual(patch, {
664+
clock: {[actor]: 1}, deps: [hash(change)], maxOp: 3,
665+
diffs: {objectId: '_root', type: 'map', props: {
666+
list: {[`1@${actor}`]: {
667+
objectId: `1@${actor}`, type: 'list',
668+
edits: [{action: 'insert', index: 0, elemId: `2@${actor}`}],
669+
props: {0: {[`2@${actor}`]: {value: 'fish'}}}
670+
}},
671+
cursor: {[`3@${actor}`]: {elemId: `2@${actor}`, datatype: 'cursor'}}
672+
}}
673+
})
674+
})
633675
})
634676
})

0 commit comments

Comments
 (0)