recoder-code
Version:
Complete AI-powered development platform with ML model training, plugin registry, real-time collaboration, monitoring, infrastructure automation, and enterprise deployment capabilities
1,101 lines (969 loc) • 37.6 kB
JavaScript
var emitter = require('../emitter');
var logger = require('../logger');
var ShareDBError = require('../error');
var types = require('../types');
var util = require('../util');
var clone = util.clone;
var deepEqual = require('fast-deep-equal');
var ACTIONS = require('../message-actions').ACTIONS;
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 deletion as an
* argument
* - `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 = null;
this.pendingFetch = [];
this.pendingSubscribe = [];
// 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;
this._wantsDestroy = 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;
// If set to true, the source will be submitted over the connection. This
// will also have the side-effect of only composing ops whose sources are
// equal
this.submitSource = false;
// Prevent own ops being submitted to the server. If subscribed, remote
// ops are still received. Should be toggled through the pause() and
// resume() methods to correctly flush on resume.
this.paused = false;
// Internal counter that gets incremented every time doc.data is updated.
// Used as a cheap way to check if doc.data has changed.
this._dataStateVersion = 0;
}
emitter.mixin(Doc);
Doc.prototype.destroy = function(callback) {
this._wantsDestroy = true;
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._setData(undefined);
} else {
var err = new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Missing type ' + newType);
return this.emit('error', err);
}
};
Doc.prototype._setData = function(data) {
this.data = data;
this._dataStateVersion++;
};
// 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._setData(
(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;
util.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 ||
this.pendingFetch.length ||
this.pendingSubscribe.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(error, snapshot) {
var callbacks = this.pendingFetch;
this.pendingFetch = [];
var callback = this.inflightFetch.shift();
if (callback) callbacks.push(callback);
if (callbacks.length) {
callback = function(error) {
util.callEach(callbacks, error);
};
}
if (error) return this._emitResponseError(error, callback);
this.ingestSnapshot(snapshot, callback);
this._emitNothingPending();
};
Doc.prototype._handleSubscribe = function(error, snapshot) {
var request = this.inflightSubscribe;
this.inflightSubscribe = null;
var callbacks = this.pendingFetch;
this.pendingFetch = [];
if (request.callback) callbacks.push(request.callback);
var callback;
if (callbacks.length) {
callback = function(error) {
util.callEach(callbacks, error);
};
}
if (error) return this._emitResponseError(error, callback);
this.subscribed = request.wantSubscribe;
if (this.subscribed) this.ingestSnapshot(snapshot, callback);
else if (callback) callback();
this._emitNothingPending();
this._flushSubscribe();
};
Doc.prototype._handleOp = function(err, message) {
if (err) {
if (this.inflightOp) {
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.inflightSubscribe) {
if (this.inflightSubscribe.wantSubscribe) {
this.pendingSubscribe.unshift(this.inflightSubscribe);
this.inflightSubscribe = null;
} else {
this._handleSubscribe();
}
}
if (this.inflightFetch.length) {
this.pendingFetch = this.pendingFetch.concat(this.inflightFetch);
this.inflightFetch.length = 0;
}
}
};
Doc.prototype._resubscribe = function() {
if (!this.pendingSubscribe.length && this.wantSubscribe) {
return this.subscribe();
}
var willFetch = this.pendingSubscribe.some(function(request) {
return request.wantSubscribe;
});
if (!willFetch && this.pendingFetch.length) this.fetch();
this._flushSubscribe();
};
// Request the current document snapshot or ops that bring us up to date
Doc.prototype.fetch = function(callback) {
this._fetch({}, callback);
};
Doc.prototype._fetch = function(options, callback) {
this.pendingFetch.push(callback);
var shouldSend = this.connection.canSend && (
options.force || !this.inflightFetch.length
);
if (!shouldSend) return;
this.inflightFetch.push(this.pendingFetch.shift());
this.connection.sendFetch(this);
};
// Fetch the initial document and keep receiving updates
Doc.prototype.subscribe = function(callback) {
var wantSubscribe = true;
this._queueSubscribe(wantSubscribe, callback);
};
// Unsubscribe. The data will stay around in local memory, but we'll stop
// receiving updates
Doc.prototype.unsubscribe = function(callback) {
var wantSubscribe = false;
this._queueSubscribe(wantSubscribe, callback);
};
Doc.prototype._queueSubscribe = function(wantSubscribe, callback) {
var lastRequest = this.pendingSubscribe[this.pendingSubscribe.length - 1] || this.inflightSubscribe;
var isDuplicateRequest = lastRequest && lastRequest.wantSubscribe === wantSubscribe;
if (isDuplicateRequest) {
lastRequest.callback = combineCallbacks([lastRequest.callback, callback]);
return;
}
this.pendingSubscribe.push({
wantSubscribe: !!wantSubscribe,
callback: callback
});
this._flushSubscribe();
};
Doc.prototype._flushSubscribe = function() {
if (this.inflightSubscribe || !this.pendingSubscribe.length) return;
if (this.connection.canSend) {
this.inflightSubscribe = this.pendingSubscribe.shift();
this.wantSubscribe = this.inflightSubscribe.wantSubscribe;
if (this.wantSubscribe) {
this.connection.sendSubscribe(this);
} else {
// Be conservative about our subscription state. We'll be unsubscribed
// some time between sending this request, and receiving the callback,
// so let's just set ourselves to unsubscribed now.
this.subscribed = false;
this.connection.sendUnsubscribe(this);
}
return;
}
// If we're offline, then we're already unsubscribed. Therefore, call back
// the next request immediately if it's an unsubscribe request.
if (!this.pendingSubscribe[0].wantSubscribe) {
this.inflightSubscribe = this.pendingSubscribe.shift();
var doc = this;
util.nextTick(function() {
doc._handleSubscribe();
});
}
};
function combineCallbacks(callbacks) {
callbacks = callbacks.filter(util.truthy);
if (!callbacks.length) return null;
return function(error) {
util.callEach(callbacks, error);
};
}
// 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 (!('op' in server)) 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' in 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
);
}
// NB: If we need to add another argument to this event, we should consider
// the fact that the 'op' event has op.src as its 3rd argument
this.emit('before op batch', op.op, source);
// 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]};
// Apply the individual op component
this.emit('before op', componentOp.op, source, op.src);
// 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);
}
this._setData(this.type.apply(this.data, componentOp.op));
this.emit('op', componentOp.op, source, op.src);
}
this.emit('op batch', op.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, op.src);
// Apply the operation to the local data, mutating it in place
this._setData(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, op.src);
this.emit('op batch', op.op, source);
return;
}
if (op.create) {
this._setType(op.create.type);
if (this.type.deserialize) {
if (this.type.createDeserialized) {
this._setData(this.type.createDeserialized(op.create.data));
} else {
this._setData(this.type.deserialize(this.type.create(op.create.data)));
}
} else {
this._setData(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() {
if (!this.connection.canSend) return;
var src = this.connection.id;
// 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) {
if (this.connection.seq >= util.MAX_SAFE_INTEGER) {
return this.emit('error', new ShareDBError(
ERROR_CODE.ERR_CONNECTION_SEQ_INTEGER_OVERFLOW,
'Connection seq has exceeded the max safe integer, maybe from being open for too long'
));
}
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' in 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, source, 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;
util.nextTick(function() {
doc.flush();
});
};
Doc.prototype._pushOp = function(op, source, callback) {
op.source = source;
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;
// If we're submitting the op source, we can only combine ops that have
// a matching source
if (this.submitSource && !deepEqual(op.source, last.source)) 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' in 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 ('op' in last && 'op' in 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();
};
// Create a snapshot that can be serialized, deserialized, and passed into `Doc.ingestSnapshot`.
Doc.prototype.toSnapshot = function() {
return {
v: this.version,
data: clone(this.data),
type: this.type.uri
};
};
// *** 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();
}
if (message[ACTIONS.fixup]) {
for (var i = 0; i < message[ACTIONS.fixup].length; i++) {
var fixupOp = message[ACTIONS.fixup][i];
for (var j = 0; j < this.pendingOps.length; j++) {
var transformErr = transformX(this.pendingOps[i], fixupOp);
if (transformErr) return this._hardRollback(transformErr);
}
try {
this._otApply(fixupOp, false);
} catch (error) {
return this._hardRollback(error);
}
}
}
// 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' in op && op.type.invert)) {
return this._hardRollback(err);
}
try {
op.op = op.type.invert(op.op);
} catch (error) {
// If the op doesn't support `.invert()`, we just reload the doc
// instead of trying to locally revert it.
return this._hardRollback(err);
}
// 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);
}
// 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) {
return this._clearInflightOp(null);
}
this._clearInflightOp(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 = this.pendingOps;
var inflightOp = this.inflightOp;
// 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({force: true}, function(fetchError) {
// 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.
if (fetchError) {
// This is critical error as it means that our doc is not in usable state
// anymore, we should throw doc error.
logger.error('Hard rollback doc fetch failed.', fetchError, inflightOp);
doc.emit('error', new ShareDBError(
ERROR_CODE.ERR_HARD_ROLLBACK_FETCH_FAILED,
'Hard rollback fetch failed: ' + fetchError.message
));
}
if (err.code === ERROR_CODE.ERR_OP_SUBMIT_REJECTED) {
/**
* Handle special case of ERR_OP_SUBMIT_REJECTED
* This ensures that we resolve the main op callback and reject
* all the pending ops. This is hard rollback so all the pending ops will be
* discarded. This will ensure that the user is at least informed about it.
* more info: https://github.com/share/sharedb/pull/626
*/
if (inflightOp) {
util.callEach(inflightOp.callbacks);
inflightOp = null;
}
if (!pendingOps.length) return;
err = new ShareDBError(
ERROR_CODE.ERR_PENDING_OP_REMOVED_BY_OP_SUBMIT_REJECTED,
'Discarding pending op because of hard rollback during ERR_OP_SUBMIT_REJECTED'
);
}
if (inflightOp) pendingOps.unshift(inflightOp);
var allOpsHadCallbacks = !!pendingOps.length;
for (var i = 0; i < pendingOps.length; i++) {
allOpsHadCallbacks = util.callEach(pendingOps[i].callbacks, err) && allOpsHadCallbacks;
}
if (err && !allOpsHadCallbacks) doc.emit('error', err);
});
};
Doc.prototype._clearInflightOp = function(err) {
var inflightOp = this.inflightOp;
this.inflightOp = null;
var called = util.callEach(inflightOp.callbacks, err);
this.flush();
this._emitNothingPending();
if (err && !called) return this.emit('error', err);
};