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 +2 -2
- package/lib/agent.js +18 -0
- package/lib/backend.js +18 -10
- package/lib/client/doc.js +7 -18
- package/lib/client/query.js +1 -2
- package/lib/ot.js +15 -9
- package/lib/projections.js +6 -6
- package/lib/util.js +5 -0
- package/package.json +1 -1
- package/test/agent.js +104 -0
- package/test/backend.js +52 -0
- package/test/client/presence/presence.js +48 -0
- package/test/client/snapshot-timestamp-request.js +58 -1
- package/test/client/snapshot-version-request.js +20 -0
- package/test/lib-util.js +56 -0
- package/test/ot.js +16 -0
- package/test/projections.js +18 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# ShareDB
|
|
2
2
|
|
|
3
3
|
[](https://npmjs.org/package/sharedb)
|
|
4
|
-

|
|
5
|
+
[](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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/client/query.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
130
|
-
var
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
package/lib/projections.js
CHANGED
|
@@ -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
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
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,
|
|
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;
|
package/test/lib-util.js
ADDED
|
@@ -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'}]});
|
package/test/projections.js
CHANGED
|
@@ -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
|
|