frontend/context.js (401 lines of code) (raw):
const { CACHE, OBJECT_ID, CONFLICTS, ELEM_IDS, STATE } = require('./constants')
const { interpretPatch } = require('./apply_patch')
const { Text } = require('./text')
const { Table } = require('./table')
const { Counter, getWriteableCounter } = require('./counter')
const { Int, Uint, Float64 } = require('./numbers')
const { isObject, parseOpId, createArrayOfNulls } = require('../src/common')
const uuid = require('../src/uuid')
/**
* An instance of this class is passed to `rootObjectProxy()`. The methods are
* called by proxy object mutation functions to query the current object state
* and to apply the requested changes.
*/
class Context {
constructor (doc, actorId, applyPatch) {
this.actorId = actorId
this.nextOpNum = doc[STATE].maxOp + 1
this.cache = doc[CACHE]
this.updated = {}
this.ops = []
this.applyPatch = applyPatch ? applyPatch : interpretPatch
}
/**
* Adds an operation object to the list of changes made in the current context.
*/
addOp(operation) {
this.ops.push(operation)
if (operation.action === 'set' && operation.values) {
this.nextOpNum += operation.values.length
} else if (operation.action === 'del' && operation.multiOp) {
this.nextOpNum += operation.multiOp
} else {
this.nextOpNum += 1
}
}
/**
* Returns the operation ID of the next operation to be added to the context.
*/
nextOpId() {
return `${this.nextOpNum}@${this.actorId}`
}
/**
* Takes a value and returns an object describing the value (in the format used by patches).
*/
getValueDescription(value) {
if (!['object', 'boolean', 'number', 'string'].includes(typeof value)) {
throw new TypeError(`Unsupported type of value: ${typeof value}`)
}
if (isObject(value)) {
if (value instanceof Date) {
// Date object, represented as milliseconds since epoch
return {type: 'value', value: value.getTime(), datatype: 'timestamp'}
} else if (value instanceof Int) {
return {type: 'value', value: value.value, datatype: 'int'}
} else if (value instanceof Uint) {
return {type: 'value', value: value.value, datatype: 'uint'}
} else if (value instanceof Float64) {
return {type: 'value', value: value.value, datatype: 'float64'}
} else if (value instanceof Counter) {
// Counter object
return {type: 'value', value: value.value, datatype: 'counter'}
} else {
// Nested object (map, list, text, or table)
const objectId = value[OBJECT_ID], type = this.getObjectType(objectId)
if (!objectId) {
throw new RangeError(`Object ${JSON.stringify(value)} has no objectId`)
}
if (type === 'list' || type === 'text') {
return {objectId, type, edits: []}
} else {
return {objectId, type, props: {}}
}
}
} else if (typeof value === 'number') {
if (Number.isInteger(value) && value <= Number.MAX_SAFE_INTEGER && value >= Number.MIN_SAFE_INTEGER) {
return {type: 'value', value, datatype: 'int'}
} else {
return {type: 'value', value, datatype: 'float64'}
}
} else {
// Primitive value (string, boolean, or null)
return {type: 'value', value}
}
}
/**
* Builds the values structure describing a single property in a patch. Finds all the values of
* property `key` of `object` (there might be multiple values in the case of a conflict), and
* returns an object that maps operation IDs to descriptions of values.
*/
getValuesDescriptions(path, object, key) {
if (object instanceof Table) {
// Table objects don't have conflicts, since rows are identified by their unique objectId
const value = object.byId(key)
return value ? {[key]: this.getValueDescription(value)} : {}
} else if (object instanceof Text) {
// Text objects don't support conflicts
const value = object.get(key)
const elemId = object.getElemId(key)
return value ? {[elemId]: this.getValueDescription(value)} : {}
} else {
// Map or list objects
const conflicts = object[CONFLICTS][key], values = {}
if (!conflicts) {
throw new RangeError(`No children at key ${key} of path ${JSON.stringify(path)}`)
}
for (let opId of Object.keys(conflicts)) {
values[opId] = this.getValueDescription(conflicts[opId])
}
return values
}
}
/**
* Returns the value at property `key` of object `object`. In the case of a conflict, returns
* the value whose assignment operation has the ID `opId`.
*/
getPropertyValue(object, key, opId) {
if (object instanceof Table) {
return object.byId(key)
} else if (object instanceof Text) {
return object.get(key)
} else {
return object[CONFLICTS][key][opId]
}
}
/**
* Recurses along `path` into the patch object `patch`, creating nodes along the way as needed
* by mutating the patch object. Returns the subpatch at the given path.
*/
getSubpatch(patch, path) {
if (path.length == 0) return patch
let subpatch = patch, object = this.getObject('_root')
for (let pathElem of path) {
let values = this.getValuesDescriptions(path, object, pathElem.key)
if (subpatch.props) {
if (!subpatch.props[pathElem.key]) {
subpatch.props[pathElem.key] = values
}
} else if (subpatch.edits) {
for (const opId of Object.keys(values)) {
subpatch.edits.push({action: 'update', index: pathElem.key, opId, value: values[opId]})
}
}
let nextOpId = null
for (let opId of Object.keys(values)) {
if (values[opId].objectId === pathElem.objectId) {
nextOpId = opId
}
}
if (!nextOpId) {
throw new RangeError(`Cannot find path object with objectId ${pathElem.objectId}`)
}
subpatch = values[nextOpId]
object = this.getPropertyValue(object, pathElem.key, nextOpId)
}
return subpatch
}
/**
* Returns an object (not proxied) from the cache or updated set, as appropriate.
*/
getObject(objectId) {
const object = this.updated[objectId] || this.cache[objectId]
if (!object) throw new RangeError(`Target object does not exist: ${objectId}`)
return object
}
/**
* Returns a string that is either 'map', 'table', 'list', or 'text', indicating
* the type of the object with ID `objectId`.
*/
getObjectType(objectId) {
if (objectId === '_root') return 'map'
const object = this.getObject(objectId)
if (object instanceof Text) return 'text'
if (object instanceof Table) return 'table'
if (Array.isArray(object)) return 'list'
return 'map'
}
/**
* Returns the value associated with the property named `key` on the object
* at path `path`. If the value is an object, returns a proxy for it.
*/
getObjectField(path, objectId, key) {
if (!['string', 'number'].includes(typeof key)) return
const object = this.getObject(objectId)
if (object[key] instanceof Counter) {
return getWriteableCounter(object[key].value, this, path, objectId, key)
} else if (isObject(object[key])) {
const childId = object[key][OBJECT_ID]
const subpath = path.concat([{key, objectId: childId}])
// The instantiateObject function is added to the context object by rootObjectProxy()
return this.instantiateObject(subpath, childId)
} else {
return object[key]
}
}
/**
* Recursively creates Automerge versions of all the objects and nested objects in `value`,
* constructing a patch and operations that describe the object tree. The new object is
* assigned to the property `key` in the object with ID `obj`. If the object is a list or
* text, `key` must be set to the list index being updated, and `elemId` must be set to the
* elemId of the element being updated. If `insert` is true, we insert a new list element
* (or text character) at index `key`, and `elemId` must be the elemId of the immediate
* predecessor element (or the string '_head' if inserting at index 0). If the assignment
* overwrites a previous value at this key/element, `pred` must be set to the array of the
* prior operations we are overwriting (empty array if there is no existing value).
*/
createNestedObjects(obj, key, value, insert, pred, elemId) {
if (value[OBJECT_ID]) {
throw new RangeError('Cannot create a reference to an existing document object')
}
const objectId = this.nextOpId()
if (value instanceof Text) {
// Create a new Text object
this.addOp(elemId ? {action: 'makeText', obj, elemId, insert, pred}
: {action: 'makeText', obj, key, insert, pred})
const subpatch = {objectId, type: 'text', edits: []}
this.insertListItems(subpatch, 0, [...value], true)
return subpatch
} else if (value instanceof Table) {
// Create a new Table object
if (value.count > 0) {
throw new RangeError('Assigning a non-empty Table object is not supported')
}
this.addOp(elemId ? {action: 'makeTable', obj, elemId, insert, pred}
: {action: 'makeTable', obj, key, insert, pred})
return {objectId, type: 'table', props: {}}
} else if (Array.isArray(value)) {
// Create a new list object
this.addOp(elemId ? {action: 'makeList', obj, elemId, insert, pred}
: {action: 'makeList', obj, key, insert, pred})
const subpatch = {objectId, type: 'list', edits: []}
this.insertListItems(subpatch, 0, value, true)
return subpatch
} else {
// Create a new map object
this.addOp(elemId ? {action: 'makeMap', obj, elemId, insert, pred}
: {action: 'makeMap', obj, key, insert, pred})
let props = {}
for (let nested of Object.keys(value).sort()) {
const opId = this.nextOpId()
const valuePatch = this.setValue(objectId, nested, value[nested], false, [])
props[nested] = {[opId]: valuePatch}
}
return {objectId, type: 'map', props}
}
}
/**
* Records an assignment to a particular key in a map, or a particular index in a list.
* `objectId` is the ID of the object being modified, `key` is the property name or list
* index being updated, and `value` is the new value being assigned. If `insert` is true,
* a new list element is inserted at index `key`, and `value` is assigned to that new list
* element. `pred` is an array of opIds for previous values of the property being assigned,
* which are overwritten by this operation. If the object being modified is a list or text,
* `elemId` is the element ID of the list element being updated (if insert=false), or the
* element ID of the list element immediately preceding the insertion (if insert=true).
*
* Returns a patch describing the new value. The return value is of the form
* `{objectId, type, props}` if `value` is an object, or `{value, datatype}` if it is a
* primitive value. For string, number, boolean, or null the datatype is omitted.
*/
setValue(objectId, key, value, insert, pred, elemId) {
if (!objectId) {
throw new RangeError('setValue needs an objectId')
}
if (key === '') {
throw new RangeError('The key of a map entry must not be an empty string')
}
if (isObject(value) && !(value instanceof Date) && !(value instanceof Counter) && !(value instanceof Int) && !(value instanceof Uint) && !(value instanceof Float64)) {
// 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)
const description = this.getValueDescription(value)
const op = {action: 'set', obj: objectId, insert, value: description.value, pred}
if (elemId) op.elemId = elemId; else op.key = key
if (description.datatype) op.datatype = description.datatype
this.addOp(op)
return description
}
}
/**
* Constructs a new patch, calls `callback` with the subpatch at the location `path`,
* and then immediately applies the patch to the document.
*/
applyAtPath(path, callback) {
let diff = {objectId: '_root', type: 'map', props: {}}
callback(this.getSubpatch(diff, path))
this.applyPatch(diff, this.cache._root, this.updated)
}
/**
* Updates the map object at path `path`, setting the property with name
* `key` to `value`.
*/
setMapKey(path, key, value) {
if (typeof key !== 'string') {
throw new RangeError(`The key of a map entry must be a string, not ${typeof key}`)
}
const objectId = path.length === 0 ? '_root' : path[path.length - 1].objectId
const object = this.getObject(objectId)
if (object[key] instanceof Counter) {
throw new RangeError('Cannot overwrite a Counter object; use .increment() or .decrement() to change its value.')
}
// If the assigned field value is the same as the existing value, and
// the assignment does not resolve a conflict, do nothing
if (object[key] !== value || Object.keys(object[CONFLICTS][key] || {}).length > 1 || value === undefined) {
this.applyAtPath(path, subpatch => {
const pred = getPred(object, key)
const opId = this.nextOpId()
const valuePatch = this.setValue(objectId, key, value, false, pred)
subpatch.props[key] = {[opId]: valuePatch}
})
}
}
/**
* Updates the map object at path `path`, deleting the property `key`.
*/
deleteMapKey(path, key) {
const objectId = path.length === 0 ? '_root' : path[path.length - 1].objectId
const object = this.getObject(objectId)
if (object[key] !== undefined) {
const pred = getPred(object, key)
this.addOp({action: 'del', obj: objectId, key, insert: false, pred})
this.applyAtPath(path, subpatch => {
subpatch.props[key] = {}
})
}
}
/**
* Inserts a sequence of new list elements `values` into a list, starting at position `index`.
* `newObject` is true if we are creating a new list object, and false if we are updating an
* existing one. `subpatch` is the patch for the list object being modified. Mutates
* `subpatch` to reflect the sequence of values.
*/
insertListItems(subpatch, index, values, newObject) {
const list = newObject ? [] : this.getObject(subpatch.objectId)
if (index < 0 || index > list.length) {
throw new RangeError(`List index ${index} is out of bounds for list of length ${list.length}`)
}
if (values.length === 0) return
let elemId = getElemId(list, index, true)
const allPrimitive = values.every(v => typeof v === 'string' || typeof v === 'number' ||
typeof v === 'boolean' || v === null ||
(isObject(v) && (v instanceof Date || v instanceof Counter || v instanceof Int ||
v instanceof Uint || v instanceof Float64)))
const allValueDescriptions = allPrimitive ? values.map(v => this.getValueDescription(v)) : []
const allDatatypesSame = allValueDescriptions.every(t => t.datatype === allValueDescriptions[0].datatype)
if (allPrimitive && allDatatypesSame && values.length > 1) {
const nextElemId = this.nextOpId()
const datatype = allValueDescriptions[0].datatype
const values = allValueDescriptions.map(v => v.value)
const op = {action: 'set', obj: subpatch.objectId, elemId, insert: true, values, pred: []}
const edit = {action: 'multi-insert', elemId: nextElemId, index, values}
if (datatype) {
op.datatype = datatype
edit.datatype = datatype
}
this.addOp(op)
subpatch.edits.push(edit)
} else {
for (let offset = 0; offset < values.length; offset++) {
let nextElemId = this.nextOpId()
const valuePatch = this.setValue(subpatch.objectId, index + offset, values[offset], true, [], elemId)
elemId = nextElemId
subpatch.edits.push({action: 'insert', index: index + offset, elemId, opId: elemId, value: valuePatch})
}
}
}
/**
* Updates the list object at path `path`, replacing the current value at
* position `index` with the new value `value`.
*/
setListIndex(path, index, value) {
const objectId = path.length === 0 ? '_root' : path[path.length - 1].objectId
const list = this.getObject(objectId)
// Assignment past the end of the list => insert nulls followed by new value
if (index >= list.length) {
const insertions = createArrayOfNulls(index - list.length)
insertions.push(value)
return this.splice(path, list.length, 0, insertions)
}
if (list[index] instanceof Counter) {
throw new RangeError('Cannot overwrite a Counter object; use .increment() or .decrement() to change its value.')
}
// If the assigned list element value is the same as the existing value, and
// the assignment does not resolve a conflict, do nothing
if (list[index] !== value || Object.keys(list[CONFLICTS][index] || {}).length > 1 || value === undefined) {
this.applyAtPath(path, subpatch => {
const pred = getPred(list, index)
const opId = this.nextOpId()
const valuePatch = this.setValue(objectId, index, value, false, pred, getElemId(list, index))
subpatch.edits.push({action: 'update', index, opId, value: valuePatch})
})
}
}
/**
* Updates the list object at path `path`, deleting `deletions` list elements starting from
* list index `start`, and inserting the list of new elements `insertions` at that position.
*/
splice(path, start, deletions, insertions) {
const objectId = path.length === 0 ? '_root' : path[path.length - 1].objectId
let list = this.getObject(objectId)
if (start < 0 || deletions < 0 || start > list.length - deletions) {
throw new RangeError(`${deletions} deletions starting at index ${start} are out of bounds for list of length ${list.length}`)
}
if (deletions === 0 && insertions.length === 0) return
let patch = {diffs: {objectId: '_root', type: 'map', props: {}}}
let subpatch = this.getSubpatch(patch.diffs, path)
if (deletions > 0) {
let op, lastElemParsed, lastPredParsed
for (let i = 0; i < deletions; i++) {
if (this.getObjectField(path, objectId, start + i) instanceof Counter) {
// This may seem bizarre, but it's really fiddly to implement deletion of counters from
// lists, and I doubt anyone ever needs to do this, so I'm just going to throw an
// exception for now. The reason is: a counter is created by a set operation with counter
// datatype, and subsequent increment ops are successors to the set operation. Normally, a
// set operation with successor indicates a value that has been overwritten, so a set
// operation with successors is normally invisible. Counters are an exception, because the
// increment operations don't make the set operation invisible. When a counter appears in
// a map, this is not too bad: if all successors are increments, then the counter remains
// visible; if one or more successors are deletions, it goes away. However, when deleting
// a list element, we have the additional challenge that we need to distinguish between a
// list element that is being deleted by the current change (in which case we need to put
// a 'remove' action in the patch's edits for that list) and a list element that was
// already deleted previously (in which case the patch should not reflect the deletion).
// This can be done, but as I said, it's fiddly. If someone wants to pick this up in the
// future, hopefully the above description will be enough to get you started. Good luck!
throw new TypeError('Unsupported operation: deleting a counter from a list')
}
// Any sequences of deletions with consecutive elemId and pred values get combined into a
// single multiOp; any others become individual deletion operations. This optimisation only
// kicks in if the user deletes a sequence of elements at once (in a single call to splice);
// it might be nice to also detect such runs of deletions in the case where the user deletes
// a sequence of list elements one by one.
const thisElem = getElemId(list, start + i), thisElemParsed = parseOpId(thisElem)
const thisPred = getPred(list, start + i)
const thisPredParsed = (thisPred.length === 1) ? parseOpId(thisPred[0]) : undefined
if (op && lastElemParsed && lastPredParsed && thisPredParsed &&
lastElemParsed.actorId === thisElemParsed.actorId && lastElemParsed.counter + 1 === thisElemParsed.counter &&
lastPredParsed.actorId === thisPredParsed.actorId && lastPredParsed.counter + 1 === thisPredParsed.counter) {
op.multiOp = (op.multiOp || 1) + 1
} else {
if (op) this.addOp(op)
op = {action: 'del', obj: objectId, elemId: thisElem, insert: false, pred: thisPred}
}
lastElemParsed = thisElemParsed
lastPredParsed = thisPredParsed
}
this.addOp(op)
subpatch.edits.push({action: 'remove', index: start, count: deletions})
}
if (insertions.length > 0) {
this.insertListItems(subpatch, start, insertions, false)
}
this.applyPatch(patch.diffs, this.cache._root, this.updated)
}
/**
* Updates the table object at path `path`, adding a new entry `row`.
* Returns the objectId of the new row.
*/
addTableRow(path, row) {
if (!isObject(row) || Array.isArray(row)) {
throw new TypeError('A table row must be an object')
}
if (row[OBJECT_ID]) {
throw new TypeError('Cannot reuse an existing object as table row')
}
if (row.id) {
throw new TypeError('A table row must not have an "id" property; it is generated automatically')
}
const id = uuid()
const valuePatch = this.setValue(path[path.length - 1].objectId, id, row, false, [])
this.applyAtPath(path, subpatch => {
subpatch.props[id] = {[valuePatch.objectId]: valuePatch}
})
return id
}
/**
* Updates the table object at path `path`, deleting the row with ID `rowId`.
* `pred` is the opId of the operation that originally created the row.
*/
deleteTableRow(path, rowId, pred) {
const objectId = path[path.length - 1].objectId, table = this.getObject(objectId)
if (table.byId(rowId)) {
this.addOp({action: 'del', obj: objectId, key: rowId, insert: false, pred: [pred]})
this.applyAtPath(path, subpatch => {
subpatch.props[rowId] = {}
})
}
}
/**
* Adds the integer `delta` to the value of the counter located at property
* `key` in the object at path `path`.
*/
increment(path, key, delta) {
const objectId = path.length === 0 ? '_root' : path[path.length - 1].objectId
const object = this.getObject(objectId)
if (!(object[key] instanceof Counter)) {
throw new TypeError('Only counter values can be incremented')
}
// TODO what if there is a conflicting value on the same key as the counter?
const type = this.getObjectType(objectId)
const value = object[key].value + delta
const opId = this.nextOpId()
const pred = getPred(object, key)
if (type === 'list' || type === 'text') {
const elemId = getElemId(object, key, false)
this.addOp({action: 'inc', obj: objectId, elemId, value: delta, insert: false, pred})
} else {
this.addOp({action: 'inc', obj: objectId, key, value: delta, insert: false, pred})
}
this.applyAtPath(path, subpatch => {
if (type === 'list' || type === 'text') {
subpatch.edits.push({action: 'update', index: key, opId, value: {value, datatype: 'counter'}})
} else {
subpatch.props[key] = {[opId]: {value, datatype: 'counter'}}
}
})
}
}
function getPred(object, key) {
if (object instanceof Table) {
return [object.opIds[key]]
} else if (object instanceof Text) {
return object.elems[key].pred
} else if (object[CONFLICTS]) {
return object[CONFLICTS][key] ? Object.keys(object[CONFLICTS][key]) : []
} else {
return []
}
}
function getElemId(list, index, insert = false) {
if (insert) {
if (index === 0) return '_head'
index -= 1
}
if (list[ELEM_IDS]) return list[ELEM_IDS][index]
if (list.getElemId) return list.getElemId(index)
throw new RangeError(`Cannot find elemId at list index ${index}`)
}
module.exports = {
Context
}