Eons ago when our story first began, I told you how I needed to make a client app more consistent and efficient by implementing optimistic concurrency and JSON Patch in our model layer.
As I said before, in our app, we combine both of these forces for an efficiency and consistency one-two punch. As I worked through the integration and some other requirements, I realized that a third module that combined the previous two and added some sane defaults for conflict resolution would be really helpful, so I built one. It's called ampersand-model-optimistic-update-mixin. Say that five times fast, or just call it AMOU (pronounced "ammo").
What it does
Let's recall our good buddy Franco from last time, and suppose that his data is edited by two different people working from the same base version:
// The original that both edits are based on
{
"id": 1,
"name": "Franco Witherspoon",
"age": 32,
"lastModified": "Mon, 10 Nov 2014 14:32:08 GMT",
"createdBy": 1,
"car": {"id": 1, "make": "Honda", "model": "CRX", "modelYear": "2006"},
"pants": [{
"id": 1, "manufacturer": "Levis", "style": "501",
"size": "32", "color": "Indigo"
},
{
"id": 2, "manufacturer": "Bonobos", "style": "Washed Chino",
"size": "32", "color": "Jet Blue"
},
{
"id": 3, "manufacturer": "IZOD", "style": "Cotton Lounge",
"size": "32", "color": "Navy"
}]
}
// Edit #1 (the one that gets saved while #2 is still editing)
[
{op: "replace", path: "/name", value: "Francis Withings"},
{op: "replace", path: "/car/model", value: "CRX SiR"},
{op: "add", path: "/pants/-", value: {
manufacturer: "Alfani", style: "RED Slim-fit",
size: "32", color: "Grey Sharkskin"
}}
]
// Edit #2
[
{op: "replace", path: "/name", value: "Frank Withers"},
{op: "replace", path: "/car/model", value: "CRX SiR"},
{op: "remove", path: "/pants/2"},
{op: "add", path: "/pants/-, value: {
manufacturer: "Joe Boxer", style: "Fleece Pajama",
size: "32", color: "Red Plaid"
}}
]
AMOU combines the features of AOS (ampersand-optimistic-sync) and AMP (ampersand-model-patch-mixin) with its own special conflict-detection-and-resolution sauce. It does this by using AOS's version tracking and AMP's difference tracking to handle all ordinary situations, and then breaking out its Einstein-like problem solving skills when that rare–but deadly–sync:invalid-version
event arises. When AMOU receives a sync:invalid-version
, it will, by default, detect the differences between the current client and server states and trigger a sync:conflict
event with a payload something like this:
person._conflict = {
conflicts: [{
client: {op: "replace", path: "/name", value: "Frank Withers"},
server: {op: "replace", path: "/name", value: "Francis Withings"},
original: "Franco Witherspoon"
}],
serverState: {
"id": 1,
"name": "Franco Witherspoon", // <-- conflicting change
"age": 32,
"lastModified": "Wed, 12 Nov 2014 04:58:08 GMT",
"createdBy": 1,
"car": {
"id": 1, "make": "Honda", "model": "CRX SiR", // <-- matching change
"modelYear": "2006"
},
"pants": [{
"id": 1, "manufacturer": "Levis", "style": "501",
"size": "32", "color": "Indigo"
},
{
"id": 2, "manufacturer": "Bonobos", "style": "Washed Chino",
"size": "32", "color": "Jet Blue"
},
{
"id": 3, "manufacturer": "IZOD", "style": "Cotton Lounge",
"size": "32", "color": "Navy"
},
{
"id": 4, "manufacturer": "Alfani", "style": "RED Slim-fit",
"size": "32", "color": "Grey Sharkskin"
}]
},
resolved: [],
unsaved: [
{op: "replace", path: "/name", value: "Frank Withers", original: "Franco Witherspoon"},
{op: "remove", path: "/pants/2", original: {
"id": 3, "manufacturer": "IZOD", "style": "Cotton Lounge",
"size": "32", "color": "Navy"
}},
{op: "add", path: "/pants/-", value: {
manufacturer: "Joe Boxer", style: "Fleece Pajama", size: "32",
color: "Red Plaid"
}, original: null}
]
};
This event payload gives you enough information to programmatically resolve the conflict or present the user with a dialog to allow them to manually resolve the conflict. AMOU doesn't stop there, though. With one or two configuration tweaks, AMOU can resolve most conflicts for you and your users, making life easier still.
How to use it
The default functionality is very simple:
var AMOU = require('ampersand-model-optimistic-update-mixin');
var BaseModel = require('ampersand-model'); // OR require('backbone').Model;
module.exports = AMOU(BaseModel, {
props: {id: 'number', name: 'string', age: 'number'},
children: {car: Car},
collections: {pants: Pants}
});
If you need to adjust the config for AOS, AMP, or AMOU itself, you'll need to pass in an _optimisticUpdate
config object:
var AMOU = require('ampersand-model-optimistic-update-mixin');
var BaseModel = require('ampersand-model'); // OR require('backbone').Model;
module.exports = AMOU(BaseModel, {
props: {id: 'number', name: 'string', age: 'number'},
children: {car: Car},
collections: {pants: Pants},
_optimisticUpdate: {
patcher: {/* AMP config */},
optimistic: {/* AOS config */},
autoResolve: false,
JSONPatch: true,
ignoreProps: [],
collectionSort: {},
customCompare: {} // prop/child/collection: function (original, current) {} map where the func returns true when considered equal, false when not, or an array of operations to make original match current
}
});
Things really get interesting when you set the autoResolve
to true
or 'server'
. When you set autoResolve
to true
, AMOU resolves all non-conflicting changes. It then triggers a sync:conflict-autoResolved
event when all differences are not conflicting or a sync:conflict
event when there are conflicts. When you set autoResolve
to 'server'
, all conflicts are resolved in favor of the server's version and the sync:conflict-autoResolved
event triggered.
When autoResolve
is set to true, the sync:conflict
payload from above would be a little different, because the new pair of Alfani pants would automatically be added to the local pants collection and be recorded in the payload's resolved
array.
person._conflict = {
conflicts: [/* same as above */],
serverState: {/* same as above */},
resolved: [{op: "add", path: "/pants/-", value: {
manufacturer: "Alfani", style: "RED Slim-fit",
size: "32", color: "Grey Sharkskin"},
client: undefined, clientDiscarded: undefined
}],
unsaved: [/* same as above */]
}
If your business rules say that the server should always win in case of conflicts, as they did in my project, simply set autoResolve
to 'server'
and the server's version will be overwrite any conflicting local changes and a sync:conflict-autoResolved
event will fire with this payload:
person._conflict = {
conflicts: [/* none because we've resolved them in the server's favor */],
serverState: {/* same as above */},
resolved: [
{op: "replace", path: "/name", value: "Francis Withings",
client: "Frank Withers", clientDiscarded: true}
{op: "add", path: "/pants/-, value: {
manufacturer: "Alfani", style: "RED Slim-fit",
size: "32", color: "Grey Sharkskin"},
client: undefined, clientDiscarded: undefined}
],
unsaved: [
{op: "remove", path: "/pants/2", original: {
"id": 3, "manufacturer": "IZOD", "style": "Cotton Lounge",
"size": "32", "color": "Navy"
}},
{op: "add", path: "/pants/-", value: {
manufacturer: "Joe Boxer", style: "Fleece Pajama", size: "32",
color: "Red Plaid"
}, original: null}
]
}
In addition to autoResolve
there are a few other configuration directives that may be helpful to you.
JSONPatch
: if your server doesn't support JSON Patch, you can disable that feature and still get the optimistic concurrency and conflict resolution benefits by setting this directive toJSONPatch: false
ignoreProps
: does your server create, update, and send data that your client-side ignores? Add those props to this directive:ignoreProps: ['createdBy', 'lastModified']
collectionSort
: do you sort child collections byname
, but the server sorts them byid
? No problem! Just addcollectionSort: {default: 'id'}
to your config. You can also set per-collection sorting by settingcollectionName: 'property'
. This directive also accepts functions that conforms to the Javascript Array.prototype.sort API.customCompare
: does your server send you some really wacky data for one of your child models or props that needs a lot of massaging to determine what is different? This directive takes prop, child model, and child collection names with a function that accepts the original and current data and returns true to indicate that they are equivalent, false if not, or an array of operations to make original into current:function (original, current) {}
The final tool that AMOU gives developers is the reverseUnsaved
method. Taking the payload of either sync:conflict
or sync:conflict-autoResolved
, it will roll back all unsaved local changes in place. This allows you to automatically roll back unsaved changes, if that's what your business rules dictate or in response to user action. To do auto-rollback, simply subscribe it to the events in your initialize
method: this.listenTo(this, 'sync:conflict sync:conflict-autoResolved', this.reverseUnsaved)
.
So there you have it: three tools to help you make client-side JavaScript apps that use Ampersand or Backbone more consistent, efficient, AND user-friendly. I hope you have enjoyed reading this series as much as I have enjoyed writing it. Please feel free to give me a shout on Twitter, if you have any questions or just want to tell me how much fun writing JavaScript is. Ciao!
Want to learn even more stuff like this? How about some general goings-on to boot? Then sign up for our email list below!