diff --git a/docs.json b/docs.json index 2803f2db..caa0b189 100644 --- a/docs.json +++ b/docs.json @@ -14,6 +14,7 @@ "lib/models/model.js", "lib/models/role.js", "lib/models/user.js", + "lib/models/change.js", "docs/api-datasource.md", "docs/api-geopoint.md", "docs/api-model.md", diff --git a/lib/loopback.js b/lib/loopback.js index 724e9835..fbc01857 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -292,6 +292,7 @@ loopback.Role = require('./models/role').Role; loopback.RoleMapping = require('./models/role').RoleMapping; loopback.ACL = require('./models/acl').ACL; loopback.Scope = require('./models/acl').Scope; +loopback.Change = require('./models/change'); /*! * Automatically attach these models to dataSources diff --git a/lib/models/change.js b/lib/models/change.js new file mode 100644 index 00000000..199438cc --- /dev/null +++ b/lib/models/change.js @@ -0,0 +1,325 @@ +/** + * Module Dependencies. + */ + +var Model = require('../loopback').Model + , loopback = require('../loopback') + , crypto = require('crypto') + , CJSON = {stringify: require('canonical-json')} + , async = require('async') + , assert = require('assert'); + +/** + * Properties + */ + +var properties = { + id: {type: String, generated: true, id: true}, + rev: {type: String}, + prev: {type: String}, + checkpoint: {type: Number}, + modelName: {type: String}, + modelId: {type: String} +}; + +/** + * Options + */ + +var options = { + +}; + +/** + * Change list entry. + * + * @property id {String} Hash of the modelName and id + * @property rev {String} the current model revision + * @property prev {String} the previous model revision + * @property checkpoint {Number} the current checkpoint at time of the change + * @property modelName {String} the model name + * @property modelId {String} the model id + * + * @class + * @inherits {Model} + */ + +var Change = module.exports = Model.extend('Change', properties, options); + +/*! + * Constants + */ + +Change.UPDATE = 'update'; +Change.CREATE = 'create'; +Change.DELETE = 'delete'; +Change.UNKNOWN = 'unknown'; + +/*! + * Setup the extended model. + */ + +Change.setup = function() { + var Change = this; + + Change.getter.id = function() { + var hasModel = this.modelName && this.modelId; + if(!hasModel) return null; + + return Change.idForModel(this.modelName, this.modelId); + } +} +Change.setup(); + + +/** + * Track the recent change of the given modelIds. + * + * @param {String} modelName + * @param {Array} modelIds + * @callback {Function} callback + * @param {Error} err + * @param {Array} changes Changes that were tracked + */ + +Change.track = function(modelName, modelIds, callback) { + var tasks = []; + var Change = this; + + modelIds.forEach(function(id) { + tasks.push(function(cb) { + Change.findOrCreate(modelName, id, function(err, change) { + if(err) return Change.handleError(err, cb); + change.rectify(cb); + }); + }); + }); + async.parallel(tasks, callback); +} + +/** + * Get an identifier for a given model. + * + * @param {String} modelName + * @param {String} modelId + * @return {String} + */ + +Change.idForModel = function(modelName, modelId) { + return this.hash([modelName, modelId].join('-')); +} + +/** + * Find or create a change for the given model. + * + * @param {String} modelName + * @param {String} modelId + * @callback {Function} callback + * @param {Error} err + * @param {Change} change + * @end + */ + +Change.findOrCreate = function(modelName, modelId, callback) { + var id = this.idForModel(modelName, modelId); + var Change = this; + + this.findById(id, function(err, change) { + if(err) return callback(err); + if(change) { + callback(null, change); + } else { + var ch = new Change({ + id: id, + modelName: modelName, + modelId: modelId + }); + ch.save(callback); + } + }); +} + +/** + * Update (or create) the change with the current revision. + * + * @callback {Function} callback + * @param {Error} err + * @param {Change} change + */ + +Change.prototype.rectify = function(cb) { + var change = this; + this.prev = this.rev; + // get the current revision + this.currentRevision(function(err, rev) { + if(err) return Change.handleError(err, cb); + change.rev = rev; + change.save(cb); + }); +} + +/** + * Get a change's current revision based on current data. + * @callback {Function} callback + * @param {Error} err + * @param {String} rev The current revision + */ + +Change.prototype.currentRevision = function(cb) { + var model = this.getModelCtor(); + model.findById(this.modelId, function(err, inst) { + if(err) return Change.handleError(err, cb); + if(inst) { + cb(null, Change.revisionForInst(inst)); + } else { + cb(null, null); + } + }); +} + +/** + * Create a hash of the given `string` with the `options.hashAlgorithm`. + * **Default: `sha1`** + * + * @param {String} str The string to be hashed + * @return {String} The hashed string + */ + +Change.hash = function(str) { + return crypto + .createHash(Change.settings.hashAlgorithm || 'sha1') + .update(str) + .digest('hex'); +} + +/** + * Get the revision string for the given object + * @param {Object} inst The data to get the revision string for + * @return {String} The revision string + */ + +Change.revisionForInst = function(inst) { + return this.hash(CJSON.stringify(inst)); +} + +/** + * Get a change's type. Returns one of: + * + * - `Change.UPDATE` + * - `Change.CREATE` + * - `Change.DELETE` + * - `Change.UNKNOWN` + * + * @return {String} the type of change + */ + +Change.prototype.type = function() { + if(this.rev && this.prev) { + return Change.UPDATE; + } + if(this.rev && !this.prev) { + return Change.CREATE; + } + if(!this.rev && this.prev) { + return Change.DELETE; + } + return Change.UNKNOWN; +} + +/** + * Get the `Model` class for `change.modelName`. + * @return {Model} + */ + +Change.prototype.getModelCtor = function() { + // todo - not sure if this works with multiple data sources + return this.constructor.modelBuilder.models[this.modelName]; +} + +/** + * Compare two changes. + * @param {Change} change + * @return {Boolean} + */ + +Change.prototype.equals = function(change) { + return change.rev === this.rev; +} + +/** + * Determine if the change is based on the given change. + * @param {Change} change + * @return {Boolean} + */ + +Change.prototype.isBasedOn = function(change) { + return this.prev === change.rev; +} + +/** + * Determine the differences for a given model since a given checkpoint. + * + * The callback will contain an error or `result`. + * + * **result** + * + * ```js + * { + * deltas: Array, + * conflicts: Array + * } + * ``` + * + * **deltas** + * + * An array of changes that differ from `remoteChanges`. + * + * **conflicts** + * + * An array of changes that conflict with `remoteChanges`. + * + * @param {String} modelName + * @param {Number} since Compare changes after this checkpoint + * @param {Change[]} remoteChanges A set of changes to compare + * @callback {Function} callback + * @param {Error} err + * @param {Object} result See above. + */ + +Change.diff = function(modelName, since, remoteChanges, callback) { + var remoteChangeIndex = {}; + var modelIds = []; + remoteChanges.forEach(function(ch) { + modelIds.push(ch.modelId); + remoteChangeIndex[ch.modelId] = new Change(ch); + }); + + // normalize `since` + since = Number(since) || 0; + this.find({ + where: { + modelName: modelName, + modelId: {inq: modelIds}, + checkpoint: {gt: since} + } + }, function(err, localChanges) { + if(err) return callback(err); + var deltas = []; + var conflicts = []; + localChanges.forEach(function(localChange) { + var remoteChange = remoteChangeIndex[localChange.modelId]; + if(!localChange.equals(remoteChange)) { + if(remoteChange.isBasedOn(localChange)) { + deltas.push(remoteChange); + } else { + conflicts.push(localChange); + } + } + }); + + callback(null, { + deltas: deltas, + conflicts: conflicts + }); + }); +} diff --git a/package.json b/package.json index 90e1b38a..ef2b38a5 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "underscore.string": "~2.3.3", "underscore": "~1.5.2", "uid2": "0.0.3", - "async": "~0.2.9" + "async": "~0.2.9", + "canonical-json": "0.0.3" }, "peerDependencies": { "loopback-datasource-juggler": "~1.2.11" diff --git a/test/change.test.js b/test/change.test.js new file mode 100644 index 00000000..3abebd38 --- /dev/null +++ b/test/change.test.js @@ -0,0 +1,268 @@ +var Change; +var TestModel; + +describe('Change', function(){ + beforeEach(function() { + var memory = loopback.createDataSource({ + connector: loopback.Memory + }); + Change = loopback.Change.extend('change'); + Change.attachTo(memory); + + TestModel = loopback.Model.extend('chtest'); + this.modelName = TestModel.modelName; + TestModel.attachTo(memory); + }); + + beforeEach(function(done) { + var test = this; + test.data = { + foo: 'bar' + }; + TestModel.create(test.data, function(err, model) { + if(err) return done(err); + test.model = model; + test.modelId = model.id; + test.revisionForModel = Change.revisionForInst(model); + done(); + }); + }); + + describe('change.id', function () { + it('should be a hash of the modelName and modelId', function () { + var change = new Change({ + rev: 'abc', + modelName: 'foo', + modelId: 'bar' + }); + + var hash = Change.hash([change.modelName, change.modelId].join('-')); + + assert.equal(change.id, hash); + }); + }); + + describe('Change.track(modelName, modelIds, callback)', function () { + describe('using an existing untracked model', function () { + beforeEach(function(done) { + var test = this; + Change.track(this.modelName, [this.modelId], function(err, trakedChagnes) { + if(err) return done(err); + test.trakedChagnes = trakedChagnes; + done(); + }); + }); + + it('should create an entry', function () { + assert(Array.isArray(this.trakedChagnes)); + assert.equal(this.trakedChagnes[0].modelId, this.modelId); + }); + + it('should only create one change', function (done) { + Change.count(function(err, count) { + assert.equal(count, 1); + done(); + }); + }); + }); + }); + + describe('Change.findOrCreate(modelName, modelId, callback)', function () { + + describe('when a change doesnt exist', function () { + beforeEach(function(done) { + var test = this; + Change.findOrCreate(this.modelName, this.modelId, function(err, result) { + if(err) return done(err); + test.result = result; + done(); + }); + }); + + it('should create an entry', function (done) { + var test = this; + Change.findById(this.result.id, function(err, change) { + assert.equal(change.id, test.result.id); + done(); + }); + }); + }); + + describe('when a change does exist', function () { + beforeEach(function(done) { + var test = this; + Change.create({ + modelName: test.modelName, + modelId: test.modelId + }, function(err, change) { + test.existingChange = change; + done(); + }); + }); + + beforeEach(function(done) { + var test = this; + Change.findOrCreate(this.modelName, this.modelId, function(err, result) { + if(err) return done(err); + test.result = result; + done(); + }); + }); + + it('should find the entry', function (done) { + var test = this; + assert.equal(test.existingChange.id, test.result.id); + done(); + }); + }); + }); + + describe('change.rectify(callback)', function () { + it('should create a new change with the correct revision', function (done) { + var test = this; + var change = new Change({ + modelName: this.modelName, + modelId: this.modelId + }); + + change.rectify(function(err, ch) { + assert.equal(ch.rev, test.revisionForModel); + done(); + }); + }); + }); + + describe('change.currentRevision(callback)', function () { + it('should get the correct revision', function (done) { + var test = this; + var change = new Change({ + modelName: this.modelName, + modelId: this.modelId + }); + + change.currentRevision(function(err, rev) { + assert.equal(rev, test.revisionForModel); + done(); + }); + }); + }); + + describe('Change.hash(str)', function () { + // todo(ritch) test other hashing algorithms + it('should hash the given string', function () { + var str = 'foo'; + var hash = Change.hash(str); + assert(hash !== str); + assert(typeof hash === 'string'); + }); + }); + + describe('Change.revisionForInst(inst)', function () { + it('should return the same revision for the same data', function () { + var a = { + b: { + b: ['c', 'd'], + c: ['d', 'e'] + } + }; + var b = { + b: { + c: ['d', 'e'], + b: ['c', 'd'] + } + }; + + var aRev = Change.revisionForInst(a); + var bRev = Change.revisionForInst(b); + assert.equal(aRev, bRev); + }); + }); + + describe('Change.type()', function () { + it('CREATE', function () { + var change = new Change({ + rev: this.revisionForModel + }); + assert.equal(Change.CREATE, change.type()); + }); + it('UPDATE', function () { + var change = new Change({ + rev: this.revisionForModel, + prev: this.revisionForModel + }); + assert.equal(Change.UPDATE, change.type()); + }); + it('DELETE', function () { + var change = new Change({ + prev: this.revisionForModel + }); + assert.equal(Change.DELETE, change.type()); + }); + it('UNKNOWN', function () { + var change = new Change(); + assert.equal(Change.UNKNOWN, change.type()); + }); + }); + + describe('change.getModelCtor()', function () { + it('should get the correct model class', function () { + var change = new Change({ + modelName: this.modelName + }); + + assert.equal(change.getModelCtor(), TestModel); + }); + }); + + describe('change.equals(otherChange)', function () { + it('should return true when the change is equal', function () { + var change = new Change({ + rev: this.revisionForModel + }); + + var otherChange = new Change({ + rev: this.revisionForModel + }); + + assert.equal(change.equals(otherChange), true); + }); + }); + + describe('change.isBasedOn(otherChange)', function () { + it('should return true when the change is based on the other', function () { + var change = new Change({ + prev: this.revisionForModel + }); + + var otherChange = new Change({ + rev: this.revisionForModel + }); + + assert.equal(change.isBasedOn(otherChange), true); + }); + }); + + describe('Change.diff(modelName, since, remoteChanges, callback)', function () { + beforeEach(function(done) { + Change.create([ + {rev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1}, + {rev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1}, + {rev: 'bat', modelName: this.modelName, modelId: 11, checkpoint: 1}, + ], done); + }); + + it('should return delta and conflict lists', function (done) { + var remoteChanges = [ + {rev: 'foo2', prev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1}, + {rev: 'bar', prev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1}, + {rev: 'bat2', prev: 'bat0', modelName: this.modelName, modelId: 11, checkpoint: 1}, + ]; + + Change.diff(this.modelName, 0, remoteChanges, function(err, diff) { + assert.equal(diff.deltas.length, 1); + assert.equal(diff.conflicts.length, 1); + done(); + }); + }); + }); +});