wheelhouse-couch
Version:
A wheelhouse library to use couchdb as a database while useing backbone on ther server.
339 lines (294 loc) • 11.3 kB
JavaScript
;
var Backbone = require('backbone')
, cradle = require('cradle')
, _ = require('lodash')
, internals = {}
, app
internals.uuids = []
// preps model for db
internals.toJSON = function toJSON(model){
var doc = model.toJSON()
doc._id = model.id
return doc
}
// Get a list of 100 UUIDs from couch
internals.getUUIDList = function getUUIDList(callback){
var count = 100
internals.connection.uuids(count, function(err, res){
if (err) app.log.error('db: uuids:', err)
// try again in case things fail
if (!res || res.length !== count){
app.log.warn('db: uuids: returned a null response. Retrying.')
setImmediate(function(){
internals.getUUIDList(callback)
})
return
}
callback(res)
})
}
// get a single UUId
internals.getUUID = function getUUID(callback){
if (!_.isFunction(callback)) throw new Error('Must provide getUUID with a callback(uuid)')
// return from a cached list of UUIDs
if (internals.uuids.length > 1) return callback(internals.uuids.pop())
// if that list is empty, get some new UUIDs
internals.getUUIDList(function(uuids){
// cache the uuids for future use
internals.uuids = uuids
callback(internals.uuids.pop())
})
}
// Backbone sync method.
internals.sync = function sync(method, model, options){
var error
, success
options = options ? _.clone(options) : {}
// ensure we have error and success callbacks
error = options.error || function(){}
success = options.success || function(){}
if (!internals.dbExists){
app.log.error('db: sync:', 'Tried to interact with db before it could be created.')
return
}
switch (method){
case 'read':
if (model.id){
internals.db.get(internals.options.getId(model), function(err, doc){
if (err){
app.log.error('db: read:', {model: model.id, err: err})
return error(err)
}
success(doc)
})
}
else {
// without an id, the model is actually a collection
internals.db.view('backbone/collection', {key: internals.options.getCollectionName(model)}, function(err, res){
if (err){
app.log.error('db: read:', {collection: internals.options.getCollectionName(model), err: err})
return error(err)
}
success(_.reduce(res.rows, function(models, row){
models.push(row.value)
return models
}, []))
})
}
break
case 'create': case 'update':
if (model.get('_rev')){
// This is an update.
// Ensure that partial updates work by retrieving the model and merging its attributes.
// also helps to prevent document update conflicts
internals.db.get(model.id, function(err, doc){
var dbUpdateCb = function syncUpdateCb(err, res){
if (err){
if (err.error === 'conflict'){
app.log.warn('db: update: document update conflict; retrying', {model: model.id, err: err})
return internals.sync('update', model, options)
}
app.log.error('db: update:', {model: model.id, err: err})
return error(err)
}
if (!res || !res.ok){
app.log.warn('db: update: db returned a null response, trying to save again.')
return internals.sync('update', model, options)
}
app.log.info('db: update:', res)
success({
_rev: res.rev
, updatedAt: newModel.updatedAt
})
// resume the feed, do it on nextTick so that we're sure backbone has time to process
setImmediate(function(){
internals.feed.resume()
})
}
, newModel
if (err) {
app.log.error('db: update: get error', {model: model.id, err: err})
return error(err)
}
else {
// overwrite what's in the db with our new info
// ensure we're using the latest rev from the db
newModel = _.extend({}, doc, internals.toJSON(model), {_rev: doc._rev, updatedAt: new Date().toString()})
if (newModel.createdAt !== doc.createdAt) {
app.log.warn('db: update: createdAt modification rejected', {id: model.id})
model.set({createdAt: doc.createdAt})
return error('createdAt can\'t be modified.')
}
if (newModel.isDeleted !== doc.isDeleted && options.isDelete !== true) {
app.log.warn('db: update: isDeleted modification rejected', {id: model.id})
model.set({isDeleted: doc.isDeleted})
return error('models can only be marked deleted by the DELETE method')
}
// pause the feed so that we can create a backbone model before the feed tries to parse it.
internals.feed.pause()
internals.db.save(model.id, doc._rev, newModel, dbUpdateCb)
}
})
}
else {
// This is a create.
internals.getUUID(function(uuid){
var id = internals.options.getId(model) + '/' + uuid
// this is the one time we don't want to ensure there's an id attribute
, date = new Date().toString()
, newModel = _.extend(model.toJSON(), {createdAt: date, updatedAt: date})
, dbCreateCb = function syncCreateCb(err, res){
if (err){
app.log.error('db: create:', {model: id, err: err, input: newModel})
return error(err)
}
if (!res || !res.ok){
app.log.warn('db: create: db returned a null response, trying to save again.')
return internals.sync('create', model, options)
}
app.log.info('db: create:', res)
success({_rev: res.rev
, _id: res.id
, createdAt: newModel.createdAt
, updatedAt: newModel.updatedAt
})
// resume the feed, do it on nextTick so that we're sure backbone has time to process
setImmediate(function(){
internals.feed.resume()
})
}
if (!_.isUndefined(newModel.isDeleted)) {
app.log.warn('db: create: isDeleted attr rejected', {id: model.id})
model.set({isDeleted: void 0})
return error('models can not be created in a deleted state')
}
// pause the feed so that we can create a backbone model before the feed tries to parse it.
internals.feed.pause()
internals.db.save(id, newModel, dbCreateCb)
})
}
break
case 'delete':
// We never actually want to remove something from the DB, instead, we'll just mark it as deleted with an attribute
model.set({isDeleted: true}, {silent: true})
// a delete therefore, is just an update, but with an attribute added
options.isDelete = true
internals.sync('update', model, options)
break
}
}
internals.feedSetup = function feedSetup(){
internals.feed = internals.db.changes({
filter: function feedSetupFilter(doc){
if (doc._id.indexOf('/') > 0 && doc._id.charAt(0) !== '_') return true
else return false
}
, since: 'now'
})
internals.feed.on('change', function feedOnChange(change){
var name = change.doc._id.replace(/(.*?)\/[A-z0-9]*$/, '$1')
, model
// bail if we don't know about this collection
if (!app.collections || !app.collections[name]) {
app.log.debug('db: changes: ignoring: no collection found', {name: name, id: change.doc._id})
return
}
// do this on nextTick to ensure backbone has time to process
process.nextTick(function feedOnChangeNextTick(){
model = app.collections[name].get(change.doc._id)
// detect a faux delete or a real delete
if (change.deleted && model || (model && change.doc.isDeleted)){
app.log.debug('db: changes: removing:', {id: change.doc._id})
app.collections[name].remove(change.doc._id, {notOriginal: true})
}
// if this is the first rev, and we don't have the model, add it
else if (change.doc._rev.charAt(0) === '1' && !model){
// probably no need to merge, but we'll do it just to be sure that our model gets added.
app.log.debug('db: changes: adding:', change.doc._id)
app.collections[name].add(change.doc, {merge: true, notOriginal: true})
}
else if (model && model.get('_rev') !== change.doc._rev){
app.log.debug('db: changes: updating:', change.doc._id)
// clear so that attributes that are deleted from the doc are removed from the model
model.clear({silent: true})
model.set(change.doc, {notOriginal: true})
}
else {
// we're probably at app start, and haven't populated our collecitons yet, or this change has already happened on this server. just bail
app.log.debug('db: changes: ignoring:', change.doc._id)
}
})
})
internals.feed.on('error', function feedOnError(err){
app.log.error('db: changes:', err)
})
}
internals.install = function install(callback){
callback || (callback = function(){})
internals.db.create(function(err){
if (err){
app.log.error('db: install:', err)
callback(new Error(err.reason))
}
internals.db.save('_design/backbone', {
views: {
collection: {
map: function(doc){
/*global emit */
if (!doc.isDeleted && doc._id.indexOf('/') > -1) emit(doc._id.split('/')[0], doc)
}
}
}
}, function(err){
if (err){
app.log.error('db: install:', err)
callback(new Error(err.reason))
}
internals.feedSetup()
internals.dbExists = true
callback()
})
})
}
// expose private methods for testing
exports.internals = internals
exports.name = 'wheelhouse-couch'
exports.attach = function(opts){
internals.options = _.defaults(opts || {}, {
host: 'localhost'
, port: 5984
, name: ''
, retries: 3
, retryTimeout: 30 * 1000
, cache: false
, raw: false
, getId: function getId(model){
return _.result(model, 'url').substring(1)
}
, getCollectionName: function getCollectionName(collection){
return _.result(collection, 'url').substring(1)
}
})
app = this
internals.connection = new(cradle.Connection)(internals.options)
internals.db = internals.connection.database(internals.options.name)
app.db = internals.db
Backbone.sync = internals.sync
app.Backbone = Backbone
}
exports.init = function(done){
internals.db.exists(function dbExistsCallback(err, exists){
if (err){
app.log.error('db: install:', err)
return done(err)
}
if (exists){
app.log.info('db: config:', 'Connected to ' + internals.options.name)
internals.dbExists = true
internals.feedSetup()
return done()
}
app.log.info('db: install:', 'CouchDB database ' + internals.options.name + ' did not exist; creating.')
internals.install(done)
})
}