UNPKG

sharedb

Version:
1,450 lines (1,345 loc) 53.7 kB
var async = require('async'); var expect = require('chai').expect; var sinon = require('sinon'); var types = require('../../lib/types'); var deserializedType = require('./deserialized-type'); var numberType = require('./number-type'); var errorHandler = require('../util').errorHandler; var richText = require('rich-text'); types.register(deserializedType.type); types.register(deserializedType.type2); types.register(numberType.type); types.register(richText.type); module.exports = function() { describe('client submit', function() { var id; var idCount = 0; beforeEach(function() { id = 'dog-' + idCount++; }); it('can fetch an uncreated doc', function(done) { var doc = this.backend.connect().get('dogs', id); expect(doc.data).equal(undefined); 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', id); 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 create a new doc without fetching', function(done) { var doc = this.backend.connect().get('dogs', id); 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 a new doc and pass options to getSnapshot', function(done) { var connection = this.backend.connect(); connection.agent.custom = { foo: 'bar' }; var getSnapshotSpy = sinon.spy(this.backend.db, 'getSnapshot'); connection.get('dogs', id).create({age: 3}, function(err) { if (err) return done(err); expect(getSnapshotSpy.firstCall.args[3]).to.haveOwnProperty('agentCustom').that.deep.equals({foo: 'bar'}); done(); }); }); it('can create then delete then create a doc', function(done) { var doc = this.backend.connect().get('dogs', id); 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.create({age: 2}, function(err) { if (err) return done(err); expect(doc.data).eql({age: 2}); expect(doc.version).eql(3); done(); }); }); }); }); it('can create then submit an op', function(done) { var doc = this.backend.connect().get('dogs', id); 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: 5}); expect(doc.version).eql(2); done(); }); }); }); it('can create then submit an op sync', function(done) { var doc = this.backend.connect().get('dogs', id); 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', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc.version++; doc.submitOp({p: ['age'], na: 2}, function(err) { expect(err).instanceOf(Error); done(); }); }); }); it('cannot submit op on an uncreated doc', function(done) { var doc = this.backend.connect().get('dogs', id); doc.submitOp({p: ['age'], na: 2}, function(err) { expect(err).instanceOf(Error); done(); }); }); it('cannot delete an uncreated doc', function(done) { var doc = this.backend.connect().get('dogs', id); doc.del(function(err) { expect(err).instanceOf(Error); done(); }); }); it('ops submitted sync get composed', function(done) { var doc = this.backend.connect().get('dogs', id); 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); 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); 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', id); 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); 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', id); 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; 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(); }); }); }); describe('create', function() { describe('metadata enabled', function() { runCreateTests(); }); describe('no snapshot metadata available', function() { beforeEach(function() { var getSnapshot = this.backend.db.getSnapshot; sinon.stub(this.backend.db, 'getSnapshot') .callsFake(function() { var args = Array.from(arguments); var callback = args.pop(); args.push(function(error, snapshot) { if (snapshot) delete snapshot.m; callback(error, snapshot); }); getSnapshot.apply(this, args); }); }); afterEach(function() { sinon.restore(); }); runCreateTests(); it('returns errors if the database cannot get committed op version', function(done) { sinon.stub(this.backend.db, 'getCommittedOpVersion') .callsFake(function() { var args = Array.from(arguments); var callback = args.pop(); callback(new Error('uh-oh')); }); var doc1 = this.backend.connect().get('dogs', 'fido'); var doc2 = this.backend.connect().get('dogs', 'fido'); async.series([ doc1.create.bind(doc1, {age: 3}), function(next) { doc2.create({name: 'Fido'}, function(error) { expect(error.message).to.equal('uh-oh'); next(); }); } ], done); }); }); function runCreateTests() { it('can create a new doc then fetch', function(done) { var doc = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); 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', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc.create({age: 4}, function(err) { expect(err).instanceOf(Error); 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', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.create({age: 4}, function(err) { expect(err).instanceOf(Error); expect(doc2.version).equal(1); expect(doc2.data).eql({age: 3}); done(); }); }); }); it('does not fail when resubmitting a create op', function(done) { var backend = this.backend; var connection = backend.connect(); var submitted = false; backend.use('submit', function(request, next) { if (!submitted) { submitted = true; connection.close(); backend.connect(connection); } next(); }); var count = 0; backend.use('reply', function(message, next) { next(); if (message.reply.a === 'op') count++; if (count === 2) done(); }); var doc = connection.get('dogs', id); doc.create({age: 10}, errorHandler(done)); }); it('does not fail when resubmitting a create op on a doc that was deleted', function(done) { var backend = this.backend; var connection1 = backend.connect(); var connection2 = backend.connect(); var doc1 = connection1.get('dogs', id); var doc2 = connection2.get('dogs', id); async.series([ doc1.create.bind(doc1, {age: 3}), doc1.del.bind(doc1), function(next) { var submitted = false; backend.use('submit', function(request, next) { if (!submitted) { submitted = true; connection2.close(); backend.connect(connection2); } next(); }); doc2.create({name: 'Fido'}, function(error) { expect(doc2.version).to.equal(3); next(error); }); } ], done); }); } }); it('server fetches and transforms by already committed op', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); doc.submitOp({p: ['age'], na: 1}, function(err) { if (err) return done(err); 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', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); doc.submitOp({p: ['age'], na: 1}, function(err) { if (err) return done(err); doc2.submitOp({p: ['age'], na: 2}, function(err) { expect(err).instanceOf(Error); 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', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); doc.submitOp({p: ['age'], na: 1}, function(err) { if (err) return done(err); doc2.submitOp({p: ['age'], na: 2}, function(err) { expect(err).instanceOf(Error); done(); }); }); }); }); }); 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', id); 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', id); 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', id); 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('resends delete when disconnected before ack', function(done) { var backend = this.backend; var doc = backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); 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('op submitted during inflight create does not compose and gets flushed', function(done) { this.backend.connect(null, null, function(connection) { var doc = connection.get('dogs', id); 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) { if (err) return done(err); expect(doc.version).equal(2); expect(doc.data).eql({age: 5}); done(); }); }); }); }); it('can commit then fetch in a new connection to get the same data', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); 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('an op submitted concurrently is transformed by the first', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); 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(); } }); }); }); }); it('second of two concurrent creates is rejected', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); 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).instanceOf(Error); 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).instanceOf(Error); expect(doc2.version).eql(1); expect(doc2.data).eql({age: 3}); done(); } }); }); it('concurrent delete operations transform', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); 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 retry below the backend.maxSubmitRetries threshold', function(done) { this.backend.maxSubmitRetries = 10; var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); 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('submits fail above the backend.maxSubmitRetries threshold', function(done) { var backend = this.backend; this.backend.maxSubmitRetries = 0; var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(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(err) { if (err) return done(err); // 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(err) { expect(err).instanceOf(Error); done(); }); }); }); }); it('pending delete transforms incoming ops', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); doc2.submitOp({p: ['age'], na: 1}, 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('pending delete transforms incoming delete', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); 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('submitting op after delete returns error', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); doc2.del(function(err) { if (err) return done(err); doc.submitOp({p: ['age'], na: 1}, function(err) { expect(err).instanceOf(Error); expect(doc.version).equal(1); expect(doc.data).eql({age: 3}); done(); }); }); }); }); }); it('transforming pending op by server delete returns error', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.fetch(function(err) { if (err) return done(err); doc2.del(function(err) { if (err) return done(err); doc.pause(); doc.submitOp({p: ['age'], na: 1}, function(err) { expect(err.code).to.equal('ERR_DOC_WAS_DELETED'); expect(doc.version).equal(2); expect(doc.data).eql(undefined); done(); }); doc.fetch(); }); }); }); }); it('transforming pending op by server create returns error', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc.del(function(err) { if (err) return done(err); 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('ERR_DOC_ALREADY_CREATED'); expect(doc.version).equal(3); expect(doc.data).eql({age: 5}); done(); }); doc.fetch(); }); }); }); }); }); it('second client can create following delete', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); 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('doc.pause() prevents ops from being sent', function(done) { var doc = this.backend.connect().get('dogs', id); doc.pause(); doc.create({age: 3}, done); done(); }); it('can call doc.resume() without pausing', function(done) { var doc = this.backend.connect().get('dogs', id); doc.resume(); doc.create({age: 3}, done); }); it('doc.resume() resumes sending ops after pause', function(done) { var doc = this.backend.connect().get('dogs', id); doc.pause(); doc.create({age: 3}, done); doc.resume(); }); it('pending ops are transformed by ops from other clients', function(done) { var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); 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); 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); 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(); }); }); }); }); }); }); }); }); it('snapshot fetch does not revert the version of deleted doc without pending ops', function(done) { var doc = this.backend.connect().get('dogs', id); this.backend.use('readSnapshots', 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', id); this.backend.use('readSnapshots', function(request, next) { doc.create({age: 3}, function(err) { if (err) return done(err); next(); }); process.nextTick(function() { doc.pause(); doc.del(done); }); }); doc.fetch(function(err) { if (err) return done(err); expect(doc.version).equal(1); doc.resume(); }); }); 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', id); 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', id); 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', id); 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(); }); expect(doc.version).equal(null); expect(doc.data).eql({age: 4}); }); } ], done); }); it('request.rejectedError() soft rejects main op and throws for pending ops on hard rollback', function(done) { this.backend.use('submit', function(request, next) { if (request.op.create) { next(request.rejectedError()); } }); var connection = this.backend.connect(); var doc = connection.get('dogs', id); doc.preventCompose = true; doc.create({age: 3}, function(error) { if (error) done(error); }); doc.submitOp({p: ['age'], na: 1}, function(err) { expect(err.code).to.be.equal('ERR_PENDING_OP_REMOVED_BY_OP_SUBMIT_REJECTED'); 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', id); 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', id); 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(); }); }); it( 'request.rejectedError() soft rejects main op and throws for pending ops on hard rollback without callback', function(done) { this.backend.use('submit', function(request, next) { if (request.op.create) { next(request.rejectedError()); } }); var connection = this.backend.connect(); var doc = connection.get('dogs', id); doc.preventCompose = true; doc.create({age: 3}); doc.submitOp({p: ['age'], na: 1}); doc.on('error', function(err) { expect(err.code).to.be.equal('ERR_PENDING_OP_REMOVED_BY_OP_SUBMIT_REJECTED'); done(); }); } ); 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 ('op' in request.op) return next({message: 'Custom error'}); next(); }); var doc = this.backend.connect().get('dogs', id); 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: 4}); }); }); it('passing an error in submit middleware rejects an op and emits the erorr', function(done) { this.backend.use('submit', function(request, next) { if ('op' in request.op) return next({message: 'Custom error'}); next(); }); var doc = this.backend.connect().get('dogs', id); 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.on('error', function(err) { expect(err.message).equal('Custom error'); expect(doc.version).equal(1); expect(doc.data).eql({age: 3}); 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', id); doc.create({age: 3}, function(err) { if (err) return done(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(doc.version).equal(2); expect(doc.data).eql({age: 8}); expect(submitCount).equal(3); done(); }); }); }); it('request.rejectedError() soft rejects an op', function(done) { this.backend.use('submit', function(request, next) { if ('op' in request.op) return next(request.rejectedError()); next(); }); var doc = this.backend.connect().get('dogs', id); 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('request.rejectedError() soft rejects main op and pending ops for invertible type', function(done) { var rejectedOnce = false; this.backend.use('submit', function(request, next) { if ('op' in request.op && !rejectedOnce) { rejectedOnce = true; return next(request.rejectedError()); } next(); }); var doc = this.backend.connect().get('dogs', id); doc.preventCompose = true; doc.create({age: 3}, function(err) { if (err) return done(err); doc.submitOp({p: ['age'], na: 1}, function(err) { if (err) return done(err); }); doc.submitOp({p: ['age'], na: 3}, function(err) { if (err) return done(err); expect(doc.version).equal(2); expect(doc.data).eql({age: 6}); done(); }); expect(doc.version).equal(1); expect(doc.data).eql({age: 7}); }); }); it( 'request.rejectedError() soft rejects main op and throws for pending ops for non invertible type', function(done) { var rejectedOnce = false; this.backend.use('submit', function(request, next) { if ('op' in request.op && !rejectedOnce) { rejectedOnce = true; return next(request.rejectedError()); } next(); }); var doc = this.backend.connect().get('dogs', id); doc.preventCompose = true; doc.create({ops: [{insert: 'Scrappy'}]}, 'rich-text', function(err) { if (err) return done(err); var nonInvertibleOp = [{insert: 'a'}]; doc.submitOp(nonInvertibleOp, function(err) { if (err) return done(err); }); doc.submitOp([{insert: 'b'}], function(err) { expect(err.code).to.be.equal('ERR_PENDING_OP_REMOVED_BY_OP_SUBMIT_REJECTED'); done(); }); expect(doc.version).equal(1); expect(doc.data.ops).eql([{insert: 'baScrappy'}]); }); } ); it('request.rejectedError() soft rejects an op without callback', function(done) { this.backend.use('submit', function(request, next) { if ('op' in request.op) return next(request.rejectedError()); next(); }); var doc = this.backend.connect().get('dogs', id); 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() { expect(doc.version).equal(1); expect(doc.data).eql({age: 3}); done(); }); }); }); it('deleting op.op makes it a no-op while returning success to the submitting client', function(done) { this.backend.use('submit', function(request, next) { if (request.op) delete request.op.op; next(); }); var doc = this.backend.connect().get('dogs', id); var doc2 = this.backend.connect().get('dogs', id); 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(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('submitting an invalid op message returns error', function(done) { var doc = this.backend.connect().get('dogs', id); doc.create({age: 3}, function(err) { if (err) return done(err); doc._submit({}, null, function(err) { expect(err).instanceOf(Error); done(); }); }); }); it('allows snapshot and op to be a non-object', function(done) { var doc = this.backend.connect().get('dogs', id); doc.create(5, numberType.type.uri, function(err) { if (err) return done(err); expect(doc.data).to.equal(5); doc.submitOp(2, function(err) { if (err) return done(err); expect(doc.data).to.equal(7); done(); }); }); }); it('commits both of two identical ops submitted from different clients by default', function(done) { var backend = this.backend; backend.doNotCommitNoOps = false; var doc1 = backend.connect().get('dogs', id); var doc2 = backend.connect().get('dogs', id); var op = [{p: ['tricks', 0], ld: 'fetch'}]; async.series([ doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}), doc2.fetch.bind(doc2), async.parallel.bind(async.parallel, [ doc1.submitOp.bind(doc1, op), doc2.submitOp.bind(doc2, op) ]), function(next) { expect(doc1.data).to.eql({tricks: ['sit']}); expect(doc2.data).to.eql({tricks: ['sit']}); next(); }, function(next) { backend.db.getOps('dogs', id, 0, null, {}, function(error, ops) { if (error) return next(error); // Expect: // v0: create // v1: update // v2: duplicate update expect(ops).to.have.length(3); next(); }); } ], done); }); it('only commits one of two identical ops submitted from different clients', function(done) { var backend = this.backend; backend.doNotCommitNoOps = true; var doc1 = backend.connect().get('dogs', id); var doc2 = backend.connect().get('dogs', id); var op = [{p: ['tricks', 0], ld: 'fetch'}]; async.series([ doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}), doc2.fetch.bind(doc2), async.parallel.bind(async.parallel, [ doc1.submitOp.bind(doc1, op), doc2.submitOp.bind(doc2, op) ]), function(next) { expect(doc1.data).to.eql({tricks: ['sit']}); expect(doc2.data).to.eql({tricks: ['sit']}); next(); }, function(next) { backend.db.getOps('dogs', id, 0, null, {}, function(error, ops) { if (error) return next(error); // Expect: // v0: create // v1: update // no duplicate update expect(ops).to.have.length(2); next(); }); } ], done); }); it('can submit a new op after getting a no-op', function(done) { var backend = this.backend; backend.doNotCommitNoOps = true; var doc1 = backend.connect().get('dogs', id); var doc2 = backend.connect().get('dogs', id); var op = [{p: ['tricks', 0], ld: 'fetch'}]; async.series([ doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}), doc2.fetch.bind(doc2), async.parallel.bind(async.parallel, [ doc1.submitOp.bind(doc1, op), doc2.submitOp.bind(doc2, op) ]), function(next) { expect(doc1.data).to.eql({tricks: ['sit']}); expect(doc2.data).to.eql({tricks: ['sit']}); next(); }, doc1.submitOp.bind(doc1, [{p: ['tricks', 0], li: 'play dead'}]), function(next) { expect(doc1.data.tricks).to.eql(['play dead', 'sit']); next(); } ], done); }); it('fixes up even if an op is fixed up to become a no-op', function(done) { var backend = this.backend; backend.doNotCommitNoOps = true; var doc = backend.connect().get('dogs', id); backend.use('apply', function(req, next) { req.$fixup([{p: ['fixme'], od: true}]); next(); }); async.series([ doc.create.bind(doc, {}), doc.submitOp.bind(doc, [{p: ['fixme'], oi: true}]), function(next) { expect(doc.data).to.eql({}); next(); } ], done); }); describe('type.deserialize', function() { it('can create a new doc', function(done) { var doc = this.backend.connect().get('dogs', id); doc.create([3], deserializedType.type.uri, function(err) { if (err) return done(err); expect(doc.data).instanceOf(deserializedType.Node); expect(doc.data.value).equal(3); expect(doc.data.next).equal(null); done(); }); }); it('is stored serialized in backend', function(done) { var db = this.backend.db; var doc = this.backend.connect().get('dogs', id); doc.create([3], deserializedType.type.uri, function(err) { if (err) return done(err); db.getSnapshot('dogs', id, 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', id); var doc2 = this.backend.connect().get('dogs', id); 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).instanceOf(deserializedType.Node); expect(doc2.data.value).equal(3); expect(doc2.data.next).equal(null); done(); }); }); }); it('can create then submit an op', function(done) { var doc = this.backend.connect().get('dogs', id); doc.create([3], deserializedType.type.uri, function(err) { if (err) return done(err); doc.submitOp({insert: 0, value: 2}, function(e