UNPKG

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
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); };