sharedb
Version:
JSON OT database backend
968 lines (857 loc) • 33.3 kB
JavaScript
var emitter = require('../emitter');
var logger = require('../logger');
var ShareDBError = require('../error');
var types = require('../types');
var ERROR_CODE = ShareDBError.CODES;
/**
* A Doc is a client's view on a sharejs document.
*
* It is is uniquely identified by its `id` and `collection`. Documents
* should not be created directly. Create them with connection.get()
*
*
* Subscriptions
* -------------
*
* We can subscribe a document to stay in sync with the server.
* doc.subscribe(function(error) {
* doc.subscribed // = true
* })
* The server now sends us all changes concerning this document and these are
* applied to our data. If the subscription was successful the initial
* data and version sent by the server are loaded into the document.
*
* To stop listening to the changes we call `doc.unsubscribe()`.
*
* If we just want to load the data but not stay up-to-date, we call
* doc.fetch(function(error) {
* doc.data // sent by server
* })
*
*
* Events
* ------
*
* You can use doc.on(eventName, callback) to subscribe to the following events:
* - `before op (op, source)` Fired before a partial operation is applied to the data.
* It may be used to read the old data just before applying an operation
* - `op (op, source)` Fired after every partial operation with this operation as the
* first argument
* - `create (source)` The document was created. That means its type was
* set and it has some initial data.
* - `del (data, source)` Fired after the document is deleted, that is
* the data is null. It is passed the data before delteion as an
* arguments
* - `load ()` Fired when a new snapshot is ingested from a fetch, subscribe, or query
*/
module.exports = Doc;
function Doc(connection, collection, id) {
emitter.EventEmitter.call(this);
this.connection = connection;
this.collection = collection;
this.id = id;
this.version = null;
this.type = null;
this.data = undefined;
// Array of callbacks or nulls as placeholders
this.inflightFetch = [];
this.inflightSubscribe = [];
this.inflightUnsubscribe = [];
this.pendingFetch = [];
// Whether we think we are subscribed on the server. Synchronously set to
// false on calls to unsubscribe and disconnect. Should never be true when
// this.wantSubscribe is false
this.subscribed = false;
// Whether to re-establish the subscription on reconnect
this.wantSubscribe = false;
// The op that is currently roundtripping to the server, or null.
//
// When the connection reconnects, the inflight op is resubmitted.
//
// This has the same format as an entry in pendingOps
this.inflightOp = null;
// All ops that are waiting for the server to acknowledge this.inflightOp
// This used to just be a single operation, but creates & deletes can't be
// composed with regular operations.
//
// This is a list of {[create:{...}], [del:true], [op:...], callbacks:[...]}
this.pendingOps = [];
// The OT type of this document. An uncreated document has type `null`
this.type = null;
// The applyStack enables us to track any ops submitted while we are
// applying an op incrementally. This value is an array when we are
// performing an incremental apply and null otherwise. When it is an array,
// all submitted ops should be pushed onto it. The `_otApply` method will
// reset it back to null when all incremental apply loops are complete.
this.applyStack = null;
// Disable the default behavior of composing submitted ops. This is read at
// the time of op submit, so it may be toggled on before submitting a
// specifc op and toggled off afterward
this.preventCompose = false;
}
emitter.mixin(Doc);
Doc.prototype.destroy = function(callback) {
var doc = this;
doc.whenNothingPending(function() {
if (doc.wantSubscribe) {
doc.unsubscribe(function(err) {
if (err) {
if (callback) return callback(err);
return doc.emit('error', err);
}
doc.connection._destroyDoc(doc);
if (callback) callback();
});
} else {
doc.connection._destroyDoc(doc);
if (callback) callback();
}
});
};
// ****** Manipulating the document data, version and type.
// Set the document's type, and associated properties. Most of the logic in
// this function exists to update the document based on any added & removed API
// methods.
//
// @param newType OT type provided by the ottypes library or its name or uri
Doc.prototype._setType = function(newType) {
if (typeof newType === 'string') {
newType = types.map[newType];
}
if (newType) {
this.type = newType;
} else if (newType === null) {
this.type = newType;
// If we removed the type from the object, also remove its data
this.data = undefined;
} else {
var err = new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Missing type ' + newType);
return this.emit('error', err);
}
};
// Ingest snapshot data. This data must include a version, snapshot and type.
// This is used both to ingest data that was exported with a webpage and data
// that was received from the server during a fetch.
//
// @param snapshot.v version
// @param snapshot.data
// @param snapshot.type
// @param callback
Doc.prototype.ingestSnapshot = function(snapshot, callback) {
if (!snapshot) return callback && callback();
if (typeof snapshot.v !== 'number') {
var err = new ShareDBError(
ERROR_CODE.ERR_INGESTED_SNAPSHOT_HAS_NO_VERSION,
'Missing version in ingested snapshot. ' + this.collection + '.' + this.id
);
if (callback) return callback(err);
return this.emit('error', err);
}
// If the doc is already created or there are ops pending, we cannot use the
// ingested snapshot and need ops in order to update the document
if (this.type || this.hasWritePending()) {
// The version should only be null on a created document when it was
// created locally without fetching
if (this.version == null) {
if (this.hasWritePending()) {
// If we have pending ops and we get a snapshot for a locally created
// document, we have to wait for the pending ops to complete, because
// we don't know what version to fetch ops from. It is possible that
// the snapshot came from our local op, but it is also possible that
// the doc was created remotely (which would conflict and be an error)
return callback && this.once('no write pending', callback);
}
// Otherwise, we've encounted an error state
var err = new ShareDBError(
ERROR_CODE.ERR_DOC_MISSING_VERSION,
'Cannot ingest snapshot in doc with null version. ' + this.collection + '.' + this.id
);
if (callback) return callback(err);
return this.emit('error', err);
}
// If we got a snapshot for a version further along than the document is
// currently, issue a fetch to get the latest ops and catch us up
if (snapshot.v > this.version) return this.fetch(callback);
return callback && callback();
}
// Ignore the snapshot if we are already at a newer version. Under no
// circumstance should we ever set the current version backward
if (this.version > snapshot.v) return callback && callback();
this.version = snapshot.v;
var type = (snapshot.type === undefined) ? types.defaultType : snapshot.type;
this._setType(type);
this.data = (this.type && this.type.deserialize) ?
this.type.deserialize(snapshot.data) :
snapshot.data;
this.emit('load');
callback && callback();
};
Doc.prototype.whenNothingPending = function(callback) {
var doc = this;
process.nextTick(function() {
if (doc.hasPending()) {
doc.once('nothing pending', callback);
return;
}
callback();
});
};
Doc.prototype.hasPending = function() {
return !!(
this.inflightOp ||
this.pendingOps.length ||
this.inflightFetch.length ||
this.inflightSubscribe.length ||
this.inflightUnsubscribe.length ||
this.pendingFetch.length
);
};
Doc.prototype.hasWritePending = function() {
return !!(this.inflightOp || this.pendingOps.length);
};
Doc.prototype._emitNothingPending = function() {
if (this.hasWritePending()) return;
this.emit('no write pending');
if (this.hasPending()) return;
this.emit('nothing pending');
};
// **** Helpers for network messages
Doc.prototype._emitResponseError = function(err, callback) {
if (err && err.code === ERROR_CODE.ERR_SNAPSHOT_READ_SILENT_REJECTION) {
this.wantSubscribe = false;
if (callback) {
callback();
}
this._emitNothingPending();
return;
}
if (callback) {
callback(err);
this._emitNothingPending();
return;
}
this._emitNothingPending();
this.emit('error', err);
};
Doc.prototype._handleFetch = function(err, snapshot) {
var callback = this.inflightFetch.shift();
if (err) return this._emitResponseError(err, callback);
this.ingestSnapshot(snapshot, callback);
this._emitNothingPending();
};
Doc.prototype._handleSubscribe = function(err, snapshot) {
var callback = this.inflightSubscribe.shift();
if (err) return this._emitResponseError(err, callback);
// Indicate we are subscribed only if the client still wants to be. In the
// time since calling subscribe and receiving a response from the server,
// unsubscribe could have been called and we might already be unsubscribed
// but not have received the response. Also, because requests from the
// client are not serialized and may take different async time to process,
// it is possible that we could hear responses back in a different order
// from the order originally sent
if (this.wantSubscribe) this.subscribed = true;
this.ingestSnapshot(snapshot, callback);
this._emitNothingPending();
};
Doc.prototype._handleUnsubscribe = function(err) {
var callback = this.inflightUnsubscribe.shift();
if (err) return this._emitResponseError(err, callback);
if (callback) callback();
this._emitNothingPending();
};
Doc.prototype._handleOp = function(err, message) {
if (err) {
if (this.inflightOp) {
// The server has rejected submission of the current operation. If we get
// an "Op submit rejected" error, this was done intentionally
// and we should roll back but not return an error to the user.
if (err.code === ERROR_CODE.ERR_OP_SUBMIT_REJECTED) err = null;
return this._rollback(err);
}
return this.emit('error', err);
}
if (this.inflightOp &&
message.src === this.inflightOp.src &&
message.seq === this.inflightOp.seq) {
// The op has already been applied locally. Just update the version
// and pending state appropriately
this._opAcknowledged(message);
return;
}
if (this.version == null || message.v > this.version) {
// This will happen in normal operation if we become subscribed to a
// new document via a query. It can also happen if we get an op for
// a future version beyond the version we are expecting next. This
// could happen if the server doesn't publish an op for whatever reason
// or because of a race condition. In any case, we can send a fetch
// command to catch back up.
//
// Fetch only sends a new fetch command if no fetches are inflight, which
// will act as a natural debouncing so we don't send multiple fetch
// requests for many ops received at once.
this.fetch();
return;
}
if (message.v < this.version) {
// We can safely ignore the old (duplicate) operation.
return;
}
if (this.inflightOp) {
var transformErr = transformX(this.inflightOp, message);
if (transformErr) return this._hardRollback(transformErr);
}
for (var i = 0; i < this.pendingOps.length; i++) {
var transformErr = transformX(this.pendingOps[i], message);
if (transformErr) return this._hardRollback(transformErr);
}
this.version++;
try {
this._otApply(message, false);
} catch (error) {
return this._hardRollback(error);
}
};
// Called whenever (you guessed it!) the connection state changes. This will
// happen when we get disconnected & reconnect.
Doc.prototype._onConnectionStateChanged = function() {
if (this.connection.canSend) {
this.flush();
this._resubscribe();
} else {
if (this.inflightOp) {
this.pendingOps.unshift(this.inflightOp);
this.inflightOp = null;
}
this.subscribed = false;
if (this.inflightFetch.length || this.inflightSubscribe.length) {
this.pendingFetch = this.pendingFetch.concat(this.inflightFetch, this.inflightSubscribe);
this.inflightFetch.length = 0;
this.inflightSubscribe.length = 0;
}
if (this.inflightUnsubscribe.length) {
var callbacks = this.inflightUnsubscribe;
this.inflightUnsubscribe = [];
callEach(callbacks);
}
}
};
Doc.prototype._resubscribe = function() {
var callbacks = this.pendingFetch;
this.pendingFetch = [];
if (this.wantSubscribe) {
if (callbacks.length) {
this.subscribe(function(err) {
callEach(callbacks, err);
});
return;
}
this.subscribe();
return;
}
if (callbacks.length) {
this.fetch(function(err) {
callEach(callbacks, err);
});
}
};
// Request the current document snapshot or ops that bring us up to date
Doc.prototype.fetch = function(callback) {
if (this.connection.canSend) {
var isDuplicate = this.connection.sendFetch(this);
pushActionCallback(this.inflightFetch, isDuplicate, callback);
return;
}
this.pendingFetch.push(callback);
};
// Fetch the initial document and keep receiving updates
Doc.prototype.subscribe = function(callback) {
this.wantSubscribe = true;
if (this.connection.canSend) {
var isDuplicate = this.connection.sendSubscribe(this);
pushActionCallback(this.inflightSubscribe, isDuplicate, callback);
return;
}
this.pendingFetch.push(callback);
};
// Unsubscribe. The data will stay around in local memory, but we'll stop
// receiving updates
Doc.prototype.unsubscribe = function(callback) {
this.wantSubscribe = false;
// The subscribed state should be conservative in indicating when we are
// subscribed on the server. We'll actually be unsubscribed some time
// between sending the message and hearing back, but we cannot know exactly
// when. Thus, immediately mark us as not subscribed
this.subscribed = false;
if (this.connection.canSend) {
var isDuplicate = this.connection.sendUnsubscribe(this);
pushActionCallback(this.inflightUnsubscribe, isDuplicate, callback);
return;
}
if (callback) process.nextTick(callback);
};
function pushActionCallback(inflight, isDuplicate, callback) {
if (isDuplicate) {
var lastCallback = inflight.pop();
inflight.push(function(err) {
lastCallback && lastCallback(err);
callback && callback(err);
});
} else {
inflight.push(callback);
}
}
// Operations //
// Send the next pending op to the server, if we can.
//
// Only one operation can be in-flight at a time. If an operation is already on
// its way, or we're not currently connected, this method does nothing.
Doc.prototype.flush = function() {
// Ignore if we can't send or we are already sending an op
if (!this.connection.canSend || this.inflightOp) return;
// Send first pending op unless paused
if (!this.paused && this.pendingOps.length) {
this._sendOp();
}
};
// Helper function to set op to contain a no-op.
function setNoOp(op) {
delete op.op;
delete op.create;
delete op.del;
}
// Transform server op data by a client op, and vice versa. Ops are edited in place.
function transformX(client, server) {
// Order of statements in this function matters. Be especially careful if
// refactoring this function
// A client delete op should dominate if both the server and the client
// delete the document. Thus, any ops following the client delete (such as a
// subsequent create) will be maintained, since the server op is transformed
// to a no-op
if (client.del) return setNoOp(server);
if (server.del) {
return new ShareDBError(ERROR_CODE.ERR_DOC_WAS_DELETED, 'Document was deleted');
}
if (server.create) {
return new ShareDBError(ERROR_CODE.ERR_DOC_ALREADY_CREATED, 'Document already created');
}
// Ignore no-op coming from server
if (!server.op) return;
// I believe that this should not occur, but check just in case
if (client.create) {
return new ShareDBError(ERROR_CODE.ERR_DOC_ALREADY_CREATED, 'Document already created');
}
// They both edited the document. This is the normal case for this function -
// as in, most of the time we'll end up down here.
//
// You should be wondering why I'm using client.type instead of this.type.
// The reason is, if we get ops at an old version of the document, this.type
// might be undefined or a totally different type. By pinning the type to the
// op data, we make sure the right type has its transform function called.
if (client.type.transformX) {
var result = client.type.transformX(client.op, server.op);
client.op = result[0];
server.op = result[1];
} else {
var clientOp = client.type.transform(client.op, server.op, 'left');
var serverOp = client.type.transform(server.op, client.op, 'right');
client.op = clientOp;
server.op = serverOp;
}
};
/**
* Applies the operation to the snapshot
*
* If the operation is create or delete it emits `create` or `del`. Then the
* operation is applied to the snapshot and `op` and `after op` are emitted.
* If the type supports incremental updates and `this.incremental` is true we
* fire `op` after every small operation.
*
* This is the only function to fire the above mentioned events.
*
* @private
*/
Doc.prototype._otApply = function(op, source) {
if (op.op) {
if (!this.type) {
// Throw here, because all usage of _otApply should be wrapped with a try/catch
throw new ShareDBError(
ERROR_CODE.ERR_DOC_DOES_NOT_EXIST,
'Cannot apply op to uncreated document. ' + this.collection + '.' + this.id
);
}
// Iteratively apply multi-component remote operations and rollback ops
// (source === false) for the default JSON0 OT type. It could use
// type.shatter(), but since this code is so specific to use cases for the
// JSON0 type and ShareDB explicitly bundles the default type, we might as
// well write it this way and save needing to iterate through the op
// components twice.
//
// Ideally, we would not need this extra complexity. However, it is
// helpful for implementing bindings that update DOM nodes and other
// stateful objects by translating op events directly into corresponding
// mutations. Such bindings are most easily written as responding to
// individual op components one at a time in order, and it is important
// that the snapshot only include updates from the particular op component
// at the time of emission. Eliminating this would require rethinking how
// such external bindings are implemented.
if (!source && this.type === types.defaultType && op.op.length > 1) {
if (!this.applyStack) this.applyStack = [];
var stackLength = this.applyStack.length;
for (var i = 0; i < op.op.length; i++) {
var component = op.op[i];
var componentOp = {op: [component]};
// Transform componentOp against any ops that have been submitted
// sychronously inside of an op event handler since we began apply of
// our operation
for (var j = stackLength; j < this.applyStack.length; j++) {
var transformErr = transformX(this.applyStack[j], componentOp);
if (transformErr) return this._hardRollback(transformErr);
}
// Apply the individual op component
this.emit('before op', componentOp.op, source);
this.data = this.type.apply(this.data, componentOp.op);
this.emit('op', componentOp.op, source);
}
// Pop whatever was submitted since we started applying this op
this._popApplyStack(stackLength);
return;
}
// The 'before op' event enables clients to pull any necessary data out of
// the snapshot before it gets changed
this.emit('before op', op.op, source);
// Apply the operation to the local data, mutating it in place
this.data = this.type.apply(this.data, op.op);
// Emit an 'op' event once the local data includes the changes from the
// op. For locally submitted ops, this will be synchronously with
// submission and before the server or other clients have received the op.
// For ops from other clients, this will be after the op has been
// committed to the database and published
this.emit('op', op.op, source);
return;
}
if (op.create) {
this._setType(op.create.type);
this.data = (this.type.deserialize) ?
(this.type.createDeserialized) ?
this.type.createDeserialized(op.create.data) :
this.type.deserialize(this.type.create(op.create.data)) :
this.type.create(op.create.data);
this.emit('create', source);
return;
}
if (op.del) {
var oldData = this.data;
this._setType(null);
this.emit('del', oldData, source);
return;
}
};
// ***** Sending operations
// Actually send op to the server.
Doc.prototype._sendOp = function() {
// Wait until we have a src id from the server
var src = this.connection.id;
if (!src) return;
// When there is no inflightOp, send the first item in pendingOps. If
// there is inflightOp, try sending it again
if (!this.inflightOp) {
// Send first pending op
this.inflightOp = this.pendingOps.shift();
}
var op = this.inflightOp;
if (!op) {
var err = new ShareDBError(ERROR_CODE.ERR_INFLIGHT_OP_MISSING, 'No op to send on call to _sendOp');
return this.emit('error', err);
}
// Track data for retrying ops
op.sentAt = Date.now();
op.retries = (op.retries == null) ? 0 : op.retries + 1;
// The src + seq number is a unique ID representing this operation. This tuple
// is used on the server to detect when ops have been sent multiple times and
// on the client to match acknowledgement of an op back to the inflightOp.
// Note that the src could be different from this.connection.id after a
// reconnect, since an op may still be pending after the reconnection and
// this.connection.id will change. In case an op is sent multiple times, we
// also need to be careful not to override the original seq value.
if (op.seq == null) op.seq = this.connection.seq++;
this.connection.sendOp(this, op);
// src isn't needed on the first try, since the server session will have the
// same id, but it must be set on the inflightOp in case it is sent again
// after a reconnect and the connection's id has changed by then
if (op.src == null) op.src = src;
};
// Queues the operation for submission to the server and applies it locally.
//
// Internal method called to do the actual work for submit(), create() and del().
// @private
//
// @param op
// @param [op.op]
// @param [op.del]
// @param [op.create]
// @param [callback] called when operation is submitted
Doc.prototype._submit = function(op, source, callback) {
// Locally submitted ops must always have a truthy source
if (!source) source = true;
// The op contains either op, create, delete, or none of the above (a no-op).
if (op.op) {
if (!this.type) {
var err = new ShareDBError(
ERROR_CODE.ERR_DOC_DOES_NOT_EXIST,
'Cannot submit op. Document has not been created. ' + this.collection + '.' + this.id
);
if (callback) return callback(err);
return this.emit('error', err);
}
// Try to normalize the op. This removes trailing skip:0's and things like that.
if (this.type.normalize) op.op = this.type.normalize(op.op);
}
try {
this._pushOp(op, callback);
this._otApply(op, source);
} catch (error) {
return this._hardRollback(error);
}
// The call to flush is delayed so if submit() is called multiple times
// synchronously, all the ops are combined before being sent to the server.
var doc = this;
process.nextTick(function() {
doc.flush();
});
};
Doc.prototype._pushOp = function(op, callback) {
if (this.applyStack) {
// If we are in the process of incrementally applying an operation, don't
// compose the op and push it onto the applyStack so it can be transformed
// against other components from the op or ops being applied
this.applyStack.push(op);
} else {
// If the type supports composes, try to compose the operation onto the
// end of the last pending operation.
var composed = this._tryCompose(op);
if (composed) {
composed.callbacks.push(callback);
return;
}
}
// Push on to the pendingOps queue of ops to submit if we didn't compose
op.type = this.type;
op.callbacks = [callback];
this.pendingOps.push(op);
};
Doc.prototype._popApplyStack = function(to) {
if (to > 0) {
this.applyStack.length = to;
return;
}
// Once we have completed the outermost apply loop, reset to null and no
// longer add ops to the applyStack as they are submitted
var op = this.applyStack[0];
this.applyStack = null;
if (!op) return;
// Compose the ops added since the beginning of the apply stack, since we
// had to skip compose when they were originally pushed
var i = this.pendingOps.indexOf(op);
if (i === -1) return;
var ops = this.pendingOps.splice(i);
for (var i = 0; i < ops.length; i++) {
var op = ops[i];
var composed = this._tryCompose(op);
if (composed) {
composed.callbacks = composed.callbacks.concat(op.callbacks);
} else {
this.pendingOps.push(op);
}
}
};
// Try to compose a submitted op into the last pending op. Returns the
// composed op if it succeeds, undefined otherwise
Doc.prototype._tryCompose = function(op) {
if (this.preventCompose) return;
// We can only compose into the last pending op. Inflight ops have already
// been sent to the server, so we can't modify them
var last = this.pendingOps[this.pendingOps.length - 1];
if (!last || last.sentAt) return;
// Compose an op into a create by applying it. This effectively makes the op
// invisible, as if the document were created including the op originally
if (last.create && op.op) {
last.create.data = this.type.apply(last.create.data, op.op);
return last;
}
// Compose two ops into a single op if supported by the type. Types that
// support compose must be able to compose any two ops together
if (last.op && op.op && this.type.compose) {
last.op = this.type.compose(last.op, op.op);
return last;
}
};
// *** Client OT entrypoints.
// Submit an operation to the document.
//
// @param operation handled by the OT type
// @param options {source: ...}
// @param [callback] called after operation submitted
//
// @fires before op, op, after op
Doc.prototype.submitOp = function(component, options, callback) {
if (typeof options === 'function') {
callback = options;
options = null;
}
var op = {op: component};
var source = options && options.source;
this._submit(op, source, callback);
};
// Create the document, which in ShareJS semantics means to set its type. Every
// object implicitly exists in the database but has no data and no type. Create
// sets the type of the object and can optionally set some initial data on the
// object, depending on the type.
//
// @param data initial
// @param type OT type
// @param options {source: ...}
// @param callback called when operation submitted
Doc.prototype.create = function(data, type, options, callback) {
if (typeof type === 'function') {
callback = type;
options = null;
type = null;
} else if (typeof options === 'function') {
callback = options;
options = null;
}
if (!type) {
type = types.defaultType.uri;
}
if (this.type) {
var err = new ShareDBError(ERROR_CODE.ERR_DOC_ALREADY_CREATED, 'Document already exists');
if (callback) return callback(err);
return this.emit('error', err);
}
var op = {create: {type: type, data: data}};
var source = options && options.source;
this._submit(op, source, callback);
};
// Delete the document. This creates and submits a delete operation to the
// server. Deleting resets the object's type to null and deletes its data. The
// document still exists, and still has the version it used to have before you
// deleted it (well, old version +1).
//
// @param options {source: ...}
// @param callback called when operation submitted
Doc.prototype.del = function(options, callback) {
if (typeof options === 'function') {
callback = options;
options = null;
}
if (!this.type) {
var err = new ShareDBError(ERROR_CODE.ERR_DOC_DOES_NOT_EXIST, 'Document does not exist');
if (callback) return callback(err);
return this.emit('error', err);
}
var op = {del: true};
var source = options && options.source;
this._submit(op, source, callback);
};
// Stops the document from sending any operations to the server.
Doc.prototype.pause = function() {
this.paused = true;
};
// Continue sending operations to the server
Doc.prototype.resume = function() {
this.paused = false;
this.flush();
};
// *** Receiving operations
// This is called when the server acknowledges an operation from the client.
Doc.prototype._opAcknowledged = function(message) {
if (this.inflightOp.create) {
this.version = message.v;
} else if (message.v !== this.version) {
// We should already be at the same version, because the server should
// have sent all the ops that have happened before acknowledging our op
logger.warn('Invalid version from server. Expected: ' + this.version + ' Received: ' + message.v, message);
// Fetching should get us back to a working document state
return this.fetch();
}
// The op was committed successfully. Increment the version number
this.version++;
this._clearInflightOp();
};
Doc.prototype._rollback = function(err) {
// The server has rejected submission of the current operation. Invert by
// just the inflight op if possible. If not possible to invert, cancel all
// pending ops and fetch the latest from the server to get us back into a
// working state, then call back
var op = this.inflightOp;
if (op.op && op.type.invert) {
op.op = op.type.invert(op.op);
// Transform the undo operation by any pending ops.
for (var i = 0; i < this.pendingOps.length; i++) {
var transformErr = transformX(this.pendingOps[i], op);
if (transformErr) return this._hardRollback(transformErr);
}
// ... and apply it locally, reverting the changes.
//
// This operation is applied to look like it comes from a remote source.
// I'm still not 100% sure about this functionality, because its really a
// local op. Basically, the problem is that if the client's op is rejected
// by the server, the editor window should update to reflect the undo.
try {
this._otApply(op, false);
} catch (error) {
return this._hardRollback(error);
}
this._clearInflightOp(err);
return;
}
this._hardRollback(err);
};
Doc.prototype._hardRollback = function(err) {
// Store pending ops so that we can notify their callbacks of the error.
// We combine the inflight op and the pending ops, because it's possible
// to hit a condition where we have no inflight op, but we do have pending
// ops. This can happen when an invalid op is submitted, which causes us
// to hard rollback before the pending op was flushed.
var pendingOps = [];
if (this.inflightOp) pendingOps.push(this.inflightOp);
pendingOps = pendingOps.concat(this.pendingOps);
// Cancel all pending ops and reset if we can't invert
this._setType(null);
this.version = null;
this.inflightOp = null;
this.pendingOps = [];
// Fetch the latest version from the server to get us back into a working state
var doc = this;
this.fetch(function() {
// We want to check that no errors are swallowed, so we check that:
// - there are callbacks to call, and
// - that every single pending op called a callback
// If there are no ops queued, or one of them didn't handle the error,
// then we emit the error.
var allOpsHadCallbacks = !!pendingOps.length;
for (var i = 0; i < pendingOps.length; i++) {
allOpsHadCallbacks = callEach(pendingOps[i].callbacks, err) && allOpsHadCallbacks;
}
if (err && !allOpsHadCallbacks) return doc.emit('error', err);
});
};
Doc.prototype._clearInflightOp = function(err) {
var inflightOp = this.inflightOp;
this.inflightOp = null;
var called = callEach(inflightOp.callbacks, err);
this.flush();
this._emitNothingPending();
if (err && !called) return this.emit('error', err);
};
function callEach(callbacks, err) {
var called = false;
for (var i = 0; i < callbacks.length; i++) {
var callback = callbacks[i];
if (callback) {
callback(err);
called = true;
}
}
return called;
}