UNPKG

sharedb

Version:
840 lines (776 loc) 28.6 kB
var async = require('async'); var Agent = require('./agent'); var Connection = require('./client/connection'); var emitter = require('./emitter'); var MemoryDB = require('./db/memory'); var NoOpMilestoneDB = require('./milestone-db/no-op'); var MemoryPubSub = require('./pubsub/memory'); var ot = require('./ot'); var projections = require('./projections'); var QueryEmitter = require('./query-emitter'); var ShareDBError = require('./error'); var Snapshot = require('./snapshot'); var StreamSocket = require('./stream-socket'); var SubmitRequest = require('./submit-request'); var ReadSnapshotsRequest = require('./read-snapshots-request'); var ERROR_CODE = ShareDBError.CODES; function Backend(options) { if (!(this instanceof Backend)) return new Backend(options); emitter.EventEmitter.call(this); if (!options) options = {}; this.db = options.db || new MemoryDB(); this.pubsub = options.pubsub || new MemoryPubSub(); // This contains any extra databases that can be queried this.extraDbs = options.extraDbs || {}; this.milestoneDb = options.milestoneDb || new NoOpMilestoneDB(); // Map from projected collection -> {type, fields} this.projections = {}; this.suppressPublish = !!options.suppressPublish; this.maxSubmitRetries = options.maxSubmitRetries || null; // Map from event name to a list of middleware this.middleware = {}; // The number of open agents for monitoring and testing memory leaks this.agentsCount = 0; this.remoteAgentsCount = 0; } module.exports = Backend; emitter.mixin(Backend); Backend.prototype.MIDDLEWARE_ACTIONS = { // An operation was successfully written to the database. afterWrite: 'afterWrite', // An operation is about to be applied to a snapshot before being committed to the database apply: 'apply', // An operation was applied to a snapshot; The operation and new snapshot are about to be written to the database. commit: 'commit', // A new client connected to the server. connect: 'connect', // An operation was loaded from the database op: 'op', // A query is about to be sent to the database query: 'query', // Snapshot(s) were received from the database and are about to be returned to a client readSnapshots: 'readSnapshots', // Received a message from a client receive: 'receive', // About to send a non-error reply to a client message. // WARNING: This gets passed a direct reference to the reply object, so // be cautious with it. While modifications to the reply message are possible // by design, changing existing reply properties can cause weird bugs, since // the rest of ShareDB would be unaware of those changes. reply: 'reply', // An operation is about to be submitted to the database submit: 'submit' }; Backend.prototype.SNAPSHOT_TYPES = { // The current snapshot is being fetched (eg through backend.fetch) current: 'current', // A specific snapshot is being fetched by version (eg through backend.fetchSnapshot) byVersion: 'byVersion', // A specific snapshot is being fetch by timestamp (eg through backend.fetchSnapshotByTimestamp) byTimestamp: 'byTimestamp' }; Backend.prototype.close = function(callback) { var wait = 4; var backend = this; function finish(err) { if (err) { if (callback) return callback(err); return backend.emit('error', err); } if (--wait) return; if (callback) callback(); } this.pubsub.close(finish); this.db.close(finish); this.milestoneDb.close(finish); for (var name in this.extraDbs) { wait++; this.extraDbs[name].close(finish); } finish(); }; Backend.prototype.connect = function(connection, req) { var socket = new StreamSocket(); if (connection) { connection.bindToSocket(socket); } else { connection = new Connection(socket); } socket._open(); var agent = this.listen(socket.stream, req); // Store a reference to the agent on the connection for convenience. This 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 connection.agent = agent; return connection; }; /** A client has connected through the specified stream. Listen for messages. * * The optional second argument (req) is an initial request which is passed * through to any connect() middleware. This is useful for inspecting cookies * or an express session or whatever on the request object in your middleware. * * (The agent is available through all middleware) */ Backend.prototype.listen = function(stream, req) { var agent = new Agent(this, stream); this.trigger(this.MIDDLEWARE_ACTIONS.connect, agent, {stream: stream, req: req}, function(err) { if (err) return agent.close(err); agent._open(); }); return agent; }; Backend.prototype.addProjection = function(name, collection, fields) { if (this.projections[name]) { throw new Error('Projection ' + name + ' already exists'); } for (var key in fields) { if (fields[key] !== true) { throw new Error('Invalid field ' + key + ' - fields must be {somekey: true}. Subfields not currently supported.'); } } this.projections[name] = { target: collection, fields: fields }; }; /** * Add middleware to an action or array of actions */ Backend.prototype.use = function(action, fn) { if (Array.isArray(action)) { for (var i = 0; i < action.length; i++) { this.use(action[i], fn); } return this; } var fns = this.middleware[action] || (this.middleware[action] = []); fns.push(fn); return this; }; /** * Passes request through the middleware stack * * Middleware may modify the request object. After all middleware have been * invoked we call `callback` with `null` and the modified request. If one of * the middleware resturns an error the callback is called with that error. */ Backend.prototype.trigger = function(action, agent, request, callback) { request.action = action; request.agent = agent; request.backend = this; var fns = this.middleware[action]; if (!fns) return callback(); // Copying the triggers we'll fire so they don't get edited while we iterate. fns = fns.slice(); var next = function(err) { if (err) return callback(err); var fn = fns.shift(); if (!fn) return callback(); fn(request, next); }; next(); }; // Submit an operation on the named collection/docname. op should contain a // {op:}, {create:} or {del:} field. It should probably contain a v: field (if // it doesn't, it defaults to the current version). Backend.prototype.submit = function(agent, index, id, op, options, callback) { var err = ot.checkOp(op); if (err) return callback(err); var request = new SubmitRequest(this, agent, index, id, op, options); var backend = this; backend.trigger(backend.MIDDLEWARE_ACTIONS.submit, agent, request, function(err) { if (err) return callback(err); request.submit(function(err) { if (err) return callback(err); backend.trigger(backend.MIDDLEWARE_ACTIONS.afterWrite, agent, request, function(err) { if (err) return callback(err); backend._sanitizeOps(agent, request.projection, request.collection, id, request.ops, function(err) { if (err) return callback(err); backend.emit('timing', 'submit.total', Date.now() - request.start, request); callback(err, request.ops); }); }); }); }); }; Backend.prototype.sanitizeOp = function(agent, index, id, op, callback) { var projection = this.projections[index]; var collection = (projection) ? projection.target : index; this._sanitizeOp(agent, projection, collection, id, op, callback); }; Backend.prototype._sanitizeOp = function(agent, projection, collection, id, op, callback) { if (projection) { try { projections.projectOp(projection.fields, op); } catch (err) { return callback(err); } } this.trigger(this.MIDDLEWARE_ACTIONS.op, agent, {collection: collection, id: id, op: op}, callback); }; Backend.prototype._sanitizeOps = function(agent, projection, collection, id, ops, callback) { var backend = this; async.each(ops, function(op, eachCb) { backend._sanitizeOp(agent, projection, collection, id, op, eachCb); }, callback); }; Backend.prototype._sanitizeOpsBulk = function(agent, projection, collection, opsMap, callback) { var backend = this; async.forEachOf(opsMap, function(ops, id, eachCb) { backend._sanitizeOps(agent, projection, collection, id, ops, eachCb); }, callback); }; Backend.prototype._sanitizeSnapshots = function(agent, projection, collection, snapshots, snapshotType, callback) { if (projection) { try { projections.projectSnapshots(projection.fields, snapshots); } catch (err) { return callback(err); } } var request = new ReadSnapshotsRequest(collection, snapshots, snapshotType); this.trigger(this.MIDDLEWARE_ACTIONS.readSnapshots, agent, request, function(err) { if (err) return callback(err); // Handle "partial rejection" - "readSnapshots" middleware functions can use // `request.rejectSnapshotRead(snapshot, error)` to reject the read of a specific snapshot. if (request.hasSnapshotRejection()) { err = request.getReadSnapshotsError(); } if (err) { callback(err); } else { callback(); } }); }; Backend.prototype._getSnapshotProjection = function(db, projection) { return (db.projectsSnapshots) ? null : projection; }; Backend.prototype._getSnapshotsFromMap = function(ids, snapshotMap) { var snapshots = new Array(ids.length); for (var i = 0; i < ids.length; i++) { var id = ids[i]; snapshots[i] = snapshotMap[id]; } return snapshots; }; Backend.prototype._getSanitizedOps = function(agent, projection, collection, id, from, to, opsOptions, callback) { var backend = this; backend.db.getOps(collection, id, from, to, opsOptions, function(err, ops) { if (err) return callback(err); backend._sanitizeOps(agent, projection, collection, id, ops, function(err) { if (err) return callback(err); callback(null, ops); }); }); }; Backend.prototype._getSanitizedOpsBulk = function(agent, projection, collection, fromMap, toMap, opsOptions, callback) { var backend = this; backend.db.getOpsBulk(collection, fromMap, toMap, opsOptions, function(err, opsMap) { if (err) return callback(err); backend._sanitizeOpsBulk(agent, projection, collection, opsMap, function(err) { if (err) return callback(err); callback(null, opsMap); }); }); }; // Non inclusive - gets ops from [from, to). Ie, all relevant ops. If to is // not defined (null or undefined) then it returns all ops. Backend.prototype.getOps = function(agent, index, id, from, to, options, callback) { if (typeof options === 'function') { callback = options; options = null; } var start = Date.now(); var projection = this.projections[index]; var collection = (projection) ? projection.target : index; var backend = this; var request = { agent: agent, index: index, collection: collection, id: id, from: from, to: to }; var opsOptions = options && options.opsOptions; backend._getSanitizedOps(agent, projection, collection, id, from, to, opsOptions, function(err, ops) { if (err) return callback(err); backend.emit('timing', 'getOps', Date.now() - start, request); callback(null, ops); }); }; Backend.prototype.getOpsBulk = function(agent, index, fromMap, toMap, options, callback) { if (typeof options === 'function') { callback = options; options = null; } var start = Date.now(); var projection = this.projections[index]; var collection = (projection) ? projection.target : index; var backend = this; var request = { agent: agent, index: index, collection: collection, fromMap: fromMap, toMap: toMap }; var opsOptions = options && options.opsOptions; backend._getSanitizedOpsBulk(agent, projection, collection, fromMap, toMap, opsOptions, function(err, opsMap) { if (err) return callback(err); backend.emit('timing', 'getOpsBulk', Date.now() - start, request); callback(null, opsMap); }); }; Backend.prototype.fetch = function(agent, index, id, options, callback) { if (typeof options === 'function') { callback = options; options = null; } var start = Date.now(); var projection = this.projections[index]; var collection = (projection) ? projection.target : index; var fields = projection && projection.fields; var backend = this; var request = { agent: agent, index: index, collection: collection, id: id }; var snapshotOptions = options && options.snapshotOptions; backend.db.getSnapshot(collection, id, fields, snapshotOptions, function(err, snapshot) { if (err) return callback(err); var snapshotProjection = backend._getSnapshotProjection(backend.db, projection); var snapshots = [snapshot]; backend._sanitizeSnapshots( agent, snapshotProjection, collection, snapshots, backend.SNAPSHOT_TYPES.current, function(err) { if (err) return callback(err); backend.emit('timing', 'fetch', Date.now() - start, request); callback(null, snapshot); }); }); }; /** * Map of document id to Snapshot or error object. * @typedef {{ [id: string]: Snapshot | { error: Error | string } }} SnapshotMap */ /** * @param {Agent} agent * @param {string} index * @param {string[]} ids * @param {*} options * @param {(err?: Error | string, snapshotMap?: SnapshotMap) => void} callback */ Backend.prototype.fetchBulk = function(agent, index, ids, options, callback) { if (typeof options === 'function') { callback = options; options = null; } var start = Date.now(); var projection = this.projections[index]; var collection = (projection) ? projection.target : index; var fields = projection && projection.fields; var backend = this; var request = { agent: agent, index: index, collection: collection, ids: ids }; var snapshotOptions = options && options.snapshotOptions; backend.db.getSnapshotBulk(collection, ids, fields, snapshotOptions, function(err, snapshotMap) { if (err) return callback(err); var snapshotProjection = backend._getSnapshotProjection(backend.db, projection); var snapshots = backend._getSnapshotsFromMap(ids, snapshotMap); backend._sanitizeSnapshots( agent, snapshotProjection, collection, snapshots, backend.SNAPSHOT_TYPES.current, function(err) { if (err) { if (err.code === ERROR_CODE.ERR_SNAPSHOT_READS_REJECTED) { for (var docId in err.idToError) { snapshotMap[docId] = {error: err.idToError[docId]}; } err = undefined; } else { snapshotMap = undefined; } } backend.emit('timing', 'fetchBulk', Date.now() - start, request); callback(err, snapshotMap); }); }); }; // Subscribe to the document from the specified version or null version Backend.prototype.subscribe = function(agent, index, id, version, options, callback) { if (typeof options === 'function') { callback = options; options = null; } if (options) { // We haven't yet implemented the ability to pass options to subscribe. This is because we need to // add the ability to SubmitRequest.commit to optionally pass the metadata to other clients on // PubSub. This behaviour is not needed right now, but we have added an options object to the // subscribe() signature so that it remains consistent with getOps() and fetch(). return callback(new ShareDBError( ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 'Passing options to subscribe has not been implemented' )); } var start = Date.now(); var projection = this.projections[index]; var collection = (projection) ? projection.target : index; var channel = this.getDocChannel(collection, id); var backend = this; var request = { agent: agent, index: index, collection: collection, id: id, version: version }; backend.pubsub.subscribe(channel, function(err, stream) { if (err) return callback(err); if (version == null) { // Subscribing from null means that the agent doesn't have a document // and needs to fetch it as well as subscribing backend.fetch(agent, index, id, function(err, snapshot) { if (err) { stream.destroy(); return callback(err); } backend.emit('timing', 'subscribe.snapshot', Date.now() - start, request); callback(null, stream, snapshot); }); } else { backend._getSanitizedOps(agent, projection, collection, id, version, null, null, function(err, ops) { if (err) { stream.destroy(); return callback(err); } backend.emit('timing', 'subscribe.ops', Date.now() - start, request); callback(null, stream, null, ops); }); } }); }; /** * Map of document id to pubsub stream. * @typedef {{ [id: string]: Stream }} StreamMap */ /** * Map of document id to array of ops for the doc. * @typedef {{ [id: string]: Op[] }} OpsMap */ /** * @param {Agent} agent * @param {string} index * @param {string[]} versions * @param {( * err?: Error | string | null, * streams?: StreamMap, * snapshotMap?: SnapshotMap | null * opsMap?: OpsMap * ) => void} callback */ Backend.prototype.subscribeBulk = function(agent, index, versions, callback) { var start = Date.now(); var projection = this.projections[index]; var collection = (projection) ? projection.target : index; var backend = this; var streams = {}; var doFetch = Array.isArray(versions); var ids = (doFetch) ? versions : Object.keys(versions); var request = { agent: agent, index: index, collection: collection, versions: versions }; async.each(ids, function(id, eachCb) { var channel = backend.getDocChannel(collection, id); backend.pubsub.subscribe(channel, function(err, stream) { if (err) return eachCb(err); streams[id] = stream; eachCb(); }); }, function(err) { if (err) { destroyStreams(streams); return callback(err); } if (doFetch) { // If an array of ids, get current snapshots backend.fetchBulk(agent, index, ids, function(err, snapshotMap) { if (err) { // Full error, destroy all streams. destroyStreams(streams); streams = undefined; snapshotMap = undefined; } for (var docId in snapshotMap) { // The doc id could map to an object `{error: Error | string}`, which indicates that // particular snapshot's read was rejected. Destroy the streams fur such docs. if (snapshotMap[docId].error) { streams[docId].destroy(); delete streams[docId]; } } backend.emit('timing', 'subscribeBulk.snapshot', Date.now() - start, request); callback(err, streams, snapshotMap); }); } else { // If a versions map, get ops since requested versions backend._getSanitizedOpsBulk(agent, projection, collection, versions, null, null, function(err, opsMap) { if (err) { destroyStreams(streams); return callback(err); } backend.emit('timing', 'subscribeBulk.ops', Date.now() - start, request); callback(null, streams, null, opsMap); }); } }); }; function destroyStreams(streams) { for (var id in streams) { streams[id].destroy(); } } Backend.prototype.queryFetch = function(agent, index, query, options, callback) { var start = Date.now(); var backend = this; backend._triggerQuery(agent, index, query, options, function(err, request) { if (err) return callback(err); backend._query(agent, request, function(err, snapshots, extra) { if (err) return callback(err); backend.emit('timing', 'queryFetch', Date.now() - start, request); callback(null, snapshots, extra); }); }); }; // Options can contain: // db: The name of the DB (if the DB is specified in the otherDbs when the backend instance is created) // skipPoll: function(collection, id, op, query) {return true or false; } // this is a synchronous function which can be used as an early filter for // operations going through the system to reduce the load on the DB. // pollDebounce: Minimum delay between subsequent database polls. This is // used to batch updates to reduce load on the database at the expense of // liveness Backend.prototype.querySubscribe = function(agent, index, query, options, callback) { var start = Date.now(); var backend = this; backend._triggerQuery(agent, index, query, options, function(err, request) { if (err) return callback(err); if (request.db.disableSubscribe) { return callback(new ShareDBError( ERROR_CODE.ERR_DATABASE_DOES_NOT_SUPPORT_SUBSCRIBE, 'DB does not support subscribe' )); } backend.pubsub.subscribe(request.channel, function(err, stream) { if (err) return callback(err); if (options.ids) { var queryEmitter = new QueryEmitter(request, stream, options.ids); backend.emit('timing', 'querySubscribe.reconnect', Date.now() - start, request); callback(null, queryEmitter); return; } // Issue query on db to get our initial results backend._query(agent, request, function(err, snapshots, extra) { if (err) { stream.destroy(); return callback(err); } var ids = pluckIds(snapshots); var queryEmitter = new QueryEmitter(request, stream, ids, extra); backend.emit('timing', 'querySubscribe.initial', Date.now() - start, request); callback(null, queryEmitter, snapshots, extra); }); }); }); }; Backend.prototype._triggerQuery = function(agent, index, query, options, callback) { var projection = this.projections[index]; var collection = (projection) ? projection.target : index; var fields = projection && projection.fields; var request = { index: index, collection: collection, projection: projection, fields: fields, channel: this.getCollectionChannel(collection), query: query, options: options, db: null, snapshotProjection: null }; var backend = this; backend.trigger(backend.MIDDLEWARE_ACTIONS.query, agent, request, function(err) { if (err) return callback(err); // Set the DB reference for the request after the middleware trigger so // that the db option can be changed in middleware request.db = (options.db) ? backend.extraDbs[options.db] : backend.db; if (!request.db) return callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_ADAPTER_NOT_FOUND, 'DB not found')); request.snapshotProjection = backend._getSnapshotProjection(request.db, projection); callback(null, request); }); }; Backend.prototype._query = function(agent, request, callback) { var backend = this; request.db.query(request.collection, request.query, request.fields, request.options, function(err, snapshots, extra) { if (err) return callback(err); backend._sanitizeSnapshots( agent, request.snapshotProjection, request.collection, snapshots, backend.SNAPSHOT_TYPES.current, function(err) { callback(err, snapshots, extra); }); }); }; Backend.prototype.getCollectionChannel = function(collection) { return collection; }; Backend.prototype.getDocChannel = function(collection, id) { return collection + '.' + id; }; Backend.prototype.getChannels = function(collection, id) { return [ this.getCollectionChannel(collection), this.getDocChannel(collection, id) ]; }; Backend.prototype.fetchSnapshot = function(agent, index, id, version, callback) { var start = Date.now(); var backend = this; var projection = this.projections[index]; var collection = projection ? projection.target : index; var request = { agent: agent, index: index, collection: collection, id: id, version: version }; this._fetchSnapshot(collection, id, version, function(error, snapshot) { if (error) return callback(error); var snapshotProjection = backend._getSnapshotProjection(backend.db, projection); var snapshots = [snapshot]; var snapshotType = backend.SNAPSHOT_TYPES.byVersion; backend._sanitizeSnapshots(agent, snapshotProjection, collection, snapshots, snapshotType, function(error) { if (error) return callback(error); backend.emit('timing', 'fetchSnapshot', Date.now() - start, request); callback(null, snapshot); }); }); }; Backend.prototype._fetchSnapshot = function(collection, id, version, callback) { var db = this.db; var backend = this; this.milestoneDb.getMilestoneSnapshot(collection, id, version, function(error, milestoneSnapshot) { if (error) return callback(error); // Bypass backend.getOps so that we don't call _sanitizeOps. We want to avoid this, because: // - we want to avoid the 'op' middleware, because we later use the 'readSnapshots' middleware in _sanitizeSnapshots // - we handle the projection in _sanitizeSnapshots var from = milestoneSnapshot ? milestoneSnapshot.v : 0; db.getOps(collection, id, from, version, null, function(error, ops) { if (error) return callback(error); backend._buildSnapshotFromOps(id, milestoneSnapshot, ops, function(error, snapshot) { if (error) return callback(error); if (version > snapshot.v) { return callback(new ShareDBError( ERROR_CODE.ERR_OP_VERSION_NEWER_THAN_CURRENT_SNAPSHOT, 'Requested version exceeds latest snapshot version' )); } callback(null, snapshot); }); }); }); }; Backend.prototype.fetchSnapshotByTimestamp = function(agent, index, id, timestamp, callback) { var start = Date.now(); var backend = this; var projection = this.projections[index]; var collection = projection ? projection.target : index; var request = { agent: agent, index: index, collection: collection, id: id, timestamp: timestamp }; this._fetchSnapshotByTimestamp(collection, id, timestamp, function(error, snapshot) { if (error) return callback(error); var snapshotProjection = backend._getSnapshotProjection(backend.db, projection); var snapshots = [snapshot]; var snapshotType = backend.SNAPSHOT_TYPES.byTimestamp; backend._sanitizeSnapshots(agent, snapshotProjection, collection, snapshots, snapshotType, function(error) { if (error) return callback(error); backend.emit('timing', 'fetchSnapshot', Date.now() - start, request); callback(null, snapshot); }); }); }; Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp, callback) { var db = this.db; var milestoneDb = this.milestoneDb; var backend = this; var milestoneSnapshot; var from = 0; var to = null; milestoneDb.getMilestoneSnapshotAtOrBeforeTime(collection, id, timestamp, function(error, snapshot) { if (error) return callback(error); milestoneSnapshot = snapshot; if (snapshot) from = snapshot.v; milestoneDb.getMilestoneSnapshotAtOrAfterTime(collection, id, timestamp, function(error, snapshot) { if (error) return callback(error); if (snapshot) to = snapshot.v; var options = {metadata: true}; db.getOps(collection, id, from, to, options, function(error, ops) { if (error) return callback(error); filterOpsInPlaceBeforeTimestamp(ops, timestamp); backend._buildSnapshotFromOps(id, milestoneSnapshot, ops, callback); }); }); }); }; Backend.prototype._buildSnapshotFromOps = function(id, startingSnapshot, ops, callback) { var snapshot = startingSnapshot || new Snapshot(id, 0, null, undefined, null); var error = ot.applyOps(snapshot, ops); callback(error, snapshot); }; function pluckIds(snapshots) { var ids = []; for (var i = 0; i < snapshots.length; i++) { ids.push(snapshots[i].id); } return ids; } function filterOpsInPlaceBeforeTimestamp(ops, timestamp) { if (timestamp === null) { return; } for (var i = 0; i < ops.length; i++) { var op = ops[i]; var opTimestamp = op.m && op.m.ts; if (opTimestamp > timestamp) { ops.length = i; return; } } }