sharedb 6.0.4 → 7.0.0

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
@@ -25,8 +25,7 @@ function Agent(backend, stream) {
25
25
  // src is a client-configurable "id" which the client will set in its handshake,
26
26
  // and attach to its ops. This should take precedence over clientId if set.
27
27
  // Only legacy clients, or new clients connecting for the first time will use the
28
- // Agent-provided clientId. Ideally we'll deprecate clientId in favour of src
29
- // in the next breaking change.
28
+ // Agent-provided clientId.
30
29
  this.src = null;
31
30
  this.connectTime = Date.now();
32
31
 
@@ -229,6 +228,7 @@ Agent.prototype._subscribeToQuery = function(emitter, queryId, collection, query
229
228
 
230
229
  Agent.prototype._onOp = function(collection, id, op) {
231
230
  if (this._isOwnOp(collection, op)) return;
231
+ if (this._isOpFromCollidingChannel(collection, id, op)) return;
232
232
 
233
233
  // Ops emitted here are coming directly from pubsub, which emits the same op
234
234
  // object to listeners without making a copy. The pattern in middleware is to
@@ -263,6 +263,12 @@ Agent.prototype._isOwnOp = function(collection, op) {
263
263
  return (this._src() === op.src) && (collection === (op.i || op.c));
264
264
  };
265
265
 
266
+ Agent.prototype._isOpFromCollidingChannel = function(collection, id, op) {
267
+ var projection = this.backend.projections[collection];
268
+ var target = (projection) ? projection.target : collection;
269
+ return op.c !== target || op.d !== id;
270
+ };
271
+
266
272
  Agent.prototype.send = function(message) {
267
273
  // Quietly drop replies if the stream was closed
268
274
  if (this.closed) return;
@@ -375,6 +381,42 @@ Agent.prototype._open = function() {
375
381
  this.stream.on('close', cleanup);
376
382
  };
377
383
 
384
+ function isInvalidName(name) {
385
+ return typeof name !== 'string' || util.isDangerousProperty(name);
386
+ }
387
+
388
+ function isInvalidVersion(version) {
389
+ return version != null && (typeof version !== 'number' || version < 0);
390
+ }
391
+
392
+ function isInvalidProtocol(value) {
393
+ return value != null && typeof value !== 'number' && typeof value !== 'string';
394
+ }
395
+
396
+ function checkBulkData(data) {
397
+ if (!data || typeof data !== 'object') return 'Invalid bulk subscribe data';
398
+ if (Array.isArray(data)) {
399
+ if (data.some(isInvalidName)) return 'Invalid id';
400
+ return;
401
+ }
402
+ for (var id in data) {
403
+ if (isInvalidName(id)) return 'Invalid id';
404
+ if (isInvalidVersion(data[id])) return 'Invalid version';
405
+ }
406
+ }
407
+
408
+ function checkQueryResults(results) {
409
+ if (!Array.isArray(results)) return 'Invalid query results';
410
+ for (var i = 0; i < results.length; i++) {
411
+ var result = results[i];
412
+ if (!Array.isArray(result)) return 'Invalid query results';
413
+ var id = result[0];
414
+ var version = result[1];
415
+ if (isInvalidName(id)) return 'Invalid id';
416
+ if (isInvalidVersion(version)) return 'Invalid version';
417
+ }
418
+ }
419
+
378
420
  // Check a request to see if its valid. Returns an error if there's a problem.
379
421
  Agent.prototype._checkRequest = function(request) {
380
422
  if (
@@ -384,6 +426,10 @@ Agent.prototype._checkRequest = function(request) {
384
426
  ) {
385
427
  // Query messages need an ID property.
386
428
  if (typeof request.id !== 'number') return 'Missing query ID';
429
+ if (request.r != null) {
430
+ var resultsError = checkQueryResults(request.r);
431
+ if (resultsError) return resultsError;
432
+ }
387
433
  } else if (request.a === ACTIONS.op ||
388
434
  request.a === ACTIONS.fetch ||
389
435
  request.a === ACTIONS.subscribe ||
@@ -392,30 +438,19 @@ Agent.prototype._checkRequest = function(request) {
392
438
  request.a === ACTIONS.snapshotFetchByTimestamp ||
393
439
  request.a === ACTIONS.presence) {
394
440
  // Doc-based request.
395
- if (request.c != null) {
396
- if (typeof request.c !== 'string' || util.isDangerousProperty(request.c)) {
397
- return 'Invalid collection';
398
- }
399
- }
400
- if (request.d != null) {
401
- if (typeof request.d !== 'string' || util.isDangerousProperty(request.d)) {
402
- return 'Invalid id';
403
- }
404
- }
441
+ if (request.d != null && isInvalidName(request.d)) return 'Invalid id';
405
442
 
406
443
  if (request.a === ACTIONS.snapshotFetch || request.a === ACTIONS.snapshotFetchByTimestamp) {
407
444
  if (request.c == null) return 'Invalid collection';
408
445
  if (request.d == null) return 'Invalid id';
409
446
  }
410
447
 
411
- if (request.a === ACTIONS.op || request.a === ACTIONS.presence) {
412
- if (request.v != null && (typeof request.v !== 'number' || request.v < 0)) return 'Invalid version';
413
- }
448
+ if (isInvalidVersion(request.v)) return 'Invalid version';
449
+ if (request.ts != null && typeof request.ts !== 'number') return 'Invalid timestamp';
414
450
 
415
451
  if (request.a === ACTIONS.presence) {
416
- if (typeof request.id !== 'string' || util.isDangerousProperty(request.id)) {
417
- return 'Invalid presence ID';
418
- }
452
+ if (isInvalidName(request.id)) return 'Invalid presence ID';
453
+ if (isInvalidVersion(request.pv)) return 'Invalid presence version';
419
454
  }
420
455
  } else if (
421
456
  request.a === ACTIONS.bulkFetch ||
@@ -423,18 +458,14 @@ Agent.prototype._checkRequest = function(request) {
423
458
  request.a === ACTIONS.bulkUnsubscribe
424
459
  ) {
425
460
  // Bulk request
426
- if (request.c != null) {
427
- if (typeof request.c !== 'string' || util.isDangerousProperty(request.c)) {
428
- return 'Invalid collection';
429
- }
430
- }
431
- if (typeof request.b !== 'object') return 'Invalid bulk subscribe data';
432
- }
433
- if (request.ch != null) {
434
- if (typeof request.ch !== 'string' || util.isDangerousProperty(request.ch)) {
435
- return 'Invalid presence channel';
436
- }
461
+ var bulkError = checkBulkData(request.b);
462
+ if (bulkError) return bulkError;
463
+ } else if (request.a === ACTIONS.handshake) {
464
+ if (isInvalidProtocol(request.protocol) || isInvalidProtocol(request.protocolMinor)) return 'Invalid protocol';
437
465
  }
466
+ if (request.c != null && isInvalidName(request.c)) return 'Invalid collection';
467
+ if (request.ch != null && isInvalidName(request.ch)) return 'Invalid presence channel';
468
+ if (request.seq != null && typeof request.seq !== 'number') return 'Invalid seq';
438
469
  };
439
470
 
440
471
  // Handle an incoming message from the client
@@ -533,6 +564,8 @@ function getQueryOptions(request) {
533
564
  }
534
565
  }
535
566
  var options = request.o || {};
567
+ delete options.pollInterval;
568
+ delete options.pollDebounce;
536
569
  options.ids = ids;
537
570
  options.fetch = fetch;
538
571
  options.fetchOps = fetchOps;
@@ -924,6 +957,10 @@ Agent.prototype._subscribePresence = function(channel, seq, cb) {
924
957
  return callback();
925
958
  }
926
959
  agent.presenceSubscriptionSeq[channel] = seq;
960
+ if (agent.subscribedPresences[channel]) {
961
+ stream.destroy();
962
+ return callback();
963
+ }
927
964
  agent.subscribedPresences[channel] = stream;
928
965
  agent._subscribeToPresenceStream(channel, stream);
929
966
  agent._requestPresence(channel, function(error) {
package/lib/backend.js CHANGED
@@ -569,22 +569,25 @@ 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,
576
576
  collection: collection,
577
577
  versions: versions
578
578
  };
579
+ var subscribeFailed = false;
579
580
  async.each(ids, function(id, eachCb) {
580
581
  var channel = backend.getDocChannel(collection, id);
581
582
  backend.pubsub.subscribe(channel, function(err, stream) {
582
583
  if (err) return eachCb(err);
584
+ if (subscribeFailed) return stream.destroy();
583
585
  streams[id] = stream;
584
586
  eachCb();
585
587
  });
586
588
  }, function(err) {
587
589
  if (err) {
590
+ subscribeFailed = true;
588
591
  destroyStreams(streams);
589
592
  return callback(err);
590
593
  }
@@ -626,6 +629,14 @@ function destroyStreams(streams) {
626
629
  streams[id].destroy();
627
630
  }
628
631
  }
632
+ function uniqueIds(ids) {
633
+ var seen = Object.create(null);
634
+ return ids.filter(function(id) {
635
+ if (seen[id]) return false;
636
+ seen[id] = true;
637
+ return true;
638
+ });
639
+ }
629
640
 
630
641
  Backend.prototype.queryFetch = function(agent, index, query, options, callback) {
631
642
  var start = Date.now();
@@ -739,12 +750,16 @@ Backend.prototype._triggerQuery = function(agent, index, query, options, callbac
739
750
  if (err) return callback(err);
740
751
  // Set the DB reference for the request after the middleware trigger so
741
752
  // that the db option can be changed in middleware
742
- request.db = (options.db) ? backend.extraDbs[options.db] : backend.db;
753
+ request.db = (options.db) ? getExtraDb(backend, options.db) : backend.db;
743
754
  if (!request.db) return callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_ADAPTER_NOT_FOUND, 'DB not found'));
744
755
  request.snapshotProjection = backend._getSnapshotProjection(request.db, projection);
745
756
  callback(null, request);
746
757
  });
747
758
  };
759
+ function getExtraDb(backend, name) {
760
+ if (typeof name !== 'string' || !util.hasOwn(backend.extraDbs, name)) return null;
761
+ return backend.extraDbs[name];
762
+ }
748
763
 
749
764
  Backend.prototype._query = function(agent, request, callback) {
750
765
  var backend = this;
@@ -875,8 +890,6 @@ Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp
875
890
  var backend = this;
876
891
 
877
892
  var milestoneSnapshot;
878
- var from = 0;
879
- var to = null;
880
893
 
881
894
  // Always fetch the current snapshot first. We request its metadata so that we
882
895
  // can read its mtime, which lets us serve the current snapshot directly when
@@ -898,11 +911,12 @@ Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp
898
911
  milestoneDb.getMilestoneSnapshotAtOrBeforeTime(collection, id, timestamp, function(error, snapshot) {
899
912
  if (error) return callback(error);
900
913
  milestoneSnapshot = snapshot;
901
- if (snapshot) from = snapshot.v;
902
914
 
903
915
  milestoneDb.getMilestoneSnapshotAtOrAfterTime(collection, id, timestamp, function(error, snapshot) {
904
916
  if (error) return callback(error);
905
- if (snapshot) to = snapshot.v;
917
+ var from = milestoneSnapshot ? milestoneSnapshot.v : 0;
918
+ var to = snapshot ? snapshot.v : currentSnapshot.v;
919
+ if (from >= to) return backend._buildSnapshotFromOps(id, milestoneSnapshot, [], callback);
906
920
 
907
921
  var options = {metadata: true};
908
922
  db.getOps(collection, id, from, to, options, function(error, ops) {
package/lib/client/doc.js CHANGED
@@ -762,7 +762,20 @@ Doc.prototype._submit = function(op, source, callback) {
762
762
  return util.callbackOrEmit(this, err, callback);
763
763
  }
764
764
  // Try to normalize the op. This removes trailing skip:0's and things like that.
765
- if (this.type.normalize) op.op = this.type.normalize(op.op);
765
+ if (this.type.normalize) {
766
+ try {
767
+ op.op = this.type.normalize(op.op);
768
+ } catch (error) {
769
+ // Otherwise a type that throws on a badly formed op throws out of
770
+ // submitOp() rather than calling back
771
+ var normalizeError = error instanceof ShareDBError ? error : new ShareDBError(
772
+ ERROR_CODE.ERR_OT_OP_BADLY_FORMED,
773
+ (error && error.message) || String(error)
774
+ );
775
+ if (callback) return callback(normalizeError);
776
+ return this.emit('error', normalizeError);
777
+ }
778
+ }
766
779
 
767
780
  // This has to happen before _pushOp(), because _tryCompose() applies the op
768
781
  // to a pending create, well before _otApply() gets a chance to check it
@@ -120,7 +120,13 @@ RemoteDocPresence.prototype._catchUpStalePresence = function() {
120
120
  this._pending.p = null;
121
121
  this._pending.v++;
122
122
  } else {
123
- ot.transformPresence(this._pending, op, isOwnOp);
123
+ var error = ot.transformPresence(this._pending, op, isOwnOp);
124
+ if (error) {
125
+ this._pending = null;
126
+ this._stopCachingOps();
127
+ this.presence.emit('error', error);
128
+ return false;
129
+ }
124
130
  }
125
131
  }
126
132
 
package/lib/ot.js CHANGED
@@ -120,32 +120,71 @@ function applyOpEdit(snapshot, edit) {
120
120
  }
121
121
  }
122
122
 
123
+ function isJson0OpComponent(component) {
124
+ return !!component && typeof component === 'object' && Array.isArray(component.p);
125
+ }
126
+
123
127
  // ot-json0 walks ops with .length and numeric indexing, so it treats array-like
124
128
  // 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.
129
+ // exactly: anything ot-json0 will apply has to be checked here. Coercing
130
+ // .length or a path segment throws for some objects; ot-json0 throws on those
131
+ // too, so they are rejected rather than applied.
126
132
  // See GHSA-9rqw-j2q5-gg2g
127
133
  function checkJson0OpPaths(op) {
128
134
  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');
135
+ try {
136
+ for (var i = 0; i < op.length; i++) {
137
+ var component = op[i];
138
+ // ot-json0's checkValidOp() rejects the whole op before applying any of it,
139
+ // so there is nothing beyond this component left to check
140
+ if (!isJson0OpComponent(component)) return;
141
+ for (var j = 0; j < component.p.length; j++) {
142
+ if (util.isDangerousProperty(component.p[j])) {
143
+ return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, 'Invalid path segment');
144
+ }
137
145
  }
138
146
  }
147
+ } catch (error) {
148
+ return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, error.message);
139
149
  }
140
150
  }
141
151
 
152
+ // Only for ops arriving at submit-request, which reach ot-json0 exactly as
153
+ // they came off the wire, with nothing to normalize them on the way. Ops that
154
+ // are already committed can only have their paths checked, since ops that
155
+ // ot-json0 quietly treats as no-ops were committable by older versions of
156
+ // ShareDB
157
+ function checkSubmittedJson0Op(op) {
158
+ if (!Array.isArray(op)) {
159
+ return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'json0 op must be an array');
160
+ }
161
+
162
+ for (var i = 0; i < op.length; i++) {
163
+ if (!isJson0OpComponent(op[i])) {
164
+ return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'Missing path');
165
+ }
166
+ }
167
+
168
+ return checkJson0OpPaths(op);
169
+ }
170
+
171
+ function isJson0(type) {
172
+ if (typeof type === 'string') type = types.map[type];
173
+ return !!type && type.name === 'json0';
174
+ }
175
+
142
176
  exports.checkOpPathsForType = function(type, op) {
143
177
  if (!('op' in op)) return;
144
- if (typeof type === 'string') type = types.map[type];
145
- if (!type || type.name !== 'json0') return;
178
+ if (!isJson0(type)) return;
146
179
  return checkJson0OpPaths(op.op);
147
180
  };
148
181
 
182
+ exports.checkSubmittedOpForType = function(type, op) {
183
+ if (!('op' in op)) return;
184
+ if (!isJson0(type)) return;
185
+ return checkSubmittedJson0Op(op.op);
186
+ };
187
+
149
188
  exports.transform = function(type, op, appliedOp) {
150
189
  // There are 16 cases this function needs to deal with - which are all the
151
190
  // combinations of create/delete/op/noop from both op and appliedOp
@@ -223,9 +262,9 @@ exports.transformPresence = function(presence, op, isOwnOp) {
223
262
  if (typeof type === 'string') {
224
263
  type = types.map[type];
225
264
  }
226
- if (!type) return {code: ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, message: 'Unknown type'};
265
+ if (!type) return new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Unknown type');
227
266
  if (!util.supportsPresence(type)) {
228
- return {code: ERROR_CODE.ERR_TYPE_DOES_NOT_SUPPORT_PRESENCE, message: 'Type does not support presence'};
267
+ return new ShareDBError(ERROR_CODE.ERR_TYPE_DOES_NOT_SUPPORT_PRESENCE, 'Type does not support presence');
229
268
  }
230
269
 
231
270
  if (op.create || op.del) {
@@ -239,7 +278,7 @@ exports.transformPresence = function(presence, op, isOwnOp) {
239
278
  null :
240
279
  type.transformPresence(presence.p, op.op, isOwnOp);
241
280
  } catch (error) {
242
- return {code: ERROR_CODE.ERR_PRESENCE_TRANSFORM_FAILED, message: error.message || error};
281
+ return new ShareDBError(ERROR_CODE.ERR_PRESENCE_TRANSFORM_FAILED, error.message || error);
243
282
  }
244
283
 
245
284
  presence.v++;
@@ -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
  }
@@ -19,6 +19,8 @@ function PubSub(options) {
19
19
  // isn't complete until the callback returns from Redis
20
20
  // Maps channel -> true
21
21
  this.subscribed = Object.create(null);
22
+ // Maps channel -> callbacks waiting on _subscribe
23
+ this._subscribing = Object.create(null);
22
24
 
23
25
  var pubsub = this;
24
26
  this._defaultCallback = function(err) {
@@ -76,11 +78,21 @@ PubSub.prototype.subscribe = function(channel, callback) {
76
78
  });
77
79
  return;
78
80
  }
81
+ if (this._subscribing[channel]) {
82
+ this._subscribing[channel].push(callback);
83
+ return;
84
+ }
85
+ var callbacks = this._subscribing[channel] = [callback];
79
86
  this._subscribe(channel, function(err) {
80
- if (err) return callback(err);
87
+ delete pubsub._subscribing[channel];
88
+ if (err) return util.callEach(callbacks, err);
81
89
  pubsub.subscribed[channel] = true;
82
- var stream = pubsub._createStream(channel);
83
- callback(null, stream);
90
+ var streams = callbacks.map(function() {
91
+ return pubsub._createStream(channel);
92
+ });
93
+ callbacks.forEach(function(callback, i) {
94
+ callback(null, streams[i]);
95
+ });
84
96
  });
85
97
  };
86
98
 
@@ -109,6 +109,12 @@ SubmitRequest.prototype.submit = function(callback) {
109
109
  request.snapshot = snapshot;
110
110
  request._addSnapshotMeta();
111
111
 
112
+ // The type is only known once we have the snapshot, so this is the earliest
113
+ // we can validate the op against it. It has to happen before the op reaches
114
+ // any type function, including through $fixup() in the apply middleware
115
+ var opError = ot.checkSubmittedOpForType(snapshot.type, op);
116
+ if (opError) return callback(opError);
117
+
112
118
  if (op.v == null) {
113
119
  if (op.create && snapshot.type && op.src) {
114
120
  // If the document was already created by another op, we will return a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sharedb",
3
- "version": "6.0.4",
3
+ "version": "7.0.0",
4
4
  "description": "JSON OT database backend",
5
5
  "main": "lib/index.js",
6
6
  "dependencies": {