Add Change model
This commit is contained in:
parent
7230890aa9
commit
4be3395298
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue