sharedb
Version:
JSON OT database backend
717 lines (634 loc) • 22 kB
JavaScript
var Doc = require('./doc');
var Query = require('./query');
var SnapshotVersionRequest = require('./snapshot-request/snapshot-version-request');
var SnapshotTimestampRequest = require('./snapshot-request/snapshot-timestamp-request');
var emitter = require('../emitter');
var ShareDBError = require('../error');
var types = require('../types');
var util = require('../util');
var logger = require('../logger');
var ERROR_CODE = ShareDBError.CODES;
function connectionState(socket) {
if (socket.readyState === 0 || socket.readyState === 1) return 'connecting';
return 'disconnected';
}
/**
* Handles communication with the sharejs server and provides queries and
* documents.
*
* We create a connection with a socket object
* connection = new sharejs.Connection(sockset)
* The socket may be any object handling the websocket protocol. See the
* documentation of bindToSocket() for details. We then wait for the connection
* to connect
* connection.on('connected', ...)
* and are finally able to work with shared documents
* connection.get('food', 'steak') // Doc
*
* @param socket @see bindToSocket
*/
module.exports = Connection;
function Connection(socket) {
emitter.EventEmitter.call(this);
// Map of collection -> id -> doc object for created documents.
// (created documents MUST BE UNIQUE)
this.collections = {};
// Each query and snapshot request is created with an id that the server uses when it sends us
// info about the request (updates, etc)
this.nextQueryId = 1;
this.nextSnapshotRequestId = 1;
// Map from query ID -> query object.
this.queries = {};
// Map from snapshot request ID -> snapshot request
this._snapshotRequests = {};
// A unique message number for the given id
this.seq = 1;
// Equals agent.clientId on the server
this.id = null;
// This direct reference from connection to agent is not used internal to
// ShareDB, but it is handy for server-side only user code that may cache
// state on the agent and read it in middleware
this.agent = null;
this.debug = false;
this.state = connectionState(socket);
this.bindToSocket(socket);
}
emitter.mixin(Connection);
/**
* Use socket to communicate with server
*
* Socket is an object that can handle the websocket protocol. This method
* installs the onopen, onclose, onmessage and onerror handlers on the socket to
* handle communication and sends messages by calling socket.send(message). The
* sockets `readyState` property is used to determine the initaial state.
*
* @param socket Handles the websocket protocol
* @param socket.readyState
* @param socket.close
* @param socket.send
* @param socket.onopen
* @param socket.onclose
* @param socket.onmessage
* @param socket.onerror
*/
Connection.prototype.bindToSocket = function(socket) {
if (this.socket) {
this.socket.close();
this.socket.onmessage = null;
this.socket.onopen = null;
this.socket.onerror = null;
this.socket.onclose = null;
}
this.socket = socket;
// State of the connection. The corresponding events are emitted when this changes
//
// - 'connecting' The connection is still being established, or we are still
// waiting on the server to send us the initialization message
// - 'connected' The connection is open and we have connected to a server
// and recieved the initialization message
// - 'disconnected' Connection is closed, but it will reconnect automatically
// - 'closed' The connection was closed by the client, and will not reconnect
// - 'stopped' The connection was closed by the server, and will not reconnect
var newState = connectionState(socket);
this._setState(newState);
// This is a helper variable the document uses to see whether we're
// currently in a 'live' state. It is true if and only if we're connected
this.canSend = false;
var connection = this;
socket.onmessage = function(event) {
try {
var data = (typeof event.data === 'string') ?
JSON.parse(event.data) : event.data;
} catch (err) {
logger.warn('Failed to parse message', event);
return;
}
if (connection.debug) logger.info('RECV', JSON.stringify(data));
var request = {data: data};
connection.emit('receive', request);
if (!request.data) return;
try {
connection.handleMessage(request.data);
} catch (err) {
process.nextTick(function() {
connection.emit('error', err);
});
}
};
socket.onopen = function() {
connection._setState('connecting');
};
socket.onerror = function(err) {
// This isn't the same as a regular error, because it will happen normally
// from time to time. Your connection should probably automatically
// reconnect anyway, but that should be triggered off onclose not onerror.
// (onclose happens when onerror gets called anyway).
connection.emit('connection error', err);
};
socket.onclose = function(reason) {
// node-browserchannel reason values:
// 'Closed' - The socket was manually closed by calling socket.close()
// 'Stopped by server' - The server sent the stop message to tell the client not to try connecting
// 'Request failed' - Server didn't respond to request (temporary, usually offline)
// 'Unknown session ID' - Server session for client is missing (temporary, will immediately reestablish)
if (reason === 'closed' || reason === 'Closed') {
connection._setState('closed', reason);
} else if (reason === 'stopped' || reason === 'Stopped by server') {
connection._setState('stopped', reason);
} else {
connection._setState('disconnected', reason);
}
};
};
/**
* @param {object} message
* @param {String} message.a action
*/
Connection.prototype.handleMessage = function(message) {
var err = null;
if (message.error) {
err = wrapErrorData(message.error, message);
delete message.error;
}
// Switch on the message action. Most messages are for documents and are
// handled in the doc class.
switch (message.a) {
case 'init':
// Client initialization packet
if (message.protocol !== 1) {
err = new ShareDBError(
ERROR_CODE.ERR_PROTOCOL_VERSION_NOT_SUPPORTED,
'Unsupported protocol version: ' + message.protocol
);
return this.emit('error', err);
}
if (types.map[message.type] !== types.defaultType) {
err = new ShareDBError(
ERROR_CODE.ERR_DEFAULT_TYPE_MISMATCH,
message.type + ' does not match the server default type'
);
return this.emit('error', err);
}
if (typeof message.id !== 'string') {
err = new ShareDBError(ERROR_CODE.ERR_CLIENT_ID_BADLY_FORMED, 'Client id must be a string');
return this.emit('error', err);
}
this.id = message.id;
this._setState('connected');
return;
case 'qf':
var query = this.queries[message.id];
if (query) query._handleFetch(err, message.data, message.extra);
return;
case 'qs':
var query = this.queries[message.id];
if (query) query._handleSubscribe(err, message.data, message.extra);
return;
case 'qu':
// Queries are removed immediately on calls to destroy, so we ignore
// replies to query unsubscribes. Perhaps there should be a callback for
// destroy, but this is currently unimplemented
return;
case 'q':
// Query message. Pass this to the appropriate query object.
var query = this.queries[message.id];
if (!query) return;
if (err) return query._handleError(err);
if (message.diff) query._handleDiff(message.diff);
if (message.hasOwnProperty('extra')) query._handleExtra(message.extra);
return;
case 'bf':
return this._handleBulkMessage(err, message, '_handleFetch');
case 'bs':
return this._handleBulkMessage(err, message, '_handleSubscribe');
case 'bu':
return this._handleBulkMessage(err, message, '_handleUnsubscribe');
case 'nf':
case 'nt':
return this._handleSnapshotFetch(err, message);
case 'f':
var doc = this.getExisting(message.c, message.d);
if (doc) doc._handleFetch(err, message.data);
return;
case 's':
var doc = this.getExisting(message.c, message.d);
if (doc) doc._handleSubscribe(err, message.data);
return;
case 'u':
var doc = this.getExisting(message.c, message.d);
if (doc) doc._handleUnsubscribe(err);
return;
case 'op':
var doc = this.getExisting(message.c, message.d);
if (doc) doc._handleOp(err, message);
return;
default:
logger.warn('Ignoring unrecognized message', message);
}
};
function wrapErrorData(errorData, fullMessage) {
// wrap in Error object so can be passed through event emitters
var err = new Error(errorData.message);
err.code = errorData.code;
if (fullMessage) {
// Add the message data to the error object for more context
err.data = fullMessage;
}
return err;
}
Connection.prototype._handleBulkMessage = function(err, message, method) {
if (message.data) {
for (var id in message.data) {
var dataForId = message.data[id];
var doc = this.getExisting(message.c, id);
if (doc) {
if (err) {
doc[method](err);
} else if (dataForId.error) {
// Bulk reply snapshot-specific errorr - see agent.js getMapResult
doc[method](wrapErrorData(dataForId.error));
} else {
doc[method](null, dataForId);
}
}
}
} else if (Array.isArray(message.b)) {
for (var i = 0; i < message.b.length; i++) {
var id = message.b[i];
var doc = this.getExisting(message.c, id);
if (doc) doc[method](err);
}
} else if (message.b) {
for (var id in message.b) {
var doc = this.getExisting(message.c, id);
if (doc) doc[method](err);
}
} else {
logger.error('Invalid bulk message', message);
}
};
Connection.prototype._reset = function() {
this.seq = 1;
this.id = null;
this.agent = null;
};
// Set the connection's state. The connection is basically a state machine.
Connection.prototype._setState = function(newState, reason) {
if (this.state === newState) return;
// I made a state diagram. The only invalid transitions are getting to
// 'connecting' from anywhere other than 'disconnected' and getting to
// 'connected' from anywhere other than 'connecting'.
if (
(
newState === 'connecting' &&
this.state !== 'disconnected' &&
this.state !== 'stopped' &&
this.state !== 'closed'
) || (
newState === 'connected' &&
this.state !== 'connecting'
)
) {
var err = new ShareDBError(
ERROR_CODE.ERR_CONNECTION_STATE_TRANSITION_INVALID,
'Cannot transition directly from ' + this.state + ' to ' + newState
);
return this.emit('error', err);
}
this.state = newState;
this.canSend = (newState === 'connected');
if (
newState === 'disconnected' ||
newState === 'stopped' ||
newState === 'closed'
) {
this._reset();
}
// Group subscribes together to help server make more efficient calls
this.startBulk();
// Emit the event to all queries
for (var id in this.queries) {
var query = this.queries[id];
query._onConnectionStateChanged();
}
// Emit the event to all documents
for (var collection in this.collections) {
var docs = this.collections[collection];
for (var id in docs) {
docs[id]._onConnectionStateChanged();
}
}
// Emit the event to all snapshots
for (var id in this._snapshotRequests) {
var snapshotRequest = this._snapshotRequests[id];
snapshotRequest._onConnectionStateChanged();
}
this.endBulk();
this.emit(newState, reason);
this.emit('state', newState, reason);
};
Connection.prototype.startBulk = function() {
if (!this.bulk) this.bulk = {};
};
Connection.prototype.endBulk = function() {
if (this.bulk) {
for (var collection in this.bulk) {
var actions = this.bulk[collection];
this._sendBulk('f', collection, actions.f);
this._sendBulk('s', collection, actions.s);
this._sendBulk('u', collection, actions.u);
}
}
this.bulk = null;
};
Connection.prototype._sendBulk = function(action, collection, values) {
if (!values) return;
var ids = [];
var versions = {};
var versionsCount = 0;
var versionId;
for (var id in values) {
var value = values[id];
if (value == null) {
ids.push(id);
} else {
versions[id] = value;
versionId = id;
versionsCount++;
}
}
if (ids.length === 1) {
var id = ids[0];
this.send({a: action, c: collection, d: id});
} else if (ids.length) {
this.send({a: 'b' + action, c: collection, b: ids});
}
if (versionsCount === 1) {
var version = versions[versionId];
this.send({a: action, c: collection, d: versionId, v: version});
} else if (versionsCount) {
this.send({a: 'b' + action, c: collection, b: versions});
}
};
Connection.prototype._sendAction = function(action, doc, version) {
// Ensure the doc is registered so that it receives the reply message
this._addDoc(doc);
if (this.bulk) {
// Bulk subscribe
var actions = this.bulk[doc.collection] || (this.bulk[doc.collection] = {});
var versions = actions[action] || (actions[action] = {});
var isDuplicate = versions.hasOwnProperty(doc.id);
versions[doc.id] = version;
return isDuplicate;
} else {
// Send single doc subscribe message
var message = {a: action, c: doc.collection, d: doc.id, v: version};
this.send(message);
}
};
Connection.prototype.sendFetch = function(doc) {
return this._sendAction('f', doc, doc.version);
};
Connection.prototype.sendSubscribe = function(doc) {
return this._sendAction('s', doc, doc.version);
};
Connection.prototype.sendUnsubscribe = function(doc) {
return this._sendAction('u', doc);
};
Connection.prototype.sendOp = function(doc, op) {
// Ensure the doc is registered so that it receives the reply message
this._addDoc(doc);
var message = {
a: 'op',
c: doc.collection,
d: doc.id,
v: doc.version,
src: op.src,
seq: op.seq
};
if (op.op) message.op = op.op;
if (op.create) message.create = op.create;
if (op.del) message.del = op.del;
this.send(message);
};
/**
* Sends a message down the socket
*/
Connection.prototype.send = function(message) {
if (this.debug) logger.info('SEND', JSON.stringify(message));
this.emit('send', message);
this.socket.send(JSON.stringify(message));
};
/**
* Closes the socket and emits 'closed'
*/
Connection.prototype.close = function() {
this.socket.close();
};
Connection.prototype.getExisting = function(collection, id) {
if (this.collections[collection]) return this.collections[collection][id];
};
/**
* Get or create a document.
*
* @param collection
* @param id
* @return {Doc}
*/
Connection.prototype.get = function(collection, id) {
var docs = this.collections[collection] ||
(this.collections[collection] = {});
var doc = docs[id];
if (!doc) {
doc = docs[id] = new Doc(this, collection, id);
this.emit('doc', doc);
}
return doc;
};
/**
* Remove document from this.collections
*
* @private
*/
Connection.prototype._destroyDoc = function(doc) {
var docs = this.collections[doc.collection];
if (!docs) return;
delete docs[doc.id];
// Delete the collection container if its empty. This could be a source of
// memory leaks if you slowly make a billion collections, which you probably
// won't do anyway, but whatever.
if (!util.hasKeys(docs)) {
delete this.collections[doc.collection];
}
};
Connection.prototype._addDoc = function(doc) {
var docs = this.collections[doc.collection];
if (!docs) {
docs = this.collections[doc.collection] = {};
}
if (docs[doc.id] !== doc) {
docs[doc.id] = doc;
}
};
// Helper for createFetchQuery and createSubscribeQuery, below.
Connection.prototype._createQuery = function(action, collection, q, options, callback) {
var id = this.nextQueryId++;
var query = new Query(action, this, id, collection, q, options, callback);
this.queries[id] = query;
query.send();
return query;
};
// Internal function. Use query.destroy() to remove queries.
Connection.prototype._destroyQuery = function(query) {
delete this.queries[query.id];
};
// The query options object can contain the following fields:
//
// db: Name of the db for the query. You can attach extraDbs to ShareDB and
// pick which one the query should hit using this parameter.
// Create a fetch query. Fetch queries are only issued once, returning the
// results directly into the callback.
//
// The callback should have the signature function(error, results, extra)
// where results is a list of Doc objects.
Connection.prototype.createFetchQuery = function(collection, q, options, callback) {
return this._createQuery('qf', collection, q, options, callback);
};
// Create a subscribe query. Subscribe queries return with the initial data
// through the callback, then update themselves whenever the query result set
// changes via their own event emitter.
//
// If present, the callback should have the signature function(error, results, extra)
// where results is a list of Doc objects.
Connection.prototype.createSubscribeQuery = function(collection, q, options, callback) {
return this._createQuery('qs', collection, q, options, callback);
};
Connection.prototype.hasPending = function() {
return !!(
this._firstDoc(hasPending) ||
this._firstQuery(hasPending) ||
this._firstSnapshotRequest()
);
};
function hasPending(object) {
return object.hasPending();
}
Connection.prototype.hasWritePending = function() {
return !!this._firstDoc(hasWritePending);
};
function hasWritePending(object) {
return object.hasWritePending();
}
Connection.prototype.whenNothingPending = function(callback) {
var doc = this._firstDoc(hasPending);
if (doc) {
// If a document is found with a pending operation, wait for it to emit
// that nothing is pending anymore, and then recheck all documents again.
// We have to recheck all documents, just in case another mutation has
// been made in the meantime as a result of an event callback
doc.once('nothing pending', this._nothingPendingRetry(callback));
return;
}
var query = this._firstQuery(hasPending);
if (query) {
query.once('ready', this._nothingPendingRetry(callback));
return;
}
var snapshotRequest = this._firstSnapshotRequest();
if (snapshotRequest) {
snapshotRequest.once('ready', this._nothingPendingRetry(callback));
return;
}
// Call back when no pending operations
process.nextTick(callback);
};
Connection.prototype._nothingPendingRetry = function(callback) {
var connection = this;
return function() {
process.nextTick(function() {
connection.whenNothingPending(callback);
});
};
};
Connection.prototype._firstDoc = function(fn) {
for (var collection in this.collections) {
var docs = this.collections[collection];
for (var id in docs) {
var doc = docs[id];
if (fn(doc)) {
return doc;
}
}
}
};
Connection.prototype._firstQuery = function(fn) {
for (var id in this.queries) {
var query = this.queries[id];
if (fn(query)) {
return query;
}
}
};
Connection.prototype._firstSnapshotRequest = function() {
for (var id in this._snapshotRequests) {
return this._snapshotRequests[id];
}
};
/**
* Fetch a read-only snapshot at a given version
*
* @param collection - the collection name of the snapshot
* @param id - the ID of the snapshot
* @param version (optional) - the version number to fetch. If null, the latest version is fetched.
* @param callback - (error, snapshot) => void, where snapshot takes the following schema:
*
* {
* id: string; // ID of the snapshot
* v: number; // version number of the snapshot
* type: string; // the OT type of the snapshot, or null if it doesn't exist or is deleted
* data: any; // the snapshot
* }
*
*/
Connection.prototype.fetchSnapshot = function(collection, id, version, callback) {
if (typeof version === 'function') {
callback = version;
version = null;
}
var requestId = this.nextSnapshotRequestId++;
var snapshotRequest = new SnapshotVersionRequest(this, requestId, collection, id, version, callback);
this._snapshotRequests[snapshotRequest.requestId] = snapshotRequest;
snapshotRequest.send();
};
/**
* Fetch a read-only snapshot at a given timestamp
*
* @param collection - the collection name of the snapshot
* @param id - the ID of the snapshot
* @param timestamp (optional) - the timestamp to fetch. If null, the latest version is fetched.
* @param callback - (error, snapshot) => void, where snapshot takes the following schema:
*
* {
* id: string; // ID of the snapshot
* v: number; // version number of the snapshot
* type: string; // the OT type of the snapshot, or null if it doesn't exist or is deleted
* data: any; // the snapshot
* }
*
*/
Connection.prototype.fetchSnapshotByTimestamp = function(collection, id, timestamp, callback) {
if (typeof timestamp === 'function') {
callback = timestamp;
timestamp = null;
}
var requestId = this.nextSnapshotRequestId++;
var snapshotRequest = new SnapshotTimestampRequest(this, requestId, collection, id, timestamp, callback);
this._snapshotRequests[snapshotRequest.requestId] = snapshotRequest;
snapshotRequest.send();
};
Connection.prototype._handleSnapshotFetch = function(error, message) {
var snapshotRequest = this._snapshotRequests[message.id];
if (!snapshotRequest) return;
delete this._snapshotRequests[message.id];
snapshotRequest._handleResponse(error, message);
};