sharedb 6.0.3 → 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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # ShareDB
2
2
 
3
3
  [![NPM Version](https://img.shields.io/npm/v/sharedb.svg)](https://npmjs.org/package/sharedb)
4
- ![Test](https://github.com/share/sharedb/workflows/Test/badge.svg)
5
- [![Coverage Status](https://coveralls.io/repos/github/share/sharedb/badge.svg?branch=master)](https://coveralls.io/github/share/sharedb?branch=master)
4
+ ![Test](https://github.com/share/sharedb/actions/workflows/test.yml/badge.svg?branch=main)
5
+ [![Coverage Status](https://coveralls.io/repos/github/share/sharedb/badge.svg?branch=main)](https://coveralls.io/github/share/sharedb?branch=main)
6
6
 
7
7
  ShareDB is a realtime database backend based on [Operational Transformation
8
8
  (OT)](https://en.wikipedia.org/wiki/Operational_transformation) of JSON
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;
@@ -388,6 +395,8 @@ Agent.prototype._checkRequest = function(request) {
388
395
  request.a === ACTIONS.fetch ||
389
396
  request.a === ACTIONS.subscribe ||
390
397
  request.a === ACTIONS.unsubscribe ||
398
+ request.a === ACTIONS.snapshotFetch ||
399
+ request.a === ACTIONS.snapshotFetchByTimestamp ||
391
400
  request.a === ACTIONS.presence) {
392
401
  // Doc-based request.
393
402
  if (request.c != null) {
@@ -401,6 +410,11 @@ Agent.prototype._checkRequest = function(request) {
401
410
  }
402
411
  }
403
412
 
413
+ if (request.a === ACTIONS.snapshotFetch || request.a === ACTIONS.snapshotFetchByTimestamp) {
414
+ if (request.c == null) return 'Invalid collection';
415
+ if (request.d == null) return 'Invalid id';
416
+ }
417
+
404
418
  if (request.a === ACTIONS.op || request.a === ACTIONS.presence) {
405
419
  if (request.v != null && (typeof request.v !== 'number' || request.v < 0)) return 'Invalid version';
406
420
  }
@@ -917,6 +931,10 @@ Agent.prototype._subscribePresence = function(channel, seq, cb) {
917
931
  return callback();
918
932
  }
919
933
  agent.presenceSubscriptionSeq[channel] = seq;
934
+ if (agent.subscribedPresences[channel]) {
935
+ stream.destroy();
936
+ return callback();
937
+ }
920
938
  agent.subscribedPresences[channel] = stream;
921
939
  agent._subscribeToPresenceStream(channel, stream);
922
940
  agent._requestPresence(channel, function(error) {
package/lib/backend.js CHANGED
@@ -106,10 +106,7 @@ Backend.prototype.close = function(callback) {
106
106
  var wait = 4;
107
107
  var backend = this;
108
108
  function finish(err) {
109
- if (err) {
110
- if (callback) return callback(err);
111
- return backend.emit('error', err);
112
- }
109
+ if (err) return util.callbackOrEmit(backend, err, callback);
113
110
  if (--wait) return;
114
111
  if (callback) callback();
115
112
  }
@@ -572,7 +569,7 @@ Backend.prototype.subscribeBulk = function(agent, index, versions, callback) {
572
569
  var backend = this;
573
570
  var streams = Object.create(null);
574
571
  var doFetch = Array.isArray(versions);
575
- var ids = (doFetch) ? versions : Object.keys(versions);
572
+ var ids = (doFetch) ? uniqueIds(versions) : Object.keys(versions);
576
573
  var request = {
577
574
  agent: agent,
578
575
  index: index,
@@ -629,6 +626,14 @@ function destroyStreams(streams) {
629
626
  streams[id].destroy();
630
627
  }
631
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
+ }
632
637
 
633
638
  Backend.prototype.queryFetch = function(agent, index, query, options, callback) {
634
639
  var start = Date.now();
@@ -742,12 +747,16 @@ Backend.prototype._triggerQuery = function(agent, index, query, options, callbac
742
747
  if (err) return callback(err);
743
748
  // Set the DB reference for the request after the middleware trigger so
744
749
  // that the db option can be changed in middleware
745
- request.db = (options.db) ? backend.extraDbs[options.db] : backend.db;
750
+ request.db = (options.db) ? getExtraDb(backend, options.db) : backend.db;
746
751
  if (!request.db) return callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_ADAPTER_NOT_FOUND, 'DB not found'));
747
752
  request.snapshotProjection = backend._getSnapshotProjection(request.db, projection);
748
753
  callback(null, request);
749
754
  });
750
755
  };
756
+ function getExtraDb(backend, name) {
757
+ if (typeof name !== 'string' || !util.hasOwn(backend.extraDbs, name)) return null;
758
+ return backend.extraDbs[name];
759
+ }
751
760
 
752
761
  Backend.prototype._query = function(agent, request, callback) {
753
762
  var backend = this;
@@ -878,8 +887,6 @@ Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp
878
887
  var backend = this;
879
888
 
880
889
  var milestoneSnapshot;
881
- var from = 0;
882
- var to = null;
883
890
 
884
891
  // Always fetch the current snapshot first. We request its metadata so that we
885
892
  // can read its mtime, which lets us serve the current snapshot directly when
@@ -901,11 +908,12 @@ Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp
901
908
  milestoneDb.getMilestoneSnapshotAtOrBeforeTime(collection, id, timestamp, function(error, snapshot) {
902
909
  if (error) return callback(error);
903
910
  milestoneSnapshot = snapshot;
904
- if (snapshot) from = snapshot.v;
905
911
 
906
912
  milestoneDb.getMilestoneSnapshotAtOrAfterTime(collection, id, timestamp, function(error, snapshot) {
907
913
  if (error) return callback(error);
908
- 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);
909
917
 
910
918
  var options = {metadata: true};
911
919
  db.getOps(collection, id, from, to, options, function(error, ops) {
package/lib/client/doc.js CHANGED
@@ -131,10 +131,7 @@ Doc.prototype.destroy = function(callback) {
131
131
  doc.whenNothingPending(function() {
132
132
  if (doc.wantSubscribe) {
133
133
  doc.unsubscribe(function(err) {
134
- if (err) {
135
- if (callback) return callback(err);
136
- return doc.emit('error', err);
137
- }
134
+ if (err) return util.callbackOrEmit(doc, err, callback);
138
135
  doc.connection._destroyDoc(doc);
139
136
  if (callback) callback();
140
137
  });
@@ -191,8 +188,7 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) {
191
188
  ERROR_CODE.ERR_INGESTED_SNAPSHOT_HAS_NO_VERSION,
192
189
  'Missing version in ingested snapshot. ' + this.collection + '.' + this.id
193
190
  );
194
- if (callback) return callback(err);
195
- return this.emit('error', err);
191
+ return util.callbackOrEmit(this, err, callback);
196
192
  }
197
193
 
198
194
  // If the doc is already created or there are ops pending, we cannot use the
@@ -214,8 +210,7 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) {
214
210
  ERROR_CODE.ERR_DOC_MISSING_VERSION,
215
211
  'Cannot ingest snapshot in doc with null version. ' + this.collection + '.' + this.id
216
212
  );
217
- if (callback) return callback(err);
218
- return this.emit('error', err);
213
+ return util.callbackOrEmit(this, err, callback);
219
214
  }
220
215
  // If we got a snapshot for a version further along than the document is
221
216
  // currently, issue a fetch to get the latest ops and catch us up
@@ -764,8 +759,7 @@ Doc.prototype._submit = function(op, source, callback) {
764
759
  );
765
760
  }
766
761
 
767
- if (callback) return callback(err);
768
- return this.emit('error', err);
762
+ return util.callbackOrEmit(this, err, callback);
769
763
  }
770
764
  // Try to normalize the op. This removes trailing skip:0's and things like that.
771
765
  if (this.type.normalize) op.op = this.type.normalize(op.op);
@@ -773,10 +767,7 @@ Doc.prototype._submit = function(op, source, callback) {
773
767
  // This has to happen before _pushOp(), because _tryCompose() applies the op
774
768
  // to a pending create, well before _otApply() gets a chance to check it
775
769
  var pathError = ot.checkOpPathsForType(this.type, op);
776
- if (pathError) {
777
- if (callback) return callback(pathError);
778
- return this.emit('error', pathError);
779
- }
770
+ if (pathError) return util.callbackOrEmit(this, pathError, callback);
780
771
  }
781
772
 
782
773
  try {
@@ -913,8 +904,7 @@ Doc.prototype.create = function(data, type, options, callback) {
913
904
  }
914
905
  if (this.type) {
915
906
  var err = new ShareDBError(ERROR_CODE.ERR_DOC_ALREADY_CREATED, 'Document already exists');
916
- if (callback) return callback(err);
917
- return this.emit('error', err);
907
+ return util.callbackOrEmit(this, err, callback);
918
908
  }
919
909
  var op = {create: {type: type, data: data}};
920
910
  var source = options && options.source;
@@ -935,8 +925,7 @@ Doc.prototype.del = function(options, callback) {
935
925
  }
936
926
  if (!this.type) {
937
927
  var err = new ShareDBError(ERROR_CODE.ERR_DOC_DOES_NOT_EXIST, 'Document does not exist');
938
- if (callback) return callback(err);
939
- return this.emit('error', err);
928
+ return util.callbackOrEmit(this, err, callback);
940
929
  }
941
930
  var op = {del: true};
942
931
  var source = options && options.source;
@@ -149,8 +149,7 @@ Query.prototype._finishResponse = function(err, callback) {
149
149
  this.ready = true;
150
150
  if (err) {
151
151
  this.connection._destroyQuery(this);
152
- if (callback) return callback(err);
153
- return this.emit('error', err);
152
+ return util.callbackOrEmit(this, err, callback);
154
153
  }
155
154
  if (callback) callback(null, this.results, this.extra);
156
155
  };
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/lib/util.js CHANGED
@@ -84,6 +84,11 @@ exports.callEach = function(callbacks, error) {
84
84
  return called;
85
85
  };
86
86
 
87
+ exports.callbackOrEmit = function(emitter, error, callback) {
88
+ if (callback) return callback(error);
89
+ if (error) emitter.emit('error', error);
90
+ };
91
+
87
92
  exports.truthy = function(arg) {
88
93
  return !!arg;
89
94
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sharedb",
3
- "version": "6.0.3",
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([
@@ -208,9 +219,32 @@ describe('SnapshotTimestampRequest', function() {
208
219
  expect(fetch).to.throw(Error);
209
220
  });
210
221
 
222
+ [{}, null, '__proto__'].forEach(function(badValue) {
223
+ it('errors without reading the database if the collection is ' + JSON.stringify(badValue), function(done) {
224
+ var getSnapshot = sinon.spy(MemoryDb.prototype, 'getSnapshot');
225
+ backend.connect().fetchSnapshotByTimestamp(badValue, 'time-machine', null, function(error) {
226
+ expect(error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: 'Invalid collection'});
227
+ expect(getSnapshot).not.to.have.been.called;
228
+ done();
229
+ });
230
+ });
231
+
232
+ it('errors without reading the database if the id is ' + JSON.stringify(badValue), function(done) {
233
+ var getSnapshot = sinon.spy(MemoryDb.prototype, 'getSnapshot');
234
+ backend.connect().fetchSnapshotByTimestamp('books', badValue, null, function(error) {
235
+ expect(error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: 'Invalid id'});
236
+ expect(getSnapshot).not.to.have.been.called;
237
+ done();
238
+ });
239
+ });
240
+ });
241
+
211
242
  it('returns an empty snapshot if trying to fetch a non-existent document', function(done) {
243
+ sinon.spy(backend.db, 'getOps');
244
+
212
245
  backend.connect().fetchSnapshotByTimestamp('books', 'does-not-exist', day1, function(error, snapshot) {
213
246
  if (error) return done(error);
247
+ expect(backend.db.getOps.called).to.be.false;
214
248
  expect(snapshot).to.eql({
215
249
  id: 'does-not-exist',
216
250
  v: 0,
@@ -449,6 +483,7 @@ describe('SnapshotTimestampRequest', function() {
449
483
  it('fetches a snapshot that matches a milestone snapshot', function(done) {
450
484
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime');
451
485
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime');
486
+ sinon.spy(db, 'getOps');
452
487
 
453
488
  backendWithMilestones.connect()
454
489
  .fetchSnapshotByTimestamp('books', 'mocking-bird', day2, function(error, snapshot) {
@@ -456,6 +491,7 @@ describe('SnapshotTimestampRequest', function() {
456
491
 
457
492
  expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.equal(true);
458
493
  expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.equal(true);
494
+ expect(db.getOps.called).to.be.false;
459
495
 
460
496
  expect(snapshot.v).to.equal(2);
461
497
  expect(snapshot.data).to.eql({title: 'To Kill a Mocking Bird', author: 'Harper Lea'});
@@ -463,6 +499,27 @@ describe('SnapshotTimestampRequest', function() {
463
499
  });
464
500
  });
465
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
+
466
523
  it('fetches a snapshot before any milestones', function(done) {
467
524
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime');
468
525
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime');
@@ -493,7 +550,7 @@ describe('SnapshotTimestampRequest', function() {
493
550
 
494
551
  expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.equal(true);
495
552
  expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.equal(true);
496
- 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);
497
554
 
498
555
  expect(snapshot.v).to.equal(5);
499
556
  expect(snapshot.data).to.eql({
@@ -143,6 +143,26 @@ describe('SnapshotVersionRequest', function() {
143
143
  expect(fetch).to.throw(Error);
144
144
  });
145
145
 
146
+ [{}, null, '__proto__'].forEach(function(badValue) {
147
+ it('errors without reading the database if the collection is ' + JSON.stringify(badValue), function(done) {
148
+ var getSnapshot = sinon.spy(MemoryDb.prototype, 'getSnapshot');
149
+ backend.connect().fetchSnapshot(badValue, 'don-quixote', null, function(error) {
150
+ expect(error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: 'Invalid collection'});
151
+ expect(getSnapshot).not.to.have.been.called;
152
+ done();
153
+ });
154
+ });
155
+
156
+ it('errors without reading the database if the id is ' + JSON.stringify(badValue), function(done) {
157
+ var getSnapshot = sinon.spy(MemoryDb.prototype, 'getSnapshot');
158
+ backend.connect().fetchSnapshot('books', badValue, null, function(error) {
159
+ expect(error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: 'Invalid id'});
160
+ expect(getSnapshot).not.to.have.been.called;
161
+ done();
162
+ });
163
+ });
164
+ });
165
+
146
166
  it('errors if asking for a version that does not exist', function(done) {
147
167
  backend.connect().fetchSnapshot('books', 'don-quixote', 4, function(error, snapshot) {
148
168
  expect(error).to.be.ok;
@@ -0,0 +1,56 @@
1
+ var EventEmitter = require('../lib/emitter').EventEmitter;
2
+ var util = require('../lib/util');
3
+ var expect = require('chai').expect;
4
+
5
+ describe('util.callbackOrEmit', function() {
6
+ var emitter;
7
+ var error;
8
+
9
+ beforeEach(function() {
10
+ emitter = new EventEmitter();
11
+ error = new Error('some error');
12
+ });
13
+
14
+ it('calls back synchronously with the error', function() {
15
+ var calledWith = 'not called';
16
+ util.callbackOrEmit(emitter, error, function(err) {
17
+ calledWith = err;
18
+ });
19
+ expect(calledWith).to.equal(error);
20
+ });
21
+
22
+ it('calls back synchronously when there is no error', function() {
23
+ var calledWith = 'not called';
24
+ util.callbackOrEmit(emitter, null, function(err) {
25
+ calledWith = err;
26
+ });
27
+ expect(calledWith).to.equal(null);
28
+ });
29
+
30
+ it('does not emit when a callback is provided', function() {
31
+ var emitted = false;
32
+ emitter.on('error', function() {
33
+ emitted = true;
34
+ });
35
+ util.callbackOrEmit(emitter, error, function() {});
36
+ expect(emitted).to.equal(false);
37
+ });
38
+
39
+ it('emits the error when there is no callback', function() {
40
+ var emittedWith = 'not emitted';
41
+ emitter.on('error', function(err) {
42
+ emittedWith = err;
43
+ });
44
+ util.callbackOrEmit(emitter, error);
45
+ expect(emittedWith).to.equal(error);
46
+ });
47
+
48
+ it('does nothing when there is no error and no callback', function() {
49
+ var emitted = false;
50
+ emitter.on('error', function() {
51
+ emitted = true;
52
+ });
53
+ util.callbackOrEmit(emitter, null);
54
+ expect(emitted).to.equal(false);
55
+ });
56
+ });
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