‹prev |
My Chain |
next›
To date, I have enjoyed decent success replacing the persistence layer in my
Backbone.js calendar application. With only a few hiccups, I have replaced the normal REST-based persistence layer with
faye pub-sub channels. The hope is that this will make it easier to respond to asynchronous change from other sources.
So far, I am able to fetch and populate a calendar appointment collection. I can also create new appointments. Now, I really need to get deleting appointments working:
Thanks to replacing
Backbone.sync()
, in my Backbone application, I already have delete requests being sent to the
/caledars/delete
faye channel:
var faye = new Faye.Client('/faye');
Backbone.sync = function(method, model, options) {
faye.publish("/calendars/" + method, model);
}
Thanks to simple client logging:
_(['create', 'update', 'delete', 'read', 'changes']).each(function(method) {
faye.subscribe('/calendars/' + method, function(message) {
console.log('[/calendars/' + method + ']');
console.log(message);
});
});
...I can already see that, indeed, deletes are being published as expected:
To actually delete things from my
CouchDB backend, I have to subscribe to the
/calendars/delete
faye channel in my
express.js server. I already have this working for adding appointments, so I can adapt the overall structure of the
/calendars/add
listener for
/calendars/delete
:
client.subscribe('/calendars/delete', function(message) {
// HTTP request options
var options = {...};
// The request object
var req = http.request(options, function(response) {...});
// Rudimentary connection error handling
req.on('error', function(e) {...});
// Send the request
req.end();
});
The HTTP options for the DELETE request are standard
node.js HTTP request parameters:
// HTTP request options
var options = {
method: 'DELETE',
host: 'localhost',
port: 5984,
path: '/calendar/' + message._id,
headers: {
'content-type': 'application/json',
'if-match': message._rev
}
};
Experience has taught me that I need to send the CouchDB revisions along with operations on existing records. The
if-match
HTTP header ought to work. The
_rev
attribute on the record/message sent to
/calendars/delete
holds the latest revision that the client holds. Similarly, I can delete the correct object from CouchDB by specifying the object ID in the
path
attribute—the ID coming from the
_id
attribute of the record/message.
That should be sufficient to delete the record from CouchDB. To tell my Backbone app to remove the deleted element from the UI, I need to send a message back on a separate faye channel. The convention that I have been following is to send requests to the server on a channel named after a CRUD operation and to send responses back on a channel named after the Backbone collection method to be used. In this case, I want to send back the deleted record on the
/calendars/remove
channel.
To achieve this, I do the normal node.js thing of passing an accumulator callback to
http.request()
. This accumulator callback accumulates chunks of the reply into a local
data
variable. When all of the data has been received, the response is parsed as JSON (of course it's JSON—this is a CouchDB data store), and the JSON object is sent back to the client:
var req = http.request(options, function(response) {
console.log("Got response: %s %s:%d%s", response.statusCode, options.host, options.port, options.path);
// Accumulate the response and publish when done
var data = '';
response.on('data', function(chunk) { data += chunk; });
response.on('end', function() {
var couch_response = JSON.parse(data);
console.log(couch_response)
client.publish('/calendars/remove', couch_response);
});
});
Before hooking a Backbone subscription to this
/calendars/remove
channel, I supply a simple logging tracer bullet:
faye.subscribe('/calendars/remove', function(message) {
console.log('[/calendars/remove]');
console.log(message);
});
So, if I have done this correctly, clicking the delete icon in the calendar UI should send a message on the
/calendars/delete
channel (which we have already seen working above). The faye subscription on the server should be able to use this to remove the object from the CouchDB database. Finally, this should result in another message being broadcast on the
/calendars/remove
channel. This
/calendars/remove
message should be logged in the browser. So let's see what actually happens...
Holy cow! That actually worked.
If I reload the web page, I see one less fake appointment on my calendar. Of course, I would like to have the appointment removed immediately. Could it be as simple as sending that JSON message as an argument to the
remove()
method of the collection?
faye.subscribe('/calendars/remove', function(message) {
console.log('[/calendars/remove]');
console.log(message);
calendar.appointments.remove(message);
});
Well, no. It is not that simple. That has no effect on the page. But wait...
After digging through the Backbone code a bit, it ought to work. The
remove()
method looks up models to be deleted via
get()
:
_remove : function(model, options) {
options || (options = {});
model = this.getByCid(model) || this.get(model);
// ...
}
The
get()
method looks up models by the
id
attribute:
// Get a model from the set by id.
get : function(id) {
if (id == null) return null;
return this._byId[id.id != null ? id.id : id];
},
The
id
attribute was set on the message/record received on the
/calendars/remove
channel:
So why is the UI not being updated by removing the appointment from the calendar?
After a little more digging, I realize that it is being removed from the collection, but the necessary events are not being generated to trigger the Backbone view to remove itself. The events that the view currently subscribes to are:
var Appointment = Backbone.View.extend({
initialize: function(options) {
// ....
options.model.bind('destroy', this.remove, this);
options.model.bind('error', this.deleteError, this);
options.model.bind('change', this.render, this);
},
// ...
});
It turns out that the
destroy
event is only emitted if the default
Backbone.sync
is in place. If the XHR DELETE is successful, a
success
callback is fired that emits
destroy
. Since I have replaced
Backbone.sync
, that event never fires.
What
does fire is the
remove
event. So all I need to change in order to make this work is to replace
destroy
with
remove
:
var Appointment = Backbone.View.extend({
initialize: function(options) {
// ....
options.model.bind('remove', this.remove, this);
options.model.bind('error', this.deleteError, this);
options.model.bind('change', this.render, this);
},
// ...
});
And it works! I can now remove all of those fake appointments:
Ah. Much better.
It took a bit of detective work, but, in the end, not much really needed to change.
I really need to cut back on my chain posts so that I can focus on writing
Recipes with Backbone. So I sincerely hope that lessons learned tonight will make updates easier tomorrow. Fingers crossed.
Day #147