Efficient, consistent client-side apps with optimistic concurrency & JSON patch: part III — let's end this conflict!

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 to JSONPatch: 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 by name, but the server sorts them by id? No problem! Just add collectionSort: {default: 'id'} to your config. You can also set per-collection sorting by setting collectionName: '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!

You might also enjoy reading: