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 +11 -0
- package/lib/backend.js +17 -6
- package/lib/ot.js +15 -9
- package/lib/projections.js +6 -6
- 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 +38 -1
- package/test/ot.js +16 -0
- package/test/projections.js +18 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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/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([
|
|
@@ -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,
|
|
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'}]});
|
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
|
|