sharedb
Version:
JSON OT database backend
1,450 lines (1,345 loc) • 53.7 kB
JavaScript
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