diff --git a/README.md b/README.md index afc273bec..ecc2969ed 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,27 @@ Get a read-only snapshot of a document at the requested version. } ``` +`connection.fetchSnapshotByTimestamp(collection, id, timestamp, callback): void;` +Get a read-only snapshot of a document at the requested version. + +* `collection` _(String)_ + Collection name of the snapshot +* `id` _(String)_ + ID of the snapshot +* `timestamp` _(number) [optional]_ + The timestamp of the desired snapshot. The returned snapshot will be the latest snapshot before the provided timestamp +* `callback` _(Function)_ + Called with `(error, snapshot)`, where `snapshot` takes the following form: + + ```javascript + { + id: string; // ID of the snapshot + v: number; // version number of the snapshot + type: string; // the OT type of the snapshot, or null if it doesn't exist or is deleted + data: any; // the snapshot + } + ``` + ### Class: `ShareDB.Doc` `doc.type` _(String_) @@ -464,3 +485,5 @@ The `41xx` and `51xx` codes are reserved for use by ShareDB DB adapters, and the * 5018 - Required QueryEmitter listener not assigned * 5019 - getMilestoneSnapshot MilestoneDB method unimplemented * 5020 - saveMilestoneSnapshot MilestoneDB method unimplemented +* 5021 - getMilestoneSnapshotBeforeTime MilestoneDB method unimplemented +* 5022 - getMilestoneSnapshotAfterTime MilestoneDB method unimplemented diff --git a/lib/agent.js b/lib/agent.js index 12d6f3cf5..2c4d44fd1 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -303,6 +303,8 @@ Agent.prototype._handleMessage = function(request, callback) { return this._submit(request.c, request.d, op, callback); case 'nf': return this._fetchSnapshot(request.c, request.d, request.v, callback); + case 'nt': + return this._fetchSnapshotByTimestamp(request.c, request.d, request.ts, callback); default: callback({code: 4000, message: 'Invalid or unknown message'}); } @@ -589,3 +591,7 @@ Agent.prototype._createOp = function(request) { Agent.prototype._fetchSnapshot = function (collection, id, version, callback) { this.backend.fetchSnapshot(this, collection, id, version, callback); }; + +Agent.prototype._fetchSnapshotByTimestamp = function (collection, id, timestamp, callback) { + this.backend.fetchSnapshotByTimestamp(this, collection, id, timestamp, callback); +}; diff --git a/lib/backend.js b/lib/backend.js index e766202ec..27555d528 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -81,7 +81,9 @@ Backend.prototype.SNAPSHOT_TYPES = { // The current snapshot is being fetched (eg through backend.fetch) current: 'current', // A specific snapshot is being fetched by version (eg through backend.fetchSnapshot) - byVersion: 'byVersion' + byVersion: 'byVersion', + // A specific snapshot is being fetch by timestamp (eg through backend.fetchSnapshotByTimestamp) + byTimestamp: 'byTimestamp' }; Backend.prototype._shimDocAction = function() { @@ -627,6 +629,8 @@ Backend.prototype.fetchSnapshot = function(agent, index, id, version, callback) Backend.prototype._fetchSnapshot = function (collection, id, version, callback) { var db = this.db; + var backend = this; + this.milestoneDb.getMilestoneSnapshot(collection, id, version, function (error, milestoneSnapshot) { if (error) return callback(error); @@ -637,45 +641,120 @@ Backend.prototype._fetchSnapshot = function (collection, id, version, callback) db.getOps(collection, id, from, version, null, function (error, ops) { if (error) return callback(error); - var type = null; - var data; - var fetchedVersion = 0; - - if (milestoneSnapshot) { - type = types.map[milestoneSnapshot.type]; - if (!type) return callback({ code: 4008, message: 'Unknown type' }); - data = milestoneSnapshot.data; - fetchedVersion = milestoneSnapshot.v; - } + backend._buildSnapshotFromOps(id, milestoneSnapshot, ops, function (error, snapshot) { + if (error) return callback(error); - for (var index = 0; index < ops.length; index++) { - var op = ops[index]; - fetchedVersion = op.v + 1; - - if (op.create) { - type = types.map[op.create.type]; - if (!type) return callback({ code: 4008, message: 'Unknown type' }); - data = type.create(op.create.data); - } else if (op.del) { - data = undefined; - type = null; - } else { - data = type.apply(data, op.op); + if (version > snapshot.v) { + return callback({ code: 4024, message: 'Requested version exceeds latest snapshot version' }); } - } - type = type ? type.uri : null; + callback(null, snapshot); + }); + }); + }); +}; - if (version > fetchedVersion) { - return callback({ code: 4024, message: 'Requested version exceeds latest snapshot version' }); - } +Backend.prototype.fetchSnapshotByTimestamp = function (agent, index, id, timestamp, callback) { + var start = Date.now(); + var backend = this; + var projection = this.projections[index]; + var collection = projection ? projection.target : index; + var request = { + agent: agent, + index: index, + collection: collection, + id: id, + timestamp: timestamp + }; - var snapshot = new Snapshot(id, fetchedVersion, type, data, null); + this._fetchSnapshotByTimestamp(collection, id, timestamp, function (error, snapshot) { + if (error) return callback(error); + var snapshotProjection = backend._getSnapshotProjection(backend.db, projection); + var snapshots = [snapshot]; + var snapshotType = backend.SNAPSHOT_TYPES.byTimestamp; + backend._sanitizeSnapshots(agent, snapshotProjection, collection, snapshots, snapshotType, function (error) { + if (error) return callback(error); + backend.emit('timing', 'fetchSnapshot', Date.now() - start, request); callback(null, snapshot); }); }); }; +Backend.prototype._fetchSnapshotByTimestamp = function (collection, id, timestamp, callback) { + var db = this.db; + var milestoneDb = this.milestoneDb; + var backend = this; + + var milestoneSnapshot; + var from = 0; + var to = null; + + milestoneDb.getMilestoneSnapshotAtOrBeforeTime(collection, id, timestamp, function (error, snapshot) { + if (error) return callback(error); + milestoneSnapshot = snapshot; + if (snapshot) from = snapshot.v; + + milestoneDb.getMilestoneSnapshotAtOrAfterTime(collection, id, timestamp, function (error, snapshot) { + if (error) return callback(error); + if (snapshot) to = snapshot.v; + + var options = {metadata: true}; + db.getOps(collection, id, from, to, options, function (error, ops) { + if (error) return callback(error); + backend._buildSnapshotFromOps(id, milestoneSnapshot, ops, callback, function shouldBreak(nextOp) { + var opTimestamp = nextOp && nextOp.m && nextOp.m.ts; + return timestamp !== null && opTimestamp > timestamp; + }); + }); + }); + }); +}; + +Backend.prototype._buildSnapshotFromOps = function (id, startingSnapshot, ops, callback, shouldBreak) { + if (typeof shouldBreak !== 'function') { + shouldBreak = function () { + return false; + }; + } + + var type = null; + var data; + var fetchedVersion = 0; + + if (startingSnapshot) { + type = types.map[startingSnapshot.type]; + if (!type) return callback({ code: 4008, message: 'Unknown type' }); + data = startingSnapshot.data; + fetchedVersion = startingSnapshot.v; + } + + for (var index = 0; index < ops.length; index++) { + var op = ops[index]; + + if (shouldBreak(op)) { + break; + } + + fetchedVersion = op.v + 1; + + if (op.create) { + type = types.map[op.create.type]; + if (!type) return callback({ code: 4008, message: 'Unknown type' }); + data = type.create(op.create.data); + } else if (op.del) { + data = undefined; + type = null; + } else { + data = type.apply(data, op.op); + } + } + + type = type ? type.uri : null; + + var snapshot = new Snapshot(id, fetchedVersion, type, data, null); + callback(null, snapshot); +}; + function pluckIds(snapshots) { var ids = []; for (var i = 0; i < snapshots.length; i++) { diff --git a/lib/client/connection.js b/lib/client/connection.js index 9a2a1fef7..560391bdc 100644 --- a/lib/client/connection.js +++ b/lib/client/connection.js @@ -1,6 +1,7 @@ var Doc = require('./doc'); var Query = require('./query'); -var SnapshotRequest = require('./snapshot-request'); +var SnapshotVersionRequest = require('./snapshot-request/snapshot-version-request'); +var SnapshotTimestampRequest = require('./snapshot-request/snapshot-timestamp-request'); var emitter = require('../emitter'); var ShareDBError = require('../error'); var types = require('../types'); @@ -233,6 +234,7 @@ Connection.prototype.handleMessage = function(message) { return this._handleBulkMessage(message, '_handleUnsubscribe'); case 'nf': + case 'nt': return this._handleSnapshotFetch(err, message); case 'f': @@ -634,7 +636,35 @@ Connection.prototype.fetchSnapshot = function(collection, id, version, callback) } var requestId = this.nextSnapshotRequestId++; - var snapshotRequest = new SnapshotRequest(this, requestId, collection, id, version, callback); + var snapshotRequest = new SnapshotVersionRequest(this, requestId, collection, id, version, callback); + this._snapshotRequests[snapshotRequest.requestId] = snapshotRequest; + snapshotRequest.send(); +}; + +/** + * Fetch a read-only snapshot at a given version + * + * @param collection - the collection name of the snapshot + * @param id - the ID of the snapshot + * @param timestamp (optional) - the timestamp to fetch + * @param callback - (error, snapshot) => void, where snapshot takes the following schema: + * + * { + * id: string; // ID of the snapshot + * v: number; // version number of the snapshot + * type: string; // the OT type of the snapshot, or null if it doesn't exist or is deleted + * data: any; // the snapshot + * } + * + */ +Connection.prototype.fetchSnapshotByTimestamp = function (collection, id, timestamp, callback) { + if (typeof timestamp === 'function') { + callback = timestamp; + timestamp = null; + } + + var requestId = this.nextSnapshotRequestId++; + var snapshotRequest = new SnapshotTimestampRequest(this, requestId, collection, id, timestamp, callback); this._snapshotRequests[snapshotRequest.requestId] = snapshotRequest; snapshotRequest.send(); }; diff --git a/lib/client/snapshot-request.js b/lib/client/snapshot-request/snapshot-request.js similarity index 72% rename from lib/client/snapshot-request.js rename to lib/client/snapshot-request/snapshot-request.js index 1b8302594..00ed9b90f 100644 --- a/lib/client/snapshot-request.js +++ b/lib/client/snapshot-request/snapshot-request.js @@ -1,25 +1,19 @@ -var Snapshot = require('../snapshot'); -var util = require('../util'); -var emitter = require('../emitter'); +var Snapshot = require('../../snapshot'); +var emitter = require('../../emitter'); module.exports = SnapshotRequest; -function SnapshotRequest(connection, requestId, collection, id, version, callback) { +function SnapshotRequest(connection, requestId, collection, id, callback) { emitter.EventEmitter.call(this); if (typeof callback !== 'function') { throw new Error('Callback is required for SnapshotRequest'); } - if (!util.isValidVersion(version)) { - throw new Error('Snapshot version must be a positive integer or null'); - } - this.requestId = requestId; this.connection = connection; this.id = id; this.collection = collection; - this.version = version; this.callback = callback; this.sent = false; @@ -31,15 +25,7 @@ SnapshotRequest.prototype.send = function () { return; } - var message = { - a: 'nf', - id: this.requestId, - c: this.collection, - d: this.id, - v: this.version, - }; - - this.connection.send(message); + this.connection.send(this._message()); this.sent = true; }; @@ -61,6 +47,8 @@ SnapshotRequest.prototype._handleResponse = function (error, message) { return this.callback(error); } - var snapshot = new Snapshot(this.id, message.v, message.type, message.data, null); + var metadata = message.meta ? message.meta : null; + var snapshot = new Snapshot(this.id, message.v, message.type, message.data, metadata); + this.callback(null, snapshot); }; diff --git a/lib/client/snapshot-request/snapshot-timestamp-request.js b/lib/client/snapshot-request/snapshot-timestamp-request.js new file mode 100644 index 000000000..53c3b2437 --- /dev/null +++ b/lib/client/snapshot-request/snapshot-timestamp-request.js @@ -0,0 +1,26 @@ +var SnapshotRequest = require('./snapshot-request'); +var util = require('../../util'); + +module.exports = SnapshotTimestampRequest; + +function SnapshotTimestampRequest(connection, requestId, collection, id, timestamp, callback) { + SnapshotRequest.call(this, connection, requestId, collection, id, callback); + + if (!util.isValidTimestamp(timestamp)) { + throw new Error('Snapshot timestamp must be a positive integer or null'); + } + + this.timestamp = timestamp; +} + +SnapshotTimestampRequest.prototype = Object.create(SnapshotRequest.prototype); + +SnapshotTimestampRequest.prototype._message = function () { + return { + a: 'nt', + id: this.requestId, + c: this.collection, + d: this.id, + ts: this.timestamp, + }; +}; diff --git a/lib/client/snapshot-request/snapshot-version-request.js b/lib/client/snapshot-request/snapshot-version-request.js new file mode 100644 index 000000000..60a2e3a3c --- /dev/null +++ b/lib/client/snapshot-request/snapshot-version-request.js @@ -0,0 +1,26 @@ +var SnapshotRequest = require('./snapshot-request'); +var util = require('../../util'); + +module.exports = SnapshotVersionRequest; + +function SnapshotVersionRequest (connection, requestId, collection, id, version, callback) { + SnapshotRequest.call(this, connection, requestId, collection, id, callback); + + if (!util.isValidVersion(version)) { + throw new Error('Snapshot version must be a positive integer or null'); + } + + this.version = version; +} + +SnapshotVersionRequest.prototype = Object.create(SnapshotRequest.prototype); + +SnapshotVersionRequest.prototype._message = function () { + return { + a: 'nf', + id: this.requestId, + c: this.collection, + d: this.id, + v: this.version, + }; +}; diff --git a/lib/milestone-db/index.js b/lib/milestone-db/index.js index 581f1ecc9..48fc1e002 100644 --- a/lib/milestone-db/index.js +++ b/lib/milestone-db/index.js @@ -26,8 +26,7 @@ MilestoneDB.prototype.close = function(callback) { */ MilestoneDB.prototype.getMilestoneSnapshot = function (collection, id, version, callback) { var error = new ShareDBError(5019, 'getMilestoneSnapshot MilestoneDB method unimplemented'); - if (callback) return process.nextTick(callback, error); - this.emit('error', error); + this._callBackOrEmitError(error, callback); }; /** @@ -38,10 +37,28 @@ MilestoneDB.prototype.getMilestoneSnapshot = function (collection, id, version, */ MilestoneDB.prototype.saveMilestoneSnapshot = function (collection, snapshot, callback) { var error = new ShareDBError(5020, 'saveMilestoneSnapshot MilestoneDB method unimplemented'); - if (callback) return process.nextTick(callback, error); - this.emit('error', error); + this._callBackOrEmitError(error, callback); +}; + +MilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function (collection, id, timestamp, callback) { + var error = new ShareDBError(5021, 'getMilestoneSnapshotBeforeTime MilestoneDB method unimplemented'); + this._callBackOrEmitError(error, callback); +}; + +MilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function (collection, id, timestamp, callback) { + var error = new ShareDBError(5022, 'getMilestoneSnapshotAfterTime MilestoneDB method unimplemented'); + this._callBackOrEmitError(error, callback); }; MilestoneDB.prototype._isValidVersion = function (version) { return util.isValidVersion(version); }; + +MilestoneDB.prototype._isValidTimestamp = function (timestamp) { + return util.isValidTimestamp(timestamp); +}; + +MilestoneDB.prototype._callBackOrEmitError = function (error, callback) { + if (callback) return process.nextTick(callback, error); + this.emit('error', error); +}; diff --git a/lib/milestone-db/memory.js b/lib/milestone-db/memory.js index f239c3f9a..f2707bc5f 100644 --- a/lib/milestone-db/memory.js +++ b/lib/milestone-db/memory.js @@ -23,24 +23,17 @@ function MemoryMilestoneDB(options) { MemoryMilestoneDB.prototype = Object.create(MilestoneDB.prototype); MemoryMilestoneDB.prototype.getMilestoneSnapshot = function (collection, id, version, callback) { - if (!callback) callback = function () {}; - if (!collection) return process.nextTick(callback, new ShareDBError(4001, 'Missing collection')); - if (!id) return process.nextTick(callback, new ShareDBError(4001, 'Missing ID')); if (!this._isValidVersion(version)) return process.nextTick(callback, new ShareDBError(4001, 'Invalid version')); - var milestoneSnapshots = this._getMilestoneSnapshotsSync(collection, id); - - var milestoneSnapshot; - for (var i = 0; i < milestoneSnapshots.length; i++) { - var nextMilestoneSnapshot = milestoneSnapshots[i]; - if (nextMilestoneSnapshot.v <= version || version === null) { - milestoneSnapshot = nextMilestoneSnapshot; - } else { - break; + var shouldBreak = function (currentSnapshot, nextSnapshot) { + if (version === null) { + return false; } - } - process.nextTick(callback, null, milestoneSnapshot); + return nextSnapshot.v > version; + }; + + this._findMilestoneSnapshot(collection, id, shouldBreak, callback); }; MemoryMilestoneDB.prototype.saveMilestoneSnapshot = function (collection, snapshot, callback) { @@ -61,6 +54,64 @@ MemoryMilestoneDB.prototype.saveMilestoneSnapshot = function (collection, snapsh process.nextTick(callback, null); }; +MemoryMilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function (collection, id, timestamp, callback) { + if (!this._isValidTimestamp(timestamp)) return process.nextTick(callback, new ShareDBError(4001, 'Invalid timestamp')); + + var shouldBreak = function (currentSnapshot, nextSnapshot) { + if (timestamp === null) { + return !!currentSnapshot; + } + + var mtime = nextSnapshot && nextSnapshot.m && nextSnapshot.m.mtime; + return mtime > timestamp; + }; + + this._findMilestoneSnapshot(collection, id, shouldBreak, callback); +}; + +MemoryMilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function (collection, id, timestamp, callback) { + if (!this._isValidTimestamp(timestamp)) return process.nextTick(callback, new ShareDBError(4001, 'Invalid timestamp')); + + var shouldBreak = function (currentSnapshot) { + if (timestamp === null) { + return false; + } + + var mtime = currentSnapshot && currentSnapshot.m && currentSnapshot.m.mtime; + return mtime >= timestamp; + } + + this._findMilestoneSnapshot(collection, id, shouldBreak, function (error, snapshot) { + if (error) return process.nextTick(callback, error); + + var mtime = snapshot && snapshot.m && snapshot.m.mtime; + if (timestamp !== null && mtime < timestamp) { + snapshot = undefined; + } + + process.nextTick(callback, null, snapshot); + }); +}; + +MemoryMilestoneDB.prototype._findMilestoneSnapshot = function (collection, id, shouldBreak, callback) { + if (!collection) return process.nextTick(callback, new ShareDBError(4001, 'Missing collection')); + if (!id) return process.nextTick(callback, new ShareDBError(4001, 'Missing ID')); + + var milestoneSnapshots = this._getMilestoneSnapshotsSync(collection, id); + + var milestoneSnapshot; + for (var i = 0; i < milestoneSnapshots.length; i++) { + var nextMilestoneSnapshot = milestoneSnapshots[i]; + if (shouldBreak(milestoneSnapshot, nextMilestoneSnapshot)) { + break; + } else { + milestoneSnapshot = nextMilestoneSnapshot; + } + } + + process.nextTick(callback, null, milestoneSnapshot); +}; + MemoryMilestoneDB.prototype._getMilestoneSnapshotsSync = function (collection, id) { var collectionSnapshots = this._milestoneSnapshots[collection] || (this._milestoneSnapshots[collection] = {}); return collectionSnapshots[id] || (collectionSnapshots[id] = []); diff --git a/lib/milestone-db/no-op.js b/lib/milestone-db/no-op.js index 77204f7ec..82d66ba10 100644 --- a/lib/milestone-db/no-op.js +++ b/lib/milestone-db/no-op.js @@ -22,3 +22,13 @@ NoOpMilestoneDB.prototype.saveMilestoneSnapshot = function (collection, snapshot if (callback) return process.nextTick(callback, null); this.emit('save', collection, snapshot); }; + +NoOpMilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function (collection, id, timestamp, callback) { + var snapshot = undefined; + process.nextTick(callback, null, snapshot); +}; + +NoOpMilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function (collection, id, timestamp, callback) { + var snapshot = undefined; + process.nextTick(callback, null, snapshot); +}; diff --git a/lib/util.js b/lib/util.js index 8f3c48804..6ca346ffe 100644 --- a/lib/util.js +++ b/lib/util.js @@ -18,3 +18,7 @@ exports.isValidVersion = function (version) { if (version === null) return true; return exports.isInteger(version) && version >= 0; }; + +exports.isValidTimestamp = function (timestamp) { + return exports.isValidVersion(timestamp); +}; diff --git a/package.json b/package.json index 876447bef..1d85a22da 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "expect.js": "^0.3.1", "istanbul": "^0.4.2", "jshint": "^2.9.2", + "lolex": "^3.0.0", "mocha": "^5.2.0", "sinon": "^6.1.5" }, diff --git a/test/client/snapshot-timestamp-request.js b/test/client/snapshot-timestamp-request.js new file mode 100644 index 000000000..9c2aeaad2 --- /dev/null +++ b/test/client/snapshot-timestamp-request.js @@ -0,0 +1,514 @@ +var Backend = require('../../lib/backend'); +var expect = require('expect.js'); +var util = require('../util'); +var lolex = require('lolex'); +var MemoryDb = require('../../lib/db/memory'); +var MemoryMilestoneDb = require('../../lib/milestone-db/memory'); +var sinon = require('sinon'); + +describe('SnapshotTimestampRequest', function () { + var backend; + var clock; + var day0 = new Date(2017, 11, 31).getTime(); + var day1 = new Date(2018, 0, 1).getTime(); + var day2 = new Date(2018, 0, 2).getTime(); + var day3 = new Date(2018, 0, 3).getTime(); + var day4 = new Date(2018, 0, 4).getTime(); + var day5 = new Date(2018, 0, 5).getTime(); + var ONE_DAY = 1000 * 60 * 60 * 24; + + beforeEach(function () { + clock = lolex.install({ now: day1 }); + backend = new Backend(); + }); + + afterEach(function (done) { + clock.uninstall(); + backend.close(done); + }); + + describe('a document with some simple versions separated by a day', function () { + var v0 = { + id: 'time-machine', + v: 0, + type: null, + data: undefined, + m: null + }; + + var v1 = { + id: 'time-machine', + v: 1, + type: 'http://sharejs.org/types/JSONv0', + data: { + title: 'The Time Machine' + }, + m: null + }; + + var v2 = { + id: 'time-machine', + v: 2, + type: 'http://sharejs.org/types/JSONv0', + data: { + title: 'The Time Machine', + author: 'HG Wells' + }, + m: null + }; + + var v3 = { + id: 'time-machine', + v: 3, + type: 'http://sharejs.org/types/JSONv0', + data: { + title: 'The Time Machine', + author: 'H.G. Wells' + }, + m: null + }; + + beforeEach(function (done) { + var doc = backend.connect().get('books', 'time-machine'); + util.callInSeries([ + function (next) { + doc.create({ title: 'The Time Machine' }, next); + }, + function (next) { + clock.tick(ONE_DAY); + doc.submitOp({ p: ['author'], oi: 'HG Wells' }, next); + }, + function (next) { + clock.tick(ONE_DAY); + doc.submitOp({ p: ['author'], od: 'HG Wells', oi: 'H.G. Wells' }, next); + }, + done + ]); + }); + + it('fetches the version at exactly day 1', function (done) { + util.callInSeries([ + function (next) { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day1, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(v1); + next(); + }, + done + ]); + }); + + it('fetches the version at exactly day 2', function (done) { + util.callInSeries([ + function (next) { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day2, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(v2); + next(); + }, + done + ]); + }); + + it('fetches the version at exactly day 3', function (done) { + util.callInSeries([ + function (next) { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day3, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(v3); + next(); + }, + done + ]); + }); + + it('fetches the day 2 version when asking for a time halfway between days 2 and 3', function (done) { + var halfwayBetweenDays2and3 = (day2 + day3) * 0.5; + util.callInSeries([ + function (next) { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', halfwayBetweenDays2and3, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(v2); + next(); + }, + done + ]); + }); + + it('fetches the day 3 version when asking for a time after day 3', function (done) { + util.callInSeries([ + function (next) { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day4, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(v3); + next(); + }, + done + ]); + }); + + it('fetches the most recent version when not specifying a timestamp', function (done) { + util.callInSeries([ + function (next) { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(v3); + next(); + }, + done + ]); + }); + + it('fetches an empty snapshot if the timestamp is before the document creation', function (done) { + util.callInSeries([ + function (next) { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day0, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(v0); + next(); + }, + done + ]); + }); + + it('throws if the timestamp is undefined', function () { + var fetch = function () { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', undefined, function () {}); + }; + + expect(fetch).to.throwError(); + }); + + it('throws without a callback', function () { + var fetch = function () { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine'); + }; + + expect(fetch).to.throwError(); + }); + + it('throws if the timestamp is -1', function () { + var fetch = function () { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', -1, function () { }); + }; + + expect(fetch).to.throwError(); + }); + + it('errors if the timestamp is a string', function () { + var fetch = function () { + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', 'foo', function () { }); + } + + expect(fetch).to.throwError(); + }); + + it('returns an empty snapshot if trying to fetch a non-existent document', function (done) { + backend.connect().fetchSnapshotByTimestamp('books', 'does-not-exist', day1, function (error, snapshot) { + if (error) return done(error); + expect(snapshot).to.eql({ + id: 'does-not-exist', + v: 0, + type: null, + data: undefined, + m: null + }); + done(); + }); + }); + + it('starts pending, and finishes not pending', function (done) { + var connection = backend.connect(); + + connection.fetchSnapshotByTimestamp('books', 'time-machine', null, function (error, snapshot) { + expect(connection.hasPending()).to.be(false); + done(); + }); + + expect(connection.hasPending()).to.be(true); + }); + + it('deletes the request from the connection', function (done) { + var connection = backend.connect(); + + connection.fetchSnapshotByTimestamp('books', 'time-machine', function (error) { + if (error) return done(error); + expect(connection._snapshotRequests).to.eql({}); + done(); + }); + + expect(connection._snapshotRequests).to.not.eql({}); + }); + + it('emits a ready event when done', function (done) { + var connection = backend.connect(); + + connection.fetchSnapshotByTimestamp('books', 'time-machine', function (error) { + if (error) return done(error); + }); + + var snapshotRequest = connection._snapshotRequests[1]; + snapshotRequest.on('ready', done); + }); + + it('fires the connection.whenNothingPending', function (done) { + var connection = backend.connect(); + var snapshotFetched = false; + + connection.fetchSnapshotByTimestamp('books', 'time-machine', function (error) { + if (error) return done(error); + snapshotFetched = true; + }); + + connection.whenNothingPending(function () { + expect(snapshotFetched).to.be(true); + done(); + }); + }); + + it('can drop its connection and reconnect, and the callback is just called once', function (done) { + var connection = backend.connect(); + + // Here we hook into middleware to make sure that we get the following flow: + // - Connection established + // - Connection attempts to fetch a snapshot + // - Snapshot is about to be returned + // - Connection is dropped before the snapshot is returned + // - Connection is re-established + // - Connection re-requests the snapshot + // - This time the fetch operation is allowed to complete (because of the connectionInterrupted flag) + // - The done callback is called just once (if it's called twice, then mocha will complain) + var connectionInterrupted = false; + backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function (request, callback) { + if (!connectionInterrupted) { + connection.close(); + backend.connect(connection); + connectionInterrupted = true; + } + + callback(); + }); + + connection.fetchSnapshotByTimestamp('books', 'time-machine', done); + }); + + it('cannot send the same request twice over a connection', function (done) { + var connection = backend.connect(); + + // Here we hook into the middleware to make sure that we get the following flow: + // - Attempt to fetch a snapshot + // - The snapshot request is temporarily stored on the Connection + // - Snapshot is about to be returned (ie the request was already successfully sent) + // - We attempt to resend the request again + // - The done callback is call just once, because the second request does not get sent + // (if the done callback is called twice, then mocha will complain) + var hasResent = false; + backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, function (request, callback) { + if (!hasResent) { + connection._snapshotRequests[1]._onConnectionStateChanged(); + hasResent = true; + } + + callback(); + }); + + connection.fetchSnapshotByTimestamp('books', 'time-machine', done); + }); + + describe('readSnapshots middleware', function () { + it('triggers the middleware', function (done) { + backend.use(backend.MIDDLEWARE_ACTIONS.readSnapshots, + function (request) { + expect(request.snapshots[0]).to.eql(v3); + expect(request.snapshotType).to.be(backend.SNAPSHOT_TYPES.byTimestamp); + done(); + } + ); + + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day3, function () { }); + }); + + it('can have its snapshot manipulated in the middleware', function (done) { + backend.middleware[backend.MIDDLEWARE_ACTIONS.readSnapshots] = [ + function (request, callback) { + request.snapshots[0].data.title = 'Alice in Wonderland'; + callback(); + }, + ]; + + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', function (error, snapshot) { + if (error) return done(error); + expect(snapshot.data.title).to.be('Alice in Wonderland'); + done(); + }); + }); + + it('respects errors thrown in the middleware', function (done) { + backend.middleware[backend.MIDDLEWARE_ACTIONS.readSnapshots] = [ + function (request, callback) { + callback({ message: 'foo' }); + }, + ]; + + backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', day1, function (error, snapshot) { + expect(error.message).to.be('foo'); + done(); + }); + }); + }); + + describe('with a registered projection', function () { + beforeEach(function () { + backend.addProjection('bookTitles', 'books', { title: true }); + }); + + it('applies the projection to a snapshot', function (done) { + backend.connect().fetchSnapshotByTimestamp('bookTitles', 'time-machine', day2, function (error, snapshot) { + if (error) return done(error); + + expect(snapshot.data.title).to.be('The Time Machine'); + expect(snapshot.data.author).to.be(undefined); + done(); + }); + }); + }); + }); + + describe('milestone snapshots enabled for every other version', function () { + var milestoneDb; + var db; + var backendWithMilestones; + + beforeEach(function () { + var options = { interval: 2 }; + db = new MemoryDb(); + milestoneDb = new MemoryMilestoneDb(options); + backendWithMilestones = new Backend({ + db: db, + milestoneDb: milestoneDb + }); + }); + + afterEach(function (done) { + backendWithMilestones.close(done); + }); + + describe('a doc with some versions in the milestone database', function () { + beforeEach(function (done) { + clock.reset(); + + var doc = backendWithMilestones.connect().get('books', 'mocking-bird'); + + util.callInSeries([ + function (next) { + doc.create({ title: 'To Kill a Mocking Bird' }, next); + }, + function (next) { + clock.tick(ONE_DAY); + doc.submitOp({ p: ['author'], oi: 'Harper Lea' }, next); + }, + function (next) { + clock.tick(ONE_DAY); + doc.submitOp({ p: ['author'], od: 'Harper Lea', oi: 'Harper Lee' }, next); + }, + function (next) { + clock.tick(ONE_DAY); + doc.submitOp({ p: ['year'], oi: 1959 }, next); + }, + function (next) { + clock.tick(ONE_DAY); + doc.submitOp({ p: ['year'], od: 1959, oi: 1960 }, next); + }, + done + ]); + }); + + it('fetches a snapshot between two milestones using the milestones', function (done) { + sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime'); + sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime'); + sinon.spy(db, 'getOps'); + var halfwayBetweenDays3and4 = (day3 + day4) * 0.5; + + backendWithMilestones.connect() + .fetchSnapshotByTimestamp('books', 'mocking-bird', halfwayBetweenDays3and4, function(error, snapshot) { + if (error) return done(error); + + expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.be(true); + expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.be(true); + expect(db.getOps.calledWith('books', 'mocking-bird', 2, 4)).to.be(true); + + expect(snapshot.v).to.be(3); + expect(snapshot.data).to.eql({ title: 'To Kill a Mocking Bird', author: 'Harper Lee' }); + done(); + }); + }); + + it('fetches a snapshot that matches a milestone snapshot', function (done) { + sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime'); + sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime'); + + backendWithMilestones.connect() + .fetchSnapshotByTimestamp('books', 'mocking-bird', day2, function (error, snapshot) { + if (error) return done(error); + + expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.be(true); + expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.be(true); + + expect(snapshot.v).to.be(2); + expect(snapshot.data).to.eql({ title: 'To Kill a Mocking Bird', author: 'Harper Lea' }); + done(); + }); + }); + + it('fetches a snapshot before any milestones', function (done) { + sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime'); + sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime'); + sinon.spy(db, 'getOps'); + + backendWithMilestones.connect() + .fetchSnapshotByTimestamp('books', 'mocking-bird', day1, function (error, snapshot) { + if (error) return done(error); + + expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.be(true); + expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.be(true); + expect(db.getOps.calledWith('books', 'mocking-bird', 0, 2)).to.be(true); + + expect(snapshot.v).to.be(1); + expect(snapshot.data).to.eql({ title: 'To Kill a Mocking Bird' }); + done(); + }); + }); + + it('fetches a snapshot after any milestones', function (done) { + sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime'); + sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime'); + sinon.spy(db, 'getOps'); + + backendWithMilestones.connect() + .fetchSnapshotByTimestamp('books', 'mocking-bird', day5, function (error, snapshot) { + if (error) return done(error); + + expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.be(true); + expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.be(true); + expect(db.getOps.calledWith('books', 'mocking-bird', 4, null)).to.be(true); + + expect(snapshot.v).to.be(5); + expect(snapshot.data).to.eql({ + title: 'To Kill a Mocking Bird', + author: 'Harper Lee', + year: 1960 + }); + + done(); + }); + }); + }); + }); +}); diff --git a/test/client/snapshot-request.js b/test/client/snapshot-version-request.js similarity index 96% rename from test/client/snapshot-request.js rename to test/client/snapshot-version-request.js index e151a2edf..4b7101e56 100644 --- a/test/client/snapshot-request.js +++ b/test/client/snapshot-version-request.js @@ -5,7 +5,7 @@ var MemoryMilestoneDb = require('../../lib/milestone-db/memory'); var sinon = require('sinon'); var util = require('../util'); -describe('SnapshotRequest', function () { +describe('SnapshotVersionRequest', function () { var backend; beforeEach(function () { @@ -16,7 +16,7 @@ describe('SnapshotRequest', function () { backend.close(done); }); - describe('a document with some simple versions a day apart', function () { + describe('a document with some simple versions', function () { var v0 = { id: 'don-quixote', v: 0, @@ -106,7 +106,7 @@ describe('SnapshotRequest', function () { }; expect(fetch).to.throwError(); - }); + }); it('fetches the latest version when the optional version is not provided', function (done) { backend.connect().fetchSnapshot('books', 'don-quixote', function (error, snapshot) { @@ -399,19 +399,24 @@ describe('SnapshotRequest', function () { describe('milestone snapshots enabled for every other version', function () { var milestoneDb; var db; + var backendWithMilestones; beforeEach(function () { var options = { interval: 2 }; db = new MemoryDb(); milestoneDb = new MemoryMilestoneDb(options); - backend = new Backend({ + backendWithMilestones = new Backend({ db: db, milestoneDb: milestoneDb }); }); + afterEach(function (done) { + backendWithMilestones.close(done); + }); + it('fetches a snapshot using the milestone', function (done) { - var doc = backend.connect().get('books', 'mocking-bird'); + var doc = backendWithMilestones.connect().get('books', 'mocking-bird'); util.callInSeries([ function (next) { @@ -426,7 +431,7 @@ describe('SnapshotRequest', function () { function (next) { sinon.spy(milestoneDb, 'getMilestoneSnapshot'); sinon.spy(db, 'getOps'); - backend.connect().fetchSnapshot('books', 'mocking-bird', 3, next); + backendWithMilestones.connect().fetchSnapshot('books', 'mocking-bird', 3, next); }, function (snapshot, next) { expect(milestoneDb.getMilestoneSnapshot.calledOnce).to.be(true); diff --git a/test/db.js b/test/db.js index 396557f89..257763173 100644 --- a/test/db.js +++ b/test/db.js @@ -2,7 +2,6 @@ var async = require('async'); var expect = require('expect.js'); var Backend = require('../lib/backend'); var ot = require('../lib/ot'); -var Snapshot = require('../lib/snapshot'); module.exports = function(options) { var create = options.create; diff --git a/test/milestone-db.js b/test/milestone-db.js index 92195c71f..b2dea9690 100644 --- a/test/milestone-db.js +++ b/test/milestone-db.js @@ -43,6 +43,20 @@ describe('Base class', function () { db.saveMilestoneSnapshot('books', {}); }); + + it('calls back with an error when trying to get a snapshot before a time', function (done) { + db.getMilestoneSnapshotAtOrBeforeTime('books', '123', 1000, function (error) { + expect(error.code).to.be(5021); + done(); + }); + }); + + it('calls back with an error when trying to get a snapshot after a time', function (done) { + db.getMilestoneSnapshotAtOrAfterTime('books', '123', 1000, function (error) { + expect(error.code).to.be(5022); + done(); + }); + }); }); describe('NoOpMilestoneDB', function () { @@ -358,6 +372,228 @@ module.exports = function (options) { }); }); + describe('snapshots with timestamps', function () { + var snapshot1 = new Snapshot( + 'catcher-in-the-rye', + 1, + 'http://sharejs.org/types/JSONv0', + { + title: 'Catcher in the Rye' + }, + { + ctime: 1000, + mtime: 1000 + } + ); + + var snapshot2 = new Snapshot( + 'catcher-in-the-rye', + 2, + 'http://sharejs.org/types/JSONv0', + { + title: 'Catcher in the Rye', + author: 'JD Salinger' + }, + { + ctime: 1000, + mtime: 2000 + } + ); + + var snapshot3 = new Snapshot( + 'catcher-in-the-rye', + 3, + 'http://sharejs.org/types/JSONv0', + { + title: 'Catcher in the Rye', + author: 'J.D. Salinger' + }, + { + ctime: 1000, + mtime: 3000 + } + ); + + beforeEach(function (done) { + util.callInSeries([ + function (next) { + db.saveMilestoneSnapshot('books', snapshot1, next); + }, + function (next) { + db.saveMilestoneSnapshot('books', snapshot2, next); + }, + function (next) { + db.saveMilestoneSnapshot('books', snapshot3, next); + }, + done + ]); + }); + + describe('fetching a snapshot before or at a time', function () { + it('fetches a snapshot before a given time', function (done) { + util.callInSeries([ + function (next) { + db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', 2500, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(snapshot2); + next(); + }, + done + ]); + }); + + it('fetches a snapshot at an exact time', function (done) { + util.callInSeries([ + function (next) { + db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', 2000, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(snapshot2); + next(); + }, + done + ]); + }); + + it('fetches the first snapshot for a null timestamp', function (done) { + util.callInSeries([ + function (next) { + db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', null, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(snapshot1); + next(); + }, + done + ]); + }); + + it('returns an error for a string timestamp', function (done) { + db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', 'not-a-timestamp', function (error) { + expect(error.code).to.be(4001); + done(); + }); + }); + + it('returns an error for a negative timestamp', function (done) { + db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', -1, function (error) { + expect(error.code).to.be(4001); + done(); + }); + }); + + it('returns undefined if there are no snapshots before a time', function (done) { + util.callInSeries([ + function (next) { + db.getMilestoneSnapshotAtOrBeforeTime('books', 'catcher-in-the-rye', 0, next); + }, + function (snapshot, next) { + expect(snapshot).to.be(undefined); + next(); + }, + done + ]); + }); + + it('errors if no collection is provided', function (done) { + db.getMilestoneSnapshotAtOrBeforeTime(undefined, 'catcher-in-the-rye', 0, function (error) { + expect(error.code).to.be(4001); + done(); + }); + }); + + it('errors if no ID is provided', function (done) { + db.getMilestoneSnapshotAtOrBeforeTime('books', undefined, 0, function (error) { + expect(error.code).to.be(4001); + done(); + }); + }); + }); + + describe('fetching a snapshot after or at a time', function () { + it('fetches a snapshot after a given time', function (done) { + util.callInSeries([ + function (next) { + db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', 2500, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(snapshot3); + next(); + }, + done + ]); + }); + + it('fetches a snapshot at an exact time', function (done) { + util.callInSeries([ + function (next) { + db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', 2000, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(snapshot2); + next(); + }, + done + ]); + }); + + it('fetches the last snapshot for a null timestamp', function (done) { + util.callInSeries([ + function (next) { + db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', null, next); + }, + function (snapshot, next) { + expect(snapshot).to.eql(snapshot3); + next(); + }, + done + ]); + }); + + it('returns an error for a string timestamp', function (done) { + db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', 'not-a-timestamp', function (error) { + expect(error.code).to.be(4001); + done(); + }); + }); + + it('returns an error for a negative timestamp', function (done) { + db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', -1, function (error) { + expect(error.code).to.be(4001); + done(); + }); + }); + + it('returns undefined if there are no snapshots after a time', function (done) { + util.callInSeries([ + function (next) { + db.getMilestoneSnapshotAtOrAfterTime('books', 'catcher-in-the-rye', 4000, next); + }, + function (snapshot, next) { + expect(snapshot).to.be(undefined); + next(); + }, + done + ]); + }); + + it('errors if no collection is provided', function (done) { + db.getMilestoneSnapshotAtOrAfterTime(undefined, 'catcher-in-the-rye', 0, function (error) { + expect(error.code).to.be(4001); + done(); + }); + }); + + it('errors if no ID is provided', function (done) { + db.getMilestoneSnapshotAtOrAfterTime('books', undefined, 0, function (error) { + expect(error.code).to.be(4001); + done(); + }); + }); + }); + }); + describe('milestones enabled for every version', function () { beforeEach(function (done) { var options = { interval: 1 };