diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..ab65eaee8 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,47 @@ +// The ESLint ecmaVersion argument is inconsistently used. Some rules will ignore it entirely, so if the rule has +// been set, it will still error even if it's not applicable to that version number. Since Google sets these +// rules, we have to turn them off ourselves. +const DISABLED_ES6_OPTIONS = { + 'no-var': 'off', + 'prefer-rest-params': 'off' +}; + +const SHAREDB_RULES = { + // Comma dangle is not supported in ES3 + 'comma-dangle': ['error', 'never'], + // We control our own objects and prototypes, so no need for this check + 'guard-for-in': 'off', + // Google prescribes different indents for different cases. Let's just use 2 spaces everywhere. Note that we have + // to override ESLint's default of 0 indents for this. + 'indent': ['error', 2, { + 'SwitchCase': 1 + }], + // Less aggressive line length than Google, which is especially useful when we have a lot of callbacks in our code + 'max-len': ['error', + { + code: 120, + tabWidth: 2, + ignoreUrls: true, + } + ], + // Google overrides the default ESLint behaviour here, which is slightly better for catching erroneously unused variables + 'no-unused-vars': ['error', {vars: 'all', args: 'after-used'}], + // It's more readable to ensure we only have one statement per line + 'max-statements-per-line': ['error', {max: 1}], + // as-needed quote props are easier to write + 'quote-props': ['error', 'as-needed'], + 'require-jsdoc': 'off', + 'valid-jsdoc': 'off' +}; + +module.exports = { + extends: 'google', + parserOptions: { + ecmaVersion: 3 + }, + rules: Object.assign( + {}, + DISABLED_ES6_OPTIONS, + SHAREDB_RULES + ), +}; diff --git a/.gitignore b/.gitignore index 3005c1397..abd0d58e5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ coverage # Dependency directories node_modules package-lock.json +yarn.lock jspm_packages diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index cf514a151..000000000 --- a/.jshintrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "node": true, - "laxcomma": true, - "eqnull": true, - "eqeqeq": true, - "indent": 2, - "newcap": true, - "quotmark": "single", - "undef": true, - "trailing": true, - "shadow": true, - "expr": true, - "boss": true, - "globals": { - "window": false, - "document": false - } -} diff --git a/.travis.yml b/.travis.yml index 21efafe46..5dc6cbaa0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,6 @@ node_js: - "10" - "8" - "6" -script: "npm run jshint && npm run test-cover" +script: "npm run lint && npm run test-cover" # Send coverage data to Coveralls after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" diff --git a/README.md b/README.md index 8cbbfecf0..b2d1f3473 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ tracker](https://github.com/share/sharedb/issues). - Realtime synchronization of any JSON document - Concurrent multi-user collaboration +- Realtime synchronization of any ephemeral "presence" data - Synchronous editing API with asynchronous eventual consistency - Realtime query subscriptions - Simple integration with any database - [MongoDB](https://github.com/share/sharedb-mongo), [PostgresQL](https://github.com/share/sharedb-postgres) (experimental) @@ -73,6 +74,22 @@ initial data. Then you can submit editing operations on the document (using OT). Finally you can delete the document with a delete operation. By default, ShareDB stores all operations forever - nothing is truly deleted. +## User Presence Synchronization + +ShareDB supports synchronization of user presence data such as cursor positions and text selections. This feature is opt-in, not enabled by default. To enable this feature, pass a presence implementation as the `presence` option to the ShareDB constructor. + +ShareDB includes an implementation of presence called `StatelessPresence`. This provides an implementation of presence that works out of the box, but it has some scalability problems. Each time a client joins a document, this implementation requests current presence information from all other clients, via the server. This approach may be problematic in terms of performance when a large number of users are present on the same document simultaneously. If you don't expect too many simultaneous users per document, `StatelessPresence` should work well. The server does not store any state at all regarding presence (it exists only in clients), hence the name "Stateless Presence". + +In `StatelessPresence`, presence data represents a user and is automatically synchronized between all clients subscribed to the same document. Its format is defined by the document's [OT Type](https://github.com/ottypes/docs) (specifically, by [`transformPresence`, `createPresence`, and `comparePresence`](https://github.com/teamwork/ot-docs#optional-properties)). All clients can modify their own presence data and receive a read-only version of other client's data. Presence data is automatically cleared when a client unsubscribes from the document or disconnects. It is also automatically transformed against applied operations, so that it still makes sense in the context of a modified document, for example a cursor position may be automatically advanced when a user types at the beginning of a text document. + +To use `StatelessPresence`, pass it into the ShareDB constructor like this: + +```js +var ShareDB = require('sharedb'); +var statelessPresence = require('sharedb/lib/presence/stateless'); +var share = new ShareDB({ presence: statelessPresence })`). +``` + ## Server API ### Initialization @@ -91,6 +108,8 @@ __Options__ * `options.pubsub` _(instance of `ShareDB.PubSub`)_ Notify other ShareDB processes when data changes through this pub/sub adapter. Defaults to `ShareDB.MemoryPubSub()`. +* `options.presence` _(implementation of presence classes)_ + Enable user presence synchronization. The value of `options.presence` option is expected to contain implementations of the classes `DocPresence`, `ConnectionPresence`, `AgentPresence`, and `BackendPresence`. Logic related to presence is encapsulated within these classes, so it is possible develop additional third party presence implementations external to ShareDB. #### Database Adapters * `ShareDB.MemoryDB`, backed by a non-persistent database with no queries @@ -308,6 +327,9 @@ Unique document ID `doc.data` _(Object)_ Document contents. Available after document is fetched or subscribed to. +`doc.presence` _(Object)_ +Each property under `doc.presence` contains presence data shared by a client subscribed to this document. The property name is an empty string for this client's data and connection IDs for other clients' data. The structure of the presence object is defined by the OT type of the document (for example, in [ot-rich-text](https://github.com/Teamwork/ot-rich-text#presence) and [@datavis-tech/json0](https://github.com/datavis-tech/json0#presence)). + `doc.fetch(function(err) {...})` Populate the fields on `doc` with a snapshot of the document from the server. @@ -337,6 +359,9 @@ An operation was applied to the data. `source` will be `false` for ops received `doc.on('del', function(data, source) {...})` The document was deleted. Document contents before deletion are passed in as an argument. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. +`doc.on('presence', function(srcList, submitted) {...})` +Presence data has changed. `srcList` is an Array of `doc.presence` property names for which values have changed. `submitted` is `true`, if the event is the result of new presence data being submitted by the local or remote user, otherwise it is `false` - eg if the presence data was transformed against an operation or was cleared on unsubscribe, disconnect or roll-back. + `doc.on('error', function(err) {...})` There was an error fetching the document or applying an operation. @@ -370,6 +395,11 @@ Invokes the given callback function after Note that `whenNothingPending` does NOT wait for pending `model.query()` calls. +`doc.submitPresence(presenceData[, function(err) {...}])` +Set local presence data and publish it for other clients. +`presenceData` structure depends on the document type. +Presence is synchronized only when subscribed to the document. + ### Class: `ShareDB.Query` `query.ready` _(Boolean)_ @@ -467,6 +497,10 @@ Additional fields may be added to the error object for debugging context dependi * 4022 - Database adapter does not support queries * 4023 - Cannot project snapshots of this type * 4024 - Invalid version +* 4025 - Passing options to subscribe has not been implemented +* 4026 - Not subscribed to document +* 4027 - Presence data superseded +* 4028 - OT Type does not support presence ### 5000 - Internal error diff --git a/examples/counter/package.json b/examples/counter/package.json index 49b84460c..42b2e5c90 100644 --- a/examples/counter/package.json +++ b/examples/counter/package.json @@ -17,7 +17,7 @@ "dependencies": { "express": "^4.14.0", "sharedb": "^1.0.0-beta", - "websocket-json-stream": "^0.0.1", + "@teamwork/websocket-json-stream": "^2.0.0", "ws": "^1.1.0" }, "devDependencies": { diff --git a/examples/counter/server.js b/examples/counter/server.js index d4b466965..f6575bfeb 100644 --- a/examples/counter/server.js +++ b/examples/counter/server.js @@ -2,7 +2,7 @@ var http = require('http'); var express = require('express'); var ShareDB = require('sharedb'); var WebSocket = require('ws'); -var WebSocketJSONStream = require('websocket-json-stream'); +var WebSocketJSONStream = require('@teamwork/websocket-json-stream'); var backend = new ShareDB(); createDoc(startServer); @@ -29,7 +29,7 @@ function startServer() { // Connect any incoming WebSocket connection to ShareDB var wss = new WebSocket.Server({server: server}); - wss.on('connection', function(ws, req) { + wss.on('connection', function(ws) { var stream = new WebSocketJSONStream(ws); backend.listen(stream); }); diff --git a/examples/leaderboard/README.md b/examples/leaderboard/README.md index 5a4c8e002..e3b520501 100644 --- a/examples/leaderboard/README.md +++ b/examples/leaderboard/README.md @@ -2,7 +2,7 @@ ![Demo](demo.gif) -This is a port of [https://github.com/percolatestudio/react-leaderboard](Leaderboard) to +This is a port of [Leaderboard](https://github.com/percolatestudio/react-leaderboard) to ShareDB. In this demo, data is not persisted. To persist data, run a Mongo diff --git a/examples/leaderboard/package.json b/examples/leaderboard/package.json index 7defaaa02..6ee5782c8 100644 --- a/examples/leaderboard/package.json +++ b/examples/leaderboard/package.json @@ -24,7 +24,7 @@ "sharedb-mingo-memory": "^1.0.0-beta", "through2": "^2.0.1", "underscore": "^1.8.3", - "websocket-json-stream": "^0.0.3", + "@teamwork/websocket-json-stream": "^2.0.0", "ws": "^1.1.0" }, "devDependencies": { diff --git a/examples/leaderboard/server/index.js b/examples/leaderboard/server/index.js index 6b7972169..6aebe997f 100644 --- a/examples/leaderboard/server/index.js +++ b/examples/leaderboard/server/index.js @@ -1,14 +1,13 @@ -var http = require("http"); -var ShareDB = require("sharedb"); -var connect = require("connect"); +var http = require('http'); +var ShareDB = require('sharedb'); +var connect = require('connect'); var serveStatic = require('serve-static'); var ShareDBMingoMemory = require('sharedb-mingo-memory'); -var WebSocketJSONStream = require('websocket-json-stream'); +var WebSocketJSONStream = require('@teamwork/websocket-json-stream'); var WebSocket = require('ws'); -var util = require('util'); // Start ShareDB -var share = ShareDB({db: new ShareDBMingoMemory()}); +var share = new ShareDB({db: new ShareDBMingoMemory()}); // Create a WebSocket server var app = connect(); @@ -16,10 +15,10 @@ app.use(serveStatic('.')); var server = http.createServer(app); var wss = new WebSocket.Server({server: server}); server.listen(8080); -console.log("Listening on http://localhost:8080"); +console.log('Listening on http://localhost:8080'); // Connect any incoming WebSocket connection with ShareDB -wss.on('connection', function(ws, req) { +wss.on('connection', function(ws) { var stream = new WebSocketJSONStream(ws); share.listen(stream); }); @@ -27,11 +26,13 @@ wss.on('connection', function(ws, req) { // Create initial documents var connection = share.connect(); connection.createFetchQuery('players', {}, {}, function(err, results) { - if (err) { throw err; } + if (err) { + throw err; + } if (results.length === 0) { - var names = ["Ada Lovelace", "Grace Hopper", "Marie Curie", - "Carl Friedrich Gauss", "Nikola Tesla", "Claude Shannon"]; + var names = ['Ada Lovelace', 'Grace Hopper', 'Marie Curie', + 'Carl Friedrich Gauss', 'Nikola Tesla', 'Claude Shannon']; names.forEach(function(name, index) { var doc = connection.get('players', ''+index); diff --git a/examples/rich-text/package.json b/examples/rich-text/package.json index 0bf0a48e8..2249e29c1 100644 --- a/examples/rich-text/package.json +++ b/examples/rich-text/package.json @@ -18,7 +18,7 @@ "quill": "^1.0.0-beta.11", "rich-text": "^3.0.1", "sharedb": "^1.0.0-beta", - "websocket-json-stream": "^0.0.1", + "@teamwork/websocket-json-stream": "^2.0.0", "ws": "^1.1.0" }, "devDependencies": { diff --git a/examples/rich-text/server.js b/examples/rich-text/server.js index 5dfb02bcc..f0654cdf8 100644 --- a/examples/rich-text/server.js +++ b/examples/rich-text/server.js @@ -3,7 +3,7 @@ var express = require('express'); var ShareDB = require('sharedb'); var richText = require('rich-text'); var WebSocket = require('ws'); -var WebSocketJSONStream = require('websocket-json-stream'); +var WebSocketJSONStream = require('@teamwork/websocket-json-stream'); ShareDB.types.register(richText.type); var backend = new ShareDB(); @@ -32,7 +32,7 @@ function startServer() { // Connect any incoming WebSocket connection to ShareDB var wss = new WebSocket.Server({server: server}); - wss.on('connection', function(ws, req) { + wss.on('connection', function(ws) { var stream = new WebSocketJSONStream(ws); backend.listen(stream); }); diff --git a/examples/textarea/client.js b/examples/textarea/client.js index c47035a1c..4cbf55208 100644 --- a/examples/textarea/client.js +++ b/examples/textarea/client.js @@ -2,35 +2,35 @@ var sharedb = require('sharedb/lib/client'); var StringBinding = require('sharedb-string-binding'); // Open WebSocket connection to ShareDB server -const WebSocket = require('reconnecting-websocket'); +var WebSocket = require('reconnecting-websocket'); var socket = new WebSocket('ws://' + window.location.host); var connection = new sharedb.Connection(socket); var element = document.querySelector('textarea'); var statusSpan = document.getElementById('status-span'); -status.innerHTML = "Not Connected" +statusSpan.innerHTML = 'Not Connected'; -element.style.backgroundColor = "gray"; -socket.onopen = function(){ - status.innerHTML = "Connected" - element.style.backgroundColor = "white"; +element.style.backgroundColor = 'gray'; +socket.onopen = function() { + statusSpan.innerHTML = 'Connected'; + element.style.backgroundColor = 'white'; }; -socket.onclose = function(){ - status.innerHTML = "Closed" - element.style.backgroundColor = "gray"; +socket.onclose = function() { + statusSpan.innerHTML = 'Closed'; + element.style.backgroundColor = 'gray'; }; socket.onerror = function() { - status.innerHTML = "Error" - element.style.backgroundColor = "red"; -} + statusSpan.innerHTML = 'Error'; + element.style.backgroundColor = 'red'; +}; // Create local Doc instance mapped to 'examples' collection document with id 'textarea' var doc = connection.get('examples', 'textarea'); doc.subscribe(function(err) { if (err) throw err; - + var binding = new StringBinding(element, doc, ['content']); binding.setup(); }); diff --git a/examples/textarea/server.js b/examples/textarea/server.js index 9d359a771..165c8ea0d 100644 --- a/examples/textarea/server.js +++ b/examples/textarea/server.js @@ -14,7 +14,7 @@ function createDoc(callback) { doc.fetch(function(err) { if (err) throw err; if (doc.type === null) { - doc.create({ content: '' }, callback); + doc.create({content: ''}, callback); return; } callback(); @@ -29,7 +29,7 @@ function startServer() { // Connect any incoming WebSocket connection to ShareDB var wss = new WebSocket.Server({server: server}); - wss.on('connection', function(ws, req) { + wss.on('connection', function(ws) { var stream = new WebSocketJSONStream(ws); backend.listen(stream); }); diff --git a/lib/agent.js b/lib/agent.js index b5cef65c1..dbd35318c 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -26,6 +26,8 @@ function Agent(backend, stream) { // Map from queryId -> emitter this.subscribedQueries = {}; + this._agentPresence = new backend.presence.AgentPresence(this); + // We need to track this manually to make sure we don't reply to messages // after the stream was closed. this.closed = false; @@ -56,7 +58,6 @@ Agent.prototype.close = function(err) { }; Agent.prototype._cleanup = function() { - // Only clean up once if the stream emits both 'end' and 'close'. if (this.closed) return; @@ -106,6 +107,10 @@ Agent.prototype._subscribeToStream = function(collection, id, stream) { logger.error('Doc subscription stream error', collection, id, data.error); return; } + if (agent._agentPresence.isPresenceMessage(data)) { + agent._agentPresence.processPresenceData(data); + return; + } if (agent._isOwnOp(collection, data)) return; agent._sendOp(collection, id, data); }); @@ -117,6 +122,7 @@ Agent.prototype._subscribeToStream = function(collection, id, stream) { if (util.hasKeys(streams)) return; delete agent.subscribedDocs[collection]; }); + this._agentPresence.subscribeToStream(collection, id, stream); }; Agent.prototype._subscribeToQuery = function(emitter, queryId, collection, query) { @@ -265,7 +271,7 @@ Agent.prototype._open = function() { agent._handleMessage(request.data, callback); }); }); - + var cleanup = agent._cleanup.bind(agent); this.stream.on('end', cleanup); this.stream.on('close', cleanup); @@ -288,6 +294,8 @@ Agent.prototype._checkRequest = function(request) { // Bulk request if (request.c != null && typeof request.c !== 'string') return 'Invalid collection'; if (typeof request.b !== 'object') return 'Invalid bulk subscribe data'; + } else { + return this._agentPresence.checkRequest(request); } }; @@ -325,6 +333,9 @@ Agent.prototype._handleMessage = function(request, callback) { case 'nt': return this._fetchSnapshotByTimestamp(request.c, request.d, request.ts, callback); default: + if (this._agentPresence.isPresenceMessage(request)) { + return this._agentPresence.handlePresenceMessage(request, callback); + } callback({code: 4000, message: 'Invalid or unknown message'}); } } catch (err) { @@ -333,7 +344,9 @@ Agent.prototype._handleMessage = function(request, callback) { }; function getQueryOptions(request) { var results = request.r; - var ids, fetch, fetchOps; + var ids; + var fetch; + var fetchOps; if (results) { ids = []; for (var i = 0; i < results.length; i++) { @@ -362,7 +375,6 @@ function getQueryOptions(request) { Agent.prototype._queryFetch = function(queryId, collection, query, options, callback) { // Fetch the results of a query once - var agent = this; this.backend.queryFetch(this, collection, query, options, function(err, results, extra) { if (err) return callback(err); var message = { @@ -607,10 +619,10 @@ Agent.prototype._createOp = function(request) { } }; -Agent.prototype._fetchSnapshot = function (collection, id, version, callback) { +Agent.prototype._fetchSnapshot = function(collection, id, version, callback) { this.backend.fetchSnapshot(this, collection, id, version, callback); }; -Agent.prototype._fetchSnapshotByTimestamp = function (collection, id, timestamp, callback) { +Agent.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp, callback) { this.backend.fetchSnapshotByTimestamp(this, collection, id, timestamp, callback); }; diff --git a/lib/backend.js b/lib/backend.js index 442da075c..54e2ca6a1 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -1,6 +1,7 @@ var async = require('async'); var Agent = require('./agent'); var Connection = require('./client/connection'); +var dummyPresence = require('./presence/dummy'); var emitter = require('./emitter'); var MemoryDB = require('./db/memory'); var NoOpMilestoneDB = require('./milestone-db/no-op'); @@ -11,11 +12,17 @@ var QueryEmitter = require('./query-emitter'); var Snapshot = require('./snapshot'); var StreamSocket = require('./stream-socket'); var SubmitRequest = require('./submit-request'); -var types = require('./types'); var warnDeprecatedDoc = true; var warnDeprecatedAfterSubmit = true; +var DOC_ACTION_DEPRECATION_WARNING = 'DEPRECATED: "doc" middleware action. Use "readSnapshots" instead. ' + + 'Pass `disableDocAction: true` option to ShareDB to disable the "doc" action and this warning.'; + +var AFFTER_SUBMIT_ACTION_DEPRECATION_WARNING = 'DEPRECATED: "after submit" middleware action. ' + + 'Use "afterSubmit" instead. Pass `disableSpaceDelimitedActions: true` option to ShareDB to ' + + 'disable the "after submit" action and this warning.'; + function Backend(options) { if (!(this instanceof Backend)) return new Backend(options); emitter.EventEmitter.call(this); @@ -48,6 +55,10 @@ function Backend(options) { if (!options.disableSpaceDelimitedActions) { this._shimAfterSubmit(); } + + this.presence = options.presence || dummyPresence; + + this._backendPresence = new this.presence.BackendPresence(this); } module.exports = Backend; emitter.mixin(Backend); @@ -95,7 +106,7 @@ Backend.prototype.SNAPSHOT_TYPES = { Backend.prototype._shimDocAction = function() { if (warnDeprecatedDoc) { warnDeprecatedDoc = false; - console.warn('DEPRECATED: "doc" middleware action. Use "readSnapshots" instead. Pass `disableDocAction: true` option to ShareDB to disable the "doc" action and this warning.'); + console.warn(DOC_ACTION_DEPRECATION_WARNING); } var backend = this; @@ -112,7 +123,7 @@ Backend.prototype._shimDocAction = function() { Backend.prototype._shimAfterSubmit = function() { if (warnDeprecatedAfterSubmit) { warnDeprecatedAfterSubmit = false; - console.warn('DEPRECATED: "after submit" middleware action. Use "afterSubmit" instead. Pass `disableSpaceDelimitedActions: true` option to ShareDB to disable the "after submit" action and this warning.'); + console.warn(AFFTER_SUBMIT_ACTION_DEPRECATION_WARNING); } var backend = this; @@ -155,6 +166,13 @@ Backend.prototype.connect = function(connection, req) { // 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; + + // Expose the DocPresence passed in through the constructor + // to the Doc class, which has access to the connection. + connection.DocPresence = this.presence.DocPresence; + + connection._connectionPresence = new this.presence.ConnectionPresence(connection); + return connection; }; @@ -313,7 +331,11 @@ Backend.prototype._getSnapshotsFromMap = function(ids, snapshotMap) { // 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, callback) { +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; @@ -326,7 +348,8 @@ Backend.prototype.getOps = function(agent, index, id, from, to, callback) { from: from, to: to }; - backend.db.getOps(collection, id, from, to, null, function(err, ops) { + var opsOptions = options && options.opsOptions; + 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); @@ -336,7 +359,11 @@ Backend.prototype.getOps = function(agent, index, id, from, to, callback) { }); }; -Backend.prototype.getOpsBulk = function(agent, index, fromMap, toMap, callback) { +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; @@ -348,7 +375,8 @@ Backend.prototype.getOpsBulk = function(agent, index, fromMap, toMap, callback) fromMap: fromMap, toMap: toMap }; - backend.db.getOpsBulk(collection, fromMap, toMap, null, function(err, opsMap) { + var opsOptions = options && options.opsOptions; + 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); @@ -358,7 +386,11 @@ Backend.prototype.getOpsBulk = function(agent, index, fromMap, toMap, callback) }); }; -Backend.prototype.fetch = function(agent, index, id, callback) { +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; @@ -370,19 +402,30 @@ Backend.prototype.fetch = function(agent, index, id, callback) { collection: collection, id: id }; - backend.db.getSnapshot(collection, id, fields, null, function(err, snapshot) { + 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); - }); + 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); + }); }); }; -Backend.prototype.fetchBulk = function(agent, index, ids, 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; @@ -394,20 +437,38 @@ Backend.prototype.fetchBulk = function(agent, index, ids, callback) { collection: collection, ids: ids }; - backend.db.getSnapshotBulk(collection, ids, fields, null, function(err, snapshotMap) { + 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) return callback(err); - backend.emit('timing', 'fetchBulk', Date.now() - start, request); - callback(null, snapshotMap); - }); + backend._sanitizeSnapshots( + agent, + snapshotProjection, + collection, + snapshots, + backend.SNAPSHOT_TYPES.current, + function(err) { + if (err) return callback(err); + backend.emit('timing', 'fetchBulk', Date.now() - start, request); + callback(null, snapshotMap); + }); }); }; // Subscribe to the document from the specified version or null version -Backend.prototype.subscribe = function(agent, index, id, version, callback) { +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({code: 4025, message: 'Passing options to subscribe has not been implemented'}); + } var start = Date.now(); var projection = this.projections[index]; var collection = (projection) ? projection.target : index; @@ -568,7 +629,7 @@ Backend.prototype._triggerQuery = function(agent, index, query, options, callbac query: query, options: options, db: null, - snapshotProjection: null, + snapshotProjection: null }; var backend = this; backend.trigger(backend.MIDDLEWARE_ACTIONS.query, agent, request, function(err) { @@ -586,9 +647,15 @@ 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._sanitizeSnapshots( + agent, + request.snapshotProjection, + request.collection, + snapshots, + backend.SNAPSHOT_TYPES.current, + function(err) { + callback(err, snapshots, extra); + }); }); }; @@ -620,12 +687,12 @@ Backend.prototype.fetchSnapshot = function(agent, index, id, version, callback) version: version }; - this._fetchSnapshot(collection, id, version, function (error, snapshot) { + 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) { + 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); @@ -633,25 +700,25 @@ Backend.prototype.fetchSnapshot = function(agent, index, id, version, callback) }); }; -Backend.prototype._fetchSnapshot = function (collection, id, version, callback) { +Backend.prototype._fetchSnapshot = function(collection, id, version, callback) { var db = this.db; var backend = this; - this.milestoneDb.getMilestoneSnapshot(collection, id, version, function (error, milestoneSnapshot) { + 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) { + db.getOps(collection, id, from, version, null, function(error, ops) { if (error) return callback(error); - backend._buildSnapshotFromOps(id, milestoneSnapshot, ops, function (error, snapshot) { + backend._buildSnapshotFromOps(id, milestoneSnapshot, ops, function(error, snapshot) { if (error) return callback(error); if (version > snapshot.v) { - return callback({ code: 4024, message: 'Requested version exceeds latest snapshot version' }); + return callback({code: 4024, message: 'Requested version exceeds latest snapshot version'}); } callback(null, snapshot); @@ -660,7 +727,7 @@ Backend.prototype._fetchSnapshot = function (collection, id, version, callback) }); }; -Backend.prototype.fetchSnapshotByTimestamp = function (agent, index, id, timestamp, callback) { +Backend.prototype.fetchSnapshotByTimestamp = function(agent, index, id, timestamp, callback) { var start = Date.now(); var backend = this; var projection = this.projections[index]; @@ -673,12 +740,12 @@ Backend.prototype.fetchSnapshotByTimestamp = function (agent, index, id, timesta timestamp: timestamp }; - this._fetchSnapshotByTimestamp(collection, id, timestamp, function (error, snapshot) { + 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) { + 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); @@ -686,7 +753,7 @@ Backend.prototype.fetchSnapshotByTimestamp = function (agent, index, id, timesta }); }; -Backend.prototype._fetchSnapshotByTimestamp = function (collection, id, timestamp, callback) { +Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp, callback) { var db = this.db; var milestoneDb = this.milestoneDb; var backend = this; @@ -695,17 +762,17 @@ Backend.prototype._fetchSnapshotByTimestamp = function (collection, id, timestam var from = 0; var to = null; - milestoneDb.getMilestoneSnapshotAtOrBeforeTime(collection, id, timestamp, function (error, snapshot) { + 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) { + 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) { + db.getOps(collection, id, from, to, options, function(error, ops) { if (error) return callback(error); filterOpsInPlaceBeforeTimestamp(ops, timestamp); backend._buildSnapshotFromOps(id, milestoneSnapshot, ops, callback); @@ -714,12 +781,16 @@ Backend.prototype._fetchSnapshotByTimestamp = function (collection, id, timestam }); }; -Backend.prototype._buildSnapshotFromOps = function (id, startingSnapshot, 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); }; +Backend.prototype.sendPresence = function(presence, callback) { + this._backendPresence.sendPresence(presence, callback); +}; + function pluckIds(snapshots) { var ids = []; for (var i = 0; i < snapshots.length; i++) { diff --git a/lib/client/connection.js b/lib/client/connection.js index cd56306b2..cbc07e38f 100644 --- a/lib/client/connection.js +++ b/lib/client/connection.js @@ -158,10 +158,8 @@ Connection.prototype.bindToSocket = function(socket) { 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); } @@ -255,6 +253,9 @@ Connection.prototype.handleMessage = function(message) { return; default: + if (this._connectionPresence.isPresenceMessage(message)) { + return this._connectionPresence.handlePresenceMessage(err, message); + } logger.warn('Ignoring unrecognized message', message); } }; @@ -295,8 +296,8 @@ Connection.prototype._setState = function(newState, reason) { // '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') + (newState === 'connecting' && this.state !== 'disconnected' && this.state !== 'stopped' && this.state !== 'closed') + || (newState === 'connected' && this.state !== 'connecting') ) { var err = new ShareDBError(5007, 'Cannot transition directly from ' + this.state + ' to ' + newState); return this.emit('error', err); @@ -424,6 +425,9 @@ Connection.prototype.sendOp = function(doc, op) { this.send(message); }; +Connection.prototype.sendPresence = function(doc, data, requestReply) { + this._connectionPresence.sendPresence(doc, data, requestReply); +}; /** * Sends a message down the socket @@ -607,7 +611,7 @@ Connection.prototype._firstQuery = function(fn) { } }; -Connection.prototype._firstSnapshotRequest = function () { +Connection.prototype._firstSnapshotRequest = function() { for (var id in this._snapshotRequests) { return this._snapshotRequests[id]; } @@ -657,7 +661,7 @@ Connection.prototype.fetchSnapshot = function(collection, id, version, callback) * } * */ -Connection.prototype.fetchSnapshotByTimestamp = function (collection, id, timestamp, callback) { +Connection.prototype.fetchSnapshotByTimestamp = function(collection, id, timestamp, callback) { if (typeof timestamp === 'function') { callback = timestamp; timestamp = null; @@ -669,7 +673,7 @@ Connection.prototype.fetchSnapshotByTimestamp = function (collection, id, timest snapshotRequest.send(); }; -Connection.prototype._handleSnapshotFetch = function (error, message) { +Connection.prototype._handleSnapshotFetch = function(error, message) { var snapshotRequest = this._snapshotRequests[message.id]; if (!snapshotRequest) return; delete this._snapshotRequests[message.id]; diff --git a/lib/client/doc.js b/lib/client/doc.js index 15798f0e5..cb504c68a 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -2,6 +2,7 @@ var emitter = require('../emitter'); var logger = require('../logger'); var ShareDBError = require('../error'); var types = require('../types'); +var callEach = require('../util').callEach; /** * A Doc is a client's view on a sharejs document. @@ -29,6 +30,14 @@ var types = require('../types'); * }) * * + * Presence + * -------- + * + * We can associate transient "presence" data with a document, eg caret position, etc. + * The presence data is synchronized on the best-effort basis between clients subscribed to the same document. + * Each client has their own presence data which is read-write. Other clients' data is read-only. + * + * * Events * ------ * @@ -43,6 +52,7 @@ var types = require('../types'); * the data is null. It is passed the data before delteion as an * arguments * - `load ()` Fired when a new snapshot is ingested from a fetch, subscribe, or query + * - `presence ([src])` Fired after the presence data has changed. */ module.exports = Doc; @@ -58,6 +68,8 @@ function Doc(connection, collection, id) { this.type = null; this.data = undefined; + this._docPresence = new connection.DocPresence(this); + // Array of callbacks or nulls as placeholders this.inflightFetch = []; this.inflightSubscribe = []; @@ -105,6 +117,7 @@ emitter.mixin(Doc); Doc.prototype.destroy = function(callback) { var doc = this; doc.whenNothingPending(function() { + doc._docPresence.destroyPresence(); if (doc.wantSubscribe) { doc.unsubscribe(function(err) { if (err) { @@ -136,12 +149,10 @@ Doc.prototype._setType = function(newType) { if (newType) { this.type = newType; - } else if (newType === null) { this.type = newType; // If we removed the type from the object, also remove its data this.data = undefined; - } else { var err = new ShareDBError(4008, 'Missing type ' + newType); return this.emit('error', err); @@ -180,7 +191,10 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) { return callback && this.once('no write pending', callback); } // Otherwise, we've encounted an error state - var err = new ShareDBError(5009, 'Cannot ingest snapshot in doc with null version. ' + this.collection + '.' + this.id); + var err = new ShareDBError( + 5009, + 'Cannot ingest snapshot in doc with null version. ' + this.collection + '.' + this.id + ); if (callback) return callback(err); return this.emit('error', err); } @@ -195,12 +209,16 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) { if (this.version > snapshot.v) return callback && callback(); this.version = snapshot.v; + + this._docPresence.clearCachedOps(); + var type = (snapshot.type === undefined) ? types.defaultType : snapshot.type; this._setType(type); this.data = (this.type && this.type.deserialize) ? this.type.deserialize(snapshot.data) : snapshot.data; this.emit('load'); + this._docPresence.processAllReceivedPresence(); callback && callback(); }; @@ -222,7 +240,8 @@ Doc.prototype.hasPending = function() { this.inflightFetch.length || this.inflightSubscribe.length || this.inflightUnsubscribe.length || - this.pendingFetch.length + this.pendingFetch.length || + this._docPresence.hasPendingPresence() ); }; @@ -269,6 +288,7 @@ Doc.prototype._handleSubscribe = function(err, snapshot) { if (this.wantSubscribe) this.subscribed = true; this.ingestSnapshot(snapshot, callback); this._emitNothingPending(); + this.flush(); }; Doc.prototype._handleUnsubscribe = function(err) { @@ -330,13 +350,23 @@ Doc.prototype._handleOp = function(err, message) { } this.version++; + this._docPresence.cacheOp(message); try { this._otApply(message, false); + this._docPresence.processAllReceivedPresence(); } catch (error) { return this._hardRollback(error); } }; +Doc.prototype._handlePresence = function(err, presence) { + this._docPresence.handlePresence(err, presence); +}; + +Doc.prototype.submitPresence = function(data, callback) { + this._docPresence.submitPresence(data, callback); +}; + // Called whenever (you guessed it!) the connection state changes. This will // happen when we get disconnected & reconnect. Doc.prototype._onConnectionStateChanged = function() { @@ -359,6 +389,7 @@ Doc.prototype._onConnectionStateChanged = function() { this.inflightUnsubscribe = []; callEach(callbacks); } + this._docPresence.pausePresence(); } }; @@ -414,6 +445,7 @@ Doc.prototype.unsubscribe = function(callback) { // between sending the message and hearing back, but we cannot know exactly // when. Thus, immediately mark us as not subscribed this.subscribed = false; + this._docPresence.pausePresence(); if (this.connection.canSend) { var isDuplicate = this.connection.sendUnsubscribe(this); pushActionCallback(this.inflightUnsubscribe, isDuplicate, callback); @@ -449,6 +481,10 @@ Doc.prototype.flush = function() { if (!this.paused && this.pendingOps.length) { this._sendOp(); } + + if (this.subscribed && !this.hasWritePending()) { + this._docPresence.flushPresence(); + } }; // Helper function to set op to contain a no-op. @@ -553,6 +589,7 @@ Doc.prototype._otApply = function(op, source) { // Apply the individual op component this.emit('before op', componentOp.op, source); this.data = this.type.apply(this.data, componentOp.op); + this._docPresence.transformAllPresence(componentOp); this.emit('op', componentOp.op, source); } // Pop whatever was submitted since we started applying this op @@ -565,6 +602,7 @@ Doc.prototype._otApply = function(op, source) { this.emit('before op', op.op, source); // Apply the operation to the local data, mutating it in place this.data = this.type.apply(this.data, op.op); + this._docPresence.transformAllPresence(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. @@ -574,6 +612,8 @@ Doc.prototype._otApply = function(op, source) { return; } + this._docPresence.transformAllPresence(op); + if (op.create) { this._setType(op.create.type); this.data = (this.type.deserialize) ? @@ -653,7 +693,10 @@ Doc.prototype._submit = function(op, source, callback) { // The op contains either op, create, delete, or none of the above (a no-op). if (op.op) { if (!this.type) { - var err = new ShareDBError(4015, 'Cannot submit op. Document has not been created. ' + this.collection + '.' + this.id); + var err = new ShareDBError( + 4015, + 'Cannot submit op. Document has not been created. ' + this.collection + '.' + this.id + ); if (callback) return callback(err); return this.emit('error', err); } @@ -839,7 +882,7 @@ Doc.prototype.resume = function() { Doc.prototype._opAcknowledged = function(message) { if (this.inflightOp.create) { this.version = message.v; - + this._docPresence.clearCachedOps(); } 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 @@ -852,7 +895,9 @@ Doc.prototype._opAcknowledged = function(message) { // The op was committed successfully. Increment the version number this.version++; + this._docPresence.cacheOp(this.inflightOp); this._clearInflightOp(); + this._docPresence.processAllReceivedPresence(); }; Doc.prototype._rollback = function(err) { @@ -900,6 +945,10 @@ Doc.prototype._hardRollback = function(err) { if (this.inflightOp) pendingOps.push(this.inflightOp); pendingOps = pendingOps.concat(this.pendingOps); + // Apply a similar technique for presence. + var pendingPresence = this._docPresence.getPendingPresence(); + this._docPresence.hardRollbackPresence(); + // Cancel all pending ops and reset if we can't invert this._setType(null); this.version = null; @@ -912,13 +961,22 @@ Doc.prototype._hardRollback = function(err) { // We want to check that no errors are swallowed, so we check that: // - there are callbacks to call, and // - that every single pending op called a callback - // If there are no ops queued, or one of them didn't handle the error, - // then we emit the error. var allOpsHadCallbacks = !!pendingOps.length; for (var i = 0; i < pendingOps.length; i++) { allOpsHadCallbacks = callEach(pendingOps[i].callbacks, err) && allOpsHadCallbacks; } - if (err && !allOpsHadCallbacks) return doc.emit('error', err); + + // Apply the same technique for presence. + var allPresenceHadCallbacks = !!pendingPresence.length; + for (var i = 0; i < pendingPresence.length; i++) { + allPresenceHadCallbacks = callEach(pendingPresence[i], err) && allPresenceHadCallbacks; + } + + // If there are no ops or presence queued, or one of them didn't handle the error, + // then we emit the error. + if (err && !allOpsHadCallbacks && !allPresenceHadCallbacks) { + doc.emit('error', err); + } }); }; @@ -934,15 +992,3 @@ Doc.prototype._clearInflightOp = function(err) { if (err && !called) return this.emit('error', err); }; - -function callEach(callbacks, err) { - var called = false; - for (var i = 0; i < callbacks.length; i++) { - var callback = callbacks[i]; - if (callback) { - callback(err); - called = true; - } - } - return called; -} diff --git a/lib/client/query.js b/lib/client/query.js index 4f8bae764..c406fb4f0 100644 --- a/lib/client/query.js +++ b/lib/client/query.js @@ -119,7 +119,6 @@ Query.prototype._handleResponse = function(err, data, extra) { wait += data.length; this.results = this._ingestSnapshots(data, finish); this.extra = extra; - } else { for (var id in data) { wait++; diff --git a/lib/client/snapshot-request/snapshot-request.js b/lib/client/snapshot-request/snapshot-request.js index 00ed9b90f..95f68055b 100644 --- a/lib/client/snapshot-request/snapshot-request.js +++ b/lib/client/snapshot-request/snapshot-request.js @@ -20,7 +20,7 @@ function SnapshotRequest(connection, requestId, collection, id, callback) { } emitter.mixin(SnapshotRequest); -SnapshotRequest.prototype.send = function () { +SnapshotRequest.prototype.send = function() { if (!this.connection.canSend) { return; } @@ -29,7 +29,7 @@ SnapshotRequest.prototype.send = function () { this.sent = true; }; -SnapshotRequest.prototype._onConnectionStateChanged = function () { +SnapshotRequest.prototype._onConnectionStateChanged = function() { if (this.connection.canSend) { if (!this.sent) this.send(); } else { @@ -40,7 +40,7 @@ SnapshotRequest.prototype._onConnectionStateChanged = function () { } }; -SnapshotRequest.prototype._handleResponse = function (error, message) { +SnapshotRequest.prototype._handleResponse = function(error, message) { this.emit('ready'); if (error) { diff --git a/lib/client/snapshot-request/snapshot-timestamp-request.js b/lib/client/snapshot-request/snapshot-timestamp-request.js index 53c3b2437..15789137b 100644 --- a/lib/client/snapshot-request/snapshot-timestamp-request.js +++ b/lib/client/snapshot-request/snapshot-timestamp-request.js @@ -15,12 +15,12 @@ function SnapshotTimestampRequest(connection, requestId, collection, id, timesta SnapshotTimestampRequest.prototype = Object.create(SnapshotRequest.prototype); -SnapshotTimestampRequest.prototype._message = function () { +SnapshotTimestampRequest.prototype._message = function() { return { a: 'nt', id: this.requestId, c: this.collection, d: this.id, - ts: this.timestamp, + ts: this.timestamp }; }; diff --git a/lib/client/snapshot-request/snapshot-version-request.js b/lib/client/snapshot-request/snapshot-version-request.js index 60a2e3a3c..d352a676a 100644 --- a/lib/client/snapshot-request/snapshot-version-request.js +++ b/lib/client/snapshot-request/snapshot-version-request.js @@ -3,7 +3,7 @@ var util = require('../../util'); module.exports = SnapshotVersionRequest; -function SnapshotVersionRequest (connection, requestId, collection, id, version, callback) { +function SnapshotVersionRequest(connection, requestId, collection, id, version, callback) { SnapshotRequest.call(this, connection, requestId, collection, id, callback); if (!util.isValidVersion(version)) { @@ -15,12 +15,12 @@ function SnapshotVersionRequest (connection, requestId, collection, id, version, SnapshotVersionRequest.prototype = Object.create(SnapshotRequest.prototype); -SnapshotVersionRequest.prototype._message = function () { +SnapshotVersionRequest.prototype._message = function() { return { a: 'nf', id: this.requestId, c: this.collection, d: this.id, - v: this.version, + v: this.version }; }; diff --git a/lib/db/memory.js b/lib/db/memory.js index 9c1a47b56..029c1229b 100644 --- a/lib/db/memory.js +++ b/lib/db/memory.js @@ -122,7 +122,7 @@ MemoryDB.prototype.query = function(collection, query, fields, options, callback // two properties: // - snapshots: array of query result snapshots // - extra: (optional) other types of results, such as counts -MemoryDB.prototype._querySync = function(snapshots, query, options) { +MemoryDB.prototype._querySync = function(snapshots) { return {snapshots: snapshots}; }; diff --git a/lib/logger/logger.js b/lib/logger/logger.js index 6d193e169..3c70e5a53 100644 --- a/lib/logger/logger.js +++ b/lib/logger/logger.js @@ -5,15 +5,20 @@ var SUPPORTED_METHODS = [ ]; function Logger() { - this.setMethods(console); + var defaultMethods = {}; + SUPPORTED_METHODS.forEach(function(method) { + // Deal with Chrome issue: https://bugs.chromium.org/p/chromium/issues/detail?id=179628 + defaultMethods[method] = console[method].bind(console); + }); + this.setMethods(defaultMethods); } module.exports = Logger; -Logger.prototype.setMethods = function (overrides) { +Logger.prototype.setMethods = function(overrides) { overrides = overrides || {}; var logger = this; - SUPPORTED_METHODS.forEach(function (method) { + SUPPORTED_METHODS.forEach(function(method) { if (typeof overrides[method] === 'function') { logger[method] = overrides[method]; } diff --git a/lib/milestone-db/index.js b/lib/milestone-db/index.js index 1b04ad30e..3726b2ca8 100644 --- a/lib/milestone-db/index.js +++ b/lib/milestone-db/index.js @@ -24,7 +24,7 @@ MilestoneDB.prototype.close = function(callback) { * @param {Function} callback - a callback to invoke once the snapshot has been fetched. Should have * the signature (error, snapshot) => void; */ -MilestoneDB.prototype.getMilestoneSnapshot = function (collection, id, version, callback) { +MilestoneDB.prototype.getMilestoneSnapshot = function(collection, id, version, callback) { var error = new ShareDBError(5019, 'getMilestoneSnapshot MilestoneDB method unimplemented'); this._callBackOrEmitError(error, callback); }; @@ -35,30 +35,30 @@ MilestoneDB.prototype.getMilestoneSnapshot = function (collection, id, version, * @param {Function} callback (optional) - a callback to invoke after the snapshot has been saved. * Should have the signature (error) => void; */ -MilestoneDB.prototype.saveMilestoneSnapshot = function (collection, snapshot, callback) { +MilestoneDB.prototype.saveMilestoneSnapshot = function(collection, snapshot, callback) { var error = new ShareDBError(5020, 'saveMilestoneSnapshot MilestoneDB method unimplemented'); this._callBackOrEmitError(error, callback); }; -MilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function (collection, id, timestamp, callback) { +MilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function(collection, id, timestamp, callback) { var error = new ShareDBError(5021, 'getMilestoneSnapshotAtOrBeforeTime MilestoneDB method unimplemented'); this._callBackOrEmitError(error, callback); }; -MilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function (collection, id, timestamp, callback) { +MilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function(collection, id, timestamp, callback) { var error = new ShareDBError(5022, 'getMilestoneSnapshotAtOrAfterTime MilestoneDB method unimplemented'); this._callBackOrEmitError(error, callback); }; -MilestoneDB.prototype._isValidVersion = function (version) { +MilestoneDB.prototype._isValidVersion = function(version) { return util.isValidVersion(version); }; -MilestoneDB.prototype._isValidTimestamp = function (timestamp) { +MilestoneDB.prototype._isValidTimestamp = function(timestamp) { return util.isValidTimestamp(timestamp); }; -MilestoneDB.prototype._callBackOrEmitError = function (error, callback) { +MilestoneDB.prototype._callBackOrEmitError = function(error, callback) { if (callback) return process.nextTick(callback, error); this.emit('error', error); }; diff --git a/lib/milestone-db/memory.js b/lib/milestone-db/memory.js index 52f1ba522..7b64dee36 100644 --- a/lib/milestone-db/memory.js +++ b/lib/milestone-db/memory.js @@ -22,15 +22,15 @@ function MemoryMilestoneDB(options) { MemoryMilestoneDB.prototype = Object.create(MilestoneDB.prototype); -MemoryMilestoneDB.prototype.getMilestoneSnapshot = function (collection, id, version, callback) { +MemoryMilestoneDB.prototype.getMilestoneSnapshot = function(collection, id, version, callback) { if (!this._isValidVersion(version)) return process.nextTick(callback, new ShareDBError(4001, 'Invalid version')); var predicate = versionLessThanOrEqualTo(version); this._findMilestoneSnapshot(collection, id, predicate, callback); }; -MemoryMilestoneDB.prototype.saveMilestoneSnapshot = function (collection, snapshot, callback) { - callback = callback || function (error) { +MemoryMilestoneDB.prototype.saveMilestoneSnapshot = function(collection, snapshot, callback) { + callback = callback || function(error) { if (error) return this.emit('error', error); this.emit('save', collection, snapshot); }.bind(this); @@ -40,25 +40,29 @@ MemoryMilestoneDB.prototype.saveMilestoneSnapshot = function (collection, snapsh var milestoneSnapshots = this._getMilestoneSnapshotsSync(collection, snapshot.id); milestoneSnapshots.push(snapshot); - milestoneSnapshots.sort(function (a, b) { + milestoneSnapshots.sort(function(a, b) { return a.v - b.v; }); process.nextTick(callback, null); }; -MemoryMilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function (collection, id, timestamp, callback) { - if (!this._isValidTimestamp(timestamp)) return process.nextTick(callback, new ShareDBError(4001, 'Invalid timestamp')); +MemoryMilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function(collection, id, timestamp, callback) { + if (!this._isValidTimestamp(timestamp)) { + return process.nextTick(callback, new ShareDBError(4001, 'Invalid timestamp')); + } var filter = timestampLessThanOrEqualTo(timestamp); this._findMilestoneSnapshot(collection, id, filter, callback); }; -MemoryMilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function (collection, id, timestamp, callback) { - if (!this._isValidTimestamp(timestamp)) return process.nextTick(callback, new ShareDBError(4001, 'Invalid timestamp')); +MemoryMilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function(collection, id, timestamp, callback) { + if (!this._isValidTimestamp(timestamp)) { + return process.nextTick(callback, new ShareDBError(4001, 'Invalid timestamp')); + } var filter = timestampGreaterThanOrEqualTo(timestamp); - this._findMilestoneSnapshot(collection, id, filter, function (error, snapshot) { + this._findMilestoneSnapshot(collection, id, filter, function(error, snapshot) { if (error) return process.nextTick(callback, error); var mtime = snapshot && snapshot.m && snapshot.m.mtime; @@ -70,7 +74,7 @@ MemoryMilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function (collec }); }; -MemoryMilestoneDB.prototype._findMilestoneSnapshot = function (collection, id, breakCondition, callback) { +MemoryMilestoneDB.prototype._findMilestoneSnapshot = function(collection, id, breakCondition, callback) { if (!collection) return process.nextTick(callback, new ShareDBError(4001, 'Missing collection')); if (!id) return process.nextTick(callback, new ShareDBError(4001, 'Missing ID')); @@ -89,13 +93,13 @@ MemoryMilestoneDB.prototype._findMilestoneSnapshot = function (collection, id, b process.nextTick(callback, null, milestoneSnapshot); }; -MemoryMilestoneDB.prototype._getMilestoneSnapshotsSync = function (collection, id) { +MemoryMilestoneDB.prototype._getMilestoneSnapshotsSync = function(collection, id) { var collectionSnapshots = this._milestoneSnapshots[collection] || (this._milestoneSnapshots[collection] = {}); return collectionSnapshots[id] || (collectionSnapshots[id] = []); }; function versionLessThanOrEqualTo(version) { - return function (currentSnapshot, nextSnapshot) { + return function(currentSnapshot, nextSnapshot) { if (version === null) { return false; } @@ -105,7 +109,7 @@ function versionLessThanOrEqualTo(version) { } function timestampGreaterThanOrEqualTo(timestamp) { - return function (currentSnapshot) { + return function(currentSnapshot) { if (timestamp === null) { return false; } @@ -116,7 +120,7 @@ function timestampGreaterThanOrEqualTo(timestamp) { } function timestampLessThanOrEqualTo(timestamp) { - return function (currentSnapshot, nextSnapshot) { + return function(currentSnapshot, nextSnapshot) { if (timestamp === null) { return !!currentSnapshot; } diff --git a/lib/milestone-db/no-op.js b/lib/milestone-db/no-op.js index 82d66ba10..fc235d248 100644 --- a/lib/milestone-db/no-op.js +++ b/lib/milestone-db/no-op.js @@ -13,22 +13,22 @@ function NoOpMilestoneDB(options) { NoOpMilestoneDB.prototype = Object.create(MilestoneDB.prototype); -NoOpMilestoneDB.prototype.getMilestoneSnapshot = function (collection, id, version, callback) { +NoOpMilestoneDB.prototype.getMilestoneSnapshot = function(collection, id, version, callback) { var snapshot = undefined; process.nextTick(callback, null, snapshot); }; -NoOpMilestoneDB.prototype.saveMilestoneSnapshot = function (collection, snapshot, callback) { +NoOpMilestoneDB.prototype.saveMilestoneSnapshot = function(collection, snapshot, callback) { if (callback) return process.nextTick(callback, null); this.emit('save', collection, snapshot); }; -NoOpMilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function (collection, id, timestamp, callback) { +NoOpMilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function(collection, id, timestamp, callback) { var snapshot = undefined; process.nextTick(callback, null, snapshot); }; -NoOpMilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function (collection, id, timestamp, callback) { +NoOpMilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function(collection, id, timestamp, callback) { var snapshot = undefined; process.nextTick(callback, null, snapshot); }; diff --git a/lib/ot.js b/lib/ot.js index f44b77835..f04da0145 100644 --- a/lib/ot.js +++ b/lib/ot.js @@ -23,10 +23,8 @@ exports.checkOp = function(op) { if (type == null || typeof type !== 'object') { return {code: 4008, message: 'Unknown type'}; } - } else if (op.del != null) { if (op.del !== true) return {code: 4009, message: 'del value must be true'}; - } else if (op.op == null) { return {code: 4010, message: 'Missing op, create, or del'}; } @@ -155,14 +153,14 @@ exports.transform = function(type, op, appliedOp) { * * @param snapshot - a Snapshot object which will be mutated by the provided ops * @param ops - an array of ops to apply to the snapshot - * @returns an error object if applicable + * @return an error object if applicable */ -exports.applyOps = function (snapshot, ops) { +exports.applyOps = function(snapshot, ops) { var type = null; if (snapshot.type) { type = types[snapshot.type]; - if (!type) return { code: 4008, message: 'Unknown type' }; + if (!type) return {code: 4008, message: 'Unknown type'}; } for (var index = 0; index < ops.length; index++) { @@ -172,7 +170,7 @@ exports.applyOps = function (snapshot, ops) { if (op.create) { type = types[op.create.type]; - if (!type) return { code: 4008, message: 'Unknown type' }; + if (!type) return {code: 4008, message: 'Unknown type'}; snapshot.data = type.create(op.create.data); snapshot.type = type.uri; } else if (op.del) { diff --git a/lib/presence/dummy.js b/lib/presence/dummy.js new file mode 100644 index 000000000..423729967 --- /dev/null +++ b/lib/presence/dummy.js @@ -0,0 +1,70 @@ +/* + * Dummy Presence + * -------------- + * + * This module provides a dummy implementation of presence that does nothing. + * Its purpose is to stand in for a real implementation, to simplify code in doc.js. + * + */ +var presence = require('./index'); + +function noop() {} +function returnEmptyArray() { + return []; +}; +function returnFalse() { + return false; +}; + +function DocPresence() {} +DocPresence.prototype = Object.create(presence.DocPresence.prototype); +Object.assign(DocPresence.prototype, { + submitPresence: noop, + handlePresence: noop, + processAllReceivedPresence: noop, + transformAllPresence: noop, + pausePresence: noop, + cacheOp: noop, + flushPresence: noop, + destroyPresence: noop, + clearCachedOps: noop, + hardRollbackPresence: returnEmptyArray, + hasPendingPresence: returnFalse, + getPendingPresence: returnEmptyArray, + _processReceivedPresence: noop, + _transformPresence: noop, + _setPresence: noop, + _emitPresence: noop +}); + +function ConnectionPresence() {} +ConnectionPresence.prototype = Object.create(presence.ConnectionPresence.prototype); +Object.assign(ConnectionPresence.prototype, { + isPresenceMessage: returnFalse, + handlePresenceMessage: noop, + sendPresence: noop +}); + +function AgentPresence() {} +AgentPresence.prototype = Object.create(presence.AgentPresence.prototype); +Object.assign(AgentPresence.prototype, { + isPresenceMessage: returnFalse, + processPresenceData: returnFalse, + createPresence: noop, + subscribeToStream: noop, + checkRequest: noop, + handlePresenceMessage: noop +}); + +function BackendPresence() {} +BackendPresence.prototype = Object.create(presence.BackendPresence.prototype); +Object.assign(BackendPresence.prototype, { + sendPresence: noop +}); + +module.exports = { + DocPresence: DocPresence, + ConnectionPresence: ConnectionPresence, + AgentPresence: AgentPresence, + BackendPresence: BackendPresence +}; diff --git a/lib/presence/index.js b/lib/presence/index.js new file mode 100644 index 000000000..cddecd0ad --- /dev/null +++ b/lib/presence/index.js @@ -0,0 +1,6 @@ +module.exports = { + DocPresence: function DocPresence() {}, + ConnectionPresence: function ConnectionPresence() {}, + AgentPresence: function AgentPresence() {}, + BackendPresence: function BackendPresence() {} +}; diff --git a/lib/presence/stateless.js b/lib/presence/stateless.js new file mode 100644 index 000000000..14c186ed7 --- /dev/null +++ b/lib/presence/stateless.js @@ -0,0 +1,539 @@ +/* + * Stateless Presence + * ------------------ + * + * This module provides an implementation of presence that works, + * but has some scalability problems. Each time a client joins a document, + * this implementation requests current presence information from all other clients, + * via the server. The server does not store any state at all regarding presence, + * it exists only in clients, hence the name "Doc Presence". + * + */ +var ShareDBError = require('../error'); +var presence = require('./index'); +var callEach = require('../util').callEach; + +// Check if a message represence presence. +// Used in both ConnectionPresence and AgentPresence. +function isPresenceMessage(data) { + return data.a === 'p'; +}; + + +/* + * Stateless Presence implementation of DocPresence + * ------------------------------------------------ + */ +function DocPresence(doc) { + this.doc = doc; + + // The current presence data. + // Map of src -> presence data + // Local src === '' + this.doc.presence = {}; + + // The presence objects received from the server. + // Map of src -> presence + this.received = {}; + + // The minimum amount of time to wait before removing processed presence from this.presence.received. + // The processed presence is removed to avoid leaking memory, in case peers keep connecting and disconnecting a lot. + // The processed presence is not removed immediately to enable avoiding race conditions, where messages with lower + // sequence number arrive after messages with higher sequence numbers. + this.receivedTimeout = 60000; + + // If set to true, then the next time the local presence is sent, + // all other clients will be asked to reply with their own presence data. + this.requestReply = true; + + // A list of ops sent by the server. These are needed for transforming presence data, + // if we get that presence data for an older version of the document. + this.cachedOps = []; + + // The ops are cached for at least 1 minute by default, which should be lots, considering that the presence + // data is supposed to be synced in real-time. + this.cachedOpsTimeout = 60000; + + // The sequence number of the inflight presence request. + this.inflightSeq = 0; + + // Callbacks (or null) for pending and inflight presence requests. + this.pending = null; + this.inflight = null; +} + +DocPresence.prototype = Object.create(presence.DocPresence.prototype); + +// Submit presence data to a document. +// This is the only public facing method. +// All the others are marked as internal with a leading "_". +DocPresence.prototype.submitPresence = function(data, callback) { + if (data != null) { + if (!this.doc.type) { + var doc = this.doc; + return process.nextTick(function() { + var err = new ShareDBError(4015, + 'Cannot submit presence. Document has not been created. ' + doc.collection + '.' + doc.id); + if (callback) return callback(err); + doc.emit('error', err); + }); + } + + if (!this.doc.type.createPresence || !this.doc.type.transformPresence) { + var doc = this.doc; + return process.nextTick(function() { + var err = new ShareDBError(4028, + 'Cannot submit presence. Document\'s type does not support presence. ' + doc.collection + '.' + doc.id); + if (callback) return callback(err); + doc.emit('error', err); + }); + } + + data = this.doc.type.createPresence(data); + } + + if (this._setPresence('', data, true) || this.pending || this.inflight) { + if (!this.pending) { + this.pending = []; + } + if (callback) { + this.pending.push(callback); + } + } else if (callback) { + process.nextTick(callback); + } + + process.nextTick(this.doc.flush.bind(this.doc)); +}; + +DocPresence.prototype.handlePresence = function(err, presence) { + if (!this.doc.subscribed) return; + + var src = presence.src; + if (!src) { + // Handle the ACK for the presence data we submitted. + // this.inflightSeq would not equal presence.seq after a hard rollback, + // when all callbacks are flushed with an error. + if (this.inflightSeq === presence.seq) { + var callbacks = this.inflight; + this.inflight = null; + this.inflightSeq = 0; + var called = callbacks && callEach(callbacks, err); + if (err && !called) this.doc.emit('error', err); + this.doc.flush(); + this.doc._emitNothingPending(); + } + return; + } + + // This shouldn't happen but check just in case. + if (err) return this.doc.emit('error', err); + + if (presence.r && !this.pending) { + // Another client requested us to share our current presence data + this.pending = []; + this.doc.flush(); + } + + // Ignore older messages which arrived out of order + if ( + this.received[src] && ( + this.received[src].seq > presence.seq || + (this.received[src].seq === presence.seq && presence.v != null) + ) + ) return; + + this.received[src] = presence; + + if (presence.v == null) { + // null version should happen only when the server automatically sends + // null presence for an unsubscribed client + presence.processedAt = Date.now(); + return this._setPresence(src, null, true); + } + + // Get missing ops first, if necessary + if (this.doc.version == null || this.doc.version < presence.v) return this.doc.fetch(); + + this._processReceivedPresence(src, true); +}; + +// If emit is true and presence has changed, emits a presence event. +// Returns true, if presence has changed for src. Otherwise false. +DocPresence.prototype._processReceivedPresence = function(src, emit) { + if (!src) return false; + var presence = this.received[src]; + if (!presence) return false; + + if (presence.processedAt != null) { + if (Date.now() >= presence.processedAt + this.receivedTimeout) { + // Remove old received and processed presence. + delete this.received[src]; + } + return false; + } + + if (this.doc.version == null || this.doc.version < presence.v) { + // keep waiting for the missing snapshot or ops. + return false; + } + + if (presence.p == null) { + // Remove presence data as requested. + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + + if (!this.doc.type || !this.doc.type.createPresence || !this.doc.type.transformPresence) { + // Remove presence data because the document is not created or its type does not support presence + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + + if (this.doc.inflightOp && this.doc.inflightOp.op == null) { + // Remove presence data because presence.received can be transformed only against "op", not "create" nor "del" + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + + for (var i = 0; i < this.doc.pendingOps.length; i++) { + if (this.doc.pendingOps[i].op == null) { + // Remove presence data because presence.received can be transformed only against "op", not "create" nor "del" + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + } + + var startIndex = this.cachedOps.length - (this.doc.version - presence.v); + if (startIndex < 0) { + // Remove presence data because we can't transform presence.received + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + + for (var i = startIndex; i < this.cachedOps.length; i++) { + if (this.cachedOps[i].op == null) { + // Remove presence data because presence.received can be transformed only against "op", not "create" nor "del" + presence.processedAt = Date.now(); + return this._setPresence(src, null, emit); + } + } + + // Make sure the format of the data is correct + var data = this.doc.type.createPresence(presence.p); + + // Transform against past ops + for (var i = startIndex; i < this.cachedOps.length; i++) { + var op = this.cachedOps[i]; + data = this.doc.type.transformPresence(data, op.op, presence.src === op.src); + } + + // Transform against pending ops + if (this.doc.inflightOp) { + data = this.doc.type.transformPresence(data, this.doc.inflightOp.op, false); + } + + for (var i = 0; i < this.doc.pendingOps.length; i++) { + data = this.doc.type.transformPresence(data, this.doc.pendingOps[i].op, false); + } + + // Set presence data + presence.processedAt = Date.now(); + return this._setPresence(src, data, emit); +}; + +DocPresence.prototype.processAllReceivedPresence = function() { + var srcList = Object.keys(this.received); + var changedSrcList = []; + for (var i = 0; i < srcList.length; i++) { + var src = srcList[i]; + if (this._processReceivedPresence(src)) { + changedSrcList.push(src); + } + } + this._emitPresence(changedSrcList, true); +}; + +DocPresence.prototype._transformPresence = function(src, op) { + var presenceData = this.doc.presence[src]; + if (op.op != null) { + var isOwnOperation = src === (op.src || ''); + presenceData = this.doc.type.transformPresence(presenceData, op.op, isOwnOperation); + } else { + presenceData = null; + } + return this._setPresence(src, presenceData); +}; + +DocPresence.prototype.transformAllPresence = function(op) { + var srcList = Object.keys(this.doc.presence); + var changedSrcList = []; + for (var i = 0; i < srcList.length; i++) { + var src = srcList[i]; + if (this._transformPresence(src, op)) { + changedSrcList.push(src); + } + } + this._emitPresence(changedSrcList, false); +}; + +DocPresence.prototype.pausePresence = function() { + if (!this) return; + + if (this.inflight) { + this.pending = this.pending + ? this.inflight.concat(this.pending) + : this.inflight; + this.inflight = null; + this.inflightSeq = 0; + } else if (!this.pending && this.doc.presence[''] != null) { + this.pending = []; + } + this.received = {}; + this.requestReply = true; + var srcList = Object.keys(this.doc.presence); + var changedSrcList = []; + for (var i = 0; i < srcList.length; i++) { + var src = srcList[i]; + if (src && this._setPresence(src, null)) { + changedSrcList.push(src); + } + } + this._emitPresence(changedSrcList, false); +}; + +// If emit is true and presence has changed, emits a presence event. +// Returns true, if presence has changed. Otherwise false. +DocPresence.prototype._setPresence = function(src, data, emit) { + if (data == null) { + if (this.doc.presence[src] == null) return false; + delete this.doc.presence[src]; + } else { + var isPresenceEqual = + this.doc.presence[src] === data || + (this.doc.type.comparePresence && this.doc.type.comparePresence(this.doc.presence[src], data)); + if (isPresenceEqual) return false; + this.doc.presence[src] = data; + } + if (emit) this._emitPresence([src], true); + return true; +}; + +DocPresence.prototype._emitPresence = function(srcList, submitted) { + if (srcList && srcList.length > 0) { + var doc = this.doc; + process.nextTick(function() { + doc.emit('presence', srcList, submitted); + }); + } +}; + +DocPresence.prototype.cacheOp = function(message) { + var op = { + src: message.src, + time: Date.now(), + create: !!message.create, + op: message.op, + del: !!message.del + }; + // Remove the old ops. + var oldOpTime = Date.now() - this.cachedOpsTimeout; + var i; + for (i = 0; i < this.cachedOps.length; i++) { + if (this.cachedOps[i].time >= oldOpTime) { + break; + } + } + if (i > 0) { + this.cachedOps.splice(0, i); + } + + // Cache the new op. + this.cachedOps.push(op); +}; + +// If there are no pending ops, this method sends the pending presence data, if possible. +DocPresence.prototype.flushPresence = function() { + if (!this.inflight && this.pending) { + this.inflight = this.pending; + this.inflightSeq = this.doc.connection.seq; + this.pending = null; + this.doc.connection.sendPresence(this.doc, this.doc.presence[''], this.requestReply); + this.requestReply = false; + } +}; + +DocPresence.prototype.destroyPresence = function() { + this.received = {}; + this.clearCachedOps(); +}; + +DocPresence.prototype.clearCachedOps = function() { + this.cachedOps.length = 0; +}; + +// Reset presence-related properties. +DocPresence.prototype.hardRollbackPresence = function() { + this.inflight = null; + this.inflightSeq = 0; + this.pending = null; + this.cachedOps.length = 0; + this.received = {}; + this.requestReply = true; + + var srcList = Object.keys(this.doc.presence); + var changedSrcList = []; + for (var i = 0; i < srcList.length; i++) { + var src = srcList[i]; + if (this._setPresence(src, null)) { + changedSrcList.push(src); + } + } + this._emitPresence(changedSrcList, false); +}; + +DocPresence.prototype.hasPendingPresence = function() { + return this.inflight || this.pending; +}; + +DocPresence.prototype.getPendingPresence = function() { + var pendingPresence = []; + if (this.inflight) pendingPresence.push(this.inflight); + if (this.pending) pendingPresence.push(this.pending); + return pendingPresence; +}; + + +/* + * Stateless Presence implementation of ConnectionPresence + * ------------------------------------------------------- + */ +function ConnectionPresence(connection) { + this.connection = connection; +} +ConnectionPresence.prototype = Object.create(presence.ConnectionPresence.prototype); + +ConnectionPresence.prototype.isPresenceMessage = isPresenceMessage; + +ConnectionPresence.prototype.handlePresenceMessage = function(err, message) { + var doc = this.connection.getExisting(message.c, message.d); + if (doc) doc._handlePresence(err, message); +}; + +ConnectionPresence.prototype.sendPresence = function(doc, data, requestReply) { + // Ensure the doc is registered so that it receives the reply message + this.connection._addDoc(doc); + var message = { + a: 'p', + c: doc.collection, + d: doc.id, + p: data, + v: doc.version || 0, + seq: this.connection.seq++ + }; + if (requestReply) { + message.r = true; + } + this.connection.send(message); +}; + + +/* + * Stateless Presence implementation of AgentPresence + * -------------------------------------------------- + */ +function AgentPresence(agent) { + this.agent = agent; + + // The max presence sequence number received from the client. + this.maxPresenceSeq = 0; +} +AgentPresence.prototype = Object.create(presence.AgentPresence.prototype); + +AgentPresence.prototype.isPresenceMessage = isPresenceMessage; + +AgentPresence.prototype.processPresenceData = function(data) { + if (data.a === 'p') { + // Send other clients' presence data + if (data.src !== this.agent.clientId) this.agent.send(data); + return true; + } +}; + +AgentPresence.prototype.createPresence = function(collection, id, data, version, requestReply, seq) { + return { + a: 'p', + src: this.agent.clientId, + seq: seq != null ? seq : this.maxPresenceSeq, + c: collection, + d: id, + p: data, + v: version, + r: requestReply + }; +}; + +AgentPresence.prototype.subscribeToStream = function(collection, id, stream) { + var agent = this.agent; + stream.on('end', function() { + agent.backend.sendPresence(agent._agentPresence.createPresence(collection, id)); + }); +}; + +AgentPresence.prototype.checkRequest = function(request) { + if (request.a === 'p') { + if (typeof request.c !== 'string') return 'Invalid collection'; + if (typeof request.d !== 'string') return 'Invalid id'; + if (typeof request.v !== 'number' || request.v < 0) return 'Invalid version'; + if (typeof request.seq !== 'number' || request.seq <= 0) return 'Invalid seq'; + if (typeof request.r !== 'undefined' && typeof request.r !== 'boolean') { + return 'Invalid "request reply" value'; + } + } +}; + +AgentPresence.prototype.handlePresenceMessage = function(request, callback) { + var presence = this.createPresence(request.c, request.d, request.p, request.v, request.r, request.seq); + if (presence.seq <= this.maxPresenceSeq) { + return process.nextTick(function() { + callback(new ShareDBError(4027, 'Presence data superseded')); + }); + } + this.maxPresenceSeq = presence.seq; + if (!this.agent.subscribedDocs[presence.c] || !this.agent.subscribedDocs[presence.c][presence.d]) { + return process.nextTick(function() { + callback(new ShareDBError(4026, [ + 'Cannot send presence. Not subscribed to document:', + presence.c, + presence.d + ].join(' '))); + }); + } + this.agent.backend.sendPresence(presence, function(err) { + if (err) return callback(err); + callback(null, {seq: presence.seq}); + }); +}; + + +/* + * Stateless Presence implementation of BackendPresence + * ---------------------------------------------------- + */ +function BackendPresence(backend) { + this.backend = backend; +} +BackendPresence.prototype = Object.create(presence.BackendPresence.prototype); + +BackendPresence.prototype.sendPresence = function(presence, callback) { + var channels = [this.backend.getDocChannel(presence.c, presence.d)]; + this.backend.pubsub.publish(channels, presence, callback); +}; + + +module.exports = { + DocPresence: DocPresence, + ConnectionPresence: ConnectionPresence, + AgentPresence: AgentPresence, + BackendPresence: BackendPresence +}; diff --git a/lib/projections.js b/lib/projections.js index 48baa5ae7..d9a48ea03 100644 --- a/lib/projections.js +++ b/lib/projections.js @@ -41,7 +41,7 @@ function projectEdit(fields, op) { var path = c.p; if (path.length === 0) { - var newC = {p:[]}; + var newC = {p: []}; if (c.od !== undefined || c.oi !== undefined) { if (c.od !== undefined) { diff --git a/lib/query-emitter.js b/lib/query-emitter.js index f79c9a684..fe5c5b01a 100644 --- a/lib/query-emitter.js +++ b/lib/query-emitter.js @@ -21,10 +21,10 @@ function QueryEmitter(request, stream, ids, extra) { this.canPollDoc = this.db.canPollDoc(this.collection, this.query); this.pollDebounce = (typeof this.options.pollDebounce === 'number') ? this.options.pollDebounce : - (typeof this.db.pollDebounce === 'number') ? this.db.pollDebounce : 0; + (typeof this.db.pollDebounce === 'number') ? this.db.pollDebounce : 0; this.pollInterval = (typeof this.options.pollInterval === 'number') ? this.options.pollInterval : - (typeof this.db.pollInterval === 'number') ? this.db.pollInterval : 0; + (typeof this.db.pollInterval === 'number') ? this.db.pollInterval : 0; this._polling = false; this._pendingPoll = null; @@ -187,13 +187,19 @@ QueryEmitter.prototype.queryPoll = function(callback) { if (err) return emitter._finishPoll(err, callback, pending); var snapshots = emitter.backend._getSnapshotsFromMap(inserted, snapshotMap); var snapshotType = emitter.backend.SNAPSHOT_TYPES.current; - emitter.backend._sanitizeSnapshots(emitter.agent, emitter.snapshotProjection, emitter.collection, snapshots, snapshotType, function(err) { - if (err) return emitter._finishPoll(err, callback, pending); - emitter._emitTiming('queryEmitter.pollGetSnapshotBulk', start); - var diff = mapDiff(idsDiff, snapshotMap); - emitter.onDiff(diff); - emitter._finishPoll(err, callback, pending); - }); + emitter.backend._sanitizeSnapshots( + emitter.agent, + emitter.snapshotProjection, + emitter.collection, + snapshots, + snapshotType, + function(err) { + if (err) return emitter._finishPoll(err, callback, pending); + emitter._emitTiming('queryEmitter.pollGetSnapshotBulk', start); + var diff = mapDiff(idsDiff, snapshotMap); + emitter.onDiff(diff); + emitter._finishPoll(err, callback, pending); + }); }); } else { emitter.onDiff(idsDiff); @@ -236,12 +242,18 @@ QueryEmitter.prototype.queryPollDoc = function(id, callback) { if (err) return callback(err); var snapshots = [snapshot]; var snapshotType = emitter.backend.SNAPSHOT_TYPES.current; - emitter.backend._sanitizeSnapshots(emitter.agent, emitter.snapshotProjection, emitter.collection, snapshots, snapshotType, function(err) { - if (err) return callback(err); - emitter.onDiff([new arraydiff.InsertDiff(index, snapshots)]); - emitter._emitTiming('queryEmitter.pollDocGetSnapshot', start); - callback(); - }); + emitter.backend._sanitizeSnapshots( + emitter.agent, + emitter.snapshotProjection, + emitter.collection, + snapshots, + snapshotType, + function(err) { + if (err) return callback(err); + emitter.onDiff([new arraydiff.InsertDiff(index, snapshots)]); + emitter._emitTiming('queryEmitter.pollDocGetSnapshot', start); + callback(); + }); }); return; } diff --git a/lib/submit-request.js b/lib/submit-request.js index ddd9f6f13..068be3123 100644 --- a/lib/submit-request.js +++ b/lib/submit-request.js @@ -54,7 +54,6 @@ SubmitRequest.prototype.submit = function(callback) { request._addSnapshotMeta(); if (op.v == null) { - if (op.create && snapshot.type && op.src) { // If the document was already created by another op, we will return a // 'Document already exists' error in response and fail to submit this @@ -147,28 +146,34 @@ SubmitRequest.prototype.commit = function(callback) { if (err) return callback(err); // Try committing the operation and snapshot to the database atomically - backend.db.commit(request.collection, request.id, request.op, request.snapshot, request.options, function(err, succeeded) { - if (err) return callback(err); - if (!succeeded) { - // Between our fetch and our call to commit, another client committed an - // operation. We expect this to be relatively infrequent but normal. - return request.retry(callback); - } - if (!request.suppressPublish) { - var op = request.op; - op.c = request.collection; - op.d = request.id; - op.m = undefined; - // Needed for agent to detect if it can ignore sending the op back to - // the client that submitted it in subscriptions - if (request.collection !== request.index) op.i = request.index; - backend.pubsub.publish(request.channels, op); - } - if (request._shouldSaveMilestoneSnapshot(request.snapshot)) { - request.backend.milestoneDb.saveMilestoneSnapshot(request.collection, request.snapshot); - } - callback(); - }); + backend.db.commit( + request.collection, + request.id, + request.op, + request.snapshot, + request.options, + function(err, succeeded) { + if (err) return callback(err); + if (!succeeded) { + // Between our fetch and our call to commit, another client committed an + // operation. We expect this to be relatively infrequent but normal. + return request.retry(callback); + } + if (!request.suppressPublish) { + var op = request.op; + op.c = request.collection; + op.d = request.id; + op.m = undefined; + // Needed for agent to detect if it can ignore sending the op back to + // the client that submitted it in subscriptions + if (request.collection !== request.index) op.i = request.index; + backend.pubsub.publish(request.channels, op); + } + if (request._shouldSaveMilestoneSnapshot(request.snapshot)) { + request.backend.milestoneDb.saveMilestoneSnapshot(request.collection, request.snapshot); + } + callback(); + }); }); }; @@ -224,7 +229,7 @@ SubmitRequest.prototype._addSnapshotMeta = function() { meta.mtime = this.start; }; -SubmitRequest.prototype._shouldSaveMilestoneSnapshot = function (snapshot) { +SubmitRequest.prototype._shouldSaveMilestoneSnapshot = function(snapshot) { // If the flag is null, it's not been overridden by the consumer, so apply the interval if (this.saveMilestoneSnapshot === null) { return snapshot && snapshot.v % this.backend.milestoneDb.interval === 0; @@ -252,7 +257,10 @@ SubmitRequest.prototype.projectionError = function() { }; // Fatal internal errors: SubmitRequest.prototype.missingOpsError = function() { - return {code: 5001, message: 'Op submit failed. DB missing ops needed to transform it up to the current snapshot version'}; + return { + code: 5001, + message: 'Op submit failed. DB missing ops needed to transform it up to the current snapshot version' + }; }; SubmitRequest.prototype.versionDuringTransformError = function() { return {code: 5002, message: 'Op submit failed. Versions mismatched during op transform'}; diff --git a/lib/util.js b/lib/util.js index 6ca346ffe..4c4783430 100644 --- a/lib/util.js +++ b/lib/util.js @@ -8,17 +8,29 @@ exports.hasKeys = function(object) { }; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isInteger#Polyfill -exports.isInteger = Number.isInteger || function (value) { +exports.isInteger = Number.isInteger || function(value) { return typeof value === 'number' && isFinite(value) && Math.floor(value) === value; }; -exports.isValidVersion = function (version) { +exports.isValidVersion = function(version) { if (version === null) return true; return exports.isInteger(version) && version >= 0; }; -exports.isValidTimestamp = function (timestamp) { +exports.isValidTimestamp = function(timestamp) { return exports.isValidVersion(timestamp); }; + +exports.callEach = function(callbacks, err) { + var called = false; + for (var i = 0; i < callbacks.length; i++) { + var callback = callbacks[i]; + if (callback) { + callback(err); + called = true; + } + } + return called; +}; diff --git a/package.json b/package.json index 76dc3f878..b30da8173 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sharedb", - "version": "1.0.0-beta.22", + "version": "1.0.0-beta.23", "description": "JSON OT database backend", "main": "lib/index.js", "dependencies": { @@ -13,17 +13,19 @@ }, "devDependencies": { "coveralls": "^2.11.8", + "eslint": "^5.16.0", + "eslint-config-google": "^0.13.0", "expect.js": "^0.3.1", "istanbul": "^0.4.2", - "jshint": "^2.9.2", "lolex": "^3.0.0", "mocha": "^5.2.0", "sinon": "^6.1.5" }, "scripts": { - "test": "./node_modules/.bin/mocha && npm run jshint", + "test": "./node_modules/.bin/mocha && npm run lint", "test-cover": "node_modules/istanbul/lib/cli.js cover node_modules/mocha/bin/_mocha", - "jshint": "./node_modules/.bin/jshint lib/*.js test/*.js" + "lint": "./node_modules/.bin/eslint --ignore-path .gitignore '**/*.js'", + "lint:fix": "npm run lint -- --fix" }, "repository": { "type": "git", diff --git a/test/backend.js b/test/backend.js new file mode 100644 index 000000000..991c6716b --- /dev/null +++ b/test/backend.js @@ -0,0 +1,104 @@ +var Backend = require('../lib/backend'); +var expect = require('expect.js'); + +describe('Backend', function() { + var backend; + + beforeEach(function() { + backend = new Backend(); + }); + + afterEach(function(done) { + backend.close(done); + }); + + describe('a simple document', function() { + beforeEach(function(done) { + var doc = backend.connect().get('books', '1984'); + doc.create({title: '1984'}, function(error) { + if (error) return done(error); + doc.submitOp({p: ['author'], oi: 'George Orwell'}, done); + }); + }); + + describe('getOps', function() { + it('fetches all the ops', function(done) { + backend.getOps(null, 'books', '1984', 0, null, function(error, ops) { + if (error) return done(error); + expect(ops).to.have.length(2); + expect(ops[0].create.data).to.eql({title: '1984'}); + expect(ops[1].op).to.eql([{p: ['author'], oi: 'George Orwell'}]); + done(); + }); + }); + + it('fetches the ops with metadata', function(done) { + var options = { + opsOptions: {metadata: true} + }; + backend.getOps(null, 'books', '1984', 0, null, options, function(error, ops) { + if (error) return done(error); + expect(ops).to.have.length(2); + expect(ops[0].m).to.be.ok(); + expect(ops[1].m).to.be.ok(); + done(); + }); + }); + }); + + describe('fetch', function() { + it('fetches the document', function(done) { + backend.fetch(null, 'books', '1984', function(error, doc) { + if (error) return done(error); + expect(doc.data).to.eql({ + title: '1984', + author: 'George Orwell' + }); + done(); + }); + }); + + it('fetches the document with metadata', function(done) { + var options = { + snapshotOptions: {metadata: true} + }; + backend.fetch(null, 'books', '1984', options, function(error, doc) { + if (error) return done(error); + expect(doc.m).to.be.ok(); + done(); + }); + }); + }); + + describe('subscribe', function() { + it('subscribes to the document', function(done) { + backend.subscribe(null, 'books', '1984', null, function(error, stream, snapshot) { + if (error) return done(error); + expect(stream.open).to.be(true); + expect(snapshot.data).to.eql({ + title: '1984', + author: 'George Orwell' + }); + var op = {op: {p: ['publication'], oi: 1949}}; + stream.on('data', function(data) { + expect(data.op).to.eql(op.op); + done(); + }); + backend.submit(null, 'books', '1984', op, null, function(error) { + if (error) return done(error); + }); + }); + }); + + it('does not support subscribing to the document with options', function(done) { + var options = { + opsOptions: {metadata: true} + }; + backend.subscribe(null, 'books', '1984', null, options, function(error) { + expect(error.code).to.be(4025); + done(); + }); + }); + }); + }); +}); diff --git a/test/client/connection.js b/test/client/connection.js index aa00e2db8..b38ff53fa 100644 --- a/test/client/connection.js +++ b/test/client/connection.js @@ -3,7 +3,6 @@ var Backend = require('../../lib/backend'); var Connection = require('../../lib/client/connection'); describe('client connection', function() { - beforeEach(function() { this.backend = new Backend(); }); @@ -42,8 +41,8 @@ describe('client connection', function() { request.agent.close(); next(); }); - var connection = this.backend.connect(); - }) + this.backend.connect(); + }); it('emits stopped event on call to agent.close()', function(done) { this.backend.use('connect', function(request, next) { @@ -105,7 +104,7 @@ describe('client connection', function() { var backend = this.backend; var connection = backend.connect(); connection.on('connected', function() { - connection.socket.stream.emit('close') + connection.socket.stream.emit('close'); expect(backend.agentsCount).equal(0); done(); }); @@ -115,8 +114,8 @@ describe('client connection', function() { var backend = this.backend; var connection = backend.connect(); connection.on('connected', function() { - connection.socket.stream.emit('end') - connection.socket.stream.emit('close') + connection.socket.stream.emit('end'); + connection.socket.stream.emit('close'); expect(backend.agentsCount).equal(0); done(); }); @@ -128,39 +127,38 @@ describe('client connection', function() { next({message: 'Error'}); }); expect(backend.agentsCount).equal(0); - var connection = backend.connect(); + backend.connect(); expect(backend.agentsCount).equal(0); }); }); describe('state management using setSocket', function() { - - it('initial connection.state is connecting, if socket.readyState is CONNECTING', function () { - // https://html.spec.whatwg.org/multipage/web-sockets.html#dom-websocket-connecting - var socket = {readyState: 0}; - var connection = new Connection(socket); - expect(connection.state).equal('connecting'); + it('initial connection.state is connecting, if socket.readyState is CONNECTING', function() { + // https://html.spec.whatwg.org/multipage/web-sockets.html#dom-websocket-connecting + var socket = {readyState: 0}; + var connection = new Connection(socket); + expect(connection.state).equal('connecting'); }); - it('initial connection.state is connecting, if socket.readyState is OPEN', function () { - // https://html.spec.whatwg.org/multipage/web-sockets.html#dom-websocket-open - var socket = {readyState: 1}; - var connection = new Connection(socket); - expect(connection.state).equal('connecting'); + it('initial connection.state is connecting, if socket.readyState is OPEN', function() { + // https://html.spec.whatwg.org/multipage/web-sockets.html#dom-websocket-open + var socket = {readyState: 1}; + var connection = new Connection(socket); + expect(connection.state).equal('connecting'); }); - it('initial connection.state is disconnected, if socket.readyState is CLOSING', function () { - // https://html.spec.whatwg.org/multipage/web-sockets.html#dom-websocket-closing - var socket = {readyState: 2}; - var connection = new Connection(socket); - expect(connection.state).equal('disconnected'); + it('initial connection.state is disconnected, if socket.readyState is CLOSING', function() { + // https://html.spec.whatwg.org/multipage/web-sockets.html#dom-websocket-closing + var socket = {readyState: 2}; + var connection = new Connection(socket); + expect(connection.state).equal('disconnected'); }); - it('initial connection.state is disconnected, if socket.readyState is CLOSED', function () { - // https://html.spec.whatwg.org/multipage/web-sockets.html#dom-websocket-closed - var socket = {readyState: 3}; - var connection = new Connection(socket); - expect(connection.state).equal('disconnected'); + it('initial connection.state is disconnected, if socket.readyState is CLOSED', function() { + // https://html.spec.whatwg.org/multipage/web-sockets.html#dom-websocket-closed + var socket = {readyState: 3}; + var connection = new Connection(socket); + expect(connection.state).equal('disconnected'); }); it('initial state is connecting', function() { @@ -198,7 +196,5 @@ describe('client connection', function() { done(); }); }); - }); - }); diff --git a/test/client/doc.js b/test/client/doc.js index 738c3055a..9b5316e3f 100644 --- a/test/client/doc.js +++ b/test/client/doc.js @@ -1,9 +1,8 @@ var Backend = require('../../lib/backend'); var expect = require('expect.js'); -var util = require('../util') +var util = require('../util'); describe('Doc', function() { - beforeEach(function() { this.backend = new Backend(); this.connection = this.backend.connect(); @@ -51,7 +50,6 @@ describe('Doc', function() { }); describe('applyStack', function() { - beforeEach(function(done) { this.doc = this.connection.get('dogs', 'fido'); this.doc2 = this.backend.connect().get('dogs', 'fido'); @@ -221,24 +219,23 @@ describe('Doc', function() { verifyConsistency(doc, doc2, doc3, handlers, done); }); }); - }); describe('submitting ops in callbacks', function() { - beforeEach(function () { + beforeEach(function() { this.doc = this.connection.get('dogs', 'scooby'); }); it('succeeds with valid op', function(done) { var doc = this.doc; - doc.create({ name: 'Scooby Doo' }, function(error) { + doc.create({name: 'Scooby Doo'}, function(error) { expect(error).to.not.be.ok(); // Build valid op that deletes a substring at index 0 of name. - var textOpComponents = [{ p: 0, d: 'Scooby '}]; - var op = [{ p: ['name'], t: 'text0', o: textOpComponents }]; + var textOpComponents = [{p: 0, d: 'Scooby '}]; + var op = [{p: ['name'], t: 'text0', o: textOpComponents}]; doc.submitOp(op, function(error) { if (error) return done(error); - expect(doc.data).eql({ name: 'Doo' }); + expect(doc.data).eql({name: 'Doo'}); done(); }); }); @@ -246,11 +243,11 @@ describe('Doc', function() { it('fails with invalid op', function(done) { var doc = this.doc; - doc.create({ name: 'Scooby Doo' }, function(error) { + doc.create({name: 'Scooby Doo'}, function(error) { expect(error).to.not.be.ok(); // Build op that tries to delete an invalid substring at index 0 of name. - var textOpComponents = [{ p: 0, d: 'invalid'}]; - var op = [{ p: ['name'], t: 'text0', o: textOpComponents }]; + var textOpComponents = [{p: 0, d: 'invalid'}]; + var op = [{p: ['name'], t: 'text0', o: textOpComponents}]; doc.submitOp(op, function(error) { expect(error).to.be.ok(); done(); @@ -259,48 +256,48 @@ describe('Doc', function() { }); }); - describe('submitting an invalid op', function () { + describe('submitting an invalid op', function() { var doc; var invalidOp; var validOp; - beforeEach(function (done) { + beforeEach(function(done) { // This op is invalid because we try to perform a list deletion // on something that isn't a list invalidOp = {p: ['name'], ld: 'Scooby'}; - validOp = {p:['snacks'], oi: true}; + validOp = {p: ['snacks'], oi: true}; doc = this.connection.get('dogs', 'scooby'); - doc.create({ name: 'Scooby' }, function (error) { + doc.create({name: 'Scooby'}, function(error) { if (error) return done(error); doc.whenNothingPending(done); }); }); - it('returns an error to the submitOp callback', function (done) { - doc.submitOp(invalidOp, function (error) { + it('returns an error to the submitOp callback', function(done) { + doc.submitOp(invalidOp, function(error) { expect(error.message).to.equal('Referenced element not a list'); done(); }); }); - it('rolls the doc back to a usable state', function (done) { + it('rolls the doc back to a usable state', function(done) { util.callInSeries([ - function (next) { - doc.submitOp(invalidOp, function (error) { + function(next) { + doc.submitOp(invalidOp, function(error) { expect(error).to.be.ok(); next(); }); }, - function (next) { + function(next) { doc.whenNothingPending(next); }, - function (next) { + function(next) { expect(doc.data).to.eql({name: 'Scooby'}); doc.submitOp(validOp, next); }, - function (next) { + function(next) { expect(doc.data).to.eql({name: 'Scooby', snacks: true}); next(); }, @@ -308,7 +305,7 @@ describe('Doc', function() { ]); }); - it('rescues an irreversible op collision', function (done) { + it('rescues an irreversible op collision', function(done) { // This test case attempts to reconstruct the following corner case, with // two independent references to the same document. We submit two simultaneous, but // incompatible operations (eg one of them changes the data structure the other op is @@ -321,9 +318,9 @@ describe('Doc', function() { var pauseSubmit = false; var fireSubmit; - this.backend.use('submit', function (request, callback) { + this.backend.use('submit', function(request, callback) { if (pauseSubmit) { - fireSubmit = function () { + fireSubmit = function() { pauseSubmit = false; callback(); }; @@ -334,26 +331,26 @@ describe('Doc', function() { }); util.callInSeries([ - function (next) { + function(next) { doc1.create({colours: ['white']}, next); }, - function (next) { + function(next) { doc1.whenNothingPending(next); }, - function (next) { + function(next) { doc2.fetch(next); }, - function (next) { + function(next) { doc2.whenNothingPending(next); }, // Both documents start off at the same v1 state, with colours as a list - function (next) { + function(next) { expect(doc1.data).to.eql({colours: ['white']}); expect(doc2.data).to.eql({colours: ['white']}); next(); }, // doc1 successfully submits an op which changes our list into a string in v2 - function (next) { + function(next) { doc1.submitOp({p: ['colours'], oi: 'white,black'}, next); }, // This next step is a little fiddly. We abuse the middleware to pause the op submission and @@ -365,21 +362,21 @@ describe('Doc', function() { // 5. doc2 attempts to roll back the inflight op by turning a list insertion into a list deletion // 6. doc2 applies this list deletion to a field that is no longer a list // 7. type.apply throws, because this is an invalid op - function (next) { + function(next) { pauseSubmit = true; - doc2.submitOp({p: ['colours', '0'], li: 'black'}, function (error) { + doc2.submitOp({p: ['colours', '0'], li: 'black'}, function(error) { expect(error.message).to.equal('Referenced element not a list'); next(); }); - doc2.fetch(function (error) { + doc2.fetch(function(error) { if (error) return next(error); fireSubmit(); }); }, // Validate that - despite the error in doc2.submitOp - doc2 has been returned to a // workable state in v2 - function (next) { + function(next) { expect(doc1.data).to.eql({colours: 'white,black'}); expect(doc2.data).to.eql(doc1.data); doc2.submitOp({p: ['colours'], oi: 'white,black,red'}, next); diff --git a/test/client/number-type.js b/test/client/number-type.js index d25401ebc..fa95056e4 100644 --- a/test/client/number-type.js +++ b/test/client/number-type.js @@ -18,6 +18,6 @@ function apply(snapshot, op) { return snapshot + op; } -function transform(op1, op2, side) { +function transform(op1) { return op1; } diff --git a/test/client/pending.js b/test/client/pending.js index 4896440d4..2b22c7a08 100644 --- a/test/client/pending.js +++ b/test/client/pending.js @@ -2,7 +2,6 @@ var expect = require('expect.js'); var Backend = require('../../lib/backend'); describe('client connection', function() { - beforeEach(function() { this.backend = new Backend(); }); @@ -89,5 +88,4 @@ describe('client connection', function() { }); }); }); - }); diff --git a/test/client/presence-type.js b/test/client/presence-type.js new file mode 100644 index 000000000..7648d0e2a --- /dev/null +++ b/test/client/presence-type.js @@ -0,0 +1,78 @@ +// A simple type for testing presence, where: +// +// - snapshot is a list +// - operation is { index, value } -> insert value at index in snapshot +// - presence is { index } -> an index in the snapshot +exports.type = { + name: 'wrapped-presence-no-compare', + uri: 'http://sharejs.org/types/wrapped-presence-no-compare', + create: create, + apply: apply, + transform: transform, + createPresence: createPresence, + transformPresence: transformPresence +}; + +// The same as `exports.type` but implements `comparePresence`. +exports.type2 = { + name: 'wrapped-presence-with-compare', + uri: 'http://sharejs.org/types/wrapped-presence-with-compare', + create: create, + apply: apply, + transform: transform, + createPresence: createPresence, + transformPresence: transformPresence, + comparePresence: comparePresence +}; + +// The same as `exports.type` but `presence.index` is unwrapped. +exports.type3 = { + name: 'unwrapped-presence', + uri: 'http://sharejs.org/types/unwrapped-presence', + create: create, + apply: apply, + transform: transform, + createPresence: createPresence2, + transformPresence: transformPresence2 +}; + +function create(data) { + return data || []; +} + +function apply(snapshot, op) { + snapshot.splice(op.index, 0, op.value); + return snapshot; +} + +function transform(op1, op2, side) { + return op1.index < op2.index || (op1.index === op2.index && side === 'left') + ? op1 + : {index: op1.index + 1, value: op1.value}; +} + +function createPresence(data) { + return {index: (data && data.index) | 0}; +} + +function transformPresence(presence, op, isOwnOperation) { + return presence.index < op.index || (presence.index === op.index && !isOwnOperation) + ? presence + : {index: presence.index + 1}; +} + +function comparePresence(presence1, presence2) { + return presence1 === presence2 || + (presence1 == null && presence2 == null) || + (presence1 != null && presence2 != null && presence1.index === presence2.index); +} + +function createPresence2(data) { + return data | 0; +} + +function transformPresence2(presence, op, isOwnOperation) { + return presence < op.index || (presence === op.index && !isOwnOperation) + ? presence + : presence + 1; +} diff --git a/test/client/presence.js b/test/client/presence.js new file mode 100644 index 000000000..c5fa39ff1 --- /dev/null +++ b/test/client/presence.js @@ -0,0 +1,1492 @@ +var async = require('async'); +var lolex = require('lolex'); +var util = require('../util'); +var errorHandler = util.errorHandler; +var Backend = require('../../lib/backend'); +var presence = require('../../lib/presence'); +var dummyPresence = require('../../lib/presence/dummy'); +var statelessPresence = require('../../lib/presence/stateless'); +var ShareDBError = require('../../lib/error'); +var expect = require('expect.js'); +var types = require('../../lib/types'); +var presenceType = require('./presence-type'); +types.register(presenceType.type); +types.register(presenceType.type2); +types.register(presenceType.type3); + +describe('client presence', function() { + it('should use dummyPresence if presence option not provided', function() { + var backend = new Backend(); + var connection = backend.connect(); + var doc = connection.get('dogs', 'fido'); + expect(doc._docPresence instanceof dummyPresence.DocPresence).to.be(true); + }); + + it('should use presence option if provided', function() { + var backend = new Backend({presence: statelessPresence}); + var connection = backend.connect(); + var doc = connection.get('dogs', 'fido'); + expect(doc._docPresence instanceof statelessPresence.DocPresence).to.be(true); + }); + + it('DummyPresence should subclass Presence', function() { + expect(dummyPresence.DocPresence.prototype instanceof presence.DocPresence).to.be(true); + }); + + it('StatelessPresence should subclass Presence', function() { + expect(statelessPresence.DocPresence.prototype instanceof presence.DocPresence).to.be(true); + }); +}); + +[ + 'wrapped-presence-no-compare', + 'wrapped-presence-with-compare', + 'unwrapped-presence' +].forEach(function(typeName) { + function p(index) { + return typeName === 'unwrapped-presence' ? index : {index: index}; + } + + describe('client presence (' + typeName + ')', function() { + beforeEach(function() { + this.backend = new Backend({presence: statelessPresence}); + this.connection = this.backend.connect(); + this.connection2 = this.backend.connect(); + this.doc = this.connection.get('dogs', 'fido'); + this.doc2 = this.connection2.get('dogs', 'fido'); + }); + + afterEach(function(done) { + this.backend.close(done); + }); + + it('sends presence immediately', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc2.once('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql([]); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('sends presence after pending ops', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc.submitOp({index: 0, value: 'a'}, errorHandler(done)); + this.doc.submitOp({index: 1, value: 'b'}, errorHandler(done)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc2.once('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('waits for pending ops before processing future presence', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + // A hack to send presence for a future version. + this.doc.version += 2; + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), function(err) { + if (err) return done(err); + this.doc.version -= 2; + this.doc.submitOp({index: 0, value: 'a'}, errorHandler(done)); + this.doc.submitOp({index: 1, value: 'b'}, errorHandler(done)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (own ops, presence.index < op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitOp.bind(this.doc, {index: 1, value: 'b'}), + this.doc.submitOp.bind(this.doc, {index: 2, value: 'c'}), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b', 'c']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = ['a']; + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (own ops, presence.index === op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitOp.bind(this.doc, {index: 1, value: 'c'}), + this.doc.submitOp.bind(this.doc, {index: 1, value: 'b'}), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b', 'c']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = ['a']; + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (own ops, presence.index > op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitOp.bind(this.doc, {index: 0, value: 'b'}), + this.doc.submitOp.bind(this.doc, {index: 0, value: 'a'}), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b', 'c']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = ['c']; + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (non-own ops, presence.index < op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc2.submitOp.bind(this.doc2, {index: 1, value: 'b'}), + this.doc2.submitOp.bind(this.doc2, {index: 2, value: 'c'}), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b', 'c']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = ['a']; + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (non-own ops, presence.index === op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc2.submitOp.bind(this.doc2, {index: 1, value: 'c'}), + this.doc2.submitOp.bind(this.doc2, {index: 1, value: 'b'}), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b', 'c']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = ['a']; + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (non-own ops, presence.index > op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc2.submitOp.bind(this.doc2, {index: 0, value: 'b'}), + this.doc2.submitOp.bind(this.doc2, {index: 0, value: 'a'}), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b', 'c']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = ['c']; + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (transform against non-op)', function(allDone) { + async.series([ + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.create.bind(this.doc, [], typeName), + this.doc.submitOp.bind(this.doc, {index: 0, value: 'a'}), + this.doc.del.bind(this.doc), + this.doc.create.bind(this.doc, ['b'], typeName), + function(done) { + this.doc2.once('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['b']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['b']); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 2; + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('handles presence sent for earlier revisions (no cached ops)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitOp.bind(this.doc, {index: 1, value: 'b'}), + this.doc.submitOp.bind(this.doc, {index: 2, value: 'c'}), + function(done) { + this.doc2.once('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b', 'c']); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(0), errorHandler(done)); + }.bind(this), + function(done) { + this.doc2._docPresence.cachedOps = []; + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.data).to.eql(['a', 'b', 'c']); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + // A hack to send presence for an older version. + this.doc.version = 1; + this.doc.data = ['a']; + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against local delete', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(0)), + setTimeout, + function(done) { + this.doc.on('presence', function(srcList, submitted) { + expect(srcList.sort()).to.eql(['', this.connection2.id]); + expect(submitted).to.equal(false); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + done(); + }.bind(this)); + this.doc.del(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against non-local delete', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, [], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(0)), + setTimeout, + function(done) { + this.doc.on('presence', function(srcList, submitted) { + expect(srcList.sort()).to.eql(['', this.connection2.id]); + expect(submitted).to.equal(false); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + done(); + }.bind(this)); + this.doc2.del(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against local op (presence.index != op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a', 'c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(2)), + setTimeout, + function(done) { + this.doc.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection2.id]); + expect(submitted).to.equal(false); + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(3)); + done(); + }.bind(this)); + this.doc.submitOp({index: 1, value: 'b'}, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against non-local op (presence.index != op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a', 'c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(2)), + setTimeout, + function(done) { + this.doc.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection2.id]); + expect(submitted).to.equal(false); + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(3)); + done(); + }.bind(this)); + this.doc2.submitOp({index: 1, value: 'b'}, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against local op (presence.index == op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a', 'c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + setTimeout, + function(done) { + this.doc.on('presence', function(srcList, submitted) { + expect(srcList).to.eql(['']); + expect(submitted).to.equal(false); + expect(this.doc.presence['']).to.eql(p(2)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + done(); + }.bind(this)); + this.doc.submitOp({index: 1, value: 'b'}, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms presence against non-local op (presence.index == op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a', 'c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + setTimeout, + function(done) { + this.doc.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection2.id]); + expect(submitted).to.equal(false); + expect(this.doc.presence['']).to.eql(p(1)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(2)); + done(); + }.bind(this)); + this.doc2.submitOp({index: 1, value: 'b'}, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('caches local ops', function(allDone) { + var op = {index: 1, value: 'b'}; + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.submitOp.bind(this.doc, op), + this.doc.del.bind(this.doc), + function(done) { + expect(this.doc._docPresence.cachedOps.length).to.equal(3); + expect(this.doc._docPresence.cachedOps[0].create).to.equal(true); + expect(this.doc._docPresence.cachedOps[1].op).to.equal(op); + expect(this.doc._docPresence.cachedOps[2].del).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('caches non-local ops', function(allDone) { + var op = {index: 1, value: 'b'}; + async.series([ + this.doc2.subscribe.bind(this.doc2), + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.submitOp.bind(this.doc, op), + this.doc.del.bind(this.doc), + setTimeout, + function(done) { + expect(this.doc2._docPresence.cachedOps.length).to.equal(3); + expect(this.doc2._docPresence.cachedOps[0].create).to.equal(true); + expect(this.doc2._docPresence.cachedOps[1].op).to.eql(op); + expect(this.doc2._docPresence.cachedOps[2].del).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('expires cached ops', function(allDone) { + var clock = lolex.install(); + var op1 = {index: 1, value: 'b'}; + var op2 = {index: 2, value: 'b'}; + var op3 = {index: 3, value: 'b'}; + this.doc._docPresence.cachedOpsTimeout = 60; + async.series([ + // Cache 2 ops. + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.submitOp.bind(this.doc, op1), + function(done) { + expect(this.doc._docPresence.cachedOps.length).to.equal(2); + expect(this.doc._docPresence.cachedOps[0].create).to.equal(true); + expect(this.doc._docPresence.cachedOps[1].op).to.equal(op1); + done(); + }.bind(this), + + // Cache another op before the first 2 expire. + function(callback) { + setTimeout(callback, 30); + clock.next(); + }, + this.doc.submitOp.bind(this.doc, op2), + function(done) { + expect(this.doc._docPresence.cachedOps.length).to.equal(3); + expect(this.doc._docPresence.cachedOps[0].create).to.equal(true); + expect(this.doc._docPresence.cachedOps[1].op).to.equal(op1); + expect(this.doc._docPresence.cachedOps[2].op).to.equal(op2); + done(); + }.bind(this), + + // Cache another op after the first 2 expire. + function(callback) { + setTimeout(callback, 31); + clock.next(); + }, + this.doc.submitOp.bind(this.doc, op3), + function(done) { + expect(this.doc._docPresence.cachedOps.length).to.equal(2); + expect(this.doc._docPresence.cachedOps[0].op).to.equal(op2); + expect(this.doc._docPresence.cachedOps[1].op).to.equal(op3); + clock.uninstall(); + done(); + }.bind(this) + ], allDone); + }); + + it('requests reply presence when sending presence for the first time', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + if (srcList[0] === '') { + expect(srcList).to.eql(['']); + expect(submitted).to.equal(true); + expect(this.doc2.presence['']).to.eql(p(1)); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + } else { + expect(srcList).to.eql([this.connection.id]); + expect(this.doc2.presence['']).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2._docPresence.requestReply).to.equal(false); + done(); + } + }.bind(this)); + this.doc2.submitPresence(p(1), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('fails to submit presence for uncreated document: callback(err)', function(allDone) { + async.series([ + this.doc.subscribe.bind(this.doc), + function(done) { + this.doc.submitPresence(p(0), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4015); + done(); + }); + }.bind(this) + ], allDone); + }); + + it('fails to submit presence for uncreated document: emit(err)', function(allDone) { + async.series([ + this.doc.subscribe.bind(this.doc), + function(done) { + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4015); + done(); + }); + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('fails to submit presence, if type does not support presence: callback(err)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, {}), + this.doc.subscribe.bind(this.doc), + function(done) { + this.doc.submitPresence(p(0), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4028); + done(); + }); + }.bind(this) + ], allDone); + }); + + it('fails to submit presence, if type does not support presence: emit(err)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, {}), + this.doc.subscribe.bind(this.doc), + function(done) { + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4028); + done(); + }); + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('submits null presence', function(allDone) { + async.series([ + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, null) + ], allDone); + }); + + it('sends presence once, if submitted multiple times synchronously', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.presence[this.connection.id]).to.eql(p(2)); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(0), errorHandler(done)); + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc.submitPresence(p(2), errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('buffers presence until subscribed', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + setTimeout(function() { + this.doc.subscribe(function(err) { + if (err) return done(err); + expect(this.doc2.presence).to.eql({}); + }.bind(this)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('buffers presence when disconnected', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + this.connection.close(); + this.doc.submitPresence(p(1), errorHandler(done)); + process.nextTick(function() { + this.backend.connect(this.connection); + this.doc._docPresence.requestReply = false; + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('submits presence without a callback', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('hasPending is true, if there is pending presence', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + expect(this.doc.hasPending()).to.equal(false); + this.doc.submitPresence(p(0)); + expect(this.doc.hasPending()).to.equal(true); + expect(!!this.doc._docPresence.pending).to.equal(true); + expect(!!this.doc._docPresence.inflight).to.equal(false); + this.doc.whenNothingPending(done); + }.bind(this), + function(done) { + expect(this.doc.hasPending()).to.equal(false); + expect(!!this.doc._docPresence.pending).to.equal(false); + expect(!!this.doc._docPresence.inflight).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('hasPending is true, if there is inflight presence', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + expect(this.doc.hasPending()).to.equal(false); + this.doc.submitPresence(p(0)); + expect(this.doc.hasPending()).to.equal(true); + expect(!!this.doc._docPresence.pending).to.equal(true); + expect(!!this.doc._docPresence.inflight).to.equal(false); + process.nextTick(done); + }.bind(this), + function(done) { + expect(this.doc.hasPending()).to.equal(true); + expect(!!this.doc._docPresence.pending).to.equal(false); + expect(!!this.doc._docPresence.inflight).to.equal(true); + this.doc.whenNothingPending(done); + }.bind(this), + function(done) { + expect(this.doc.hasPending()).to.equal(false); + expect(!!this.doc._docPresence.pending).to.equal(false); + expect(!!this.doc._docPresence.inflight).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('receives presence after doc is deleted', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + setTimeout, + function(done) { + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + // The call to `del` transforms the presence and fires the event. + // The call to `submitPresence` does not fire the event because presence is already null. + expect(submitted).to.equal(false); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc2.del(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('clears peer presence on peer disconnection', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + setTimeout, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2.presence['']).to.eql(p(1)); + + var connectionId = this.connection.id; + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([connectionId]); + expect(submitted).to.equal(true); + expect(this.doc2.presence).to.not.have.key(connectionId); + expect(this.doc2.presence['']).to.eql(p(1)); + done(); + }.bind(this)); + this.connection.close(); + }.bind(this) + ], allDone); + }); + + it('clears peer presence on own disconnection', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + setTimeout, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2.presence['']).to.eql(p(1)); + + var connectionId = this.connection.id; + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([connectionId]); + expect(submitted).to.equal(false); + expect(this.doc2.presence).to.not.have.key(connectionId); + expect(this.doc2.presence['']).to.eql(p(1)); + done(); + }.bind(this)); + this.connection2.close(); + }.bind(this) + ], allDone); + }); + + it('clears peer presence on peer unsubscribe', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + setTimeout, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2.presence['']).to.eql(p(1)); + + var connectionId = this.connection.id; + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([connectionId]); + expect(submitted).to.equal(true); + expect(this.doc2.presence).to.not.have.key(connectionId); + expect(this.doc2.presence['']).to.eql(p(1)); + done(); + }.bind(this)); + this.doc.unsubscribe(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('clears peer presence on own unsubscribe', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + setTimeout, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + expect(this.doc2.presence['']).to.eql(p(1)); + + var connectionId = this.connection.id; + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([connectionId]); + expect(submitted).to.equal(false); + expect(this.doc2.presence).to.not.have.key(connectionId); + expect(this.doc2.presence['']).to.eql(p(1)); + done(); + }.bind(this)); + this.doc2.unsubscribe(errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('pauses inflight and pending presence on disconnect', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + var called = 0; + function callback(err) { + if (err) return done(err); + if (++called === 2) done(); + } + this.doc.submitPresence(p(0), callback); + process.nextTick(function() { + this.doc.submitPresence(p(1), callback); + this.connection.close(); + process.nextTick(function() { + this.backend.connect(this.connection); + }.bind(this)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('pauses inflight and pending presence on unsubscribe', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + var called = 0; + function callback(err) { + if (err) return done(err); + if (++called === 2) done(); + } + this.doc.submitPresence(p(0), callback); + process.nextTick(function() { + this.doc.submitPresence(p(1), callback); + this.doc.unsubscribe(errorHandler(done)); + process.nextTick(function() { + this.doc.subscribe(errorHandler(done)); + }.bind(this)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + it('re-synchronizes presence after reconnecting', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + setTimeout, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + this.connection.close(); + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + this.backend.connect(this.connection); + process.nextTick(done); + }.bind(this), + setTimeout, // wait for re-sync + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + process.nextTick(done); + }.bind(this) + ], allDone); + }); + + it('re-synchronizes presence after resubscribing', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(1)), + setTimeout, + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + this.doc.unsubscribe(errorHandler(done)); + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + this.doc.subscribe(done); + }.bind(this), + setTimeout, // wait for re-sync + function(done) { + expect(this.doc.presence['']).to.eql(p(0)); + expect(this.doc.presence[this.connection2.id]).to.eql(p(1)); + process.nextTick(done); + }.bind(this) + ], allDone); + }); + + it('transforms received presence against inflight/pending ops (presence.index < op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.presence[this.connection.id]).to.eql(p(0)); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(0), errorHandler(done)); + this.doc2.submitOp({index: 1, value: 'b'}, errorHandler(done)); + this.doc2.submitOp({index: 2, value: 'c'}, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms received presence against inflight/pending ops (presence.index === op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc2.submitOp({index: 1, value: 'c'}, errorHandler(done)); + this.doc2.submitOp({index: 1, value: 'b'}, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms received presence against inflight/pending ops (presence.index > op.index)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.presence[this.connection.id]).to.eql(p(3)); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(1), errorHandler(done)); + this.doc2.submitOp({index: 0, value: 'b'}, errorHandler(done)); + this.doc2.submitOp({index: 0, value: 'a'}, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms received presence against inflight delete', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + setTimeout, + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + expect(srcList).to.eql([this.connection.id]); + // The call to `del` transforms the presence and fires the event. + // The call to `submitPresence` does not fire the event because presence is already null. + expect(submitted).to.equal(false); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(2), errorHandler(done)); + this.doc2.del(errorHandler(done)); + this.doc2.create(['c'], typeName, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('transforms received presence against a pending delete', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + setTimeout, + function(done) { + var firstCall = true; + this.doc2.on('presence', function(srcList, submitted) { + if (firstCall) return firstCall = false; + expect(srcList).to.eql([this.connection.id]); + // The call to `del` transforms the presence and fires the event. + // The call to `submitPresence` does not fire the event because presence is already null. + expect(submitted).to.equal(false); + expect(this.doc2.presence).to.not.have.key(this.connection.id); + done(); + }.bind(this)); + this.doc._docPresence.requestReply = false; + this.doc.submitPresence(p(2), errorHandler(done)); + this.doc2.submitOp({index: 0, value: 'b'}, errorHandler(done)); + this.doc2.del(errorHandler(done)); + this.doc2.create(['c'], typeName, errorHandler(done)); + }.bind(this) + ], allDone); + }); + + it('emits the same presence only if comparePresence is not implemented (local presence)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(1)), + function(done) { + this.doc.on('presence', function(srcList, submitted) { + if (typeName === 'wrapped-presence-no-compare') { + expect(srcList).to.eql(['']); + expect(submitted).to.equal(true); + expect(this.doc.presence['']).to.eql(p(1)); + done(); + } else { + done(new Error('Unexpected presence event')); + } + }.bind(this)); + this.doc.submitPresence(p(1), typeName === 'wrapped-presence-no-compare' ? errorHandler(done) : done); + }.bind(this) + ], allDone); + }); + + it('emits the same presence only if comparePresence is not implemented (non-local presence)', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(1)), + setTimeout, + function(done) { + this.doc2.on('presence', function(srcList, submitted) { + if (typeName === 'wrapped-presence-no-compare') { + expect(srcList).to.eql([this.connection.id]); + expect(submitted).to.equal(true); + expect(this.doc2.presence[this.connection.id]).to.eql(p(1)); + done(); + } else { + done(new Error('Unexpected presence event')); + } + }.bind(this)); + this.doc.submitPresence(p(1), typeName === 'wrapped-presence-no-compare' ? errorHandler(done) : done); + }.bind(this) + ], allDone); + }); + + it('returns an error when not subscribed on the server', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + this.connection.sendUnsubscribe(this.doc); + process.nextTick(done); + }.bind(this), + function(done) { + this.doc.on('error', done); + this.doc.submitPresence(p(0), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4026); + done(); + }); + }.bind(this) + ], allDone); + }); + + it('emits an error when not subscribed on the server and no callback is provided', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + function(done) { + this.connection.sendUnsubscribe(this.doc); + process.nextTick(done); + }.bind(this), + function(done) { + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4026); + done(); + }); + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('returns an error when the server gets an old sequence number', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + setTimeout, + function(done) { + this.doc.on('error', done); + this.connection.seq--; + this.doc.submitPresence(p(1), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4027); + done(); + }); + }.bind(this) + ], allDone); + }); + + it('emits an error when the server gets an old sequence number and no callback is provided', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + setTimeout, + function(done) { + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(4027); + done(); + }); + this.connection.seq--; + this.doc.submitPresence(p(1)); + }.bind(this) + ], allDone); + }); + + it('does not publish presence unnecessarily', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + setTimeout, + function(done) { + this.doc.on('error', done); + // Decremented sequence number would cause the server to return an error, however, + // the message won't be sent to the server at all because the presence data has not changed. + this.connection.seq--; + this.doc.submitPresence(p(0), function(err) { + if (typeName === 'wrapped-presence-no-compare') { + // The OT type does not support comparing presence. + expect(err).to.be.an(Error); + expect(err.code).to.equal(4027); + } else { + expect(err).to.not.be.ok(); + } + done(); + }); + }.bind(this) + ], allDone); + }); + + it('does not publish presence unnecessarily when no callback is provided', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc.submitPresence.bind(this.doc, p(0)), + setTimeout, + function(done) { + this.doc.on('error', function(err) { + if (typeName === 'wrapped-presence-no-compare') { + // The OT type does not support comparing presence. + expect(err).to.be.an(Error); + expect(err.code).to.equal(4027); + done(); + } else { + done(err); + } + }); + // Decremented sequence number would cause the server to return an error, however, + // the message won't be sent to the server at all because the presence data has not changed. + this.connection.seq--; + this.doc.submitPresence(p(0)); + if (typeName !== 'wrapped-presence-no-compare') { + process.nextTick(done); + } + }.bind(this) + ], allDone); + }); + + it('returns an error when publishing presence fails', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + setTimeout, + function(done) { + var sendPresence = this.backend.sendPresence; + this.backend.sendPresence = function(presence, callback) { + if (presence.a === 'p' && presence.v != null) { + return callback(new ShareDBError(-1, 'Test publishing error')); + } + sendPresence.apply(this, arguments); + }; + this.doc.on('error', done); + this.doc.submitPresence(p(0), function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(-1); + done(); + }); + }.bind(this) + ], allDone); + }); + + it('emits an error when publishing presence fails and no callback is provided', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['c'], typeName), + this.doc.subscribe.bind(this.doc), + setTimeout, + function(done) { + var sendPresence = this.backend.sendPresence; + this.backend.sendPresence = function(presence, callback) { + if (presence.a === 'p' && presence.v != null) { + return callback(new ShareDBError(-1, 'Test publishing error')); + } + sendPresence.apply(this, arguments); + }; + this.doc.on('error', function(err) { + expect(err).to.be.an(Error); + expect(err.code).to.equal(-1); + done(); + }); + this.doc.submitPresence(p(0)); + }.bind(this) + ], allDone); + }); + + it('clears presence on hard rollback and emits an error', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a', 'b', 'c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(0)), + setTimeout, + function(done) { + // A hack to allow testing of hard rollback of both inflight and pending presence. + var doc = this.doc; + var _handlePresence = this.doc._handlePresence; + this.doc._handlePresence = function(err, presence) { + setTimeout(function() { + _handlePresence.call(doc, err, presence); + }); + }; + process.nextTick(done); + }.bind(this), + this.doc.submitPresence.bind(this.doc, p(1)), // presence.inflight + process.nextTick, // wait for "presence" event + this.doc.submitPresence.bind(this.doc, p(2)), // presence.pending + process.nextTick, // wait for "presence" event + function(done) { + var presenceEmitted = false; + this.doc.on('presence', function(srcList, submitted) { + expect(presenceEmitted).to.equal(false); + presenceEmitted = true; + expect(srcList.sort()).to.eql(['', this.connection2.id]); + expect(submitted).to.equal(false); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + }.bind(this)); + + this.doc.on('error', function(err) { + expect(presenceEmitted).to.equal(true); + expect(err).to.be.an(Error); + expect(err.code).to.equal(4000); + done(); + }); + + // send an invalid op + this.doc._submit({}, null); + }.bind(this) + ], allDone); + }); + + it('clears presence on hard rollback and executes all callbacks', function(allDone) { + async.series([ + this.doc.create.bind(this.doc, ['a', 'b', 'c'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + this.doc.submitPresence.bind(this.doc, p(0)), + this.doc2.submitPresence.bind(this.doc2, p(0)), + setTimeout, + function(done) { + // A hack to allow testing of hard rollback of both inflight and pending presence. + var doc = this.doc; + var _handlePresence = this.doc._handlePresence; + this.doc._handlePresence = function(err, presence) { + setTimeout(function() { + _handlePresence.call(doc, err, presence); + }); + }; + process.nextTick(done); + }.bind(this), + function(done) { + var presenceEmitted = false; + var called = 0; + function callback(err) { + expect(presenceEmitted).to.equal(true); + expect(err).to.be.an(Error); + expect(err.code).to.equal(4000); + if (++called < 3) return; + done(); + } + this.doc.submitPresence(p(1), callback); // presence.inflight + process.nextTick(function() { // wait for presence event + this.doc.submitPresence(p(2), callback); // presence.pending + process.nextTick(function() { // wait for presence event + this.doc.on('presence', function(srcList, submitted) { + expect(presenceEmitted).to.equal(false); + presenceEmitted = true; + expect(srcList.sort()).to.eql(['', this.connection2.id]); + expect(submitted).to.equal(false); + expect(this.doc.presence).to.not.have.key(''); + expect(this.doc.presence).to.not.have.key(this.connection2.id); + }.bind(this)); + this.doc.on('error', done); + + // send an invalid op + this.doc._submit({index: 3, value: 'b'}, null, callback); + }.bind(this)); + }.bind(this)); + }.bind(this) + ], allDone); + }); + + function testReceivedMessageExpiry(expireCache, reduceSequence) { + return function(allDone) { + var lastPresence = null; + var handleMessage = this.connection.handleMessage; + this.connection.handleMessage = function(message) { + if (message.a === 'p' && message.src) { + lastPresence = JSON.parse(JSON.stringify(message)); + } + return handleMessage.apply(this, arguments); + }; + if (expireCache) { + this.doc._docPresence.receivedTimeout = 0; + } + async.series([ + this.doc.create.bind(this.doc, ['a'], typeName), + this.doc.subscribe.bind(this.doc), + this.doc2.subscribe.bind(this.doc2), + function(done) { + this.doc2._docPresence.requestReply = false; + this.doc2.submitPresence(p(0), done); + }.bind(this), + setTimeout, + this.doc2.submitOp.bind(this.doc2, {index: 1, value: 'b'}), // forces processing of all received presence + setTimeout, + function(done) { + expect(this.doc.data).to.eql(['a', 'b']); + expect(this.doc.presence[this.connection2.id]).to.eql(p(0)); + // Replay the `lastPresence` with modified payload. + lastPresence.p = p(1); + lastPresence.v++; // +1 to account for the op above + if (reduceSequence) { + lastPresence.seq--; + } + this.connection.handleMessage(lastPresence); + process.nextTick(done); + }.bind(this), + function(done) { + expect(this.doc.presence[this.connection2.id]).to.eql(expireCache ? p(1) : p(0)); + process.nextTick(done); + }.bind(this) + ], allDone); + }; + } + + it('ignores an old message (cache not expired, presence.seq === cachedPresence.seq)', + testReceivedMessageExpiry(false, false)); + + it('ignores an old message (cache not expired, presence.seq < cachedPresence.seq)', + testReceivedMessageExpiry(false, true)); + + it('processes an old message (cache expired, presence.seq === cachedPresence.seq)', + testReceivedMessageExpiry(true, false)); + + it('processes an old message (cache expired, presence.seq < cachedPresence.seq)', + testReceivedMessageExpiry(true, true)); + + it('invokes presence.destroy inside doc.destroy', function(done) { + var presence = this.doc._docPresence; + presence.cachedOps = ['foo']; + presence.received = {bar: true}; + this.doc.destroy(function(err) { + if (err) return done(err); + expect(presence.cachedOps).to.eql([]); + expect(presence.received).to.eql({}); + done(); + }); + }); + }); +}); diff --git a/test/client/projections.js b/test/client/projections.js index fb1c993e5..77359563e 100644 --- a/test/client/projections.js +++ b/test/client/projections.js @@ -2,328 +2,331 @@ var expect = require('expect.js'); var util = require('../util'); module.exports = function() { -describe('client projections', function() { - - beforeEach(function(done) { - this.backend.addProjection('dogs_summary', 'dogs', {age: true, owner: true}); - this.connection = this.backend.connect(); - var data = {age: 3, color: 'gold', owner: {name: 'jim'}, litter: {count: 4}}; - this.connection.get('dogs', 'fido').create(data, done); - }); + describe('client projections', function() { + beforeEach(function(done) { + this.backend.addProjection('dogs_summary', 'dogs', {age: true, owner: true}); + this.connection = this.backend.connect(); + var data = {age: 3, color: 'gold', owner: {name: 'jim'}, litter: {count: 4}}; + this.connection.get('dogs', 'fido').create(data, done); + }); - ['fetch', 'subscribe'].forEach(function(method) { - it('snapshot ' + method, function(done) { - var connection2 = this.backend.connect(); - var fido = connection2.get('dogs_summary', 'fido'); - fido[method](function(err) { - if (err) return done(err); - expect(fido.data).eql({age: 3, owner: {name: 'jim'}}); - expect(fido.version).eql(1); - done(); + ['fetch', 'subscribe'].forEach(function(method) { + it('snapshot ' + method, function(done) { + var connection2 = this.backend.connect(); + var fido = connection2.get('dogs_summary', 'fido'); + fido[method](function(err) { + if (err) return done(err); + expect(fido.data).eql({age: 3, owner: {name: 'jim'}}); + expect(fido.version).eql(1); + done(); + }); }); }); - }); - ['createFetchQuery', 'createSubscribeQuery'].forEach(function(method) { - it('snapshot ' + method, function(done) { - var connection2 = this.backend.connect(); - connection2[method]('dogs_summary', {}, null, function(err, results) { - if (err) return done(err); - expect(results.length).eql(1); - expect(results[0].data).eql({age: 3, owner: {name: 'jim'}}); - expect(results[0].version).eql(1); - done(); + ['createFetchQuery', 'createSubscribeQuery'].forEach(function(method) { + it('snapshot ' + method, function(done) { + var connection2 = this.backend.connect(); + connection2[method]('dogs_summary', {}, null, function(err, results) { + if (err) return done(err); + expect(results.length).eql(1); + expect(results[0].data).eql({age: 3, owner: {name: 'jim'}}); + expect(results[0].version).eql(1); + done(); + }); }); }); - }); - function opTests(test) { - it('projected field', function(done) { - test.call(this, - {p: ['age'], na: 1}, - {age: 4, owner: {name: 'jim'}}, - done - ); - }); + function opTests(test) { + it('projected field', function(done) { + test.call(this, + {p: ['age'], na: 1}, + {age: 4, owner: {name: 'jim'}}, + done + ); + }); - it('non-projected field', function(done) { - test.call(this, - {p: ['color'], oi: 'brown', od: 'gold'}, - {age: 3, owner: {name: 'jim'}}, - done - ); - }); + it('non-projected field', function(done) { + test.call(this, + {p: ['color'], oi: 'brown', od: 'gold'}, + {age: 3, owner: {name: 'jim'}}, + done + ); + }); - it('parent field replace', function(done) { - test.call(this, - {p: [], oi: {age: 2, color: 'brown', owner: false}, od: {age: 3, color: 'gold', owner: {name: 'jim'}, litter: {count: 4}}}, - {age: 2, owner: false}, - done - ); - }); + it('parent field replace', function(done) { + test.call(this, + { + p: [], + oi: {age: 2, color: 'brown', owner: false}, + od: {age: 3, color: 'gold', owner: {name: 'jim'}, + litter: {count: 4}} + }, + {age: 2, owner: false}, + done + ); + }); - it('parent field set', function(done) { - test.call(this, - {p: [], oi: {age: 2, color: 'brown', owner: false}}, - {age: 2, owner: false}, - done - ); - }); + it('parent field set', function(done) { + test.call(this, + {p: [], oi: {age: 2, color: 'brown', owner: false}}, + {age: 2, owner: false}, + done + ); + }); - it('projected child field', function(done) { - test.call(this, - {p: ['owner', 'sex'], oi: 'male'}, - {age: 3, owner: {name: 'jim', sex: 'male'}}, - done - ); - }); + it('projected child field', function(done) { + test.call(this, + {p: ['owner', 'sex'], oi: 'male'}, + {age: 3, owner: {name: 'jim', sex: 'male'}}, + done + ); + }); - it('non-projected child field', function(done) { - test.call(this, - {p: ['litter', 'count'], na: 1}, - {age: 3, owner: {name: 'jim'}}, - done - ); - }); - } + it('non-projected child field', function(done) { + test.call(this, + {p: ['litter', 'count'], na: 1}, + {age: 3, owner: {name: 'jim'}}, + done + ); + }); + } - describe('op fetch', function() { - function test(op, expected, done) { - var connection = this.connection; - var connection2 = this.backend.connect(); - var fido = connection2.get('dogs_summary', 'fido'); - fido.fetch(function(err) { - if (err) return done(err); - connection.get('dogs', 'fido').submitOp(op, function(err) { + describe('op fetch', function() { + function test(op, expected, done) { + var connection = this.connection; + var connection2 = this.backend.connect(); + var fido = connection2.get('dogs_summary', 'fido'); + fido.fetch(function(err) { if (err) return done(err); - fido.fetch(function(err) { + connection.get('dogs', 'fido').submitOp(op, function(err) { if (err) return done(err); + fido.fetch(function(err) { + if (err) return done(err); + expect(fido.data).eql(expected); + expect(fido.version).eql(2); + done(); + }); + }); + }); + }; + opTests(test); + }); + + describe('op subscribe', function() { + function test(op, expected, done) { + var connection = this.connection; + var connection2 = this.backend.connect(); + var fido = connection2.get('dogs_summary', 'fido'); + fido.subscribe(function(err) { + if (err) return done(err); + fido.on('op', function() { expect(fido.data).eql(expected); expect(fido.version).eql(2); done(); }); + connection.get('dogs', 'fido').submitOp(op); }); - }); - }; - opTests(test); - }); + }; + opTests(test); + }); - describe('op subscribe', function() { - function test(op, expected, done) { - var connection = this.connection; - var connection2 = this.backend.connect(); - var fido = connection2.get('dogs_summary', 'fido'); - fido.subscribe(function(err) { - if (err) return done(err); - fido.on('op', function() { - expect(fido.data).eql(expected); - expect(fido.version).eql(2); - done(); + describe('op fetch query', function() { + function test(op, expected, done) { + var connection = this.connection; + var connection2 = this.backend.connect(); + var fido = connection2.get('dogs_summary', 'fido'); + fido.fetch(function(err) { + if (err) return done(err); + connection.get('dogs', 'fido').submitOp(op, function(err) { + if (err) return done(err); + connection2.createFetchQuery('dogs_summary', {}, null, function(err) { + if (err) return done(err); + expect(fido.data).eql(expected); + expect(fido.version).eql(2); + done(); + }); + }); }); - connection.get('dogs', 'fido').submitOp(op); - }); - }; - opTests(test); - }); + }; + opTests(test); + }); - describe('op fetch query', function() { - function test(op, expected, done) { - var connection = this.connection; - var connection2 = this.backend.connect(); - var fido = connection2.get('dogs_summary', 'fido'); - fido.fetch(function(err) { - if (err) return done(err); - connection.get('dogs', 'fido').submitOp(op, function(err) { + describe('op subscribe query', function() { + function test(op, expected, done) { + var connection = this.connection; + var connection2 = this.backend.connect(); + var fido = connection2.get('dogs_summary', 'fido'); + connection2.createSubscribeQuery('dogs_summary', {}, null, function(err) { if (err) return done(err); - connection2.createFetchQuery('dogs_summary', {}, null, function(err) { - if (err) return done(err); + fido.on('op', function() { expect(fido.data).eql(expected); expect(fido.version).eql(2); done(); }); + connection.get('dogs', 'fido').submitOp(op); }); + }; + opTests(test); + }); + + function queryUpdateTests(test) { + it('doc create', function(done) { + test.call(this, + function(connection, callback) { + var data = {age: 5, color: 'spotted', owner: {name: 'sue'}, litter: {count: 6}}; + connection.get('dogs', 'spot').create(data, callback); + }, + function(err, results) { + var sorted = util.sortById(results.slice()); + expect(sorted.length).eql(2); + expect(util.pluck(sorted, 'id')).eql(['fido', 'spot']); + expect(util.pluck(sorted, 'data')).eql([ + {age: 3, owner: {name: 'jim'}}, + {age: 5, owner: {name: 'sue'}} + ]); + done(); + } + ); }); - }; - opTests(test); - }); + } - describe('op subscribe query', function() { - function test(op, expected, done) { - var connection = this.connection; - var connection2 = this.backend.connect(); - var fido = connection2.get('dogs_summary', 'fido'); - connection2.createSubscribeQuery('dogs_summary', {}, null, function(err) { - if (err) return done(err); - fido.on('op', function() { - expect(fido.data).eql(expected); - expect(fido.version).eql(2); - done(); + describe('subscribe query', function() { + function test(trigger, callback) { + var connection = this.connection; + var connection2 = this.backend.connect(); + var query = connection2.createSubscribeQuery('dogs_summary', {}, null, function(err) { + if (err) return callback(err); + query.on('insert', function() { + callback(null, query.results); + }); + trigger(connection); }); - connection.get('dogs', 'fido').submitOp(op); - }); - }; - opTests(test); - }); + } + queryUpdateTests(test); + }); - function queryUpdateTests(test) { - it('doc create', function(done) { - test.call(this, - function(connection, callback) { - var data = {age: 5, color: 'spotted', owner: {name: 'sue'}, litter: {count: 6}}; - connection.get('dogs', 'spot').create(data, callback); - }, - function(err, results) { - var sorted = util.sortById(results.slice()); - expect(sorted.length).eql(2); - expect(util.pluck(sorted, 'id')).eql(['fido', 'spot']); - expect(util.pluck(sorted, 'data')).eql([ - {age: 3, owner: {name: 'jim'}}, - {age: 5, owner: {name: 'sue'}} - ]); - done(); - } - ); + describe('fetch query', function() { + function test(trigger, callback) { + var connection = this.connection; + var connection2 = this.backend.connect(); + trigger(connection, function(err) { + if (err) return callback(err); + connection2.createFetchQuery('dogs_summary', {}, null, callback); + }); + } + queryUpdateTests(test); }); - } - describe('subscribe query', function() { - function test(trigger, callback) { - var connection = this.connection; - var connection2 = this.backend.connect(); - var query = connection2.createSubscribeQuery('dogs_summary', {}, null, function(err) { - if (err) return callback(err); - query.on('insert', function() { - callback(null, query.results); + describe('submit on projected doc', function() { + function test(op, expected, done) { + var doc = this.connection.get('dogs', 'fido'); + var projected = this.backend.connect().get('dogs_summary', 'fido'); + projected.fetch(function(err) { + if (err) return done(err); + projected.submitOp(op, function(err) { + if (err) return done(err); + doc.fetch(function(err) { + if (err) return done(err); + expect(doc.data).eql(expected); + expect(doc.version).equal(2); + done(); + }); + }); }); - trigger(connection); + } + function testError(op, done) { + var doc = this.connection.get('dogs', 'fido'); + var projected = this.backend.connect().get('dogs_summary', 'fido'); + projected.fetch(function(err) { + if (err) return done(err); + projected.submitOp(op, function(err) { + expect(err).ok(); + doc.fetch(function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 3, color: 'gold', owner: {name: 'jim'}, litter: {count: 4}}); + expect(doc.version).equal(1); + done(); + }); + }); + }); + } + + it('can set on projected field', function(done) { + test.call(this, + {p: ['age'], na: 1}, + {age: 4, color: 'gold', owner: {name: 'jim'}, litter: {count: 4}}, + done + ); }); - } - queryUpdateTests(test); - }); - describe('fetch query', function() { - function test(trigger, callback) { - var connection = this.connection; - var connection2 = this.backend.connect(); - trigger(connection, function(err) { - if (err) return callback(err); - connection2.createFetchQuery('dogs_summary', {}, null, callback); + it('can set on child of projected field', function(done) { + test.call(this, + {p: ['owner', 'sex'], oi: 'male'}, + {age: 3, color: 'gold', owner: {name: 'jim', sex: 'male'}, litter: {count: 4}}, + done + ); + }); + + it('cannot set on non-projected field', function(done) { + testError.call(this, + {p: ['color'], od: 'gold', oi: 'tan'}, + done + ); + }); + + it('cannot set on root path of projected doc', function(done) { + testError.call(this, + {p: [], oi: null}, + done + ); }); - } - queryUpdateTests(test); - }); - describe('submit on projected doc', function() { - function test(op, expected, done) { - var doc = this.connection.get('dogs', 'fido'); - var projected = this.backend.connect().get('dogs_summary', 'fido'); - projected.fetch(function(err) { - if (err) return done(err); - projected.submitOp(op, function(err) { + it('can delete on projected doc', function(done) { + var doc = this.connection.get('dogs', 'fido'); + var projected = this.backend.connect().get('dogs_summary', 'fido'); + projected.fetch(function(err) { if (err) return done(err); - doc.fetch(function(err) { + projected.del(function(err) { if (err) return done(err); - expect(doc.data).eql(expected); - expect(doc.version).equal(2); - done(); + doc.fetch(function(err) { + if (err) return done(err); + expect(doc.data).eql(undefined); + expect(doc.version).equal(2); + done(); + }); }); }); }); - } - function testError(op, done) { - var doc = this.connection.get('dogs', 'fido'); - var projected = this.backend.connect().get('dogs_summary', 'fido'); - projected.fetch(function(err) { - if (err) return done(err); - projected.submitOp(op, function(err) { - expect(err).ok(); + + it('can create a projected doc with only projected fields', function(done) { + var doc = this.connection.get('dogs', 'spot'); + var projected = this.backend.connect().get('dogs_summary', 'spot'); + var data = {age: 5}; + projected.create(data, function(err) { + if (err) return done(err); doc.fetch(function(err) { if (err) return done(err); - expect(doc.data).eql({age: 3, color: 'gold', owner: {name: 'jim'}, litter: {count: 4}}); + expect(doc.data).eql({age: 5}); expect(doc.version).equal(1); done(); }); }); }); - } - - it('can set on projected field', function(done) { - test.call(this, - {p: ['age'], na: 1}, - {age: 4, color: 'gold', owner: {name: 'jim'}, litter: {count: 4}}, - done - ); - }); - - it('can set on child of projected field', function(done) { - test.call(this, - {p: ['owner', 'sex'], oi: 'male'}, - {age: 3, color: 'gold', owner: {name: 'jim', sex: 'male'}, litter: {count: 4}}, - done - ); - }); - - it('cannot set on non-projected field', function(done) { - testError.call(this, - {p: ['color'], od: 'gold', oi: 'tan'}, - done - ); - }); - - it('cannot set on root path of projected doc', function(done) { - testError.call(this, - {p: [], oi: null}, - done - ); - }); - it('can delete on projected doc', function(done) { - var doc = this.connection.get('dogs', 'fido'); - var projected = this.backend.connect().get('dogs_summary', 'fido'); - projected.fetch(function(err) { - if (err) return done(err); - projected.del(function(err) { - if (err) return done(err); + it('cannot create a projected doc with non-projected fields', function(done) { + var doc = this.connection.get('dogs', 'spot'); + var projected = this.backend.connect().get('dogs_summary', 'spot'); + var data = {age: 5, foo: 'bar'}; + projected.create(data, function(err) { + expect(err).ok(); doc.fetch(function(err) { if (err) return done(err); expect(doc.data).eql(undefined); - expect(doc.version).equal(2); + expect(doc.version).equal(0); done(); }); }); }); }); - - it('can create a projected doc with only projected fields', function(done) { - var doc = this.connection.get('dogs', 'spot'); - var projected = this.backend.connect().get('dogs_summary', 'spot'); - var data = {age: 5}; - projected.create(data, function(err) { - if (err) return done(err); - doc.fetch(function(err) { - if (err) return done(err); - expect(doc.data).eql({age: 5}); - expect(doc.version).equal(1); - done(); - }); - }); - }); - - it('cannot create a projected doc with non-projected fields', function(done) { - var doc = this.connection.get('dogs', 'spot'); - var projected = this.backend.connect().get('dogs_summary', 'spot'); - var data = {age: 5, foo: 'bar'}; - projected.create(data, function(err) { - expect(err).ok(); - doc.fetch(function(err) { - if (err) return done(err); - expect(doc.data).eql(undefined); - expect(doc.version).equal(0); - done(); - }); - }); - }); }); - -}); }; diff --git a/test/client/query-subscribe.js b/test/client/query-subscribe.js index 108fe0de5..166c859eb 100644 --- a/test/client/query-subscribe.js +++ b/test/client/query-subscribe.js @@ -3,444 +3,483 @@ var async = require('async'); var util = require('../util'); module.exports = function(options) { -var getQuery = options.getQuery; + var getQuery = options.getQuery; -describe('client query subscribe', function() { - before(function() { - if (!getQuery) return this.skip(); - this.matchAllDbQuery = getQuery({query: {}}); - }); - - it('creating a document updates a subscribed query', function(done) { - var connection = this.backend.connect(); - var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err) { - if (err) return done(err); - connection.get('dogs', 'fido').create({age: 3}); - }); - query.on('insert', function(docs, index) { - expect(util.pluck(docs, 'id')).eql(['fido']); - expect(util.pluck(docs, 'data')).eql([{age: 3}]); - expect(index).equal(0); - expect(util.pluck(query.results, 'id')).eql(['fido']); - expect(util.pluck(query.results, 'data')).eql([{age: 3}]); - done(); + describe('client query subscribe', function() { + before(function() { + if (!getQuery) return this.skip(); + this.matchAllDbQuery = getQuery({query: {}}); }); - }); - it('creating an additional document updates a subscribed query', function(done) { - var connection = this.backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + it('creating a document updates a subscribed query', function(done) { + var connection = this.backend.connect(); + var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err) { if (err) return done(err); - connection.get('dogs', 'taco').create({age: 2}); + connection.get('dogs', 'fido').create({age: 3}); }); query.on('insert', function(docs, index) { - expect(util.pluck(docs, 'id')).eql(['taco']); - expect(util.pluck(docs, 'data')).eql([{age: 2}]); - expect(query.results[index]).equal(docs[0]); - var results = util.sortById(query.results); - expect(util.pluck(results, 'id')).eql(['fido', 'spot', 'taco']); - expect(util.pluck(results, 'data')).eql([{age: 3}, {age: 5}, {age: 2}]); + expect(util.pluck(docs, 'id')).eql(['fido']); + expect(util.pluck(docs, 'data')).eql([{age: 3}]); + expect(index).equal(0); + expect(util.pluck(query.results, 'id')).eql(['fido']); + expect(util.pluck(query.results, 'data')).eql([{age: 3}]); done(); }); }); - }); - it('deleting a document updates a subscribed query', function(done) { - var connection = this.backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + it('creating an additional document updates a subscribed query', function(done) { + var connection = this.backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + } + ], function(err) { if (err) return done(err); - connection.get('dogs', 'fido').del(); - }); - query.on('remove', function(docs, index) { - expect(util.pluck(docs, 'id')).eql(['fido']); - expect(util.pluck(docs, 'data')).eql([undefined]); - expect(index).a('number'); - var results = util.sortById(query.results); - expect(util.pluck(results, 'id')).eql(['spot']); - expect(util.pluck(results, 'data')).eql([{age: 5}]); - done(); + var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + if (err) return done(err); + connection.get('dogs', 'taco').create({age: 2}); + }); + query.on('insert', function(docs, index) { + expect(util.pluck(docs, 'id')).eql(['taco']); + expect(util.pluck(docs, 'data')).eql([{age: 2}]); + expect(query.results[index]).equal(docs[0]); + var results = util.sortById(query.results); + expect(util.pluck(results, 'id')).eql(['fido', 'spot', 'taco']); + expect(util.pluck(results, 'data')).eql([{age: 3}, {age: 5}, {age: 2}]); + done(); + }); }); }); - }); - it('subscribed query does not get updated after destroyed', function(done) { - var connection = this.backend.connect(); - var connection2 = this.backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + it('deleting a document updates a subscribed query', function(done) { + var connection = this.backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + } + ], function(err) { if (err) return done(err); - query.destroy(function(err) { + var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { if (err) return done(err); - connection2.get('dogs', 'taco').create({age: 2}, done); + connection.get('dogs', 'fido').del(); + }); + query.on('remove', function(docs, index) { + expect(util.pluck(docs, 'id')).eql(['fido']); + expect(util.pluck(docs, 'data')).eql([undefined]); + expect(index).a('number'); + var results = util.sortById(query.results); + expect(util.pluck(results, 'id')).eql(['spot']); + expect(util.pluck(results, 'data')).eql([{age: 5}]); + done(); }); - }); - query.on('insert', function() { - done(); }); }); - }); - it('subscribed query does not get updated after connection is disconnected', function(done) { - var connection = this.backend.connect(); - var connection2 = this.backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + it('subscribed query does not get updated after destroyed', function(done) { + var connection = this.backend.connect(); + var connection2 = this.backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + } + ], function(err) { if (err) return done(err); - connection.close(); - connection2.get('dogs', 'taco').create({age: 2}, done); - }); - query.on('insert', function() { - done(); + var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + if (err) return done(err); + query.destroy(function(err) { + if (err) return done(err); + connection2.get('dogs', 'taco').create({age: 2}, done); + }); + }); + query.on('insert', function() { + done(); + }); }); }); - }); - it('subscribed query gets update after reconnecting', function(done) { - var backend = this.backend; - var connection = backend.connect(); - var connection2 = backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + it('subscribed query does not get updated after connection is disconnected', function(done) { + var connection = this.backend.connect(); + var connection2 = this.backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + } + ], function(err) { if (err) return done(err); - connection.close(); - connection2.get('dogs', 'taco').create({age: 2}); - process.nextTick(function() { - backend.connect(connection); + var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + if (err) return done(err); + connection.close(); + connection2.get('dogs', 'taco').create({age: 2}, done); + }); + query.on('insert', function() { + done(); }); - }); - query.on('insert', function() { - done(); }); }); - }); - it('subscribed query gets simultaneous insert and remove after reconnecting', function(done) { - var backend = this.backend; - var connection = backend.connect(); - var connection2 = backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + it('subscribed query gets update after reconnecting', function(done) { + var backend = this.backend; + var connection = backend.connect(); + var connection2 = backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + } + ], function(err) { if (err) return done(err); - connection.close(); - connection2.get('dogs', 'fido').fetch(function(err) { + var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { if (err) return done(err); - connection2.get('dogs', 'fido').del(); + connection.close(); connection2.get('dogs', 'taco').create({age: 2}); process.nextTick(function() { backend.connect(connection); }); }); - }); - var wait = 2; - function finish() { - if (--wait) return; - var results = util.sortById(query.results); - expect(util.pluck(results, 'id')).eql(['spot', 'taco']); - expect(util.pluck(results, 'data')).eql([{age: 5}, {age: 2}]); - done(); - } - query.on('insert', function(docs) { - expect(util.pluck(docs, 'id')).eql(['taco']); - expect(util.pluck(docs, 'data')).eql([{age: 2}]); - finish(); - }); - query.on('remove', function(docs) { - expect(util.pluck(docs, 'id')).eql(['fido']); - expect(util.pluck(docs, 'data')).eql([undefined]); - finish(); + query.on('insert', function() { + done(); + }); }); }); - }); - it('creating an additional document updates a subscribed query', function(done) { - var connection = this.backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + it('subscribed query gets simultaneous insert and remove after reconnecting', function(done) { + var backend = this.backend; + var connection = backend.connect(); + var connection2 = backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + } + ], function(err) { if (err) return done(err); - connection.get('dogs', 'taco').create({age: 2}); + var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + if (err) return done(err); + connection.close(); + connection2.get('dogs', 'fido').fetch(function(err) { + if (err) return done(err); + connection2.get('dogs', 'fido').del(); + connection2.get('dogs', 'taco').create({age: 2}); + process.nextTick(function() { + backend.connect(connection); + }); + }); + }); + var wait = 2; + function finish() { + if (--wait) return; + var results = util.sortById(query.results); + expect(util.pluck(results, 'id')).eql(['spot', 'taco']); + expect(util.pluck(results, 'data')).eql([{age: 5}, {age: 2}]); + done(); + } + query.on('insert', function(docs) { + expect(util.pluck(docs, 'id')).eql(['taco']); + expect(util.pluck(docs, 'data')).eql([{age: 2}]); + finish(); + }); + query.on('remove', function(docs) { + expect(util.pluck(docs, 'id')).eql(['fido']); + expect(util.pluck(docs, 'data')).eql([undefined]); + finish(); + }); }); - query.on('insert', function(docs, index) { - expect(util.pluck(docs, 'id')).eql(['taco']); - expect(util.pluck(docs, 'data')).eql([{age: 2}]); - expect(query.results[index]).equal(docs[0]); - var results = util.sortById(query.results); - expect(util.pluck(results, 'id')).eql(['fido', 'spot', 'taco']); - expect(util.pluck(results, 'data')).eql([{age: 3}, {age: 5}, {age: 2}]); - done(); + }); + + it('creating an additional document updates a subscribed query', function(done) { + var connection = this.backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + } + ], function(err) { + if (err) return done(err); + var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) { + if (err) return done(err); + connection.get('dogs', 'taco').create({age: 2}); + }); + query.on('insert', function(docs, index) { + expect(util.pluck(docs, 'id')).eql(['taco']); + expect(util.pluck(docs, 'data')).eql([{age: 2}]); + expect(query.results[index]).equal(docs[0]); + var results = util.sortById(query.results); + expect(util.pluck(results, 'id')).eql(['fido', 'spot', 'taco']); + expect(util.pluck(results, 'data')).eql([{age: 3}, {age: 5}, {age: 2}]); + done(); + }); }); }); - }); - it('pollDebounce option reduces subsequent poll interval', function(done) { - var connection = this.backend.connect(); - this.backend.db.canPollDoc = function() { - return false; - }; - var query = connection.createSubscribeQuery('items', this.matchAllDbQuery, {pollDebounce: 1000}); - var batchSizes = []; - var total = 0; + it('pollDebounce option reduces subsequent poll interval', function(done) { + var connection = this.backend.connect(); + this.backend.db.canPollDoc = function() { + return false; + }; + var query = connection.createSubscribeQuery('items', this.matchAllDbQuery, {pollDebounce: 1000}); + var batchSizes = []; + var total = 0; - query.on('insert', function(docs) { - batchSizes.push(docs.length); - total += docs.length; - if (total === 1) { + query.on('insert', function(docs) { + batchSizes.push(docs.length); + total += docs.length; + if (total === 1) { // first write received by client. we're debouncing. create 9 // more documents. - for (var i = 1; i < 10; i++) connection.get('items', i.toString()).create({}); - } - if (total === 10) { + for (var i = 1; i < 10; i++) connection.get('items', i.toString()).create({}); + } + if (total === 10) { // first document is its own batch; then subsequent creates // are debounced until after all other 9 docs are created - expect(batchSizes).eql([1, 9]); - done(); - } - }); + expect(batchSizes).eql([1, 9]); + done(); + } + }); - // create an initial document. this will lead to the 'insert' - // event firing the first time, while sharedb is definitely - // debouncing - connection.get('items', '0').create({}); - }); + // create an initial document. this will lead to the 'insert' + // event firing the first time, while sharedb is definitely + // debouncing + connection.get('items', '0').create({}); + }); - it('db.pollDebounce option reduces subsequent poll interval', function(done) { - var connection = this.backend.connect(); - this.backend.db.canPollDoc = function() { - return false; - }; - this.backend.db.pollDebounce = 1000; - var query = connection.createSubscribeQuery('items', this.matchAllDbQuery); - var batchSizes = []; - var total = 0; + it('db.pollDebounce option reduces subsequent poll interval', function(done) { + var connection = this.backend.connect(); + this.backend.db.canPollDoc = function() { + return false; + }; + this.backend.db.pollDebounce = 1000; + var query = connection.createSubscribeQuery('items', this.matchAllDbQuery); + var batchSizes = []; + var total = 0; - query.on('insert', function(docs) { - batchSizes.push(docs.length); - total += docs.length; - if (total === 1) { + query.on('insert', function(docs) { + batchSizes.push(docs.length); + total += docs.length; + if (total === 1) { // first write received by client. we're debouncing. create 9 // more documents. - for (var i = 1; i < 10; i++) connection.get('items', i.toString()).create({}); - } - if (total === 10) { + for (var i = 1; i < 10; i++) connection.get('items', i.toString()).create({}); + } + if (total === 10) { // first document is its own batch; then subsequent creates // are debounced until after all other 9 docs are created - expect(batchSizes).eql([1, 9]); - done(); - } - }); - - // create an initial document. this will lead to the 'insert' - // event firing the first time, while sharedb is definitely - // debouncing - connection.get('items', '0').create({}); - }); - - it('pollInterval updates a subscribed query after an unpublished create', function(done) { - var connection = this.backend.connect(); - this.backend.suppressPublish = true; - var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 50}, function(err) { - if (err) return done(err); - connection.get('dogs', 'fido').create({}); - }); - query.on('insert', function(docs, index) { - expect(util.pluck(docs, 'id')).eql(['fido']); - done(); - }); - }); + expect(batchSizes).eql([1, 9]); + done(); + } + }); - it('db.pollInterval updates a subscribed query after an unpublished create', function(done) { - var connection = this.backend.connect(); - this.backend.suppressPublish = true; - this.backend.db.pollInterval = 50; - var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err) { - if (err) return done(err); - connection.get('dogs', 'fido').create({}); + // create an initial document. this will lead to the 'insert' + // event firing the first time, while sharedb is definitely + // debouncing + connection.get('items', '0').create({}); }); - query.on('insert', function(docs, index) { - expect(util.pluck(docs, 'id')).eql(['fido']); - done(); - }); - }); - it('pollInterval captures additional unpublished creates', function(done) { - var connection = this.backend.connect(); - this.backend.suppressPublish = true; - var count = 0; - var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 50}, function(err) { - if (err) return done(err); - connection.get('dogs', count.toString()).create({}); - }); - query.on('insert', function() { - count++; - if (count === 3) return done(); - connection.get('dogs', count.toString()).create({}); + it('pollInterval updates a subscribed query after an unpublished create', function(done) { + var connection = this.backend.connect(); + this.backend.suppressPublish = true; + var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 50}, function(err) { + if (err) return done(err); + connection.get('dogs', 'fido').create({}); + }); + query.on('insert', function(docs) { + expect(util.pluck(docs, 'id')).eql(['fido']); + done(); + }); }); - }); - it('query extra is returned to client', function(done) { - var connection = this.backend.connect(); - this.backend.db.query = function(collection, query, fields, options, callback) { - process.nextTick(function() { - callback(null, [], {colors: ['brown', 'gold']}); + it('db.pollInterval updates a subscribed query after an unpublished create', function(done) { + var connection = this.backend.connect(); + this.backend.suppressPublish = true; + this.backend.db.pollInterval = 50; + var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err) { + if (err) return done(err); + connection.get('dogs', 'fido').create({}); + }); + query.on('insert', function(docs) { + expect(util.pluck(docs, 'id')).eql(['fido']); + done(); }); - }; - var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err, results, extra) { - if (err) return done(err); - expect(results).eql([]); - expect(extra).eql({colors: ['brown', 'gold']}); - expect(query.extra).eql({colors: ['brown', 'gold']}); - done(); }); - }); - it('query extra is updated on change', function(done) { - var connection = this.backend.connect(); - this.backend.db.query = function(collection, query, fields, options, callback) { - process.nextTick(function() { - callback(null, [], 1); + it('pollInterval captures additional unpublished creates', function(done) { + var connection = this.backend.connect(); + this.backend.suppressPublish = true; + var count = 0; + var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 50}, function(err) { + if (err) return done(err); + connection.get('dogs', count.toString()).create({}); }); - }; - this.backend.db.queryPoll = function(collection, query, options, callback) { - process.nextTick(function() { - callback(null, [], 2); + query.on('insert', function() { + count++; + if (count === 3) return done(); + connection.get('dogs', count.toString()).create({}); }); - }; - this.backend.db.canPollDoc = function() { - return false; - }; - var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err, results, extra) { - if (err) return done(err); - expect(extra).eql(1); - expect(query.extra).eql(1); - }); - query.on('extra', function(extra) { - expect(extra).eql(2); - expect(query.extra).eql(2); - done(); }); - connection.get('dogs', 'fido').create({age: 3}); - }); - it('changing a filtered property removes from a subscribed query', function(done) { - var connection = this.backend.connect(); - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 3}, cb); } - ], function(err) { - if (err) return done(err); - var dbQuery = getQuery({query: {age: 3}}); - var query = connection.createSubscribeQuery('dogs', dbQuery, null, function(err, results) { + it('query extra is returned to client', function(done) { + var connection = this.backend.connect(); + this.backend.db.query = function(collection, query, fields, options, callback) { + process.nextTick(function() { + callback(null, [], {colors: ['brown', 'gold']}); + }); + }; + var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err, results, extra) { if (err) return done(err); - var sorted = util.sortById(results); - expect(util.pluck(sorted, 'id')).eql(['fido', 'spot']); - expect(util.pluck(sorted, 'data')).eql([{age: 3}, {age: 3}]); - connection.get('dogs', 'fido').submitOp({p: ['age'], na: 2}); - }); - query.on('remove', function(docs, index) { - expect(util.pluck(docs, 'id')).eql(['fido']); - expect(util.pluck(docs, 'data')).eql([{age: 5}]); - expect(index).a('number'); - var results = util.sortById(query.results); - expect(util.pluck(results, 'id')).eql(['spot']); - expect(util.pluck(results, 'data')).eql([{age: 3}]); + expect(results).eql([]); + expect(extra).eql({colors: ['brown', 'gold']}); + expect(query.extra).eql({colors: ['brown', 'gold']}); done(); }); }); - }); - it('changing a filtered property inserts to a subscribed query', function(done) { - var connection = this.backend.connect(); - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - var dbQuery = getQuery({query: {age: 3}}); - var query = connection.createSubscribeQuery('dogs', dbQuery, null, function(err, results) { + it('query extra is updated on change', function(done) { + var connection = this.backend.connect(); + this.backend.db.query = function(collection, query, fields, options, callback) { + process.nextTick(function() { + callback(null, [], 1); + }); + }; + this.backend.db.queryPoll = function(collection, query, options, callback) { + process.nextTick(function() { + callback(null, [], 2); + }); + }; + this.backend.db.canPollDoc = function() { + return false; + }; + var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err, results, extra) { if (err) return done(err); - var sorted = util.sortById(results); - expect(util.pluck(sorted, 'id')).eql(['fido']); - expect(util.pluck(sorted, 'data')).eql([{age: 3}]); - connection.get('dogs', 'spot').submitOp({p: ['age'], na: -2}); + expect(extra).eql(1); + expect(query.extra).eql(1); }); - query.on('insert', function(docs, index) { - expect(util.pluck(docs, 'id')).eql(['spot']); - expect(util.pluck(docs, 'data')).eql([{age: 3}]); - expect(index).a('number'); - var results = util.sortById(query.results); - expect(util.pluck(results, 'id')).eql(['fido', 'spot']); - expect(util.pluck(results, 'data')).eql([{age: 3}, {age: 3}]); + query.on('extra', function(extra) { + expect(extra).eql(2); + expect(query.extra).eql(2); done(); }); + connection.get('dogs', 'fido').create({age: 3}); }); - }); - it('changing a sorted property moves in a subscribed query', function(done) { - var connection = this.backend.connect(); + it('changing a filtered property removes from a subscribed query', function(done) { + var connection = this.backend.connect(); + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 3}, cb); + } + ], function(err) { + if (err) return done(err); + var dbQuery = getQuery({query: {age: 3}}); + var query = connection.createSubscribeQuery('dogs', dbQuery, null, function(err, results) { + if (err) return done(err); + var sorted = util.sortById(results); + expect(util.pluck(sorted, 'id')).eql(['fido', 'spot']); + expect(util.pluck(sorted, 'data')).eql([{age: 3}, {age: 3}]); + connection.get('dogs', 'fido').submitOp({p: ['age'], na: 2}); + }); + query.on('remove', function(docs, index) { + expect(util.pluck(docs, 'id')).eql(['fido']); + expect(util.pluck(docs, 'data')).eql([{age: 5}]); + expect(index).a('number'); + var results = util.sortById(query.results); + expect(util.pluck(results, 'id')).eql(['spot']); + expect(util.pluck(results, 'data')).eql([{age: 3}]); + done(); + }); + }); + }); - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - var dbQuery = getQuery({query: {}, sort: [['age', 1]]}); - var query = connection.createSubscribeQuery( - 'dogs', - dbQuery, - null, - function(err, results) { + it('changing a filtered property inserts to a subscribed query', function(done) { + var connection = this.backend.connect(); + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + } + ], function(err) { + if (err) return done(err); + var dbQuery = getQuery({query: {age: 3}}); + var query = connection.createSubscribeQuery('dogs', dbQuery, null, function(err, results) { if (err) return done(err); + var sorted = util.sortById(results); + expect(util.pluck(sorted, 'id')).eql(['fido']); + expect(util.pluck(sorted, 'data')).eql([{age: 3}]); + connection.get('dogs', 'spot').submitOp({p: ['age'], na: -2}); + }); + query.on('insert', function(docs, index) { + expect(util.pluck(docs, 'id')).eql(['spot']); + expect(util.pluck(docs, 'data')).eql([{age: 3}]); + expect(index).a('number'); + var results = util.sortById(query.results); expect(util.pluck(results, 'id')).eql(['fido', 'spot']); - expect(util.pluck(results, 'data')).eql([{age: 3}, {age: 5}]); - connection.get('dogs', 'spot').submitOp({p: ['age'], na: -3}); + expect(util.pluck(results, 'data')).eql([{age: 3}, {age: 3}]); + done(); }); + }); + }); - query.on('move', function(docs, from, to) { - expect(docs.length).eql(1); - expect(from).a('number'); - expect(to).a('number'); - expect(util.pluck(query.results, 'id')).eql(['spot', 'fido']); - expect(util.pluck(query.results, 'data')).eql([{age: 2}, {age: 3}]); - done(); + it('changing a sorted property moves in a subscribed query', function(done) { + var connection = this.backend.connect(); + + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + } + ], function(err) { + if (err) return done(err); + var dbQuery = getQuery({query: {}, sort: [['age', 1]]}); + var query = connection.createSubscribeQuery( + 'dogs', + dbQuery, + null, + function(err, results) { + if (err) return done(err); + expect(util.pluck(results, 'id')).eql(['fido', 'spot']); + expect(util.pluck(results, 'data')).eql([{age: 3}, {age: 5}]); + connection.get('dogs', 'spot').submitOp({p: ['age'], na: -3}); + }); + + query.on('move', function(docs, from, to) { + expect(docs.length).eql(1); + expect(from).a('number'); + expect(to).a('number'); + expect(util.pluck(query.results, 'id')).eql(['spot', 'fido']); + expect(util.pluck(query.results, 'data')).eql([{age: 2}, {age: 3}]); + done(); + }); }); }); }); - -}); }; diff --git a/test/client/query.js b/test/client/query.js index 0b7912cb3..785c55211 100644 --- a/test/client/query.js +++ b/test/client/query.js @@ -3,74 +3,64 @@ var async = require('async'); var util = require('../util'); module.exports = function(options) { -var getQuery = options.getQuery; + var getQuery = options.getQuery; -describe('client query', function() { - before(function() { - if (!getQuery) return this.skip(); - this.matchAllDbQuery = getQuery({query: {}}); - }); - - ['createFetchQuery', 'createSubscribeQuery'].forEach(function(method) { - it(method + ' on an empty collection', function(done) { - var connection = this.backend.connect(); - connection[method]('dogs', this.matchAllDbQuery, null, function(err, results) { - if (err) return done(err); - expect(results).eql([]); - done(); - }); + describe('client query', function() { + before(function() { + if (!getQuery) return this.skip(); + this.matchAllDbQuery = getQuery({query: {}}); }); - it(method + ' on collection with fetched docs', function(done) { - var connection = this.backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); }, - function(cb) { connection.get('cats', 'finn').create({age: 2}, cb); } - ], function(err) { - if (err) return done(err); - connection[method]('dogs', matchAllDbQuery, null, function(err, results) { + ['createFetchQuery', 'createSubscribeQuery'].forEach(function(method) { + it(method + ' on an empty collection', function(done) { + var connection = this.backend.connect(); + connection[method]('dogs', this.matchAllDbQuery, null, function(err, results) { if (err) return done(err); - var sorted = util.sortById(results); - expect(util.pluck(sorted, 'id')).eql(['fido', 'spot']); - expect(util.pluck(sorted, 'data')).eql([{age: 3}, {age: 5}]); + expect(results).eql([]); done(); }); }); - }); - it(method + ' on collection with unfetched docs', function(done) { - var connection = this.backend.connect(); - var connection2 = this.backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); }, - function(cb) { connection.get('cats', 'finn').create({age: 2}, cb); } - ], function(err) { - if (err) return done(err); - connection2[method]('dogs', matchAllDbQuery, null, function(err, results) { + it(method + ' on collection with fetched docs', function(done) { + var connection = this.backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + }, + function(cb) { + connection.get('cats', 'finn').create({age: 2}, cb); + } + ], function(err) { if (err) return done(err); - var sorted = util.sortById(results); - expect(util.pluck(sorted, 'id')).eql(['fido', 'spot']); - expect(util.pluck(sorted, 'data')).eql([{age: 3}, {age: 5}]); - done(); + connection[method]('dogs', matchAllDbQuery, null, function(err, results) { + if (err) return done(err); + var sorted = util.sortById(results); + expect(util.pluck(sorted, 'id')).eql(['fido', 'spot']); + expect(util.pluck(sorted, 'data')).eql([{age: 3}, {age: 5}]); + done(); + }); }); }); - }); - it(method + ' on collection with one fetched doc', function(done) { - var connection = this.backend.connect(); - var connection2 = this.backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); }, - function(cb) { connection.get('cats', 'finn').create({age: 2}, cb); } - ], function(err) { - if (err) return done(err); - connection2.get('dogs', 'fido').fetch(function(err) { + it(method + ' on collection with unfetched docs', function(done) { + var connection = this.backend.connect(); + var connection2 = this.backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + }, + function(cb) { + connection.get('cats', 'finn').create({age: 2}, cb); + } + ], function(err) { if (err) return done(err); connection2[method]('dogs', matchAllDbQuery, null, function(err, results) { if (err) return done(err); @@ -81,43 +71,75 @@ describe('client query', function() { }); }); }); - }); - it(method + ' on collection with one fetched doc missing an op', function(done) { - var connection = this.backend.connect(); - var connection2 = this.backend.connect(); - var matchAllDbQuery = this.matchAllDbQuery; - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); }, - function(cb) { connection.get('cats', 'finn').create({age: 2}, cb); } - ], function(err) { - if (err) return done(err); - connection2.get('dogs', 'fido').fetch(function(err) { + it(method + ' on collection with one fetched doc', function(done) { + var connection = this.backend.connect(); + var connection2 = this.backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + }, + function(cb) { + connection.get('cats', 'finn').create({age: 2}, cb); + } + ], function(err) { if (err) return done(err); - connection.get('dogs', 'fido').submitOp([{p: ['age'], na: 1}], function(err) { + connection2.get('dogs', 'fido').fetch(function(err) { if (err) return done(err); - // The results option is meant for making resubscribing more - // efficient and has no effect on query fetching - var options = { - results: [ - connection2.get('dogs', 'fido'), - connection2.get('dogs', 'spot') - ] - }; - connection2[method]('dogs', matchAllDbQuery, options, function(err, results) { + connection2[method]('dogs', matchAllDbQuery, null, function(err, results) { if (err) return done(err); var sorted = util.sortById(results); expect(util.pluck(sorted, 'id')).eql(['fido', 'spot']); - expect(util.pluck(sorted, 'data')).eql([{age: 4}, {age: 5}]); + expect(util.pluck(sorted, 'data')).eql([{age: 3}, {age: 5}]); done(); }); }); }); }); - }); + it(method + ' on collection with one fetched doc missing an op', function(done) { + var connection = this.backend.connect(); + var connection2 = this.backend.connect(); + var matchAllDbQuery = this.matchAllDbQuery; + async.parallel([ + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + }, + function(cb) { + connection.get('cats', 'finn').create({age: 2}, cb); + } + ], function(err) { + if (err) return done(err); + connection2.get('dogs', 'fido').fetch(function(err) { + if (err) return done(err); + connection.get('dogs', 'fido').submitOp([{p: ['age'], na: 1}], function(err) { + if (err) return done(err); + // The results option is meant for making resubscribing more + // efficient and has no effect on query fetching + var options = { + results: [ + connection2.get('dogs', 'fido'), + connection2.get('dogs', 'spot') + ] + }; + connection2[method]('dogs', matchAllDbQuery, options, function(err, results) { + if (err) return done(err); + var sorted = util.sortById(results); + expect(util.pluck(sorted, 'id')).eql(['fido', 'spot']); + expect(util.pluck(sorted, 'data')).eql([{age: 4}, {age: 5}]); + done(); + }); + }); + }); + }); + }); + }); }); - -}); }; diff --git a/test/client/snapshot-timestamp-request.js b/test/client/snapshot-timestamp-request.js index 9c2aeaad2..8d7cc4bf3 100644 --- a/test/client/snapshot-timestamp-request.js +++ b/test/client/snapshot-timestamp-request.js @@ -6,7 +6,7 @@ var MemoryDb = require('../../lib/db/memory'); var MemoryMilestoneDb = require('../../lib/milestone-db/memory'); var sinon = require('sinon'); -describe('SnapshotTimestampRequest', function () { +describe('SnapshotTimestampRequest', function() { var backend; var clock; var day0 = new Date(2017, 11, 31).getTime(); @@ -17,17 +17,17 @@ describe('SnapshotTimestampRequest', function () { var day5 = new Date(2018, 0, 5).getTime(); var ONE_DAY = 1000 * 60 * 60 * 24; - beforeEach(function () { - clock = lolex.install({ now: day1 }); + beforeEach(function() { + clock = lolex.install({now: day1}); backend = new Backend(); }); - afterEach(function (done) { + afterEach(function(done) { clock.uninstall(); backend.close(done); }); - describe('a document with some simple versions separated by a day', function () { + describe('a document with some simple versions separated by a day', function() { var v0 = { id: 'time-machine', v: 0, @@ -68,30 +68,30 @@ describe('SnapshotTimestampRequest', function () { m: null }; - beforeEach(function (done) { + beforeEach(function(done) { var doc = backend.connect().get('books', 'time-machine'); util.callInSeries([ - function (next) { - doc.create({ title: 'The Time Machine' }, next); + function(next) { + doc.create({title: 'The Time Machine'}, next); }, - function (next) { + function(next) { clock.tick(ONE_DAY); - doc.submitOp({ p: ['author'], oi: 'HG Wells' }, next); + doc.submitOp({p: ['author'], oi: 'HG Wells'}, next); }, - function (next) { + function(next) { clock.tick(ONE_DAY); - doc.submitOp({ p: ['author'], od: 'HG Wells', oi: 'H.G. Wells' }, next); + doc.submitOp({p: ['author'], od: 'HG Wells', oi: 'H.G. Wells'}, next); }, done ]); }); - it('fetches the version at exactly day 1', function (done) { + it('fetches the version at exactly day 1', function(done) { util.callInSeries([ - function (next) { + function(next) { backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day1, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(v1); next(); }, @@ -99,12 +99,12 @@ describe('SnapshotTimestampRequest', function () { ]); }); - it('fetches the version at exactly day 2', function (done) { + it('fetches the version at exactly day 2', function(done) { util.callInSeries([ - function (next) { + function(next) { backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day2, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(v2); next(); }, @@ -112,12 +112,12 @@ describe('SnapshotTimestampRequest', function () { ]); }); - it('fetches the version at exactly day 3', function (done) { + it('fetches the version at exactly day 3', function(done) { util.callInSeries([ - function (next) { + function(next) { backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day3, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(v3); next(); }, @@ -125,13 +125,13 @@ describe('SnapshotTimestampRequest', function () { ]); }); - it('fetches the day 2 version when asking for a time halfway between days 2 and 3', function (done) { + it('fetches the day 2 version when asking for a time halfway between days 2 and 3', function(done) { var halfwayBetweenDays2and3 = (day2 + day3) * 0.5; util.callInSeries([ - function (next) { + function(next) { backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', halfwayBetweenDays2and3, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(v2); next(); }, @@ -139,12 +139,12 @@ describe('SnapshotTimestampRequest', function () { ]); }); - it('fetches the day 3 version when asking for a time after day 3', function (done) { + it('fetches the day 3 version when asking for a time after day 3', function(done) { util.callInSeries([ - function (next) { + function(next) { backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day4, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(v3); next(); }, @@ -152,12 +152,12 @@ describe('SnapshotTimestampRequest', function () { ]); }); - it('fetches the most recent version when not specifying a timestamp', function (done) { + it('fetches the most recent version when not specifying a timestamp', function(done) { util.callInSeries([ - function (next) { + function(next) { backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(v3); next(); }, @@ -165,12 +165,12 @@ describe('SnapshotTimestampRequest', function () { ]); }); - it('fetches an empty snapshot if the timestamp is before the document creation', function (done) { + it('fetches an empty snapshot if the timestamp is before the document creation', function(done) { util.callInSeries([ - function (next) { + function(next) { backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day0, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(v0); next(); }, @@ -178,40 +178,40 @@ describe('SnapshotTimestampRequest', function () { ]); }); - it('throws if the timestamp is undefined', function () { - var fetch = function () { - backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', undefined, function () {}); + it('throws if the timestamp is undefined', function() { + var fetch = function() { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', undefined, function() {}); }; expect(fetch).to.throwError(); }); - it('throws without a callback', function () { - var fetch = function () { + it('throws without a callback', function() { + var fetch = function() { backend.connect().fetchSnapshotByTimestamp('books', 'time-machine'); }; expect(fetch).to.throwError(); }); - it('throws if the timestamp is -1', function () { - var fetch = function () { - backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', -1, function () { }); + it('throws if the timestamp is -1', function() { + var fetch = function() { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', -1, function() { }); }; expect(fetch).to.throwError(); }); - it('errors if the timestamp is a string', function () { - var fetch = function () { - backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', 'foo', function () { }); - } + it('errors if the timestamp is a string', function() { + var fetch = function() { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', 'foo', function() { }); + }; expect(fetch).to.throwError(); }); - it('returns an empty snapshot if trying to fetch a non-existent document', function (done) { - backend.connect().fetchSnapshotByTimestamp('books', 'does-not-exist', day1, function (error, snapshot) { + it('returns an empty snapshot if trying to fetch a non-existent document', function(done) { + backend.connect().fetchSnapshotByTimestamp('books', 'does-not-exist', day1, function(error, snapshot) { if (error) return done(error); expect(snapshot).to.eql({ id: 'does-not-exist', @@ -224,10 +224,11 @@ describe('SnapshotTimestampRequest', function () { }); }); - it('starts pending, and finishes not pending', function (done) { + it('starts pending, and finishes not pending', function(done) { var connection = backend.connect(); - connection.fetchSnapshotByTimestamp('books', 'time-machine', null, function (error, snapshot) { + connection.fetchSnapshotByTimestamp('books', 'time-machine', null, function(error) { + if (error) return done(error); expect(connection.hasPending()).to.be(false); done(); }); @@ -235,10 +236,10 @@ describe('SnapshotTimestampRequest', function () { expect(connection.hasPending()).to.be(true); }); - it('deletes the request from the connection', function (done) { + it('deletes the request from the connection', function(done) { var connection = backend.connect(); - connection.fetchSnapshotByTimestamp('books', 'time-machine', function (error) { + connection.fetchSnapshotByTimestamp('books', 'time-machine', function(error) { if (error) return done(error); expect(connection._snapshotRequests).to.eql({}); done(); @@ -247,10 +248,10 @@ describe('SnapshotTimestampRequest', function () { expect(connection._snapshotRequests).to.not.eql({}); }); - it('emits a ready event when done', function (done) { + it('emits a ready event when done', function(done) { var connection = backend.connect(); - connection.fetchSnapshotByTimestamp('books', 'time-machine', function (error) { + connection.fetchSnapshotByTimestamp('books', 'time-machine', function(error) { if (error) return done(error); }); @@ -258,22 +259,22 @@ describe('SnapshotTimestampRequest', function () { snapshotRequest.on('ready', done); }); - it('fires the connection.whenNothingPending', function (done) { + it('fires the connection.whenNothingPending', function(done) { var connection = backend.connect(); var snapshotFetched = false; - connection.fetchSnapshotByTimestamp('books', 'time-machine', function (error) { + connection.fetchSnapshotByTimestamp('books', 'time-machine', function(error) { if (error) return done(error); snapshotFetched = true; }); - connection.whenNothingPending(function () { + connection.whenNothingPending(function() { expect(snapshotFetched).to.be(true); done(); }); }); - it('can drop its connection and reconnect, and the callback is just called once', function (done) { + it('can drop its connection and reconnect, and the callback is just called once', function(done) { var connection = backend.connect(); // Here we hook into middleware to make sure that we get the following flow: @@ -286,7 +287,7 @@ describe('SnapshotTimestampRequest', function () { // - This time the fetch operation is allowed to complete (because of the connectionInterrupted flag) // - The done callback is called just once (if it's called twice, then mocha will complain) var connectionInterrupted = false; - backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function (request, callback) { + backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function(request, callback) { if (!connectionInterrupted) { connection.close(); backend.connect(connection); @@ -299,7 +300,7 @@ describe('SnapshotTimestampRequest', function () { connection.fetchSnapshotByTimestamp('books', 'time-machine', done); }); - it('cannot send the same request twice over a connection', function (done) { + it('cannot send the same request twice over a connection', function(done) { var connection = backend.connect(); // Here we hook into the middleware to make sure that we get the following flow: @@ -310,7 +311,7 @@ describe('SnapshotTimestampRequest', function () { // - The done callback is call just once, because the second request does not get sent // (if the done callback is called twice, then mocha will complain) var hasResent = false; - backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function (request, callback) { + backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function(request, callback) { if (!hasResent) { connection._snapshotRequests[1]._onConnectionStateChanged(); hasResent = true; @@ -322,55 +323,55 @@ describe('SnapshotTimestampRequest', function () { connection.fetchSnapshotByTimestamp('books', 'time-machine', done); }); - describe('readSnapshots middleware', function () { - it('triggers the middleware', function (done) { + describe('readSnapshots middleware', function() { + it('triggers the middleware', function(done) { backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, - function (request) { + function(request) { expect(request.snapshots[0]).to.eql(v3); expect(request.snapshotType).to.be(backend.SNAPSHOT_TYPES.byTimestamp); done(); } ); - backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day3, function () { }); + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day3, function() { }); }); - it('can have its snapshot manipulated in the middleware', function (done) { + it('can have its snapshot manipulated in the middleware', function(done) { backend.middleware[backend.MIDDLEWARE_ACTIONS.readSnapshots] = [ - function (request, callback) { + function(request, callback) { request.snapshots[0].data.title = 'Alice in Wonderland'; callback(); - }, + } ]; - backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', function (error, snapshot) { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', function(error, snapshot) { if (error) return done(error); expect(snapshot.data.title).to.be('Alice in Wonderland'); done(); }); }); - it('respects errors thrown in the middleware', function (done) { + it('respects errors thrown in the middleware', function(done) { backend.middleware[backend.MIDDLEWARE_ACTIONS.readSnapshots] = [ - function (request, callback) { - callback({ message: 'foo' }); - }, + function(request, callback) { + callback({message: 'foo'}); + } ]; - backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day1, function (error, snapshot) { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day1, function(error) { expect(error.message).to.be('foo'); done(); }); }); }); - describe('with a registered projection', function () { - beforeEach(function () { - backend.addProjection('bookTitles', 'books', { title: true }); + describe('with a registered projection', function() { + beforeEach(function() { + backend.addProjection('bookTitles', 'books', {title: true}); }); - it('applies the projection to a snapshot', function (done) { - backend.connect().fetchSnapshotByTimestamp('bookTitles', 'time-machine', day2, function (error, snapshot) { + it('applies the projection to a snapshot', function(done) { + backend.connect().fetchSnapshotByTimestamp('bookTitles', 'time-machine', day2, function(error, snapshot) { if (error) return done(error); expect(snapshot.data.title).to.be('The Time Machine'); @@ -381,13 +382,13 @@ describe('SnapshotTimestampRequest', function () { }); }); - describe('milestone snapshots enabled for every other version', function () { + describe('milestone snapshots enabled for every other version', function() { var milestoneDb; var db; var backendWithMilestones; - beforeEach(function () { - var options = { interval: 2 }; + beforeEach(function() { + var options = {interval: 2}; db = new MemoryDb(); milestoneDb = new MemoryMilestoneDb(options); backendWithMilestones = new Backend({ @@ -396,41 +397,41 @@ describe('SnapshotTimestampRequest', function () { }); }); - afterEach(function (done) { + afterEach(function(done) { backendWithMilestones.close(done); }); - describe('a doc with some versions in the milestone database', function () { - beforeEach(function (done) { + describe('a doc with some versions in the milestone database', function() { + beforeEach(function(done) { clock.reset(); var doc = backendWithMilestones.connect().get('books', 'mocking-bird'); util.callInSeries([ - function (next) { - doc.create({ title: 'To Kill a Mocking Bird' }, next); + function(next) { + doc.create({title: 'To Kill a Mocking Bird'}, next); }, - function (next) { + function(next) { clock.tick(ONE_DAY); - doc.submitOp({ p: ['author'], oi: 'Harper Lea' }, next); + doc.submitOp({p: ['author'], oi: 'Harper Lea'}, next); }, - function (next) { + function(next) { clock.tick(ONE_DAY); - doc.submitOp({ p: ['author'], od: 'Harper Lea', oi: 'Harper Lee' }, next); + doc.submitOp({p: ['author'], od: 'Harper Lea', oi: 'Harper Lee'}, next); }, - function (next) { + function(next) { clock.tick(ONE_DAY); - doc.submitOp({ p: ['year'], oi: 1959 }, next); + doc.submitOp({p: ['year'], oi: 1959}, next); }, - function (next) { + function(next) { clock.tick(ONE_DAY); - doc.submitOp({ p: ['year'], od: 1959, oi: 1960 }, next); + doc.submitOp({p: ['year'], od: 1959, oi: 1960}, next); }, done ]); }); - it('fetches a snapshot between two milestones using the milestones', function (done) { + it('fetches a snapshot between two milestones using the milestones', function(done) { sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime'); sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime'); sinon.spy(db, 'getOps'); @@ -445,35 +446,35 @@ describe('SnapshotTimestampRequest', function () { expect(db.getOps.calledWith('books', 'mocking-bird', 2, 4)).to.be(true); expect(snapshot.v).to.be(3); - expect(snapshot.data).to.eql({ title: 'To Kill a Mocking Bird', author: 'Harper Lee' }); + expect(snapshot.data).to.eql({title: 'To Kill a Mocking Bird', author: 'Harper Lee'}); done(); }); }); - it('fetches a snapshot that matches a milestone snapshot', function (done) { + it('fetches a snapshot that matches a milestone snapshot', function(done) { sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime'); sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime'); backendWithMilestones.connect() - .fetchSnapshotByTimestamp('books', 'mocking-bird', day2, function (error, snapshot) { + .fetchSnapshotByTimestamp('books', 'mocking-bird', day2, function(error, snapshot) { if (error) return done(error); expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.be(true); expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.be(true); expect(snapshot.v).to.be(2); - expect(snapshot.data).to.eql({ title: 'To Kill a Mocking Bird', author: 'Harper Lea' }); + expect(snapshot.data).to.eql({title: 'To Kill a Mocking Bird', author: 'Harper Lea'}); done(); }); }); - it('fetches a snapshot before any milestones', function (done) { + it('fetches a snapshot before any milestones', function(done) { sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime'); sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime'); sinon.spy(db, 'getOps'); backendWithMilestones.connect() - .fetchSnapshotByTimestamp('books', 'mocking-bird', day1, function (error, snapshot) { + .fetchSnapshotByTimestamp('books', 'mocking-bird', day1, function(error, snapshot) { if (error) return done(error); expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.be(true); @@ -481,18 +482,18 @@ describe('SnapshotTimestampRequest', function () { expect(db.getOps.calledWith('books', 'mocking-bird', 0, 2)).to.be(true); expect(snapshot.v).to.be(1); - expect(snapshot.data).to.eql({ title: 'To Kill a Mocking Bird' }); + expect(snapshot.data).to.eql({title: 'To Kill a Mocking Bird'}); done(); }); }); - it('fetches a snapshot after any milestones', function (done) { + it('fetches a snapshot after any milestones', function(done) { sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime'); sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime'); sinon.spy(db, 'getOps'); backendWithMilestones.connect() - .fetchSnapshotByTimestamp('books', 'mocking-bird', day5, function (error, snapshot) { + .fetchSnapshotByTimestamp('books', 'mocking-bird', day5, function(error, snapshot) { if (error) return done(error); expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.be(true); diff --git a/test/client/snapshot-version-request.js b/test/client/snapshot-version-request.js index 4b7101e56..a39355f38 100644 --- a/test/client/snapshot-version-request.js +++ b/test/client/snapshot-version-request.js @@ -5,18 +5,18 @@ var MemoryMilestoneDb = require('../../lib/milestone-db/memory'); var sinon = require('sinon'); var util = require('../util'); -describe('SnapshotVersionRequest', function () { +describe('SnapshotVersionRequest', function() { var backend; - beforeEach(function () { + beforeEach(function() { backend = new Backend(); }); - afterEach(function (done) { + afterEach(function(done) { backend.close(done); }); - describe('a document with some simple versions', function () { + describe('a document with some simple versions', function() { var v0 = { id: 'don-quixote', v: 0, @@ -57,99 +57,99 @@ describe('SnapshotVersionRequest', function () { m: null }; - beforeEach(function (done) { + beforeEach(function(done) { var doc = backend.connect().get('books', 'don-quixote'); - doc.create({ title: 'Don Quixote' }, function (error) { + doc.create({title: 'Don Quixote'}, function(error) { if (error) return done(error); - doc.submitOp({ p: ['author'], oi: 'Miguel de Cervante' }, function (error) { + doc.submitOp({p: ['author'], oi: 'Miguel de Cervante'}, function(error) { if (error) return done(error); - doc.submitOp({ p: ['author'], od: 'Miguel de Cervante', oi: 'Miguel de Cervantes' }, done); + doc.submitOp({p: ['author'], od: 'Miguel de Cervante', oi: 'Miguel de Cervantes'}, done); }); }); }); - it('fetches v1', function (done) { - backend.connect().fetchSnapshot('books', 'don-quixote', 1, function (error, snapshot) { + it('fetches v1', function(done) { + backend.connect().fetchSnapshot('books', 'don-quixote', 1, function(error, snapshot) { if (error) return done(error); expect(snapshot).to.eql(v1); done(); }); }); - it('fetches v2', function (done) { - backend.connect().fetchSnapshot('books', 'don-quixote', 2, function (error, snapshot) { + it('fetches v2', function(done) { + backend.connect().fetchSnapshot('books', 'don-quixote', 2, function(error, snapshot) { if (error) return done(error); expect(snapshot).to.eql(v2); done(); }); }); - it('fetches v3', function (done) { - backend.connect().fetchSnapshot('books', 'don-quixote', 3, function (error, snapshot) { + it('fetches v3', function(done) { + backend.connect().fetchSnapshot('books', 'don-quixote', 3, function(error, snapshot) { if (error) return done(error); expect(snapshot).to.eql(v3); done(); }); }); - it('returns an empty snapshot if the version is 0', function (done) { - backend.connect().fetchSnapshot('books', 'don-quixote', 0, function (error, snapshot) { + it('returns an empty snapshot if the version is 0', function(done) { + backend.connect().fetchSnapshot('books', 'don-quixote', 0, function(error, snapshot) { if (error) return done(error); expect(snapshot).to.eql(v0); done(); }); }); - it('throws if the version is undefined', function () { - var fetch = function () { - backend.connect().fetchSnapshot('books', 'don-quixote', undefined, function () {}); + it('throws if the version is undefined', function() { + var fetch = function() { + backend.connect().fetchSnapshot('books', 'don-quixote', undefined, function() {}); }; expect(fetch).to.throwError(); }); - it('fetches the latest version when the optional version is not provided', function (done) { - backend.connect().fetchSnapshot('books', 'don-quixote', function (error, snapshot) { + it('fetches the latest version when the optional version is not provided', function(done) { + backend.connect().fetchSnapshot('books', 'don-quixote', function(error, snapshot) { if (error) return done(error); expect(snapshot).to.eql(v3); done(); }); }); - it('throws without a callback', function () { - var fetch = function () { + it('throws without a callback', function() { + var fetch = function() { backend.connect().fetchSnapshot('books', 'don-quixote'); }; expect(fetch).to.throwError(); }); - it('throws if the version is -1', function () { - var fetch = function () { - backend.connect().fetchSnapshot('books', 'don-quixote', -1, function () {}); + it('throws if the version is -1', function() { + var fetch = function() { + backend.connect().fetchSnapshot('books', 'don-quixote', -1, function() {}); }; expect(fetch).to.throwError(); }); - it('errors if the version is a string', function () { - var fetch = function () { - backend.connect().fetchSnapshot('books', 'don-quixote', 'foo', function () { }); - } + it('errors if the version is a string', function() { + var fetch = function() { + backend.connect().fetchSnapshot('books', 'don-quixote', 'foo', function() { }); + }; expect(fetch).to.throwError(); }); - it('errors if asking for a version that does not exist', function (done) { - backend.connect().fetchSnapshot('books', 'don-quixote', 4, function (error, snapshot) { + it('errors if asking for a version that does not exist', function(done) { + backend.connect().fetchSnapshot('books', 'don-quixote', 4, function(error, snapshot) { expect(error.code).to.be(4024); expect(snapshot).to.be(undefined); done(); }); }); - it('returns an empty snapshot if trying to fetch a non-existent document', function (done) { - backend.connect().fetchSnapshot('books', 'does-not-exist', 0, function (error, snapshot) { + it('returns an empty snapshot if trying to fetch a non-existent document', function(done) { + backend.connect().fetchSnapshot('books', 'does-not-exist', 0, function(error, snapshot) { if (error) return done(error); expect(snapshot).to.eql({ id: 'does-not-exist', @@ -162,10 +162,11 @@ describe('SnapshotVersionRequest', function () { }); }); - it('starts pending, and finishes not pending', function (done) { + it('starts pending, and finishes not pending', function(done) { var connection = backend.connect(); - connection.fetchSnapshot('books', 'don-quixote', null, function (error, snapshot) { + connection.fetchSnapshot('books', 'don-quixote', null, function(error) { + if (error) return done(error); expect(connection.hasPending()).to.be(false); done(); }); @@ -173,10 +174,10 @@ describe('SnapshotVersionRequest', function () { expect(connection.hasPending()).to.be(true); }); - it('deletes the request from the connection', function (done) { + it('deletes the request from the connection', function(done) { var connection = backend.connect(); - connection.fetchSnapshot('books', 'don-quixote', function (error) { + connection.fetchSnapshot('books', 'don-quixote', function(error) { if (error) return done(error); expect(connection._snapshotRequests).to.eql({}); done(); @@ -185,10 +186,10 @@ describe('SnapshotVersionRequest', function () { expect(connection._snapshotRequests).to.not.eql({}); }); - it('emits a ready event when done', function (done) { + it('emits a ready event when done', function(done) { var connection = backend.connect(); - connection.fetchSnapshot('books', 'don-quixote', function (error) { + connection.fetchSnapshot('books', 'don-quixote', function(error) { if (error) return done(error); }); @@ -196,22 +197,22 @@ describe('SnapshotVersionRequest', function () { snapshotRequest.on('ready', done); }); - it('fires the connection.whenNothingPending', function (done) { + it('fires the connection.whenNothingPending', function(done) { var connection = backend.connect(); var snapshotFetched = false; - connection.fetchSnapshot('books', 'don-quixote', function (error) { + connection.fetchSnapshot('books', 'don-quixote', function(error) { if (error) return done(error); snapshotFetched = true; }); - connection.whenNothingPending(function () { + connection.whenNothingPending(function() { expect(snapshotFetched).to.be(true); done(); }); }); - it('can drop its connection and reconnect, and the callback is just called once', function (done) { + it('can drop its connection and reconnect, and the callback is just called once', function(done) { var connection = backend.connect(); // Here we hook into middleware to make sure that we get the following flow: @@ -224,7 +225,7 @@ describe('SnapshotVersionRequest', function () { // - This time the fetch operation is allowed to complete (because of the connectionInterrupted flag) // - The done callback is called just once (if it's called twice, then mocha will complain) var connectionInterrupted = false; - backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function (request, callback) { + backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function(request, callback) { if (!connectionInterrupted) { connection.close(); backend.connect(connection); @@ -237,7 +238,7 @@ describe('SnapshotVersionRequest', function () { connection.fetchSnapshot('books', 'don-quixote', done); }); - it('cannot send the same request twice over a connection', function (done) { + it('cannot send the same request twice over a connection', function(done) { var connection = backend.connect(); // Here we hook into the middleware to make sure that we get the following flow: @@ -248,7 +249,7 @@ describe('SnapshotVersionRequest', function () { // - The done callback is call just once, because the second request does not get sent // (if the done callback is called twice, then mocha will complain) var hasResent = false; - backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function (request, callback) { + backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function(request, callback) { if (!hasResent) { connection._snapshotRequests[1]._onConnectionStateChanged(); hasResent = true; @@ -260,55 +261,55 @@ describe('SnapshotVersionRequest', function () { connection.fetchSnapshot('books', 'don-quixote', done); }); - describe('readSnapshots middleware', function () { - it('triggers the middleware', function (done) { + describe('readSnapshots middleware', function() { + it('triggers the middleware', function(done) { backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, - function (request) { + function(request) { expect(request.snapshots[0]).to.eql(v3); expect(request.snapshotType).to.be(backend.SNAPSHOT_TYPES.byVersion); done(); } ); - backend.connect().fetchSnapshot('books', 'don-quixote', 3, function () { }); + backend.connect().fetchSnapshot('books', 'don-quixote', 3, function() { }); }); - it('can have its snapshot manipulated in the middleware', function (done) { + it('can have its snapshot manipulated in the middleware', function(done) { backend.middleware[backend.MIDDLEWARE_ACTIONS.readSnapshots] = [ - function (request, callback) { + function(request, callback) { request.snapshots[0].data.title = 'Alice in Wonderland'; callback(); - }, + } ]; - backend.connect().fetchSnapshot('books', 'don-quixote', function (error, snapshot) { + backend.connect().fetchSnapshot('books', 'don-quixote', function(error, snapshot) { if (error) return done(error); expect(snapshot.data.title).to.be('Alice in Wonderland'); done(); }); }); - it('respects errors thrown in the middleware', function (done) { + it('respects errors thrown in the middleware', function(done) { backend.middleware[backend.MIDDLEWARE_ACTIONS.readSnapshots] = [ - function (request, callback) { - callback({ message: 'foo' }); - }, + function(request, callback) { + callback({message: 'foo'}); + } ]; - backend.connect().fetchSnapshot('books', 'don-quixote', 0, function (error, snapshot) { + backend.connect().fetchSnapshot('books', 'don-quixote', 0, function(error) { expect(error.message).to.be('foo'); done(); }); }); }); - describe('with a registered projection', function () { - beforeEach(function () { - backend.addProjection('bookTitles', 'books', { title: true }); + describe('with a registered projection', function() { + beforeEach(function() { + backend.addProjection('bookTitles', 'books', {title: true}); }); - it('applies the projection to a snapshot', function (done) { - backend.connect().fetchSnapshot('bookTitles', 'don-quixote', 2, function (error, snapshot) { + it('applies the projection to a snapshot', function(done) { + backend.connect().fetchSnapshot('bookTitles', 'don-quixote', 2, function(error, snapshot) { if (error) return done(error); expect(snapshot.data.title).to.be('Don Quixote'); @@ -319,19 +320,19 @@ describe('SnapshotVersionRequest', function () { }); }); - describe('a document that is currently deleted', function () { - beforeEach(function (done) { + describe('a document that is currently deleted', function() { + beforeEach(function(done) { var doc = backend.connect().get('books', 'catch-22'); - doc.create({ title: 'Catch 22' }, function (error) { + doc.create({title: 'Catch 22'}, function(error) { if (error) return done(error); - doc.del(function (error) { + doc.del(function(error) { done(error); }); }); }); - it('returns a null type', function (done) { - backend.connect().fetchSnapshot('books', 'catch-22', null, function (error, snapshot) { + it('returns a null type', function(done) { + backend.connect().fetchSnapshot('books', 'catch-22', null, function(error, snapshot) { expect(snapshot).to.eql({ id: 'catch-22', v: 2, @@ -344,8 +345,8 @@ describe('SnapshotVersionRequest', function () { }); }); - it('fetches v1', function (done) { - backend.connect().fetchSnapshot('books', 'catch-22', 1, function (error, snapshot) { + it('fetches v1', function(done) { + backend.connect().fetchSnapshot('books', 'catch-22', 1, function(error, snapshot) { if (error) return done(error); expect(snapshot).to.eql({ @@ -353,7 +354,7 @@ describe('SnapshotVersionRequest', function () { v: 1, type: 'http://sharejs.org/types/JSONv0', data: { - title: 'Catch 22', + title: 'Catch 22' }, m: null }); @@ -363,22 +364,22 @@ describe('SnapshotVersionRequest', function () { }); }); - describe('a document that was deleted and then created again', function () { - beforeEach(function (done) { + describe('a document that was deleted and then created again', function() { + beforeEach(function(done) { var doc = backend.connect().get('books', 'hitchhikers-guide'); - doc.create({ title: 'Hitchhiker\'s Guide to the Galaxy' }, function (error) { + doc.create({title: 'Hitchhiker\'s Guide to the Galaxy'}, function(error) { if (error) return done(error); - doc.del(function (error) { + doc.del(function(error) { if (error) return done(error); - doc.create({ title: 'The Restaurant at the End of the Universe' }, function (error) { + doc.create({title: 'The Restaurant at the End of the Universe'}, function(error) { done(error); }); }); }); }); - it('fetches the latest version of the document', function (done) { - backend.connect().fetchSnapshot('books', 'hitchhikers-guide', null, function (error, snapshot) { + it('fetches the latest version of the document', function(done) { + backend.connect().fetchSnapshot('books', 'hitchhikers-guide', null, function(error, snapshot) { if (error) return done(error); expect(snapshot).to.eql({ @@ -386,7 +387,7 @@ describe('SnapshotVersionRequest', function () { v: 3, type: 'http://sharejs.org/types/JSONv0', data: { - title: 'The Restaurant at the End of the Universe', + title: 'The Restaurant at the End of the Universe' }, m: null }); @@ -396,13 +397,13 @@ describe('SnapshotVersionRequest', function () { }); }); - describe('milestone snapshots enabled for every other version', function () { + describe('milestone snapshots enabled for every other version', function() { var milestoneDb; var db; var backendWithMilestones; - beforeEach(function () { - var options = { interval: 2 }; + beforeEach(function() { + var options = {interval: 2}; db = new MemoryDb(); milestoneDb = new MemoryMilestoneDb(options); backendWithMilestones = new Backend({ @@ -411,33 +412,33 @@ describe('SnapshotVersionRequest', function () { }); }); - afterEach(function (done) { + afterEach(function(done) { backendWithMilestones.close(done); }); - it('fetches a snapshot using the milestone', function (done) { + it('fetches a snapshot using the milestone', function(done) { var doc = backendWithMilestones.connect().get('books', 'mocking-bird'); util.callInSeries([ - function (next) { - doc.create({ title: 'To Kill a Mocking Bird' }, next); + function(next) { + doc.create({title: 'To Kill a Mocking Bird'}, next); }, - function (next) { - doc.submitOp({ p: ['author'], oi: 'Harper Lea' }, next); + function(next) { + doc.submitOp({p: ['author'], oi: 'Harper Lea'}, next); }, - function (next) { - doc.submitOp({ p: ['author'], od: 'Harper Lea', oi: 'Harper Lee' }, next); + function(next) { + doc.submitOp({p: ['author'], od: 'Harper Lea', oi: 'Harper Lee'}, next); }, - function (next) { + function(next) { sinon.spy(milestoneDb, 'getMilestoneSnapshot'); sinon.spy(db, 'getOps'); backendWithMilestones.connect().fetchSnapshot('books', 'mocking-bird', 3, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(milestoneDb.getMilestoneSnapshot.calledOnce).to.be(true); expect(db.getOps.calledWith('books', 'mocking-bird', 2, 3)).to.be(true); expect(snapshot.v).to.be(3); - expect(snapshot.data).to.eql({ title: 'To Kill a Mocking Bird', author: 'Harper Lee' }); + expect(snapshot.data).to.eql({title: 'To Kill a Mocking Bird', author: 'Harper Lee'}); next(); }, done diff --git a/test/client/submit.js b/test/client/submit.js index 82cecbbe3..592aa9eff 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -8,24 +8,34 @@ types.register(deserializedType.type2); types.register(numberType.type); module.exports = function() { -describe('client submit', function() { - - it('can fetch an uncreated doc', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - expect(doc.data).equal(undefined); - expect(doc.version).equal(null); - doc.fetch(function(err) { - if (err) return done(err); + describe('client submit', function() { + it('can fetch an uncreated doc', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); expect(doc.data).equal(undefined); - expect(doc.version).equal(0); - done(); + expect(doc.version).equal(null); + doc.fetch(function(err) { + if (err) return done(err); + expect(doc.data).equal(undefined); + expect(doc.version).equal(0); + done(); + }); + }); + + it('can fetch then create a new doc', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.fetch(function(err) { + if (err) return done(err); + doc.create({age: 3}, function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 3}); + expect(doc.version).eql(1); + done(); + }); + }); }); - }); - it('can fetch then create a new doc', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.fetch(function(err) { - if (err) return done(err); + it('can create a new doc without fetching', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); doc.create({age: 3}, function(err) { if (err) return done(err); expect(doc.data).eql({age: 3}); @@ -33,630 +43,603 @@ describe('client submit', function() { done(); }); }); - }); - it('can create a new doc without fetching', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - expect(doc.data).eql({age: 3}); - expect(doc.version).eql(1); - done(); - }); - }); + it('can create then delete then create a doc', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 3}); + expect(doc.version).eql(1); - it('can create then delete then create a doc', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - expect(doc.data).eql({age: 3}); - expect(doc.version).eql(1); + doc.del(null, function(err) { + if (err) return done(err); + expect(doc.data).eql(undefined); + expect(doc.version).eql(2); - doc.del(null, function(err) { - if (err) return done(err); - expect(doc.data).eql(undefined); - expect(doc.version).eql(2); + doc.create({age: 2}, function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 2}); + expect(doc.version).eql(3); + done(); + }); + }); + }); + }); - doc.create({age: 2}, function(err) { + it('can create then submit an op', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc.submitOp({p: ['age'], na: 2}, function(err) { if (err) return done(err); - expect(doc.data).eql({age: 2}); - expect(doc.version).eql(3); + expect(doc.data).eql({age: 5}); + expect(doc.version).eql(2); done(); }); }); }); - }); - it('can create then submit an op', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.submitOp({p: ['age'], na: 2}, function(err) { + it('can create then submit an op sync', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}); + expect(doc.data).eql({age: 3}); + expect(doc.version).eql(null); + doc.submitOp({p: ['age'], na: 2}); + expect(doc.data).eql({age: 5}); + expect(doc.version).eql(null); + doc.whenNothingPending(done); + }); + + it('submitting an op from a future version fails', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.data).eql({age: 5}); - expect(doc.version).eql(2); - done(); + doc.version++; + doc.submitOp({p: ['age'], na: 2}, function(err) { + expect(err).ok(); + done(); + }); }); }); - }); - - it('can create then submit an op sync', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}); - expect(doc.data).eql({age: 3}); - expect(doc.version).eql(null); - doc.submitOp({p: ['age'], na: 2}); - expect(doc.data).eql({age: 5}); - expect(doc.version).eql(null); - doc.whenNothingPending(done); - }); - it('submitting an op from a future version fails', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.version++; + it('cannot submit op on an uncreated doc', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); doc.submitOp({p: ['age'], na: 2}, function(err) { expect(err).ok(); done(); }); }); - }); - - it('cannot submit op on an uncreated doc', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.submitOp({p: ['age'], na: 2}, function(err) { - expect(err).ok(); - done(); - }); - }); - it('cannot delete an uncreated doc', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.del(function(err) { - expect(err).ok(); - done(); + it('cannot delete an uncreated doc', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.del(function(err) { + expect(err).ok(); + done(); + }); }); - }); - it('ops submitted sync get composed', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}); - doc.submitOp({p: ['age'], na: 2}); - doc.submitOp({p: ['age'], na: 2}, function(err) { - if (err) return done(err); - expect(doc.data).eql({age: 7}); - // Version is 1 instead of 3, because the create and ops got composed - expect(doc.version).eql(1); + it('ops submitted sync get composed', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}); doc.submitOp({p: ['age'], na: 2}); doc.submitOp({p: ['age'], na: 2}, function(err) { if (err) return done(err); - expect(doc.data).eql({age: 11}); - // Ops get composed - expect(doc.version).eql(2); + expect(doc.data).eql({age: 7}); + // Version is 1 instead of 3, because the create and ops got composed + expect(doc.version).eql(1); doc.submitOp({p: ['age'], na: 2}); - doc.del(function(err) { + doc.submitOp({p: ['age'], na: 2}, function(err) { if (err) return done(err); - expect(doc.data).eql(undefined); - // del DOES NOT get composed - expect(doc.version).eql(4); - done(); + expect(doc.data).eql({age: 11}); + // Ops get composed + expect(doc.version).eql(2); + doc.submitOp({p: ['age'], na: 2}); + doc.del(function(err) { + if (err) return done(err); + expect(doc.data).eql(undefined); + // del DOES NOT get composed + expect(doc.version).eql(4); + done(); + }); }); }); }); - }); - it('does not compose ops when doc.preventCompose is true', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.preventCompose = true; - doc.create({age: 3}); - doc.submitOp({p: ['age'], na: 2}); - doc.submitOp({p: ['age'], na: 2}, function(err) { - if (err) return done(err); - expect(doc.data).eql({age: 7}); - // Compare to version in above test - expect(doc.version).eql(3); + it('does not compose ops when doc.preventCompose is true', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.preventCompose = true; + doc.create({age: 3}); doc.submitOp({p: ['age'], na: 2}); doc.submitOp({p: ['age'], na: 2}, function(err) { if (err) return done(err); - expect(doc.data).eql({age: 11}); + expect(doc.data).eql({age: 7}); // Compare to version in above test - expect(doc.version).eql(5); - done(); + expect(doc.version).eql(3); + doc.submitOp({p: ['age'], na: 2}); + doc.submitOp({p: ['age'], na: 2}, function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 11}); + // Compare to version in above test + expect(doc.version).eql(5); + done(); + }); }); }); - }); - it('resumes composing after doc.preventCompose is set back to false', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.preventCompose = true; - doc.create({age: 3}); - doc.submitOp({p: ['age'], na: 2}); - doc.submitOp({p: ['age'], na: 2}, function(err) { - if (err) return done(err); - expect(doc.data).eql({age: 7}); - // Compare to version in above test - expect(doc.version).eql(3); - // Reset back to start composing ops again - doc.preventCompose = false; + it('resumes composing after doc.preventCompose is set back to false', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.preventCompose = true; + doc.create({age: 3}); doc.submitOp({p: ['age'], na: 2}); doc.submitOp({p: ['age'], na: 2}, function(err) { if (err) return done(err); - expect(doc.data).eql({age: 11}); + expect(doc.data).eql({age: 7}); // Compare to version in above test - expect(doc.version).eql(4); - done(); + expect(doc.version).eql(3); + // Reset back to start composing ops again + doc.preventCompose = false; + doc.submitOp({p: ['age'], na: 2}); + doc.submitOp({p: ['age'], na: 2}, function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 11}); + // Compare to version in above test + expect(doc.version).eql(4); + done(); + }); }); }); - }); - it('can create a new doc then fetch', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.fetch(function(err) { + it('can create a new doc then fetch', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.data).eql({age: 3}); - expect(doc.version).eql(1); - done(); + doc.fetch(function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 3}); + expect(doc.version).eql(1); + done(); + }); }); }); - }); - it('calling create on the same doc twice fails', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.create({age: 4}, function(err) { - expect(err).ok(); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 3}); - done(); + it('calling create on the same doc twice fails', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc.create({age: 4}, function(err) { + expect(err).ok(); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 3}); + done(); + }); }); }); - }); - it('trying to create an already created doc without fetching fails and fetches', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.create({age: 4}, function(err) { - expect(err).ok(); - expect(doc2.version).equal(1); - expect(doc2.data).eql({age: 3}); - done(); + it('trying to create an already created doc without fetching fails and fetches', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc2.create({age: 4}, function(err) { + expect(err).ok(); + expect(doc2.version).equal(1); + expect(doc2.data).eql({age: 3}); + done(); + }); }); }); - }); - it('server fetches and transforms by already committed op', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('server fetches and transforms by already committed op', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}, function(err) { + doc2.fetch(function(err) { if (err) return done(err); - doc2.submitOp({p: ['age'], na: 2}, function(err) { + doc.submitOp({p: ['age'], na: 1}, function(err) { if (err) return done(err); - expect(doc2.version).equal(3); - expect(doc2.data).eql({age: 6}); - done(); + doc2.submitOp({p: ['age'], na: 2}, function(err) { + if (err) return done(err); + expect(doc2.version).equal(3); + expect(doc2.data).eql({age: 6}); + done(); + }); }); }); }); }); - }); - it('submit fails if the server is missing ops required for transforming', function(done) { - this.backend.db.getOpsToSnapshot = function(collection, id, from, snapshot, options, callback) { - callback(null, []); - }; - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('submit fails if the server is missing ops required for transforming', function(done) { + this.backend.db.getOpsToSnapshot = function(collection, id, from, snapshot, options, callback) { + callback(null, []); + }; + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}, function(err) { + doc2.fetch(function(err) { if (err) return done(err); - doc2.submitOp({p: ['age'], na: 2}, function(err) { - expect(err).ok(); - done(); + doc.submitOp({p: ['age'], na: 1}, function(err) { + if (err) return done(err); + doc2.submitOp({p: ['age'], na: 2}, function(err) { + expect(err).ok(); + done(); + }); }); }); }); }); - }); - it('submit fails if ops returned are not the expected version', function(done) { - var getOpsToSnapshot = this.backend.db.getOpsToSnapshot; - this.backend.db.getOpsToSnapshot = function(collection, id, from, snapshot, options, callback) { - getOpsToSnapshot.call(this, collection, id, from, snapshot, options, function(err, ops) { - ops[0].v++; - callback(null, ops); - }); - }; - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('submit fails if ops returned are not the expected version', function(done) { + var getOpsToSnapshot = this.backend.db.getOpsToSnapshot; + this.backend.db.getOpsToSnapshot = function(collection, id, from, snapshot, options, callback) { + getOpsToSnapshot.call(this, collection, id, from, snapshot, options, function(err, ops) { + ops[0].v++; + callback(null, ops); + }); + }; + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}, function(err) { + doc2.fetch(function(err) { if (err) return done(err); - doc2.submitOp({p: ['age'], na: 2}, function(err) { - expect(err).ok(); - done(); + doc.submitOp({p: ['age'], na: 1}, function(err) { + if (err) return done(err); + doc2.submitOp({p: ['age'], na: 2}, function(err) { + expect(err).ok(); + done(); + }); }); }); }); }); - }); - function delayedReconnect(backend, connection) { + function delayedReconnect(backend, connection) { // Disconnect after the message has sent and before the server will have // had a chance to reply - process.nextTick(function() { - connection.close(); - // Reconnect once the server has a chance to save the op data - setTimeout(function() { - backend.connect(connection); - }, 100); - }); - } - - it('resends create when disconnected before ack', function(done) { - var backend = this.backend; - var doc = backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 3}); - done(); - }); - delayedReconnect(backend, doc.connection); - }); - - it('resent create on top of deleted doc gets proper starting version', function(done) { - var backend = this.backend; - var doc = backend.connect().get('dogs', 'fido'); - doc.create({age: 4}, function(err) { - if (err) return done(err); - doc.del(function(err) { - if (err) return done(err); - - var doc2 = backend.connect().get('dogs', 'fido'); - doc2.create({age: 3}, function(err) { - if (err) return done(err); - expect(doc2.version).equal(3); - expect(doc2.data).eql({age: 3}); - done(); - }); - delayedReconnect(backend, doc2.connection); + process.nextTick(function() { + connection.close(); + // Reconnect once the server has a chance to save the op data + setTimeout(function() { + backend.connect(connection); + }, 100); }); - }); - }); + } - it('resends delete when disconnected before ack', function(done) { - var backend = this.backend; - var doc = backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.del(function(err) { + it('resends create when disconnected before ack', function(done) { + var backend = this.backend; + var doc = backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.version).equal(2); - expect(doc.data).eql(undefined); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 3}); done(); }); delayedReconnect(backend, doc.connection); }); - }); - it('op submitted during inflight create does not compose and gets flushed', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}); - // Submit an op after message is sent but before server has a chance to reply - process.nextTick(function() { - doc.submitOp({p: ['age'], na: 2}, function(err) { + it('resent create on top of deleted doc gets proper starting version', function(done) { + var backend = this.backend; + var doc = backend.connect().get('dogs', 'fido'); + doc.create({age: 4}, function(err) { if (err) return done(err); - expect(doc.version).equal(2); - expect(doc.data).eql({age: 5}); - done(); + doc.del(function(err) { + if (err) return done(err); + + var doc2 = backend.connect().get('dogs', 'fido'); + doc2.create({age: 3}, function(err) { + if (err) return done(err); + expect(doc2.version).equal(3); + expect(doc2.data).eql({age: 3}); + done(); + }); + delayedReconnect(backend, doc2.connection); + }); }); }); - }); - it('can commit then fetch in a new connection to get the same data', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('resends delete when disconnected before ack', function(done) { + var backend = this.backend; + var doc = backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.data).eql({age: 3}); - expect(doc2.data).eql({age: 3}); - expect(doc.version).eql(1); - expect(doc2.version).eql(1); - expect(doc.data).not.equal(doc2.data); - done(); + doc.del(function(err) { + if (err) return done(err); + expect(doc.version).equal(2); + expect(doc.data).eql(undefined); + done(); + }); + delayedReconnect(backend, doc.connection); }); }); - }); - it('an op submitted concurrently is transformed by the first', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { - if (err) return done(err); - var count = 0; + it('op submitted during inflight create does not compose and gets flushed', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}); + // Submit an op after message is sent but before server has a chance to reply + process.nextTick(function() { doc.submitOp({p: ['age'], na: 2}, function(err) { - count++; - if (err) return done(err); - if (count === 1) { - expect(doc.data).eql({age: 5}); - expect(doc.version).eql(2); - } else { - expect(doc.data).eql({age: 12}); - expect(doc.version).eql(3); - done(); - } - }); - doc2.submitOp({p: ['age'], na: 7}, function(err) { - count++; if (err) return done(err); - if (count === 1) { - expect(doc2.data).eql({age: 10}); - expect(doc2.version).eql(2); - } else { - expect(doc2.data).eql({age: 12}); - expect(doc2.version).eql(3); - done(); - } + expect(doc.version).equal(2); + expect(doc.data).eql({age: 5}); + done(); }); }); }); - }); - it('second of two concurrent creates is rejected', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - var count = 0; - doc.create({age: 3}, function(err) { - count++; - if (count === 1) { - if (err) return done(err); - expect(doc.version).eql(1); - expect(doc.data).eql({age: 3}); - } else { - expect(err).ok(); - expect(doc.version).eql(1); - expect(doc.data).eql({age: 5}); - done(); - } - }); - doc2.create({age: 5}, function(err) { - count++; - if (count === 1) { + it('can commit then fetch in a new connection to get the same data', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc2.version).eql(1); - expect(doc2.data).eql({age: 5}); - } else { - expect(err).ok(); - expect(doc2.version).eql(1); - expect(doc2.data).eql({age: 3}); - done(); - } + doc2.fetch(function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 3}); + expect(doc2.data).eql({age: 3}); + expect(doc.version).eql(1); + expect(doc2.version).eql(1); + expect(doc.data).not.equal(doc2.data); + done(); + }); + }); }); - }); - it('concurrent delete operations transform', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('an op submitted concurrently is transformed by the first', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - var count = 0; - doc.del(function(err) { - count++; + doc2.fetch(function(err) { if (err) return done(err); - if (count === 1) { - expect(doc.version).eql(2); - expect(doc.data).eql(undefined); - } else { - expect(doc.version).eql(3); - expect(doc.data).eql(undefined); - done(); - } + var count = 0; + doc.submitOp({p: ['age'], na: 2}, function(err) { + count++; + if (err) return done(err); + if (count === 1) { + expect(doc.data).eql({age: 5}); + expect(doc.version).eql(2); + } else { + expect(doc.data).eql({age: 12}); + expect(doc.version).eql(3); + done(); + } + }); + doc2.submitOp({p: ['age'], na: 7}, function(err) { + count++; + if (err) return done(err); + if (count === 1) { + expect(doc2.data).eql({age: 10}); + expect(doc2.version).eql(2); + } else { + expect(doc2.data).eql({age: 12}); + expect(doc2.version).eql(3); + done(); + } + }); }); - doc2.del(function(err) { - count++; + }); + }); + + it('second of two concurrent creates is rejected', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + var count = 0; + doc.create({age: 3}, function(err) { + count++; + if (count === 1) { if (err) return done(err); - if (count === 1) { - expect(doc2.version).eql(2); - expect(doc2.data).eql(undefined); - } else { - expect(doc2.version).eql(3); - expect(doc2.data).eql(undefined); - done(); - } - }); + expect(doc.version).eql(1); + expect(doc.data).eql({age: 3}); + } else { + expect(err).ok(); + expect(doc.version).eql(1); + expect(doc.data).eql({age: 5}); + done(); + } + }); + doc2.create({age: 5}, function(err) { + count++; + if (count === 1) { + if (err) return done(err); + expect(doc2.version).eql(1); + expect(doc2.data).eql({age: 5}); + } else { + expect(err).ok(); + expect(doc2.version).eql(1); + expect(doc2.data).eql({age: 3}); + done(); + } }); }); - }); - it('submits retry below the backend.maxSubmitRetries threshold', function(done) { - this.backend.maxSubmitRetries = 10; - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('concurrent delete operations transform', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - var count = 0; - var cb = function(err) { - count++; + doc2.fetch(function(err) { if (err) return done(err); - if (count > 1) done(); - }; - doc.submitOp({p: ['age'], na: 2}, cb); - doc2.submitOp({p: ['age'], na: 7}, cb); + var count = 0; + doc.del(function(err) { + count++; + if (err) return done(err); + if (count === 1) { + expect(doc.version).eql(2); + expect(doc.data).eql(undefined); + } else { + expect(doc.version).eql(3); + expect(doc.data).eql(undefined); + done(); + } + }); + doc2.del(function(err) { + count++; + if (err) return done(err); + if (count === 1) { + expect(doc2.version).eql(2); + expect(doc2.data).eql(undefined); + } else { + expect(doc2.version).eql(3); + expect(doc2.data).eql(undefined); + done(); + } + }); + }); }); }); - }); - it('submits fail above the backend.maxSubmitRetries threshold', function(done) { - var backend = this.backend; - this.backend.maxSubmitRetries = 0; - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('submits retry below the backend.maxSubmitRetries threshold', function(done) { + this.backend.maxSubmitRetries = 10; + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - var docCallback; - var doc2Callback; - // The submit retry happens just after an op is committed. This hook into the middleware - // catches both ops just before they're about to be committed. This ensures that both ops - // are certainly working on the same snapshot (ie one op hasn't been committed before the - // other fetches the snapshot to apply to). By storing the callbacks, we can then - // manually trigger the callbacks, first calling doc, and when we know that's been committed, - // we then commit doc2. - backend.use('commit', function (request, callback) { - if (request.op.op[0].na === 2) docCallback = callback; - if (request.op.op[0].na === 7) doc2Callback = callback; - - // Wait until both ops have been applied to the same snapshot and are about to be committed - if (docCallback && doc2Callback) { - // Trigger the first op's commit and then the second one later, which will cause the - // second op to retry - docCallback(); - } - }); - doc.submitOp({p: ['age'], na: 2}, function (error) { - if (error) return done(error); - // When we know the first op has been committed, we try to commit the second op, which will - // fail because it's working on an out-of-date snapshot. It will retry, but exceed the - // maxSubmitRetries limit of 0 - doc2Callback(); - }); - doc2.submitOp({p: ['age'], na: 7}, function (error) { - expect(error).ok(); - done(); + doc2.fetch(function(err) { + if (err) return done(err); + var count = 0; + var cb = function(err) { + count++; + if (err) return done(err); + if (count > 1) done(); + }; + doc.submitOp({p: ['age'], na: 2}, cb); + doc2.submitOp({p: ['age'], na: 7}, cb); }); }); }); - }); - it('pending delete transforms incoming ops', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('submits fail above the backend.maxSubmitRetries threshold', function(done) { + var backend = this.backend; + this.backend.maxSubmitRetries = 0; + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.submitOp({p: ['age'], na: 1}, function(err) { + doc2.fetch(function(err) { if (err) return done(err); - async.parallel([ - function(cb) { doc.del(cb); }, - function(cb) { doc.create({age: 5}, cb); } - ], function(err) { - if (err) return done(err); - expect(doc.version).equal(4); - expect(doc.data).eql({age: 5}); + var docCallback; + var doc2Callback; + // The submit retry happens just after an op is committed. This hook into the middleware + // catches both ops just before they're about to be committed. This ensures that both ops + // are certainly working on the same snapshot (ie one op hasn't been committed before the + // other fetches the snapshot to apply to). By storing the callbacks, we can then + // manually trigger the callbacks, first calling doc, and when we know that's been committed, + // we then commit doc2. + backend.use('commit', function(request, callback) { + if (request.op.op[0].na === 2) docCallback = callback; + if (request.op.op[0].na === 7) doc2Callback = callback; + + // Wait until both ops have been applied to the same snapshot and are about to be committed + if (docCallback && doc2Callback) { + // Trigger the first op's commit and then the second one later, which will cause the + // second op to retry + docCallback(); + } + }); + doc.submitOp({p: ['age'], na: 2}, function(error) { + if (error) return done(error); + // When we know the first op has been committed, we try to commit the second op, which will + // fail because it's working on an out-of-date snapshot. It will retry, but exceed the + // maxSubmitRetries limit of 0 + doc2Callback(); + }); + doc2.submitOp({p: ['age'], na: 7}, function(error) { + expect(error).ok(); done(); }); }); }); }); - }); - it('pending delete transforms incoming delete', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('pending delete transforms incoming ops', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.del(function(err) { + doc2.fetch(function(err) { if (err) return done(err); - async.parallel([ - function(cb) { doc.del(cb); }, - function(cb) { doc.create({age: 5}, cb); } - ], function(err) { + doc2.submitOp({p: ['age'], na: 1}, function(err) { if (err) return done(err); - expect(doc.version).equal(4); - expect(doc.data).eql({age: 5}); - done(); + async.parallel([ + function(cb) { + doc.del(cb); + }, + function(cb) { + doc.create({age: 5}, cb); + } + ], function(err) { + if (err) return done(err); + expect(doc.version).equal(4); + expect(doc.data).eql({age: 5}); + done(); + }); }); }); }); }); - }); - it('submitting op after delete returns error', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('pending delete transforms incoming delete', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.del(function(err) { + doc2.fetch(function(err) { if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}, function(err) { - expect(err).ok(); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 3}); - done(); + doc2.del(function(err) { + if (err) return done(err); + async.parallel([ + function(cb) { + doc.del(cb); + }, + function(cb) { + doc.create({age: 5}, cb); + } + ], function(err) { + if (err) return done(err); + expect(doc.version).equal(4); + expect(doc.data).eql({age: 5}); + done(); + }); }); }); }); }); - }); - it('transforming pending op by server delete returns error', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('submitting op after delete returns error', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.del(function(err) { + doc2.fetch(function(err) { if (err) return done(err); - doc.pause(); - doc.submitOp({p: ['age'], na: 1}, function(err) { - expect(err.code).to.equal(4017); - expect(doc.version).equal(2); - expect(doc.data).eql(undefined); - done(); + doc2.del(function(err) { + if (err) return done(err); + doc.submitOp({p: ['age'], na: 1}, function(err) { + expect(err).ok(); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 3}); + done(); + }); }); - doc.fetch(); }); }); }); - }); - it('transforming pending op by server create returns error', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.del(function(err) { + it('transforming pending op by server delete returns error', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); - doc2.create({age: 5}, function(err) { + doc2.del(function(err) { if (err) return done(err); doc.pause(); - doc.create({age: 9}, function(err) { - expect(err.code).to.equal(4018); - expect(doc.version).equal(3); - expect(doc.data).eql({age: 5}); + doc.submitOp({p: ['age'], na: 1}, function(err) { + expect(err.code).to.equal(4017); + expect(doc.version).equal(2); + expect(doc.data).eql(undefined); done(); }); doc.fetch(); @@ -664,88 +647,117 @@ describe('client submit', function() { }); }); }); - }); - it('second client can create following delete', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.del(function(err) { + it('transforming pending op by server create returns error', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.create({age: 5}, function(err) { + doc.del(function(err) { if (err) return done(err); - expect(doc2.version).eql(3); - expect(doc2.data).eql({age: 5}); - done(); + doc2.fetch(function(err) { + if (err) return done(err); + doc2.create({age: 5}, function(err) { + if (err) return done(err); + doc.pause(); + doc.create({age: 9}, function(err) { + expect(err.code).to.equal(4018); + expect(doc.version).equal(3); + expect(doc.data).eql({age: 5}); + done(); + }); + doc.fetch(); + }); + }); }); }); }); - }); - it('doc.pause() prevents ops from being sent', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.pause(); - doc.create({age: 3}, done); - done(); - }); + it('second client can create following delete', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc.del(function(err) { + if (err) return done(err); + doc2.create({age: 5}, function(err) { + if (err) return done(err); + expect(doc2.version).eql(3); + expect(doc2.data).eql({age: 5}); + done(); + }); + }); + }); + }); - it('can call doc.resume() without pausing', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.resume(); - doc.create({age: 3}, done); - }); + it('doc.pause() prevents ops from being sent', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.pause(); + doc.create({age: 3}, done); + done(); + }); - it('doc.resume() resumes sending ops after pause', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.pause(); - doc.create({age: 3}, done); - doc.resume(); - }); + it('can call doc.resume() without pausing', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.resume(); + doc.create({age: 3}, done); + }); - it('pending ops are transformed by ops from other clients', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { - if (err) return done(err); - doc.pause(); - doc.submitOp({p: ['age'], na: 1}); - doc.submitOp({p: ['color'], oi: 'gold'}); - expect(doc.version).equal(1); + it('doc.resume() resumes sending ops after pause', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.pause(); + doc.create({age: 3}, done); + doc.resume(); + }); - doc2.submitOp({p: ['age'], na: 5}); - process.nextTick(function() { - doc2.submitOp({p: ['sex'], oi: 'female'}, function(err) { - if (err) return done(err); - expect(doc2.version).equal(3); + it('pending ops are transformed by ops from other clients', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc2.fetch(function(err) { + if (err) return done(err); + doc.pause(); + doc.submitOp({p: ['age'], na: 1}); + doc.submitOp({p: ['color'], oi: 'gold'}); + expect(doc.version).equal(1); - async.parallel([ - function(cb) { doc.fetch(cb); }, - function(cb) { doc2.fetch(cb); } - ], function(err) { + doc2.submitOp({p: ['age'], na: 5}); + process.nextTick(function() { + doc2.submitOp({p: ['sex'], oi: 'female'}, function(err) { if (err) return done(err); - expect(doc.data).eql({age: 9, color: 'gold', sex: 'female'}); - expect(doc.version).equal(3); - expect(doc.hasPending()).equal(true); - - expect(doc2.data).eql({age: 8, sex: 'female'}); expect(doc2.version).equal(3); - expect(doc2.hasPending()).equal(false); - doc.resume(); - doc.whenNothingPending(function() { - doc2.fetch(function(err) { - if (err) return done(err); - expect(doc.data).eql({age: 9, color: 'gold', sex: 'female'}); - expect(doc.version).equal(4); - expect(doc.hasPending()).equal(false); - - expect(doc2.data).eql({age: 9, color: 'gold', sex: 'female'}); - expect(doc2.version).equal(4); - expect(doc2.hasPending()).equal(false); - done(); + async.parallel([ + function(cb) { + doc.fetch(cb); + }, + function(cb) { + doc2.fetch(cb); + } + ], function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 9, color: 'gold', sex: 'female'}); + expect(doc.version).equal(3); + expect(doc.hasPending()).equal(true); + + expect(doc2.data).eql({age: 8, sex: 'female'}); + expect(doc2.version).equal(3); + expect(doc2.hasPending()).equal(false); + + doc.resume(); + doc.whenNothingPending(function() { + doc2.fetch(function(err) { + if (err) return done(err); + expect(doc.data).eql({age: 9, color: 'gold', sex: 'female'}); + expect(doc.version).equal(4); + expect(doc.hasPending()).equal(false); + + expect(doc2.data).eql({age: 9, color: 'gold', sex: 'female'}); + expect(doc2.version).equal(4); + expect(doc2.hasPending()).equal(false); + done(); + }); }); }); }); @@ -753,424 +765,453 @@ describe('client submit', function() { }); }); }); - }); - it('snapshot fetch does not revert the version of deleted doc without pending ops', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - this.backend.use('doc', function(request, next) { - doc.create({age: 3}); - doc.del(next); - }); - doc.fetch(function(err) { - if (err) return done(err); - expect(doc.version).equal(2); - done(); + it('snapshot fetch does not revert the version of deleted doc without pending ops', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + this.backend.use('doc', function(request, next) { + doc.create({age: 3}); + doc.del(next); + }); + doc.fetch(function(err) { + if (err) return done(err); + expect(doc.version).equal(2); + done(); + }); }); - }); - it('snapshot fetch does not revert the version of deleted doc with pending ops', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - this.backend.use('doc', function(request, next) { - doc.create({age: 3}, function(err) { - if (err) return done(err); - next(); + it('snapshot fetch does not revert the version of deleted doc with pending ops', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + this.backend.use('doc', function(request, next) { + doc.create({age: 3}, function(err) { + if (err) return done(err); + next(); + }); + process.nextTick(function() { + doc.pause(); + doc.del(done); + }); }); - process.nextTick(function() { - doc.pause(); - doc.del(done); + doc.fetch(function(err) { + if (err) return done(err); + expect(doc.version).equal(1); + doc.resume(); }); }); - doc.fetch(function(err) { - if (err) return done(err); - expect(doc.version).equal(1); - doc.resume(); - }); - }); - it('snapshot fetch from query does not advance version of doc with pending ops', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({name: 'kido'}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it('snapshot fetch from query does not advance version of doc with pending ops', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({name: 'kido'}, function(err) { if (err) return done(err); - doc2.submitOp({p: ['name', 0], si: 'f'}, function(err) { + doc2.fetch(function(err) { if (err) return done(err); - expect(doc2.data).eql({name: 'fkido'}); - doc.connection.createFetchQuery('dogs', {}, null, function(err) { + doc2.submitOp({p: ['name', 0], si: 'f'}, function(err) { if (err) return done(err); - doc.resume(); + expect(doc2.data).eql({name: 'fkido'}); + doc.connection.createFetchQuery('dogs', {}, null, function(err) { + if (err) return done(err); + doc.resume(); + }); }); }); }); - }); - process.nextTick(function() { - doc.pause(); - doc.submitOp({p: ['name', 0], sd: 'k'}, function(err) { - if (err) return done(err); + process.nextTick(function() { doc.pause(); - doc2.fetch(function(err) { + doc.submitOp({p: ['name', 0], sd: 'k'}, function(err) { if (err) return done(err); - expect(doc2.version).equal(3); - expect(doc2.data).eql({name: 'fido'}); - done(); - }); - }); - doc.del(); - }); - }); - - it('passing an error in submit middleware rejects a create and calls back with the erorr', function(done) { - this.backend.use('submit', function(request, next) { - next({message: 'Custom error'}); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - expect(err.message).equal('Custom error'); - expect(doc.version).equal(0); - expect(doc.data).equal(undefined); - done(); - }); - expect(doc.version).equal(null); - expect(doc.data).eql({age: 3}); - }); - - it('passing an error in submit middleware rejects a create and throws the erorr', function(done) { - this.backend.use('submit', function(request, next) { - next({message: 'Custom error'}); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}); - expect(doc.version).equal(null); - expect(doc.data).eql({age: 3}); - doc.on('error', function(err) { - expect(err.message).equal('Custom error'); - expect(doc.version).equal(0); - expect(doc.data).equal(undefined); - done(); - }); - }); - - it('passing an error in submit middleware rejects pending ops after failed create', function(done) { - var submitCount = 0; - this.backend.use('submit', function(request, next) { - submitCount++; - if (submitCount === 1) return next({message: 'Custom error'}); - next(); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - async.parallel([ - function(cb) { - doc.create({age: 3}, function(err) { - expect(err.message).equal('Custom error'); - expect(doc.version).equal(0); - expect(doc.data).equal(undefined); - cb(); - }); - expect(doc.version).equal(null); - expect(doc.data).eql({age: 3}); - }, - function(cb) { - process.nextTick(function() { - doc.submitOp({p: ['age'], na: 1}, function(err) { - expect(err.message).equal('Custom error'); - expect(doc.version).equal(0); - expect(doc.data).equal(undefined); - expect(submitCount).equal(1); - cb(); + doc.pause(); + doc2.fetch(function(err) { + if (err) return done(err); + expect(doc2.version).equal(3); + expect(doc2.data).eql({name: 'fido'}); + done(); }); - expect(doc.version).equal(null); - expect(doc.data).eql({age: 4}); }); - } - ], done); - }); - - it('request.rejectedError() soft rejects a create', function(done) { - this.backend.use('submit', function(request, next) { - next(request.rejectedError()); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - expect(doc.version).equal(0); - expect(doc.data).equal(undefined); - done(); - }); - expect(doc.version).equal(null); - expect(doc.data).eql({age: 3}); - }); - - it('request.rejectedError() soft rejects a create without callback', function(done) { - this.backend.use('submit', function(request, next) { - next(request.rejectedError()); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}); - expect(doc.version).equal(null); - expect(doc.data).eql({age: 3}); - doc.whenNothingPending(function() { - expect(doc.version).equal(0); - expect(doc.data).equal(undefined); - done(); + doc.del(); + }); }); - }); - it('passing an error in submit middleware rejects an op and calls back with the erorr', function(done) { - this.backend.use('submit', function(request, next) { - if (request.op.op) return next({message: 'Custom error'}); - next(); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}, function(err) { + it('passing an error in submit middleware rejects a create and calls back with the erorr', function(done) { + this.backend.use('submit', function(request, next) { + next({message: 'Custom error'}); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { expect(err.message).equal('Custom error'); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 3}); + expect(doc.version).equal(0); + expect(doc.data).equal(undefined); done(); }); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 4}); + expect(doc.version).equal(null); + expect(doc.data).eql({age: 3}); }); - }); - it('passing an error in submit middleware rejects an op and emits the erorr', function(done) { - this.backend.use('submit', function(request, next) { - if (request.op.op) return next({message: 'Custom error'}); - next(); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 4}); + it('passing an error in submit middleware rejects a create and throws the erorr', function(done) { + this.backend.use('submit', function(request, next) { + next({message: 'Custom error'}); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}); + expect(doc.version).equal(null); + expect(doc.data).eql({age: 3}); doc.on('error', function(err) { expect(err.message).equal('Custom error'); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 3}); + expect(doc.version).equal(0); + expect(doc.data).equal(undefined); done(); }); }); - }); - it('passing an error in submit middleware transforms pending ops after failed op', function(done) { - var submitCount = 0; - this.backend.use('submit', function(request, next) { - submitCount++; - if (submitCount === 2) return next({message: 'Custom error'}); - next(); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); + it('passing an error in submit middleware rejects pending ops after failed create', function(done) { + var submitCount = 0; + this.backend.use('submit', function(request, next) { + submitCount++; + if (submitCount === 1) return next({message: 'Custom error'}); + next(); + }); + var doc = this.backend.connect().get('dogs', 'fido'); async.parallel([ function(cb) { - doc.submitOp({p: ['age'], na: 1}, function(err) { + doc.create({age: 3}, function(err) { expect(err.message).equal('Custom error'); + expect(doc.version).equal(0); + expect(doc.data).equal(undefined); cb(); }); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 4}); + expect(doc.version).equal(null); + expect(doc.data).eql({age: 3}); }, function(cb) { process.nextTick(function() { - doc.submitOp({p: ['age'], na: 5}, cb); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 9}); + doc.submitOp({p: ['age'], na: 1}, function(err) { + expect(err.message).equal('Custom error'); + expect(doc.version).equal(0); + expect(doc.data).equal(undefined); + expect(submitCount).equal(1); + cb(); + }); + expect(doc.version).equal(null); + expect(doc.data).eql({age: 4}); }); } - ], function(err) { + ], done); + }); + + it('request.rejectedError() soft rejects a create', function(done) { + this.backend.use('submit', function(request, next) { + next(request.rejectedError()); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.version).equal(2); - expect(doc.data).eql({age: 8}); - expect(submitCount).equal(3); + expect(doc.version).equal(0); + expect(doc.data).equal(undefined); done(); }); + expect(doc.version).equal(null); + expect(doc.data).eql({age: 3}); }); - }); - it('request.rejectedError() soft rejects an op', function(done) { - this.backend.use('submit', function(request, next) { - if (request.op.op) return next(request.rejectedError()); - next(); + it('request.rejectedError() soft rejects a create without callback', function(done) { + this.backend.use('submit', function(request, next) { + next(request.rejectedError()); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}); + expect(doc.version).equal(null); + expect(doc.data).eql({age: 3}); + doc.whenNothingPending(function() { + expect(doc.version).equal(0); + expect(doc.data).equal(undefined); + done(); + }); }); - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}, function(err) { + + it('passing an error in submit middleware rejects an op and calls back with the erorr', function(done) { + this.backend.use('submit', function(request, next) { + if (request.op.op) return next({message: 'Custom error'}); + next(); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); + doc.submitOp({p: ['age'], na: 1}, function(err) { + expect(err.message).equal('Custom error'); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 3}); + done(); + }); expect(doc.version).equal(1); - expect(doc.data).eql({age: 3}); - done(); + expect(doc.data).eql({age: 4}); }); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 4}); }); - }); - it('request.rejectedError() soft rejects an op without callback', function(done) { - this.backend.use('submit', function(request, next) { - if (request.op.op) return next(request.rejectedError()); - next(); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 4}); - doc.whenNothingPending(function() { + it('passing an error in submit middleware rejects an op and emits the erorr', function(done) { + this.backend.use('submit', function(request, next) { + if (request.op.op) return next({message: 'Custom error'}); + next(); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc.submitOp({p: ['age'], na: 1}); expect(doc.version).equal(1); - expect(doc.data).eql({age: 3}); - done(); + expect(doc.data).eql({age: 4}); + doc.on('error', function(err) { + expect(err.message).equal('Custom error'); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 3}); + done(); + }); }); }); - }); - it('setting op.op to null makes it a no-op while returning success to the submitting client', function(done) { - this.backend.use('submit', function(request, next) { - if (request.op) request.op.op = null; - next(); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}, function(err) { + it('passing an error in submit middleware transforms pending ops after failed op', function(done) { + var submitCount = 0; + this.backend.use('submit', function(request, next) { + submitCount++; + if (submitCount === 2) return next({message: 'Custom error'}); + next(); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.version).equal(2); - expect(doc.data).eql({age: 4}); - doc2.fetch(function(err) { + async.parallel([ + function(cb) { + doc.submitOp({p: ['age'], na: 1}, function(err) { + expect(err.message).equal('Custom error'); + cb(); + }); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 4}); + }, + function(cb) { + process.nextTick(function() { + doc.submitOp({p: ['age'], na: 5}, cb); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 9}); + }); + } + ], function(err) { if (err) return done(err); - expect(doc2.version).equal(2); - expect(doc2.data).eql({age: 3}); + expect(doc.version).equal(2); + expect(doc.data).eql({age: 8}); + expect(submitCount).equal(3); done(); }); }); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 4}); }); - }); - it('submitting an invalid op message returns error', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc._submit({}, null, function(err) { - expect(err).ok(); - done(); + it('request.rejectedError() soft rejects an op', function(done) { + this.backend.use('submit', function(request, next) { + if (request.op.op) return next(request.rejectedError()); + next(); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc.submitOp({p: ['age'], na: 1}, function(err) { + if (err) return done(err); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 3}); + done(); + }); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 4}); }); }); - }); - it('allows snapshot and op to be a non-object', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create(5, numberType.type.uri, function (err) { - if (err) return done(err); - expect(doc.data).to.equal(5); - doc.submitOp(2, function(err) { + it('request.rejectedError() soft rejects an op without callback', function(done) { + this.backend.use('submit', function(request, next) { + if (request.op.op) return next(request.rejectedError()); + next(); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.data).to.equal(7); - done(); + doc.submitOp({p: ['age'], na: 1}); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 4}); + doc.whenNothingPending(function() { + expect(doc.version).equal(1); + expect(doc.data).eql({age: 3}); + done(); + }); }); }); - }); - describe('type.deserialize', function() { - it('can create a new doc', function(done) { + it('setting op.op to null makes it a no-op while returning success to the submitting client', function(done) { + this.backend.use('submit', function(request, next) { + if (request.op) request.op.op = null; + next(); + }); var doc = this.backend.connect().get('dogs', 'fido'); - doc.create([3], deserializedType.type.uri, function(err) { + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.data).a(deserializedType.Node); - expect(doc.data).eql({value: 3, next: null}); - done(); + doc.submitOp({p: ['age'], na: 1}, function(err) { + if (err) return done(err); + expect(doc.version).equal(2); + expect(doc.data).eql({age: 4}); + doc2.fetch(function(err) { + if (err) return done(err); + expect(doc2.version).equal(2); + expect(doc2.data).eql({age: 3}); + done(); + }); + }); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 4}); }); }); - it('is stored serialized in backend', function(done) { - var db = this.backend.db; + it('submitting an invalid op message returns error', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); - doc.create([3], deserializedType.type.uri, function(err) { + doc.create({age: 3}, function(err) { if (err) return done(err); - db.getSnapshot('dogs', 'fido', null, null, function(err, snapshot) { - if (err) return done(err); - expect(snapshot.data).eql([3]); + doc._submit({}, null, function(err) { + expect(err).ok(); done(); }); }); }); - it('deserializes on fetch', function(done) { + it('allows snapshot and op to be a non-object', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - var backend = this.backend; - doc.create([3], deserializedType.type.uri, function(err) { + doc.create(5, numberType.type.uri, function(err) { if (err) return done(err); - doc2.fetch(function(err) { + expect(doc.data).to.equal(5); + doc.submitOp(2, function(err) { if (err) return done(err); - expect(doc2.data).a(deserializedType.Node); - expect(doc2.data).eql({value: 3, next: null}); + expect(doc.data).to.equal(7); done(); }); }); }); - it('can create then submit an op', function(done) { + it('hasWritePending is false when create\'s callback is executed', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + expect(doc.hasWritePending()).equal(false); + done(); + }); + }); + + it('hasWritePending is false when submimtOp\'s callback is executed', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); - doc.create([3], deserializedType.type.uri, function(err) { + doc.create({age: 3}, function(err) { if (err) return done(err); - doc.submitOp({insert: 0, value: 2}, function(err) { + doc.submitOp({p: ['age'], na: 2}, function(err) { if (err) return done(err); - expect(doc.data).eql({value: 2, next: {value: 3, next: null}}); + expect(doc.hasWritePending()).equal(false); done(); }); }); }); - it('server fetches and transforms by already committed op', function(done) { + it('hasWritePending is false when del\'s callback is executed', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - var backend = this.backend; - doc.create([3], deserializedType.type.uri, function(err) { + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.fetch(function(err) { + doc.del(function(err) { + if (err) return done(err); + expect(doc.hasWritePending()).equal(false); + done(); + }); + }); + }); + + describe('type.deserialize', function() { + it('can create a new doc', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create([3], deserializedType.type.uri, function(err) { + if (err) return done(err); + expect(doc.data).a(deserializedType.Node); + expect(doc.data).eql({value: 3, next: null}); + done(); + }); + }); + + it('is stored serialized in backend', function(done) { + var db = this.backend.db; + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create([3], deserializedType.type.uri, function(err) { + if (err) return done(err); + db.getSnapshot('dogs', 'fido', null, null, function(err, snapshot) { + if (err) return done(err); + expect(snapshot.data).eql([3]); + done(); + }); + }); + }); + + it('deserializes on fetch', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create([3], deserializedType.type.uri, function(err) { + if (err) return done(err); + doc2.fetch(function(err) { + if (err) return done(err); + expect(doc2.data).a(deserializedType.Node); + expect(doc2.data).eql({value: 3, next: null}); + done(); + }); + }); + }); + + it('can create then submit an op', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create([3], deserializedType.type.uri, function(err) { if (err) return done(err); doc.submitOp({insert: 0, value: 2}, function(err) { if (err) return done(err); - doc2.submitOp({insert: 1, value: 4}, function(err) { + expect(doc.data).eql({value: 2, next: {value: 3, next: null}}); + done(); + }); + }); + }); + + it('server fetches and transforms by already committed op', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create([3], deserializedType.type.uri, function(err) { + if (err) return done(err); + doc2.fetch(function(err) { + if (err) return done(err); + doc.submitOp({insert: 0, value: 2}, function(err) { if (err) return done(err); - expect(doc2.data).eql({value: 2, next: {value: 3, next: {value: 4, next: null}}}); - done(); + doc2.submitOp({insert: 1, value: 4}, function(err) { + if (err) return done(err); + expect(doc2.data).eql({value: 2, next: {value: 3, next: {value: 4, next: null}}}); + done(); + }); }); }); }); }); }); - }); - describe('type.createDeserialized', function() { - it('can create a new doc', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create([3], deserializedType.type2.uri, function(err) { - if (err) return done(err); - expect(doc.data).a(deserializedType.Node); - expect(doc.data).eql({value: 3, next: null}); - done(); + describe('type.createDeserialized', function() { + it('can create a new doc', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create([3], deserializedType.type2.uri, function(err) { + if (err) return done(err); + expect(doc.data).a(deserializedType.Node); + expect(doc.data).eql({value: 3, next: null}); + done(); + }); }); - }); - it('can create a new doc from deserialized form', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create(new deserializedType.Node(3), deserializedType.type2.uri, function(err) { - if (err) return done(err); - expect(doc.data).a(deserializedType.Node); - expect(doc.data).eql({value: 3, next: null}); - done(); + it('can create a new doc from deserialized form', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create(new deserializedType.Node(3), deserializedType.type2.uri, function(err) { + if (err) return done(err); + expect(doc.data).a(deserializedType.Node); + expect(doc.data).eql({value: 3, next: null}); + done(); + }); }); }); }); - -}); }; diff --git a/test/client/subscribe.js b/test/client/subscribe.js index b24a94749..2af398681 100644 --- a/test/client/subscribe.js +++ b/test/client/subscribe.js @@ -2,627 +2,688 @@ var expect = require('expect.js'); var async = require('async'); module.exports = function() { -describe('client subscribe', function() { + describe('client subscribe', function() { + it('can call bulk without doing any actions', function() { + var connection = this.backend.connect(); + connection.startBulk(); + connection.endBulk(); + }); - it('can call bulk without doing any actions', function() { - var connection = this.backend.connect(); - connection.startBulk(); - connection.endBulk(); - }); + ['fetch', 'subscribe'].forEach(function(method) { + it(method + ' gets initial data', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc2[method](function(err) { + if (err) return done(err); + expect(doc2.version).eql(1); + expect(doc2.data).eql({age: 3}); + done(); + }); + }); + }); - ['fetch', 'subscribe'].forEach(function(method) { - it(method + ' gets initial data', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2[method](function(err) { + it(method + ' twice simultaneously calls back', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc2.version).eql(1); - expect(doc2.data).eql({age: 3}); - done(); + async.parallel([ + function(cb) { + doc2[method](cb); + }, + function(cb) { + doc2[method](cb); + } + ], function(err) { + if (err) return done(err); + expect(doc2.version).eql(1); + expect(doc2.data).eql({age: 3}); + done(); + }); }); }); - }); - it(method + ' twice simultaneously calls back', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - async.parallel([ - function(cb) { doc2[method](cb); }, - function(cb) { doc2[method](cb); } - ], function(err) { + it(method + ' twice in bulk simultaneously calls back', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc2.version).eql(1); - expect(doc2.data).eql({age: 3}); - done(); + doc2.connection.startBulk(); + async.parallel([ + function(cb) { + doc2[method](cb); + }, + function(cb) { + doc2[method](cb); + } + ], function(err) { + if (err) return done(err); + expect(doc2.version).eql(1); + expect(doc2.data).eql({age: 3}); + done(); + }); + doc2.connection.endBulk(); }); }); - }); - it(method + ' twice in bulk simultaneously calls back', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.connection.startBulk(); + it(method + ' bulk on same collection', function(done) { + var connection = this.backend.connect(); + var connection2 = this.backend.connect(); async.parallel([ - function(cb) { doc2[method](cb); }, - function(cb) { doc2[method](cb); } + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + }, + function(cb) { + connection.get('cats', 'finn').create({age: 2}, cb); + } ], function(err) { if (err) return done(err); - expect(doc2.version).eql(1); - expect(doc2.data).eql({age: 3}); - done(); + var fido = connection2.get('dogs', 'fido'); + var spot = connection2.get('dogs', 'spot'); + var finn = connection2.get('cats', 'finn'); + connection2.startBulk(); + async.parallel([ + function(cb) { + fido[method](cb); + }, + function(cb) { + spot[method](cb); + }, + function(cb) { + finn[method](cb); + } + ], function(err) { + if (err) return done(err); + expect(fido.data).eql({age: 3}); + expect(spot.data).eql({age: 5}); + expect(finn.data).eql({age: 2}); + done(); + }); + connection2.endBulk(); }); - doc2.connection.endBulk(); }); - }); - it(method + ' bulk on same collection', function(done) { - var connection = this.backend.connect(); - var connection2 = this.backend.connect(); - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); }, - function(cb) { connection.get('cats', 'finn').create({age: 2}, cb); } - ], function(err) { - if (err) return done(err); + it(method + ' bulk on same collection from known version', function(done) { + var connection = this.backend.connect(); + var connection2 = this.backend.connect(); var fido = connection2.get('dogs', 'fido'); var spot = connection2.get('dogs', 'spot'); var finn = connection2.get('cats', 'finn'); connection2.startBulk(); async.parallel([ - function(cb) { fido[method](cb); }, - function(cb) { spot[method](cb); }, - function(cb) { finn[method](cb); } + function(cb) { + fido[method](cb); + }, + function(cb) { + spot[method](cb); + }, + function(cb) { + finn[method](cb); + } ], function(err) { if (err) return done(err); - expect(fido.data).eql({age: 3}); - expect(spot.data).eql({age: 5}); - expect(finn.data).eql({age: 2}); - done(); - }); - connection2.endBulk(); - }); - }); + expect(fido.version).equal(0); + expect(spot.version).equal(0); + expect(finn.version).equal(0); + expect(fido.data).equal(undefined); + expect(spot.data).equal(undefined); + expect(finn.data).equal(undefined); - it(method + ' bulk on same collection from known version', function(done) { - var connection = this.backend.connect(); - var connection2 = this.backend.connect(); - var fido = connection2.get('dogs', 'fido'); - var spot = connection2.get('dogs', 'spot'); - var finn = connection2.get('cats', 'finn'); - connection2.startBulk(); - async.parallel([ - function(cb) { fido[method](cb); }, - function(cb) { spot[method](cb); }, - function(cb) { finn[method](cb); } - ], function(err) { - if (err) return done(err); - expect(fido.version).equal(0); - expect(spot.version).equal(0); - expect(finn.version).equal(0); - expect(fido.data).equal(undefined); - expect(spot.data).equal(undefined); - expect(finn.data).equal(undefined); - - async.parallel([ - function(cb) { connection.get('dogs', 'fido').create({age: 3}, cb); }, - function(cb) { connection.get('dogs', 'spot').create({age: 5}, cb); }, - function(cb) { connection.get('cats', 'finn').create({age: 2}, cb); } - ], function(err) { - if (err) return done(err); - connection2.startBulk(); async.parallel([ - function(cb) { fido[method](cb); }, - function(cb) { spot[method](cb); }, - function(cb) { finn[method](cb); } + function(cb) { + connection.get('dogs', 'fido').create({age: 3}, cb); + }, + function(cb) { + connection.get('dogs', 'spot').create({age: 5}, cb); + }, + function(cb) { + connection.get('cats', 'finn').create({age: 2}, cb); + } ], function(err) { if (err) return done(err); - expect(fido.data).eql({age: 3}); - expect(spot.data).eql({age: 5}); - expect(finn.data).eql({age: 2}); - - // Test sending a fetch without any new ops being created connection2.startBulk(); async.parallel([ - function(cb) { fido[method](cb); }, - function(cb) { spot[method](cb); }, - function(cb) { finn[method](cb); } + function(cb) { + fido[method](cb); + }, + function(cb) { + spot[method](cb); + }, + function(cb) { + finn[method](cb); + } ], function(err) { if (err) return done(err); + expect(fido.data).eql({age: 3}); + expect(spot.data).eql({age: 5}); + expect(finn.data).eql({age: 2}); - // Create new ops and test if they are received + // Test sending a fetch without any new ops being created + connection2.startBulk(); async.parallel([ - function(cb) { connection.get('dogs', 'fido').submitOp([{p: ['age'], na: 1}], cb); }, - function(cb) { connection.get('dogs', 'spot').submitOp([{p: ['age'], na: 1}], cb); }, - function(cb) { connection.get('cats', 'finn').submitOp([{p: ['age'], na: 1}], cb); } + function(cb) { + fido[method](cb); + }, + function(cb) { + spot[method](cb); + }, + function(cb) { + finn[method](cb); + } ], function(err) { if (err) return done(err); - connection2.startBulk(); + + // Create new ops and test if they are received async.parallel([ - function(cb) { fido[method](cb); }, - function(cb) { spot[method](cb); }, - function(cb) { finn[method](cb); } + function(cb) { + connection.get('dogs', 'fido').submitOp([{p: ['age'], na: 1}], cb); + }, + function(cb) { + connection.get('dogs', 'spot').submitOp([{p: ['age'], na: 1}], cb); + }, + function(cb) { + connection.get('cats', 'finn').submitOp([{p: ['age'], na: 1}], cb); + } ], function(err) { if (err) return done(err); - expect(fido.data).eql({age: 4}); - expect(spot.data).eql({age: 6}); - expect(finn.data).eql({age: 3}); - done(); + connection2.startBulk(); + async.parallel([ + function(cb) { + fido[method](cb); + }, + function(cb) { + spot[method](cb); + }, + function(cb) { + finn[method](cb); + } + ], function(err) { + if (err) return done(err); + expect(fido.data).eql({age: 4}); + expect(spot.data).eql({age: 6}); + expect(finn.data).eql({age: 3}); + done(); + }); + connection2.endBulk(); }); - connection2.endBulk(); }); + connection2.endBulk(); }); connection2.endBulk(); }); - connection2.endBulk(); }); + connection2.endBulk(); }); - connection2.endBulk(); - }); - it(method + ' gets new ops', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.fetch(function(err) { + it(method + ' gets new ops', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}, function(err) { + doc2.fetch(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { - done(); + doc.submitOp({p: ['age'], na: 1}, function(err) { + if (err) return done(err); + doc2.on('op', function() { + done(); + }); + doc2[method](); }); - doc2[method](); }); }); }); - }); - it(method + ' calls back after reconnect', function(done) { - var backend = this.backend; - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2[method](function(err) { + it(method + ' calls back after reconnect', function(done) { + var backend = this.backend; + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc2.version).eql(1); - expect(doc2.data).eql({age: 3}); - done(); - }); - doc2.connection.close(); - process.nextTick(function() { - backend.connect(doc2.connection); + doc2[method](function(err) { + if (err) return done(err); + expect(doc2.version).eql(1); + expect(doc2.data).eql({age: 3}); + done(); + }); + doc2.connection.close(); + process.nextTick(function() { + backend.connect(doc2.connection); + }); }); }); - }); - it(method + ' returns error passed to doc read middleware', function(done) { - this.backend.use('doc', function(request, next) { - next({message: 'Reject doc read'}); - }); - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2[method](function(err) { - expect(err.message).equal('Reject doc read'); - expect(doc2.version).eql(null); - expect(doc2.data).eql(undefined); - done(); + it(method + ' returns error passed to doc read middleware', function(done) { + this.backend.use('doc', function(request, next) { + next({message: 'Reject doc read'}); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc2[method](function(err) { + expect(err.message).equal('Reject doc read'); + expect(doc2.version).eql(null); + expect(doc2.data).eql(undefined); + done(); + }); }); }); - }); - it(method + ' emits error passed to doc read middleware', function(done) { - this.backend.use('doc', function(request, next) { - next({message: 'Reject doc read'}); + it(method + ' emits error passed to doc read middleware', function(done) { + this.backend.use('doc', function(request, next) { + next({message: 'Reject doc read'}); + }); + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc2[method](); + doc2.on('error', function(err) { + expect(err.message).equal('Reject doc read'); + expect(doc2.version).eql(null); + expect(doc2.data).eql(undefined); + done(); + }); + }); }); - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2[method](); - doc2.on('error', function(err) { - expect(err.message).equal('Reject doc read'); - expect(doc2.version).eql(null); - expect(doc2.data).eql(undefined); - done(); + + it(method + ' will call back when ops are pending', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc.pause(); + doc.submitOp({p: ['age'], na: 1}); + doc[method](done); }); }); - }); - it(method + ' will call back when ops are pending', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); + it(method + ' will not call back when creating the doc is pending', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); doc.pause(); - doc.submitOp({p: ['age'], na: 1}); + doc.create({age: 3}); doc[method](done); + // HACK: Delay done call to keep from closing the db connection too soon + setTimeout(done, 10); }); - }); - - it(method + ' will not call back when creating the doc is pending', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.pause(); - doc.create({age: 3}); - doc[method](done); - // HACK: Delay done call to keep from closing the db connection too soon - setTimeout(done, 10); - }); - - it(method + ' will wait for write when doc is locally created', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.pause(); - var calls = 0; - doc.create({age: 3}, function(err) { - if (err) return done(err); - calls++; - }); - doc[method](function(err) { - if (err) return done(err); - expect(calls).equal(1); - expect(doc.version).equal(1); - expect(doc.data).eql({age: 3}); - done(); - }); - setTimeout(function() { - doc.resume(); - }, 10); - }); - it(method + ' will wait for write when doc is locally created and will fail to submit', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc2.create({age: 5}, function(err) { - if (err) return done(err); + it(method + ' will wait for write when doc is locally created', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); doc.pause(); var calls = 0; doc.create({age: 3}, function(err) { - expect(err).ok(); + if (err) return done(err); calls++; }); doc[method](function(err) { if (err) return done(err); expect(calls).equal(1); expect(doc.version).equal(1); - expect(doc.data).eql({age: 5}); + expect(doc.data).eql({age: 3}); done(); }); setTimeout(function() { doc.resume(); }, 10); }); - }); - }); - - it('unsubscribe calls back immediately on disconnect', function(done) { - var backend = this.backend; - var doc = this.backend.connect().get('dogs', 'fido'); - doc.subscribe(function(err) { - if (err) return done(err); - doc.unsubscribe(done); - doc.connection.close(); - }); - }); - - it('unsubscribe calls back immediately when already disconnected', function(done) { - var backend = this.backend; - var doc = this.backend.connect().get('dogs', 'fido'); - doc.subscribe(function(err) { - if (err) return done(err); - doc.connection.close(); - doc.unsubscribe(done); - }); - }); - it('subscribed client gets create from other client', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc2.subscribe(function(err) { - if (err) return done(err); - doc2.on('create', function(context) { - expect(context).equal(false); - expect(doc2.version).eql(1); - expect(doc2.data).eql({age: 3}); - done(); + it(method + ' will wait for write when doc is locally created and will fail to submit', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc2.create({age: 5}, function(err) { + if (err) return done(err); + doc.pause(); + var calls = 0; + doc.create({age: 3}, function(err) { + expect(err).ok(); + calls++; + }); + doc[method](function(err) { + if (err) return done(err); + expect(calls).equal(1); + expect(doc.version).equal(1); + expect(doc.data).eql({age: 5}); + done(); + }); + setTimeout(function() { + doc.resume(); + }, 10); + }); }); - doc.create({age: 3}); }); - }); - it('subscribed client gets op from other client', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.subscribe(function(err) { + it('unsubscribe calls back immediately on disconnect', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { - expect(doc2.version).eql(2); - expect(doc2.data).eql({age: 4}); - done(); - }); - doc.submitOp({p: ['age'], na: 1}); + doc.unsubscribe(done); + doc.connection.close(); }); }); - }); - it('disconnecting stops op updates', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.subscribe(function(err) { + it('unsubscribe calls back immediately when already disconnected', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { - done(); - }); - doc2.connection.close(); - doc.submitOp({p: ['age'], na: 1}, done); + doc.connection.close(); + doc.unsubscribe(done); }); }); - }); - it('backend.suppressPublish stops op updates', function(done) { - var backend = this.backend; - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); + it('subscribed client gets create from other client', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); doc2.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { + doc2.on('create', function(context) { + expect(context).equal(false); + expect(doc2.version).eql(1); + expect(doc2.data).eql({age: 3}); done(); }); - backend.suppressPublish = true; - doc.submitOp({p: ['age'], na: 1}, done); + doc.create({age: 3}); }); }); - }); - it('unsubscribe stops op updates', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.subscribe(function(err) { + it('subscribed client gets op from other client', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { - done(); - }); - doc2.unsubscribe(function(err) { + doc2.subscribe(function(err) { if (err) return done(err); - doc.submitOp({p: ['age'], na: 1}, done); + doc2.on('op', function() { + expect(doc2.version).eql(2); + expect(doc2.data).eql({age: 4}); + done(); + }); + doc.submitOp({p: ['age'], na: 1}); }); }); }); - }); - it('doc destroy stops op updates', function(done) { - var connection1 = this.backend.connect(); - var connection2 = this.backend.connect(); - var doc = connection1.get('dogs', 'fido'); - var doc2 = connection2.get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.subscribe(function(err) { + it('disconnecting stops op updates', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { - done(new Error('Should not get op event')); - }); - doc2.destroy(function(err) { + doc2.subscribe(function(err) { if (err) return done(err); - expect(connection2.getExisting('dogs', 'fido')).equal(undefined); + doc2.on('op', function() { + done(); + }); + doc2.connection.close(); doc.submitOp({p: ['age'], na: 1}, done); }); }); }); - }); - - it('doc destroy removes doc from connection when doc is not subscribed', function(done) { - var connection = this.backend.connect(); - var doc = connection.get('dogs', 'fido'); - expect(connection.getExisting('dogs', 'fido')).equal(doc); - doc.destroy(function(err) { - if (err) return done(err); - expect(connection.getExisting('dogs', 'fido')).equal(undefined); - done(); - }); - }); - it('bulk unsubscribe stops op updates', function(done) { - var connection = this.backend.connect(); - var connection2 = this.backend.connect(); - var doc = connection.get('dogs', 'fido'); - var fido = connection2.get('dogs', 'fido'); - var spot = connection2.get('dogs', 'spot'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - async.parallel([ - function(cb) { fido.subscribe(cb); }, - function(cb) { spot.subscribe(cb); } - ], function(err) { + it('backend.suppressPublish stops op updates', function(done) { + var backend = this.backend; + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - fido.connection.startBulk(); - async.parallel([ - function(cb) { fido.unsubscribe(cb); }, - function(cb) { spot.unsubscribe(cb); } - ], function(err) { + doc2.subscribe(function(err) { if (err) return done(err); - fido.on('op', function(op, context) { + doc2.on('op', function() { done(); }); + backend.suppressPublish = true; doc.submitOp({p: ['age'], na: 1}, done); }); - fido.connection.endBulk(); }); }); - }); - it('a subscribed doc is re-subscribed after reconnect and gets any missing ops', function(done) { - var backend = this.backend; - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.subscribe(function(err) { + it('unsubscribe stops op updates', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { - expect(doc2.version).eql(2); - expect(doc2.data).eql({age: 4}); - done(); - }); - - doc2.connection.close(); - doc.submitOp({p: ['age'], na: 1}, function(err) { + doc2.subscribe(function(err) { if (err) return done(err); - backend.connect(doc2.connection); + doc2.on('op', function() { + done(); + }); + doc2.unsubscribe(function(err) { + if (err) return done(err); + doc.submitOp({p: ['age'], na: 1}, done); + }); }); }); }); - }); - it('calling subscribe, unsubscribe, subscribe sync leaves a doc subscribed', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.subscribe(); - doc2.unsubscribe(); - doc2.subscribe(function(err) { + it('doc destroy stops op updates', function(done) { + var connection1 = this.backend.connect(); + var connection2 = this.backend.connect(); + var doc = connection1.get('dogs', 'fido'); + var doc2 = connection2.get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { - done(); + doc2.subscribe(function(err) { + if (err) return done(err); + doc2.on('op', function() { + done(new Error('Should not get op event')); + }); + doc2.destroy(function(err) { + if (err) return done(err); + expect(connection2.getExisting('dogs', 'fido')).equal(undefined); + doc.submitOp({p: ['age'], na: 1}, done); + }); }); - doc.submitOp({p: ['age'], na: 1}); }); }); - }); - it('doc fetches ops to catch up if it receives a future op', function(done) { - var backend = this.backend; - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.subscribe(function(err) { + it('doc destroy removes doc from connection when doc is not subscribed', function(done) { + var connection = this.backend.connect(); + var doc = connection.get('dogs', 'fido'); + expect(connection.getExisting('dogs', 'fido')).equal(doc); + doc.destroy(function(err) { if (err) return done(err); - var expected = [ - [{p: ['age'], na: 1}], - [{p: ['age'], na: 5}], - ]; - doc2.on('op', function(op, context) { - var item = expected.shift(); - expect(op).eql(item); - if (expected.length) return; - expect(doc2.version).equal(3); - expect(doc2.data).eql({age: 9}); - done(); - }); - backend.suppressPublish = true; - doc.submitOp({p: ['age'], na: 1}, function(err) { - if (err) return done(err); - backend.suppressPublish = false; - doc.submitOp({p: ['age'], na: 5}); - }); + expect(connection.getExisting('dogs', 'fido')).equal(undefined); + done(); }); }); - }); - it('doc fetches ops to catch up if it receives multiple future ops', function(done) { - var backend = this.backend; - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); - // Delaying op replies will cause multiple future ops to be received - // before the fetch to catch up completes - backend.use('op', function(request, next) { - setTimeout(next, 10 * Math.random()); - }); - doc.create({age: 3}, function(err) { - if (err) return done(err); - doc2.subscribe(function(err) { + it('bulk unsubscribe stops op updates', function(done) { + var connection = this.backend.connect(); + var connection2 = this.backend.connect(); + var doc = connection.get('dogs', 'fido'); + var fido = connection2.get('dogs', 'fido'); + var spot = connection2.get('dogs', 'spot'); + doc.create({age: 3}, function(err) { if (err) return done(err); - var wait = 4; - doc2.on('op', function(op, context) { - if (--wait) return; - expect(doc2.version).eql(5); - expect(doc2.data).eql({age: 122}); - done(); - }); - backend.suppressPublish = true; - doc.submitOp({p: ['age'], na: 1}, function(err) { + async.parallel([ + function(cb) { + fido.subscribe(cb); + }, + function(cb) { + spot.subscribe(cb); + } + ], function(err) { if (err) return done(err); - backend.suppressPublish = false; - doc.submitOp({p: ['age'], na: 5}, function(err) { + fido.connection.startBulk(); + async.parallel([ + function(cb) { + fido.unsubscribe(cb); + }, + function(cb) { + spot.unsubscribe(cb); + } + ], function(err) { if (err) return done(err); - doc.submitOp({p: ['age'], na: 13}, function(err) { - if (err) return done(err); - doc.submitOp({p: ['age'], na: 100}); + fido.on('op', function() { + done(); }); + doc.submitOp({p: ['age'], na: 1}, done); }); + fido.connection.endBulk(); }); }); }); - }); - describe('doc.subscribed', function() { - it('is set to false initially', function() { + it('a subscribed doc is re-subscribed after reconnect and gets any missing ops', function(done) { + var backend = this.backend; var doc = this.backend.connect().get('dogs', 'fido'); - expect(doc.subscribed).equal(false); - }); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { + if (err) return done(err); + doc2.subscribe(function(err) { + if (err) return done(err); + doc2.on('op', function() { + expect(doc2.version).eql(2); + expect(doc2.data).eql({age: 4}); + done(); + }); - it('remains false before subscribe call completes', function() { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.subscribe(); - expect(doc.subscribed).equal(false); + doc2.connection.close(); + doc.submitOp({p: ['age'], na: 1}, function(err) { + if (err) return done(err); + backend.connect(doc2.connection); + }); + }); + }); }); - it('is set to true after subscribe completes', function(done) { + it('calling subscribe, unsubscribe, subscribe sync leaves a doc subscribed', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); - doc.subscribe(function(err) { + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.subscribed).equal(true); - done(); + doc2.subscribe(); + doc2.unsubscribe(); + doc2.subscribe(function(err) { + if (err) return done(err); + doc2.on('op', function() { + done(); + }); + doc.submitOp({p: ['age'], na: 1}); + }); }); }); - it('is not set to true after subscribe completes if already unsubscribed', function(done) { + it('doc fetches ops to catch up if it receives a future op', function(done) { + var backend = this.backend; var doc = this.backend.connect().get('dogs', 'fido'); - doc.subscribe(function(err) { + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.subscribed).equal(false); - done(); + doc2.subscribe(function(err) { + if (err) return done(err); + var expected = [ + [{p: ['age'], na: 1}], + [{p: ['age'], na: 5}] + ]; + doc2.on('op', function(op) { + var item = expected.shift(); + expect(op).eql(item); + if (expected.length) return; + expect(doc2.version).equal(3); + expect(doc2.data).eql({age: 9}); + done(); + }); + backend.suppressPublish = true; + doc.submitOp({p: ['age'], na: 1}, function(err) { + if (err) return done(err); + backend.suppressPublish = false; + doc.submitOp({p: ['age'], na: 5}); + }); + }); }); - doc.unsubscribe(); }); - it('is set to false sychronously in unsubscribe', function(done) { + it('doc fetches ops to catch up if it receives multiple future ops', function(done) { + var backend = this.backend; var doc = this.backend.connect().get('dogs', 'fido'); - doc.subscribe(function(err) { + var doc2 = this.backend.connect().get('dogs', 'fido'); + // Delaying op replies will cause multiple future ops to be received + // before the fetch to catch up completes + backend.use('op', function(request, next) { + setTimeout(next, 10 * Math.random()); + }); + doc.create({age: 3}, function(err) { if (err) return done(err); - expect(doc.subscribed).equal(true); - doc.unsubscribe(); - expect(doc.subscribed).equal(false); - done(); + doc2.subscribe(function(err) { + if (err) return done(err); + var wait = 4; + doc2.on('op', function() { + if (--wait) return; + expect(doc2.version).eql(5); + expect(doc2.data).eql({age: 122}); + done(); + }); + backend.suppressPublish = true; + doc.submitOp({p: ['age'], na: 1}, function(err) { + if (err) return done(err); + backend.suppressPublish = false; + doc.submitOp({p: ['age'], na: 5}, function(err) { + if (err) return done(err); + doc.submitOp({p: ['age'], na: 13}, function(err) { + if (err) return done(err); + doc.submitOp({p: ['age'], na: 100}); + }); + }); + }); + }); }); }); - it('is set to false sychronously on disconnect', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - doc.subscribe(function(err) { - if (err) return done(err); - expect(doc.subscribed).equal(true); - doc.connection.close(); + describe('doc.subscribed', function() { + it('is set to false initially', function() { + var doc = this.backend.connect().get('dogs', 'fido'); expect(doc.subscribed).equal(false); - done(); + }); + + it('remains false before subscribe call completes', function() { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.subscribe(); + expect(doc.subscribed).equal(false); + }); + + it('is set to true after subscribe completes', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.subscribe(function(err) { + if (err) return done(err); + expect(doc.subscribed).equal(true); + done(); + }); + }); + + it('is not set to true after subscribe completes if already unsubscribed', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.subscribe(function(err) { + if (err) return done(err); + expect(doc.subscribed).equal(false); + done(); + }); + doc.unsubscribe(); + }); + + it('is set to false sychronously in unsubscribe', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.subscribe(function(err) { + if (err) return done(err); + expect(doc.subscribed).equal(true); + doc.unsubscribe(); + expect(doc.subscribed).equal(false); + done(); + }); + }); + + it('is set to false sychronously on disconnect', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.subscribe(function(err) { + if (err) return done(err); + expect(doc.subscribed).equal(true); + doc.connection.close(); + expect(doc.subscribed).equal(false); + done(); + }); }); }); }); -}); }; diff --git a/test/db-memory.js b/test/db-memory.js index b8b8e064e..0f0d01a92 100644 --- a/test/db-memory.js +++ b/test/db-memory.js @@ -64,7 +64,7 @@ function BasicQueryableMemoryDB() { BasicQueryableMemoryDB.prototype = Object.create(MemoryDB.prototype); BasicQueryableMemoryDB.prototype.constructor = BasicQueryableMemoryDB; -BasicQueryableMemoryDB.prototype._querySync = function(snapshots, query, options) { +BasicQueryableMemoryDB.prototype._querySync = function(snapshots, query) { if (query.filter) { snapshots = snapshots.filter(function(snapshot) { for (var queryKey in query.filter) { diff --git a/test/db.js b/test/db.js index 257763173..701bdb7a0 100644 --- a/test/db.js +++ b/test/db.js @@ -238,7 +238,7 @@ module.exports = function(options) { var data = {x: 5, y: 6}; var op = {v: 0, create: {type: 'json0', data: data}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getSnapshot('testcollection', 'test', null, null, function(err, result) { if (err) return done(err); @@ -251,6 +251,7 @@ module.exports = function(options) { it('getSnapshot does not return committed metadata by default', function(done) { var db = this.db; commitSnapshotWithMetadata(db, function(err) { + if (err) return done(err); db.getSnapshot('testcollection', 'test', null, null, function(err, result) { if (err) return done(err); expect(result.m).equal(null); @@ -262,6 +263,7 @@ module.exports = function(options) { it('getSnapshot returns metadata when option is true', function(done) { var db = this.db; commitSnapshotWithMetadata(db, function(err) { + if (err) return done(err); db.getSnapshot('testcollection', 'test', null, {metadata: true}, function(err, result) { if (err) return done(err); expect(result.m).eql({test: 3}); @@ -273,6 +275,7 @@ module.exports = function(options) { it('getSnapshot returns metadata when fields is {$submit: true}', function(done) { var db = this.db; commitSnapshotWithMetadata(db, function(err) { + if (err) return done(err); db.getSnapshot('testcollection', 'test', {$submit: true}, null, function(err, result) { if (err) return done(err); expect(result.m).eql({test: 3}); @@ -287,7 +290,7 @@ module.exports = function(options) { var data = {x: 5, y: 6}; var op = {v: 0, create: {type: 'json0', data: data}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getSnapshotBulk('testcollection', ['test2', 'test'], null, null, function(err, resultMap) { if (err) return done(err); @@ -303,6 +306,7 @@ module.exports = function(options) { it('getSnapshotBulk does not return committed metadata by default', function(done) { var db = this.db; commitSnapshotWithMetadata(db, function(err) { + if (err) return done(err); db.getSnapshotBulk('testcollection', ['test2', 'test'], null, null, function(err, resultMap) { if (err) return done(err); expect(resultMap.test.m).equal(null); @@ -314,6 +318,7 @@ module.exports = function(options) { it('getSnapshotBulk returns metadata when option is true', function(done) { var db = this.db; commitSnapshotWithMetadata(db, function(err) { + if (err) return done(err); db.getSnapshotBulk('testcollection', ['test2', 'test'], null, {metadata: true}, function(err, resultMap) { if (err) return done(err); expect(resultMap.test.m).eql({test: 3}); @@ -335,7 +340,7 @@ module.exports = function(options) { it('getOps returns 1 committed op', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getOps('testcollection', 'test', 0, null, null, function(err, ops) { if (err) return done(err); @@ -349,9 +354,9 @@ module.exports = function(options) { var op0 = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var op1 = {v: 1, op: [{p: ['x'], na: 1}]}; var db = this.db; - submit(db, 'testcollection', 'test', op0, function(err, succeeded) { + submit(db, 'testcollection', 'test', op0, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test', op1, function(err, succeeded) { + submit(db, 'testcollection', 'test', op1, function(err) { if (err) return done(err); db.getOps('testcollection', 'test', 0, null, null, function(err, ops) { if (err) return done(err); @@ -366,9 +371,9 @@ module.exports = function(options) { var op0 = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var op1 = {v: 1, op: [{p: ['x'], na: 1}]}; var db = this.db; - submit(db, 'testcollection', 'test', op0, function(err, succeeded) { + submit(db, 'testcollection', 'test', op0, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test', op1, function(err, succeeded) { + submit(db, 'testcollection', 'test', op1, function(err) { if (err) return done(err); db.getOps('testcollection', 'test', null, null, null, function(err, ops) { if (err) return done(err); @@ -383,9 +388,9 @@ module.exports = function(options) { var op0 = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var op1 = {v: 1, op: [{p: ['x'], na: 1}]}; var db = this.db; - submit(db, 'testcollection', 'test', op0, function(err, succeeded) { + submit(db, 'testcollection', 'test', op0, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test', op1, function(err, succeeded) { + submit(db, 'testcollection', 'test', op1, function(err) { if (err) return done(err); db.getOps('testcollection', 'test', 1, null, null, function(err, ops) { if (err) return done(err); @@ -400,9 +405,9 @@ module.exports = function(options) { var op0 = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var op1 = {v: 1, op: [{p: ['x'], na: 1}]}; var db = this.db; - submit(db, 'testcollection', 'test', op0, function(err, succeeded) { + submit(db, 'testcollection', 'test', op0, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test', op1, function(err, succeeded) { + submit(db, 'testcollection', 'test', op1, function(err) { if (err) return done(err); db.getOps('testcollection', 'test', 0, 1, null, function(err, ops) { if (err) return done(err); @@ -416,7 +421,7 @@ module.exports = function(options) { it('getOps does not return committed metadata by default', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}, m: {test: 3}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getOps('testcollection', 'test', null, null, null, function(err, ops) { if (err) return done(err); @@ -429,7 +434,7 @@ module.exports = function(options) { it('getOps returns metadata when option is true', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}, m: {test: 3}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getOps('testcollection', 'test', null, null, {metadata: true}, function(err, ops) { if (err) return done(err); @@ -455,9 +460,9 @@ module.exports = function(options) { it('getOpsBulk returns committed ops', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test2', op, function(err, succeeded) { + submit(db, 'testcollection', 'test2', op, function(err) { if (err) return done(err); db.getOpsBulk('testcollection', {test: 0, test2: 0}, null, null, function(err, opsMap) { if (err) return done(err); @@ -474,9 +479,9 @@ module.exports = function(options) { it('getOpsBulk returns all ops committed from null', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test2', op, function(err, succeeded) { + submit(db, 'testcollection', 'test2', op, function(err) { if (err) return done(err); db.getOpsBulk('testcollection', {test: null, test2: null}, null, null, function(err, opsMap) { if (err) return done(err); @@ -494,13 +499,13 @@ module.exports = function(options) { var op0 = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var op1 = {v: 1, op: [{p: ['x'], na: 1}]}; var db = this.db; - submit(db, 'testcollection', 'test', op0, function(err, succeeded) { + submit(db, 'testcollection', 'test', op0, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test2', op0, function(err, succeeded) { + submit(db, 'testcollection', 'test2', op0, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test', op1, function(err, succeeded) { + submit(db, 'testcollection', 'test', op1, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test2', op1, function(err, succeeded) { + submit(db, 'testcollection', 'test2', op1, function(err) { if (err) return done(err); db.getOpsBulk('testcollection', {test: 0, test2: 1}, null, null, function(err, opsMap) { if (err) return done(err); @@ -520,13 +525,13 @@ module.exports = function(options) { var op0 = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var op1 = {v: 1, op: [{p: ['x'], na: 1}]}; var db = this.db; - submit(db, 'testcollection', 'test', op0, function(err, succeeded) { + submit(db, 'testcollection', 'test', op0, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test2', op0, function(err, succeeded) { + submit(db, 'testcollection', 'test2', op0, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test', op1, function(err, succeeded) { + submit(db, 'testcollection', 'test', op1, function(err) { if (err) return done(err); - submit(db, 'testcollection', 'test2', op1, function(err, succeeded) { + submit(db, 'testcollection', 'test2', op1, function(err) { if (err) return done(err); db.getOpsBulk('testcollection', {test: 1, test2: 0}, {test2: 1}, null, function(err, opsMap) { if (err) return done(err); @@ -545,7 +550,7 @@ module.exports = function(options) { it('getOpsBulk does not return committed metadata by default', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}, m: {test: 3}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getOpsBulk('testcollection', {test: null}, null, null, function(err, opsMap) { if (err) return done(err); @@ -558,7 +563,7 @@ module.exports = function(options) { it('getOpsBulk returns metadata when option is true', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}, m: {test: 3}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getOpsBulk('testcollection', {test: null}, null, {metadata: true}, function(err, opsMap) { if (err) return done(err); @@ -573,7 +578,7 @@ module.exports = function(options) { it('getOpsToSnapshot returns committed op', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getSnapshot('testcollection', 'test', {$submit: true}, null, function(err, snapshot) { if (err) return done(err); @@ -589,7 +594,7 @@ module.exports = function(options) { it('getOpsToSnapshot does not return committed metadata by default', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}, m: {test: 3}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getSnapshot('testcollection', 'test', {$submit: true}, null, function(err, snapshot) { if (err) return done(err); @@ -605,7 +610,7 @@ module.exports = function(options) { it('getOpsToSnapshot returns metadata when option is true', function(done) { var op = {v: 0, create: {type: 'json0', data: {x: 5, y: 6}}, m: {test: 3}}; var db = this.db; - submit(db, 'testcollection', 'test', op, function(err, succeeded) { + submit(db, 'testcollection', 'test', op, function(err) { if (err) return done(err); db.getSnapshot('testcollection', 'test', {$submit: true}, null, function(err, snapshot) { if (err) return done(err); @@ -623,7 +628,7 @@ module.exports = function(options) { it('query returns data in the collection', function(done) { var snapshot = {v: 1, type: 'json0', data: {x: 5, y: 6}, m: null}; var db = this.db; - db.commit('testcollection', 'test', {v: 0, create: {}}, snapshot, null, function(err, succeeded) { + db.commit('testcollection', 'test', {v: 0, create: {}}, snapshot, null, function(err) { if (err) return done(err); db.query('testcollection', {x: 5}, null, null, function(err, results) { if (err) return done(err); @@ -645,6 +650,7 @@ module.exports = function(options) { it('query does not return committed metadata by default', function(done) { var db = this.db; commitSnapshotWithMetadata(db, function(err) { + if (err) return done(err); db.query('testcollection', {x: 5}, null, null, function(err, results) { if (err) return done(err); expect(results[0].m).equal(null); @@ -656,6 +662,7 @@ module.exports = function(options) { it('query returns metadata when option is true', function(done) { var db = this.db; commitSnapshotWithMetadata(db, function(err) { + if (err) return done(err); db.query('testcollection', {x: 5}, null, {metadata: true}, function(err, results) { if (err) return done(err); expect(results[0].m).eql({test: 3}); @@ -672,6 +679,7 @@ module.exports = function(options) { var snapshot = {type: 'json0', v: 1, data: {x: 5, y: 6}}; var db = this.db; db.commit('testcollection', 'test', {v: 0, create: {}}, snapshot, null, function(err) { + if (err) return done(err); db.query('testcollection', {x: 5}, {y: true}, null, function(err, results) { if (err) return done(err); expect(results).eql([{type: 'json0', v: 1, data: {y: 6}, id: 'test'}]); @@ -686,6 +694,7 @@ module.exports = function(options) { var snapshot = {type: 'json0', v: 1, data: {x: 5, y: 6}}; var db = this.db; db.commit('testcollection', 'test', {v: 0, create: {}}, snapshot, null, function(err) { + if (err) return done(err); db.query('testcollection', {x: 5}, {}, null, function(err, results) { if (err) return done(err); expect(results).eql([{type: 'json0', v: 1, data: {}, id: 'test'}]); @@ -697,6 +706,7 @@ module.exports = function(options) { it('query does not return committed metadata by default with projection', function(done) { var db = this.db; commitSnapshotWithMetadata(db, function(err) { + if (err) return done(err); db.query('testcollection', {x: 5}, {x: true}, null, function(err, results) { if (err) return done(err); expect(results[0].m).equal(null); @@ -708,6 +718,7 @@ module.exports = function(options) { it('query returns metadata when option is true with projection', function(done) { var db = this.db; commitSnapshotWithMetadata(db, function(err) { + if (err) return done(err); db.query('testcollection', {x: 5}, {x: true}, {metadata: true}, function(err, results) { if (err) return done(err); expect(results[0].m).eql({test: 3}); @@ -721,7 +732,7 @@ module.exports = function(options) { it('returns data in the collection', function(done) { var snapshot = {v: 1, type: 'json0', data: {x: 5, y: 6}}; var db = this.db; - db.commit('testcollection', 'test', {v: 0, create: {}}, snapshot, null, function(err, succeeded) { + db.commit('testcollection', 'test', {v: 0, create: {}}, snapshot, null, function(err) { if (err) return done(err); db.queryPoll('testcollection', {x: 5}, null, function(err, ids) { if (err) return done(err); @@ -760,6 +771,7 @@ module.exports = function(options) { var snapshot = {type: 'json0', v: 1, data: {x: 5, y: 6}}; var db = this.db; db.commit('testcollection', 'test', {v: 0, create: {}}, snapshot, null, function(err) { + if (err) return done(err); db.queryPollDoc('testcollection', 'test', query, null, function(err, result) { if (err) return done(err); expect(result).equal(true); @@ -775,6 +787,7 @@ module.exports = function(options) { var snapshot = {type: 'json0', v: 1, data: {x: 5, y: 6}}; var db = this.db; db.commit('testcollection', 'test', {v: 0, create: {}}, snapshot, null, function(err) { + if (err) return done(err); db.queryPollDoc('testcollection', 'test', query, null, function(err, result) { if (err) return done(err); expect(result).equal(false); @@ -790,9 +803,9 @@ module.exports = function(options) { // sorts by foo first, then bar var snapshots = [ {type: 'json0', id: '0', v: 1, data: {foo: 1, bar: 1}, m: null}, - {type: 'json0', id: '1', v: 1, data: { foo: 2, bar: 1 }, m: null}, - {type: 'json0', id: '2', v: 1, data: { foo: 1, bar: 2 }, m: null}, - {type: 'json0', id: '3', v: 1, data: { foo: 2, bar: 2 }, m: null} + {type: 'json0', id: '1', v: 1, data: {foo: 2, bar: 1}, m: null}, + {type: 'json0', id: '2', v: 1, data: {foo: 1, bar: 2}, m: null}, + {type: 'json0', id: '3', v: 1, data: {foo: 2, bar: 2}, m: null} ]; var db = this.db; var dbQuery = getQuery({query: {}, sort: [['foo', 1], ['bar', -1]]}); diff --git a/test/logger.js b/test/logger.js index aba680eb7..4efbb0cfa 100644 --- a/test/logger.js +++ b/test/logger.js @@ -2,23 +2,23 @@ var Logger = require('../lib/logger/logger'); var expect = require('expect.js'); var sinon = require('sinon'); -describe('Logger', function () { - describe('Stubbing console.warn', function () { - beforeEach(function () { +describe('Logger', function() { + describe('Stubbing console.warn', function() { + beforeEach(function() { sinon.stub(console, 'warn'); }); - afterEach(function () { + afterEach(function() { sinon.restore(); }); - it('logs to console by default', function () { + it('logs to console by default', function() { var logger = new Logger(); logger.warn('warning'); expect(console.warn.calledOnceWithExactly('warning')).to.be(true); }); - it('overrides console', function () { + it('overrides console', function() { var customWarn = sinon.stub(); var logger = new Logger(); logger.setMethods({ @@ -31,7 +31,7 @@ describe('Logger', function () { expect(customWarn.calledOnceWithExactly('warning')).to.be(true); }); - it('only overrides if provided with a method', function () { + it('only overrides if provided with a method', function() { var badWarn = 'not a function'; var logger = new Logger(); logger.setMethods({ diff --git a/test/middleware.js b/test/middleware.js index 4af42fbc6..85ceb686e 100644 --- a/test/middleware.js +++ b/test/middleware.js @@ -1,11 +1,9 @@ -var async = require('async'); var Backend = require('../lib/backend'); var expect = require('expect.js'); var util = require('./util'); var types = require('../lib/types'); describe('middleware', function() { - beforeEach(function() { this.backend = new Backend(); }); @@ -22,16 +20,13 @@ describe('middleware', function() { } describe('use', function() { - it('returns itself to allow chaining', function() { - var response = this.backend.use('submit', function(request, next) {}); + var response = this.backend.use('submit', function() {}); expect(response).equal(this.backend); }); - }); describe('connect', function() { - it('passes the agent on connect', function(done) { var clientId; this.backend.use('connect', function(request, next) { @@ -57,7 +52,6 @@ describe('middleware', function() { done(); }); }); - }); function testReadDoc(expectFidoOnly, expectFidoAndSpot) { @@ -136,7 +130,6 @@ describe('middleware', function() { function expectFidoAndSpot(backend, done) { var doneAfter = util.callAfter(2, done); - var i = 0; backend.use('doc', function(request, next) { doneAfter(); if (doneAfter.called === 1) { @@ -305,5 +298,4 @@ describe('middleware', function() { }); }); }); - }); diff --git a/test/milestone-db.js b/test/milestone-db.js index 1354eb4ee..223423d47 100644 --- a/test/milestone-db.js +++ b/test/milestone-db.js @@ -5,22 +5,22 @@ var NoOpMilestoneDB = require('../lib/milestone-db/no-op'); var Snapshot = require('../lib/snapshot'); var util = require('./util'); -describe('Base class', function () { +describe('Base class', function() { var db; - beforeEach(function () { + beforeEach(function() { db = new MilestoneDB(); }); - it('calls back with an error when trying to get a snapshot', function (done) { - db.getMilestoneSnapshot('books', '123', 1, function (error) { + it('calls back with an error when trying to get a snapshot', function(done) { + db.getMilestoneSnapshot('books', '123', 1, function(error) { expect(error.code).to.be(5019); done(); }); }); - it('emits an error when trying to get a snapshot', function (done) { - db.on('error', function (error) { + it('emits an error when trying to get a snapshot', function(done) { + db.on('error', function(error) { expect(error.code).to.be(5019); done(); }); @@ -28,15 +28,15 @@ describe('Base class', function () { db.getMilestoneSnapshot('books', '123', 1); }); - it('calls back with an error when trying to save a snapshot', function (done) { - db.saveMilestoneSnapshot('books', {}, function (error) { + it('calls back with an error when trying to save a snapshot', function(done) { + db.saveMilestoneSnapshot('books', {}, function(error) { expect(error.code).to.be(5020); done(); }); }); - it('emits an error when trying to save a snapshot', function (done) { - db.on('error', function (error) { + it('emits an error when trying to save a snapshot', function(done) { + db.on('error', function(error) { expect(error.code).to.be(5020); done(); }); @@ -44,45 +44,45 @@ describe('Base class', function () { db.saveMilestoneSnapshot('books', {}); }); - it('calls back with an error when trying to get a snapshot before a time', function (done) { - db.getMilestoneSnapshotAtOrBeforeTime('books', '123', 1000, function (error) { + it('calls back with an error when trying to get a snapshot before a time', function(done) { + db.getMilestoneSnapshotAtOrBeforeTime('books', '123', 1000, function(error) { expect(error.code).to.be(5021); done(); }); }); - it('calls back with an error when trying to get a snapshot after a time', function (done) { - db.getMilestoneSnapshotAtOrAfterTime('books', '123', 1000, function (error) { + it('calls back with an error when trying to get a snapshot after a time', function(done) { + db.getMilestoneSnapshotAtOrAfterTime('books', '123', 1000, function(error) { expect(error.code).to.be(5022); done(); }); }); }); -describe('NoOpMilestoneDB', function () { +describe('NoOpMilestoneDB', function() { var db; - beforeEach(function () { + beforeEach(function() { db = new NoOpMilestoneDB(); }); - it('does not error when trying to save and fetch a snapshot', function (done) { + it('does not error when trying to save and fetch a snapshot', function(done) { var snapshot = new Snapshot( 'catcher-in-the-rye', 2, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye' }, + {title: 'Catcher in the Rye'}, null ); util.callInSeries([ - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot, next); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', null, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.be(undefined); next(); }, @@ -90,8 +90,8 @@ describe('NoOpMilestoneDB', function () { ]); }); - it('emits an event when saving without a callback', function (done) { - db.on('save', function () { + it('emits an event when saving without a callback', function(done) { + db.on('save', function() { done(); }); @@ -99,51 +99,51 @@ describe('NoOpMilestoneDB', function () { }); }); -module.exports = function (options) { +module.exports = function(options) { var create = options.create; - describe('Milestone Database', function () { + describe('Milestone Database', function() { var db; var backend; - beforeEach(function (done) { - create(function (error, createdDb) { + beforeEach(function(done) { + create(function(error, createdDb) { if (error) return done(error); db = createdDb; - backend = new Backend({ milestoneDb: db }); + backend = new Backend({milestoneDb: db}); done(); }); }); - afterEach(function (done) { + afterEach(function(done) { db.close(done); }); - it('can call close() without a callback', function (done) { - create(function (error, db) { + it('can call close() without a callback', function(done) { + create(function(error, db) { if (error) return done(error); db.close(); done(); }); }); - it('stores and fetches a milestone snapshot', function (done) { + it('stores and fetches a milestone snapshot', function(done) { var snapshot = new Snapshot( 'catcher-in-the-rye', 2, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye' }, + {title: 'Catcher in the Rye'}, null ); util.callInSeries([ - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot, next); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 2, next); }, - function (retrievedSnapshot, next) { + function(retrievedSnapshot, next) { expect(retrievedSnapshot).to.eql(snapshot); next(); }, @@ -151,12 +151,12 @@ module.exports = function (options) { ]); }); - it('fetches the most recent snapshot before the requested version', function (done) { + it('fetches the most recent snapshot before the requested version', function(done) { var snapshot1 = new Snapshot( 'catcher-in-the-rye', 1, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye' }, + {title: 'Catcher in the Rye'}, null ); @@ -164,7 +164,7 @@ module.exports = function (options) { 'catcher-in-the-rye', 2, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye', author: 'J.D. Salinger' }, + {title: 'Catcher in the Rye', author: 'J.D. Salinger'}, null ); @@ -172,24 +172,24 @@ module.exports = function (options) { 'catcher-in-the-rye', 10, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye', author: 'J.D. Salinger', publicationDate: '1951-07-16' }, + {title: 'Catcher in the Rye', author: 'J.D. Salinger', publicationDate: '1951-07-16'}, null ); util.callInSeries([ - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot1, next); }, - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot2, next); }, - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot10, next); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 4, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(snapshot2); next(); }, @@ -197,12 +197,12 @@ module.exports = function (options) { ]); }); - it('fetches the most recent snapshot even if they are inserted in the wrong order', function (done) { + it('fetches the most recent snapshot even if they are inserted in the wrong order', function(done) { var snapshot1 = new Snapshot( 'catcher-in-the-rye', 1, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye' }, + {title: 'Catcher in the Rye'}, null ); @@ -210,21 +210,21 @@ module.exports = function (options) { 'catcher-in-the-rye', 2, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye', author: 'J.D. Salinger' }, + {title: 'Catcher in the Rye', author: 'J.D. Salinger'}, null ); util.callInSeries([ - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot2, next); }, - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot1, next); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 4, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(snapshot2); next(); }, @@ -232,12 +232,12 @@ module.exports = function (options) { ]); }); - it('fetches the most recent snapshot when the version is null', function (done) { + it('fetches the most recent snapshot when the version is null', function(done) { var snapshot1 = new Snapshot( 'catcher-in-the-rye', 1, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye' }, + {title: 'Catcher in the Rye'}, null ); @@ -245,21 +245,21 @@ module.exports = function (options) { 'catcher-in-the-rye', 2, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye', author: 'J.D. Salinger' }, + {title: 'Catcher in the Rye', author: 'J.D. Salinger'}, null ); util.callInSeries([ - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot1, next); }, - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot2, next); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', null, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(snapshot2); next(); }, @@ -267,62 +267,62 @@ module.exports = function (options) { ]); }); - it('errors when fetching an undefined version', function (done) { - db.getMilestoneSnapshot('books', 'catcher-in-the-rye', undefined, function (error) { + it('errors when fetching an undefined version', function(done) { + db.getMilestoneSnapshot('books', 'catcher-in-the-rye', undefined, function(error) { expect(error).to.be.ok(); done(); }); }); - it('errors when fetching version -1', function (done) { - db.getMilestoneSnapshot('books', 'catcher-in-the-rye', -1, function (error) { + it('errors when fetching version -1', function(done) { + db.getMilestoneSnapshot('books', 'catcher-in-the-rye', -1, function(error) { expect(error).to.be.ok(); done(); }); }); - it('errors when fetching version "foo"', function (done) { - db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 'foo', function (error) { + it('errors when fetching version "foo"', function(done) { + db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 'foo', function(error) { expect(error).to.be.ok(); done(); }); }); - it('errors when fetching a null collection', function (done) { - db.getMilestoneSnapshot(null, 'catcher-in-the-rye', 1, function (error) { + it('errors when fetching a null collection', function(done) { + db.getMilestoneSnapshot(null, 'catcher-in-the-rye', 1, function(error) { expect(error).to.be.ok(); done(); }); }); - it('errors when fetching a null ID', function (done) { - db.getMilestoneSnapshot('books', null, 1, function (error) { + it('errors when fetching a null ID', function(done) { + db.getMilestoneSnapshot('books', null, 1, function(error) { expect(error).to.be.ok(); done(); }); }); - it('errors when saving a null collection', function (done) { + it('errors when saving a null collection', function(done) { var snapshot = new Snapshot( 'catcher-in-the-rye', 1, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye' }, + {title: 'Catcher in the Rye'}, null ); - db.saveMilestoneSnapshot(null, snapshot, function (error) { + db.saveMilestoneSnapshot(null, snapshot, function(error) { expect(error).to.be.ok(); done(); }); }); - it('returns undefined if no snapshot exists', function (done) { + it('returns undefined if no snapshot exists', function(done) { util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 1, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.be(undefined); next(); }, @@ -330,16 +330,16 @@ module.exports = function (options) { ]); }); - it('does not store a milestone snapshot on commit', function (done) { + it('does not store a milestone snapshot on commit', function(done) { util.callInSeries([ - function (next) { + function(next) { var doc = backend.connect().get('books', 'catcher-in-the-rye'); - doc.create({ title: 'Catcher in the Rye' }, next); + doc.create({title: 'Catcher in the Rye'}, next); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', null, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.be(undefined); next(); }, @@ -347,16 +347,16 @@ module.exports = function (options) { ]); }); - it('can save without a callback', function (done) { + it('can save without a callback', function(done) { var snapshot = new Snapshot( 'catcher-in-the-rye', 1, 'http://sharejs.org/types/JSONv0', - { title: 'Catcher in the Rye' }, + {title: 'Catcher in the Rye'}, null ); - db.on('save', function (collection, snapshot) { + db.on('save', function(collection, snapshot) { expect(collection).to.be('books'); expect(snapshot).to.eql(snapshot); done(); @@ -365,14 +365,14 @@ module.exports = function (options) { db.saveMilestoneSnapshot('books', snapshot); }); - it('errors when the snapshot is undefined', function (done) { - db.saveMilestoneSnapshot('books', undefined, function (error) { + it('errors when the snapshot is undefined', function(done) { + db.saveMilestoneSnapshot('books', undefined, function(error) { expect(error).to.be.ok(); done(); }); }); - describe('snapshots with timestamps', function () { + describe('snapshots with timestamps', function() { var snapshot1 = new Snapshot( 'catcher-in-the-rye', 1, @@ -414,28 +414,28 @@ module.exports = function (options) { } ); - beforeEach(function (done) { + beforeEach(function(done) { util.callInSeries([ - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot1, next); }, - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot2, next); }, - function (next) { + function(next) { db.saveMilestoneSnapshot('books', snapshot3, next); }, done ]); }); - describe('fetching a snapshot before or at a time', function () { - it('fetches a snapshot before a given time', function (done) { + describe('fetching a snapshot before or at a time', function() { + it('fetches a snapshot before a given time', function(done) { util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', 2500, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(snapshot2); next(); }, @@ -443,12 +443,12 @@ module.exports = function (options) { ]); }); - it('fetches a snapshot at an exact time', function (done) { + it('fetches a snapshot at an exact time', function(done) { util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', 2000, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(snapshot2); next(); }, @@ -456,12 +456,12 @@ module.exports = function (options) { ]); }); - it('fetches the first snapshot for a null timestamp', function (done) { + it('fetches the first snapshot for a null timestamp', function(done) { util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', null, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(snapshot1); next(); }, @@ -469,26 +469,26 @@ module.exports = function (options) { ]); }); - it('returns an error for a string timestamp', function (done) { - db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', 'not-a-timestamp', function (error) { + it('returns an error for a string timestamp', function(done) { + db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', 'not-a-timestamp', function(error) { expect(error).to.be.ok(); done(); }); }); - it('returns an error for a negative timestamp', function (done) { - db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', -1, function (error) { + it('returns an error for a negative timestamp', function(done) { + db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', -1, function(error) { expect(error).to.be.ok(); done(); }); }); - it('returns undefined if there are no snapshots before a time', function (done) { + it('returns undefined if there are no snapshots before a time', function(done) { util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', 0, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.be(undefined); next(); }, @@ -496,28 +496,28 @@ module.exports = function (options) { ]); }); - it('errors if no collection is provided', function (done) { - db.getMilestoneSnapshotAtOrBeforeTime(undefined, 'catcher-in-the-rye', 0, function (error) { + it('errors if no collection is provided', function(done) { + db.getMilestoneSnapshotAtOrBeforeTime(undefined, 'catcher-in-the-rye', 0, function(error) { expect(error).to.be.ok(); done(); }); }); - it('errors if no ID is provided', function (done) { - db.getMilestoneSnapshotAtOrBeforeTime('books', undefined, 0, function (error) { + it('errors if no ID is provided', function(done) { + db.getMilestoneSnapshotAtOrBeforeTime('books', undefined, 0, function(error) { expect(error).to.be.ok(); done(); }); }); }); - describe('fetching a snapshot after or at a time', function () { - it('fetches a snapshot after a given time', function (done) { + describe('fetching a snapshot after or at a time', function() { + it('fetches a snapshot after a given time', function(done) { util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', 2500, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(snapshot3); next(); }, @@ -525,12 +525,12 @@ module.exports = function (options) { ]); }); - it('fetches a snapshot at an exact time', function (done) { + it('fetches a snapshot at an exact time', function(done) { util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', 2000, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(snapshot2); next(); }, @@ -538,12 +538,12 @@ module.exports = function (options) { ]); }); - it('fetches the last snapshot for a null timestamp', function (done) { + it('fetches the last snapshot for a null timestamp', function(done) { util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', null, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.eql(snapshot3); next(); }, @@ -551,26 +551,26 @@ module.exports = function (options) { ]); }); - it('returns an error for a string timestamp', function (done) { - db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', 'not-a-timestamp', function (error) { + it('returns an error for a string timestamp', function(done) { + db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', 'not-a-timestamp', function(error) { expect(error).to.be.ok(); done(); }); }); - it('returns an error for a negative timestamp', function (done) { - db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', -1, function (error) { + it('returns an error for a negative timestamp', function(done) { + db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', -1, function(error) { expect(error).to.be.ok(); done(); }); }); - it('returns undefined if there are no snapshots after a time', function (done) { + it('returns undefined if there are no snapshots after a time', function(done) { util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', 4000, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.be(undefined); next(); }, @@ -578,15 +578,15 @@ module.exports = function (options) { ]); }); - it('errors if no collection is provided', function (done) { - db.getMilestoneSnapshotAtOrAfterTime(undefined, 'catcher-in-the-rye', 0, function (error) { + it('errors if no collection is provided', function(done) { + db.getMilestoneSnapshotAtOrAfterTime(undefined, 'catcher-in-the-rye', 0, function(error) { expect(error).to.be.ok(); done(); }); }); - it('errors if no ID is provided', function (done) { - db.getMilestoneSnapshotAtOrAfterTime('books', undefined, 0, function (error) { + it('errors if no ID is provided', function(done) { + db.getMilestoneSnapshotAtOrAfterTime('books', undefined, 0, function(error) { expect(error).to.be.ok(); done(); }); @@ -594,72 +594,72 @@ module.exports = function (options) { }); }); - describe('milestones enabled for every version', function () { - beforeEach(function (done) { - var options = { interval: 1 }; + describe('milestones enabled for every version', function() { + beforeEach(function(done) { + var options = {interval: 1}; - create(options, function (error, createdDb) { + create(options, function(error, createdDb) { if (error) return done(error); db = createdDb; - backend = new Backend({ milestoneDb: db }); + backend = new Backend({milestoneDb: db}); done(); }); }); - it('stores a milestone snapshot on commit', function (done) { - db.on('save', function (collection, snapshot) { + it('stores a milestone snapshot on commit', function(done) { + db.on('save', function(collection, snapshot) { expect(collection).to.be('books'); - expect(snapshot.data).to.eql({ title: 'Catcher in the Rye' }); + expect(snapshot.data).to.eql({title: 'Catcher in the Rye'}); done(); }); var doc = backend.connect().get('books', 'catcher-in-the-rye'); - doc.create({ title: 'Catcher in the Rye' }); + doc.create({title: 'Catcher in the Rye'}); }); }); - describe('milestones enabled for every other version', function () { - beforeEach(function (done) { - var options = { interval: 2 }; + describe('milestones enabled for every other version', function() { + beforeEach(function(done) { + var options = {interval: 2}; - create(options, function (error, createdDb) { + create(options, function(error, createdDb) { if (error) return done(error); db = createdDb; - backend = new Backend({ milestoneDb: db }); + backend = new Backend({milestoneDb: db}); done(); }); }); - it('only stores even-numbered versions', function (done) { - db.on('save', function (collection, snapshot) { + it('only stores even-numbered versions', function(done) { + db.on('save', function(collection, snapshot) { if (snapshot.v !== 4) return; util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 1, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.be(undefined); next(); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 2, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot.v).to.be(2); next(); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 3, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot.v).to.be(2); next(); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 4, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot.v).to.be(4); next(); }, @@ -670,56 +670,56 @@ module.exports = function (options) { var doc = backend.connect().get('books', 'catcher-in-the-rye'); util.callInSeries([ - function (next) { - doc.create({ title: 'Catcher in the Rye' }, next); + function(next) { + doc.create({title: 'Catcher in the Rye'}, next); }, - function (next) { - doc.submitOp({ p: ['author'], oi: 'J.F.Salinger' }, next); + function(next) { + doc.submitOp({p: ['author'], oi: 'J.F.Salinger'}, next); }, - function (next) { - doc.submitOp({ p: ['author'], od: 'J.F.Salinger', oi: 'J.D.Salinger' }, next); + function(next) { + doc.submitOp({p: ['author'], od: 'J.F.Salinger', oi: 'J.D.Salinger'}, next); }, - function (next) { - doc.submitOp({ p: ['author'], od: 'J.D.Salinger', oi: 'J.D. Salinger' }, next); + function(next) { + doc.submitOp({p: ['author'], od: 'J.D.Salinger', oi: 'J.D. Salinger'}, next); } ]); }); - it('can have the saving logic overridden in middleware', function (done) { - backend.use('commit', function (request, callback) { + it('can have the saving logic overridden in middleware', function(done) { + backend.use('commit', function(request, callback) { request.saveMilestoneSnapshot = request.snapshot.v >= 3; callback(); }); - db.on('save', function (collection, snapshot) { + db.on('save', function(collection, snapshot) { if (snapshot.v !== 4) return; util.callInSeries([ - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 1, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.be(undefined); next(); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 2, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot).to.be(undefined); next(); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 3, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot.v).to.be(3); next(); }, - function (next) { + function(next) { db.getMilestoneSnapshot('books', 'catcher-in-the-rye', 4, next); }, - function (snapshot, next) { + function(snapshot, next) { expect(snapshot.v).to.be(4); next(); }, @@ -730,17 +730,17 @@ module.exports = function (options) { var doc = backend.connect().get('books', 'catcher-in-the-rye'); util.callInSeries([ - function (next) { - doc.create({ title: 'Catcher in the Rye' }, next); + function(next) { + doc.create({title: 'Catcher in the Rye'}, next); }, - function (next) { - doc.submitOp({ p: ['author'], oi: 'J.F.Salinger' }, next); + function(next) { + doc.submitOp({p: ['author'], oi: 'J.F.Salinger'}, next); }, - function (next) { - doc.submitOp({ p: ['author'], od: 'J.F.Salinger', oi: 'J.D.Salinger' }, next); + function(next) { + doc.submitOp({p: ['author'], od: 'J.F.Salinger', oi: 'J.D.Salinger'}, next); }, - function (next) { - doc.submitOp({ p: ['author'], od: 'J.D.Salinger', oi: 'J.D. Salinger' }, next); + function(next) { + doc.submitOp({p: ['author'], od: 'J.D.Salinger', oi: 'J.D. Salinger'}, next); } ]); }); diff --git a/test/ot.js b/test/ot.js index 3f663bac4..b58b9c899 100644 --- a/test/ot.js +++ b/test/ot.js @@ -3,7 +3,6 @@ var ot = require('../lib/ot'); var type = require('../lib/types').defaultType; describe('ot', function() { - describe('checkOp', function() { it('fails if op is not an object', function() { expect(ot.checkOp('hi')).ok(); @@ -28,7 +27,7 @@ describe('ot', function() { }); it('fails if the type is missing', function() { - expect(ot.checkOp({create:{type: 'something that does not exist'}})).ok(); + expect(ot.checkOp({create: {type: 'something that does not exist'}})).ok(); }); it('accepts valid create operations', function() { @@ -37,11 +36,11 @@ describe('ot', function() { }); it('accepts valid delete operations', function() { - expect(ot.checkOp({del:true})).equal(); + expect(ot.checkOp({del: true})).equal(); }); it('accepts valid ops', function() { - expect(ot.checkOp({op:[1,2,3]})).equal(); + expect(ot.checkOp({op: [1, 2, 3]})).equal(); }); }); @@ -106,11 +105,11 @@ describe('ot', function() { describe('op', function() { it('fails if the document does not exist', function() { - expect(ot.apply({v: 6}, {v: 6, op: [1,2,3]})).ok(); + expect(ot.apply({v: 6}, {v: 6, op: [1, 2, 3]})).ok(); }); it('fails if the type is missing', function() { - expect(ot.apply({v: 6, type: 'some non existant type'}, {v: 6, op: [1,2,3]})).ok(); + expect(ot.apply({v: 6, type: 'some non existant type'}, {v: 6, op: [1, 2, 3]})).ok(); }); it('applies the operation to the document data', function() { @@ -238,5 +237,4 @@ describe('ot', function() { expect(op).eql({}); }); }); - }); diff --git a/test/projections.js b/test/projections.js index 3d6f35687..52dc72618 100644 --- a/test/projections.js +++ b/test/projections.js @@ -3,7 +3,6 @@ var projections = require('../lib/projections'); var type = require('../lib/types').defaultType.uri; describe('projection utility methods', function() { - describe('projectSnapshot', function() { function test(fields, snapshot, expected) { projections.projectSnapshot(fields, snapshot); @@ -91,8 +90,8 @@ describe('projection utility methods', function() { ); test( {x: true}, - {type: type, data: {x: [1,2,3]}}, - {type: type, data: {x: [1,2,3]}} + {type: type, data: {x: [1, 2, 3]}}, + {type: type, data: {x: [1, 2, 3]}} ); test( {x: true}, @@ -186,23 +185,23 @@ describe('projection utility methods', function() { it('filters root ops', function() { test( {}, - {op: [{p: [], od: {a:1, x: 2}, oi: {x: 3}}]}, + {op: [{p: [], od: {a: 1, x: 2}, oi: {x: 3}}]}, {op: [{p: [], od: {}, oi: {}}]} ); test( {x: true}, - {op: [{p: [], od: {a:1, x: 2}, oi: {x: 3}}]}, + {op: [{p: [], od: {a: 1, x: 2}, oi: {x: 3}}]}, {op: [{p: [], od: {x: 2}, oi: {x: 3}}]} ); test( {x: true}, - {op: [{p: [], od: {a:1, x: 2}, oi: {z:3}}]}, + {op: [{p: [], od: {a: 1, x: 2}, oi: {z: 3}}]}, {op: [{p: [], od: {x: 2}, oi: {}}]} ); test( - {x: true, a:true, z:true}, - {op: [{p: [], od: {a:1, x: 2}, oi: {z:3}}]}, - {op: [{p: [], od: {a:1, x: 2}, oi: {z:3}}]} + {x: true, a: true, z: true}, + {op: [{p: [], od: {a: 1, x: 2}, oi: {z: 3}}]}, + {op: [{p: [], od: {a: 1, x: 2}, oi: {z: 3}}]} ); test( {x: true}, @@ -212,7 +211,7 @@ describe('projection utility methods', function() { // If you make the document something other than an object, it just looks like null. test( {x: true}, - {op: [{p: [], od: {a:2, x: 5}, oi: []}]}, + {op: [{p: [], od: {a: 2, x: 5}, oi: []}]}, {op: [{p: [], od: {x: 5}, oi: null}]} ); }); @@ -359,7 +358,7 @@ describe('projection utility methods', function() { {}, {del: true} ); - expect(projections.isOpAllowed(null, {}, {del:true})).equal(true); + expect(projections.isOpAllowed(null, {}, {del: true})).equal(true); }); it('works with ops', function() { diff --git a/test/pubsub.js b/test/pubsub.js index 48f8abb6b..57c193095 100644 --- a/test/pubsub.js +++ b/test/pubsub.js @@ -38,7 +38,7 @@ module.exports = function(create) { it('publish optional callback returns', function(done) { var pubsub = this.pubsub; - pubsub.subscribe('x', function(err, stream) { + pubsub.subscribe('x', function(err) { if (err) done(err); pubsub.publish(['x'], {test: true}, done); }); @@ -46,10 +46,10 @@ module.exports = function(create) { it('can subscribe to a channel twice', function(done) { var pubsub = this.pubsub; - pubsub.subscribe('y', function(err, stream) { + pubsub.subscribe('y', function(err) { + if (err) done(err); pubsub.subscribe('y', function(err, stream) { if (err) done(err); - var emitted; stream.on('data', function(data) { expect(data).eql({test: true}); done(); diff --git a/test/util.js b/test/util.js index 5f982ed6e..ee32c8744 100644 --- a/test/util.js +++ b/test/util.js @@ -15,6 +15,12 @@ exports.pluck = function(docs, key) { return values; }; +exports.errorHandler = function(callback) { + return function(err) { + if (err) callback(err); + }; +}; + // Wrap a done function to call back only after a specified number of calls. // For example, `var callbackAfter = callAfter(1, callback)` means that if // `callbackAfter` is called once, it won't call back. If it is called twice @@ -52,7 +58,7 @@ exports.callInSeries = function(callbacks, args) { var callback = callbacks.shift(); if (callbacks.length) { - args.push(function () { + args.push(function() { var args = Array.from(arguments); exports.callInSeries(callbacks, args); });