diff --git a/README.md b/README.md index d4d9bc17..2c66759a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # sharedb-mongo - [![NPM Version](https://img.shields.io/npm/v/sharedb-mongo.svg)](https://npmjs.org/package/sharedb-mongo) - [![Build Status](https://travis-ci.org/share/sharedb-mongo.svg?branch=master)](https://travis-ci.org/share/sharedb-mongo) - [![Coverage Status](https://coveralls.io/repos/github/share/sharedb-mongo/badge.svg?branch=master)](https://coveralls.io/github/share/sharedb-mongo?branch=master) +[![NPM Version](https://img.shields.io/npm/v/sharedb-mongo.svg)](https://npmjs.org/package/sharedb-mongo) +[![Build Status](https://travis-ci.org/share/sharedb-mongo.svg?branch=master)](https://travis-ci.org/share/sharedb-mongo) +[![Coverage Status](https://coveralls.io/repos/github/share/sharedb-mongo/badge.svg?branch=master)](https://coveralls.io/github/share/sharedb-mongo?branch=master) MongoDB database adapter for [sharedb](https://github.com/share/sharedb). This driver can be used both as a snapshot store and oplog. -Snapshots are stored where you'd expect (the named collection with _id=id). In +Snapshots are stored where you'd expect (the named collection with \_id=id). In addition, operations are stored in `o_COLLECTION`. For example, if you have a `users` collection, the operations are stored in `o_users`. @@ -17,38 +17,36 @@ the form of `_v` and `_type`). It is safe to query documents directly with the MongoDB driver or command line. Any read only mongo features, including find, aggregate, and map reduce are safe to perform concurrent with ShareDB. -However, you must *always* use ShareDB to edit documents. Never use the +However, you must _always_ use ShareDB to edit documents. Never use the MongoDB driver or command line to directly modify any documents that ShareDB might create or edit. ShareDB must be used to properly persist operations together with snapshots. - ## Usage `sharedb-mongo` uses the [MongoDB NodeJS Driver](https://github.com/mongodb/node-mongodb-native), and it supports the same configuration options. There are two ways to instantiate a sharedb-mongo wrapper: -1. The simplest way is to invoke the module and pass in your mongo DB -arguments as arguments to the module function. For example: - - ```javascript - const db = require('sharedb-mongo')('mongodb://localhost:27017/test', {mongoOptions: {...}}); - const backend = new ShareDB({db}); - ``` +1. The simplest way is to invoke the module and pass in your mongo DB + arguments as arguments to the module function. For example: -2. If you'd like to reuse a mongo db connection or handle mongo driver -instantiation yourself, you can pass in a function that calls back with -a mongo instance. + ```javascript + const db = require('sharedb-mongo')('mongodb://localhost:27017/test', {mongoOptions: {...}}); + const backend = new ShareDB({db}); + ``` - ```javascript - const mongodb = require('mongodb'); - const db = require('sharedb-mongo')({mongo: function(callback) { - mongodb.connect('mongodb://localhost:27017/test', callback); - }}); - const backend = new ShareDB({db}); - ``` +2. If you'd like to reuse a mongo db connection or handle mongo driver + instantiation yourself, you can pass in a function that calls back with + a mongo instance. + ```javascript + const mongodb = require('mongodb'); + const db = require('sharedb-mongo')({mongo: function(callback) { + mongodb.connect('mongodb://localhost:27017/test', callback); + }}); + const backend = new ShareDB({db}); + ``` ## Queries @@ -172,7 +170,7 @@ failed ops: - v4: collision 4 - v5: unique - v6: unique -... + ... - v1000: unique If I want to fetch ops v1-v3, then we: @@ -190,6 +188,34 @@ In the case where a valid op cannot be determined, we still fall back to fetching all ops and working backwards from the current version. +### Middlewares + +Middlewares let you hook into the `sharedb-mongo` pipeline for certain actions. They are distinct from [middleware in `ShareDB`](https://github.com/share/sharedb) as they are closer to the concrete calls that are made to `MongoDB` itself. + +The original intent for middleware on `sharedb-mongo` is to support running in a sharded `MongoDB` cluster to satisfy the requirements on shard keys for versions 4.2 and greater of `MongoDB`. For more information see [the MongoDB docs](https://docs.mongodb.com/manual/core/sharding-shard-key/#shard-keys). + +#### Usage + +`share.use(action, fn)` +Register a new middleware. + +- `action` _(String)_ + One of: + - `'beforeOverwrite'`: directly before the call to replace a document, can include edits as well as deletions + - `'beforeSnapshotLookup'`: directly before the call to issue a query for one or more snapshots by ID +- `fn` _(Function(context, callback))_ + Call this function at the time specified by `action` + - `context` will always have the following properties: + - `action`: The action this middleware is handling + - `collectionName`: The collection name being handled + - `options`: Original options as they were passed into the relevant function that triggered the action + - `'beforeOverwrite'` actions have additional context properties: + - `documentToWrite` - The document to be written + - `op` - The op that represents the changes that will be made to the document + - `query` - A filter that will be used to lookup the document that is about to be edited, which should always include an ID and snapshot version e.g. `{_id: 'uuid', _v: 1}` + - `'beforeSnapshotLookup'` actions have additional context properties: + - `query` - A filter that will be used to lookup the snapshot. When a single snapshot is looked up the query will take the shape `{_id: docId}` while a bulk lookup by a list of IDs will resemble `{_id: {$in: docIdsArray}}`. + ### Limitations #### Integrity @@ -226,26 +252,26 @@ Mongo errors are passed back directly. Additional error codes: #### 4100 -- Bad request - DB -* 4101 -- Invalid op version -* 4102 -- Invalid collection name -* 4103 -- $where queries disabled -* 4104 -- $mapReduce queries disabled -* 4105 -- $aggregate queries disabled -* 4106 -- $query property deprecated in queries -* 4107 -- Malformed query operator -* 4108 -- Only one collection operation allowed -* 4109 -- Only one cursor operation allowed -* 4110 -- Cursor methods can't run after collection method +- 4101 -- Invalid op version +- 4102 -- Invalid collection name +- 4103 -- $where queries disabled +- 4104 -- $mapReduce queries disabled +- 4105 -- $aggregate queries disabled +- 4106 -- $query property deprecated in queries +- 4107 -- Malformed query operator +- 4108 -- Only one collection operation allowed +- 4109 -- Only one cursor operation allowed +- 4110 -- Cursor methods can't run after collection method #### 5100 -- Internal error - DB -* 5101 -- Already closed -* 5102 -- Snapshot missing last operation field -* 5103 -- Missing ops from requested version -* 5104 -- Failed to parse query - +- 5101 -- Already closed +- 5102 -- Snapshot missing last operation field +- 5103 -- Missing ops from requested version +- 5104 -- Failed to parse query ## MIT License + Copyright (c) 2015 by Joseph Gentle and Nate Smith Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/index.js b/index.js index 9c9fb83a..0b341fb1 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ var async = require('async'); var mongodb = require('mongodb'); var DB = require('sharedb').DB; var OpLinkValidator = require('./op-link-validator'); +var MiddlewareHandler = require('./src/middleware/middlewareHandler'); module.exports = ShareDbMongo; @@ -66,6 +67,8 @@ function ShareDbMongo(mongo, options) { } else { throw new Error('deprecated: pass mongo as url string or function with callback'); } + + this._middleware = new MiddlewareHandler(); }; ShareDbMongo.prototype = Object.create(DB.prototype); @@ -215,20 +218,31 @@ ShareDbMongo.prototype.close = function(callback) { ShareDbMongo.prototype.commit = function(collectionName, id, op, snapshot, options, callback) { var self = this; + var request = createRequestForMiddleware(options, collectionName, op); this._writeOp(collectionName, id, op, snapshot, function(err, result) { if (err) return callback(err); var opId = result.insertedId; - self._writeSnapshot(collectionName, id, snapshot, opId, function(err, succeeded) { + self._writeSnapshot(request, id, snapshot, opId, function(err, succeeded) { if (succeeded) return callback(err, succeeded); // Cleanup unsuccessful op if snapshot write failed. This is not // neccessary for data correctness, but it gets rid of clutter - self._deleteOp(collectionName, opId, function(removeErr) { + self._deleteOp(request.collectionName, opId, function(removeErr) { callback(err || removeErr, succeeded); }); }); }); }; +function createRequestForMiddleware(options, collectionName, op) { + // Create a new request object which will be passed to helper functions and middleware + var request = { + options: options, + collectionName: collectionName + }; + if (op) request.op = op; + return request; +} + ShareDbMongo.prototype._writeOp = function(collectionName, id, op, snapshot, callback) { if (typeof op.v !== 'number') { var err = ShareDbMongo.invalidOpVersionError(collectionName, id, op.v); @@ -250,12 +264,13 @@ ShareDbMongo.prototype._deleteOp = function(collectionName, opId, callback) { }); }; -ShareDbMongo.prototype._writeSnapshot = function(collectionName, id, snapshot, opLink, callback) { - this.getCollection(collectionName, function(err, collection) { +ShareDbMongo.prototype._writeSnapshot = function(request, id, snapshot, opId, callback) { + var self = this; + this.getCollection(request.collectionName, function(err, collection) { if (err) return callback(err); - var doc = castToDoc(id, snapshot, opLink); - if (doc._v === 1) { - collection.insertOne(doc, function(err) { + request.documentToWrite = castToDoc(id, snapshot, opId); + if (request.documentToWrite._v === 1) { + collection.insertOne(request.documentToWrite, function(err) { if (err) { // Return non-success instead of duplicate key error, since this is // expected to occur during simultaneous creates on the same id @@ -267,10 +282,16 @@ ShareDbMongo.prototype._writeSnapshot = function(collectionName, id, snapshot, o callback(null, true); }); } else { - collection.replaceOne({_id: id, _v: doc._v - 1}, doc, function(err, result) { - if (err) return callback(err); - var succeeded = !!result.modifiedCount; - callback(null, succeeded); + request.query = {_id: id, _v: request.documentToWrite._v - 1}; + self._middleware.trigger(MiddlewareHandler.Actions.beforeOverwrite, request, function(middlewareErr) { + if (middlewareErr) { + return callback(middlewareErr); + } + collection.replaceOne(request.query, request.documentToWrite, function(err, result) { + if (err) return callback(err); + var succeeded = !!result.modifiedCount; + callback(null, succeeded); + }); }); } }); @@ -280,36 +301,50 @@ ShareDbMongo.prototype._writeSnapshot = function(collectionName, id, snapshot, o // **** Snapshot methods ShareDbMongo.prototype.getSnapshot = function(collectionName, id, fields, options, callback) { + var self = this; this.getCollection(collectionName, function(err, collection) { if (err) return callback(err); var query = {_id: id}; var projection = getProjection(fields, options); - collection.find(query).limit(1).project(projection).next(function(err, doc) { - if (err) return callback(err); - var snapshot = (doc) ? castToSnapshot(doc) : new MongoSnapshot(id, 0, null, undefined); - callback(null, snapshot); + var request = createRequestForMiddleware(options, collectionName); + request.query = query; + self._middleware.trigger(MiddlewareHandler.Actions.beforeSnapshotLookup, request, function(middlewareErr) { + if (middlewareErr) return callback(middlewareErr); + + collection.find(request.query).limit(1).project(projection).next(function(err, doc) { + if (err) return callback(err); + var snapshot = (doc) ? castToSnapshot(doc) : new MongoSnapshot(id, 0, null, undefined); + callback(null, snapshot); + }); }); }); }; ShareDbMongo.prototype.getSnapshotBulk = function(collectionName, ids, fields, options, callback) { + var self = this; this.getCollection(collectionName, function(err, collection) { if (err) return callback(err); var query = {_id: {$in: ids}}; var projection = getProjection(fields, options); - collection.find(query).project(projection).toArray(function(err, docs) { - if (err) return callback(err); - var snapshotMap = {}; - for (var i = 0; i < docs.length; i++) { - var snapshot = castToSnapshot(docs[i]); - snapshotMap[snapshot.id] = snapshot; - } - for (var i = 0; i < ids.length; i++) { - var id = ids[i]; - if (snapshotMap[id]) continue; - snapshotMap[id] = new MongoSnapshot(id, 0, null, undefined); - } - callback(null, snapshotMap); + var request = createRequestForMiddleware(options, collectionName); + request.query = query; + self._middleware.trigger(MiddlewareHandler.Actions.beforeSnapshotLookup, request, function(middlewareErr) { + if (middlewareErr) return callback(middlewareErr); + + collection.find(request.query).project(projection).toArray(function(err, docs) { + if (err) return callback(err); + var snapshotMap = {}; + for (var i = 0; i < docs.length; i++) { + var snapshot = castToSnapshot(docs[i]); + snapshotMap[snapshot.id] = snapshot; + } + for (var i = 0; i < ids.length; i++) { + var id = ids[i]; + if (snapshotMap[id]) continue; + snapshotMap[id] = new MongoSnapshot(id, 0, null, undefined); + } + callback(null, snapshotMap); + }); }); }); }; @@ -1576,3 +1611,11 @@ ShareDbMongo.parseQueryError = function(err) { err.code = 5104; return err; }; + +// Middleware + +ShareDbMongo.prototype.use = function(action, fn) { + this._middleware.use(action, fn); +}; + +ShareDbMongo.MiddlewareActions = MiddlewareHandler.Actions; diff --git a/src/middleware/actions.js b/src/middleware/actions.js new file mode 100644 index 00000000..3e33c646 --- /dev/null +++ b/src/middleware/actions.js @@ -0,0 +1,7 @@ +module.exports = { + // Triggers before the call to replace a document is made + beforeOverwrite: 'beforeOverwrite', + // Triggers directly before the call to issue a query for snapshots + // Applies for both a single lookup by ID and bulk lookups by a list of IDs + beforeSnapshotLookup: 'beforeSnapshotLookup' +}; diff --git a/src/middleware/middlewareHandler.js b/src/middleware/middlewareHandler.js new file mode 100644 index 00000000..18e3aa97 --- /dev/null +++ b/src/middleware/middlewareHandler.js @@ -0,0 +1,66 @@ +var MIDDLEWARE_ACTIONS = require('./actions'); + +function MiddlewareHandler() { + this._middleware = {}; +} + +/** +* Add middleware to an action or array of actions +* +* @param action The action to use from MIDDLEWARE_ACTIONS (e.g. 'beforeOverwrite') +* @param fn The function to call when this middleware is triggered +* The fn receives a request object with information on the triggered action (e.g. the snapshot to write) +* and a next function to call once the middleware is complete +* +* NOTE: It is recommended not to add async or long running tasks to the sharedb-mongo middleware as it will +* be called very frequently during sensitive operations. It may have a significant performance impact. +*/ +MiddlewareHandler.prototype.use = function(action, fn) { + if (Array.isArray(action)) { + for (var i = 0; i < action.length; i++) { + this.use(action[i], fn); + } + return this; + } + if (!action) throw new Error('Expected action to be defined'); + if (!fn) throw new Error('Expected fn to be defined'); + if (!Object.values(MIDDLEWARE_ACTIONS).includes(action)) { + throw new Error('Unrecognized action name ' + action); + } + + var fns = this._middleware[action] || (this._middleware[action] = []); + fns.push(fn); + return this; +}; + +/** + * Passes request through the middleware stack + * + * Middleware may modify the request object. After all middleware have been + * invoked we call `callback` with `null` and the modified request. If one of + * the middleware resturns an error the callback is called with that error. + * + * @param action The action to trigger from MIDDLEWARE_ACTIONS (e.g. 'beforeOverwrite') + * @param request Request details such as the snapshot to write, depends on the triggered action + * @param callback Function to call once the middleware has been processed. + */ +MiddlewareHandler.prototype.trigger = function(action, request, callback) { + request.action = action; + + var fns = this._middleware[action]; + if (!fns) return callback(); + + // Copying the triggers we'll fire so they don't get edited while we iterate. + fns = fns.slice(); + var next = function(err) { + if (err) return callback(err); + var fn = fns.shift(); + if (!fn) return callback(); + fn(request, next); + }; + next(); +}; + +MiddlewareHandler.Actions = MIDDLEWARE_ACTIONS; + +module.exports = MiddlewareHandler; diff --git a/test/test_mongo_middleware.js b/test/test_mongo_middleware.js new file mode 100644 index 00000000..50b624fa --- /dev/null +++ b/test/test_mongo_middleware.js @@ -0,0 +1,287 @@ +var async = require('async'); +var expect = require('chai').expect; +var ShareDbMongo = require('..'); + +var mongoUrl = process.env.TEST_MONGO_URL || 'mongodb://localhost:27017/test'; +var BEFORE_EDIT = ShareDbMongo.MiddlewareActions.beforeOverwrite; +var BEFORE_SNAPSHOT_LOOKUP = ShareDbMongo.MiddlewareActions.beforeSnapshotLookup; + +function create(callback) { + var db = new ShareDbMongo(mongoUrl); + db.getDbs(function(err, mongo) { + if (err) return callback(err); + mongo.dropDatabase(function(err) { + if (err) return callback(err); + callback(null, db, mongo); + }); + }); +}; + +describe('mongo db middleware', function() { + var db; + + beforeEach(function(done) { + create(function(err, createdDb) { + if (err) return done(err); + db = createdDb; + done(); + }); + }); + + afterEach(function(done) { + db.close(done); + }); + + describe('error handling', function() { + it('throws error when no action is given', function() { + function invalidAction() { + db.use(null, function(_request, next) { + next(); + }); + } + expect(invalidAction).to.throw(); + }); + + it('throws error when no handler is given', function() { + function invalidAction() { + db.use('someAction'); + } + expect(invalidAction).to.throw(); + }); + + it('throws error on unrecognized action name', function() { + function invalidAction() { + db.use('someAction', function(_request, next) { + next(); + }); + } + expect(invalidAction).to.throw(); + }); + }); + + describe(BEFORE_EDIT, function() { + it('has the expected properties on the request object', function(done) { + db.use(BEFORE_EDIT, function(request, next) { + expect(request).to.have.all.keys([ + 'action', + 'collectionName', + 'documentToWrite', + 'op', + 'options', + 'query' + ]); + expect(request.action).to.equal(BEFORE_EDIT); + expect(request.collectionName).to.equal('testcollection'); + expect(request.documentToWrite.foo).to.equal('fuzz'); + expect(request.op.op).to.exist; + expect(request.options.testOptions).to.equal('yes'); + expect(request.query._id).to.equal('test1'); + next(); + done(); + }); + + var snapshot = {type: 'json0', id: 'test1', v: 1, data: {foo: 'bar'}}; + var editOp = {v: 2, op: [{p: ['foo'], oi: 'bar', oi: 'baz'}], m: {ts: Date.now()}}; + var newSnapshot = {type: 'json0', id: 'test1', v: 2, data: {foo: 'fuzz'}}; + + db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, function(err) { + if (err) return done(err); + db.commit('testcollection', snapshot.id, editOp, newSnapshot, {testOptions: 'yes'}, function(err) { + if (err) return done(err); + }); + }); + }); + + it('should augment query filter and write to the document when commit is called', function(done) { + // Augment the query. The original query looks up the document by id, wheras this middleware + // changes it to use the `foo` property. The end result still returns the same document. The next + // middleware ensures we attached it to the request. + // We can't truly change which document is returned from the query because MongoDB will not allow + // the immutable fields such as `_id` to be changed. + db.use(BEFORE_EDIT, function(request, next) { + request.query.foo = 'bar'; + next(); + }); + // Attach this middleware to check that the original one is passing the context + // correctly. Commit will be called after this. + db.use(BEFORE_EDIT, function(request, next) { + expect(request.query).to.deep.equal({ + _id: 'test1', + _v: 1, + foo: 'bar' + }); + next(); + }); + + var snapshot = {type: 'json0', id: 'test1', v: 1, data: {foo: 'bar'}}; + var editOp = {v: 2, op: [{p: ['foo'], oi: 'bar', oi: 'baz'}], m: {ts: Date.now()}}; + var newSnapshot = {type: 'json0', id: 'test1', v: 2, data: {foo: 'baz'}}; + + db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, function(err) { + if (err) return done(err); + expectDocumentToContainFoo('bar', function() { + db.commit('testcollection', snapshot.id, editOp, newSnapshot, null, function(err) { + if (err) return done(err); + // Ensure the value is updated as expected + expectDocumentToContainFoo('baz', done); + }); + }); + }); + }); + }); + + it('should augment the written document when commit is called', function(done) { + // Change the written value of foo to be `fuzz` + db.use(BEFORE_EDIT, function(request, next) { + request.documentToWrite.foo = 'fuzz'; + next(); + }); + + var snapshot = {type: 'json0', id: 'test1', v: 1, data: {foo: 'bar'}}; + + // Issue a commit to change `bar` to `baz` + var editOp = {v: 2, op: [{p: ['foo'], oi: 'bar', oi: 'baz'}], m: {ts: Date.now()}}; + var newSnapshot = {type: 'json0', id: 'test1', v: 2, data: {foo: 'baz'}}; + + db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, function(err) { + if (err) return done(err); + expectDocumentToContainFoo('bar', function() { + db.commit('testcollection', snapshot.id, editOp, newSnapshot, null, function(err) { + if (err) return done(err); + // Ensure the value is updated as expected + expectDocumentToContainFoo('fuzz', done); + }); + }); + }); + }); + + describe(BEFORE_SNAPSHOT_LOOKUP, function() { + it('has the expected properties on the request object before getting a single snapshot', function(done) { + db.use(BEFORE_SNAPSHOT_LOOKUP, function(request, next) { + expect(request).to.have.all.keys([ + 'action', + 'collectionName', + 'options', + 'query' + ]); + expect(request.action).to.equal(BEFORE_SNAPSHOT_LOOKUP); + expect(request.collectionName).to.equal('testcollection'); + expect(request.options.testOptions).to.equal('yes'); + expect(request.query._id).to.equal('test1'); + next(); + done(); + }); + + var snapshot = {type: 'json0', id: 'test1', v: 1, data: {foo: 'bar'}}; + db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, function(err) { + if (err) return done(err); + db.getSnapshot('testcollection', 'test1', null, {testOptions: 'yes'}, function(err, doc) { + if (err) return done(err); + expect(doc).to.exist; + }); + }); + }); + + it('has the expected properties on the request object before getting bulk snapshots', function(done) { + db.use(BEFORE_SNAPSHOT_LOOKUP, function(request, next) { + expect(request).to.have.all.keys([ + 'action', + 'collectionName', + 'options', + 'query' + ]); + expect(request.action).to.equal(BEFORE_SNAPSHOT_LOOKUP); + expect(request.collectionName).to.equal('testcollection'); + expect(request.options.testOptions).to.equal('yes'); + expect(request.query._id).to.deep.equal({$in: ['test1']}); + next(); + done(); + }); + + var snapshot = {type: 'json0', id: 'test1', v: 1, data: {foo: 'bar'}}; + db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, function(err) { + if (err) return done(err); + db.getSnapshotBulk('testcollection', ['test1'], null, {testOptions: 'yes'}, function(err, doc) { + if (err) return done(err); + expect(doc).to.exist; + }); + }); + }); + + it('should augment the query when getSnapshot is called', function(done) { + var snapshots = [ + {type: 'json0', id: 'test1', v: 1, data: {foo: 'bar'}}, + {type: 'json0', id: 'test2', v: 1, data: {foo: 'baz'}} + ]; + + async.each(snapshots, function(snapshot, cb) { + db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, cb); + }, function(err) { + if (err) return done(err); + db.getSnapshot('testcollection', 'test1', null, null, function(err, doc) { + if (err) return done(err); + expect(doc.data).eql({ + foo: 'bar' + }); + + // Change the query to look for baz and not bar + db.use(BEFORE_SNAPSHOT_LOOKUP, function(request, next) { + request.query = {_id: 'test2'}; + next(); + }); + + db.getSnapshot('testcollection', 'test1', null, null, function(err, doc) { + if (err) return done(err); + expect(doc.data).eql({ + foo: 'baz' + }); + done(); + }); + }); + }); + }); + + it('should augment the query when getSnapshotBulk is called', function(done) { + var snapshots = [ + {type: 'json0', id: 'test1', v: 1, data: {foo: 'bar'}}, + {type: 'json0', id: 'test2', v: 1, data: {foo: 'baz'}} + ]; + + async.each(snapshots, function(snapshot, cb) { + db.commit('testcollection', snapshot.id, {v: 0, create: {}}, snapshot, null, cb); + }, function(err) { + if (err) return done(err); + db.getSnapshotBulk('testcollection', ['test1', 'test2'], null, null, function(err, docs) { + if (err) return done(err); + expect(docs.test1.data.foo).to.equal('bar'); + expect(docs.test2.data.foo).to.equal('baz'); + + // Change the query to look for baz and not bar + db.use(BEFORE_SNAPSHOT_LOOKUP, function(request, next) { + request.query = {_id: {$in: ['test2']}}; + next(); + }); + + db.getSnapshotBulk('testcollection', ['test1', 'test2'], null, null, function(err, docs) { + if (err) return done(err); + expect(docs.test1.data).not.to.exist; + expect(docs.test2.data.foo).to.equal('baz'); + done(); + }); + }); + }); + }); + }); + + function expectDocumentToContainFoo(valueOfFoo, cb) { + var query = {_id: 'test1'}; + + db.query('testcollection', query, null, null, function(err, results) { + if (err) return done(err); + expect(results[0].data).eql({ + foo: valueOfFoo + }); + cb(); + }); + }; +});