sharedb 1.9.1 → 2.2.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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## v2.0
2
+
3
+ ### Breaking changes
4
+
5
+ * Drop Node.js v10 support
6
+
1
7
  ## v1.0-beta
2
8
 
3
9
  ### Breaking changes
package/lib/agent.js CHANGED
@@ -736,17 +736,25 @@ Agent.prototype._src = function() {
736
736
 
737
737
  Agent.prototype._broadcastPresence = function(presence, callback) {
738
738
  var agent = this;
739
+ var backend = this.backend;
739
740
  var requests = this.presenceRequests[presence.ch] || (this.presenceRequests[presence.ch] = {});
740
741
  var previousRequest = requests[presence.id];
741
742
  if (!previousRequest || previousRequest.pv < presence.pv) {
742
743
  this.presenceRequests[presence.ch][presence.id] = presence;
743
744
  }
744
- this.backend.transformPresenceToLatestVersion(this, presence, function(error, presence) {
745
+ var context = {
746
+ presence: presence,
747
+ collection: presence.c
748
+ };
749
+ backend.trigger(backend.MIDDLEWARE_ACTIONS.receivePresence, this, context, function(error) {
745
750
  if (error) return callback(error);
746
- var channel = agent._getPresenceChannel(presence.ch);
747
- agent.backend.pubsub.publish([channel], presence, function(error) {
751
+ backend.transformPresenceToLatestVersion(agent, presence, function(error, presence) {
748
752
  if (error) return callback(error);
749
- callback(null, presence);
753
+ var channel = agent._getPresenceChannel(presence.ch);
754
+ agent.backend.pubsub.publish([channel], presence, function(error) {
755
+ if (error) return callback(error);
756
+ callback(null, presence);
757
+ });
750
758
  });
751
759
  });
752
760
  };
@@ -815,7 +823,9 @@ Agent.prototype._handlePresenceData = function(presence) {
815
823
  var agent = this;
816
824
  backend.trigger(backend.MIDDLEWARE_ACTIONS.sendPresence, this, context, function(error) {
817
825
  if (error) {
818
- return agent.send({a: 'p', ch: presence.ch, id: presence.id, error: getReplyErrorObject(error)});
826
+ if (backend.doNotForwardSendPresenceErrorsToClient) backend.errorHandler(error, {agent: agent});
827
+ else agent.send({a: 'p', ch: presence.ch, id: presence.id, error: getReplyErrorObject(error)});
828
+ return;
819
829
  }
820
830
  agent.send(presence);
821
831
  });
package/lib/backend.js CHANGED
@@ -14,6 +14,7 @@ var StreamSocket = require('./stream-socket');
14
14
  var SubmitRequest = require('./submit-request');
15
15
  var ReadSnapshotsRequest = require('./read-snapshots-request');
16
16
  var util = require('./util');
17
+ var logger = require('./logger');
17
18
 
18
19
  var ERROR_CODE = ShareDBError.CODES;
19
20
 
@@ -34,6 +35,14 @@ function Backend(options) {
34
35
  this.suppressPublish = !!options.suppressPublish;
35
36
  this.maxSubmitRetries = options.maxSubmitRetries || null;
36
37
  this.presenceEnabled = !!options.presence;
38
+ this.doNotForwardSendPresenceErrorsToClient = !!options.doNotForwardSendPresenceErrorsToClient;
39
+ if (!this.doNotForwardSendPresenceErrorsToClient) {
40
+ logger.warn(
41
+ 'Broadcasting "sendPresence" middleware errors to clients is deprecated ' +
42
+ 'and will be removed in a future release. Disable this behaviour with:\n\n' +
43
+ 'new Backend({doNotForwardSendPresenceErrorsToClient: true})\n\n'
44
+ );
45
+ }
37
46
 
38
47
  // Map from event name to a list of middleware
39
48
  this.middleware = {};
@@ -41,6 +50,13 @@ function Backend(options) {
41
50
  // The number of open agents for monitoring and testing memory leaks
42
51
  this.agentsCount = 0;
43
52
  this.remoteAgentsCount = 0;
53
+
54
+ this.errorHandler = typeof options.errorHandler === 'function' ?
55
+ options.errorHandler :
56
+ // eslint-disable-next-line no-unused-vars
57
+ function(error, context) {
58
+ logger.error(error);
59
+ };
44
60
  }
45
61
  module.exports = Backend;
46
62
  emitter.mixin(Backend);
@@ -68,6 +84,8 @@ Backend.prototype.MIDDLEWARE_ACTIONS = {
68
84
  // by design, changing existing reply properties can cause weird bugs, since
69
85
  // the rest of ShareDB would be unaware of those changes.
70
86
  reply: 'reply',
87
+ // The server received presence information
88
+ receivePresence: 'receivePresence',
71
89
  // About to send presence information to a client
72
90
  sendPresence: 'sendPresence',
73
91
  // An operation is about to be submitted to the database
@@ -836,7 +854,7 @@ Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp
836
854
 
837
855
  Backend.prototype._buildSnapshotFromOps = function(id, startingSnapshot, ops, callback) {
838
856
  var snapshot = startingSnapshot || new Snapshot(id, 0, null, undefined, null);
839
- var error = ot.applyOps(snapshot, ops, {_normalizeJson0Paths: true});
857
+ var error = ot.applyOps(snapshot, ops, {_normalizeLegacyJson0Ops: true});
840
858
  callback(error, snapshot);
841
859
  };
842
860
 
package/lib/client/doc.js CHANGED
@@ -3,6 +3,7 @@ var logger = require('../logger');
3
3
  var ShareDBError = require('../error');
4
4
  var types = require('../types');
5
5
  var util = require('../util');
6
+ var clone = util.clone;
6
7
  var deepEqual = require('fast-deep-equal');
7
8
 
8
9
  var ERROR_CODE = ShareDBError.CODES;
@@ -919,6 +920,15 @@ Doc.prototype.resume = function() {
919
920
  this.flush();
920
921
  };
921
922
 
923
+ // Create a snapshot that can be serialized, deserialized, and passed into `Doc.ingestSnapshot`.
924
+ Doc.prototype.toSnapshot = function() {
925
+ return {
926
+ v: this.version,
927
+ data: clone(this.data),
928
+ type: this.type.uri
929
+ };
930
+ };
931
+
922
932
  // *** Receiving operations
923
933
 
924
934
  // This is called when the server acknowledges an operation from the client.
@@ -948,7 +958,13 @@ Doc.prototype._rollback = function(err) {
948
958
  var op = this.inflightOp;
949
959
 
950
960
  if ('op' in op && op.type.invert) {
951
- op.op = op.type.invert(op.op);
961
+ try {
962
+ op.op = op.type.invert(op.op);
963
+ } catch (error) {
964
+ // If the op doesn't support `.invert()`, we just reload the doc
965
+ // instead of trying to locally revert it.
966
+ return this._hardRollback(err);
967
+ }
952
968
 
953
969
  // Transform the undo operation by any pending ops.
954
970
  for (var i = 0; i < this.pendingOps.length; i++) {
package/lib/db/memory.js CHANGED
@@ -1,6 +1,7 @@
1
1
  var DB = require('./index');
2
2
  var Snapshot = require('../snapshot');
3
3
  var util = require('../util');
4
+ var clone = util.clone;
4
5
 
5
6
  // In-memory ShareDB database
6
7
  //
@@ -172,7 +173,3 @@ MemoryDB.prototype._getVersionSync = function(collection, id) {
172
173
  var collectionOps = this.ops[collection];
173
174
  return (collectionOps && collectionOps[id] && collectionOps[id].length) || 0;
174
175
  };
175
-
176
- function clone(obj) {
177
- return (obj === undefined) ? undefined : JSON.parse(JSON.stringify(obj));
178
- }
package/lib/error.js CHANGED
@@ -38,6 +38,7 @@ ShareDBError.CODES = {
38
38
  ERR_OP_VERSION_MISMATCH_AFTER_TRANSFORM: 'ERR_OP_VERSION_MISMATCH_AFTER_TRANSFORM',
39
39
  ERR_OP_VERSION_MISMATCH_DURING_TRANSFORM: 'ERR_OP_VERSION_MISMATCH_DURING_TRANSFORM',
40
40
  ERR_OP_VERSION_NEWER_THAN_CURRENT_SNAPSHOT: 'ERR_OP_VERSION_NEWER_THAN_CURRENT_SNAPSHOT',
41
+ ERR_OT_LEGACY_JSON0_OP_CANNOT_BE_NORMALIZED: 'ERR_OT_LEGACY_JSON0_OP_CANNOT_BE_NORMALIZED',
41
42
  ERR_OT_OP_BADLY_FORMED: 'ERR_OT_OP_BADLY_FORMED',
42
43
  ERR_OT_OP_NOT_APPLIED: 'ERR_OT_OP_NOT_APPLIED',
43
44
  ERR_OT_OP_NOT_PROVIDED: 'ERR_OT_OP_NOT_PROVIDED',
package/lib/ot.js CHANGED
@@ -164,7 +164,16 @@ exports.applyOps = function(snapshot, ops, options) {
164
164
  options = options || {};
165
165
  for (var index = 0; index < ops.length; index++) {
166
166
  var op = ops[index];
167
- if (options._normalizeJson0Paths) normalizeJson0Paths(snapshot, op);
167
+ if (options._normalizeLegacyJson0Ops) {
168
+ try {
169
+ normalizeLegacyJson0Ops(snapshot, op);
170
+ } catch (error) {
171
+ return new ShareDBError(
172
+ ERROR_CODE.ERR_OT_LEGACY_JSON0_OP_CANNOT_BE_NORMALIZED,
173
+ 'Cannot normalize legacy json0 op'
174
+ );
175
+ }
176
+ }
168
177
  snapshot.v = op.v;
169
178
  var error = exports.apply(snapshot, op);
170
179
  if (error) return error;
@@ -205,15 +214,17 @@ exports.transformPresence = function(presence, op, isOwnOp) {
205
214
  * json0 had a breaking change in https://github.com/ottypes/json0/pull/40
206
215
  * The change added stricter type checking, which breaks fetchSnapshot()
207
216
  * when trying to rebuild a snapshot from old, committed ops that didn't
208
- * have this stricter validation. This method fixes up the op paths to
217
+ * have this stricter validation. This method fixes up legacy ops to
209
218
  * pass the stricter validation
210
219
  */
211
- function normalizeJson0Paths(snapshot, json0Op) {
220
+ function normalizeLegacyJson0Ops(snapshot, json0Op) {
212
221
  if (snapshot.type !== types.defaultType.uri) return;
213
222
  var components = json0Op.op;
214
223
  if (!components) return;
215
224
  for (var i = 0; i < components.length; i++) {
216
- var path = components[i].p;
225
+ var component = components[i];
226
+ if (typeof component.lm === 'string') component.lm = +component.lm;
227
+ var path = component.p;
217
228
  var element = snapshot.data;
218
229
  for (var j = 0; j < path.length; j++) {
219
230
  var key = path[j];
package/lib/util.js CHANGED
@@ -96,3 +96,8 @@ exports.nextTick = function(callback) {
96
96
  callback.apply(null, args);
97
97
  });
98
98
  };
99
+
100
+ exports.clone = function(obj) {
101
+ return (obj === undefined) ? undefined : JSON.parse(JSON.stringify(obj));
102
+ };
103
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sharedb",
3
- "version": "1.9.1",
3
+ "version": "2.2.0",
4
4
  "description": "JSON OT database backend",
5
5
  "main": "lib/index.js",
6
6
  "dependencies": {
@@ -13,13 +13,14 @@
13
13
  "devDependencies": {
14
14
  "chai": "^4.2.0",
15
15
  "coveralls": "^3.0.7",
16
- "eslint": "^6.5.1",
16
+ "eslint": "^7.32.0",
17
17
  "eslint-config-google": "^0.14.0",
18
- "ot-json0-v2": "ottypes/json0",
19
18
  "lolex": "^5.1.1",
20
19
  "mocha": "^8.2.1",
21
20
  "nyc": "^14.1.1",
21
+ "ot-json0-v2": "ottypes/json0",
22
22
  "ot-json1": "^0.3.0",
23
+ "rich-text": "^4.1.0",
23
24
  "sharedb-legacy": "npm:sharedb@=1.1.0",
24
25
  "sinon": "^7.5.0"
25
26
  },
package/test/backend.js CHANGED
@@ -1,29 +1,51 @@
1
1
  var Backend = require('../lib/backend');
2
2
  var expect = require('chai').expect;
3
3
  var sinon = require('sinon');
4
+ var logger = require('../lib/logger');
4
5
 
5
6
  describe('Backend', function() {
6
7
  var backend;
7
- var agent = {
8
- custom: {
9
- foo: 'bar'
10
- }
11
- };
12
- var fetchOptions = {
13
- snapshotOptions: {
14
- fizz: 'buzz'
15
- }
16
- };
17
-
18
- beforeEach(function() {
19
- backend = new Backend();
20
- });
21
8
 
22
9
  afterEach(function(done) {
23
10
  backend.close(done);
24
11
  });
25
12
 
13
+ describe('options', function() {
14
+ describe('errorHandler', function() {
15
+ it('logs by default', function() {
16
+ backend = new Backend();
17
+ var error = new Error('foo');
18
+ sinon.spy(logger, 'error');
19
+ backend.errorHandler(error);
20
+ expect(logger.error.callCount).to.equal(1);
21
+ });
22
+
23
+ it('overrides with another function', function() {
24
+ var handler = sinon.spy();
25
+ backend = new Backend({errorHandler: handler});
26
+ var error = new Error('foo');
27
+ backend.errorHandler(error);
28
+ expect(handler.callCount).to.equal(1);
29
+ });
30
+ });
31
+ });
32
+
26
33
  describe('a simple document', function() {
34
+ var agent = {
35
+ custom: {
36
+ foo: 'bar'
37
+ }
38
+ };
39
+ var fetchOptions = {
40
+ snapshotOptions: {
41
+ fizz: 'buzz'
42
+ }
43
+ };
44
+
45
+ beforeEach(function() {
46
+ backend = new Backend();
47
+ });
48
+
27
49
  beforeEach(function(done) {
28
50
  var doc = backend.connect().get('books', '1984');
29
51
  doc.create({title: '1984'}, function(error) {
@@ -1,6 +1,9 @@
1
1
  var Backend = require('../../lib/backend');
2
2
  var expect = require('chai').expect;
3
3
  var async = require('async');
4
+ var json0 = require('ot-json0').type;
5
+ var richText = require('rich-text').type;
6
+ var ShareDBError = require('../../lib/error');
4
7
  var errorHandler = require('../util').errorHandler;
5
8
 
6
9
  describe('Doc', function() {
@@ -407,6 +410,43 @@ describe('Doc', function() {
407
410
  ], done);
408
411
  });
409
412
 
413
+ it('rolls the doc back even if the op is not invertible', function(done) {
414
+ var backend = this.backend;
415
+
416
+ async.series([
417
+ function(next) {
418
+ // Register the rich text type, which can't be inverted
419
+ json0.registerSubtype(richText);
420
+
421
+ var validOp = {p: ['richName'], oi: {ops: [{insert: 'Scooby\n'}]}};
422
+ doc.submitOp(validOp, function(error) {
423
+ expect(error).to.not.exist;
424
+ next();
425
+ });
426
+ },
427
+ function(next) {
428
+ // Make the server reject this insertion
429
+ backend.use('submit', function(_context, backendNext) {
430
+ backendNext(new ShareDBError(ShareDBError.CODES.ERR_UNKNOWN_ERROR, 'Custom unknown error'));
431
+ });
432
+ var nonInvertibleOp = {p: ['richName'], t: 'rich-text', o: [{insert: 'e'}]};
433
+
434
+ // The server error should get all the way back to our handler
435
+ doc.submitOp(nonInvertibleOp, function(error) {
436
+ expect(error.message).to.eql('Custom unknown error');
437
+ next();
438
+ });
439
+ },
440
+ doc.whenNothingPending.bind(doc),
441
+ function(next) {
442
+ // The doc should have been reverted successfully
443
+ expect(doc.data).to.eql({name: 'Scooby', richName: {ops: [{insert: 'Scooby\n'}]}});
444
+ next();
445
+ }
446
+ ], done);
447
+ });
448
+
449
+
410
450
  it('rescues an irreversible op collision', function(done) {
411
451
  // This test case attempts to reconstruct the following corner case, with
412
452
  // two independent references to the same document. We submit two simultaneous, but
@@ -475,4 +515,30 @@ describe('Doc', function() {
475
515
  ], done);
476
516
  });
477
517
  });
518
+
519
+ describe('toSnapshot', function() {
520
+ var doc;
521
+ beforeEach(function(done) {
522
+ doc = this.connection.get('dogs', 'scooby');
523
+ doc.create({name: 'Scooby'}, done);
524
+ });
525
+
526
+ it('generates a snapshot', function() {
527
+ expect(doc.toSnapshot()).to.eql({
528
+ v: 1,
529
+ data: {name: 'Scooby'},
530
+ type: 'http://sharejs.org/types/JSONv0'
531
+ });
532
+ });
533
+
534
+ it('clones snapshot data to guard against mutation', function() {
535
+ var snapshot = doc.toSnapshot();
536
+ doc.data.name = 'Shaggy';
537
+ expect(snapshot).to.eql({
538
+ v: 1,
539
+ data: {name: 'Scooby'},
540
+ type: 'http://sharejs.org/types/JSONv0'
541
+ });
542
+ });
543
+ });
478
544
  });
@@ -487,4 +487,102 @@ describe('Presence', function() {
487
487
  }
488
488
  ], done);
489
489
  });
490
+
491
+ describe('middleware', function() {
492
+ describe('receivePresence', function() {
493
+ it('provides the presence in the middleware', function(done) {
494
+ backend.use(backend.MIDDLEWARE_ACTIONS.receivePresence, function(context) {
495
+ expect(context.presence.p).to.eql({index: 5});
496
+ done();
497
+ });
498
+
499
+ var localPresence1 = presence1.create('presence-1');
500
+ localPresence1.submit({index: 5}, errorHandler(done));
501
+ });
502
+
503
+ it('can mutate the presence in the middleware', function(done) {
504
+ backend.use(backend.MIDDLEWARE_ACTIONS.receivePresence, function(context, next) {
505
+ context.presence.p.index++;
506
+ next();
507
+ });
508
+
509
+ var localPresence1 = presence1.create('presence-1');
510
+
511
+ async.series([
512
+ presence2.subscribe.bind(presence2),
513
+ function(next) {
514
+ presence2.on('receive', function(id, value) {
515
+ expect(value).to.eql({index: 6});
516
+ next();
517
+ });
518
+ localPresence1.submit({index: 5}, errorHandler(done));
519
+ }
520
+ ], done);
521
+ });
522
+
523
+ it('can cancel a presence broadcast by erroring', function(done) {
524
+ backend.use(backend.MIDDLEWARE_ACTIONS.receivePresence, function(context, next) {
525
+ next(new Error('bad!'));
526
+ });
527
+
528
+ var localPresence1 = presence1.create('presence-1');
529
+
530
+ async.series([
531
+ presence2.subscribe.bind(presence2),
532
+ function(next) {
533
+ presence2.on('receive', function() {
534
+ done(new Error('should not have received presence'));
535
+ });
536
+ localPresence1.submit({index: 5}, function(error) {
537
+ expect(error.message).to.contain('bad!');
538
+ next();
539
+ });
540
+ }
541
+ ], done);
542
+ });
543
+ });
544
+
545
+ describe('sendPresence', function() {
546
+ // TODO: This functionality is deprecated
547
+ it('sends an error to a subscribed client', function(done) {
548
+ var localPresence1 = presence1.create('presence-1');
549
+
550
+ backend.use(backend.MIDDLEWARE_ACTIONS.sendPresence, function(context, next) {
551
+ next(new Error('uh-oh!'));
552
+ });
553
+
554
+ async.series([
555
+ presence2.subscribe.bind(presence2),
556
+ function(next) {
557
+ localPresence1.submit({index: 3}, errorHandler(done));
558
+ presence2.once('error', function(error) {
559
+ expect(error.message).to.equal('uh-oh!');
560
+ next();
561
+ });
562
+ }
563
+ ], done);
564
+ });
565
+
566
+ it('emits errors on the server instead of sending to the client', function(done) {
567
+ var localPresence1 = presence1.create('presence-1');
568
+
569
+ backend.doNotForwardSendPresenceErrorsToClient = true;
570
+ backend.use(backend.MIDDLEWARE_ACTIONS.sendPresence, function(context, next) {
571
+ next(new Error('uh-oh!'));
572
+ });
573
+
574
+ async.series([
575
+ presence2.subscribe.bind(presence2),
576
+ function(next) {
577
+ localPresence1.submit({index: 3}, errorHandler(done));
578
+ presence2.on('error', errorHandler(done));
579
+ backend.errorHandler = function(error) {
580
+ expect(error.message).to.equal('uh-oh!');
581
+ next();
582
+ };
583
+ }
584
+ ], done);
585
+ });
586
+ });
587
+ });
490
588
  });
@@ -220,17 +220,16 @@ module.exports = function(options) {
220
220
  if (err) return done(err);
221
221
  expect(results).to.have.length(0);
222
222
 
223
+ query.once('changed', function() {
224
+ expect(query.results).to.have.length(1);
225
+ expect(query.results[0].id).to.equal('fido');
226
+ expect(query.results[0].data).to.eql({age: 3, owner: {name: 'jim'}});
227
+ done();
228
+ });
229
+
223
230
  // Submit an op on a field not in the projection, where the op should
224
231
  // cause the doc to be included in the query results
225
- fido.submitOp({p: ['color'], od: 'gold', oi: 'black'}, null, function(err) {
226
- if (err) return done(err);
227
- setTimeout(function() {
228
- expect(query.results).to.have.length(1);
229
- expect(query.results[0].id).to.equal('fido');
230
- expect(query.results[0].data).to.eql({age: 3, owner: {name: 'jim'}});
231
- done();
232
- }, 10);
233
- });
232
+ fido.submitOp({p: ['color'], od: 'gold', oi: 'black'}, null, util.errorHandler(done));
234
233
  });
235
234
  });
236
235
  });
@@ -216,19 +216,28 @@ module.exports = function(options) {
216
216
  }
217
217
  ], function(err) {
218
218
  if (err) return done(err);
219
- var query = connection.createSubscribeQuery('dogs', matchAllDbQuery, null, function(err) {
220
- if (err) return done(err);
219
+ var query = connection.createSubscribeQuery('dogs', matchAllDbQuery);
220
+ query.on('error', done);
221
+
222
+ query.once('ready', function() {
221
223
  connection.close();
224
+
222
225
  connection2.get('dogs', 'fido').fetch(function(err) {
223
226
  if (err) return done(err);
224
- connection2.get('dogs', 'fido').del();
225
- connection2.get('dogs', 'taco').on('error', done).create({age: 2});
226
- process.nextTick(function() {
227
+ async.parallel([
228
+ function(cb) {
229
+ connection2.get('dogs', 'fido').del(cb);
230
+ },
231
+ function(cb) {
232
+ connection2.get('dogs', 'taco').create({age: 2}, cb);
233
+ }
234
+ ], function(error) {
235
+ if (error) return done(error);
227
236
  backend.connect(connection);
228
237
  });
229
238
  });
230
239
  });
231
- query.on('error', done);
240
+
232
241
  var wait = 2;
233
242
  function finish() {
234
243
  if (--wait) return;
@@ -237,12 +246,12 @@ module.exports = function(options) {
237
246
  expect(util.pluck(results, 'data')).eql([{age: 5}, {age: 2}]);
238
247
  done();
239
248
  }
240
- query.on('insert', function(docs) {
249
+ query.once('insert', function(docs) {
241
250
  expect(util.pluck(docs, 'id')).eql(['taco']);
242
251
  expect(util.pluck(docs, 'data')).eql([{age: 2}]);
243
252
  finish();
244
253
  });
245
- query.on('remove', function(docs) {
254
+ query.once('remove', function(docs) {
246
255
  expect(util.pluck(docs, 'id')).eql(['fido']);
247
256
  // We don't assert the value of data, because the del op could be
248
257
  // applied by the client before or after the query result is removed.
@@ -441,16 +441,15 @@ describe('SnapshotVersionRequest', function() {
441
441
  });
442
442
  });
443
443
 
444
- describe('invalid json0 path', function() {
444
+ describe('invalid json0v2 path', function() {
445
445
  beforeEach(function(done) {
446
446
  var doc = backend.connect().get('series', 'his-dark-materials');
447
- doc.create([{title: 'Golden Compass'}], function(error) {
448
- if (error) return done(error);
449
- doc.submitOp({p: ['0', 'title'], od: 'Golden Compass', oi: 'Northern Lights'}, function(error) {
450
- if (error) return done(error);
451
- doc.submitOp({p: ['1'], li: {title: 'Subtle Knife'}}, done);
452
- });
453
- });
447
+ async.series([
448
+ doc.create.bind(doc, [{title: 'Golden Compass'}]),
449
+ doc.submitOp.bind(doc, {p: ['0', 'title'], od: 'Golden Compass', oi: 'Northern Lights'}),
450
+ doc.submitOp.bind(doc, {p: ['1'], li: {title: 'Subtle Knife'}}),
451
+ doc.submitOp.bind(doc, {p: ['1'], lm: '0'})
452
+ ], done);
454
453
  });
455
454
 
456
455
  describe('json0v1', function() {
@@ -477,13 +476,24 @@ describe('SnapshotVersionRequest', function() {
477
476
  types.register(defaultType);
478
477
  });
479
478
 
480
- it('fetches v2 with json0v2', function(done) {
479
+ it('fetches a string-indexed list insertion with json0v2', function(done) {
481
480
  backend.connect().fetchSnapshot('series', 'his-dark-materials', 2, function(error, snapshot) {
482
481
  if (error) return done(error);
483
482
  expect(snapshot.data).to.eql([{title: 'Northern Lights'}]);
484
483
  done();
485
484
  });
486
485
  });
486
+
487
+ it('fetches a list move using a string target', function(done) {
488
+ backend.connect().fetchSnapshot('series', 'his-dark-materials', 4, function(error, snapshot) {
489
+ if (error) return done(error);
490
+ expect(snapshot.data).to.eql([
491
+ {title: 'Subtle Knife'},
492
+ {title: 'Northern Lights'}
493
+ ]);
494
+ done();
495
+ });
496
+ });
487
497
  });
488
498
  });
489
499
  });
@@ -1,6 +1,7 @@
1
1
  var expect = require('chai').expect;
2
2
  var projections = require('../lib/projections');
3
3
  var type = require('../lib/types').defaultType.uri;
4
+ var clone = require('../lib/util').clone;
4
5
 
5
6
  describe('projection utility methods', function() {
6
7
  describe('projectSnapshot', function() {
@@ -385,7 +386,3 @@ describe('projection utility methods', function() {
385
386
  });
386
387
  });
387
388
  });
388
-
389
- function clone(obj) {
390
- return (obj === undefined) ? undefined : JSON.parse(JSON.stringify(obj));
391
- }