├── Makefile ├── package.json ├── LICENSE ├── test ├── scripts │ └── prepare-db.js ├── response-test.js ├── cache-test.js └── cradle-test.js ├── lib ├── cradle │ ├── cache.js │ └── response.js └── cradle.js └── README.md /Makefile: -------------------------------------------------------------------------------- 1 | default: test 2 | 3 | # 4 | # Run all tests 5 | # 6 | test: 7 | node test/scripts/prepare-db.js 8 | vows test/*-test.js 9 | 10 | .PHONY: test 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "cradle", 3 | "description" : "the high-level, caching, CouchDB library", 4 | "url" : "http://cloudhead.io/cradle", 5 | "keywords" : ["couchdb", "database", "couch"], 6 | "author" : "Alexis Sellier ", 7 | "contributors" : [], 8 | "dependencies" : { "vargs": ">=0.1.0", "vows": ">=0.4.0" }, 9 | "version" : "0.3.1", 10 | "main" : "./lib/cradle", 11 | "directories" : { "test": "./test" }, 12 | "engines" : { "node": ">=0.2.0" } 13 | } 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/scripts/prepare-db.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | events = require('events'); 3 | 4 | var client = http.createClient(5984, '127.0.0.1'); 5 | 6 | function r(method, url, doc) { 7 | var promise = new(events.EventEmitter); 8 | var request = client.request(method, url, {}); 9 | 10 | if (doc) { request.write(JSON.stringify(doc)) } 11 | 12 | request.addListener('response', function (res) { 13 | var body = ''; 14 | 15 | res.setEncoding('utf8'); 16 | res.addListener('data', function (chunk) { 17 | body += (chunk || ''); 18 | }).addListener('end', function () { 19 | var obj, response; 20 | 21 | try { obj = JSON.parse(body) } 22 | catch (e) { return promise.emit('error', e) } 23 | 24 | promise.emit('success', obj); 25 | }); 26 | }); 27 | request.end(); 28 | return promise; 29 | } 30 | 31 | ['rabbits', 'pigs','badgers'].forEach(function (db) { 32 | r('DELETE', '/' + db).addListener('success', function () { 33 | if (db === 'pigs') { 34 | r('PUT', '/pigs').addListener('success', function () { 35 | r('PUT', '/pigs/_design/pigs', { 36 | _id: '_design/pigs', views: { 37 | all: { map: "function (doc) { if (doc.color) emit(doc._id, doc) }" } 38 | } 39 | }); 40 | r('PUT', '/pigs/mike', {color: 'pink'}); 41 | r('PUT', '/pigs/bill', {color: 'blue'}); 42 | }); 43 | } else if (db === 'rabbits') { 44 | r('PUT', '/rabbits').addListener('success', function () { 45 | r('PUT', '/rabbits/alex', {color: 'blue'}); 46 | }); 47 | } 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/response-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | sys = require('sys'), 3 | assert = require('assert'), 4 | events = require('events'), 5 | http = require('http'), 6 | fs = require('fs'); 7 | 8 | require.paths.unshift(path.join(__dirname, '..', 'lib')); 9 | 10 | var vows = require('vows'); 11 | var cradle = require('cradle'); 12 | 13 | var document = { _rev: '2-76be', _id: 'f6av8', name: 'buzz', age: 99 }; 14 | 15 | vows.describe('cradle/Response').addBatch({ 16 | 'A cradle.Response instance': { 17 | 'from a document': { 18 | topic: new(cradle.Response)(document), 19 | 20 | 'should only have the original keys': function (topic) { 21 | assert.length (Object.keys(topic), 4); 22 | assert.equal (topic.name, 'buzz'); 23 | assert.equal (topic.age, 99); 24 | assert.deepEqual (document, topic); 25 | }, 26 | 'should own the keys': function (topic) { 27 | assert.include (topic, 'name'); 28 | assert.include (topic, 'age'); 29 | }, 30 | 'should return the original document, when `json` is called': function (topic) { 31 | assert.isObject (topic.json); 32 | assert.deepEqual (topic.json, document); 33 | assert.isUndefined (topic.json.json); 34 | assert.isUndefined (topic.headers); 35 | assert.length (Object.keys(topic.json), 4); 36 | }, 37 | 'when using a `for .. in` loop, should only return the original keys': function (topic) { 38 | var keys = []; 39 | for (var k in topic) { keys.push(k) } 40 | 41 | assert.length (keys, 4); 42 | assert.include (keys, 'name'); 43 | assert.include (keys, 'age'); 44 | }, 45 | 'should stringify': function (topic) { 46 | var expected = JSON.stringify(document); 47 | assert.equal (topic.toString(), expected); 48 | assert.equal (JSON.stringify(topic), expected); 49 | }, 50 | 'should respond to both `id` and `_id`': function (topic) { 51 | assert.equal (topic.id, 'f6av8'); 52 | assert.equal (topic._id, 'f6av8'); 53 | }, 54 | 'should respond to both `rev` and `_rev`': function (topic) { 55 | assert.equal (topic.rev, '2-76be'); 56 | assert.equal (topic._rev, '2-76be'); 57 | }, 58 | 'should have Response as its constructor': function (topic) { 59 | assert.equal (topic.constructor, cradle.Response); 60 | } 61 | } 62 | } 63 | }).export(module); 64 | 65 | -------------------------------------------------------------------------------- /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/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 | Object.keys(json).forEach(function (k) { 17 | Object.defineProperty(obj.__proto__, k, { 18 | value: json[k], 19 | enumerable: false 20 | }); 21 | }); 22 | } else if (json.results) { 23 | obj = json.results.slice(0); 24 | obj.__proto__ = new(Array); 25 | obj.last_seq = json.last_seq; 26 | } else if (json.uuids) { 27 | obj = json.uuids; 28 | obj.__proto__ = new(Array); 29 | } else if (Array.isArray(json)) { 30 | obj = json.slice(0); 31 | obj.__proto__ = new(Array); 32 | } else { 33 | obj = {}; 34 | obj.__proto__ = new(Object); 35 | Object.keys(json).forEach(function (k) { 36 | obj[k] = json[k]; 37 | }); 38 | } 39 | 40 | // If the response was originally a document, 41 | // give access to it via the 'json' getter. 42 | if (!Array.isArray(json) && !obj.json) { 43 | Object.defineProperty(obj, 'json', { 44 | value: json, 45 | enumerable: false 46 | }); 47 | } 48 | 49 | if (response) { 50 | headers = { status: response.statusCode }; 51 | Object.keys(response.headers).forEach(function (k) { 52 | headers[k] = response.headers[k]; 53 | }); 54 | 55 | // Set the 'headers' special field, with the response's status code. 56 | exports.extend(obj, 'headers' in obj ? { _headers: headers } 57 | : { headers: headers }); 58 | } 59 | 60 | // Alias '_rev' and '_id' 61 | if (obj.id && obj.rev) { 62 | exports.extend(obj, { _id: obj.id, _rev: obj.rev }); 63 | } else if (obj._id && obj._rev) { 64 | exports.extend(obj, { id: obj._id, rev: obj._rev }); 65 | } 66 | 67 | if (Array.isArray(obj) && json.rows) { 68 | exports.extend(obj, exports.collectionPrototype); 69 | } 70 | exports.extend(obj, exports.basePrototype); 71 | 72 | // Set the constructor to be this function 73 | Object.defineProperty(obj, 'constructor', { 74 | value: arguments.callee 75 | }); 76 | 77 | return obj; 78 | }; 79 | 80 | this.basePrototype = { 81 | toJSON: function () { 82 | return this.json; 83 | }, 84 | toString: function () { 85 | return JSON.stringify(this.json); 86 | } 87 | }; 88 | 89 | this.collectionPrototype = { 90 | forEach: function (f) { 91 | for (var i = 0, value; i < this.length; i++) { 92 | value = this[i].doc || this[i].json || this[i].value || this[i]; 93 | if (f.length === 1) { 94 | f.call(this[i], value); 95 | } else { 96 | f.call(this[i], this[i].key, value, this[i].id); 97 | } 98 | } 99 | }, 100 | map: function (f) { 101 | var ary = []; 102 | if (f.length === 1) { 103 | this.forEach(function (a) { ary.push(f.call(this, a)) }); 104 | } else { 105 | this.forEach(function () { ary.push(f.apply(this, arguments)) }); 106 | } 107 | return ary; 108 | }, 109 | toArray: function () { 110 | return this.map(function (k, v) { return v }); 111 | } 112 | }; 113 | 114 | this.extend = function (obj, properties) { 115 | var descriptor = Object.keys(properties).reduce(function (hash, k) { 116 | hash[k] = { 117 | value: properties[k], 118 | enumerable: false 119 | }; 120 | return hash; 121 | }, {}); 122 | return Object.defineProperties(obj, descriptor); 123 | }; 124 | -------------------------------------------------------------------------------- /test/cache-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | sys = require('sys'), 3 | assert = require('assert'), 4 | events = require('events'); 5 | 6 | require.paths.unshift(path.join(__dirname, '..', 'lib')); 7 | 8 | var vows = require('vows'); 9 | var cradle = require('cradle'); 10 | 11 | vows.describe('cradle/Cache').addBatch({ 12 | 'A cradle.Connection instance with a *cacheSize* specified': { 13 | topic: new(cradle.Connection)({ cache: true, cacheSize: 16 }), 14 | 15 | 'should set the database cache size appropriately': function (topic) { 16 | assert.equal (topic.database('random').cache.size, 16); 17 | } 18 | }, 19 | 'A cradle.Cache instance with a *cacheSize* of `8`': { 20 | topic: new(cradle.Cache)({ cache: true, cacheSize: 8 }), 21 | 22 | 'should be able to store 8 keys': function (topic) { 23 | for (var i = 0; i < 8; i++) { topic.save(i.toString(), {}) } 24 | assert.length (Object.keys(topic.store), 8); 25 | }, 26 | 'if more than 8 keys are set': { 27 | topic: function (cache) { 28 | var that = this; 29 | cache.save('17af', {}); 30 | process.nextTick(function () { 31 | that.callback(null, cache); 32 | }); 33 | }, 34 | 'there should still be 8 keys in the store': function (cache) { 35 | assert.length (Object.keys(cache.store), 8); 36 | } 37 | }, 38 | 'if an extra 8 keys are set': { 39 | topic: function (cache) { 40 | var that = this; 41 | setTimeout(function () { 42 | for (var i = 1; i <= 8; i++) { cache.save((i * 10).toString(), 'extra') } 43 | process.nextTick(function () { 44 | that.callback(null, cache); 45 | }); 46 | }, 30); 47 | }, 48 | 'it should purge the initial 8 keys, and keep the new ones': function (cache) { 49 | Object.keys(cache.store).forEach(function (k) { 50 | assert.equal (cache.store[k].document, 'extra'); 51 | }); 52 | } 53 | }, 54 | }, 55 | 'Another cradle.Cache instance': { 56 | topic: new(cradle.Cache)({ cache: true, cacheSize: 8 }), 57 | 'after setting 8 keys on it, accessing 3 of them, and adding 5 more': { 58 | topic: function (cache) { 59 | var that = this; 60 | for (var i = 0; i < 8; i++) { cache.save(i.toString(), { id: i.toString() }) } 61 | setTimeout(function () { 62 | cache.get('2'); 63 | cache.get('5'); 64 | cache.get('1'); 65 | for (var i = 8; i < 13; i++) { cache.save(i.toString(), { id: i.toString() }) } 66 | process.nextTick(function () { 67 | that.callback(null, cache); 68 | }); 69 | }, 10); 70 | }, 71 | 'it should have the 3 accessed ones, with the 5 new ones': function (cache) { 72 | assert.length (Object.keys(cache.store), 8); 73 | assert.isTrue (cache.has('2')); 74 | assert.isTrue (cache.has('5')); 75 | assert.isTrue (cache.has('1')); 76 | for (var i = 8; i < 13; i++) { cache.has(i.toString()) } 77 | } 78 | } 79 | }, 80 | 'A cradle.Cache instance with a *cacheSize* of *1024*': { 81 | topic: new(cradle.Cache)({ cache: true, cacheSize: 1024 }), 82 | 83 | 'setting 4096 keys': { 84 | topic: function (cache) { 85 | var that = this; 86 | var keys = 0; 87 | var timer = setInterval(function () { 88 | if (keys >= 4096) { 89 | clearInterval(timer); 90 | process.nextTick(function () { that.callback(null, cache) }) 91 | } 92 | cache.save(keys.toString(), {}) 93 | keys++; 94 | }, 1); 95 | }, 96 | 'should result in 1025 keys': function (cache) { 97 | assert.equal (Object.keys(cache.store).length, 1025); 98 | assert.equal (cache.keys, 1025); 99 | } 100 | } 101 | 102 | } 103 | }).export(module); 104 | 105 | -------------------------------------------------------------------------------- /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 | var cradle = require('cradle'); 24 | var db = new(cradle.Connection)().database('starwars'); 25 | 26 | db.get('vador', function (err, doc) { 27 | doc.name; // 'Darth Vador' 28 | assert.equal(doc.force, 'dark'); 29 | }); 30 | 31 | db.save('skywalker', { 32 | force: 'light', 33 | name: 'Luke Skywalker' 34 | }, function (err, res) { 35 | if (err) { 36 | // Handle error 37 | } else { 38 | // Handle success 39 | } 40 | }); 41 | 42 | installation 43 | ------------ 44 | 45 | $ npm install cradle 46 | 47 | API 48 | --- 49 | 50 | 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. 51 | 52 | ### Opening a connection ### 53 | 54 | new(cradle.Connection)('http://living-room.couch', 5984, { 55 | cache: true, 56 | raw: false 57 | }); 58 | 59 | _Defaults to `127.0.0.1:5984`_ 60 | 61 | Note that you can also use `cradle.setup` to set a global configuration: 62 | 63 | cradle.setup({host: 'http://living-room.couch', 64 | options: {cache: true, raw: false}}); 65 | var c = new(cradle.Connection), 66 | cc = new(cradle.Connection)('173.45.66.92'); 67 | 68 | ### creating a database ### 69 | 70 | var db = c.database('starwars'); 71 | db.create(); 72 | 73 | > You can check if a database exists with the `exists()` method. 74 | 75 | ### fetching a document _(GET)_ ### 76 | 77 | db.get('vador', function (err, doc) { 78 | sys.puts(doc); 79 | }); 80 | 81 | > If you want to get a specific revision for that document, you can pass it as the 2nd parameter to `get()`. 82 | 83 | Cradle is also able to fetch multiple documents if you have a list of ids, just pass an array to `get`: 84 | 85 | db.get(['luke', 'vador'], function (err, doc) { ... }); 86 | 87 | ### Querying a view ### 88 | 89 | db.view('characters/all', function (err, res) { 90 | res.forEach(function (row) { 91 | sys.puts(row.name + " is on the " + 92 | row.force + " side of the force."); 93 | }); 94 | }); 95 | 96 | ### creating/updating documents ### 97 | 98 | In general, document creation is done with the `save()` method, while updating is done with `merge()`. 99 | 100 | #### creating with an id _(PUT)_ #### 101 | 102 | db.save('vador', { 103 | name: 'darth', force: 'dark' 104 | }, function (err, res) { 105 | // Handle response 106 | }); 107 | 108 | #### creating without an id _(POST)_ #### 109 | 110 | db.save({ 111 | force: 'dark', name: 'Darth' 112 | }, function (err, res) { 113 | // Handle response 114 | }); 115 | 116 | #### updating an existing document with the revision #### 117 | 118 | db.save('luke', '1-94B6F82', { 119 | force: 'dark', name: 'Luke' 120 | }, function (err, res) { 121 | // Handle response 122 | }); 123 | 124 | 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. 125 | 126 | If you only want to update one or more attributes, and leave the others untouched, you can use the `merge()` method: 127 | 128 | db.merge('luke', {jedi: true}, function (err, res) { 129 | // Luke is now a jedi, 130 | // but remains on the dark side of the force. 131 | }); 132 | 133 | 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. 134 | 135 | #### bulk insertion #### 136 | 137 | If you want to insert more than one document at a time, for performance reasons, you can pass an array to `save()`: 138 | 139 | db.save([ 140 | {name: 'Yoda'}, 141 | {name: 'Han Solo'}, 142 | {name: 'Leia'} 143 | ], function (err, res) { 144 | // Handle response 145 | }); 146 | 147 | #### creating views #### 148 | 149 | Here we create a design document named 'characters', with two views: 'all' and 'darkside'. 150 | 151 | db.save('_design/characters', { 152 | all: { 153 | map: function (doc) { 154 | if (doc.name) emit(doc.name, doc); 155 | } 156 | }, 157 | darkside: { 158 | map: function (doc) { 159 | if (doc.name && doc.force == 'dark') { 160 | emit(null, doc); 161 | } 162 | } 163 | } 164 | }); 165 | 166 | These views can later be queried with `db.view('characters/all')`, for example. 167 | 168 | ### removing documents _(DELETE)_ ### 169 | 170 | To remove a document, you call the `remove()` method, passing the latest document revision. 171 | 172 | db.remove('luke', '1-94B6F82', function (err, res) { 173 | // Handle response 174 | }); 175 | 176 | 177 | 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. 178 | 179 | Changes API 180 | ----------- 181 | 182 | For a one-time `_changes` query, simply call `db.changes` with a callback: 183 | 184 | db.changes(function (list) { 185 | list.forEach(function (change) { console.log(change) }); 186 | }); 187 | 188 | Or if you want to see changes since a specific sequence number: 189 | 190 | db.changes({ since: 42 }, function (list) { 191 | ... 192 | }); 193 | 194 | The callback will receive the list of changes as an *Array*. If you want to include 195 | the affected documents, simply pass `include_docs: true` in the options. 196 | 197 | ### Streaming # 198 | 199 | You can also *stream* changes, by calling `db.changes` without the callback: 200 | 201 | db.changes({ since: 42 }).on('response', function (res) { 202 | res.on('data', function (change) { 203 | console.log(change); 204 | }); 205 | res.on('end', function () { ... }); 206 | }); 207 | 208 | In this case, it returns an `EventEmitter`, which behaves very similarly to node's `Stream` API. 209 | 210 | 211 | Other API methods 212 | ----------------- 213 | 214 | ### CouchDB Server level ### 215 | 216 | new(cradle.Connection)().* 217 | 218 | - `databases()`: Get list of databases 219 | - `config()`: Get server config 220 | - `info()`: Get server information 221 | - `stats()`: Statistics overview 222 | - `activeTasks()`: Get list of currently active tasks 223 | - `uuids(count)`: Get _count_ list of UUIDs 224 | 225 | ### database level ### 226 | 227 | new(cradle.Connection)().database('starwars').* 228 | 229 | - `info()`: Database information 230 | - `all()`: Get all documents 231 | - `allBySeq()`: Get all documents by sequence 232 | - `compact()`: Compact database 233 | - `viewCleanup()`: Cleanup old view data 234 | 235 | -------------------------------------------------------------------------------- /lib/cradle.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | require.paths.unshift(path.join(__dirname, 'cradle')); 4 | 5 | var sys = require("sys"), 6 | http = require("http"), 7 | events = require('events'), 8 | fs = require("fs"), 9 | url = require('url'), 10 | buffer = require('buffer'); 11 | 12 | var querystring = require('querystring'); 13 | var Args = require('vargs').Constructor; 14 | 15 | var cradle = exports; 16 | 17 | cradle.extend = require('response').extend; 18 | cradle.Response = require('response').Response; 19 | cradle.Cache = require('cache').Cache; 20 | 21 | cradle.host = '127.0.0.1'; 22 | cradle.port = 5984; 23 | cradle.options = { 24 | cache: true, 25 | raw: false, 26 | timeout: 0 27 | }; 28 | 29 | cradle.setup = function (settings) { 30 | this.host = settings.host; 31 | this.port = parseInt(settings.port); 32 | cradle.merge(this.options, settings); 33 | 34 | return this; 35 | }; 36 | 37 | cradle.Connection = function Connection(/* variable args */) { 38 | var args = Array.prototype.slice.call(arguments), 39 | host, port, remote, auth, options = {}; 40 | 41 | if (typeof(args[0]) === 'string') { 42 | remote = args[0].replace('http://', '').split(':'); 43 | host = remote[0]; 44 | port = parseInt(remote[1]); 45 | } 46 | 47 | // An options hash was passed 48 | if (args.length === 1 && typeof(args[0]) === 'object') { 49 | options = args[0]; 50 | host = options.host; 51 | port = parseInt(options.port); 52 | auth = options.auth; 53 | // The host and port were passed separately 54 | } else if (args.length >= 2) { 55 | host = host || args[0]; 56 | port = parseInt(args[1]); 57 | options = args[2] || {}; 58 | auth = options.auth; 59 | } 60 | 61 | this.host = host || cradle.host; 62 | this.port = port || cradle.port; 63 | this.auth = auth || cradle.auth; 64 | this.options = cradle.merge({}, cradle.options, options); 65 | 66 | this.socket = http.createClient(this.port, this.host); 67 | this.socket.setTimeout(options.timeout); 68 | }; 69 | 70 | // 71 | // Connection.rawRequest() 72 | // 73 | // This is a base wrapper around connections to CouchDB. Given that it handles 74 | // *all* requests, including those for attachments, it knows nothing about 75 | // JSON serialization and does not presuppose it is sending or receiving JSON 76 | // content 77 | // 78 | cradle.Connection.prototype.rawRequest = function (method, path, options, data, headers) { 79 | var promise = new(events.EventEmitter), request, that = this; 80 | 81 | // HTTP Headers 82 | headers = cradle.merge({ host: this.host }, headers || {}); 83 | 84 | // Set HTTP Basic Auth 85 | if (this.auth) { 86 | headers['Authorization'] = "Basic " + new Buffer(this.auth.user + ':' + this.auth.pass).toString('base64'); 87 | } 88 | 89 | path = (path || '/').replace('http://','').replace(/\/{2,}/g, '/'); 90 | if (path.charAt(0) !== '/') { path = '/' + path } 91 | 92 | if (options) { 93 | for (var k in options) { 94 | if (typeof(options[k]) === 'boolean') { 95 | options[k] = String(options[k]); 96 | } 97 | } 98 | path += '?' + querystring.stringify(options); 99 | } 100 | 101 | request = this.socket.request(method.toUpperCase(), path, headers); 102 | 103 | if (data && data.addListener) { headers['Transfer-Encoding'] = 'chunked' } 104 | 105 | headers['Connection'] = 'keep-alive'; 106 | 107 | request.on('response', function (res) { 108 | promise.emit('response', res); 109 | res.on('data', function (chunk) { promise.emit('data', chunk) }); 110 | res.on('end', function () { promise.emit('end') }); 111 | }); 112 | 113 | if (data) { 114 | if (data.addListener) { 115 | data.on('data', function (chunk) { request.write(chunk) }); 116 | data.on('end', function () { request.end() }); 117 | } else { 118 | request.write(data, 'utf8'); 119 | request.end(); 120 | } 121 | } else { 122 | request.end(); 123 | } 124 | 125 | return promise; 126 | } 127 | // 128 | // Connection.request() 129 | // 130 | // This is the entry point for all requests to CouchDB, at this point, 131 | // the database name has been embed in the url, by one of the wrappers. 132 | // 133 | cradle.Connection.prototype.request = function (method, path, /* [options], [data], [headers] */ callback) { 134 | var request, that = this, args = Array.prototype.slice.call(arguments, 2); 135 | 136 | if (typeof(callback = args.pop()) !== 'function') { 137 | args.push(callback); 138 | callback = function () {}; 139 | } 140 | 141 | var options = args.shift() || {}, 142 | data = args.shift() || null, 143 | headers = cradle.merge({ host: this.host }, args.shift() || {}); 144 | 145 | // 146 | // Handle POST/PUT data. We also convert functions to strings, 147 | // so they can be used in _design documents. 148 | // 149 | if (data) { 150 | data = JSON.stringify(data, function (k, val) { 151 | if (typeof(val) === 'function') { 152 | return val.toString(); 153 | } else { return val } 154 | }); 155 | headers["Content-Length"] = Buffer.byteLength(data); 156 | headers["Content-Type"] = "application/json"; 157 | } 158 | 159 | request = this.rawRequest(method, path, options, data, headers); 160 | 161 | // 162 | // Initialize the request, send the body, and finally, 163 | // dispatch the request. 164 | // 165 | request.on('response', function (res) { 166 | var body = []; 167 | 168 | res.setEncoding('utf8'); 169 | res.on('data', function (chunk) { 170 | chunk && body.push(chunk); 171 | }).on('end', function () { 172 | var json, response; 173 | 174 | if (method === 'HEAD') { 175 | callback(null, res.headers, res.statusCode); 176 | } else { 177 | try { json = JSON.parse(body.join('')) } 178 | catch (e) { return callback(e) } 179 | 180 | 181 | if (json.error) { 182 | cradle.extend(json, { headers: res.headers }); 183 | json.headers.status = res.statusCode; 184 | callback(json); 185 | } else { 186 | // If the `raw` option was set, we return the parsed 187 | // body as-is. If not, we wrap it in a `Response` object. 188 | callback(null, that.options.raw ? json : new(cradle.Response)(json, res)); 189 | } 190 | } 191 | }); 192 | }); 193 | }; 194 | 195 | // 196 | // The database object 197 | // 198 | // We return an object with database functions, 199 | // closing around the `name` argument. 200 | // 201 | cradle.Connection.prototype.database = function (name) { 202 | var that = this, connection = this; 203 | 204 | return { 205 | name: name, 206 | // 207 | // The database document cache. 208 | // 209 | cache: new(cradle.Cache)(that.options), 210 | 211 | // A wrapper around `Connection.request`, 212 | // which prepends the database name. 213 | query: function (method, path /* [options], [data], [headers], [callback] */) { 214 | var args = Array.prototype.slice.call(arguments, 2); 215 | that.request.apply(that, [method, [name, path].join('/')].concat(args)); 216 | }, 217 | exists: function (callback) { 218 | this.query('GET', '/', function (err, res) { 219 | if (err) { 220 | if (err.headers.status === 404) { 221 | callback(null, false); 222 | } else { 223 | callback(err); 224 | } 225 | } else { 226 | callback(null, true); 227 | } 228 | }); 229 | }, 230 | 231 | // Fetch either a single document from the database, or cache, 232 | // or multiple documents from the database. 233 | // If it's a single doc from the db, attempt to save it to the cache. 234 | get: function (id, rev) { 235 | var that = this, options = null, 236 | args = new(Args)(arguments); 237 | 238 | if (Array.isArray(id)) { // Bulk GET 239 | this.query('POST', '/_all_docs', { include_docs: true }, { keys: id }, 240 | function (err, res) { args.callback(err, res) }); 241 | } else { 242 | if (rev && args.length === 2) { 243 | if (typeof(rev) === 'string') { options = { rev: rev } } 244 | else if (typeof(rev) === 'object') { options = rev } 245 | } else if (this.cache.has(id)) { 246 | return args.callback(null, this.cache.get(id)); 247 | } 248 | this.query('GET', id, options, function (err, res) { 249 | if (! err) that.cache.save(res.id, res.json); 250 | args.callback(err, res); 251 | }); 252 | } 253 | }, 254 | 255 | save: function (/* [id], [rev], doc | [doc, ...] */) { 256 | var args = new(Args)(arguments), 257 | array = args.all.slice(0), doc, id, rev; 258 | 259 | if (Array.isArray(args.first)) { 260 | doc = args.first; 261 | } else { 262 | doc = array.pop(), 263 | id = array.shift(), 264 | rev = array.shift(); 265 | } 266 | this._save(id, rev, doc, args.callback); 267 | }, 268 | _save: function (id, rev, doc, callback) { 269 | var options = connection.options; 270 | var document = {}, that = this; 271 | 272 | // Bulk Insert 273 | if (Array.isArray(doc)) { 274 | document.docs = doc; 275 | if (options.allOrNothing) { document.all_or_nothing = true } 276 | this.query('POST', '/_bulk_docs', {}, document, callback); 277 | } else { 278 | // PUT a single document, with an id (Create or Update) 279 | if (id) { 280 | // Design document 281 | if (/^_design\/\w+$/.test(id) && !('views' in doc)) { 282 | document.language = "javascript"; 283 | document.views = doc; 284 | } else { 285 | document = doc; 286 | } 287 | // Try to set the '_rev' attribute of the document. 288 | // If it wasn't passed, attempt to retrieve it from the cache. 289 | rev && (document._rev = rev); 290 | 291 | if (document._rev) { 292 | this.put(id, document, callback); 293 | } else if (this.cache.has(id)) { 294 | document._rev = this.cache.get(id)._rev; 295 | this.put(id, document, callback); 296 | } else { 297 | // Attempt to create a new document. If it fails, 298 | // because an existing document with that _id exists (409), 299 | // perform a HEAD, to get the _rev, and try to re-save. 300 | this.put(id, document, function (e, res) { 301 | if (e && e.headers.status === 409) { // Conflict 302 | that.head(id, function (e, headers) { 303 | document._rev = headers['etag'].slice(1, -1); 304 | that.put(id, document, callback); 305 | }); 306 | } else { callback(e, res) } 307 | }); 308 | } 309 | // POST a single document, without an id (Create) 310 | } else { 311 | this.post(doc, callback); 312 | } 313 | } 314 | }, 315 | 316 | merge: function (/* [id], doc */) { 317 | var args = Array.prototype.slice.call(arguments), 318 | callback = args.pop(), 319 | doc = args.pop(), 320 | id = args.pop() || doc._id; 321 | 322 | this._merge(id, doc, callback); 323 | }, 324 | _merge: function (id, doc, callback) { 325 | var that = this; 326 | this.get(id, function (e, res) { 327 | if (e) { return callback(e) } 328 | doc = cradle.merge({}, res.json || res, doc); 329 | that.save(id, res._rev, doc, callback); 330 | }); 331 | }, 332 | 333 | // 334 | // PUT a document, and write through cache 335 | // 336 | put: function (id, doc, callback) { 337 | var cache = this.cache; 338 | if (typeof(id) !== 'string') { throw new(TypeError)("id must be a string") } 339 | this.query('PUT', id, null, doc, function (e, res) { 340 | if (! e) { cache.save(id, cradle.merge({}, doc, { _rev: res.rev })) } 341 | callback && callback(e, res); 342 | }); 343 | }, 344 | 345 | // 346 | // POST a document, and write through cache 347 | // 348 | post: function (doc, callback) { 349 | var cache = this.cache; 350 | this.query('POST', '/', null, doc, function (e, res) { 351 | if (! e) { cache.save(res.id, cradle.merge({}, doc, { _rev: res.rev })) } 352 | callback && callback(e, res); 353 | }); 354 | }, 355 | 356 | // 357 | // Perform a HEAD request 358 | // 359 | head: function (id, callback) { 360 | this.query('HEAD', id, null, callback); 361 | }, 362 | 363 | insert: function () { 364 | throw new(Error)("`insert` is deprecated, use `save` instead"); 365 | }, 366 | 367 | // Destroys a database with 'DELETE' 368 | // we raise an exception if arguments were supplied, 369 | // as we don't want users to confuse this function with `remove`. 370 | destroy: function (callback) { 371 | if (arguments.length > 1) { 372 | throw new(Error)("destroy() doesn't take any additional arguments"); 373 | } else { 374 | this.query('DELETE', '/', callback); 375 | } 376 | }, 377 | 378 | // Delete a document 379 | // if the _rev wasn't supplied, we attempt to retrieve it from the 380 | // cache. If the deletion was successful, we purge the cache. 381 | remove: function (id, rev) { 382 | var that = this, doc, args = new(Args)(arguments); 383 | 384 | if (typeof(rev) !== 'string') { 385 | if (doc = this.cache.get(id)) { rev = doc._rev } 386 | else { throw new(Error)("rev needs to be supplied") } 387 | } 388 | this.query('DELETE', id, {rev: rev}, function (err, res) { 389 | if (! err) { that.cache.purge(id) } 390 | args.callback(err, res); 391 | }); 392 | }, 393 | create: function (callback) { 394 | this.query('PUT', '/', callback); 395 | }, 396 | info: function (callback) { 397 | this.query('GET', '/', callback); 398 | }, 399 | all: function (options, callback) { 400 | if (arguments.length === 1) { callback = options, options = {} } 401 | this.query('GET', '/_all_docs', options, callback); 402 | }, 403 | compact: function (design) { 404 | this.query('POST', '/_compact' + (typeof(design) === 'string' ? '/' + design : ''), 405 | Args.last(arguments)); 406 | }, 407 | viewCleanup: function (callback) { 408 | this.query('POST', '/_view_cleanup', callback); 409 | }, 410 | allBySeq: function (options) { 411 | options = typeof(options) === 'object' ? options : {}; 412 | this.query('GET', '/_all_docs_by_seq', options, Args.last(arguments)); 413 | }, 414 | 415 | // Query a view, passing any options to the query string. 416 | // Some query string parameters' values have to be JSON-encoded. 417 | view: function (path, options) { 418 | var args = new(Args)(arguments); 419 | path = path.split('/'); 420 | 421 | if (typeof(options) === 'object') { 422 | ['key', 'startkey', 'endkey'].forEach(function (k) { 423 | if (k in options) { options[k] = JSON.stringify(options[k]) } 424 | }); 425 | } 426 | this.query('GET', ['_design', path[0], '_view', path[1]].join('/'), options, args.callback); 427 | }, 428 | 429 | push: function (doc) {}, 430 | 431 | changes: function (options, callback) { 432 | var promise = new(events.EventEmitter); 433 | 434 | if (typeof(options) === 'function') { callback = options, options = {}; } 435 | 436 | if (callback) { 437 | this.query('GET', '_changes', options, callback); 438 | } else { 439 | options = options || {}; 440 | options.feed = options.feed || 'continuous'; 441 | options.heartbeat = options.heartbeat || 1000; 442 | 443 | that.rawRequest('GET', [name, '_changes'].join('/'), options).on('response', function (res) { 444 | var response = new(events.EventEmitter); 445 | res.setEncoding('utf8'); 446 | 447 | response.statusCode = res.statusCode; 448 | response.headers = res.headers; 449 | 450 | promise.emit('response', response); 451 | 452 | res.on('data', function (data) { data.trim() && response.emit('data', JSON.parse(data)) }) 453 | .on('end', function () { response.emit('end') }); 454 | }); 455 | return promise; 456 | } 457 | }, 458 | 459 | saveAttachment: function (docOrId, attachmentName, contentType, dataOrStream) { 460 | var rev, id, doc, pathname, headers = {}, response, body = '', resHeaders, error, db = this; 461 | var args = new(Args)(arguments); 462 | 463 | if (typeof(docOrId) === 'string') { 464 | id = docOrId; 465 | doc = db.cache.get(id); 466 | if (doc) { rev = {rev: doc._rev}; } 467 | } else { 468 | id = docOrId._id; 469 | if (docOrId._rev) { 470 | rev = { rev: docOrId._rev }; 471 | } else { rev = {} } 472 | } 473 | 474 | pathname = '/' + [name, id, attachmentName].join('/'); 475 | headers['Content-Type'] = contentType; 476 | 477 | that.rawRequest('PUT', pathname, rev, dataOrStream, headers) 478 | .on('response', function (res) { resHeaders = { status: res.statusCode } }) 479 | .on('data', function (chunk) { body += chunk }) 480 | .on('end', function () { 481 | response = JSON.parse(body); 482 | response.headers = resHeaders; 483 | 484 | if (response.headers.status == 201 && db.cache.has(id)) { 485 | cached = db.cache.store[id].document; 486 | cached._rev = response.rev; 487 | cached._attachments = cached._attachments || {}; 488 | cached._attachments[attachmentName] = { 489 | content_type: contentType, 490 | stub: true, 491 | revpos: parseInt(response.rev.match(/^\d+/)[0]) 492 | }; 493 | } 494 | args.callback(null, response); 495 | }); 496 | }, 497 | 498 | getAttachment: function(docId, attachmentName) { 499 | var pathname, req; 500 | pathname = '/' + [name, docId, attachmentName].join('/'); 501 | return that.rawRequest('GET', pathname); 502 | } 503 | } 504 | 505 | }; 506 | 507 | // 508 | // Wrapper functions for the server API 509 | // 510 | cradle.Connection.prototype.databases = function (c) { 511 | this.request('GET', '/_all_dbs', c); 512 | }; 513 | cradle.Connection.prototype.config = function (c) { 514 | this.request('GET', '/_config', c); 515 | }; 516 | cradle.Connection.prototype.info = function (c) { 517 | this.request('GET', '/', c); 518 | }; 519 | cradle.Connection.prototype.stats = function (c) { 520 | this.request('GET', '/_stats', c); 521 | }; 522 | cradle.Connection.prototype.activeTasks = function (c) { 523 | this.request('GET', '/_active_tasks', c); 524 | }; 525 | cradle.Connection.prototype.uuids = function (count, callback) { 526 | if (typeof(count) === 'function') { callback = count, count = null } 527 | this.request('GET', '/_uuids', count ? {count: count} : {}, callback); 528 | }; 529 | 530 | cradle.merge = function (target) { 531 | var objs = Array.prototype.slice.call(arguments, 1); 532 | objs.forEach(function(o) { 533 | Object.keys(o).forEach(function (attr) { 534 | if (! o.__lookupGetter__(attr)) { 535 | target[attr] = o[attr]; 536 | } 537 | }); 538 | }); 539 | return target; 540 | } 541 | -------------------------------------------------------------------------------- /test/cradle-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | sys = require('sys'), 3 | assert = require('assert'), 4 | events = require('events'), 5 | http = require('http'), 6 | fs = require('fs'); 7 | 8 | require('./scripts/prepare-db'); 9 | 10 | require.paths.unshift(path.join(__dirname, '..', 'lib')); 11 | 12 | function status(code) { 13 | return function (e, res) { 14 | assert.ok(res || e); 15 | assert.equal((res || e).headers.status, code); 16 | }; 17 | } 18 | 19 | function mixin(target) { 20 | var objs = Array.prototype.slice.call(arguments, 1); 21 | objs.forEach(function (o) { 22 | for (var attr in o) { target[attr] = o[attr] } 23 | }); 24 | return target; 25 | } 26 | 27 | var cradle = require('cradle'); 28 | var vows = require('vows'); 29 | 30 | vows.describe("Cradle").addBatch({ 31 | "Default connection settings": { 32 | topic: function () { 33 | cradle.setup({ 34 | host: "http://cloudhead.io", 35 | port: 4242, 36 | milk: 'white' 37 | }); 38 | return new(cradle.Connection); 39 | }, 40 | "should be carried on to new Connections": function (c) { 41 | assert.equal(c.host, "http://cloudhead.io"); 42 | assert.equal(c.port, 4242); 43 | assert.equal(c.options.milk, 'white'); 44 | assert.equal(c.options.cache, true); 45 | }, 46 | "with just a {} passed to a new Connection object": { 47 | topic: function () { return new(cradle.Connection)({milk: 'green'}) }, 48 | "should override the defaults": function (c) { 49 | assert.equal(c.options.milk, 'green'); 50 | assert.equal(c.port, 4242); 51 | } 52 | }, 53 | "with a host and port passed to Connection": { 54 | topic: function () { return new(cradle.Connection)("255.255.0.0", 9696) }, 55 | "should override the defaults": function (c) { 56 | assert.equal(c.host, '255.255.0.0'); 57 | assert.equal(c.port, 9696); 58 | } 59 | }, 60 | "with a host and port passed as a string to Connection": { 61 | topic: function () { return new(cradle.Connection)("8.8.8.8:4141") }, 62 | "should override the defaults": function (c) { 63 | assert.equal(c.host, '8.8.8.8'); 64 | assert.equal(c.port, 4141); 65 | } 66 | }, 67 | "with a host, port and options passed to Connection": { 68 | topic: function () { return new(cradle.Connection)("4.4.4.4", 911, {raw: true}) }, 69 | "should override the defaults": function (c) { 70 | assert.equal(c.host, '4.4.4.4'); 71 | assert.equal(c.port, 911); 72 | assert.equal(c.options.raw, true); 73 | } 74 | }, 75 | "with a host and port and protocol passed to Connection": { 76 | topic: function () { return new(cradle.Connection)("http://4.4.4.4", 911, {raw: true}) }, 77 | "should override the defaults": function (c) { 78 | assert.equal(c.host, '4.4.4.4'); 79 | assert.equal(c.port, 911); 80 | assert.equal(c.options.raw, true); 81 | } 82 | } 83 | }, 84 | 85 | // 86 | // Cache 87 | // 88 | "A Cradle connection (cache)": { 89 | topic: function () { 90 | return new(cradle.Connection)('127.0.0.1', 5984, {cache: true}).database('pigs'); 91 | }, 92 | "save()": { 93 | topic: function (db) { 94 | var promise = new(events.EventEmitter); 95 | db.save('bob', {ears: true}, function (e, res) { 96 | promise.emit("success", db); 97 | }); 98 | return promise; 99 | }, 100 | "should write through the cache": function (db) { 101 | assert.ok(db.cache.has('bob')); 102 | assert.ok(db.cache.get('bob')._rev); 103 | }, 104 | "and": { 105 | topic: function (db) { 106 | var promise = new(events.EventEmitter); 107 | db.save('bob', {size: 12}, function (e, res) { 108 | promise.emit('success', res, db.cache.get('bob')); 109 | }); 110 | return promise; 111 | }, 112 | "return a 201": status(201), 113 | "allow an overwrite": function (res) { 114 | assert.match(res.rev, /^2/); 115 | }, 116 | "caches the updated document": function (e, res, doc) { 117 | assert.ok(doc); 118 | assert.equal(doc.size, 12); 119 | assert.isUndefined(doc.ears); 120 | } 121 | } 122 | }, 123 | "merge()": { 124 | topic: function (db) { 125 | var promise = new(events.EventEmitter); 126 | db.save('billy', {ears: true}, function (e, res) { 127 | promise.emit("success", db); 128 | }); 129 | return promise; 130 | }, 131 | "should write through the cache": function (db) { 132 | assert.ok(db.cache.has('bob')); 133 | assert.ok(db.cache.get('bob')._rev); 134 | }, 135 | "and": { 136 | topic: function (db) { 137 | var promise = new(events.EventEmitter); 138 | db.merge('billy', {size: 12}, function (e, res) { 139 | promise.emit('success', res, db.cache.get('billy')); 140 | }); 141 | return promise; 142 | }, 143 | "return a 201": status(201), 144 | "allow an overwrite": function (res) { 145 | assert.match(res.rev, /^2/); 146 | }, 147 | "caches the updated document": function (e, res, doc) { 148 | assert.ok(doc); 149 | assert.equal(doc.size, 12); 150 | assert.equal(doc.ears, true); 151 | } 152 | } 153 | }, 154 | "remove()": { 155 | topic: function (db) { 156 | var promise = new(events.EventEmitter); 157 | db.save('bruno', {}, function (e, res) { 158 | promise.emit("success", db); 159 | }); 160 | return promise; 161 | }, 162 | "shouldn't ask for a revision": { 163 | topic: function (db) { 164 | var promise = new(events.EventEmitter); 165 | db.remove('bruno', function () { promise.emit('success', db) }); 166 | return promise; 167 | }, 168 | "and should purge the cache": function (db) { 169 | assert.equal(db.cache.has('bruno'), false); 170 | }, 171 | "and raise an exception if you use remove() without a rev": function (db) { 172 | //assert.throws(db.remove('bruno'), Error); 173 | } 174 | } 175 | }, 176 | "saveAttachment()": { 177 | "updates the cache": { 178 | topic: function (db) { 179 | var that = this; 180 | db.save({_id:'attachment-cacher'}, function (e, res) { 181 | db.saveAttachment({ _id: res.id, _rev: res.rev }, 'foo.txt', 'text/plain', 'Foo!', function (attRes) { 182 | that.callback(null, db.cache.get(res.id)); 183 | }); 184 | }); 185 | }, 186 | "with the revision": function (cached) { 187 | assert.match(cached._rev, /^2-/); 188 | }, 189 | "with the _attachments": function (cached) { 190 | assert.ok(cached._attachments); 191 | assert.ok(cached._attachments['foo.txt']); 192 | assert.equal(cached._attachments['foo.txt'].stub, true); 193 | assert.equal(cached._attachments['foo.txt'].content_type, 'text/plain'); 194 | assert.equal(cached._attachments['foo.txt'].revpos, 2); 195 | }, 196 | "and is valid enough to re-save": { 197 | topic: function (cached, db) { 198 | var that = this 199 | db.save(mixin({foo:'bar'}, cached), function (e,res) { 200 | db.cache.purge(cached._id); 201 | db.get(cached._id, that.callback); 202 | }); 203 | }, 204 | "has the attachment": function (res) { 205 | var att = res._attachments['foo.txt']; 206 | assert.equal(att.stub, true); 207 | assert.equal(att.content_type, 'text/plain'); 208 | assert.equal(att.length, 4); 209 | assert.equal(att.revpos, 2); 210 | }, 211 | "and actually updated the rev": function (res) { 212 | assert.match(res._rev, /^3-/); 213 | } 214 | } 215 | }, 216 | "pulls the revision from the cache if not given": { 217 | topic: function (db) { 218 | var callback = this.callback; 219 | db.save({_id:'attachment-saving-pulls-rev-from-cache'}, function (e, res) { 220 | db.saveAttachment(res.id, 'foo.txt', 'text/plain', 'Foo!', callback); 221 | }); 222 | }, 223 | "and saves successfully": status(201) 224 | } 225 | } 226 | }, 227 | "Connection": { 228 | topic: function () { 229 | return new(cradle.Connection)('127.0.0.1', 5984, {cache: false}); 230 | }, 231 | "getting server info": { 232 | topic: function (c) { c.info(this.callback) }, 233 | 234 | "returns a 200": status(200), 235 | "returns the version number": function (info) { 236 | assert.ok(info); 237 | assert.match(info.version, /\d+\.\d+\.\d+/); 238 | } 239 | }, 240 | "uuids()": { 241 | "with count": { 242 | topic: function (c) { c.uuids(42, this.callback) }, 243 | 244 | "returns a 200": status(200), 245 | "returns an array of UUIDs": function (uuids) { 246 | assert.isArray(uuids); 247 | assert.length(uuids, 42); 248 | } 249 | }, 250 | "without count": { 251 | topic: function (c) { c.uuids(this.callback) }, 252 | 253 | "returns a 200": status(200), 254 | "returns an array of UUIDs": function (uuids) { 255 | assert.isArray(uuids); 256 | assert.length(uuids, 1); 257 | } 258 | } 259 | }, 260 | "getting the list of databases": { 261 | topic: function (c) { 262 | c.databases(this.callback); 263 | }, 264 | "should contain the 'rabbits' and 'pigs' databases": function (dbs) { 265 | assert.isArray(dbs); 266 | assert.include(dbs, 'rabbits'); 267 | assert.include(dbs, 'pigs'); 268 | } 269 | }, 270 | "create()": { 271 | topic: function (c) { 272 | c.database('badgers').create(this.callback); 273 | }, 274 | "returns a 201": status(201), 275 | "creates a database": { 276 | topic: function (res, c) { c.database('badgers').exists(this.callback) }, 277 | "it exists": function (res) { assert.ok(res) } 278 | } 279 | }, 280 | "destroy()": { 281 | topic: function (c) { 282 | c.database('rabbits').destroy(this.callback); 283 | }, 284 | "returns a 200": status(200), 285 | "destroys a database": { 286 | topic: function (res, c) { 287 | c.database('rabbits').exists(this.callback); 288 | }, 289 | "it doesn't exist anymore": function (res) { assert.ok(! res) } 290 | } 291 | }, 292 | "database()": { 293 | topic: function (c) { return c.database('pigs') }, 294 | 295 | "info()": { 296 | topic: function (db) { 297 | db.info(this.callback); 298 | }, 299 | "returns a 200": status(200), 300 | "returns database info": function (info) { 301 | assert.equal(info['db_name'], 'pigs'); 302 | } 303 | }, 304 | "fetching a document by id (GET)": { 305 | topic: function (db) { db.get('mike', this.callback) }, 306 | "returns a 200": status(200), 307 | "returns the document": function (res) { 308 | assert.equal(res.id, 'mike'); 309 | }, 310 | "when not found": { 311 | topic: function (_, db) { db.get('tyler', this.callback) }, 312 | "returns a 404": status(404), 313 | "returns the error": function (err, res) { 314 | assert.isObject(err); 315 | assert.isObject(err.headers); 316 | assert.isUndefined(res); 317 | }, 318 | } 319 | }, 320 | "head()": { 321 | topic: function (db) { db.head('mike', this.callback) }, 322 | "returns the headers": function (res) { 323 | assert.match(res.etag, /^"\d-[a-z0-9]+"$/); 324 | } 325 | }, 326 | "save()": { 327 | "with an id & doc": { 328 | topic: function (db) { 329 | db.save('joe', {gender: 'male'}, this.callback); 330 | }, 331 | "creates a new document (201)": status(201), 332 | "returns the revision": function (res) { 333 | assert.ok(res.rev); 334 | } 335 | }, 336 | "with a doc containing non-ASCII characters": { 337 | topic: function(db) { 338 | db.save('john', {umlauts: 'äöü'}, this.callback); 339 | }, 340 | "creates a new document (201)": status(201) 341 | }, 342 | "with a large doc": { 343 | topic: function (db) { 344 | var text = (function (s) { 345 | for (var i = 0; i < 18; i++) { s += s } 346 | return s; 347 | })('blah'); 348 | 349 | db.save('large-bob', { 350 | gender: 'male', 351 | speech: text 352 | }, this.callback); 353 | }, 354 | "creates a new document (201)": status(201) 355 | }, 356 | "with a '_design' id": { 357 | topic: function (db) { 358 | db.save('_design/horses', { 359 | all: { 360 | map: function (doc) { 361 | if (doc.speed == 72) emit(null, doc); 362 | } 363 | } 364 | }, this.callback); 365 | }, 366 | "creates a doc (201)": status(201), 367 | "returns the revision": function (res) { 368 | assert.ok(res.rev); 369 | }, 370 | "creates a design doc": { 371 | topic: function (res, db) { 372 | db.view('horses/all', this.callback); 373 | }, 374 | "which can be queried": status(200) 375 | } 376 | }, 377 | "without an id (POST)": {}, 378 | }, 379 | "calling save() with an array": { 380 | topic: function (db) { 381 | db.save([{_id: 'tom'}, {_id: 'flint'}], this.callback); 382 | }, 383 | "returns an array of document ids and revs": function (res) { 384 | assert.equal(res[0].id, 'tom'); 385 | assert.equal(res[1].id, 'flint'); 386 | }, 387 | "should bulk insert the documents": { 388 | topic: function (res, db) { 389 | var promise = new(events.EventEmitter); 390 | db.get('tom', function (e, tom) { 391 | db.get('flint', function (e, flint) { 392 | promise.emit('success', tom, flint); 393 | }); 394 | }); 395 | return promise; 396 | }, 397 | "which can then be retrieved": function (e, tom, flint) { 398 | assert.ok(tom._id); 399 | assert.ok(flint._id); 400 | } 401 | } 402 | }, 403 | "getting all documents": { 404 | topic: function (db) { 405 | db.all(this.callback); 406 | }, 407 | "returns a 200": status(200), 408 | "returns a list of all docs": function (res) { 409 | assert.isArray(res); 410 | assert.isNumber(res.total_rows); 411 | assert.isNumber(res.offset); 412 | assert.isArray(res.rows); 413 | }, 414 | "which can be iterated upon": function (res) { 415 | assert.isFunction(res.forEach); 416 | } 417 | }, 418 | "updating a document (PUT)": { 419 | topic: function (db) { 420 | var promise = new(events.EventEmitter); 421 | db.get('mike', function (err, doc) { 422 | db.save('mike', doc.rev, 423 | {color: doc.color, age: 13}, function (err, res) { 424 | if (! err) promise.emit('success', res, db); 425 | else promise.emit('error', res); 426 | }); 427 | }); 428 | return promise; 429 | }, 430 | "returns a 201": status(201), 431 | "returns the revision": function (res) { 432 | assert.ok(res.rev); 433 | assert.match(res.rev, /^2/); 434 | }, 435 | }, 436 | "deleting a document (DELETE)": { 437 | topic: function (db) { 438 | var promise = new(events.EventEmitter); 439 | db.get('bill', function (e, res) { 440 | db.remove('bill', res.rev, function (e, res) { 441 | promise.emit('success', res); 442 | }); 443 | }); 444 | return promise; 445 | }, 446 | "returns a 200": status(200) 447 | }, 448 | "querying a view": { 449 | topic: function (db) { 450 | db.view('pigs/all', this.callback); 451 | }, 452 | "returns a 200": status(200), 453 | "returns view results": function (res) { 454 | assert.isArray(res.rows); 455 | assert.equal(res.rows.length, 2); 456 | assert.equal(res.total_rows, 2); 457 | }, 458 | "returns an iterable object with key/val pairs": function (res) { 459 | assert.isArray(res); 460 | assert.length(res, 2); 461 | res.forEach(function (k, v) { 462 | assert.isObject(v); 463 | assert.isString(k); 464 | assert.ok(k === 'mike' || k === 'bill'); 465 | }); 466 | }, 467 | "with options": { 468 | 469 | }, 470 | "with a start & end key": { 471 | 472 | } 473 | }, 474 | "putting an attachment": { 475 | "to an existing document": { 476 | "with given data": { 477 | topic: function (db) { 478 | var callback = this.callback; 479 | db.save({_id: 'complete-attachment'}, function (e, res) { 480 | db.saveAttachment({_id: res.id, _rev: res.rev}, 'foo.txt', 'text/plain', 'Foo!', callback); 481 | }); 482 | }, 483 | "returns a 201": status(201), 484 | "returns the revision": function (res) { 485 | assert.ok(res.rev); 486 | assert.match(res.rev, /^2/); 487 | }, 488 | }, 489 | "with streaming data": { 490 | topic: function (db) { 491 | var callback = this.callback, filestream; 492 | db.save({'_id':'streaming-attachment'}, function (e, res) { 493 | filestream = fs.createReadStream(__dirname + "/../README.md"); 494 | db.saveAttachment({_id: res.id, _rev: res.rev}, 'foo.txt', 'text/plain', filestream, callback); 495 | }) 496 | }, 497 | "returns a 201": status(201), 498 | "returns the revision": function (res) { 499 | assert.ok(res.rev); 500 | assert.match(res.rev, /^2/); 501 | } 502 | }, 503 | "with incorrect revision": { 504 | topic: function (db) { 505 | var callback = this.callback, oldRev; 506 | db.save({_id: 'attachment-incorrect-revision'}, function (e, res) { 507 | oldRev = res.rev; 508 | db.save({_id: 'attachment-incorrect-revision', _rev:res.rev}, function (e, res) { 509 | db.saveAttachment({_id: res.id, _rev: oldRev}, 'foo.txt', 'text/plain', 'Foo!', callback); 510 | }); 511 | }); 512 | }, 513 | "returns a 409": status(409) 514 | } 515 | }, 516 | "to a non-existing document": { 517 | topic: function (db) { 518 | db.saveAttachment('standalone-attachment', 'foo.txt', 'text/plain', 'Foo!', this.callback); 519 | }, 520 | "returns a 201": status(201), 521 | "returns the revision": function (res) { 522 | assert.ok(res.rev); 523 | assert.match(res.rev, /^1-/); 524 | } 525 | } 526 | }, 527 | "getting an attachment": { 528 | "when it exists": { 529 | topic: function (db) { 530 | var promise = new(events.EventEmitter), response = {}; 531 | doc = {_id:'attachment-getter', _attachments:{ "foo.txt":{content_type:"text/plain", data:"aGVsbG8gd29ybGQ="} }}; 532 | db.save(doc, function (e, res) { 533 | var streamer = db.getAttachment('attachment-getter','foo.txt'); 534 | streamer.addListener('response', function (res) { 535 | response.headers = res.headers; 536 | response.headers.status = res.statusCode; 537 | response.body = ""; 538 | }); 539 | streamer.addListener('data', function (chunk) { response.body += chunk; }); 540 | streamer.addListener('end', function () { promise.emit('success', response); }); 541 | }); 542 | return promise; 543 | }, 544 | "returns a 200": status(200), 545 | "returns the right mime-type in the header": function (res) { 546 | assert.equal(res.headers['content-type'], 'text/plain'); 547 | }, 548 | "returns the attachment in the body": function (res) { 549 | assert.equal(res.body, "hello world"); 550 | } 551 | }, 552 | "when not found": { 553 | topic: function (db) { 554 | var promise = new(events.EventEmitter), response = {}; 555 | db.save({_id:'attachment-not-found'}, function (e, res) { 556 | var streamer = db.getAttachment('attachment-not-found','foo.txt'); 557 | streamer.addListener('response', function (res) { 558 | response.headers = res.headers; 559 | response.headers.status = res.statusCode; 560 | promise.emit('success', response); 561 | }); 562 | }); 563 | return promise; 564 | }, 565 | "returns a 404": status(404) 566 | } 567 | } 568 | } 569 | } 570 | }).export(module); 571 | --------------------------------------------------------------------------------