Merge pull request #1233 from strongloop/feature/conflict-resolution-api

Add conflict resolution API
This commit is contained in:
Miroslav Bajtoš 2015-03-24 12:08:25 +01:00
commit 4a8c3be8f4
2 changed files with 161 additions and 28 deletions

View File

@ -616,6 +616,13 @@ module.exports = function(Change) {
/** /**
* Resolve the conflict. * Resolve the conflict.
* *
* Set the source change's previous revision to the current revision of the
* (conflicting) target change. Since the changes are no longer conflicting
* and appear as if the source change was based on the target, they will be
* replicated normally as part of the next replicate() call.
*
* This is effectively resolving the conflict using the source version.
*
* @callback {Function} callback * @callback {Function} callback
* @param {Error} err * @param {Error} err
*/ */
@ -629,6 +636,74 @@ module.exports = function(Change) {
}); });
}; };
/**
* Resolve the conflict using the instance data in the source model.
*
* @callback {Function} callback
* @param {Error} err
*/
Conflict.prototype.resolveUsingSource = function(cb) {
this.resolve(function(err) {
// don't forward any cb arguments from resolve()
cb(err);
});
};
/**
* Resolve the conflict using the instance data in the target model.
*
* @callback {Function} callback
* @param {Error} err
*/
Conflict.prototype.resolveUsingTarget = function(cb) {
var conflict = this;
conflict.models(function(err, source, target) {
if (err) return done(err);
if (target === null) {
return conflict.SourceModel.deleteById(conflict.modelId, done);
}
var inst = new conflict.SourceModel(target);
inst.save(done);
});
function done(err) {
// don't forward any cb arguments from internal calls
cb(err);
}
};
/**
* Resolve the conflict using the supplied instance data.
*
* @param {Object} data The set of changes to apply on the model
* instance. Use `null` value to delete the source instance instead.
* @callback {Function} callback
* @param {Error} err
*/
Conflict.prototype.resolveManually = function(data, cb) {
var conflict = this;
if (!data) {
return conflict.SourceModel.deleteById(conflict.modelId, done);
}
conflict.models(function(err, source, target) {
if (err) return done(err);
var inst = source || new conflict.SourceModel(target);
inst.setAttributes(data);
inst.save(function(err) {
if (err) return done(err);
conflict.resolve(done);
});
});
function done(err) {
// don't forward any cb arguments from internal calls
cb(err);
}
};
/** /**
* Determine the conflict type. * Determine the conflict type.
* *

View File

@ -963,50 +963,70 @@ describe('Replication / Change APIs', function() {
], done); ], done);
}); });
it('handles conflict resolved using "ours"', function(done) { it('handles UPDATE conflict resolved using "ours"', function(done) {
testResolvedConflictIsHandledWithNoMoreConflicts( testUpdateConflictIsResolved(
function resolveUsingOurs(conflict, cb) { function resolveUsingOurs(conflict, cb) {
conflict.resolve(cb); conflict.resolveUsingSource(cb);
}, },
done); done);
}); });
it('handles conflict resolved using "theirs"', function(done) { it('handles UPDATE conflict resolved using "theirs"', function(done) {
testResolvedConflictIsHandledWithNoMoreConflicts( testUpdateConflictIsResolved(
function resolveUsingTheirs(conflict, cb) { function resolveUsingTheirs(conflict, cb) {
conflict.models(function(err, source, target) {
if (err) return cb(err);
// We sync ClientA->Server first // We sync ClientA->Server first
expect(conflict.SourceModel.modelName) expect(conflict.SourceModel.modelName)
.to.equal(ClientB.modelName); .to.equal(ClientB.modelName);
var m = new conflict.SourceModel(target); conflict.resolveUsingTarget(cb);
m.save(cb);
});
}, },
done); done);
}); });
it('handles conflict resolved manually', function(done) { it('handles UPDATE conflict resolved manually', function(done) {
testResolvedConflictIsHandledWithNoMoreConflicts( testUpdateConflictIsResolved(
function resolveManually(conflict, cb) { function resolveManually(conflict, cb) {
conflict.models(function(err, source, target) { conflict.resolveManually({ name: 'manual' }, cb);
if (err) return cb(err); },
var m = source || new conflict.SourceModel(target); done);
m.name = 'manual';
m.save(function(err) {
if (err) return cb(err);
conflict.resolve(function(err) {
if (err) return cb(err);
cb();
}); });
it('handles DELETE conflict resolved using "ours"', function(done) {
testDeleteConflictIsResolved(
function resolveUsingOurs(conflict, cb) {
conflict.resolveUsingSource(cb);
},
done);
}); });
it('handles DELETE conflict resolved using "theirs"', function(done) {
testDeleteConflictIsResolved(
function resolveUsingTheirs(conflict, cb) {
// We sync ClientA->Server first
expect(conflict.SourceModel.modelName)
.to.equal(ClientB.modelName);
conflict.resolveUsingTarget(cb);
},
done);
}); });
it('handles DELETE conflict resolved as manual delete', function(done) {
testDeleteConflictIsResolved(
function resolveManually(conflict, cb) {
conflict.resolveManually(null, cb);
},
done);
});
it('handles DELETE conflict resolved manually', function(done) {
testDeleteConflictIsResolved(
function resolveManually(conflict, cb) {
conflict.resolveManually({ name: 'manual' }, cb);
}, },
done); done);
}); });
}); });
function testResolvedConflictIsHandledWithNoMoreConflicts(resolver, cb) { function testUpdateConflictIsResolved(resolver, cb) {
async.series([ async.series([
// sync the new model to ClientB // sync the new model to ClientB
sync(ClientB, Server), sync(ClientB, Server),
@ -1041,6 +1061,44 @@ describe('Replication / Change APIs', function() {
], cb); ], cb);
} }
function testDeleteConflictIsResolved(resolver, cb) {
async.series([
// sync the new model to ClientB
sync(ClientB, Server),
verifyInstanceWasReplicated(ClientA, ClientB, sourceInstanceId),
// ClientA makes a change
function deleteInstanceOnClientA(next) {
ClientA.deleteById(sourceInstanceId, next);
},
sync(ClientA, Server),
// ClientB changes the same instance
updateClientB('b'),
function syncAndResolveConflict(next) {
replicate(ClientB, Server, function(err, conflicts, cps) {
if (err) return next(err);
expect(conflicts).to.have.length(1);
expect(conflicts[0].SourceModel.modelName)
.to.equal(ClientB.modelName);
debug('Resolving the conflict %j', conflicts[0]);
resolver(conflicts[0], next);
});
},
// repeat the last sync, it should pass now
sync(ClientB, Server),
// and sync back to ClientA too
sync(ClientA, Server),
verifyInstanceWasReplicated(ClientB, ClientA, sourceInstanceId)
], cb);
}
function updateClientB(name) { function updateClientB(name) {
return function updateInstanceB(next) { return function updateInstanceB(next) {
ClientB.findById(sourceInstanceId, function(err, instance) { ClientB.findById(sourceInstanceId, function(err, instance) {