frontend/index.js (339 lines of code) (raw):
const { OPTIONS, CACHE, STATE, OBJECT_ID, CONFLICTS, CHANGE, ELEM_IDS } = require('./constants')
const { isObject, copyObject } = require('../src/common')
const uuid = require('../src/uuid')
const { interpretPatch, cloneRootObject } = require('./apply_patch')
const { rootObjectProxy } = require('./proxies')
const { Context } = require('./context')
const { Text } = require('./text')
const { Table } = require('./table')
const { Counter } = require('./counter')
const { Float64, Int, Uint } = require('./numbers')
const { Observable } = require('./observable')
/**
* Actor IDs must consist only of hexadecimal digits so that they can be encoded
* compactly in binary form.
*/
function checkActorId(actorId) {
if (typeof actorId !== 'string') {
throw new TypeError(`Unsupported type of actorId: ${typeof actorId}`)
}
if (!/^[0-9a-f]+$/.test(actorId)) {
throw new RangeError('actorId must consist only of lowercase hex digits')
}
if (actorId.length % 2 !== 0) {
throw new RangeError('actorId must consist of an even number of digits')
}
}
/**
* Takes a set of objects that have been updated (in `updated`) and an updated state object
* `state`, and returns a new immutable document root object based on `doc` that reflects
* those updates.
*/
function updateRootObject(doc, updated, state) {
let newDoc = updated._root
if (!newDoc) {
newDoc = cloneRootObject(doc[CACHE]._root)
updated._root = newDoc
}
Object.defineProperty(newDoc, OPTIONS, {value: doc[OPTIONS]})
Object.defineProperty(newDoc, CACHE, {value: updated})
Object.defineProperty(newDoc, STATE, {value: state})
if (doc[OPTIONS].freeze) {
for (let objectId of Object.keys(updated)) {
if (updated[objectId] instanceof Table) {
updated[objectId]._freeze()
} else if (updated[objectId] instanceof Text) {
Object.freeze(updated[objectId].elems)
Object.freeze(updated[objectId])
} else {
Object.freeze(updated[objectId])
Object.freeze(updated[objectId][CONFLICTS])
}
}
}
for (let objectId of Object.keys(doc[CACHE])) {
if (!updated[objectId]) {
updated[objectId] = doc[CACHE][objectId]
}
}
if (doc[OPTIONS].freeze) {
Object.freeze(updated)
}
return newDoc
}
/**
* Adds a new change request to the list of pending requests, and returns an
* updated document root object.
* The details of the change are taken from the context object `context`.
* `options` contains properties that may affect how the change is processed; in
* particular, the `message` property of `options` is an optional human-readable
* string describing the change.
*/
function makeChange(doc, context, options) {
const actor = getActorId(doc)
if (!actor) {
throw new Error('Actor ID must be initialized with setActorId() before making a change')
}
const state = copyObject(doc[STATE])
state.seq += 1
const change = {
actor,
seq: state.seq,
startOp: state.maxOp + 1,
deps: state.deps,
time: (options && typeof options.time === 'number') ? options.time
: Math.round(new Date().getTime() / 1000),
message: (options && typeof options.message === 'string') ? options.message : '',
ops: context.ops
}
if (doc[OPTIONS].backend) {
const [backendState, patch, binaryChange] = doc[OPTIONS].backend.applyLocalChange(state.backendState, change)
state.backendState = backendState
state.lastLocalChange = binaryChange
// NOTE: When performing a local change, the patch is effectively applied twice -- once by the
// context invoking interpretPatch as soon as any change is made, and the second time here
// (after a round-trip through the backend). This is perhaps more robust, as changes only take
// effect in the form processed by the backend, but the downside is a performance cost.
// Should we change this?
const newDoc = applyPatchToDoc(doc, patch, state, true)
const patchCallback = options && options.patchCallback || doc[OPTIONS].patchCallback
if (patchCallback) patchCallback(patch, doc, newDoc, true, [binaryChange])
return [newDoc, change]
} else {
const queuedRequest = {actor, seq: change.seq, before: doc}
state.requests = state.requests.concat([queuedRequest])
state.maxOp = state.maxOp + countOps(change.ops)
state.deps = []
return [updateRootObject(doc, context ? context.updated : {}, state), change]
}
}
function countOps(ops) {
let count = 0
for (const op of ops) {
if (op.action === 'set' && op.values) {
count += op.values.length
} else {
count += 1
}
}
return count
}
/**
* Returns the binary encoding of the last change made by the local actor.
*/
function getLastLocalChange(doc) {
return doc[STATE] && doc[STATE].lastLocalChange ? doc[STATE].lastLocalChange : null
}
/**
* Applies the changes described in `patch` to the document with root object
* `doc`. The state object `state` is attached to the new root object.
* `fromBackend` should be set to `true` if the patch came from the backend,
* and to `false` if the patch is a transient local (optimistically applied)
* change from the frontend.
*/
function applyPatchToDoc(doc, patch, state, fromBackend) {
const actor = getActorId(doc)
const updated = {}
interpretPatch(patch.diffs, doc, updated)
if (fromBackend) {
if (!patch.clock) throw new RangeError('patch is missing clock field')
if (patch.clock[actor] && patch.clock[actor] > state.seq) {
state.seq = patch.clock[actor]
}
state.clock = patch.clock
state.deps = patch.deps
state.maxOp = Math.max(state.maxOp, patch.maxOp)
}
return updateRootObject(doc, updated, state)
}
/**
* Creates an empty document object with no changes.
*/
function init(options) {
if (typeof options === 'string') {
options = {actorId: options}
} else if (typeof options === 'undefined') {
options = {}
} else if (!isObject(options)) {
throw new TypeError(`Unsupported value for init() options: ${options}`)
}
if (!options.deferActorId) {
if (options.actorId === undefined) {
options.actorId = uuid()
}
checkActorId(options.actorId)
}
if (options.observable) {
const patchCallback = options.patchCallback, observable = options.observable
options.patchCallback = (patch, before, after, local, changes) => {
if (patchCallback) patchCallback(patch, before, after, local, changes)
observable.patchCallback(patch, before, after, local, changes)
}
}
const root = {}, cache = {_root: root}
const state = {seq: 0, maxOp: 0, requests: [], clock: {}, deps: []}
if (options.backend) {
state.backendState = options.backend.init()
state.lastLocalChange = null
}
Object.defineProperty(root, OBJECT_ID, {value: '_root'})
Object.defineProperty(root, OPTIONS, {value: Object.freeze(options)})
Object.defineProperty(root, CONFLICTS, {value: Object.freeze({})})
Object.defineProperty(root, CACHE, {value: Object.freeze(cache)})
Object.defineProperty(root, STATE, {value: Object.freeze(state)})
return Object.freeze(root)
}
/**
* Returns a new document object initialized with the given state.
*/
function from(initialState, options) {
return change(init(options), 'Initialization', doc => Object.assign(doc, initialState))
}
/**
* Changes a document `doc` according to actions taken by the local user.
* `options` is an object that can contain the following properties:
* - `message`: an optional descriptive string that is attached to the change.
* If `options` is a string, it is treated as `message`.
*
* The actual change is made within the callback function `callback`, which is
* given a mutable version of the document as argument. Returns a two-element
* array `[doc, request]` where `doc` is the updated document, and `request`
* is the change request to send to the backend. If nothing was actually
* changed, returns the original `doc` and a `null` change request.
*/
function change(doc, options, callback) {
if (doc[OBJECT_ID] !== '_root') {
throw new TypeError('The first argument to Automerge.change must be the document root')
}
if (doc[CHANGE]) {
throw new TypeError('Calls to Automerge.change cannot be nested')
}
if (typeof options === 'function' && callback === undefined) {
[options, callback] = [callback, options]
}
if (typeof options === 'string') {
options = {message: options}
}
if (options !== undefined && !isObject(options)) {
throw new TypeError('Unsupported type of options')
}
const actorId = getActorId(doc)
if (!actorId) {
throw new Error('Actor ID must be initialized with setActorId() before making a change')
}
const context = new Context(doc, actorId)
callback(rootObjectProxy(context))
if (Object.keys(context.updated).length === 0) {
// If the callback didn't change anything, return the original document object unchanged
return [doc, null]
} else {
return makeChange(doc, context, options)
}
}
/**
* Triggers a new change request on the document `doc` without actually
* modifying its data. `options` is an object as described in the documentation
* for the `change` function. This function can be useful for acknowledging the
* receipt of some message (as it's incorported into the `deps` field of the
* change). Returns a two-element array `[doc, request]` where `doc` is the
* updated document, and `request` is the change request to send to the backend.
*/
function emptyChange(doc, options) {
if (doc[OBJECT_ID] !== '_root') {
throw new TypeError('The first argument to Automerge.emptyChange must be the document root')
}
if (typeof options === 'string') {
options = {message: options}
}
if (options !== undefined && !isObject(options)) {
throw new TypeError('Unsupported type of options')
}
const actorId = getActorId(doc)
if (!actorId) {
throw new Error('Actor ID must be initialized with setActorId() before making a change')
}
return makeChange(doc, new Context(doc, actorId), options)
}
/**
* Applies `patch` to the document root object `doc`. This patch must come
* from the backend; it may be the result of a local change or a remote change.
* If it is the result of a local change, the `seq` field from the change
* request should be included in the patch, so that we can match them up here.
*/
function applyPatch(doc, patch, backendState = undefined) {
if (doc[OBJECT_ID] !== '_root') {
throw new TypeError('The first argument to Frontend.applyPatch must be the document root')
}
const state = copyObject(doc[STATE])
if (doc[OPTIONS].backend) {
if (!backendState) {
throw new RangeError('applyPatch must be called with the updated backend state')
}
state.backendState = backendState
return applyPatchToDoc(doc, patch, state, true)
}
let baseDoc
if (state.requests.length > 0) {
baseDoc = state.requests[0].before
if (patch.actor === getActorId(doc)) {
if (state.requests[0].seq !== patch.seq) {
throw new RangeError(`Mismatched sequence number: patch ${patch.seq} does not match next request ${state.requests[0].seq}`)
}
state.requests = state.requests.slice(1)
} else {
state.requests = state.requests.slice()
}
} else {
baseDoc = doc
state.requests = []
}
let newDoc = applyPatchToDoc(baseDoc, patch, state, true)
if (state.requests.length === 0) {
return newDoc
} else {
state.requests[0] = copyObject(state.requests[0])
state.requests[0].before = newDoc
return updateRootObject(doc, {}, state)
}
}
/**
* Returns the Automerge object ID of the given object.
*/
function getObjectId(object) {
return object[OBJECT_ID]
}
/**
* Returns the object with the given Automerge object ID. Note: when called
* within a change callback, the returned object is read-only (not a mutable
* proxy object).
*/
function getObjectById(doc, objectId) {
// It would be nice to return a proxied object in a change callback.
// However, that requires knowing the path from the root to the current
// object, which we don't have if we jumped straight to the object by its ID.
// If we maintained an index from object ID to parent ID we could work out the path.
if (doc[CHANGE]) {
throw new TypeError('Cannot use getObjectById in a change callback')
}
return doc[CACHE][objectId]
}
/**
* Returns the Automerge actor ID of the given document.
*/
function getActorId(doc) {
return doc[STATE].actorId || doc[OPTIONS].actorId
}
/**
* Sets the Automerge actor ID on the document object `doc`, returning a
* document object with updated metadata.
*/
function setActorId(doc, actorId) {
checkActorId(actorId)
const state = Object.assign({}, doc[STATE], {actorId})
return updateRootObject(doc, {}, state)
}
/**
* Fetches the conflicts on the property `key` of `object`, which may be any
* object in a document. If `object` is a list, then `key` must be a list
* index; if `object` is a map, then `key` must be a property name.
*/
function getConflicts(object, key) {
if (object[CONFLICTS] && object[CONFLICTS][key] &&
Object.keys(object[CONFLICTS][key]).length > 1) {
return object[CONFLICTS][key]
}
}
/**
* Returns the backend state associated with the document `doc` (only used if
* a backend implementation is passed to `init()`).
*/
function getBackendState(doc, callerName = null, argPos = 'first') {
if (doc[OBJECT_ID] !== '_root') {
// Most likely cause of passing an array here is forgetting to deconstruct the return value of
// Automerge.applyChanges().
const extraMsg = Array.isArray(doc) ? '. Note: Automerge.applyChanges now returns an array.' : ''
if (callerName) {
throw new TypeError(`The ${argPos} argument to Automerge.${callerName} must be the document root${extraMsg}`)
} else {
throw new TypeError(`Argument is not an Automerge document root${extraMsg}`)
}
}
return doc[STATE].backendState
}
/**
* Given an array or text object from an Automerge document, returns an array
* containing the unique element ID of each list element/character.
*/
function getElementIds(list) {
if (list instanceof Text) {
return list.elems.map(elem => elem.elemId)
} else {
return list[ELEM_IDS]
}
}
module.exports = {
init, from, change, emptyChange, applyPatch,
getObjectId, getObjectById, getActorId, setActorId, getConflicts, getLastLocalChange,
getBackendState, getElementIds,
Text, Table, Counter, Observable, Float64, Int, Uint
}