Backbone.js automagic syncing of Collections and Models

The idea here is to periodically fetch() and keep the client and server Collections in sync in such a way that the consuming View(s) only get updated when a Model is added to or removed from a Collection, or the attributes of one of the Models in it change. What's done is:

  • Compare the existing Collection to the incoming set and remove every Model from the existing Collection that is not in the incoming set.
  • Compare the incoming set to the existing Collection and add every Model from the incoming set to the existing Collection that is not already there.
  • Compare each Model in the two sets and update the ones in the existing Collection to the ones in the incoming set that are different.

The first two steps compare the Model's resource_uris and the last part is done with SHA1 hashes.

window.Model = Backbone.Model.extend({
    urlRoot: BASE_API + 'model/',
    defaults: {
        foo: ''
    },
    initialize: function() {
        console.log('Model->initialize()', this);
        this.bind('change', function(model) {
            console.log('Model->change()', model);
        });
    }
});
window.ModelCollection = Backbone.Collection.extend({
    model: Model,
    url: BASE_API + 'model/',
    initialize: function() {
        console.log('ModelCollection->initialize()', this);
        this.bind('add', function(model) {
            console.log('ModelCollection->add()', model);
        });
        this.bind('remove', function(model) {
            console.log('ModelCollection->remove()', model);
        });
    }
});

window.Root = Backbone.Model.extend({
    urlRoot: BASE_API + 'root/',
    defaults: {
        models: new ModelCollection()
    },
    parse: function(data) {
        var attrs = data && data.objects && ( _.isArray( data.objects ) ? data.objects[ 0 ] : data.objects ) || data;
        var model = this;
        incoming_model_uris = _.map(attrs.models, function(model) {
            return model.resource_uri;
        });
        existing_model_uris = this.get('models').map(function(model) {
            return model.get('resource_uri');
        });
        _.each(existing_model_uris, function(uri) {
            if(incoming_model_uris.indexOf(uri) == -1) {
                model.get('models').remove(model.get('models').get(uri));
            }
        });
        _.each(incoming_model_uris, function(uri) {
            if(existing_model_uris.indexOf(uri) == -1) {
                model.get('models').add(_.detect( attrs.models, function(model) { return model.resource_uri == uri; }));
            }
        });
        _.each(attrs.models, function(model) {
            if(Sha1.hash(JSON.stringify(model)) != Sha1.hash(JSON.stringify(model.get('models').get(model.resource_uri)))) {
                model.get('models').get(model.resource_uri).set(model);
            }
        });         

        delete attrs.models;        

        return attrs;
    },
    initialize: function() {
        _.bindAll(this, 'parse');
        this.fetch();
    }
});

Update 3: looking at this afterwards, I'm not sure of the complete watertightness of the above. Perhaps the Backbone Poller project would be be a better approach.

Update 2: In order to avoid borking on an empty response, do:

var attrs = Backbone.Model.prototype.parse.apply(this, data);
if(!attrs) return;

Update: My Javascript-Fu is weak which made me not see the obvious. As suggested in Backbone.js documentation, you can call the parent's implementation like this:

Backbone.Model.prototype.method.apply(this, args);

So, instead of unnecessarily copypasting behavior from Backbone-tastypie.js, we can say:

var attrs = Backbone.Model.prototype.parse.apply(this, data);

…and still have Backbone-tastypie.js do it's parsing thing for us.

Ugh.

Tagged with:

Categorised as: