Efficient, consistent client-side apps with optimistic concurrency & JSON patch: part II

Today's entry: Building the Mixins!

This post is second in a three part series that I started with a little bit of background last week.

Building the optimistic concurrency mixin

Following the Human way, I made the optimistic concurrency mixin a CommonJS module and published it with npm. It's called ampersand-optimistic-sync, but we'll call it AOS here. AOS replaces the sync method on your Backbone or Ampersand models. Since sync is the core Ajax method, extending there allows AOS to read and write the versioning headers it needs.

AOS supports both the ETag/If-Match and Last-Modified/If-Unmodified-Since approaches for the version information with ETag being the default.

What it does

Regardless of HTTP verb (GET, POST, PUT, DELETE, PATCH), AOS interrogates the server's response for the configured version header (ETag or Last-Modified), and stores the header's value on the model in a _version property and triggers a sync:version event with the model and the version data as payload. Then on any updating requests (PUT or PATCH), AOS adds the appropriate request header with the _version data as its value.

When the server will respond with a 412 - Pre-Condition Failed error status due to an invalid version, AOS triggers a sync:invalid-version event with the model, new version, and any JSON response data. This event allows the developer to handle invalid-version scenarios as needed.

How to use it

Adding AOS's functionality to Ampersand models is as easy as:

var BaseModel = require('ampersand-model');
var AOS = require('ampersand-optimistic-sync');

module.exports = BaseModel.extend(AOS(BaseModel));

That's pretty easy, but maybe it doesn't give you exactly what you want. No worries though; you can do a bit of configuring:

var BaseModel = require('backbone').Model;
var AOS = require('ampersand-optimistic-sync');

module.exports = BaseModel.extend(AOS(BaseModel, {
    // [default], 'last-modified' is also supported
    type: 'etag',
    // pre-define a handler for the sync:invalid-version event 
    invalidHandler: function (model, version, response) {
        // make the most of a bad situation
    },
    // pre-define default sync options
    options: {
        all: {
            // any options you'd like to set for all requests
        },
        // you can also set options for particular methods
        create: {
            success: function (data) {
                // do stuff with data
            }
        },
        read: {},
        update: {
            patch: true
        },
        delete: {},
    }
}
}));

Now that I had a way to track versions and set a handler for invalid version events, it was time to work on the JSON Patch implementation.

Building the JSON Patch mixin

Once again, I created a CommonJS module and published it with npm as ampersand-model-patch-mixin. Rather than keep saying that mouthful, I'll refer to it as AMP for the rest of the post.

What it does

  1. It keeps track of the last known server state for the model, so it can calculate differences.
  2. It generates JSON Patch operations as model data changes.
  3. It generates op-count events to let devs know how many it has tracked.
  4. It enables setting an autoSave test that will trigger a save based on the op-count event.

How to use it

The basics are simple. Given a pretty standard Ampersand model with a child model and a child collection, we would do the following:

var BaseModel = require('backbone').Model;
var Car = require('./car');
var Pants = require('../collections/pants');
var AMP = require('ampersand-model-patch-mixin');

// A simple person model
module.exports = BaseModel.extend(AMP(BaseModel, {
    props: {id: 'number', name: 'string', age: 'number'},
    children: {car: Car},
    collections: {pants: Pants}
}));

Now let's assume that we fetch the record for Person 1 and make some changes:

var Person = require('../models/person');
var person = new Person({id: 1});

person.fetch();
// Server's response
{
    "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"   
    }]
}
// AMP stores the response data at the property specified by its originalProperty config directive (default: _original).

// Identity is so fluid these days!
person.name = "Frank Withers";

// Let's be specific.
person.car.model += " SiR";

// Frank loved those IZOD's, but their time had come.
person.pants.remove(person.pants.at(2))

// Gotta have PJ pants.
person.pants.add({
    manufacturer: "Joe Boxer", style: "Fleece Pajama",
    size: "32", color: "Blue Plaid"
})

// Wait! Not blue, red.
person.pants.at(2).color = "Red Plaid";

console.log(person._ops.length)
// => 4

person.save();

// Sends the following to the server with Content-Type: application/json+patch
[
    {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"
    }}
]

Note that there are only four operation objects despite our having made five changes. AMP collapses changes to new models—ones that already have an add operation—into the add operation because their path is unknown. In order to do this, AMP stores the model's cid in its internal _ops array. This also allows child collections to have a different sort order than the server's.

"What about this auto-save business you mentioned earlier?" you ask. Let's talk about that. You can set up auto-saving when AMP reaches five operations like this:

module.exports = BaseModel.extend(AMP(BaseModel, {
    _patcherConfig: {
        autoSave: 5
    },
    props: {id: 'number', name: 'string', age: 'number'},
    children: {car: Car},
    collections: {pants: Pants}
}));

If you need more control, you can also set autoSave to a function that returns truthily when you want to save:

module.exports = BaseModel.extend(AMP(BaseModel, {
    _patcherConfig: {
        autoSave: function (model, opCount) {
            // Returns true if 5 total
            if (opCount >= 5) return true;
            var roots = [];
            // Returns true if more than three different root paths in ops
            this._ops.forEach(function (op) {
                var root = op.path.slice(1).split('/').shift();
                if (roots.indexOf(root) === -1) roots.push(root);
            });
            if (roots.length >= 3) return true;
            return false;
        }
    },
    props: {id: 'number', name: 'string', age: 'number'},
    children: {car: Car},
    collections: {pants: Pants}
}));

The End or a Hint of Things to Come?

As I said before, in our app we need to combine both of these forces for a efficiency and consistency one-two punch. But 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, and it's a powerhouse, but to hear its story you'll have to wait for the next shot: "Part Three: Let's End This Conflict!"

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: