├── .gitignore ├── test ├── helpers │ ├── macros.js │ └── seed.js ├── fixtures │ └── databases.json ├── response-test.js ├── cache-test.js ├── database-view-test.js ├── database-cache-test.js ├── connection-test.js ├── database-test.js └── database-attachment-test.js ├── package.json ├── LICENSE ├── lib ├── cradle │ ├── database │ │ ├── changes.js │ │ ├── index.js │ │ ├── attachments.js │ │ ├── views.js │ │ └── documents.js │ ├── cache.js │ └── response.js └── cradle.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | test/fixtures/README.md 4 | test/fixtures/not-found.txt -------------------------------------------------------------------------------- /test/helpers/macros.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'), 2 | cradle = require('../../'); 3 | 4 | var macros = exports; 5 | 6 | macros.database = function (options, tests) { 7 | if (arguments.length === 1) { 8 | tests = options; 9 | options = { cache: false }; 10 | } 11 | 12 | tests.topic = function () { 13 | return new(cradle.Connection)('127.0.0.1', 5984, options).database('pigs'); 14 | }; 15 | return { 16 | 'A `cradle.Connection`': tests 17 | }; 18 | }; 19 | 20 | macros.status = function (code) { 21 | return function (e, res, body) { 22 | assert.ok(res || e); 23 | assert.equal((res || e).headers.status || (res || e).statusCode, code); 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cradle", 3 | "version": "0.6.3", 4 | "description": "the high-level, caching, CouchDB library", 5 | "url": "http://cloudhead.io/cradle", 6 | "keywords": ["couchdb", "database", "couch"], 7 | "author": "Alexis Sellier ", 8 | "contributors": [ 9 | { "name": "Charlie Robbins", "email": "charlie@nodejitsu.com" }, 10 | { "name": "Maciej Malecki", "email": "maciej@nodejitsu.com" } 11 | ], 12 | "main": "./lib/cradle", 13 | "dependencies": { 14 | "follow": "0.7.x", 15 | "request": "2.x.x", 16 | "vargs": "0.1.0" 17 | }, 18 | "devDependencies": { 19 | "async": "~0.1.x", 20 | "vows": "0.6.x" 21 | }, 22 | "scripts": { 23 | "test": "node test/helpers/seed.js && vows --spec" 24 | }, 25 | "engines": { 26 | "node": ">=0.6.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/fixtures/databases.json: -------------------------------------------------------------------------------- 1 | { 2 | "rabbits": [ 3 | { 4 | "_id": "alex", 5 | "color": "blue" 6 | } 7 | ], 8 | "pigs": [ 9 | { 10 | "_id": "_design/pigs", 11 | "views": { 12 | "all": { "map": "function (doc) { if (doc.color) emit(doc._id, doc) }" } 13 | } 14 | }, 15 | { 16 | "_id": "mike", 17 | "color": "pink" 18 | }, 19 | { 20 | "_id": "bill", 21 | "color": "blue" 22 | }, 23 | { 24 | "_id": "alex", 25 | "color": "red" 26 | }, 27 | { 28 | "_id": "deleteme" 29 | } 30 | ], 31 | "animals/snorlax": [ 32 | { 33 | "_id": "_design/pigs", 34 | "views": { 35 | "all": { "map": "function (doc) { if (doc.color) emit(doc._id, doc) }" } 36 | } 37 | }, 38 | { 39 | "_id": "mike", 40 | "color": "pink" 41 | }, 42 | { 43 | "_id": "bill", 44 | "color": "blue" 45 | }, 46 | { 47 | "_id": "deleteme" 48 | } 49 | ], 50 | "badgers": null, 51 | "madeup/ewoks": null 52 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 cloudhead 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/cradle/database/changes.js: -------------------------------------------------------------------------------- 1 | var events = require('events'), 2 | querystring = require('querystring'), 3 | Args = require('vargs').Constructor, 4 | follow = require('follow'), 5 | cradle = require('../../cradle'), 6 | Database = require('./index').Database; 7 | 8 | Database.prototype.changes = function (options, callback) { 9 | if (typeof(options) === 'function') { 10 | callback = options; 11 | options = {}; 12 | } 13 | 14 | options = options || {}; 15 | 16 | if (callback) { 17 | return this.query({ 18 | method: 'GET', 19 | path: '_changes', 20 | query: options 21 | }, callback); 22 | } 23 | 24 | var response = new events.EventEmitter(), 25 | responded = false, 26 | auth = '', 27 | feed; 28 | 29 | if (!options.db) { 30 | if (this.connection.auth && this.connection.auth.username 31 | && this.connection.auth.password) { 32 | auth = this.connection.auth.username + ':' + this.connection.auth.password + '@'; 33 | } 34 | 35 | options.db = 'http://' + auth + this.connection.host + ':' + this.connection.port + '/' + this.name; 36 | } 37 | 38 | feed = new follow.Feed(options); 39 | feed.on('change', function () { 40 | // 41 | // Remark: Support the legacy `data` events. 42 | // 43 | if (!responded) { 44 | responded = true; 45 | feed.emit('response', response); 46 | } 47 | 48 | response.emit.apply(response, ['data'].concat(Array.prototype.slice.call(arguments))); 49 | }); 50 | 51 | if (options.follow !== false) { 52 | feed.follow(); 53 | } 54 | 55 | return feed; 56 | }; 57 | -------------------------------------------------------------------------------- /test/helpers/seed.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'), 2 | fs = require('fs'), 3 | path = require('path'), 4 | async = require('async'), 5 | request = require('request'); 6 | 7 | var databases = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'databases.json'), 'utf8')); 8 | 9 | var seed = exports; 10 | 11 | seed.createDatabase = function (name, callback) { 12 | request({ 13 | method: 'PUT', 14 | url: 'http://127.0.0.1:5984/' + encodeURIComponent(name) 15 | }, callback); 16 | }; 17 | 18 | seed.deleteDatabase = function (name, callback) { 19 | request({ 20 | method: 'DELETE', 21 | url: 'http://127.0.0.1:5984/' + encodeURIComponent(name) 22 | }, callback); 23 | }; 24 | 25 | seed.seedDatabase = function (name, callback) { 26 | console.log('Seeding ' + name); 27 | seed.deleteDatabase(name, function (err, res, body) { 28 | if (!databases[name]) { 29 | return callback(err); 30 | } 31 | 32 | function putDoc (doc, next) { 33 | request({ 34 | method: 'PUT', 35 | url: 'http://127.0.0.1:5984/' + encodeURIComponent(name) + '/' + doc._id, 36 | body: JSON.stringify(doc) 37 | }, next); 38 | } 39 | 40 | seed.createDatabase(name, function () { 41 | async.forEach(databases[name], putDoc, callback); 42 | }); 43 | }); 44 | }; 45 | 46 | seed.requireSeed = function () { 47 | return { 48 | "Tests require database seeding": { 49 | topic: function () { 50 | async.forEach(Object.keys(databases), seed.seedDatabase, this.callback) 51 | }, 52 | "should respond with no errors": function (err) { 53 | assert.isTrue(!err); 54 | } 55 | } 56 | } 57 | }; 58 | 59 | if (!module.parent) { 60 | async.forEachSeries(Object.keys(databases), seed.seedDatabase, function (err) { 61 | return err 62 | ? console.log('Error seeding database: ' + err.message) 63 | : console.log('Database seed completed.'); 64 | }); 65 | } -------------------------------------------------------------------------------- /lib/cradle/database/index.js: -------------------------------------------------------------------------------- 1 | var querystring = require('querystring'), 2 | Args = require('vargs').Constructor, 3 | cradle = require('../../cradle'); 4 | 5 | var Database = exports.Database = function (name, connection) { 6 | this.connection = connection; 7 | this.name = encodeURIComponent(name); 8 | this.cache = new (cradle.Cache)(connection.options); 9 | }; 10 | 11 | // A wrapper around `Connection.request`, 12 | // which prepends the database name. 13 | Database.prototype.query = function (options, callback) { 14 | options.path = [this.name, options.path].filter(Boolean).join('/'); 15 | this.connection.request(options, callback); 16 | }; 17 | 18 | Database.prototype.exists = function (callback) { 19 | this.query({ method: 'HEAD' }, function (err, res, status) { 20 | if (err) { 21 | callback(err); 22 | } else { 23 | if (status === 404) { 24 | callback(null, false); 25 | } else { 26 | callback(null, true); 27 | } 28 | } 29 | }); 30 | }; 31 | 32 | Database.prototype.replicate = function (target, options, callback) { 33 | if (typeof(options) === 'function') { callback = options, options = {} } 34 | this.connection.replicate(cradle.merge({ source: name, target: target }, options), callback); 35 | }; 36 | 37 | Database.prototype.info = function (callback) { 38 | this.query({ method: 'GET' }, callback); 39 | }; 40 | 41 | Database.prototype.create = function (callback) { 42 | this.query({ method: 'PUT' }, callback); 43 | }; 44 | 45 | // Destroys a database with 'DELETE' 46 | // we raise an exception if arguments were supplied, 47 | // as we don't want users to confuse this function with `remove`. 48 | Database.prototype.destroy = function (callback) { 49 | if (arguments.length > 1) { 50 | throw new(Error)("destroy() doesn't take any additional arguments"); 51 | } 52 | 53 | this.query({ 54 | method: 'DELETE', 55 | path: '/', 56 | }, callback); 57 | }; 58 | 59 | // 60 | // Extend the Database prototype with Couch features 61 | // 62 | require('./attachments'); 63 | require('./changes'); 64 | require('./documents'); 65 | require('./views'); -------------------------------------------------------------------------------- /test/response-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | assert = require('assert'), 3 | events = require('events'), 4 | http = require('http'), 5 | fs = require('fs'), 6 | vows = require('vows'); 7 | 8 | var cradle = require('../lib/cradle'); 9 | var document = { _rev: '2-76be', _id: 'f6av8', name: 'buzz', age: 99 }; 10 | 11 | vows.describe('cradle/response').addBatch({ 12 | 'A cradle.Response instance': { 13 | 'from a document': { 14 | topic: new(cradle.Response)(document), 15 | 16 | 'should only have the original keys': function (topic) { 17 | assert.lengthOf (Object.keys(topic), 4); 18 | assert.equal (topic.name, 'buzz'); 19 | assert.equal (topic.age, 99); 20 | assert.deepEqual (document, topic); 21 | }, 22 | 'should own the keys': function (topic) { 23 | assert.include (topic, 'name'); 24 | assert.include (topic, 'age'); 25 | }, 26 | 'should return the original document, when `json` is called': function (topic) { 27 | assert.isObject (topic.json); 28 | assert.deepEqual (topic.json, document); 29 | assert.isUndefined (topic.json.json); 30 | assert.isUndefined (topic.headers); 31 | assert.lengthOf (Object.keys(topic.json), 4); 32 | }, 33 | 'when using a `for .. in` loop, should only return the original keys': function (topic) { 34 | var keys = []; 35 | for (var k in topic) { keys.push(k) } 36 | 37 | assert.lengthOf (keys, 4); 38 | assert.include (keys, 'name'); 39 | assert.include (keys, 'age'); 40 | }, 41 | 'should stringify': function (topic) { 42 | var expected = JSON.stringify(document); 43 | assert.equal (topic.toString(), expected); 44 | assert.equal (JSON.stringify(topic), expected); 45 | }, 46 | 'should respond to both `id` and `_id`': function (topic) { 47 | assert.equal (topic.id, 'f6av8'); 48 | assert.equal (topic._id, 'f6av8'); 49 | }, 50 | 'should respond to both `rev` and `_rev`': function (topic) { 51 | assert.equal (topic.rev, '2-76be'); 52 | assert.equal (topic._rev, '2-76be'); 53 | }, 54 | 'should have Response as its constructor': function (topic) { 55 | assert.equal (topic.constructor, cradle.Response); 56 | }, 57 | 'when modifying & adding keys': { 58 | topic: function (response) { 59 | response.hair = 'blue'; 60 | response.age = 88; 61 | return response; 62 | }, 63 | 'should return the modified document with toJSON': function (response) { 64 | var json = JSON.parse(JSON.stringify(response)); 65 | assert.equal(json.age, 88); 66 | assert.equal(json.hair, 'blue'); 67 | } 68 | } 69 | } 70 | } 71 | }).export(module); 72 | 73 | -------------------------------------------------------------------------------- /lib/cradle/cache.js: -------------------------------------------------------------------------------- 1 | var Response = require('./response').Response; 2 | // 3 | // Each database object has its own cache store. 4 | // The cache.* methods are all wrappers around 5 | // `cache.query`, which transparently checks if 6 | // caching is enabled, before performing any action. 7 | // 8 | this.Cache = function (options) { 9 | var that = this; 10 | 11 | this.store = {}; 12 | this.options = options; 13 | this.size = options.cacheSize || 0; 14 | this.keys = 0; 15 | }; 16 | 17 | this.Cache.prototype = { 18 | // API 19 | get: function (id) { return this.query('get', id) }, 20 | save: function (id, doc) { return this.query('save', id, doc) }, 21 | purge: function (id) { return this.query('purge', id) }, 22 | has: function (id) { return this.query('has', id) }, 23 | 24 | _get: function (id) { 25 | var entry; 26 | 27 | if (id in this.store) { 28 | entry = this.store[id]; 29 | entry.atime = Date.now(); 30 | 31 | if (this.options.raw) { 32 | return entry.document; 33 | } else { 34 | // If the document is already wrapped in a `Response`, 35 | // just return it. Else, wrap it first. We clone the documents 36 | // before returning them, to protect them from modification. 37 | if (entry.document.toJSON) { 38 | return clone(entry.document); 39 | } else { 40 | return new(Response)(clone(entry.document)); 41 | } 42 | } 43 | } 44 | }, 45 | _has: function (id) { 46 | return id in this.store; 47 | }, 48 | _save: function (id, doc) { 49 | if (! this._has(id)) { 50 | this.keys ++; 51 | this.prune(); 52 | } 53 | 54 | return this.store[id] = { 55 | atime: Date.now(), 56 | document: doc 57 | }; 58 | }, 59 | _purge: function (id) { 60 | if (id) { 61 | delete(this.store[id]); 62 | this.keys --; 63 | } else { 64 | this.store = {}; 65 | } 66 | }, 67 | query: function (op, id, doc) { 68 | if (this.options.cache) { 69 | return this['_' + op](id, doc); 70 | } else { 71 | return false; 72 | } 73 | }, 74 | prune: function () { 75 | var that = this; 76 | if (this.size && this.keys > this.size) { 77 | process.nextTick(function () { 78 | var store = that.store, 79 | keys = Object.keys(store), 80 | pruned = Math.ceil(that.size / 8); 81 | 82 | keys.sort(function (a, b) { 83 | return store[a].atime > store[b].atime ? 1 : -1; 84 | }); 85 | 86 | for (var i = 0; i < pruned; i++) { 87 | delete(store[keys[i]]); 88 | } 89 | that.keys -= pruned; 90 | }); 91 | } 92 | } 93 | }; 94 | 95 | function clone(obj) { 96 | return Object.keys(obj).reduce(function (clone, k) { 97 | if (! obj.__lookupGetter__(k)) { 98 | clone[k] = obj[k]; 99 | } 100 | return clone; 101 | }, {}); 102 | } 103 | -------------------------------------------------------------------------------- /lib/cradle/database/attachments.js: -------------------------------------------------------------------------------- 1 | var querystring = require('querystring'), 2 | Args = require('vargs').Constructor, 3 | cradle = require('../../cradle'), 4 | Database = require('./index').Database; 5 | 6 | Database.prototype.getAttachment = function (id, attachmentName, callback) { 7 | // 8 | // TODO: Update cache? 9 | // 10 | 11 | return this.connection.rawRequest({ 12 | method: 'GET', 13 | path: '/' + [this.name, querystring.escape(id), attachmentName].join('/') 14 | }, callback); 15 | }; 16 | 17 | Database.prototype.removeAttachment = function (doc, attachmentName, callback) { 18 | var params, 19 | rev, 20 | id; 21 | 22 | if (typeof doc === 'string') { 23 | id = doc; 24 | } else { 25 | id = doc.id || doc._id; 26 | rev = doc.rev || doc._rev; 27 | } 28 | 29 | if (!id) { 30 | error = new(TypeError)("first argument must be a document id"); 31 | if (!callback) { throw error } 32 | return callback(error); 33 | } 34 | 35 | if (!rev && this.cache.has(id)) { 36 | rev = this.cache.get(id)._rev; 37 | } else if (rev) { 38 | rev = rev.replace(/\"/g, ''); 39 | } 40 | 41 | this.query({ 42 | method: 'DELETE', 43 | path: [querystring.escape(id), attachmentName].join('/'), 44 | query: { rev: rev } 45 | }, callback); 46 | }; 47 | 48 | Database.prototype.saveAttachment = function (doc, attachment, callback) { 49 | var attachmentName, 50 | options = {}, 51 | self = this, 52 | params, 53 | error, 54 | rev, 55 | id; 56 | 57 | if (typeof doc === 'string') { 58 | id = doc; 59 | } else { 60 | id = doc.id || doc._id; 61 | rev = doc.rev || doc._rev; 62 | } 63 | 64 | if (!id) { 65 | error = new(TypeError)("Missing document id."); 66 | if (!callback) { throw error } 67 | return callback(error); 68 | } 69 | 70 | attachmentName = typeof attachment !== 'string' 71 | ? attachment.name 72 | : attachment; 73 | 74 | if (!rev && this.cache.has(id)) { 75 | params = { rev: this.cache.get(id)._rev }; 76 | } else if (rev) { 77 | params = { rev: rev.replace(/\"/g, '') }; 78 | } 79 | 80 | options.method = 'PUT'; 81 | options.path = '/' + [this.name, querystring.escape(id), attachmentName].join('/'); 82 | options.headers = { 83 | 'Content-Type': attachment['content-type'] 84 | || attachment['contentType'] 85 | || attachment['Content-Type'] 86 | || 'text/plain' 87 | }; 88 | 89 | if (attachment.body) { 90 | options.body = attachment.body; 91 | } 92 | 93 | if (params) { 94 | options.path += ('?' + querystring.stringify(params)); 95 | } 96 | 97 | return this.connection.rawRequest(options, function (err, res, body) { 98 | var result = JSON.parse(body); 99 | result.headers = res.headers; 100 | result.headers.status = res.statusCode; 101 | 102 | if (result.headers.status == 201) { 103 | if (self.cache.has(id)) { 104 | cached = self.cache.store[id].document; 105 | cached._rev = result.rev; 106 | cached._attachments = cached._attachments || {}; 107 | cached._attachments[attachmentName] = { stub: true }; 108 | } 109 | 110 | return callback(null, result); 111 | } 112 | 113 | callback(result); 114 | }); 115 | }; 116 | 117 | // 118 | // Alias `saveAttachment` to `addAttachment` 119 | // 120 | Database.prototype.addAttachment = Database.prototype.saveAttachment; 121 | -------------------------------------------------------------------------------- /lib/cradle/database/views.js: -------------------------------------------------------------------------------- 1 | var querystring = require('querystring'), 2 | Args = require('vargs').Constructor, 3 | cradle = require('../../cradle'), 4 | Database = require('./index').Database; 5 | 6 | Database.prototype.all = function (options, callback) { 7 | if (arguments.length === 1) { 8 | callback = options; 9 | options = {}; 10 | } 11 | 12 | return this._getOrPostView('/_all_docs', options, callback); 13 | }; 14 | 15 | // Query a view, passing any options to the query string. 16 | // Some query string parameters' values have to be JSON-encoded. 17 | Database.prototype.view = function (path, options, callback) { 18 | if (typeof options === 'function') { 19 | callback = options; 20 | options = {}; 21 | } 22 | 23 | path = path.split('/'); 24 | path = ['_design', path[0], '_view', path[1]].map(querystring.escape).join('/'); 25 | 26 | return this._getOrPostView(path, options, callback); 27 | }; 28 | 29 | Database.prototype.temporaryView = function (doc, options, callback) { 30 | if (!callback && typeof options === 'function') { 31 | callback = options; 32 | options = null; 33 | } 34 | 35 | if (options && typeof options === 'object') { 36 | ['key', 'keys', 'startkey', 'endkey'].forEach(function (k) { 37 | if (k in options) { options[k] = JSON.stringify(options[k]) } 38 | }); 39 | } 40 | 41 | return this.query({ 42 | method: 'POST', 43 | path: '_temp_view', 44 | query: options, 45 | body: doc 46 | }, callback); 47 | }; 48 | 49 | Database.prototype.viewCleanup = function (callback) { 50 | this.query({ 51 | method: 'POST', 52 | path: '/_view_cleanup', 53 | headers: { 54 | 'Content-Type': 'application/json' 55 | } 56 | }, callback); 57 | }; 58 | 59 | Database.prototype.compact = function (design) { 60 | this.query({ 61 | method: 'POST', 62 | path: '/_compact' + (typeof(design) === 'string' ? '/' + querystring.escape(design) : ''), 63 | headers: { 64 | 'Content-Type': 'application/json' 65 | } 66 | }, Args.last(arguments)); 67 | }; 68 | 69 | // Query a list, passing any options to the query string. 70 | // Some query string parameters' values have to be JSON-encoded. 71 | Database.prototype.list = function (path, options) { 72 | var callback = new(Args)(arguments).callback; 73 | path = path.split('/'); 74 | 75 | this._getOrPostView( 76 | ['_design', path[0], '_list', path[1], path[2]].map(querystring.escape).join('/'), 77 | options, 78 | callback 79 | ); 80 | }; 81 | 82 | // 83 | // Helper function which parses options and makes either a `GET` 84 | // or `POST` request to `path` depending on if `options.keys` or 85 | // `options.body` is present. 86 | // 87 | Database.prototype._getOrPostView = function (path, options, callback) { 88 | options = parseOptions(options); 89 | 90 | if (options && options.body) { 91 | var body = options.body; 92 | delete options.body; 93 | 94 | return this.query({ 95 | method: 'POST', 96 | path: path, 97 | query: options, 98 | body: body 99 | }, callback); 100 | } 101 | 102 | return this.query({ 103 | method: 'GET', 104 | path: path, 105 | query: options 106 | }, callback); 107 | } 108 | 109 | // 110 | // Helper function for parsing and stringifying complex options 111 | // to pass to CouchDB. 112 | // 113 | function parseOptions(options) { 114 | if (options && typeof options === 'object') { 115 | ['key', 'startkey', 'endkey'].forEach(function (k) { 116 | if (k in options) { options[k] = JSON.stringify(options[k]) } 117 | }); 118 | } 119 | 120 | if (options && options.keys) { 121 | options.body = options.body || {}; 122 | options.body.keys = options.keys; 123 | delete options.keys; 124 | } 125 | 126 | return options; 127 | } 128 | -------------------------------------------------------------------------------- /lib/cradle/response.js: -------------------------------------------------------------------------------- 1 | // 2 | // HTTP response wrapper 3 | // 4 | // It allows us to call array-like methods on documents 5 | // with a 'row' attribute. 6 | // 7 | this.Response = function Response(json, response) { 8 | var obj, headers; 9 | 10 | // If there's rows, this is the result 11 | // of a view function. 12 | // We want to return this as an Array. 13 | if (json.rows) { 14 | obj = json.rows.slice(0); 15 | obj.__proto__ = new(Array); 16 | if (json && typeof json === 'object') { 17 | Object.keys(json).forEach(function (k) { 18 | Object.defineProperty(obj.__proto__, k, { 19 | value: json[k], 20 | enumerable: false 21 | }); 22 | }); 23 | } 24 | } else if (json.results) { 25 | obj = json.results.slice(0); 26 | obj.__proto__ = new(Array); 27 | obj.last_seq = json.last_seq; 28 | } else if (json.uuids) { 29 | obj = json.uuids; 30 | obj.__proto__ = new(Array); 31 | } else if (Array.isArray(json)) { 32 | obj = json.slice(0); 33 | obj.__proto__ = new(Array); 34 | } else { 35 | obj = {}; 36 | obj.__proto__ = new(Object); 37 | if (json && typeof json === 'object') { 38 | Object.keys(json).forEach(function (k) { 39 | obj[k] = json[k]; 40 | }); 41 | } 42 | } 43 | 44 | // If the response was originally a document, 45 | // give access to it via the 'json' getter. 46 | if (!Array.isArray(json) && !obj.json) { 47 | Object.defineProperty(obj, 'json', { 48 | value: json, 49 | enumerable: false 50 | }); 51 | } 52 | 53 | if (response) { 54 | headers = { status: response.statusCode }; 55 | Object.keys(response.headers).forEach(function (k) { 56 | headers[k] = response.headers[k]; 57 | }); 58 | 59 | // Set the 'headers' special field, with the response's status code. 60 | exports.extend(obj, 'headers' in obj ? { _headers: headers } 61 | : { headers: headers }); 62 | } 63 | 64 | // Alias '_rev' and '_id' 65 | if (obj.id && obj.rev) { 66 | exports.extend(obj, { _id: obj.id, _rev: obj.rev }); 67 | } else if (obj._id && obj._rev) { 68 | exports.extend(obj, { id: obj._id, rev: obj._rev }); 69 | } 70 | 71 | if (Array.isArray(obj) && json.rows) { 72 | exports.extend(obj, exports.collectionPrototype); 73 | } 74 | exports.extend(obj, exports.basePrototype); 75 | 76 | // Set the constructor to be this function 77 | Object.defineProperty(obj, 'constructor', { 78 | value: arguments.callee 79 | }); 80 | 81 | return obj; 82 | }; 83 | 84 | this.basePrototype = { 85 | toJSON: function () { 86 | return this; 87 | }, 88 | toString: function () { 89 | return JSON.stringify(this); 90 | } 91 | }; 92 | 93 | this.collectionPrototype = { 94 | forEach: function (f) { 95 | for (var i = 0, value; i < this.length; i++) { 96 | value = this[i].doc || this[i].json || this[i].value || this[i]; 97 | if (f.length === 1) { 98 | f.call(this[i], value); 99 | } else { 100 | f.call(this[i], this[i].key, value, this[i].id); 101 | } 102 | } 103 | }, 104 | map: function (f) { 105 | var ary = []; 106 | if (f.length === 1) { 107 | this.forEach(function (a) { ary.push(f.call(this, a)) }); 108 | } else { 109 | this.forEach(function () { ary.push(f.apply(this, arguments)) }); 110 | } 111 | return ary; 112 | }, 113 | toArray: function () { 114 | return this.map(function (k, v) { return v }); 115 | } 116 | }; 117 | 118 | this.extend = function (obj, properties) { 119 | var descriptor = Object.keys(properties).reduce(function (hash, k) { 120 | hash[k] = { 121 | value: properties[k], 122 | enumerable: false 123 | }; 124 | return hash; 125 | }, {}); 126 | return Object.defineProperties(obj, descriptor); 127 | }; 128 | -------------------------------------------------------------------------------- /test/cache-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | assert = require('assert'), 3 | events = require('events'), 4 | vows = require('vows'); 5 | 6 | var cradle = require('../lib/cradle'); 7 | 8 | vows.describe('cradle/cache').addBatch({ 9 | 'A cradle.Connection instance with a *cacheSize* specified': { 10 | topic: new(cradle.Connection)({ cache: true, cacheSize: 16 }), 11 | 'should set the database cache size appropriately': function (topic) { 12 | assert.equal (topic.database('random').cache.size, 16); 13 | } 14 | }, 15 | 'A cradle.Cache instance with a *cacheSize* of `8`': { 16 | topic: new(cradle.Cache)({ cache: true, cacheSize: 8 }), 17 | 'should be able to store 8 keys': function (topic) { 18 | for (var i = 0; i < 8; i++) { topic.save(i.toString(), {}) } 19 | assert.lengthOf (Object.keys(topic.store), 8); 20 | }, 21 | 'if more than 8 keys are set': { 22 | topic: function (cache) { 23 | var that = this; 24 | cache.save('17af', {}); 25 | process.nextTick(function () { 26 | that.callback(null, cache); 27 | }); 28 | }, 29 | 'there should still be 8 keys in the store': function (cache) { 30 | assert.lengthOf (Object.keys(cache.store), 8); 31 | } 32 | }, 33 | 'if an extra 8 keys are set': { 34 | topic: function (cache) { 35 | var that = this; 36 | setTimeout(function () { 37 | for (var i = 1; i <= 8; i++) { cache.save((i * 10).toString(), 'extra') } 38 | process.nextTick(function () { 39 | that.callback(null, cache); 40 | }); 41 | }, 30); 42 | }, 43 | 'it should purge the initial 8 keys, and keep the new ones': function (cache) { 44 | Object.keys(cache.store).forEach(function (k) { 45 | assert.equal (cache.store[k].document, 'extra'); 46 | }); 47 | } 48 | }, 49 | }, 50 | 'Another cradle.Cache instance': { 51 | topic: new(cradle.Cache)({ cache: true, cacheSize: 8 }), 52 | 'after setting 8 keys on it, accessing 3 of them, and adding 5 more': { 53 | topic: function (cache) { 54 | var that = this; 55 | for (var i = 0; i < 8; i++) { cache.save(i.toString(), { id: i.toString() }) } 56 | setTimeout(function () { 57 | cache.get('2'); 58 | cache.get('5'); 59 | cache.get('1'); 60 | for (var i = 8; i < 13; i++) { cache.save(i.toString(), { id: i.toString() }) } 61 | process.nextTick(function () { 62 | that.callback(null, cache); 63 | }); 64 | }, 10); 65 | }, 66 | 'it should have the 3 accessed ones, with the 5 new ones': function (cache) { 67 | assert.lengthOf (Object.keys(cache.store), 8); 68 | assert.isTrue (cache.has('2')); 69 | assert.isTrue (cache.has('5')); 70 | assert.isTrue (cache.has('1')); 71 | for (var i = 8; i < 13; i++) { cache.has(i.toString()) } 72 | } 73 | } 74 | }, 75 | 'A cradle.Cache instance with a *cacheSize* of *1024*': { 76 | topic: new(cradle.Cache)({ cache: true, cacheSize: 1024 }), 77 | 'setting 4096 keys': { 78 | topic: function (cache) { 79 | var that = this; 80 | var keys = 0; 81 | var timer = setInterval(function () { 82 | if (keys >= 4096) { 83 | clearInterval(timer); 84 | process.nextTick(function () { that.callback(null, cache) }) 85 | } 86 | cache.save(keys.toString(), {}) 87 | keys++; 88 | }, 1); 89 | }, 90 | 'should result in 1025 keys': function (cache) { 91 | assert.equal (Object.keys(cache.store).length, 1025); 92 | assert.equal (cache.keys, 1025); 93 | } 94 | } 95 | 96 | } 97 | }).export(module); 98 | 99 | -------------------------------------------------------------------------------- /test/database-view-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | assert = require('assert'), 3 | events = require('events'), 4 | http = require('http'), 5 | fs = require('fs'), 6 | vows = require('vows'), 7 | macros = require('./helpers/macros'); 8 | 9 | function shouldQueryView(topic, rows, total) { 10 | return { 11 | topic: topic, 12 | "returns a 200": macros.status(200), 13 | "returns view results": function (res) { 14 | assert.isArray(res.rows); 15 | assert.equal(res.rows.length, rows.length); 16 | assert.equal(res.total_rows, total || rows.length); 17 | }, 18 | "returns an iterable object with key/val pairs": function (res) { 19 | assert.isArray(res); 20 | assert.lengthOf(res, rows.length); 21 | res.forEach(function (k, v) { 22 | assert.isObject(v); 23 | assert.isString(k); 24 | assert.notEqual(rows.indexOf(k), -1); 25 | }); 26 | }, 27 | } 28 | } 29 | 30 | var cradle = require('../lib/cradle'); 31 | 32 | vows.describe('cradle/database/view').addBatch( 33 | macros.database({ 34 | "querying a view": { 35 | "with no options": shouldQueryView( 36 | function (db) { 37 | db.view('pigs/all', this.callback); 38 | }, 39 | ['bill', 'mike', 'alex'] 40 | ), 41 | "with a single key": shouldQueryView( 42 | function (db) { 43 | db.view('pigs/all', { key: 'bill' }, this.callback); 44 | }, 45 | ['bill'], 46 | 3 47 | ), 48 | "with a startKey and endKey": shouldQueryView( 49 | function (db) { 50 | db.view('pigs/all', { startkey: 'b', endkey: 'r' }, this.callback); 51 | }, 52 | ['bill', 'mike'], 53 | 3 54 | ), 55 | "with keys": shouldQueryView( 56 | function (db) { 57 | db.view('pigs/all', { keys: ['mike', 'bill'] }, this.callback); 58 | }, 59 | ['mike', 'bill'], 60 | 3 61 | ), 62 | "with a `keys` body": shouldQueryView( 63 | function (db) { 64 | db.view('pigs/all', { body: { keys: ['mike', 'bill'] } }, this.callback); 65 | }, 66 | ['mike', 'bill'], 67 | 3 68 | ) 69 | }, 70 | // same as the above test, but with a temporary view 71 | "querying a temporary view": { 72 | "with no options": shouldQueryView( 73 | function (db) { 74 | db.temporaryView({ 75 | map: function (doc) { 76 | if (doc.color) emit(doc._id, doc); 77 | } 78 | }, this.callback); 79 | }, 80 | ['mike', 'bill', 'alex'] 81 | ) 82 | }, 83 | "cleaning up a view with viewCleanup()": { 84 | topic: function (db) { 85 | db.viewCleanup(this.callback); 86 | }, 87 | "returns a 202": macros.status(202), 88 | "no error is thrown and we get ok response": function (e, res) { 89 | assert.ok(!e); 90 | assert.ok(res && res.ok && res.ok === true); 91 | } 92 | } 93 | }) 94 | ).addBatch( 95 | macros.database({ 96 | "querying a temporary view": { 97 | "with a single key": shouldQueryView( 98 | function (db) { 99 | db.temporaryView({ 100 | map: function (doc) { 101 | if (doc.color) emit(doc._id, doc); 102 | } 103 | }, { key: 'mike' }, this.callback); 104 | }, 105 | ['mike'], 106 | 3 107 | ) 108 | } 109 | }) 110 | ).addBatch( 111 | macros.database({ 112 | "querying a temporary view": { 113 | "with a startKey and endKey": shouldQueryView( 114 | function (db) { 115 | db.temporaryView({ 116 | map: function (doc) { 117 | if (doc.color) emit(doc._id, doc); 118 | } 119 | }, { startkey: 'b', endkey: 'zzzz' }, this.callback); 120 | }, 121 | ['mike', 'bill'], 122 | 3 123 | ) 124 | } 125 | }) 126 | ).export(module); 127 | -------------------------------------------------------------------------------- /test/database-cache-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | assert = require('assert'), 3 | events = require('events'), 4 | http = require('http'), 5 | fs = require('fs'), 6 | vows = require('vows'), 7 | macros = require('./helpers/macros'); 8 | 9 | var cradle = require('../lib/cradle'); 10 | 11 | vows.describe('cradle/database/cache').addBatch( 12 | macros.database({ couch: true }, { 13 | "save()": { 14 | topic: function (db) { 15 | var promise = new(events.EventEmitter); 16 | db.save('bob', {ears: true}, function (e, res) { 17 | promise.emit("success", db); 18 | }); 19 | return promise; 20 | }, 21 | "should write through the cache": function (db) { 22 | assert.ok(db.cache.has('bob')); 23 | assert.ok(db.cache.get('bob')._rev); 24 | }, 25 | "when fetching the cached document": { 26 | topic: function (db) { 27 | db.get('bob', this.callback) 28 | }, 29 | "document contains _id": function (e, doc) { 30 | assert.equal(doc._id, 'bob'); 31 | } 32 | }, 33 | "and": { 34 | topic: function (db) { 35 | var promise = new(events.EventEmitter); 36 | db.save('bob', {size: 12}, function (e, res) { 37 | promise.emit('success', res, db.cache.get('bob')); 38 | }); 39 | return promise; 40 | }, 41 | "return a 201": macros.status(201), 42 | "allow an overwrite": function (res) { 43 | assert.match(res.rev, /^2/); 44 | }, 45 | "caches the updated document": function (e, res, doc) { 46 | assert.ok(doc); 47 | assert.equal(doc.size, 12); 48 | assert.isUndefined(doc.ears); 49 | } 50 | } 51 | }, 52 | "save() with / in id": { 53 | topic: function (db) { 54 | var promise = new(events.EventEmitter); 55 | db.save('bob/someotherdoc', {size: 12}, function (e, res) { 56 | promise.emit('success', res, db.cache.get('bob/someotherdoc')); 57 | }); 58 | return promise; 59 | }, 60 | "return a 201": macros.status(201), 61 | "allow an overwrite": function (res) { 62 | assert.match(res.rev, /^1/); 63 | }, 64 | "caches the updated document": function (e, res, doc) { 65 | assert.ok(doc); 66 | assert.equal(doc.size, 12); 67 | } 68 | }, 69 | "merge()": { 70 | topic: function (db) { 71 | var promise = new(events.EventEmitter); 72 | db.save('billy', {ears: true}, function (e, res) { 73 | promise.emit("success", db); 74 | }); 75 | return promise; 76 | }, 77 | "should write through the cache": function (db) { 78 | assert.ok(db.cache.has('billy')); 79 | assert.ok(db.cache.get('billy')._rev); 80 | }, 81 | "and": { 82 | topic: function (db) { 83 | var promise = new(events.EventEmitter); 84 | db.merge('billy', {size: 12}, function (e, res) { 85 | promise.emit('success', res, db.cache.get('billy')); 86 | }); 87 | return promise; 88 | }, 89 | "return a 201": macros.status(201), 90 | "allow an overwrite": function (res) { 91 | assert.match(res.rev, /^2/); 92 | }, 93 | "caches the updated document": function (e, res, doc) { 94 | assert.ok(doc); 95 | assert.equal(doc.size, 12); 96 | assert.equal(doc.ears, true); 97 | } 98 | } 99 | }, 100 | "remove()": { 101 | topic: function (db) { 102 | var promise = new(events.EventEmitter); 103 | db.save('bruno', {}, function (e, res) { 104 | promise.emit("success", db); 105 | }); 106 | return promise; 107 | }, 108 | "shouldn't ask for a revision": { 109 | topic: function (db) { 110 | var promise = new(events.EventEmitter); 111 | db.remove('bruno', function () { promise.emit('success', db) }); 112 | return promise; 113 | }, 114 | "and should purge the cache": function (db) { 115 | assert.equal(db.cache.has('bruno'), false); 116 | }, 117 | "and raise an exception if you use remove() without a rev": function (db) { 118 | //assert.throws(db.remove('bruno'), Error); 119 | } 120 | } 121 | } 122 | }) 123 | ).export(module); 124 | -------------------------------------------------------------------------------- /test/connection-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | assert = require('assert'), 3 | events = require('events'), 4 | http = require('http'), 5 | fs = require('fs'), 6 | vows = require('vows'), 7 | macros = require('./helpers/macros'); 8 | 9 | var cradle = require('../lib/cradle'); 10 | 11 | vows.describe('cradle/connection').addBatch({ 12 | "Default connection settings": { 13 | topic: function () { 14 | cradle.setup({ 15 | host: "http://cloudhead.io", 16 | port: 4242, 17 | milk: 'white' 18 | }); 19 | return new(cradle.Connection); 20 | }, 21 | "should be carried on to new Connections": function (c) { 22 | assert.equal(c.host, "cloudhead.io"); 23 | assert.equal(c.protocol, "http"); 24 | assert.equal(c.port, 4242); 25 | assert.equal(c.options.milk, 'white'); 26 | assert.equal(c.options.cache, true); 27 | }, 28 | "with just a {} passed to a new Connection object": { 29 | topic: function () { return new(cradle.Connection)({milk: 'green'}) }, 30 | "should override the defaults": function (c) { 31 | assert.equal(c.options.milk, 'green'); 32 | assert.equal(c.port, 4242); 33 | } 34 | }, 35 | "with a host and port passed to Connection": { 36 | topic: function () { return new(cradle.Connection)("255.255.0.0", 9696) }, 37 | "should override the defaults": function (c) { 38 | assert.equal(c.host, '255.255.0.0'); 39 | assert.equal(c.port, 9696); 40 | } 41 | }, 42 | "with a host, port and options passed to Connection": { 43 | topic: function () { return new(cradle.Connection)("4.4.4.4", 911, {raw: true}) }, 44 | "should override the defaults": function (c) { 45 | assert.equal(c.host, '4.4.4.4'); 46 | assert.equal(c.port, 911); 47 | assert.equal(c.options.raw, true); 48 | } 49 | }, 50 | "with a host and port and protocol passed to Connection": { 51 | topic: function () { return new(cradle.Connection)("http://4.4.4.4", 911, {raw: true, secure: true}) }, 52 | "should override the defaults": function (c) { 53 | assert.equal(c.host, '4.4.4.4'); 54 | assert.equal(c.port, 911); 55 | assert.equal(c.options.raw, true); 56 | assert.equal(c.options.secure, true); 57 | } 58 | }, 59 | "with a host and port passed as an object to Connection": { 60 | topic: function () { return new(cradle.Connection)({ host: "https://4.4.4.4", port: 911, raw: true }) }, 61 | "should override the defaults": function (c) { 62 | assert.equal(c.options.secure, true); 63 | assert.equal(c.host, '4.4.4.4'); 64 | assert.equal(c.port, 911); 65 | assert.equal(c.options.raw, true); 66 | } 67 | }, 68 | "with a the 'https' protocol": { 69 | topic: function () { return new(cradle.Connection)("https://couch.io", 5984) }, 70 | "should set 'secure' to `true`": function (c) { 71 | assert.equal(c.protocol, 'https'); 72 | assert.equal(c.options.secure, true); 73 | assert.equal(c.host, 'couch.io'); 74 | assert.equal(c.port, 5984); 75 | } 76 | }, 77 | "with the port as part of the URL": { 78 | topic: function () { return new(cradle.Connection)("https://couch.io:418") }, 79 | "should read the port from the URL": function (c) { 80 | assert.equal(c.protocol, 'https'); 81 | assert.equal(c.options.secure, true); 82 | assert.equal(c.host, 'couch.io'); 83 | assert.equal(c.port, 418); 84 | } 85 | } 86 | } 87 | }).addBatch({ 88 | "Connection": { 89 | topic: function () { 90 | return new(cradle.Connection)('127.0.0.1', 5984, {cache: false}); 91 | }, 92 | "getting server info": { 93 | topic: function (c) { c.info(this.callback) }, 94 | 95 | "returns a 200": macros.status(200), 96 | "returns the version number": function (info) { 97 | assert.ok(info); 98 | assert.match(info.version, /\d+\.\d+\.\d+/); 99 | } 100 | }, 101 | "uuids()": { 102 | "with count": { 103 | topic: function (c) { c.uuids(42, this.callback) }, 104 | 105 | "returns a 200": macros.status(200), 106 | "returns an array of UUIDs": function (uuids) { 107 | assert.isArray(uuids); 108 | assert.lengthOf(uuids, 42); 109 | } 110 | }, 111 | "without count": { 112 | topic: function (c) { c.uuids(this.callback) }, 113 | 114 | "returns a 200": macros.status(200), 115 | "returns an array of UUIDs": function (uuids) { 116 | assert.isArray(uuids); 117 | assert.lengthOf(uuids, 1); 118 | } 119 | } 120 | }, 121 | "getting the list of databases": { 122 | topic: function (c) { 123 | c.databases(this.callback); 124 | }, 125 | "should contain the 'rabbits' and 'pigs' databases": function (dbs) { 126 | assert.isArray(dbs); 127 | assert.include(dbs, 'rabbits'); 128 | assert.include(dbs, 'pigs'); 129 | } 130 | }, 131 | } 132 | }).addBatch({ 133 | "Connection": { 134 | topic: function () { 135 | return new(cradle.Connection)('127.0.0.1', 5984, {cache: false}); 136 | }, 137 | "create()": { 138 | "with no / in the name": { 139 | topic: function (c) { 140 | c.database('badgers').create(this.callback); 141 | }, 142 | "returns a 201": macros.status(201), 143 | "creates a database": { 144 | topic: function (res, c) { c.database('badgers').exists(this.callback) }, 145 | "it exists": function (res) { assert.ok(res) } 146 | } 147 | }, 148 | "with a / in the name": { 149 | topic: function (c) { 150 | c.database('madeup/ewoks').create(this.callback); 151 | }, 152 | "returns a 201": macros.status(201), 153 | "creates a database": { 154 | topic: function (res, c) { c.database('madeup/ewoks').exists(this.callback) }, 155 | "it exists": function (res) { assert.ok(res) } 156 | } 157 | 158 | } 159 | }, 160 | "destroy()": { 161 | topic: function (c) { 162 | c.database('rabbits').destroy(this.callback); 163 | }, 164 | "returns a 200": macros.status(200), 165 | "destroys a database": { 166 | topic: function (res, c) { 167 | c.database('rabbits').exists(this.callback); 168 | }, 169 | "it doesn't exist anymore": function (res) { assert.ok(! res) } 170 | } 171 | } 172 | } 173 | }).export(module); 174 | -------------------------------------------------------------------------------- /lib/cradle/database/documents.js: -------------------------------------------------------------------------------- 1 | var querystring = require('querystring'), 2 | Args = require('vargs').Constructor, 3 | cradle = require('../../cradle'), 4 | Database = require('./index').Database; 5 | 6 | // 7 | // Perform a HEAD request 8 | // 9 | Database.prototype.head = function (id, callback) { 10 | this.query({ 11 | method: 'HEAD', 12 | path: cradle.escape(id) 13 | }, callback); 14 | }; 15 | 16 | // Fetch either a single document from the database, or cache, 17 | // or multiple documents from the database. 18 | // If it's a single doc from the db, attempt to save it to the cache. 19 | Database.prototype.get = function (id, rev) { 20 | var args = new (Args)(arguments), 21 | options = null, 22 | that = this; 23 | 24 | if (Array.isArray(id)) { // Bulk GET 25 | this.query({ 26 | method: 'POST', 27 | path: '/_all_docs', 28 | query: { include_docs: true }, 29 | body: { keys: id }, 30 | }, function (err, res) { 31 | args.callback(err, res) 32 | }); 33 | } else { 34 | if (rev && args.length === 2) { 35 | if (typeof(rev) === 'string') { options = { rev: rev } } 36 | else if (typeof(rev) === 'object') { options = rev } 37 | } else if (this.cache.has(id)) { 38 | return args.callback(null, this.cache.get(id)); 39 | } 40 | this.query({ 41 | path: cradle.escape(id), 42 | query: options 43 | }, function (err, res) { 44 | if (! err) that.cache.save(res.id, res.json); 45 | args.callback(err, res); 46 | }); 47 | } 48 | }; 49 | 50 | // 51 | // PUT a document, and write through cache 52 | // 53 | Database.prototype.put = function (id, doc, callback) { 54 | var cache = this.cache; 55 | if (typeof(id) !== 'string') { throw new(TypeError)("id must be a string") } 56 | this.query({ 57 | method: 'PUT', 58 | path: cradle.escape(id), 59 | body: doc 60 | }, function (e, res) { 61 | if (! e) { cache.save(id, cradle.merge({}, doc, { _id: id, _rev: res.rev })) } 62 | callback && callback(e, res); 63 | }); 64 | }; 65 | 66 | // 67 | // POST a document, and write through cache 68 | // 69 | Database.prototype.post = function (doc, callback) { 70 | var cache = this.cache; 71 | this.query({ 72 | method: 'POST', 73 | path: '/', 74 | body: doc 75 | }, function (e, res) { 76 | if (! e) { cache.save(res.id, cradle.merge({}, doc, { _id: res.id, _rev: res.rev })) } 77 | callback && callback(e, res); 78 | }); 79 | }; 80 | 81 | Database.prototype.save = function (/* [id], [rev], doc | [doc, ...] */) { 82 | var args = new(Args)(arguments), 83 | array = args.all.slice(0), doc, id, rev; 84 | 85 | if (Array.isArray(args.first)) { 86 | doc = args.first; 87 | } else { 88 | doc = array.pop(), 89 | id = array.shift(), 90 | rev = array.shift(); 91 | } 92 | this._save(id, rev, doc, args.callback); 93 | }; 94 | 95 | Database.prototype._save = function (id, rev, doc, callback) { 96 | var options = this.connection.options; 97 | var document = {}, that = this; 98 | 99 | // Bulk Insert 100 | if (Array.isArray(doc)) { 101 | document.docs = doc; 102 | if (options.allOrNothing) { document.all_or_nothing = true } 103 | this.query({ 104 | method: 'POST', 105 | path: '/_bulk_docs', 106 | body: document 107 | }, callback); 108 | } else { 109 | if (!id && doc._id) { 110 | id = doc._id; 111 | } 112 | 113 | // PUT a single document, with an id (Create or Update) 114 | if (id) { 115 | // Design document 116 | if (/^_design\/(\w|%|\-)+$/.test(id) && !('views' in doc)) { 117 | document.language = "javascript"; 118 | document.views = doc; 119 | } else { 120 | document = doc; 121 | } 122 | // Try to set the '_rev' attribute of the document. 123 | // If it wasn't passed, attempt to retrieve it from the cache. 124 | rev && (document._rev = rev); 125 | 126 | if (document._rev) { 127 | this.put(id, document, callback); 128 | } else if (this.cache.has(id)) { 129 | document._rev = this.cache.get(id)._rev; 130 | this.put(id, document, callback); 131 | } else { 132 | // Attempt to create a new document. If it fails, 133 | // because an existing document with that _id exists (409), 134 | // perform a HEAD, to get the _rev, and try to re-save. 135 | this.put(id, document, function (e, res) { 136 | if (e && e.headers && e.headers.status === 409) { // Conflict 137 | that.head(id, function (e, headers, res) { 138 | if (res === 404 || !headers['etag']) { 139 | return callback({ reason: 'not_found' }); 140 | } 141 | 142 | document._rev = headers['etag'].slice(1, -1); 143 | that.put(id, document, callback); 144 | }); 145 | } else { callback(e, res) } 146 | }); 147 | } 148 | // POST a single document, without an id (Create) 149 | } else { 150 | this.post(doc, callback); 151 | } 152 | } 153 | }; 154 | 155 | Database.prototype.merge = function (/* [id], doc */) { 156 | var args = Array.prototype.slice.call(arguments), 157 | callback = args.pop(), 158 | doc = args.pop(), 159 | id = args.pop() || doc._id; 160 | 161 | this._merge(id, doc, callback); 162 | }; 163 | 164 | Database.prototype._merge = function (id, doc, callback) { 165 | var that = this; 166 | this.get(id, function (e, res) { 167 | if (e) { return callback(e) } 168 | doc = cradle.merge({}, res.json || res, doc); 169 | that.save(id, res._rev, doc, callback); 170 | }); 171 | }; 172 | 173 | Database.prototype.insert = function () { 174 | throw new Error("`insert` is deprecated, use `save` instead"); 175 | }; 176 | 177 | Database.prototype.update = function (path, id, options) { 178 | var args = new(Args)(arguments); 179 | path = path.split('/'); 180 | 181 | if (id) { 182 | return this.query({ 183 | method: 'PUT', 184 | path: ['_design', path[0], '_update', path[1], id].map(querystring.escape).join('/'), 185 | query: options 186 | }, args.callback); 187 | } 188 | 189 | return this.query({ 190 | method: 'POST', 191 | path: ['_design', path[0], '_update', path[1]].map(querystring.escape).join('/'), 192 | query: options, 193 | }, args.callback); 194 | }; 195 | 196 | // Delete a document 197 | // if the _rev wasn't supplied, we attempt to retrieve it from the 198 | // cache. If the deletion was successful, we purge the cache. 199 | Database.prototype.remove = function (id, rev) { 200 | var that = this, doc, args = new(Args)(arguments); 201 | 202 | if (typeof(rev) !== 'string') { 203 | if (doc = this.cache.get(id)) { rev = doc._rev } 204 | else { throw new(Error)("rev needs to be supplied") } 205 | } 206 | 207 | this.query({ 208 | method: 'DELETE', 209 | path: cradle.escape(id), 210 | query: { rev: rev } 211 | }, function (err, res) { 212 | if (! err) { that.cache.purge(id) } 213 | args.callback(err, res); 214 | }); 215 | }; -------------------------------------------------------------------------------- /test/database-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | assert = require('assert'), 3 | events = require('events'), 4 | http = require('http'), 5 | fs = require('fs'), 6 | vows = require('vows'), 7 | macros = require('./helpers/macros'); 8 | 9 | function shouldQueryCouch(name) { 10 | return { 11 | topic: function (c) { return c.database(name) }, 12 | 13 | "info()": { 14 | topic: function (db) { 15 | db.info(this.callback); 16 | }, 17 | "returns a 200": macros.status(200), 18 | "returns database info": function (info) { 19 | assert.equal(info['db_name'], name); 20 | } 21 | }, 22 | "fetching a document by id (GET)": { 23 | topic: function (db) { db.get('mike', this.callback) }, 24 | "returns a 200": macros.status(200), 25 | "returns the document": function (res) { 26 | assert.equal(res.id, 'mike'); 27 | }, 28 | "when not found": { 29 | topic: function (_, db) { db.get('tyler', this.callback) }, 30 | "returns a 404": macros.status(404), 31 | "returns the error": function (err, res) { 32 | assert.isObject(err); 33 | assert.isObject(err.headers); 34 | assert.isUndefined(res); 35 | }, 36 | } 37 | }, 38 | "head()": { 39 | topic: function (db) { db.head('mike', this.callback) }, 40 | "returns the headers": function (res) { 41 | assert.match(res.etag, /^"\d-[a-z0-9]+"$/); 42 | } 43 | }, 44 | "save()": { 45 | "with an id & doc": { 46 | topic: function (db) { 47 | db.save('joe', {gender: 'male'}, this.callback); 48 | }, 49 | "creates a new document (201)": macros.status(201), 50 | "returns the revision": function (res) { 51 | assert.ok(res.rev); 52 | } 53 | }, 54 | "with a doc containing non-ASCII characters": { 55 | topic: function (db) { 56 | db.save('john', {umlauts: 'äöü'}, this.callback); 57 | }, 58 | "creates a new document (201)": macros.status(201) 59 | }, 60 | "with a large doc": { 61 | topic: function (db) { 62 | var text = (function (s) { 63 | for (var i = 0; i < 18; i++) { s += s } 64 | return s; 65 | })('blah'); 66 | 67 | db.save('large-bob', { 68 | gender: 'male', 69 | speech: text 70 | }, this.callback); 71 | }, 72 | "creates a new document (201)": macros.status(201) 73 | }, 74 | "with a '_design' id": { 75 | topic: function (db) { 76 | db.save('_design/horses', { 77 | all: { 78 | map: function (doc) { 79 | if (doc.speed == 72) emit(null, doc); 80 | } 81 | } 82 | }, this.callback); 83 | }, 84 | "creates a doc (201)": macros.status(201), 85 | "returns the revision": function (res) { 86 | assert.ok(res.rev); 87 | }, 88 | "creates a design doc": { 89 | topic: function (res, db) { 90 | db.view('horses/all', this.callback); 91 | }, 92 | "which can be queried": macros.status(200) 93 | } 94 | }, 95 | "without an id (POST)": {}, 96 | }, 97 | "calling save() with an array": { 98 | topic: function (db) { 99 | db.save([{_id: 'tom'}, {_id: 'flint'}], this.callback); 100 | }, 101 | "returns an array of document ids and revs": function (res) { 102 | assert.equal(res[0].id, 'tom'); 103 | assert.equal(res[1].id, 'flint'); 104 | }, 105 | "should bulk insert the documents": { 106 | topic: function (res, db) { 107 | var promise = new(events.EventEmitter); 108 | db.get('tom', function (e, tom) { 109 | db.get('flint', function (e, flint) { 110 | promise.emit('success', tom, flint); 111 | }); 112 | }); 113 | return promise; 114 | }, 115 | "which can then be retrieved": function (e, tom, flint) { 116 | assert.ok(tom._id); 117 | assert.ok(flint._id); 118 | } 119 | } 120 | }, 121 | "getting all documents": { 122 | "with no options": { 123 | topic: function (db) { 124 | db.all(this.callback); 125 | }, 126 | "returns a 200": macros.status(200), 127 | "returns a list of all docs": function (res) { 128 | assert.isArray(res); 129 | assert.isNumber(res.total_rows); 130 | assert.isNumber(res.offset); 131 | assert.isArray(res.rows); 132 | }, 133 | "which can be iterated upon": function (res) { 134 | assert.isFunction(res.forEach); 135 | } 136 | }, 137 | "with { limit: 1 }": { 138 | topic: function (db) { 139 | db.all({ limit: 1 }, this.callback); 140 | }, 141 | "returns a 200": macros.status(200), 142 | "returns a list of all docs": function (res) { 143 | assert.isArray(res); 144 | assert.isNumber(res.total_rows); 145 | assert.isNumber(res.offset); 146 | assert.isArray(res.rows); 147 | assert.lengthOf(res.rows, 1); 148 | }, 149 | "which can be iterated upon": function (res) { 150 | assert.isFunction(res.forEach); 151 | } 152 | }, 153 | "with { keys: ['mike'] }": { 154 | topic: function (db) { 155 | db.all({ keys: ['mike'] }, this.callback); 156 | }, 157 | "returns a 200": macros.status(200), 158 | "returns a list of all docs": function (res) { 159 | assert.isArray(res); 160 | assert.isNumber(res.total_rows); 161 | assert.isNumber(res.offset); 162 | assert.isArray(res.rows); 163 | assert.lengthOf(res.rows, 1); 164 | }, 165 | "which can be iterated upon": function (res) { 166 | assert.isFunction(res.forEach); 167 | } 168 | } 169 | }, 170 | "updating a document (PUT)": { 171 | topic: function (db) { 172 | var promise = new(events.EventEmitter); 173 | db.get('mike', function (err, doc) { 174 | db.save('mike', doc.rev, 175 | {color: doc.color, age: 13}, function (err, res) { 176 | if (! err) promise.emit('success', res, db); 177 | else promise.emit('error', res); 178 | }); 179 | }); 180 | return promise; 181 | }, 182 | "returns a 201": macros.status(201), 183 | "returns the revision": function (res) { 184 | assert.ok(res.rev); 185 | assert.match(res.rev, /^2/); 186 | }, 187 | }, 188 | "deleting a document (DELETE)": { 189 | topic: function (db) { 190 | var promise = new(events.EventEmitter); 191 | db.get('deleteme', function (e, res) { 192 | db.remove('deleteme', res.rev, function (e, res) { 193 | promise.emit('success', res); 194 | }); 195 | }); 196 | return promise; 197 | }, 198 | "returns a 200": macros.status(200) 199 | } 200 | } 201 | } 202 | 203 | var cradle = require('../lib/cradle'); 204 | 205 | vows.describe('cradle/database').addBatch({ 206 | "Connection": { 207 | topic: function () { 208 | return new(cradle.Connection)('127.0.0.1', 5984, {cache: false}); 209 | }, 210 | "database() with no /": shouldQueryCouch('pigs'), 211 | "database() with /": shouldQueryCouch('animals/snorlax') 212 | } 213 | }).export(module); 214 | -------------------------------------------------------------------------------- /lib/cradle.js: -------------------------------------------------------------------------------- 1 | var events = require('events'), 2 | fs = require('fs'), 3 | path = require('path'), 4 | url = require('url'), 5 | http = require('http'), 6 | https = require('https'), 7 | querystring = require('querystring'), 8 | request = require('request'); 9 | 10 | var cradle = exports; 11 | 12 | cradle.extend = require('./cradle/response').extend; 13 | cradle.Response = require('./cradle/response').Response; 14 | cradle.Cache = require('./cradle/cache').Cache; 15 | cradle.Database = require('./cradle/database').Database; 16 | 17 | cradle.host = '127.0.0.1'; 18 | cradle.port = 5984; 19 | cradle.auth = null; 20 | cradle.options = { 21 | cache: true, 22 | raw: false, 23 | timeout: 0, 24 | secure: false, 25 | headers: {} 26 | }; 27 | 28 | cradle.setup = function (settings) { 29 | this.host = settings.host; 30 | this.auth = settings.auth; 31 | if (settings.port) { 32 | this.port = parseInt(settings.port, 10); 33 | } 34 | cradle.merge(this.options, settings); 35 | 36 | return this; 37 | }; 38 | 39 | var protocolPattern = /^(https?):\/\//; 40 | 41 | cradle.Connection = function Connection(/* variable args */) { 42 | var args = Array.prototype.slice.call(arguments), 43 | options = {}, 44 | remote, 45 | match, 46 | host, 47 | port, 48 | auth; 49 | 50 | args.forEach(function (a) { 51 | if (typeof(a) === 'number' || (typeof(a) === 'string' && /^\d{2,5}$/.test(a))) { 52 | port = parseInt(a); 53 | } else if (typeof(a) === 'object') { 54 | options = a; 55 | host = host || options.host; 56 | port = port || options.port; 57 | auth = options.auth; 58 | } else { 59 | host = a; 60 | 61 | if (match = host.match(/^(.+)\:(\d{2,5})$/)) { 62 | host = match[1]; 63 | port = parseInt(match[2]); 64 | } 65 | } 66 | }); 67 | 68 | this.host = host || cradle.host; 69 | this.port = port || cradle.port; 70 | this.auth = auth || cradle.auth; 71 | this.options = cradle.merge({}, cradle.options, options); 72 | 73 | this.options.maxSockets = this.options.maxSockets || 20; 74 | this.options.secure = this.options.secure || this.options.ssl; 75 | 76 | if (protocolPattern.test(this.host)) { 77 | this.protocol = this.host.match(protocolPattern)[1]; 78 | this.host = this.host.replace(protocolPattern, ''); 79 | } 80 | 81 | if (this.protocol === 'https') this.options.secure = true; 82 | 83 | if (this.auth && this.auth.user) { // Deprecation warning 84 | console.log('Warning: "user" & "pass" parameters ignored. Use "username" & "password"'); 85 | } 86 | if (this.options.ssl) { // Deprecation warning 87 | console.log('Warning: "ssl" option is deprecated. Use "secure" instead.'); 88 | } 89 | 90 | this.transport = (this.options.secure) ? https : http; 91 | this.agent = new (this.transport.Agent)({ 92 | host: this.host, 93 | port: this.port 94 | }); 95 | 96 | this.agent.maxSockets = this.options.maxSockets; 97 | }; 98 | 99 | // 100 | // Connection.rawRequest() 101 | // 102 | // This is a base wrapper around connections to CouchDB. Given that it handles 103 | // *all* requests, including those for attachments, it knows nothing about 104 | // JSON serialization and does not presuppose it is sending or receiving JSON 105 | // content 106 | // 107 | // OLDAPI: function (method, path, options, data, headers) 108 | // 109 | cradle.Connection.prototype.rawRequest = function (options, callback) { 110 | var promise = new(events.EventEmitter), 111 | self = this; 112 | 113 | // HTTP Headers 114 | options.headers = options.headers || {}; 115 | 116 | // Set HTTP Basic Auth 117 | if (this.auth) { 118 | options.headers['Authorization'] = "Basic " + new Buffer(this.auth.username + ':' + this.auth.password).toString('base64'); 119 | } 120 | 121 | // Set client-wide headers 122 | Object.keys(this.options.headers).forEach(function (header) { 123 | options.headers[header] = self.options.headers[header]; 124 | }); 125 | 126 | if (options.query && Object.keys(options.query).length) { 127 | for (var k in options.query) { 128 | if (typeof(options.query[k]) === 'boolean') { 129 | options.query[k] = String(options.query[k]); 130 | } 131 | } 132 | options.path += '?' + querystring.stringify(options.query); 133 | } 134 | 135 | options.headers['Connection'] = options.headers['Connection'] || 'keep-alive'; 136 | options.agent = this.agent; 137 | options.uri = this._url(options.path); 138 | delete options.path; 139 | 140 | return request(options, callback || function () { }); 141 | }; 142 | 143 | // 144 | // Connection.close() 145 | // 146 | // Close all underlying sockets associated with the agent for the connection. 147 | // 148 | cradle.Connection.prototype.close = function () { 149 | this.agent.sockets.forEach(function (socket) { 150 | socket.end(); 151 | }); 152 | } 153 | 154 | // 155 | // Connection.request() 156 | // 157 | // This is the entry point for all requests to CouchDB, at this point, 158 | // the database name has been embed in the url, by one of the wrappers. 159 | // 160 | cradle.Connection.prototype.request = function (options, callback) { 161 | var headers = cradle.merge({ host: this.host }, options.headers || {}), 162 | self = this; 163 | 164 | callback = callback || function () {}; 165 | 166 | // HTTP Headers 167 | options.headers = options.headers || {}; 168 | 169 | // 170 | // Handle POST/PUT data. We also convert functions to strings, 171 | // so they can be used in _design documents. 172 | // 173 | if (options.body) { 174 | options.body = JSON.stringify(options.body, function (k, val) { 175 | if (typeof(val) === 'function') { 176 | return val.toString(); 177 | } else { return val } 178 | }); 179 | options.headers["Content-Length"] = Buffer.byteLength(options.body); 180 | options.headers["Content-Type"] = "application/json"; 181 | } 182 | 183 | return this.rawRequest(options, function (err, res, body) { 184 | if (err) { 185 | return callback(err); 186 | } 187 | else if (options.method === 'HEAD') { 188 | return callback(null, res.headers, res.statusCode); 189 | } 190 | else if (body && body.error) { 191 | cradle.extend(body, { headers: res.headers }); 192 | body.headers.status = res.statusCode; 193 | return callback(body); 194 | } 195 | 196 | try { body = JSON.parse(body) } 197 | catch (err) { } 198 | 199 | if (body && body.error) { 200 | cradle.extend(body, { headers: res.headers }); 201 | body.headers.status = res.statusCode; 202 | return callback(body); 203 | } 204 | 205 | callback(null, self.options.raw ? body : new cradle.Response(body, res)); 206 | }); 207 | }; 208 | 209 | // 210 | // The database object 211 | // 212 | // We return an object with database functions, 213 | // closing around the `name` argument. 214 | // 215 | cradle.Connection.prototype.database = function (name) { 216 | return new cradle.Database(name, this) 217 | }; 218 | 219 | // 220 | // Wrapper functions for the server API 221 | // 222 | cradle.Connection.prototype.databases = function (callback) { 223 | this.request({ path: '/_all_dbs' }, callback); 224 | }; 225 | cradle.Connection.prototype.config = function (callback) { 226 | this.request({ path: '/_config' }, callback); 227 | }; 228 | cradle.Connection.prototype.info = function (callback) { 229 | this.request({ path: '/' }, callback); 230 | }; 231 | cradle.Connection.prototype.stats = function (callback) { 232 | this.request({ path: '/_stats' }, callback); 233 | }; 234 | cradle.Connection.prototype.activeTasks = function (callback) { 235 | this.request({ path: '/_active_tasks' }, callback); 236 | }; 237 | cradle.Connection.prototype.uuids = function (count, callback) { 238 | if (typeof(count) === 'function') { 239 | callback = count; 240 | count = null; 241 | } 242 | 243 | this.request({ 244 | method: 'GET', 245 | path: '/_uuids', 246 | query: count ? { count: count } : {} 247 | }, callback); 248 | }; 249 | cradle.Connection.prototype.replicate = function (options, callback) { 250 | this.request({ 251 | method: 'POST', 252 | path: '/_replicate', 253 | query: options 254 | }, callback); 255 | }; 256 | 257 | cradle.Connection.prototype._url = function (path) { 258 | var url = (this.protocol || 'http') + '://' + this.host; 259 | 260 | if (this.port !== 443 && this.port !== 80) { 261 | url += ':' + this.port; 262 | } 263 | 264 | url += path[0] === '/' ? path : ('/' + path); 265 | 266 | return url; 267 | } 268 | 269 | cradle.escape = function (id) { 270 | return ['_design', '_changes', '_temp_view'].indexOf(id.split('/')[0]) === -1 271 | ? querystring.escape(id) 272 | : id; 273 | }; 274 | 275 | cradle.merge = function (target) { 276 | var objs = Array.prototype.slice.call(arguments, 1); 277 | objs.forEach(function (o) { 278 | Object.keys(o).forEach(function (attr) { 279 | if (! o.__lookupGetter__(attr)) { 280 | target[attr] = o[attr]; 281 | } 282 | }); 283 | }); 284 | return target; 285 | }; 286 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cradle 2 | ====== 3 | 4 | A high-level, caching, CouchDB client for Node.js 5 | 6 | introduction 7 | ------------ 8 | 9 | Cradle is an asynchronous javascript client for [CouchDB](http://couchdb.apache.org). 10 | It is somewhat higher-level than most other CouchDB clients, requiring a little less knowledge of CouchDB's REST API. 11 | Cradle also has built-in write-through caching, giving you an extra level of speed, and making document _updates_ and _deletion_ easier. 12 | Cradle was built from the love of CouchDB and Node.js, and tries to make the most out of this wonderful marriage of technologies. 13 | 14 | philosophy 15 | ---------- 16 | 17 | The key concept here is the common ground shared by CouchDB and Node.js, that is, _javascript_. The other important aspect of this marriage is the asynchronous behaviors of both these technologies. Cradle tries to make use of these symmetries, whenever it can. 18 | Cradle's API, although closely knit with CouchDB's, isn't overly so. Whenever the API can be abstracted in a friendlier, simpler way, that's the route it takes. So even though a large part of the `Cradle <--> CouchDB` mappings are one to one, some Cradle functions, such as `save()`, can perform more than one operation, depending on how they are used. 19 | 20 | synopsis 21 | -------- 22 | 23 | ``` js 24 | var cradle = require('cradle'); 25 | var db = new(cradle.Connection)().database('starwars'); 26 | 27 | db.get('vader', function (err, doc) { 28 | doc.name; // 'Darth Vader' 29 | assert.equal(doc.force, 'dark'); 30 | }); 31 | 32 | db.save('skywalker', { 33 | force: 'light', 34 | name: 'Luke Skywalker' 35 | }, function (err, res) { 36 | if (err) { 37 | // Handle error 38 | } else { 39 | // Handle success 40 | } 41 | }); 42 | ``` 43 | 44 | installation 45 | ------------ 46 | 47 | ``` bash 48 | $ npm install cradle 49 | ``` 50 | 51 | API 52 | --- 53 | 54 | Cradle's API builds right on top of Node's asynch API. Every asynch method takes a callback as its last argument. The return value is an `event.EventEmitter`, so listeners can also be optionally added. 55 | 56 | ### Opening a connection ### 57 | 58 | ``` js 59 | new(cradle.Connection)('http://living-room.couch', 5984, { 60 | cache: true, 61 | raw: false 62 | }); 63 | ``` 64 | 65 | _Defaults to `127.0.0.1:5984`_ 66 | 67 | Note that you can also use `cradle.setup` to set a global configuration: 68 | 69 | ``` js 70 | cradle.setup({ 71 | host: 'living-room.couch', 72 | cache: true, 73 | raw: false 74 | }); 75 | 76 | var c = new(cradle.Connection), 77 | cc = new(cradle.Connection)('173.45.66.92'); 78 | ``` 79 | 80 | ### creating a database ### 81 | 82 | ``` js 83 | var db = c.database('starwars'); 84 | db.create(); 85 | ``` 86 | 87 | #### checking for database existence #### 88 | 89 | You can check if a database exists with the `exists()` method. 90 | 91 | ``` js 92 | db.exists(function (err, exists) { 93 | if (err) { 94 | console.log('error', err); 95 | } else if (exists) { 96 | console.log('the force is with you.'); 97 | } else { 98 | console.log('database does not exists.'); 99 | db.create(); 100 | /* populate design documents */ 101 | } 102 | }); 103 | ``` 104 | 105 | ### destroy a database ### 106 | 107 | ``` js 108 | db.destroy(cb); 109 | ``` 110 | 111 | ### fetching a document _(GET)_ ### 112 | 113 | ``` js 114 | db.get('vader', function (err, doc) { 115 | console.log(doc); 116 | }); 117 | ``` 118 | 119 | > If you want to get a specific revision for that document, you can pass it as the 2nd parameter to `get()`. 120 | 121 | Cradle is also able to fetch multiple documents if you have a list of ids, just pass an array to `get`: 122 | 123 | ``` js 124 | db.get(['luke', 'vader'], function (err, doc) { ... }); 125 | ``` 126 | 127 | ### Querying a view ### 128 | 129 | ``` js 130 | db.view('characters/all', function (err, res) { 131 | res.forEach(function (row) { 132 | console.log("%s is on the %s side of the force.", row.name, row.force); 133 | }); 134 | }); 135 | ``` 136 | 137 | #### Querying a row with a specific key #### 138 | Lets suppose that you have a design document that you've created: 139 | 140 | ``` js 141 | db.save('_design/user', { 142 | views: { 143 | byUsername: { 144 | map: 'function (doc) { if (doc.resource === 'User') { emit(doc.username, doc) } }' 145 | } 146 | } 147 | }); 148 | ``` 149 | 150 | In CouchDB you could query this view directly by making an HTTP request to: 151 | 152 | ``` 153 | /_design/User/_view/byUsername/?key="luke" 154 | ``` 155 | 156 | In `cradle` you can make this same query by using the `.view()` database function: 157 | 158 | ``` js 159 | db.view('user/byUsername', { key: 'luke' }, function (err, doc) { 160 | console.dir(doc); 161 | }); 162 | ``` 163 | 164 | ### creating/updating documents ### 165 | 166 | In general, document creation is done with the `save()` method, while updating is done with `merge()`. 167 | 168 | #### creating with an id _(PUT)_ #### 169 | 170 | ``` js 171 | db.save('vader', { 172 | name: 'darth', force: 'dark' 173 | }, function (err, res) { 174 | // Handle response 175 | }); 176 | ``` 177 | 178 | #### creating without an id _(POST)_ #### 179 | 180 | ``` js 181 | db.save({ 182 | force: 'dark', name: 'Darth' 183 | }, function (err, res) { 184 | // Handle response 185 | }); 186 | ``` 187 | 188 | #### updating an existing document with the revision #### 189 | 190 | ``` js 191 | db.save('luke', '1-94B6F82', { 192 | force: 'dark', name: 'Luke' 193 | }, function (err, res) { 194 | // Handle response 195 | }); 196 | ``` 197 | 198 | Note that when saving a document this way, CouchDB overwrites the existing document with the new one. If you want to update only certain fields of the document, you have to fetch it first (with `get`), make your changes, then resave the modified document with the above method. 199 | 200 | If you only want to update one or more attributes, and leave the others untouched, you can use the `merge()` method: 201 | 202 | ``` js 203 | db.merge('luke', {jedi: true}, function (err, res) { 204 | // Luke is now a jedi, 205 | // but remains on the dark side of the force. 206 | }); 207 | ``` 208 | 209 | Note that we didn't pass a `_rev`, this only works because we previously saved a full version of 'luke', and the `cache` option is enabled. 210 | 211 | #### bulk insertion #### 212 | 213 | If you want to insert more than one document at a time, for performance reasons, you can pass an array to `save()`: 214 | 215 | ``` js 216 | db.save([ 217 | { name: 'Yoda' }, 218 | { name: 'Han Solo' }, 219 | { name: 'Leia' } 220 | ], function (err, res) { 221 | // Handle response 222 | }); 223 | ``` 224 | 225 | #### creating views #### 226 | 227 | Here we create a design document named 'characters', with two views: 'all' and 'darkside'. 228 | 229 | ``` js 230 | db.save('_design/characters', { 231 | all: { 232 | map: function (doc) { 233 | if (doc.name) emit(doc.name, doc); 234 | } 235 | }, 236 | darkside: { 237 | map: function (doc) { 238 | if (doc.name && doc.force == 'dark') { 239 | emit(null, doc); 240 | } 241 | } 242 | } 243 | }); 244 | ``` 245 | 246 | These views can later be queried with `db.view('characters/all')`, for example. 247 | 248 | Here we create a temporary view. WARNING: do not use this in production as it is 249 | extremely slow (use it to test views). 250 | 251 | ``` js 252 | db.temporaryView({ 253 | map: function (doc) { 254 | if (doc.color) emit(doc._id, doc); 255 | } 256 | }, function (err, res) { 257 | if (err) console.log(err); 258 | console.log(res); 259 | }); 260 | ``` 261 | 262 | ### creating validation ### 263 | 264 | when saving a design document, cradle guesses you want to create a view, mention views explicitly to work around this. 265 | 266 | ``` js 267 | db.save('_design/laws', { 268 | views: {}, 269 | validate_doc_update: 270 | function (newDoc, oldDoc, usrCtx) { 271 | if(! /^(light|dark|neutral)$/(newDoc.force)) 272 | throw { error: "invalid value", reason:"force must be dark, light, or neutral" } 273 | } 274 | } 275 | }); 276 | ``` 277 | 278 | ### removing documents _(DELETE)_ ### 279 | 280 | To remove a document, you call the `remove()` method, passing the latest document revision. 281 | 282 | ``` js 283 | db.remove('luke', '1-94B6F82', function (err, res) { 284 | // Handle response 285 | }); 286 | ``` 287 | 288 | 289 | If `remove` is called without a revision, and the document was recently fetched from the database, it will attempt to use the cached document's revision, providing caching is enabled. 290 | 291 | Connecting with authentication and SSL 292 | -------------------------------------- 293 | 294 | ``` js 295 | var connection = new(cradle.Connection)('https://couch.io', 443, { 296 | auth: { username: 'john', password: 'fha82l' } 297 | }); 298 | ``` 299 | 300 | or 301 | 302 | ``` js 303 | var connection = new(cradle.Connection)('couch.io', 443, { 304 | secure: true, 305 | auth: { username: 'john', password: 'fha82l' } 306 | }); 307 | ``` 308 | 309 | Changes API 310 | ----------- 311 | 312 | For a one-time `_changes` query, simply call `db.changes` with a callback: 313 | 314 | ``` js 315 | db.changes(function (list) { 316 | list.forEach(function (change) { console.log(change) }); 317 | }); 318 | ``` 319 | 320 | Or if you want to see changes since a specific sequence number: 321 | 322 | ``` js 323 | db.changes({ since: 42 }, function (list) { 324 | ... 325 | }); 326 | ``` 327 | 328 | The callback will receive the list of changes as an *Array*. If you want to include 329 | the affected documents, simply pass `include_docs: true` in the options. 330 | 331 | ### Streaming # 332 | 333 | You can also *stream* changes, by calling `db.changes` without the callback. This API uses the **excellent** [follow][0] library from [IrisCouch][1]: 334 | 335 | ``` js 336 | var feed = db.changes({ since: 42 }); 337 | 338 | feed.on('change', function (change) { 339 | console.log(change); 340 | }); 341 | ``` 342 | 343 | In this case, it returns an instance of `follow.Feed`, which behaves very similarly to node's `EventEmitter` API. For full documentation on the options available to you when monitoring CouchDB with `.changes()` see the [follow documentation][0]. 344 | 345 | 346 | Other API methods 347 | ----------------- 348 | 349 | ### CouchDB Server level ### 350 | 351 | ``` js 352 | new(cradle.Connection)().* 353 | ``` 354 | 355 | - `databases()`: Get list of databases 356 | - `config()`: Get server config 357 | - `info()`: Get server information 358 | - `stats()`: Statistics overview 359 | - `activeTasks()`: Get list of currently active tasks 360 | - `uuids(count)`: Get _count_ list of UUIDs 361 | - `replicate(options)`: Replicate a database. 362 | 363 | ### database level ### 364 | 365 | ``` js 366 | new(cradle.Connection)().database('starwars').* 367 | ``` 368 | 369 | - `info()`: Database information 370 | - `all()`: Get all documents 371 | - `allBySeq()`: Get all documents by sequence 372 | - `compact()`: Compact database 373 | - `viewCleanup()`: Cleanup old view data 374 | - `replicate(target, options)`: Replicate this database to `target`. 375 | 376 | [0]: https://github.com/iriscouch/follow 377 | [1]: http://iriscouch.com 378 | -------------------------------------------------------------------------------- /test/database-attachment-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | assert = require('assert'), 3 | events = require('events'), 4 | http = require('http'), 5 | fs = require('fs'), 6 | vows = require('vows'), 7 | macros = require('./helpers/macros'); 8 | 9 | function mixin(target) { 10 | var objs = Array.prototype.slice.call(arguments, 1); 11 | objs.forEach(function (o) { 12 | for (var attr in o) { target[attr] = o[attr] } 13 | }); 14 | return target; 15 | } 16 | 17 | var cradle = require('../lib/cradle'); 18 | 19 | vows.describe('cradle/database/attachments').addBatch( 20 | macros.database({ cache: true }, { 21 | "saveAttachment()": { 22 | "updates the cache": { 23 | topic: function (db) { 24 | var that = this; 25 | db.save({ _id: 'attachment-cacher' }, function (e, res) { 26 | db.saveAttachment({ 27 | id: res.id, 28 | rev: res.rev 29 | }, { 30 | name: 'cached/foo.txt', 31 | 'Content-Type': 'text/plain', 32 | body: 'Foo!' 33 | }, function () { 34 | that.callback(null, db.cache.get(res.id)); 35 | }); 36 | }); 37 | }, 38 | "with the revision": function (cached) { 39 | assert.match(cached._rev, /^2-/); 40 | }, 41 | "with the _attachments": function (cached) { 42 | assert.ok(cached._attachments); 43 | assert.ok(cached._attachments['cached/foo.txt']); 44 | assert.equal(cached._attachments['cached/foo.txt'].stub, true); 45 | }, 46 | "and is valid enough to re-save": { 47 | topic: function (cached, db) { 48 | var that = this 49 | db.save(mixin({ foo: 'bar' }, cached), function (e,res) { 50 | db.cache.purge(cached._id); 51 | db.get(cached._id, that.callback); 52 | }); 53 | }, 54 | "has the attachment": function (res) { 55 | var att = res._attachments['cached/foo.txt']; 56 | assert.equal(att.stub, true); 57 | assert.equal(att.content_type, 'text/plain'); 58 | assert.equal(att.length, 4); 59 | assert.equal(att.revpos, 2); 60 | }, 61 | "and actually updated the rev": function (res) { 62 | assert.match(res._rev, /^3-/); 63 | } 64 | } 65 | }, 66 | "pulls the revision from the cache if not given": { 67 | topic: function (db) { 68 | var callback = this.callback; 69 | db.save({ _id: 'attachment-saving-pulls-rev-from-cache' }, function (e, res) { 70 | db.saveAttachment(res.id, { 71 | name: 'foo.txt', 72 | contentType: 'text/plain', 73 | body: 'Foo!' 74 | }, callback); 75 | }); 76 | }, 77 | "and saves successfully": macros.status(201) 78 | } 79 | } 80 | }) 81 | ).addBatch( 82 | macros.database({ 83 | "putting an attachment": { 84 | "to an existing document": { 85 | "with given data": { 86 | topic: function (db) { 87 | var that = this; 88 | db.save({_id: 'complete-attachment'}, function (e, res) { 89 | db.saveAttachment({ 90 | id: res.id, 91 | rev: res.rev 92 | }, { 93 | name: 'foo.txt', 94 | 'content-type': 'text/plain', 95 | body: 'Foo!' 96 | }, that.callback); 97 | }); 98 | }, 99 | "returns a 201": macros.status(201), 100 | "returns the revision": function (res) { 101 | assert.ok(res.rev); 102 | assert.match(res.rev, /^2/); 103 | }, 104 | }, 105 | "when piping": { 106 | topic: function (db) { 107 | var callback = this.callback, filestream; 108 | db.save({ _id: 'piped-attachment' }, function (e, res) { 109 | var stream = db.saveAttachment({ 110 | id: res.id, 111 | rev: res.rev 112 | }, { 113 | name: 'foo.txt', 114 | contentType: 'text/plain' 115 | }, callback); 116 | 117 | fs.createReadStream(__dirname + "/../README.md").pipe(stream); 118 | }); 119 | }, 120 | "returns a 201": macros.status(201), 121 | "returns the revision": function (res) { 122 | assert.ok(res.rev); 123 | assert.match(res.rev, /^2/); 124 | } 125 | }, 126 | "with incorrect revision": { 127 | topic: function (db) { 128 | var callback = this.callback, oldRev; 129 | db.save({ _id: 'attachment-incorrect-revision' }, function (e, res) { 130 | oldRev = res.rev; 131 | db.save({_id: 'attachment-incorrect-revision', _rev:res.rev}, function (e, res) { 132 | db.saveAttachment({ 133 | id: res.id, 134 | rev: oldRev 135 | }, { 136 | name: 'foo.txt', 137 | contentType: 'text/plain', 138 | body: 'Foo!' 139 | }, callback); 140 | }); 141 | }); 142 | }, 143 | "returns a 409": macros.status(409) 144 | } 145 | }, 146 | "to a non-existing document": { 147 | topic: function (db) { 148 | db.saveAttachment('standalone-attachment', { 149 | name: 'foo.txt', 150 | contentType: 'text/plain', 151 | body: 'Foo!' 152 | }, this.callback); 153 | }, 154 | "returns a 201": macros.status(201), 155 | "returns the revision": function (res) { 156 | assert.ok(res.rev); 157 | assert.match(res.rev, /^1-/); 158 | } 159 | } 160 | }, 161 | "getting an attachment": { 162 | "when it exists": { 163 | topic: function (db) { 164 | var that = this, doc = { 165 | _id: 'attachment-getter', 166 | _attachments: { 167 | "foo.txt": { 168 | content_type: "text/plain", 169 | data: "aGVsbG8gd29ybGQ=" 170 | } 171 | } 172 | }; 173 | 174 | db.save(doc, function (e, res) { 175 | db.getAttachment('attachment-getter', 'foo.txt', that.callback); 176 | }); 177 | }, 178 | "returns a 200": macros.status(200), 179 | "returns the right mime-type in the header": function (err, res, body) { 180 | assert.equal(res.headers['content-type'], 'text/plain'); 181 | }, 182 | "returns the attachment in the body": function (err, res, body) { 183 | assert.equal(body, "hello world"); 184 | } 185 | }, 186 | "when not found": { 187 | topic: function (db) { 188 | var that = this; 189 | db.save({ _id: 'attachment-not-found' }, function (e, res) { 190 | db.getAttachment('attachment-not-found', 'foo.txt', that.callback); 191 | }); 192 | }, 193 | "returns a 404": macros.status(404) 194 | } 195 | } 196 | }) 197 | ).addBatch( 198 | macros.database({ 199 | "saving an attachment with ETag": { 200 | topic: function (db) { 201 | var id = 'attachment-incorrect-revision', 202 | that = this; 203 | 204 | db.head('attachment-incorrect-revision', function (err, _doc) { 205 | db.saveAttachment({ 206 | id: id, 207 | rev: _doc.etag, 208 | }, { 209 | name: 'etag-foo.txt', 210 | contentType: 'text/plain', 211 | body: 'FOOO!!' 212 | }, that.callback); 213 | }); 214 | }, 215 | "returns a 201": macros.status(201), 216 | "returns the revision": function (res) { 217 | assert.ok(res.rev); 218 | assert.match(res.rev, /^3/); 219 | } 220 | } 221 | }) 222 | ).addBatch( 223 | macros.database({ 224 | "getting an attachment with .pipe()": { 225 | "when it exists": { 226 | topic: function (db) { 227 | var stream = db.getAttachment('piped-attachment', 'foo.txt', this.callback); 228 | stream.pipe(fs.createWriteStream(path.join(__dirname, 'fixtures', 'README.md'))); 229 | }, 230 | "returns a 200": macros.status(200), 231 | "returns the right mime-type in the header": function (err, res, body) { 232 | assert.equal(res.headers['content-type'], 'text/plain'); 233 | }, 234 | "should write the correct attachment to disk": function (err, res, body) { 235 | assert.isNull(err); 236 | 237 | assert.equal( 238 | fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8'), 239 | fs.readFileSync(path.join(__dirname, 'fixtures', 'README.md'), 'utf8') 240 | ); 241 | } 242 | }, 243 | "when not found": { 244 | topic: function (db) { 245 | var stream = db.getAttachment('attachment-not-found', 'foo.txt'); 246 | stream.pipe(fs.createWriteStream(path.join(__dirname, 'fixtures', 'not-found.txt'))); 247 | 248 | stream.on('end', this.callback); 249 | }, 250 | "should write the error to disk": function () { 251 | var result = JSON.parse( 252 | fs.readFileSync(path.join(__dirname, 'fixtures', 'not-found.txt'), 'utf8') 253 | ); 254 | 255 | assert.equal(result.reason, 'Document is missing attachment'); 256 | } 257 | } 258 | } 259 | }) 260 | ).addBatch( 261 | macros.database({ 262 | "removeAttachment()": { 263 | "when it exists": { 264 | topic: function (db) { 265 | var that = this; 266 | db.get('attachment-getter', function (err, doc) { 267 | db.removeAttachment(doc, 'foo.txt', that.callback); 268 | }); 269 | }, 270 | "should remove the attachment": function (err, res) { 271 | assert.isNull(err); 272 | assert.ok(res.ok); 273 | } 274 | }, 275 | "when the document doesnt exist": { 276 | topic: function (db) { 277 | db.removeAttachment({ 278 | id: 'YUNOEXIST', 279 | rev: '2-6bb732ce2ecc7ac85567b444b10590b4' 280 | }, 'foo.txt', this.callback.bind(this, null)); 281 | }, 282 | "should respond with the correct error": function (_, err) { 283 | assert.isObject(err); 284 | assert.equal(err.headers.status, 500); 285 | assert.equal(err.error, '{not_found,missing}'); 286 | } 287 | } 288 | } 289 | }) 290 | ).addBatch( 291 | macros.database({ cache: true }, { 292 | "removeAttachment()": { 293 | "when it exists": { 294 | topic: function (db) { 295 | var that = this; 296 | db.get('attachment-cacher', function (err, doc) { 297 | db.removeAttachment(doc._id, 'cached/foo.txt', that.callback); 298 | }); 299 | }, 300 | "should remove the attachment": function (err, res) { 301 | assert.isNull(err); 302 | assert.ok(res.ok); 303 | } 304 | }, 305 | "when the document doesnt exist": { 306 | topic: function (db) { 307 | db.removeAttachment({ 308 | id: 'YUNOEXIST', 309 | rev: '2-6bb732ce2ecc7ac85567b444b10590b4' 310 | }, 'foo.txt', this.callback.bind(this, null)); 311 | }, 312 | "should respond with the correct error": function (_, err) { 313 | assert.isObject(err); 314 | assert.equal(err.headers.status, 500); 315 | assert.equal(err.error, '{not_found,missing}'); 316 | } 317 | } 318 | } 319 | }) 320 | ).export(module); 321 | --------------------------------------------------------------------------------