diff --git a/README.md b/README.md index 72c0c2868..df612c696 100644 --- a/README.md +++ b/README.md @@ -220,8 +220,14 @@ The document was created. Technically, this means it has a type. `source` will b `doc.on('before op'), function(op, source) {...})` An operation is about to be applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. -`doc.on('op', function(op, source) {...})` -An operation was applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. +`doc.on('after op', function(op, source) {...})` +An operation was entirely applied to the data. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. + +`doc.on('before component', function(op, source) {...})` +An operation component is about to be applied to the data. `op` will be part of a shattered operation consisting of an operation with only a single component to be applied. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. If incremental apply is disabled or the doc ot type doesn't support shatter(), this event will still emit but with `op` being the entire operation. + +`doc.on('after component', function(op, source) {...})` +An operation component was applied to the data. `op` will be part of a shattered operation consisting of an operation with only a single component that has been applied. `source` will be `false` for ops received from the server and defaults to `true` for ops generated locally. If incremental apply is disabled or the doc ot type doesn't support shatter(), this event will still emit but with `op` being the entire operation. `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. diff --git a/lib/client/doc.js b/lib/client/doc.js index 05e17976d..15f59c968 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -87,6 +87,10 @@ function Doc(connection, collection, id) { // The OT type of this document. An uncreated document has type `null` this.type = null; + // Enable ops be incrementally applied. OT Type must support type.shatter() + this.applyLocalOpsIncremental = false; + this.applyRemoteOpsIncremental = true; + // The applyStack enables us to track any ops submitted while we are // applying an op incrementally. This value is an array when we are // performing an incremental apply and null otherwise. When it is an array, @@ -507,12 +511,11 @@ Doc.prototype._otApply = function(op, source) { return this.emit('error', err); } - // Iteratively apply multi-component remote operations and rollback ops - // (source === false) for the default JSON0 OT type. It could use - // type.shatter(), but since this code is so specific to use cases for the - // JSON0 type and ShareDB explicitly bundles the default type, we might as - // well write it this way and save needing to iterate through the op - // components twice. + // The 'before op' event enables clients to pull any necessary data out of + // the snapshot before it gets changed + this.emit('before op', op.op, source); + + // Iteratively apply multi-component for the OT types that support type.shatter(). // // Ideally, we would not need this extra complexity. However, it is // helpful for implementing bindings that update DOM nodes and other @@ -522,12 +525,15 @@ Doc.prototype._otApply = function(op, source) { // that the snapshot only include updates from the particular op component // at the time of emission. Eliminating this would require rethinking how // such external bindings are implemented. - if (!source && this.type === types.defaultType && op.op.length > 1) { + if ( (this.type.shatter && op.op.length > 1) && + ( (this.applyLocalOpsIncremental && source) || + (this.applyRemoteOpsIncremental && !source) ) ) { + if (!this.applyStack) this.applyStack = []; var stackLength = this.applyStack.length; - for (var i = 0; i < op.op.length; i++) { - var component = op.op[i]; - var componentOp = {op: [component]}; + var shatteredOps = this.type.shatter(op.op); + for (var i = 0; i < shatteredOps.length; i++) { + var componentOp = {op: shatteredOps[i]}; // Transform componentOp against any ops that have been submitted // sychronously inside of an op event handler since we began apply of // our operation @@ -536,26 +542,27 @@ Doc.prototype._otApply = function(op, source) { if (transformErr) return this._hardRollback(transformErr); } // Apply the individual op component - this.emit('before op', componentOp.op, source); + this.emit('before component', componentOp.op, source); this.data = this.type.apply(this.data, componentOp.op); - this.emit('op', componentOp.op, source); + this.emit('after component', componentOp.op, source); } // Pop whatever was submitted since we started applying this op this._popApplyStack(stackLength); - return; } - // The 'before op' event enables clients to pull any necessary data out of - // the snapshot before it gets changed - 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); - // Emit an 'op' event once the local data includes the changes from the + // Apply the full operation to the local data, mutating it in place + else { + this.emit('before component', op.op, source); + this.data = this.type.apply(this.data, op.op); + this.emit('after component', op.op, source); + } + + // Emit an 'after 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. // For ops from other clients, this will be after the op has been // committed to the database and published - this.emit('op', op.op, source); + this.emit('after op', op.op, source); return; } diff --git a/test/client/doc.js b/test/client/doc.js index eef54d92f..8033cfdad 100644 --- a/test/client/doc.js +++ b/test/client/doc.js @@ -67,7 +67,7 @@ describe('client query subscribe', function() { }); } - it('single component ops emit an `op` event', function(done) { + it('single component ops emit an `after component` event', function(done) { var doc = this.doc; var doc2 = this.doc2; var doc3 = this.doc3; @@ -92,7 +92,7 @@ describe('client query subscribe', function() { expect(doc.data).eql({color: 'black'}); } ]; - doc.on('op', function(op, source) { + doc.on('after component', function(op, source) { var handler = handlers.shift(); handler(op, source); }); @@ -104,7 +104,7 @@ describe('client query subscribe', function() { }); }); - it('remote multi component ops emit individual `op` events', function(done) { + it('remote multi component ops emit multiple `after component` events', function(done) { var doc = this.doc; var doc2 = this.doc2; var doc3 = this.doc3; @@ -141,7 +141,7 @@ describe('client query subscribe', function() { expect(doc.data).eql({color: 'black', weight: 40, age: 5, owner: 'sue'}); } ]; - doc.on('op', function(op, source) { + doc.on('after component', function(op, source) { var handler = handlers.shift(); handler(op, source); }); @@ -153,7 +153,7 @@ describe('client query subscribe', function() { }); }); - it('remote multi component ops are transformed by ops submitted in `op` event handlers', function(done) { + it('remote multi component ops are transformed by ops submitted in `after component` event handlers', function(done) { var doc = this.doc; var doc2 = this.doc2; var doc3 = this.doc3; @@ -192,7 +192,7 @@ describe('client query subscribe', function() { expect(doc.data).eql({tricks: ['shake', 'tug stick']}); } ]; - doc.on('op', function(op, source) { + doc.on('after component', function(op, source) { var handler = handlers.shift(); handler(op, source); }); @@ -210,6 +210,85 @@ describe('client query subscribe', function() { }); }); + + it('ops emit all lifecycle events', function(done) { + var doc = this.doc; + var doc2 = this.doc2; + var doc3 = this.doc3; + var handlers = [ + // doc submit before op + function(op, source) { + expect(source).equal(true); + expect(op).eql([{p: ['make'], oi: 'bmw'}, {p: ['speed'], oi: 160}]); + expect(doc.data).eql({}); + // doc submit before component 1 + }, function(op, source) { + expect(source).equal(true); + expect(op).eql([{p: ['make'], oi: 'bmw'}]); + expect(doc.data).eql({}); + // doc submit after component 1 + }, function(op, source) { + expect(source).equal(true); + expect(op).eql([{p: ['make'], oi: 'bmw'}]); + expect(doc.data).eql({make: 'bmw'}); + // doc submit before component 2 + }, function(op, source) { + expect(source).equal(true); + expect(op).eql([{p: ['speed'], oi: 160}]); + expect(doc.data).eql({make: 'bmw'}); + // doc submit after component 2 + }, function(op, source) { + expect(source).equal(true); + expect(op).eql([{p: ['speed'], oi: 160}]); + expect(doc.data).eql({make: 'bmw', speed: 160}); + // doc submit after op + }, function(op, source) { + expect(source).equal(true); + expect(op).eql([{p: ['make'], oi: 'bmw'}, {p: ['speed'], oi: 160}]); + expect(doc.data).eql({make: 'bmw', speed: 160}); + // doc2 submit before op + }, function(op, source) { + expect(source).equal(false); + expect(op).eql([{p: ['model'], oi: '260e'}]); + expect(doc.data).eql({make: 'bmw', speed: 160}); + // doc2 submit before component 1 + }, function(op, source) { + expect(source).equal(false); + expect(op).eql([{p: ['model'], oi: '260e'}]); + expect(doc.data).eql({make: 'bmw', speed: 160}); + // doc2 submit after component 1 + }, function(op, source) { + expect(source).equal(false); + expect(op).eql([{p: ['model'], oi: '260e'}]); + expect(doc.data).eql({make: 'bmw', model: '260e', speed: 160}); + // doc2 submit after op + }, function(op, source) { + expect(source).equal(false); + expect(op).eql([{p: ['model'], oi: '260e'}]); + expect(doc.data).eql({make: 'bmw', model: '260e', speed: 160}); + } + ]; + + doc.applyLocalOpsIncremental = true; + doc.applyRemoteOpsIncremental = true; + + var handleOpEvent = function(op, source) { + var handler = handlers.shift(); + handler(op, source); + }; + doc.on('before op', handleOpEvent); + doc.on('before component', handleOpEvent); + doc.on('after component', handleOpEvent); + doc.on('after op', handleOpEvent); + + doc2.submitOp([{p: ['make'], oi: 'mercedes'}, {p: ['model'], oi: '260e'}], function(err) { + if (err) return done(err); + doc.submitOp([{p: ['make'], oi: 'bmw'}, {p: ['speed'], oi: 160}]); + expect(doc.data).eql({make: 'bmw', speed: 160}); + verifyConsistency(doc, doc2, doc3, handlers, done); + }); + }); + }); }); diff --git a/test/client/projections.js b/test/client/projections.js index fb1c993e5..e6f10c7d0 100644 --- a/test/client/projections.js +++ b/test/client/projections.js @@ -115,7 +115,7 @@ describe('client projections', function() { var fido = connection2.get('dogs_summary', 'fido'); fido.subscribe(function(err) { if (err) return done(err); - fido.on('op', function() { + fido.on('after op', function() { expect(fido.data).eql(expected); expect(fido.version).eql(2); done(); @@ -154,7 +154,7 @@ describe('client projections', function() { var fido = connection2.get('dogs_summary', 'fido'); connection2.createSubscribeQuery('dogs_summary', {}, null, function(err) { if (err) return done(err); - fido.on('op', function() { + fido.on('after op', function() { expect(fido.data).eql(expected); expect(fido.version).eql(2); done(); diff --git a/test/client/subscribe.js b/test/client/subscribe.js index 567031d0a..8c2d5c4ab 100644 --- a/test/client/subscribe.js +++ b/test/client/subscribe.js @@ -174,7 +174,7 @@ describe('client subscribe', function() { if (err) return done(err); doc.submitOp({p: ['age'], na: 1}, function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { done(); }); doc2[method](); @@ -343,7 +343,7 @@ describe('client subscribe', function() { if (err) return done(err); doc2.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { expect(doc2.version).eql(2); expect(doc2.data).eql({age: 4}); done(); @@ -360,7 +360,7 @@ describe('client subscribe', function() { if (err) return done(err); doc2.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { done(); }); doc2.connection.close(); @@ -377,7 +377,7 @@ describe('client subscribe', function() { if (err) return done(err); doc2.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { done(); }); backend.suppressPublish = true; @@ -393,7 +393,7 @@ describe('client subscribe', function() { if (err) return done(err); doc2.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { done(); }); doc2.unsubscribe(function(err) { @@ -411,7 +411,7 @@ describe('client subscribe', function() { if (err) return done(err); doc2.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { done(); }); doc2.destroy(function(err) { @@ -441,7 +441,7 @@ describe('client subscribe', function() { function(cb) { spot.unsubscribe(cb); } ], function(err) { if (err) return done(err); - fido.on('op', function(op, context) { + fido.on('after op', function(op, context) { done(); }); doc.submitOp({p: ['age'], na: 1}, done); @@ -459,7 +459,7 @@ describe('client subscribe', function() { if (err) return done(err); doc2.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { expect(doc2.version).eql(2); expect(doc2.data).eql({age: 4}); done(); @@ -483,7 +483,7 @@ describe('client subscribe', function() { doc2.unsubscribe(); doc2.subscribe(function(err) { if (err) return done(err); - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { done(); }); doc.submitOp({p: ['age'], na: 1}); @@ -503,7 +503,7 @@ describe('client subscribe', function() { [{p: ['age'], na: 1}], [{p: ['age'], na: 5}], ]; - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { var item = expected.shift(); expect(op).eql(item); if (expected.length) return; @@ -535,7 +535,7 @@ describe('client subscribe', function() { doc2.subscribe(function(err) { if (err) return done(err); var wait = 4; - doc2.on('op', function(op, context) { + doc2.on('after op', function(op, context) { if (--wait) return; expect(doc2.version).eql(5); expect(doc2.data).eql({age: 122});