sharedb 6.0.4 → 6.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/agent.js CHANGED
@@ -229,6 +229,7 @@ Agent.prototype._subscribeToQuery = function(emitter, queryId, collection, query
229
229
 
230
230
  Agent.prototype._onOp = function(collection, id, op) {
231
231
  if (this._isOwnOp(collection, op)) return;
232
+ if (this._isOpFromCollidingChannel(collection, id, op)) return;
232
233
 
233
234
  // Ops emitted here are coming directly from pubsub, which emits the same op
234
235
  // object to listeners without making a copy. The pattern in middleware is to
@@ -263,6 +264,12 @@ Agent.prototype._isOwnOp = function(collection, op) {
263
264
  return (this._src() === op.src) && (collection === (op.i || op.c));
264
265
  };
265
266
 
267
+ Agent.prototype._isOpFromCollidingChannel = function(collection, id, op) {
268
+ var projection = this.backend.projections[collection];
269
+ var target = (projection) ? projection.target : collection;
270
+ return op.c !== target || op.d !== id;
271
+ };
272
+
266
273
  Agent.prototype.send = function(message) {
267
274
  // Quietly drop replies if the stream was closed
268
275
  if (this.closed) return;
@@ -924,6 +931,10 @@ Agent.prototype._subscribePresence = function(channel, seq, cb) {
924
931
  return callback();
925
932
  }
926
933
  agent.presenceSubscriptionSeq[channel] = seq;
934
+ if (agent.subscribedPresences[channel]) {
935
+ stream.destroy();
936
+ return callback();
937
+ }
927
938
  agent.subscribedPresences[channel] = stream;
928
939
  agent._subscribeToPresenceStream(channel, stream);
929
940
  agent._requestPresence(channel, function(error) {
package/lib/backend.js CHANGED
@@ -569,7 +569,7 @@ Backend.prototype.subscribeBulk = function(agent, index, versions, callback) {
569
569
  var backend = this;
570
570
  var streams = Object.create(null);
571
571
  var doFetch = Array.isArray(versions);
572
- var ids = (doFetch) ? versions : Object.keys(versions);
572
+ var ids = (doFetch) ? uniqueIds(versions) : Object.keys(versions);
573
573
  var request = {
574
574
  agent: agent,
575
575
  index: index,
@@ -626,6 +626,14 @@ function destroyStreams(streams) {
626
626
  streams[id].destroy();
627
627
  }
628
628
  }
629
+ function uniqueIds(ids) {
630
+ var seen = Object.create(null);
631
+ return ids.filter(function(id) {
632
+ if (seen[id]) return false;
633
+ seen[id] = true;
634
+ return true;
635
+ });
636
+ }
629
637
 
630
638
  Backend.prototype.queryFetch = function(agent, index, query, options, callback) {
631
639
  var start = Date.now();
@@ -739,12 +747,16 @@ Backend.prototype._triggerQuery = function(agent, index, query, options, callbac
739
747
  if (err) return callback(err);
740
748
  // Set the DB reference for the request after the middleware trigger so
741
749
  // that the db option can be changed in middleware
742
- request.db = (options.db) ? backend.extraDbs[options.db] : backend.db;
750
+ request.db = (options.db) ? getExtraDb(backend, options.db) : backend.db;
743
751
  if (!request.db) return callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_ADAPTER_NOT_FOUND, 'DB not found'));
744
752
  request.snapshotProjection = backend._getSnapshotProjection(request.db, projection);
745
753
  callback(null, request);
746
754
  });
747
755
  };
756
+ function getExtraDb(backend, name) {
757
+ if (typeof name !== 'string' || !util.hasOwn(backend.extraDbs, name)) return null;
758
+ return backend.extraDbs[name];
759
+ }
748
760
 
749
761
  Backend.prototype._query = function(agent, request, callback) {
750
762
  var backend = this;
@@ -875,8 +887,6 @@ Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp
875
887
  var backend = this;
876
888
 
877
889
  var milestoneSnapshot;
878
- var from = 0;
879
- var to = null;
880
890
 
881
891
  // Always fetch the current snapshot first. We request its metadata so that we
882
892
  // can read its mtime, which lets us serve the current snapshot directly when
@@ -898,11 +908,12 @@ Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp
898
908
  milestoneDb.getMilestoneSnapshotAtOrBeforeTime(collection, id, timestamp, function(error, snapshot) {
899
909
  if (error) return callback(error);
900
910
  milestoneSnapshot = snapshot;
901
- if (snapshot) from = snapshot.v;
902
911
 
903
912
  milestoneDb.getMilestoneSnapshotAtOrAfterTime(collection, id, timestamp, function(error, snapshot) {
904
913
  if (error) return callback(error);
905
- if (snapshot) to = snapshot.v;
914
+ var from = milestoneSnapshot ? milestoneSnapshot.v : 0;
915
+ var to = snapshot ? snapshot.v : currentSnapshot.v;
916
+ if (from >= to) return backend._buildSnapshotFromOps(id, milestoneSnapshot, [], callback);
906
917
 
907
918
  var options = {metadata: true};
908
919
  db.getOps(collection, id, from, to, options, function(error, ops) {
package/lib/ot.js CHANGED
@@ -122,20 +122,26 @@ function applyOpEdit(snapshot, edit) {
122
122
 
123
123
  // ot-json0 walks ops with .length and numeric indexing, so it treats array-like
124
124
  // objects as ops too, and it coerces .length. This traversal has to match it
125
- // exactly: anything ot-json0 will apply has to be checked here.
125
+ // exactly: anything ot-json0 will apply has to be checked here. Coercing
126
+ // .length or a path segment throws for some objects; ot-json0 throws on those
127
+ // too, so they are rejected rather than applied.
126
128
  // See GHSA-9rqw-j2q5-gg2g
127
129
  function checkJson0OpPaths(op) {
128
130
  if (op == null) return;
129
- for (var i = 0; i < op.length; i++) {
130
- var component = op[i];
131
- // ot-json0's checkValidOp() rejects the whole op before applying any of it,
132
- // so there is nothing beyond this component left to check
133
- if (!component || typeof component !== 'object' || !Array.isArray(component.p)) return;
134
- for (var j = 0; j < component.p.length; j++) {
135
- if (util.isDangerousProperty(component.p[j])) {
136
- return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, 'Invalid path segment');
131
+ try {
132
+ for (var i = 0; i < op.length; i++) {
133
+ var component = op[i];
134
+ // ot-json0's checkValidOp() rejects the whole op before applying any of it,
135
+ // so there is nothing beyond this component left to check
136
+ if (!component || typeof component !== 'object' || !Array.isArray(component.p)) return;
137
+ for (var j = 0; j < component.p.length; j++) {
138
+ if (util.isDangerousProperty(component.p[j])) {
139
+ return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, 'Invalid path segment');
140
+ }
137
141
  }
138
142
  }
143
+ } catch (error) {
144
+ return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, error.message);
139
145
  }
140
146
  }
141
147
 
@@ -97,13 +97,13 @@ function isSnapshotAllowed(fields, snapshot) {
97
97
  }
98
98
 
99
99
  function isEditAllowed(fields, op) {
100
+ if (!Array.isArray(op)) return false;
100
101
  for (var i = 0; i < op.length; i++) {
101
- var c = op[i];
102
- if (c.p.length === 0) {
103
- return false;
104
- } else if (!isFieldAllowed(fields, c.p[0])) {
105
- return false;
106
- }
102
+ var path = op[i] && op[i].p;
103
+ if (!Array.isArray(path)) return false;
104
+ var field = path[0];
105
+ if (typeof field !== 'string' && typeof field !== 'number') return false;
106
+ if (!isFieldAllowed(fields, field)) return false;
107
107
  }
108
108
  return true;
109
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sharedb",
3
- "version": "6.0.4",
3
+ "version": "6.0.5",
4
4
  "description": "JSON OT database backend",
5
5
  "main": "lib/index.js",
6
6
  "dependencies": {
package/test/agent.js CHANGED
@@ -83,4 +83,108 @@ describe('Agent', function() {
83
83
  });
84
84
  });
85
85
  });
86
+
87
+ describe('pub/sub channel collisions', function() {
88
+ var writer;
89
+ var subscriber;
90
+
91
+ beforeEach(function() {
92
+ writer = backend.connect();
93
+ subscriber = backend.connect();
94
+ });
95
+
96
+ it('does not deliver ops for a doc whose collection and id join to the same channel', function(done) {
97
+ var doc = subscriber.get('notes', 'private.alice');
98
+ doc.subscribe(function(error) {
99
+ if (error) return done(error);
100
+ doc.once('create', function() {
101
+ expect(doc.data).to.eql({text: 'Hello'});
102
+ done();
103
+ });
104
+ writer.get('notes.private', 'alice').create({diary: 'Dear diary'}, function(error) {
105
+ if (error) return done(error);
106
+ writer.get('notes', 'private.alice').create({text: 'Hello'}, function(error) {
107
+ if (error) done(error);
108
+ });
109
+ });
110
+ });
111
+ });
112
+
113
+ it('does not deliver ops from a collection whose channel matches the doc channel', function(done) {
114
+ var doc = subscriber.get('notes', 'private');
115
+ doc.subscribe(function(error) {
116
+ if (error) return done(error);
117
+ doc.once('create', function() {
118
+ expect(doc.data).to.eql({text: 'Hello'});
119
+ done();
120
+ });
121
+ writer.get('notes.private', 'alice').create({diary: 'Dear diary'}, function(error) {
122
+ if (error) return done(error);
123
+ writer.get('notes', 'private').create({text: 'Hello'}, function(error) {
124
+ if (error) done(error);
125
+ });
126
+ });
127
+ });
128
+ });
129
+
130
+ it('does not deliver ops to a query on a collection whose channel matches the doc channel', function(done) {
131
+ var opMessages = [];
132
+ var diaryEdit = {p: ['diary'], od: 'Dear diary', oi: 'Dear diary, again'};
133
+ subscriber.on('receive', function(request) {
134
+ if (request.data.a === ACTIONS.op) opMessages.push(request.data);
135
+ });
136
+ var privateDoc = writer.get('notes.private', 'private');
137
+ privateDoc.create({diary: 'Dear diary'}, function(error) {
138
+ if (error) return done(error);
139
+ subscriber.createSubscribeQuery('notes.private', {}, null, function(error, results) {
140
+ if (error) return done(error);
141
+ results[0].once('op', function() {
142
+ expect(opMessages).to.have.length(1);
143
+ expect(opMessages[0].op).to.eql([diaryEdit]);
144
+ done();
145
+ });
146
+ writer.get('notes', 'private').create({text: 'Hello'}, function(error) {
147
+ if (error) return done(error);
148
+ privateDoc.submitOp(diaryEdit, function(error) {
149
+ if (error) done(error);
150
+ });
151
+ });
152
+ });
153
+ });
154
+ });
155
+
156
+ it('does not deliver ops to a query from another collection sharing its custom channel', function(done) {
157
+ backend.use('commit', function(context, next) {
158
+ context.channels.push('shared');
159
+ next();
160
+ });
161
+ backend.use('query', function(context, next) {
162
+ context.channels = ['shared'];
163
+ next();
164
+ });
165
+ var opMessages = [];
166
+ var rename = {p: ['name'], od: 'Fido', oi: 'Rex'};
167
+ subscriber.on('receive', function(request) {
168
+ if (request.data.a === ACTIONS.op) opMessages.push(request.data);
169
+ });
170
+ var dog = writer.get('dogs', 'fido');
171
+ dog.create({name: 'Fido'}, function(error) {
172
+ if (error) return done(error);
173
+ subscriber.createSubscribeQuery('dogs', {}, null, function(error, results) {
174
+ if (error) return done(error);
175
+ results[0].once('op', function() {
176
+ expect(opMessages).to.have.length(1);
177
+ expect(opMessages[0].op).to.eql([rename]);
178
+ done();
179
+ });
180
+ writer.get('cats', 'fido').create({name: 'Tom'}, function(error) {
181
+ if (error) return done(error);
182
+ dog.submitOp(rename, function(error) {
183
+ if (error) done(error);
184
+ });
185
+ });
186
+ });
187
+ });
188
+ });
189
+ });
86
190
  });
package/test/backend.js CHANGED
@@ -1,4 +1,5 @@
1
1
  var Backend = require('../lib/backend');
2
+ var MemoryDB = require('../lib/db/memory');
2
3
  var expect = require('chai').expect;
3
4
  var sinon = require('sinon');
4
5
  var logger = require('../lib/logger');
@@ -28,6 +29,46 @@ describe('Backend', function() {
28
29
  expect(handler.callCount).to.equal(1);
29
30
  });
30
31
  });
32
+
33
+ describe('extraDbs', function() {
34
+ var archive;
35
+
36
+ beforeEach(function() {
37
+ archive = new MemoryDB();
38
+ backend = new Backend({extraDbs: {archive: archive}});
39
+ });
40
+
41
+ ['createFetchQuery', 'createSubscribeQuery'].forEach(function(method) {
42
+ it(method + ' queries the extra DB named by the db option', function(done) {
43
+ var query = sinon.spy(archive, 'query');
44
+ backend.connect()[method]('books', {}, {db: 'archive'}, function(error) {
45
+ if (error) return done(error);
46
+ expect(query).to.have.been.calledWith('books');
47
+ done();
48
+ });
49
+ });
50
+
51
+ ['constructor', '__proto__', {toString: 1}].forEach(function(badValue) {
52
+ it(method + ' errors if the db option is ' + JSON.stringify(badValue), function(done) {
53
+ backend.connect()[method]('books', {}, {db: badValue}, function(error) {
54
+ expect(error).to.include({code: 'ERR_DATABASE_ADAPTER_NOT_FOUND'});
55
+ done();
56
+ });
57
+ });
58
+
59
+ it(method + ' errors if middleware sets the db option to ' + JSON.stringify(badValue), function(done) {
60
+ backend.use('query', function(request, next) {
61
+ request.options.db = badValue;
62
+ process.nextTick(next);
63
+ });
64
+ backend.connect()[method]('books', {}, null, function(error) {
65
+ expect(error).to.include({code: 'ERR_DATABASE_ADAPTER_NOT_FOUND'});
66
+ done();
67
+ });
68
+ });
69
+ });
70
+ });
71
+ });
31
72
  });
32
73
 
33
74
  describe('a simple document', function() {
@@ -229,6 +270,17 @@ describe('Backend', function() {
229
270
  });
230
271
  });
231
272
 
273
+ describe('subscribeBulk', function() {
274
+ it('only subscribes once to a duplicated id', function(done) {
275
+ backend.subscribeBulk(agent, 'books', ['1984', '1984'], function(error, streams) {
276
+ if (error) return done(error);
277
+ expect(Object.keys(streams)).to.eql(['1984']);
278
+ expect(backend.pubsub.streamsCount).to.equal(1);
279
+ done();
280
+ });
281
+ });
282
+ });
283
+
232
284
  describe('submitRequestEnd', function() {
233
285
  it('emits after write', function(done) {
234
286
  var afterWriteCalled = false;
@@ -486,6 +486,54 @@ describe('Presence', function() {
486
486
  ], done);
487
487
  });
488
488
 
489
+ it('does not leak Streams when subscribing the same presence multiple times in parallel', function(done) {
490
+ var streamsCount = backend.pubsub.streamsCount;
491
+ async.series([
492
+ function(next) {
493
+ presence1.subscribe();
494
+ // Trick it into sending a duplicate request
495
+ presence1.wantSubscribe = false;
496
+ presence1.subscribe(next);
497
+ },
498
+ function(next) {
499
+ expect(backend.pubsub.streamsCount).to.equal(streamsCount + 1);
500
+ next();
501
+ },
502
+ presence1.unsubscribe.bind(presence1),
503
+ function(next) {
504
+ expect(backend.pubsub.streamsCount).to.equal(streamsCount);
505
+ next();
506
+ }
507
+ ], done);
508
+ });
509
+
510
+ it('does not broadcast a disconnect when subscribing the same presence multiple times in parallel', function(done) {
511
+ var localPresence1 = presence1.create('presence-1');
512
+
513
+ async.series([
514
+ presence2.subscribe.bind(presence2),
515
+ function(next) {
516
+ localPresence1.submit({index: 1}, errorHandler(done));
517
+ presence2.once('receive', function() {
518
+ next();
519
+ });
520
+ },
521
+ function(next) {
522
+ presence1.subscribe();
523
+ // Trick it into sending a duplicate request
524
+ presence1.wantSubscribe = false;
525
+ presence1.subscribe(next);
526
+ },
527
+ function(next) {
528
+ localPresence1.submit({index: 2}, errorHandler(done));
529
+ presence2.once('receive', function(id, presence) {
530
+ expect(presence).to.eql({index: 2});
531
+ next();
532
+ });
533
+ }
534
+ ], done);
535
+ });
536
+
489
537
  it('throws an error when trying to create a presence with a non-string ID', function() {
490
538
  expect(function() {
491
539
  presence1.create(123);
@@ -128,6 +128,17 @@ describe('SnapshotTimestampRequest', function() {
128
128
  ], done);
129
129
  });
130
130
 
131
+ it('fetches ops up to the current version when there are no milestones', function(done) {
132
+ sinon.spy(backend.db, 'getOps');
133
+ var halfwayBetweenDays2and3 = (day2 + day3) * 0.5;
134
+
135
+ backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', halfwayBetweenDays2and3, function(error) {
136
+ if (error) return done(error);
137
+ expect(backend.db.getOps.calledWith('books', 'time-machine', 0, 3)).to.equal(true);
138
+ done();
139
+ });
140
+ });
141
+
131
142
  it('fetches the day 3 version when asking for a time after day 3', function(done) {
132
143
  var connection = backend.connect();
133
144
  async.waterfall([
@@ -229,8 +240,11 @@ describe('SnapshotTimestampRequest', function() {
229
240
  });
230
241
 
231
242
  it('returns an empty snapshot if trying to fetch a non-existent document', function(done) {
243
+ sinon.spy(backend.db, 'getOps');
244
+
232
245
  backend.connect().fetchSnapshotByTimestamp('books', 'does-not-exist', day1, function(error, snapshot) {
233
246
  if (error) return done(error);
247
+ expect(backend.db.getOps.called).to.be.false;
234
248
  expect(snapshot).to.eql({
235
249
  id: 'does-not-exist',
236
250
  v: 0,
@@ -469,6 +483,7 @@ describe('SnapshotTimestampRequest', function() {
469
483
  it('fetches a snapshot that matches a milestone snapshot', function(done) {
470
484
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime');
471
485
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime');
486
+ sinon.spy(db, 'getOps');
472
487
 
473
488
  backendWithMilestones.connect()
474
489
  .fetchSnapshotByTimestamp('books', 'mocking-bird', day2, function(error, snapshot) {
@@ -476,6 +491,7 @@ describe('SnapshotTimestampRequest', function() {
476
491
 
477
492
  expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.equal(true);
478
493
  expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.equal(true);
494
+ expect(db.getOps.called).to.be.false;
479
495
 
480
496
  expect(snapshot.v).to.equal(2);
481
497
  expect(snapshot.data).to.eql({title: 'To Kill a Mocking Bird', author: 'Harper Lea'});
@@ -483,6 +499,27 @@ describe('SnapshotTimestampRequest', function() {
483
499
  });
484
500
  });
485
501
 
502
+ it('skips fetching ops when milestone times are out of version order', function(done) {
503
+ var milestoneAtVersion = function(version) {
504
+ return function(collection, id, _timestamp, callback) {
505
+ milestoneDb.getMilestoneSnapshot(collection, id, version, callback);
506
+ };
507
+ };
508
+ sinon.stub(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime').callsFake(milestoneAtVersion(4));
509
+ sinon.stub(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime').callsFake(milestoneAtVersion(2));
510
+ sinon.spy(db, 'getOps');
511
+ var halfwayBetweenDays3and4 = (day3 + day4) * 0.5;
512
+
513
+ backendWithMilestones.connect()
514
+ .fetchSnapshotByTimestamp('books', 'mocking-bird', halfwayBetweenDays3and4, function(error, snapshot) {
515
+ if (error) return done(error);
516
+ expect(db.getOps.called).to.be.false;
517
+ expect(snapshot.v).to.equal(4);
518
+ expect(snapshot.data).to.eql({title: 'To Kill a Mocking Bird', author: 'Harper Lee', year: 1959});
519
+ done();
520
+ });
521
+ });
522
+
486
523
  it('fetches a snapshot before any milestones', function(done) {
487
524
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime');
488
525
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime');
@@ -513,7 +550,7 @@ describe('SnapshotTimestampRequest', function() {
513
550
 
514
551
  expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.equal(true);
515
552
  expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.equal(true);
516
- expect(db.getOps.calledWith('books', 'mocking-bird', 4, null)).to.equal(true);
553
+ expect(db.getOps.calledWith('books', 'mocking-bird', 4, 5)).to.equal(true);
517
554
 
518
555
  expect(snapshot.v).to.equal(5);
519
556
  expect(snapshot.data).to.eql({
package/test/ot.js CHANGED
@@ -159,6 +159,22 @@ describe('ot', function() {
159
159
  expect({}.polluted).to.equal(undefined);
160
160
  });
161
161
 
162
+ it('returns an error for a path segment that cannot be converted to a key', function() {
163
+ var error;
164
+ expect(function() {
165
+ error = ot.apply(snapshot, {v: 6, op: [{p: ['colour', {toString: 'x'}], oi: 'yes'}]});
166
+ }).to.not.throw();
167
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
168
+ });
169
+
170
+ it('returns an error for an op whose length cannot be converted to a number', function() {
171
+ var error;
172
+ expect(function() {
173
+ error = ot.apply(snapshot, {v: 6, op: {length: {toString: 'x'}}});
174
+ }).to.not.throw();
175
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
176
+ });
177
+
162
178
  ['__proto__', 'constructor'].forEach(function(badProp) {
163
179
  it('rejects ' + badProp + ' as a path segment', function() {
164
180
  var error = ot.apply(snapshot, {v: 6, op: [{p: [badProp, 'polluted'], oi: 'yes'}]});
@@ -383,6 +383,24 @@ describe('projection utility methods', function() {
383
383
  {x: true},
384
384
  {op: [{p: [], oi: {y: 1}}]}
385
385
  );
386
+ test(
387
+ {0: true},
388
+ {op: [{p: [0], na: 1}]}
389
+ );
390
+ });
391
+
392
+ [
393
+ null,
394
+ {},
395
+ [null],
396
+ [{}],
397
+ [{p: null}],
398
+ [{p: 'x'}],
399
+ [{p: [{toString: 'x'}]}]
400
+ ].forEach(function(edit) {
401
+ it('does not allow the malformed op ' + JSON.stringify(edit), function() {
402
+ expect(projections.isOpAllowed(null, {x: true}, {op: edit})).equal(false);
403
+ });
386
404
  });
387
405
  });
388
406